Initial commit: Hermes Chat App with Svelte 5 + shadcn-svelte

This commit is contained in:
2026-05-22 04:25:20 +02:00
commit e0697c9522
82 changed files with 3349 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
.svelte-kit/
build/
dist/
.env
.env.*
!.env.example
*.log
.DS_Store

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

67
src/app.css Normal file
View 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
View 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
View 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>

View 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

View 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>

View 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>

View 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>

View 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>

View 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}

View 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} />

View 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>

View 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";

View 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>

View File

@@ -0,0 +1 @@
export { default as Badge } from "./badge.svelte";

View 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>

View File

@@ -0,0 +1 @@
export { default as Button } from "./button.svelte";

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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";

View 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>

View 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>

View 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>

View 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>

View 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}

View 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";

View File

@@ -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>

View 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}

View File

@@ -0,0 +1,2 @@
export { default as DropdownMenu } from "./dropdown-menu.svelte";
export { default as DropdownMenuItem } from "./dropdown-menu-item.svelte";

View File

@@ -0,0 +1 @@
export { default as Input } from "./input.svelte";

View 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} />

View File

@@ -0,0 +1 @@
export { default as Label } from "./label.svelte";

View 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>

View File

@@ -0,0 +1 @@
export { default as Progress } from "./progress.svelte";

View 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>

View File

@@ -0,0 +1 @@
export { default as ScrollArea } from "./scroll-area.svelte";

View 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>

View File

@@ -0,0 +1 @@
export { default as Select } from "./select.svelte";

View 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>

View File

@@ -0,0 +1 @@
export { default as Separator } from "./separator.svelte";

View 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}
/>

View File

@@ -0,0 +1 @@
export { default as Sheet } from "./sheet.svelte";

View 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}

View 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>

View 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>

View File

@@ -0,0 +1 @@
export { default as Switch } from "./switch.svelte";

View 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>

View 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";

View 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}

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
export { default as Textarea } from "./textarea.svelte";

View 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} />

View File

@@ -0,0 +1 @@
export { default as Tooltip } from "./tooltip.svelte";

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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 }
);
}
}

View 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'
]);
}
}

View 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([]);
}
}

View 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
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

17
svelte.config.js Normal file
View 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
View 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
View 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()]
});