Initial commit: Hermes Chat App with Svelte 5 + shadcn-svelte
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm hermes-chat-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
17
components.json
Normal file
17
components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils.js",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry",
|
||||
"style": "default"
|
||||
}
|
||||
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "hermes-chat-app",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"bits-ui": "^2.18.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"marked": "^18.0.4",
|
||||
"svelte-tooltip": "^1.2.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
}
|
||||
}
|
||||
1282
pnpm-lock.yaml
generated
Normal file
1282
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
67
src/app.css
Normal file
67
src/app.css
Normal file
@@ -0,0 +1,67 @@
|
||||
@import "tailwindcss";
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(240 10% 3.9%);
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||
--color-primary: hsl(240 5.9% 10%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||
--color-muted: hsl(240 4.8% 95.9%);
|
||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--color-accent: hsl(240 4.8% 95.9%);
|
||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||
--color-destructive: hsl(0 72.22% 50.59%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--color-ring: hsl(240 5% 64.9%);
|
||||
--color-sidebar-background: hsl(0 0% 98%);
|
||||
--color-sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--color-sidebar-primary: hsl(240 5.9% 10%);
|
||||
--color-sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--color-sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--color-sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--color-sidebar-border: hsl(220 13% 91%);
|
||||
--color-sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--radius: 0.5rem;
|
||||
|
||||
--color-chat-user: oklch(0.62 0.18 255);
|
||||
--color-chat-assistant: oklch(0.55 0.15 280);
|
||||
--color-hermes-brand: oklch(0.58 0.22 290);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: hsl(240 10% 3.9%);
|
||||
--color-foreground: hsl(0 0% 98%);
|
||||
--color-card: hsl(240 10% 3.9%);
|
||||
--color-card-foreground: hsl(0 0% 98%);
|
||||
--color-popover: hsl(240 10% 3.9%);
|
||||
--color-popover-foreground: hsl(0 0% 98%);
|
||||
--color-primary: hsl(0 0% 98%);
|
||||
--color-primary-foreground: hsl(240 5.9% 10%);
|
||||
--color-secondary: hsl(240 3.7% 15.9%);
|
||||
--color-secondary-foreground: hsl(0 0% 98%);
|
||||
--color-muted: hsl(240 3.7% 15.9%);
|
||||
--color-muted-foreground: hsl(240 5% 64.9%);
|
||||
--color-accent: hsl(240 3.7% 15.9%);
|
||||
--color-accent-foreground: hsl(0 0% 98%);
|
||||
--color-destructive: hsl(0 62.8% 30.6%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
--color-border: hsl(240 3.7% 15.9%);
|
||||
--color-input: hsl(240 3.7% 15.9%);
|
||||
--color-ring: hsl(240 4.9% 83.9%);
|
||||
--color-sidebar-background: hsl(240 5.9% 10%);
|
||||
--color-sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--color-sidebar-primary: hsl(0 0% 98%);
|
||||
--color-sidebar-primary-foreground: hsl(240 5.9% 10%);
|
||||
--color-sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--color-sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--color-sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--color-sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
src/app.html
Normal file
12
src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
82
src/lib/components/chat/ChatInput.svelte
Normal file
82
src/lib/components/chat/ChatInput.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { isStreaming } from '$lib/stores/chat';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let inputValue = $state('');
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state(undefined);
|
||||
|
||||
function handleSubmit() {
|
||||
if (!inputValue.trim() || get(isStreaming)) return;
|
||||
onsubmit?.(inputValue.trim());
|
||||
inputValue = '';
|
||||
if (textareaEl) {
|
||||
textareaEl.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
if (textareaEl) {
|
||||
textareaEl.style.height = 'auto';
|
||||
textareaEl.style.height = Math.min(textareaEl.scrollHeight, 200) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
let { onsubmit }: { onsubmit?: (text: string) => void } = $props();
|
||||
let isBusy = $state(false);
|
||||
isStreaming.subscribe((v) => (isBusy = v));
|
||||
</script>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}
|
||||
class="flex items-end gap-2 border-t bg-background px-4 py-3 md:px-6"
|
||||
>
|
||||
<div class="relative flex-1">
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
oninput={autoResize}
|
||||
placeholder="Message Hermes..."
|
||||
rows="1"
|
||||
disabled={isBusy}
|
||||
class="w-full resize-none rounded-xl border border-input bg-background px-4 py-2.5 pr-12 text-sm
|
||||
placeholder:text-muted-foreground
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
min-h-[44px] max-h-[200px] leading-relaxed"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputValue.trim() || isBusy}
|
||||
class="absolute right-1.5 bottom-1.5 inline-flex items-center justify-center rounded-lg
|
||||
h-8 w-8 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13" />
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isBusy}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg h-10 w-10 border border-border hover:bg-accent transition-colors shrink-0"
|
||||
title="Stop generating"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
148
src/lib/components/chat/ChatMessage.svelte
Normal file
148
src/lib/components/chat/ChatMessage.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { marked } from 'marked';
|
||||
|
||||
let {
|
||||
content = '',
|
||||
role = 'assistant' as 'user' | 'assistant',
|
||||
status = 'complete' as 'sending' | 'streaming' | 'complete' | 'error'
|
||||
}: {
|
||||
content?: string;
|
||||
role?: 'user' | 'assistant';
|
||||
status?: 'sending' | 'streaming' | 'complete' | 'error';
|
||||
} = $props();
|
||||
|
||||
let renderedHtml = $derived.by(() => {
|
||||
if (!content) return '';
|
||||
try {
|
||||
return marked.parse(content, { async: false }) as string;
|
||||
} catch {
|
||||
return `<p>${content}</p>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={cn(
|
||||
'flex gap-3 px-4 py-3',
|
||||
role === 'user' ? 'justify-end' : 'justify-start'
|
||||
)}>
|
||||
<!-- Assistant avatar -->
|
||||
{#if role === 'assistant'}
|
||||
<div class="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">
|
||||
H
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Message bubble -->
|
||||
<div dir="auto" class={cn(
|
||||
'max-w-[80%] md:max-w-[70%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed',
|
||||
role === 'user'
|
||||
? 'bg-primary text-primary-foreground rounded-br-md'
|
||||
: 'bg-muted/50 text-foreground rounded-bl-md border border-border/50'
|
||||
)}>
|
||||
{#if !content && status === 'streaming'}
|
||||
<div class="flex gap-1 py-2">
|
||||
<span class="h-2 w-2 rounded-full bg-foreground/40 animate-bounce" style="animation-delay: 0ms" />
|
||||
<span class="h-2 w-2 rounded-full bg-foreground/40 animate-bounce" style="animation-delay: 150ms" />
|
||||
<span class="h-2 w-2 rounded-full bg-foreground/40 animate-bounce" style="animation-delay: 300ms" />
|
||||
</div>
|
||||
{:else if content}
|
||||
<!-- Markdown rendered -->
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
|
||||
<!-- Status indicator -->
|
||||
{#if status === 'streaming'}
|
||||
<span class="inline-block h-3 w-1.5 bg-foreground/50 animate-pulse ml-0.5" />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if status === 'error'}
|
||||
<div class="mt-1 text-xs text-destructive">Error generating response</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User avatar -->
|
||||
{#if role === 'user'}
|
||||
<div class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0 mt-0.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.prose pre) {
|
||||
background-color: hsl(var(--color-muted)/0.5);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
:global(.prose code) {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: hsl(var(--color-muted)/0.5);
|
||||
}
|
||||
:global(.prose pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
:global(.prose img) {
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
:global(.prose table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.8rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
:global(.prose th),
|
||||
:global(.prose td) {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
:global(.prose th) {
|
||||
background-color: hsl(var(--color-muted)/0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
:global(.prose blockquote) {
|
||||
border-left: 3px solid hsl(var(--color-border));
|
||||
padding-left: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
:global(.prose ul),
|
||||
:global(.prose ol) {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
:global(.prose li) {
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
:global(.prose a) {
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
:global(.prose h1),
|
||||
:global(.prose h2),
|
||||
:global(.prose h3),
|
||||
:global(.prose h4) {
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
:global(.prose h1) { font-size: 1.25rem; }
|
||||
:global(.prose h2) { font-size: 1.1rem; }
|
||||
:global(.prose h3) { font-size: 1rem; }
|
||||
</style>
|
||||
208
src/lib/components/chat/SettingsDialog.svelte
Normal file
208
src/lib/components/chat/SettingsDialog.svelte
Normal file
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import { Select } from '$lib/components/ui/select/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { config, darkMode, availableModels } from '$lib/stores/chat';
|
||||
import { fetchModels } from '$lib/hermes';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let cfg = $state(get(config));
|
||||
let isDark = $state(get(darkMode));
|
||||
let models = $state<string[]>(get(availableModels));
|
||||
let customModel = $state('');
|
||||
|
||||
config.subscribe((v) => (cfg = v));
|
||||
darkMode.subscribe((v) => (isDark = v));
|
||||
availableModels.subscribe((v) => (models = v));
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const result = await fetchModels();
|
||||
availableModels.set(result);
|
||||
} catch {
|
||||
// defaults fine
|
||||
}
|
||||
});
|
||||
|
||||
function updateConfig(key: string, value: any) {
|
||||
config.update((c) => ({ ...c, [key]: value }));
|
||||
}
|
||||
|
||||
let activeTab = $state('general');
|
||||
|
||||
const providers = [
|
||||
'openrouter',
|
||||
'openai',
|
||||
'anthropic',
|
||||
'google',
|
||||
'deepseek',
|
||||
'mistral',
|
||||
'nous',
|
||||
'xai',
|
||||
'github'
|
||||
];
|
||||
</script>
|
||||
|
||||
<Dialog bind:open>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Configure your Hermes Chat experience</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-1 my-4">
|
||||
<button
|
||||
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{activeTab === 'general' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => { activeTab = 'general'; }}
|
||||
>General</button>
|
||||
<button
|
||||
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{activeTab === 'model' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => { activeTab = 'model'; }}
|
||||
>Model</button>
|
||||
<button
|
||||
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{activeTab === 'about' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => { activeTab = 'about'; }}
|
||||
>About</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'general'}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Dark Mode</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Toggle dark/light theme</p>
|
||||
</div>
|
||||
<Switch checked={isDark} onchange={() => darkMode.update((v) => !v)} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label for="model-select">Default Model</Label>
|
||||
<Select
|
||||
id="model-select"
|
||||
value={cfg.model || ''}
|
||||
onchange={(e) => updateConfig('model', (e.target as HTMLSelectElement).value)}
|
||||
class="mt-1.5"
|
||||
>
|
||||
<option value="">Auto (default)</option>
|
||||
{#each models as m}
|
||||
<option value={m} selected={m === cfg.model}>{m}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="custom-model">Custom Model (override)</Label>
|
||||
<Input
|
||||
id="custom-model"
|
||||
placeholder="e.g. anthropic/claude-sonnet-4"
|
||||
bind:value={customModel}
|
||||
oninput={() => {
|
||||
if (customModel) updateConfig('model', customModel);
|
||||
}}
|
||||
class="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label for="provider-select">Provider</Label>
|
||||
<Select
|
||||
id="provider-select"
|
||||
value={cfg.provider}
|
||||
onchange={(e) => updateConfig('provider', (e.target as HTMLSelectElement).value)}
|
||||
class="mt-1.5"
|
||||
>
|
||||
{#each providers as provider}
|
||||
<option value={provider} selected={provider === cfg.provider}>
|
||||
{provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||
</option>
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeTab === 'model'}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label for="temperature">Temperature: {cfg.temperature}</Label>
|
||||
<input
|
||||
id="temperature"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={cfg.temperature}
|
||||
oninput={(e) => updateConfig('temperature', parseFloat((e.target as HTMLInputElement).value))}
|
||||
class="w-full mt-1.5 accent-primary"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">Controls randomness in responses. Lower = more deterministic.</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label for="max-tokens">Max Tokens: {cfg.maxTokens}</Label>
|
||||
<input
|
||||
id="max-tokens"
|
||||
type="range"
|
||||
min="512"
|
||||
max="16384"
|
||||
step="512"
|
||||
value={cfg.maxTokens}
|
||||
oninput={(e) => updateConfig('maxTokens', parseInt((e.target as HTMLInputElement).value))}
|
||||
class="w-full mt-1.5 accent-primary"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">Maximum response length.</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label for="system-prompt">System Prompt</Label>
|
||||
<textarea
|
||||
id="system-prompt"
|
||||
value={cfg.systemPrompt}
|
||||
oninput={(e) => updateConfig('systemPrompt', (e.target as HTMLTextAreaElement).value)}
|
||||
class="w-full mt-1.5 rounded-lg border border-input bg-background px-3 py-2 text-sm min-h-[80px] resize-y
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeTab === 'about'}
|
||||
<div class="space-y-4 text-center py-6">
|
||||
<div class="h-16 w-16 rounded-2xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center text-white text-2xl font-bold mx-auto">
|
||||
H
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Hermes Chat</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">A modern chat interface for Hermes Agent</p>
|
||||
</div>
|
||||
<div class="flex justify-center gap-4 text-xs text-muted-foreground">
|
||||
<span>Svelte 5 + shadcn-svelte</span>
|
||||
<span>•</span>
|
||||
<span>Tailwind CSS v4</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground/60 mt-2">
|
||||
Built with ❤️ for Nous Research Hermes Agent
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => { open = false; }}>Close</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
102
src/lib/components/chat/Sidebar.svelte
Normal file
102
src/lib/components/chat/Sidebar.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { sessions, activeSessionId, createSession } from '$lib/stores/chat';
|
||||
import { isStreaming } from '$lib/stores/chat';
|
||||
import { get } from 'svelte/store';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { getHermesStatus } from '$lib/hermes';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { onClose }: { onClose?: () => void } = $props();
|
||||
|
||||
let hermesAvailable = $state(false);
|
||||
let hermesModel = $state('');
|
||||
let sessionList = $state<any[]>([]);
|
||||
let activeId = $state<string | null>(null);
|
||||
|
||||
sessions.subscribe((v) => (sessionList = v));
|
||||
activeSessionId.subscribe((v) => (activeId = v));
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const status = await getHermesStatus();
|
||||
hermesAvailable = status.available;
|
||||
hermesModel = status.model || status.provider || 'connected';
|
||||
} catch {
|
||||
hermesAvailable = false;
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(ts: number) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
if (diff < 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diff < 604800000) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-72 flex-col border-r bg-muted/30">
|
||||
<!-- New chat button -->
|
||||
<div class="p-3">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:opacity-50"
|
||||
onclick={() => { createSession(); onClose?.(); }}
|
||||
disabled={get(isStreaming)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Sessions list -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{#if sessionList.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center px-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground/40 mb-3">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm text-muted-foreground">No conversations yet</p>
|
||||
<p class="text-xs text-muted-foreground/60 mt-1">Start a new chat above</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each sessionList as session (session.id)}
|
||||
<button
|
||||
class="w-full text-left rounded-lg px-3 py-2.5 transition-colors group
|
||||
{session.id === activeId
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50 text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => {
|
||||
activeSessionId.set(session.id);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate flex-1">{session.title}</span>
|
||||
<span class="text-[10px] text-muted-foreground/60 whitespace-nowrap mt-0.5">
|
||||
{formatDate(session.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{#if session.preview}
|
||||
<p class="text-xs text-muted-foreground/70 mt-1 truncate">{session.preview}</p>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer status -->
|
||||
<div class="border-t p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full {hermesAvailable ? 'bg-green-500' : 'bg-red-500'}" />
|
||||
<span class="text-xs text-muted-foreground truncate">
|
||||
{hermesAvailable ? (hermesModel || 'Hermes Agent') : 'Hermes disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
18
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
18
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLSpanAttributes } from "svelte/elements";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let { class: className = "", delayMs = 0, children, ...rest }: HTMLSpanAttributes & { delayMs?: number } = $props();
|
||||
let visible = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const timer = setTimeout(() => { visible = true; }, delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
{#if visible}
|
||||
<span class={cn("flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
7
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
7
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLImgAttributes } from "svelte/elements";
|
||||
|
||||
let { class: className = "", ...rest }: HTMLImgAttributes = $props();
|
||||
</script>
|
||||
<img class={cn("aspect-square h-full w-full", className)} {...rest} />
|
||||
9
src/lib/components/ui/avatar/avatar.svelte
Normal file
9
src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
3
src/lib/components/ui/avatar/index.ts
Normal file
3
src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Avatar } from "./avatar.svelte";
|
||||
export { default as AvatarImage } from "./avatar-image.svelte";
|
||||
export { default as AvatarFallback } from "./avatar-fallback.svelte";
|
||||
32
src/lib/components/ui/badge/badge.svelte
Normal file
32
src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { HTMLSpanAttributes } from "svelte/elements";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
let {
|
||||
variant = "default" as NonNullable<VariantProps<typeof badgeVariants>["variant"]>,
|
||||
class: className = "",
|
||||
children,
|
||||
...rest
|
||||
}: HTMLSpanAttributes & {
|
||||
variant?: NonNullable<VariantProps<typeof badgeVariants>["variant"]>;
|
||||
} = $props();
|
||||
</script>
|
||||
<span class={cn(badgeVariants({ variant }), className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
1
src/lib/components/ui/badge/index.ts
Normal file
1
src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
43
src/lib/components/ui/button/button.svelte
Normal file
43
src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
let {
|
||||
variant = "default" as NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
|
||||
size = "default" as NonNullable<VariantProps<typeof buttonVariants>["size"]>,
|
||||
class: className = "",
|
||||
children,
|
||||
...rest
|
||||
}: HTMLButtonAttributes & {
|
||||
variant?: NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
|
||||
size?: NonNullable<VariantProps<typeof buttonVariants>["size"]>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button class={cn(buttonVariants({ variant, size }), className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
1
src/lib/components/ui/button/index.ts
Normal file
1
src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Button } from "./button.svelte";
|
||||
8
src/lib/components/ui/card/card-content.svelte
Normal file
8
src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("p-6 pt-0", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
8
src/lib/components/ui/card/card-description.svelte
Normal file
8
src/lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLParagraphElement> = $props();
|
||||
</script>
|
||||
<p class={cn("text-sm text-muted-foreground", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
8
src/lib/components/ui/card/card-footer.svelte
Normal file
8
src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("flex items-center p-6 pt-0", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
8
src/lib/components/ui/card/card-header.svelte
Normal file
8
src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("flex flex-col space-y-1.5 p-6", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
8
src/lib/components/ui/card/card-title.svelte
Normal file
8
src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLHeadingElement> = $props();
|
||||
</script>
|
||||
<h3 class={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</h3>
|
||||
9
src/lib/components/ui/card/card.svelte
Normal file
9
src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
6
src/lib/components/ui/card/index.ts
Normal file
6
src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as Card } from "./card.svelte";
|
||||
export { default as CardHeader } from "./card-header.svelte";
|
||||
export { default as CardTitle } from "./card-title.svelte";
|
||||
export { default as CardDescription } from "./card-description.svelte";
|
||||
export { default as CardContent } from "./card-content.svelte";
|
||||
export { default as CardFooter } from "./card-footer.svelte";
|
||||
8
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
8
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLParagraphElement> = $props();
|
||||
</script>
|
||||
<p class={cn("text-sm text-muted-foreground", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
8
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
8
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
8
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
8
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
8
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
8
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLHeadingElement> = $props();
|
||||
</script>
|
||||
<h2 class={cn("text-lg font-semibold leading-none tracking-tight", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</h2>
|
||||
18
src/lib/components/ui/dialog/dialog.svelte
Normal file
18
src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let { open = $bindable(false), class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> & { open?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50">
|
||||
<div class="fixed inset-0 bg-black/80" onclick={() => { open = false; }} />
|
||||
<div class={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className
|
||||
)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
5
src/lib/components/ui/dialog/index.ts
Normal file
5
src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Dialog } from "./dialog.svelte";
|
||||
export { default as DialogHeader } from "./dialog-header.svelte";
|
||||
export { default as DialogFooter } from "./dialog-footer.svelte";
|
||||
export { default as DialogTitle } from "./dialog-title.svelte";
|
||||
export { default as DialogDescription } from "./dialog-description.svelte";
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, onclick, ...rest }: HTMLButtonAttributes = $props();
|
||||
</script>
|
||||
<button
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"w-full",
|
||||
className
|
||||
)}
|
||||
{onclick}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
51
src/lib/components/ui/dropdown-menu/dropdown-menu.svelte
Normal file
51
src/lib/components/ui/dropdown-menu/dropdown-menu.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { tick } from "svelte";
|
||||
|
||||
let { class: className = "", align = "start" as "start" | "end", children }: {
|
||||
class?: string;
|
||||
align?: "start" | "end";
|
||||
children?: import("svelte").Snippet;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let menuEl: HTMLDivElement | undefined = $state(undefined);
|
||||
let triggerEl: HTMLButtonElement | undefined = $state(undefined);
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuEl && !menuEl.contains(e.target as Node) && triggerEl && !triggerEl.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet children(api: { trigger: import("svelte").Snippet; items: import("svelte").Snippet })}
|
||||
<div class="relative inline-block text-left">
|
||||
<button bind:this={triggerEl} onclick={toggle} class={cn("inline-flex items-center justify-center", className)}>
|
||||
{@render api.trigger()}
|
||||
</button>
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={menuEl}
|
||||
class={cn(
|
||||
"absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
align === "end" ? "right-0" : "left-0",
|
||||
"mt-2"
|
||||
)}
|
||||
>
|
||||
{@render api.items()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
2
src/lib/components/ui/dropdown-menu/index.ts
Normal file
2
src/lib/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as DropdownMenu } from "./dropdown-menu.svelte";
|
||||
export { default as DropdownMenuItem } from "./dropdown-menu-item.svelte";
|
||||
1
src/lib/components/ui/input/index.ts
Normal file
1
src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./input.svelte";
|
||||
13
src/lib/components/ui/input/input.svelte
Normal file
13
src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
let { class: className = "", ...rest }: HTMLInputAttributes = $props();
|
||||
</script>
|
||||
<input class={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)} {...rest} />
|
||||
1
src/lib/components/ui/label/index.ts
Normal file
1
src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from "./label.svelte";
|
||||
8
src/lib/components/ui/label/label.svelte
Normal file
8
src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLLabelAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLLabelAttributes = $props();
|
||||
</script>
|
||||
<label class={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</label>
|
||||
1
src/lib/components/ui/progress/index.ts
Normal file
1
src/lib/components/ui/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Progress } from "./progress.svelte";
|
||||
15
src/lib/components/ui/progress/progress.svelte
Normal file
15
src/lib/components/ui/progress/progress.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", value = 0, ...rest }: HTMLAttributes<HTMLDivElement> & { value?: number } = $props();
|
||||
</script>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={value}
|
||||
class={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
{...rest}
|
||||
>
|
||||
<div class="h-full w-full flex-1 bg-primary transition-all" style="transform: translateX(-{100 - value}%)" />
|
||||
</div>
|
||||
1
src/lib/components/ui/scroll-area/index.ts
Normal file
1
src/lib/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ScrollArea } from "./scroll-area.svelte";
|
||||
9
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
9
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("overflow-auto", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
1
src/lib/components/ui/select/index.ts
Normal file
1
src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Select } from "./select.svelte";
|
||||
13
src/lib/components/ui/select/select.svelte
Normal file
13
src/lib/components/ui/select/select.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLSelectAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLSelectAttributes = $props();
|
||||
</script>
|
||||
<select class={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)} {...rest}>
|
||||
{@render children?.()}
|
||||
</select>
|
||||
1
src/lib/components/ui/separator/index.ts
Normal file
1
src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from "./separator.svelte";
|
||||
16
src/lib/components/ui/separator/separator.svelte
Normal file
16
src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let { class: className = "", orientation = "horizontal" as "horizontal" | "vertical", ...rest }: HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" } = $props();
|
||||
</script>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
class={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
1
src/lib/components/ui/sheet/index.ts
Normal file
1
src/lib/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Sheet } from "./sheet.svelte";
|
||||
19
src/lib/components/ui/sheet/sheet.svelte
Normal file
19
src/lib/components/ui/sheet/sheet.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let { open = $bindable(false), side = "left" as "left" | "right", class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> & { open?: boolean; side?: "left" | "right" } = $props();
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50">
|
||||
<div class="fixed inset-0 bg-black/80" onclick={() => { open = false; }} />
|
||||
<div class={cn(
|
||||
"fixed top-0 z-50 h-full w-72 gap-4 border bg-background p-6 shadow-lg",
|
||||
side === "left" ? "left-0" : "right-0",
|
||||
className
|
||||
)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
34
src/lib/components/ui/sonner/ToastContainer.svelte
Normal file
34
src/lib/components/ui/sonner/ToastContainer.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
|
||||
let toastList = $state<any[]>([]);
|
||||
toasts.subscribe((v) => (toastList = v));
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
{#each toastList as t (t.id)}
|
||||
<div
|
||||
class={cn(
|
||||
'pointer-events-auto flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg',
|
||||
'animate-in slide-in-from-right duration-200',
|
||||
t.variant === 'destructive'
|
||||
? 'border-destructive bg-destructive text-destructive-foreground'
|
||||
: 'border-border bg-background text-foreground'
|
||||
)}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold">{t.title}</p>
|
||||
{#if t.description}
|
||||
<p class="text-xs opacity-90">{t.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => toasts.update((ts) => ts.filter((x) => x.id !== t.id))}
|
||||
class="text-xs opacity-70 hover:opacity-100 shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
44
src/lib/components/ui/sonner/index.svelte
Normal file
44
src/lib/components/ui/sonner/index.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const toasts = writable<{ id: string; title: string; description?: string; variant?: "default" | "destructive" }[]>([]);
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function toast(title: string, opts?: { description?: string; variant?: "default" | "destructive" }) {
|
||||
const id = `toast-${counter++}`;
|
||||
toasts.update(t => [...t, { id, title, ...opts }]);
|
||||
setTimeout(() => {
|
||||
toasts.update(t => t.filter(toast => toast.id !== id));
|
||||
}, 4000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" context="module">
|
||||
export function toast(title: string, opts?: { description?: string; variant?: "default" | "destructive" }) {
|
||||
const { toasts } = await import("./index.svelte");
|
||||
// handled in component script
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||
{#each $toasts as t (t.id)}
|
||||
<div
|
||||
class={cn(
|
||||
"pointer-events-auto flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg animate-in slide-in-from-right",
|
||||
t.variant === "destructive"
|
||||
? "border-destructive bg-destructive text-destructive-foreground"
|
||||
: "border-border bg-background text-foreground"
|
||||
)}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold">{t.title}</p>
|
||||
{#if t.description}
|
||||
<p class="text-xs opacity-90">{t.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={() => toasts.update(ts => ts.filter(x => x.id !== t.id))} class="text-xs opacity-70 hover:opacity-100">✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
1
src/lib/components/ui/switch/index.ts
Normal file
1
src/lib/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from "./switch.svelte";
|
||||
27
src/lib/components/ui/switch/switch.svelte
Normal file
27
src/lib/components/ui/switch/switch.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
|
||||
let { class: className = "", checked = $bindable(false), disabled = false, children, ...rest }: HTMLButtonAttributes & { checked?: boolean; } = $props();
|
||||
</script>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onclick={() => { checked = !checked; }}
|
||||
class={cn(
|
||||
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-primary" : "bg-input",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span
|
||||
class={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
4
src/lib/components/ui/tabs/index.ts
Normal file
4
src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Tabs } from "./tabs.svelte";
|
||||
export { default as TabsList } from "./tabs-list.svelte";
|
||||
export { default as TabsTrigger } from "./tabs-trigger.svelte";
|
||||
export { default as TabsContent } from "./tabs-content.svelte";
|
||||
10
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
10
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", value = "", activeTab = "", children, ...rest }: HTMLAttributes<HTMLDivElement> & { value?: string; activeTab?: string } = $props();
|
||||
</script>
|
||||
{#if value === activeTab}
|
||||
<div class={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
8
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
8
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
<div class={cn("inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
17
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
17
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
let { class: className = "", value = "", selected = false, onclick, children, ...rest }: HTMLButtonAttributes & { value?: string; selected?: boolean } = $props();
|
||||
</script>
|
||||
<button
|
||||
class={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
selected ? "bg-background text-foreground shadow-sm" : "hover:bg-background/50 hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{onclick}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
8
src/lib/components/ui/tabs/tabs.svelte
Normal file
8
src/lib/components/ui/tabs/tabs.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
let { class: className = "", value = $bindable(""), children, ...rest }: HTMLAttributes<HTMLDivElement> & { value?: string } = $props();
|
||||
</script>
|
||||
<div class={cn("", className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
1
src/lib/components/ui/textarea/index.ts
Normal file
1
src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Textarea } from "./textarea.svelte";
|
||||
12
src/lib/components/ui/textarea/textarea.svelte
Normal file
12
src/lib/components/ui/textarea/textarea.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||
let { class: className = "", ...rest }: HTMLTextareaAttributes = $props();
|
||||
</script>
|
||||
<textarea class={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background",
|
||||
"placeholder:text-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)} {...rest} />
|
||||
1
src/lib/components/ui/tooltip/index.ts
Normal file
1
src/lib/components/ui/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Tooltip } from "./tooltip.svelte";
|
||||
18
src/lib/components/ui/tooltip/tooltip.svelte
Normal file
18
src/lib/components/ui/tooltip/tooltip.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
let { class: className = "", content = "", children }: { class?: string; content: string; children?: import("svelte").Snippet } = $props();
|
||||
let visible = $state(false);
|
||||
</script>
|
||||
|
||||
<span class="relative inline-flex" onmouseenter={() => { visible = true; }} onmouseleave={() => { visible = false; }}>
|
||||
{@render children?.()}
|
||||
{#if visible}
|
||||
<span class={cn(
|
||||
"absolute z-50 px-2 py-1 text-xs rounded-md bg-popover text-popover-foreground border shadow-sm whitespace-nowrap",
|
||||
"-top-8 left-1/2 -translate-x-1/2",
|
||||
className
|
||||
)}>
|
||||
{content}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
65
src/lib/hermes.ts
Normal file
65
src/lib/hermes.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Hermes API integration
|
||||
// This service communicates with a SvelteKit API route that proxies to Hermes CLI
|
||||
|
||||
const API_BASE = '/api/hermes';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
status?: 'sending' | 'streaming' | 'complete' | 'error';
|
||||
}
|
||||
|
||||
export interface HermesConfig {
|
||||
model: string;
|
||||
provider: string;
|
||||
systemPrompt: string;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: HermesConfig = {
|
||||
model: '',
|
||||
provider: 'openrouter',
|
||||
systemPrompt: 'You are Hermes AI Agent, a helpful assistant built with Nous Research technology.',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7
|
||||
};
|
||||
|
||||
export async function sendMessage(
|
||||
messages: { role: string; content: string }[],
|
||||
config: HermesConfig
|
||||
): Promise<Response> {
|
||||
return fetch(`${API_BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages, config })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHermesStatus(): Promise<{
|
||||
available: boolean;
|
||||
model: string;
|
||||
provider: string;
|
||||
version: string;
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/status`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchModels(): Promise<string[]> {
|
||||
const res = await fetch(`${API_BASE}/models`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchSessions(): Promise<
|
||||
{ id: string; title: string; preview: string; timestamp: number }[]
|
||||
> {
|
||||
const res = await fetch(`${API_BASE}/sessions`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/sessions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
210
src/lib/stores/chat.ts
Normal file
210
src/lib/stores/chat.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { ChatMessage, HermesConfig } from '$lib/hermes';
|
||||
import { sendMessage } from '$lib/hermes';
|
||||
import { DEFAULT_CONFIG } from '$lib/hermes';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
// Generate simple IDs
|
||||
let idCounter = 0;
|
||||
function genId() {
|
||||
return `msg_${Date.now()}_${idCounter++}`;
|
||||
}
|
||||
|
||||
// Sessions store
|
||||
export const sessions = writable<
|
||||
{ id: string; title: string; preview: string; timestamp: number; messages: ChatMessage[] }[]
|
||||
>([]);
|
||||
|
||||
export const activeSessionId = writable<string | null>(null);
|
||||
|
||||
export const config = writable<HermesConfig>(DEFAULT_CONFIG);
|
||||
|
||||
// Messages for active session
|
||||
export const messages = derived(
|
||||
[sessions, activeSessionId],
|
||||
([$sessions, $activeSessionId]) => {
|
||||
if (!$activeSessionId) return [];
|
||||
const session = $sessions.find((s) => s.id === $activeSessionId);
|
||||
return session?.messages ?? [];
|
||||
}
|
||||
);
|
||||
|
||||
// Dark mode
|
||||
export const darkMode = writable(false);
|
||||
|
||||
// Model list
|
||||
export const availableModels = writable<string[]>([]);
|
||||
|
||||
// Is streaming
|
||||
export const isStreaming = writable(false);
|
||||
|
||||
export function createSession(messages: ChatMessage[] = []) {
|
||||
const id = `session_${Date.now()}`;
|
||||
const session = {
|
||||
id,
|
||||
title: 'New Chat',
|
||||
preview: '',
|
||||
timestamp: Date.now(),
|
||||
messages
|
||||
};
|
||||
sessions.update((s) => [session, ...s]);
|
||||
activeSessionId.set(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deleteSessionAction(id: string) {
|
||||
sessions.update((s) => {
|
||||
const filtered = s.filter((sess) => sess.id !== id);
|
||||
if (activeSessionId && id === activeSessionId) {
|
||||
// Doesn't work in derived, handled in component
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
|
||||
export function addMessage(msg: ChatMessage) {
|
||||
sessions.update((s) => {
|
||||
return s.map((session) => {
|
||||
if (session.id === $activeSessionId) {
|
||||
return {
|
||||
...session,
|
||||
messages: [...session.messages, msg],
|
||||
preview: msg.content.slice(0, 80)
|
||||
};
|
||||
}
|
||||
return session;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLastMessage(id: string, updates: Partial<ChatMessage>) {
|
||||
sessions.update((s) => {
|
||||
return s.map((session) => {
|
||||
if (session.id === $activeSessionId) {
|
||||
const msgs = [...session.messages];
|
||||
const idx = msgs.findIndex((m) => m.id === id);
|
||||
if (idx >= 0) {
|
||||
msgs[idx] = { ...msgs[idx], ...updates };
|
||||
}
|
||||
return { ...session, messages: msgs };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// We need a mutable ref for the activeSessionId because stores use $variable syntax
|
||||
let $activeSessionId: string | null = null;
|
||||
activeSessionId.subscribe((v) => ($activeSessionId = v));
|
||||
|
||||
export async function submitMessage(content: string) {
|
||||
if (!content.trim()) return;
|
||||
|
||||
// Ensure we have a session
|
||||
if (!$activeSessionId) {
|
||||
createSession();
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
timestamp: Date.now(),
|
||||
status: 'complete'
|
||||
};
|
||||
addMessage(userMsg);
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
status: 'streaming'
|
||||
};
|
||||
addMessage(assistantMsg);
|
||||
isStreaming.set(true);
|
||||
|
||||
try {
|
||||
let $config: HermesConfig = DEFAULT_CONFIG;
|
||||
config.subscribe((v) => ($config = v))();
|
||||
|
||||
const $messages: ChatMessage[] = [];
|
||||
const unsub = messages.subscribe((v) => {
|
||||
$messages.length = 0;
|
||||
$messages.push(...v);
|
||||
});
|
||||
unsub();
|
||||
|
||||
const history = $messages
|
||||
.filter((m) => m.status === 'complete' && m.role !== 'streaming')
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
|
||||
const response = await sendMessage(history, $config);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
updateLastMessage(assistantMsg.id, {
|
||||
content: `**Error**: ${errText || response.statusText}`,
|
||||
status: 'error'
|
||||
});
|
||||
toast('Chat failed', { description: errText || response.statusText, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta?.content || '';
|
||||
fullContent += delta;
|
||||
updateLastMessage(assistantMsg.id, {
|
||||
content: fullContent,
|
||||
status: 'streaming'
|
||||
});
|
||||
} catch {
|
||||
// partial JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLastMessage(assistantMsg.id, {
|
||||
content: fullContent,
|
||||
status: 'complete'
|
||||
});
|
||||
|
||||
// Update session preview and title
|
||||
sessions.update((s) => {
|
||||
return s.map((session) => {
|
||||
if (session.id === $activeSessionId) {
|
||||
const title =
|
||||
userMsg.content.slice(0, 50) + (userMsg.content.length > 50 ? '...' : '');
|
||||
return { ...session, title, preview: userMsg.content.slice(0, 80) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
updateLastMessage(assistantMsg.id, {
|
||||
content: `**Connection Error**: ${err instanceof Error ? err.message : String(err)}`,
|
||||
status: 'error'
|
||||
});
|
||||
} finally {
|
||||
isStreaming.set(false);
|
||||
}
|
||||
}
|
||||
20
src/lib/stores/toast.ts
Normal file
20
src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function toast(title: string, opts?: { description?: string; variant?: 'default' | 'destructive' }) {
|
||||
const id = `toast-${counter++}`;
|
||||
toasts.update((t) => [...t, { id, title, ...opts }]);
|
||||
setTimeout(() => {
|
||||
toasts.update((t) => t.filter((x) => x.id !== id));
|
||||
}, 4000);
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
122
src/routes/+layout.svelte
Normal file
122
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { darkMode } from '$lib/stores/chat';
|
||||
import Sidebar from '$lib/components/chat/Sidebar.svelte';
|
||||
import SettingsDialog from '$lib/components/chat/SettingsDialog.svelte';
|
||||
import ToastContainer from '$lib/components/ui/sonner/ToastContainer.svelte';
|
||||
import { onMount, getContext, setContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let { children }: { children?: import('svelte').Snippet } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
let settingsOpen = $state(false);
|
||||
let isDark = $state(false);
|
||||
|
||||
darkMode.subscribe((v) => (isDark = v));
|
||||
|
||||
onMount(() => {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const stored = localStorage.getItem('hermes-dark-mode');
|
||||
const val = stored !== null ? stored === 'true' : prefersDark;
|
||||
darkMode.set(val);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
localStorage.setItem('hermes-dark-mode', String(isDark));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||
<!-- Sidebar (desktop) -->
|
||||
<div class="hidden md:block h-full">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar overlay -->
|
||||
{#if sidebarOpen}
|
||||
<div class="fixed inset-0 z-40 md:hidden">
|
||||
<div class="fixed inset-0 bg-black/80" onclick={() => { sidebarOpen = false; }} />
|
||||
<aside class="fixed left-0 top-0 z-50 h-full w-72 border-r bg-background">
|
||||
<Sidebar onClose={() => { sidebarOpen = false; }} />
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex flex-1 flex-col min-w-0">
|
||||
<!-- Top bar -->
|
||||
<header class="flex items-center justify-between border-b px-4 h-14 shrink-0 md:px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="md:hidden inline-flex items-center justify-center rounded-md p-2 hover:bg-accent"
|
||||
onclick={() => { sidebarOpen = !sidebarOpen; }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-7 w-7 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
H
|
||||
</div>
|
||||
<span class="font-semibold text-sm hidden sm:inline">Hermes Chat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md p-2 hover:bg-accent text-muted-foreground"
|
||||
onclick={() => { settingsOpen = true; }}
|
||||
title="Settings"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md p-2 hover:bg-accent text-muted-foreground"
|
||||
onclick={() => { darkMode.update((v) => !v); }}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{#if isDark}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 overflow-hidden">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<!-- Settings dialog -->
|
||||
<SettingsDialog bind:open={settingsOpen} />
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
92
src/routes/+page.svelte
Normal file
92
src/routes/+page.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { messages, activeSessionId, submitMessage, isStreaming } from '$lib/stores/chat';
|
||||
import { get } from 'svelte/store';
|
||||
import ChatMessage from '$lib/components/chat/ChatMessage.svelte';
|
||||
import ChatInput from '$lib/components/chat/ChatInput.svelte';
|
||||
|
||||
let messagesContainer: HTMLDivElement | undefined = $state(undefined);
|
||||
|
||||
// Auto scroll to bottom when messages change
|
||||
$effect(() => {
|
||||
const msgs = get(messages);
|
||||
if (msgs.length > 0 && messagesContainer) {
|
||||
requestAnimationFrame(() => {
|
||||
messagesContainer?.scrollTo({
|
||||
top: messagesContainer.scrollHeight,
|
||||
behavior: get(isStreaming) ? 'smooth' : 'instant'
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function handleSubmit(text: string) {
|
||||
submitMessage(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Messages area -->
|
||||
<div bind:this={messagesContainer} class="flex-1 overflow-y-auto" role="log" aria-label="Chat messages">
|
||||
{#if get(activeSessionId) === null || get(messages).length === 0}
|
||||
<!-- Welcome screen -->
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="max-w-md text-center px-6">
|
||||
<div class="h-20 w-20 rounded-3xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold mx-auto mb-6 shadow-lg shadow-purple-500/20">
|
||||
H
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold mb-2">Hermes Chat</h1>
|
||||
<p class="text-muted-foreground mb-8 text-sm leading-relaxed">
|
||||
Interact with Hermes Agent through a modern chat interface.
|
||||
Supports markdown, streaming responses, and session management.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-left">
|
||||
<button
|
||||
class="rounded-xl border border-border/60 bg-card p-4 hover:bg-accent/50 transition-colors text-left"
|
||||
onclick={() => submitMessage("Explain how Hermes Agent works and what makes it different from other AI agents.")}
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">🤖 What is Hermes?</div>
|
||||
<p class="text-xs text-muted-foreground">Learn about the agent</p>
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-border/60 bg-card p-4 hover:bg-accent/50 transition-colors text-left"
|
||||
onclick={() => submitMessage("Write a Python script to monitor system resources (CPU, memory, disk) and send alerts.")}
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">⚡ System Monitor</div>
|
||||
<p class="text-xs text-muted-foreground">Build a Python script</p>
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-border/60 bg-card p-4 hover:bg-accent/50 transition-colors text-left"
|
||||
onclick={() => submitMessage("Design a REST API structure for a task management app with users, projects, and tasks.")}
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">📋 API Design</div>
|
||||
<p class="text-xs text-muted-foreground">Design a REST API</p>
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-border/60 bg-card p-4 hover:bg-accent/50 transition-colors text-left"
|
||||
onclick={() => submitMessage("Write a git workflow for a team using feature branches with code review and CI/CD.")}
|
||||
>
|
||||
<div class="text-sm font-medium mb-1">🔧 Git Workflow</div>
|
||||
<p class="text-xs text-muted-foreground">Team collaboration</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Message list -->
|
||||
<div class="py-4 space-y-1">
|
||||
{#each get(messages) as msg (msg.id)}
|
||||
<ChatMessage
|
||||
content={msg.content}
|
||||
role={msg.role}
|
||||
status={msg.status}
|
||||
/>
|
||||
{/each}
|
||||
<div class="h-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<ChatInput onsubmit={handleSubmit} />
|
||||
</div>
|
||||
98
src/routes/api/hermes/chat/+server.ts
Normal file
98
src/routes/api/hermes/chat/+server.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { spawn } from 'child_process';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function POST({ request }: RequestEvent) {
|
||||
try {
|
||||
const { messages, config } = await request.json();
|
||||
|
||||
const lastMsg = messages?.[messages.length - 1]?.content || '';
|
||||
const modelFlag = config?.model ? ['-m', config.model] : [];
|
||||
const providerFlag = config?.provider ? ['--provider', config.provider] : [];
|
||||
|
||||
const hermes = spawn('hermes', [
|
||||
'chat',
|
||||
'-q', lastMsg,
|
||||
...modelFlag,
|
||||
...providerFlag,
|
||||
'-Q',
|
||||
'--source', 'web-ui'
|
||||
], {
|
||||
timeout: 60000,
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const processChunk = (chunk: Buffer) => {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
|
||||
while (buffer.includes('\n')) {
|
||||
const idx = buffer.indexOf('\n');
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
|
||||
if (line.trim()) {
|
||||
const payload = JSON.stringify({
|
||||
choices: [{ delta: { content: line + '\n' } }]
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${payload}\n\n`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
hermes.stdout?.on('data', processChunk);
|
||||
|
||||
hermes.stderr?.on('data', (data: Buffer) => {
|
||||
const text = decoder.decode(data, { stream: true });
|
||||
// Only send non-empty stderr lines (warnings, etc.)
|
||||
if (text.trim()) {
|
||||
const payload = JSON.stringify({
|
||||
choices: [{ delta: { content: `*${text.trim()}* ` } }]
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${payload}\n\n`));
|
||||
}
|
||||
});
|
||||
|
||||
hermes.on('close', (code) => {
|
||||
// Flush remaining buffer
|
||||
if (buffer.trim()) {
|
||||
const payload = JSON.stringify({
|
||||
choices: [{ delta: { content: buffer } }]
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${payload}\n\n`));
|
||||
}
|
||||
|
||||
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
});
|
||||
|
||||
hermes.on('error', (err) => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`data: ${JSON.stringify({ error: err.message })}\n\n`
|
||||
)
|
||||
);
|
||||
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
return json(
|
||||
{ error: err instanceof Error ? err.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/routes/api/hermes/models/+server.ts
Normal file
29
src/routes/api/hermes/models/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Try to get models from Hermes config
|
||||
const output = execSync('hermes config get model 2>/dev/null', {
|
||||
timeout: 5000,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
return json(lines);
|
||||
} catch {
|
||||
// Return sensible defaults
|
||||
return json([
|
||||
'openai/gpt-4o',
|
||||
'openai/gpt-4o-mini',
|
||||
'anthropic/claude-sonnet-4',
|
||||
'anthropic/claude-haiku-3.5',
|
||||
'google/gemini-3.5-flash',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-pro',
|
||||
'mistral/mistral-small-3.1',
|
||||
'meta-llama/llama-4-maverick',
|
||||
'qwen/qwen-3-235b-a22b'
|
||||
]);
|
||||
}
|
||||
}
|
||||
22
src/routes/api/hermes/sessions/+server.ts
Normal file
22
src/routes/api/hermes/sessions/+server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const output = execSync(
|
||||
'hermes sessions list --limit 20 --json 2>/dev/null || echo "[]"',
|
||||
{ timeout: 5000, encoding: 'utf-8' }
|
||||
);
|
||||
const parsed = JSON.parse(output);
|
||||
return json(
|
||||
parsed.map((s: any) => ({
|
||||
id: s.id || s.session_id || '',
|
||||
title: s.title || 'Untitled',
|
||||
preview: s.preview || (s.first_message || '').slice(0, 80),
|
||||
timestamp: s.timestamp || s.created_at || Date.now()
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
return json([]);
|
||||
}
|
||||
}
|
||||
35
src/routes/api/hermes/status/+server.ts
Normal file
35
src/routes/api/hermes/status/+server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const version = execSync('hermes --version 2>/dev/null || echo "not found"', {
|
||||
timeout: 5000,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
const modelOutput = execSync(
|
||||
'hermes config get model.default 2>/dev/null || echo "not set"',
|
||||
{ timeout: 5000, encoding: 'utf-8' }
|
||||
).trim();
|
||||
|
||||
const providerOutput = execSync(
|
||||
'hermes config get model.provider 2>/dev/null || echo "openrouter"',
|
||||
{ timeout: 5000, encoding: 'utf-8' }
|
||||
).trim();
|
||||
|
||||
return json({
|
||||
available: version !== 'not found',
|
||||
model: modelOutput === 'not set' ? '' : modelOutput,
|
||||
provider: providerOutput,
|
||||
version
|
||||
});
|
||||
} catch {
|
||||
return json({
|
||||
available: false,
|
||||
model: '',
|
||||
provider: 'openrouter',
|
||||
version: 'not found'
|
||||
});
|
||||
}
|
||||
}
|
||||
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
17
svelte.config.js
Normal file
17
svelte.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user