Initial commit: Hermes Chat v2
Svelte 5 + shadcn-svelte + Tailwind CSS v4 chat interface for Hermes Agent with Monaco editor, file drag/drop, artifacts, image preview, and real session management. - SSE streaming via hermes CLI proxy - File upload + drag/drop with image preview - Code artifacts with Monaco editor - Markdown + highlight.js syntax highlighting - Full settings (model, provider, temp, system prompt) - 12 shadcn UI components - Dark/light theme
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
}
|
||||||
|
}
|
||||||
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 --add tailwindcss="plugins:none" sveltekit-adapter="adapter:node" --install pnpm hermes-chat-v2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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",
|
||||||
|
"style": "default",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "hermes-chat-v2",
|
||||||
|
"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-node": "^5.5.4",
|
||||||
|
"@sveltejs/kit": "^2.57.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"svelte": "^5.55.2",
|
||||||
|
"svelte-check": "^4.4.6",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^8.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bits-ui": "^2.18.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"marked": "^18.0.4",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1647
pnpm-lock.yaml
generated
Normal file
1647
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@tailwindcss/oxide': true
|
||||||
|
esbuild: true
|
||||||
97
src/app.css
Normal file
97
src/app.css
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
@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-hermes-brand: oklch(0.58 0.22 290);
|
||||||
|
--color-chat-user: oklch(0.62 0.18 255);
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default highlight.js theme (dark fallback) */
|
||||||
|
@import "highlight.js/styles/github-dark.css" layer(base);
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root:not(.dark) {
|
||||||
|
@import "highlight.js/styles/github.css" layer(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code block styling */
|
||||||
|
pre code.hljs {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--color-muted-foreground) / 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--color-muted-foreground) / 0.4);
|
||||||
|
}
|
||||||
11
src/app.d.ts
vendored
Normal file
11
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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>
|
||||||
138
src/lib/components/chat/ArtifactViewer.svelte
Normal file
138
src/lib/components/chat/ArtifactViewer.svelte
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog } from 'bits-ui';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { toast } from '$lib/stores/toast';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Artifact } from '$lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
artifact,
|
||||||
|
open = $bindable(false)
|
||||||
|
}: {
|
||||||
|
artifact?: Artifact | null;
|
||||||
|
open?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let monacoReady = $state(false);
|
||||||
|
let editorContainer: HTMLDivElement | undefined = $state(undefined);
|
||||||
|
let editorInstance: any = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && artifact && artifact.type === 'code' && editorContainer && !editorInstance) {
|
||||||
|
loadMonaco();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMonaco() {
|
||||||
|
try {
|
||||||
|
const monaco = await import('monaco-editor');
|
||||||
|
monacoReady = true;
|
||||||
|
if (editorContainer) {
|
||||||
|
editorInstance = monaco.editor.create(editorContainer, {
|
||||||
|
value: artifact?.content || '',
|
||||||
|
language: mapLang(artifact?.language || ''),
|
||||||
|
readOnly: true,
|
||||||
|
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
|
||||||
|
minimap: { enabled: false },
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
padding: { top: 12 },
|
||||||
|
automaticLayout: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
monacoReady = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLang(lang: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
js: 'javascript', ts: 'typescript', py: 'python', rb: 'ruby',
|
||||||
|
go: 'go', rs: 'rust', java: 'java', cpp: 'cpp', c: 'c',
|
||||||
|
html: 'html', css: 'css', scss: 'scss', json: 'json',
|
||||||
|
yaml: 'yaml', yml: 'yaml', md: 'markdown', sh: 'shell',
|
||||||
|
bash: 'shell', sql: 'sql', xml: 'xml', svelte: 'html',
|
||||||
|
svg: 'xml'
|
||||||
|
};
|
||||||
|
return map[lang] || lang || 'plaintext';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent() {
|
||||||
|
if (artifact) {
|
||||||
|
navigator.clipboard.writeText(artifact.content);
|
||||||
|
toast('Copied to clipboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadContent() {
|
||||||
|
if (!artifact) return;
|
||||||
|
const blob = new Blob([artifact.content], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = artifact.title || 'artifact';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/60" />
|
||||||
|
<Dialog.Content
|
||||||
|
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-[90vw] max-w-4xl h-[85vh] data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||||
|
oninteractoutside={() => { open = false; }}
|
||||||
|
>
|
||||||
|
{#if artifact}
|
||||||
|
<div class="flex flex-col h-full rounded-lg border border-border bg-background overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">{artifact.title}</h3>
|
||||||
|
<Badge variant="secondary">{artifact.language || artifact.type}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick={copyContent} class="rounded-md px-2 py-1 text-xs hover:bg-accent border border-border transition-colors" title="Copy">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button onclick={downloadContent} class="rounded-md px-2 py-1 text-xs hover:bg-accent border border-border transition-colors" title="Download">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button onclick={() => { open = false; }} class="rounded-md p-1 hover:bg-accent transition-colors">
|
||||||
|
<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="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
{#if artifact.type === 'html' || artifact.type === 'svg'}
|
||||||
|
<iframe
|
||||||
|
srcdoc={artifact.content}
|
||||||
|
class="w-full h-full border-0"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
title={artifact.title}
|
||||||
|
/>
|
||||||
|
{:else if artifact.type === 'code'}
|
||||||
|
{#if monacoReady}
|
||||||
|
<div bind:this={editorContainer} class="w-full h-full"></div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-4">
|
||||||
|
<pre class="text-sm overflow-auto max-h-[70vh]"><code>{artifact.content}</code></pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="p-4 overflow-auto h-full">
|
||||||
|
<pre class="text-sm whitespace-pre-wrap">{artifact.content}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
208
src/lib/components/chat/ChatInput.svelte
Normal file
208
src/lib/components/chat/ChatInput.svelte
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { isStreaming, stopGeneration, uploadedFiles } from '$lib/stores/chat';
|
||||||
|
import { uploadFile } from '$lib/hermes';
|
||||||
|
import { toast } from '$lib/stores/toast';
|
||||||
|
import type { FileAttachment } from '$lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onsubmit
|
||||||
|
}: {
|
||||||
|
onsubmit?: (text: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let inputValue = $state('');
|
||||||
|
let textareaEl: HTMLTextAreaElement | undefined = $state(undefined);
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let isBusy = $state(false);
|
||||||
|
let pendingFiles = $state<FileAttachment[]>([]);
|
||||||
|
|
||||||
|
isStreaming.subscribe((v) => (isBusy = v));
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!inputValue.trim() || isBusy) return;
|
||||||
|
onsubmit?.(inputValue.trim());
|
||||||
|
inputValue = '';
|
||||||
|
pendingFiles = [];
|
||||||
|
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, 160) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
function onDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
function onDragLeave() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
if (e.dataTransfer?.files) {
|
||||||
|
handleFiles(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFiles(files: File[]) {
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(file);
|
||||||
|
const att: FileAttachment = {
|
||||||
|
id: `file_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
name: result.name,
|
||||||
|
type: result.type,
|
||||||
|
size: result.size,
|
||||||
|
url: result.url,
|
||||||
|
previewUrl: result.previewUrl
|
||||||
|
};
|
||||||
|
pendingFiles = [...pendingFiles, att];
|
||||||
|
if (file.type.startsWith('image/') && result.previewUrl) {
|
||||||
|
// Append image markdown to input
|
||||||
|
inputValue += ` `;
|
||||||
|
} else {
|
||||||
|
inputValue += `[${file.name}](${result.url}) `;
|
||||||
|
}
|
||||||
|
toast('File attached', { description: file.name });
|
||||||
|
} catch (err) {
|
||||||
|
toast('Upload failed', {
|
||||||
|
description: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploadedFiles.set([...pendingFiles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingFile(id: string) {
|
||||||
|
pendingFiles = pendingFiles.filter((f) => f.id !== id);
|
||||||
|
uploadedFiles.set(pendingFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFiles() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.multiple = true;
|
||||||
|
input.onchange = () => {
|
||||||
|
if (input.files) handleFiles(Array.from(input.files));
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return bytes + 'B';
|
||||||
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB';
|
||||||
|
return (bytes / 1048576).toFixed(1) + 'MB';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
role="region"
|
||||||
|
aria-label="Chat input area"
|
||||||
|
ondragover={onDragOver}
|
||||||
|
ondragleave={onDragLeave}
|
||||||
|
ondrop={onDrop}
|
||||||
|
>
|
||||||
|
<!-- Drag overlay -->
|
||||||
|
{#if isDragging}
|
||||||
|
<div class="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-background/90">
|
||||||
|
<div class="text-center">
|
||||||
|
<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="mx-auto mb-2 text-primary">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm font-medium text-primary">Drop files here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{#if pendingFiles.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-2 mb-2">
|
||||||
|
{#each pendingFiles as file (file.id)}
|
||||||
|
<div class="flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs max-w-[200px]">
|
||||||
|
{#if file.previewUrl}
|
||||||
|
<img src={file.previewUrl} alt="" class="h-5 w-5 rounded object-cover" />
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
||||||
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /><polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">{file.name}</span>
|
||||||
|
<span class="text-muted-foreground">{formatSize(file.size)}</span>
|
||||||
|
<button type="button" onclick={() => removePendingFile(file.id)} class="text-muted-foreground hover:text-foreground ml-0.5">✕</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<textarea
|
||||||
|
bind:this={textareaEl}
|
||||||
|
bind:value={inputValue}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
oninput={autoResize}
|
||||||
|
placeholder="Message Hermes... (or drop files)"
|
||||||
|
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-[160px] leading-relaxed"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={pickFiles}
|
||||||
|
class="absolute left-1.5 bottom-1.5 inline-flex items-center justify-center rounded-lg h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
title="Attach file"
|
||||||
|
aria-label="Attach file"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
onclick={stopGeneration}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
179
src/lib/components/chat/ChatMessage.svelte
Normal file
179
src/lib/components/chat/ChatMessage.svelte
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import { toast } from '$lib/stores/toast';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
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 ready = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
highlight: function (code: string, lang: string) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(code, { language: lang }).value;
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value;
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let renderedHtml = $derived.by(() => {
|
||||||
|
if (!content || !ready) return '';
|
||||||
|
try {
|
||||||
|
return marked.parse(content, { async: false }) as string;
|
||||||
|
} catch {
|
||||||
|
return `<p>${content}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyCode(code: string, e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
toast('Copied to clipboard');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('flex gap-3 px-4 py-3', role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||||
|
{#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}
|
||||||
|
|
||||||
|
<div dir="auto" class={cn(
|
||||||
|
'max-w-[80%] md:max-w-[70%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed relative group',
|
||||||
|
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}
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
{@html renderedHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
{#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;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
:global(.prose pre:hover .copy-btn) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
:global(.copy-btn) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
: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;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
: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 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; }
|
||||||
|
:global(.hljs) { background: transparent !important; }
|
||||||
|
</style>
|
||||||
35
src/lib/components/chat/ImagePreview.svelte
Normal file
35
src/lib/components/chat/ImagePreview.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
src = '',
|
||||||
|
alt = '',
|
||||||
|
open = $bindable(false)
|
||||||
|
}: {
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
open?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||||
|
<Dialog.Content
|
||||||
|
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||||
|
oninteractoutside={() => { open = false; }}
|
||||||
|
>
|
||||||
|
<div class="relative max-w-[90vw] max-h-[90vh]">
|
||||||
|
<img src={src} alt={alt} class="max-w-full max-h-[85vh] rounded-lg object-contain" />
|
||||||
|
<button
|
||||||
|
onclick={() => { open = false; }}
|
||||||
|
class="absolute -top-3 -right-3 rounded-full bg-background border border-border p-1 shadow-md hover:bg-accent"
|
||||||
|
>
|
||||||
|
<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="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
215
src/lib/components/chat/SettingsDialog.svelte
Normal file
215
src/lib/components/chat/SettingsDialog.svelte
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog } from 'bits-ui';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { config, darkMode, availableModels } from '$lib/stores/chat';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false)
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isDark = $state(false);
|
||||||
|
darkMode.subscribe((v) => (isDark = v));
|
||||||
|
|
||||||
|
let models = $state<string[]>([]);
|
||||||
|
availableModels.subscribe((v) => (models = v));
|
||||||
|
|
||||||
|
let activeTab = $state('general');
|
||||||
|
let cfg = $state({ ...get(config) });
|
||||||
|
let customModel = $state('');
|
||||||
|
|
||||||
|
config.subscribe((v) => { cfg = v; });
|
||||||
|
|
||||||
|
function updateConfig(key: string, value: any) {
|
||||||
|
config.update((c) => ({ ...c, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
'openrouter', 'openai', 'anthropic', 'google',
|
||||||
|
'deepseek', 'mistral', 'nous', 'xai', 'github', 'custom'
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||||
|
<Dialog.Content class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-[90vw] max-w-lg max-h-[85vh] overflow-y-auto rounded-lg border border-border bg-background p-6 shadow-xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-8">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<h2 class="text-lg font-semibold">Settings</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">Configure your Hermes Chat experience</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 cursor-pointer
|
||||||
|
{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 cursor-pointer
|
||||||
|
{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 cursor-pointer
|
||||||
|
{activeTab === 'about' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
onclick={() => { activeTab = 'about'; }}
|
||||||
|
>About</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- General Tab -->
|
||||||
|
{#if activeTab === 'general'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium">Dark Mode</label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">Toggle dark/light theme</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {isDark ? 'bg-primary' : 'bg-input'}"
|
||||||
|
onclick={() => darkMode.update((v) => !v)}
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 rounded-full bg-background shadow-sm transition-transform {isDark ? 'translate-x-[18px]' : 'translate-x-[2px]'}" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="model-select" class="text-sm font-medium">Default Model</label>
|
||||||
|
<select
|
||||||
|
id="model-select"
|
||||||
|
value={cfg.model || ''}
|
||||||
|
onchange={(e) => updateConfig('model', (e.target as HTMLSelectElement).value)}
|
||||||
|
class="w-full mt-1.5 rounded-lg border border-input bg-background px-3 py-2 text-sm
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<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" class="text-sm font-medium">Custom Model Override</label>
|
||||||
|
<input
|
||||||
|
id="custom-model"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. anthropic/claude-sonnet-4"
|
||||||
|
bind:value={customModel}
|
||||||
|
oninput={() => { if (customModel) updateConfig('model', customModel); }}
|
||||||
|
class="w-full mt-1.5 rounded-lg border border-input bg-background px-3 py-2 text-sm
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="provider-select" class="text-sm font-medium">Provider</label>
|
||||||
|
<select
|
||||||
|
id="provider-select"
|
||||||
|
value={cfg.provider}
|
||||||
|
onchange={(e) => updateConfig('provider', (e.target as HTMLSelectElement).value)}
|
||||||
|
class="w-full mt-1.5 rounded-lg border border-input bg-background px-3 py-2 text-sm
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{#each providers as p}
|
||||||
|
<option value={p} selected={p === cfg.provider}>
|
||||||
|
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Tab -->
|
||||||
|
{:else if activeTab === 'model'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium">Temperature: {cfg.temperature}</label>
|
||||||
|
<input
|
||||||
|
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. Lower = more deterministic.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium">Max Tokens: {cfg.maxTokens}</label>
|
||||||
|
<input
|
||||||
|
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 class="text-sm font-medium">Top P: {cfg.topP}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0" max="1" step="0.05"
|
||||||
|
value={cfg.topP}
|
||||||
|
oninput={(e) => updateConfig('topP', parseFloat((e.target as HTMLInputElement).value))}
|
||||||
|
class="w-full mt-1.5 accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="sys-prompt" class="text-sm font-medium">System Prompt</label>
|
||||||
|
<textarea
|
||||||
|
id="sys-prompt"
|
||||||
|
bind:value={cfg.systemPrompt}
|
||||||
|
oninput={() => updateConfig('systemPrompt', cfg.systemPrompt)}
|
||||||
|
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"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About Tab -->
|
||||||
|
{: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 v2</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">Enhanced web 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">
|
||||||
|
Monaco Editor • Highlight.js • Markdown • Drag & Drop Files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
onclick={() => { open = false; }}
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
|
||||||
|
>Close</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
104
src/lib/components/chat/Sidebar.svelte
Normal file
104
src/lib/components/chat/Sidebar.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { sessions, activeSessionId, createSession, deleteSessionAction, sidebarOpen } from '$lib/stores/chat';
|
||||||
|
import { isStreaming } from '$lib/stores/chat';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { getStatus as 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));
|
||||||
|
|
||||||
|
let streamBusy = $state(false);
|
||||||
|
isStreaming.subscribe((v) => (streamBusy = 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 -->
|
||||||
|
<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={streamBusy}
|
||||||
|
>
|
||||||
|
<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 -->
|
||||||
|
<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 cursor-pointer
|
||||||
|
{session.id === activeId
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50 text-muted-foreground hover:text-foreground'}"
|
||||||
|
onclick={() => {
|
||||||
|
activeSessionId.set(session.id);
|
||||||
|
sidebarOpen.set(false);
|
||||||
|
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 -->
|
||||||
|
<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') : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
34
src/lib/components/chat/ToastContainer.svelte
Normal file
34
src/lib/components/chat/ToastContainer.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import { toasts, dismissToast } 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 max-w-sm">
|
||||||
|
{#each toastList as t (t.id)}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'pointer-events-auto flex items-start 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 min-w-0">
|
||||||
|
<p class="text-sm font-semibold">{t.title}</p>
|
||||||
|
{#if t.description}
|
||||||
|
<p class="text-xs opacity-90 mt-0.5">{t.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => dismissToast(t.id)}
|
||||||
|
class="text-xs opacity-70 hover:opacity-100 shrink-0 mt-0.5"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
41
src/lib/components/ui/badge/badge.svelte
Normal file
41
src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>["variant"]>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
variant = "default" as BadgeVariant,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn(badgeVariants({ variant }), className)} {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
7
src/lib/components/ui/badge/index.ts
Normal file
7
src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./badge.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Badge,
|
||||||
|
};
|
||||||
60
src/lib/components/ui/button/button.svelte
Normal file
60
src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonVariant = VariantProps<typeof buttonVariants>;
|
||||||
|
type ButtonSize = NonNullable<ButtonVariant["size"]>;
|
||||||
|
type ButtonVariantType = NonNullable<ButtonVariant["variant"]>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $state<HTMLButtonElement | null>(null),
|
||||||
|
class: className = "",
|
||||||
|
variant = "default" as ButtonVariantType,
|
||||||
|
size = "default" as ButtonSize,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
ref?: HTMLButtonElement | null;
|
||||||
|
class?: string;
|
||||||
|
variant?: ButtonVariantType;
|
||||||
|
size?: ButtonSize;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
7
src/lib/components/ui/button/index.ts
Normal file
7
src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./button.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Button,
|
||||||
|
};
|
||||||
26
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
26
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
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 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
23
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
23
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
class={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
19
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
19
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2", className)} {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
19
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
19
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
26
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
26
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
class={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Overlay>
|
||||||
23
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
23
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
17
src/lib/components/ui/dialog/dialog.svelte
Normal file
17
src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Root {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Root>
|
||||||
25
src/lib/components/ui/dialog/index.ts
Normal file
25
src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./dialog.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Overlay,
|
||||||
|
Title,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Title as DialogTitle,
|
||||||
|
};
|
||||||
7
src/lib/components/ui/input/index.ts
Normal file
7
src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
||||||
22
src/lib/components/ui/input/input.svelte
Normal file
22
src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $state<HTMLInputElement | null>(null),
|
||||||
|
class: className = "",
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
class?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
"flex h-10 w-full rounded-md 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
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
||||||
29
src/lib/components/ui/label/label.svelte
Normal file
29
src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $state<HTMLLabelElement | null>(null),
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
ref?: HTMLLabelElement | null;
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
bind:ref={ref}
|
||||||
|
class={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</LabelPrimitive.Root>
|
||||||
7
src/lib/components/ui/scroll-area/index.ts
Normal file
7
src/lib/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./scroll-area.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as ScrollArea,
|
||||||
|
};
|
||||||
22
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
22
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn("relative overflow-hidden", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
7
src/lib/components/ui/select/index.ts
Normal file
7
src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./select.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Select,
|
||||||
|
};
|
||||||
44
src/lib/components/ui/select/select.svelte
Normal file
44
src/lib/components/ui/select/select.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $state<HTMLSelectElement | null>(null),
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
ref?: HTMLSelectElement | null;
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<select
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 appearance-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 opacity-50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
7
src/lib/components/ui/separator/index.ts
Normal file
7
src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./separator.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Separator,
|
||||||
|
};
|
||||||
31
src/lib/components/ui/separator/separator.svelte
Normal file
31
src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
orientation = "horizontal" as "horizontal" | "vertical",
|
||||||
|
decorative = true,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
decorative?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if orientation === "horizontal"}
|
||||||
|
<hr
|
||||||
|
role={decorative ? "none" : "separator"}
|
||||||
|
aria-orientation={orientation}
|
||||||
|
class={cn("shrink-0 bg-border h-[1px] w-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<hr
|
||||||
|
role={decorative ? "none" : "separator"}
|
||||||
|
aria-orientation={orientation}
|
||||||
|
class={cn("shrink-0 bg-border w-[1px] h-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
28
src/lib/components/ui/sheet/index.ts
Normal file
28
src/lib/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Root from "./sheet.svelte";
|
||||||
|
import Close from "./sheet-close.svelte";
|
||||||
|
import Content from "./sheet-content.svelte";
|
||||||
|
import Description from "./sheet-description.svelte";
|
||||||
|
import Footer from "./sheet-footer.svelte";
|
||||||
|
import Header from "./sheet-header.svelte";
|
||||||
|
import Overlay from "./sheet-overlay.svelte";
|
||||||
|
import Title from "./sheet-title.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Close,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Overlay,
|
||||||
|
Title,
|
||||||
|
//
|
||||||
|
Root as Sheet,
|
||||||
|
Close as SheetClose,
|
||||||
|
Content as SheetContent,
|
||||||
|
Description as SheetDescription,
|
||||||
|
Footer as SheetFooter,
|
||||||
|
Header as SheetHeader,
|
||||||
|
Overlay as SheetOverlay,
|
||||||
|
Title as SheetTitle,
|
||||||
|
};
|
||||||
17
src/lib/components/ui/sheet/sheet-close.svelte
Normal file
17
src/lib/components/ui/sheet/sheet-close.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Close {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</SheetPrimitive.Close>
|
||||||
58
src/lib/components/ui/sheet/sheet-content.svelte
Normal file
58
src/lib/components/ui/sheet/sheet-content.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
type SheetContentProps = {
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
side = "right" as "top" | "bottom" | "left" | "right",
|
||||||
|
...restProps
|
||||||
|
}: SheetContentProps = $props();
|
||||||
|
|
||||||
|
const sideStyles: Record<string, string> = {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
sideStyles[side],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
<SheetPrimitive.Close class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="size-4"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
23
src/lib/components/ui/sheet/sheet-description.svelte
Normal file
23
src/lib/components/ui/sheet/sheet-description.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
class={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</SheetPrimitive.Description>
|
||||||
19
src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
19
src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2", className)} {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
19
src/lib/components/ui/sheet/sheet-header.svelte
Normal file
19
src/lib/components/ui/sheet/sheet-header.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
26
src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
26
src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
class={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</SheetPrimitive.Overlay>
|
||||||
23
src/lib/components/ui/sheet/sheet-title.svelte
Normal file
23
src/lib/components/ui/sheet/sheet-title.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
class={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</SheetPrimitive.Title>
|
||||||
17
src/lib/components/ui/sheet/sheet.svelte
Normal file
17
src/lib/components/ui/sheet/sheet.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Root {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</SheetPrimitive.Root>
|
||||||
7
src/lib/components/ui/switch/index.ts
Normal file
7
src/lib/components/ui/switch/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./switch.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Switch,
|
||||||
|
};
|
||||||
35
src/lib/components/ui/switch/switch.svelte
Normal file
35
src/lib/components/ui/switch/switch.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Switch as SwitchPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $state<HTMLButtonElement | null>(null),
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
ref?: HTMLButtonElement | null;
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
bind:ref={ref}
|
||||||
|
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 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
class={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
16
src/lib/components/ui/tabs/index.ts
Normal file
16
src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Root from "./tabs.svelte";
|
||||||
|
import Content from "./tabs-content.svelte";
|
||||||
|
import List from "./tabs-list.svelte";
|
||||||
|
import Trigger from "./tabs-trigger.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
List,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Tabs,
|
||||||
|
Content as TabsContent,
|
||||||
|
List as TabsList,
|
||||||
|
Trigger as TabsTrigger,
|
||||||
|
};
|
||||||
26
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
26
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
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
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</TabsPrimitive.Content>
|
||||||
26
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
26
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.List
|
||||||
|
class={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</TabsPrimitive.List>
|
||||||
26
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
26
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
class={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</TabsPrimitive.Trigger>
|
||||||
17
src/lib/components/ui/tabs/tabs.svelte
Normal file
17
src/lib/components/ui/tabs/tabs.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Root {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</TabsPrimitive.Root>
|
||||||
13
src/lib/components/ui/tooltip/index.ts
Normal file
13
src/lib/components/ui/tooltip/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./tooltip.svelte";
|
||||||
|
import Content from "./tooltip-content.svelte";
|
||||||
|
import Trigger from "./tooltip-trigger.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Tooltip,
|
||||||
|
Content as TooltipContent,
|
||||||
|
Trigger as TooltipTrigger,
|
||||||
|
};
|
||||||
29
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal file
29
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.ts";
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
sideOffset = 4,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
sideOffset?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
17
src/lib/components/ui/tooltip/tooltip-trigger.svelte
Normal file
17
src/lib/components/ui/tooltip/tooltip-trigger.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Trigger {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</TooltipPrimitive.Trigger>
|
||||||
17
src/lib/components/ui/tooltip/tooltip.svelte
Normal file
17
src/lib/components/ui/tooltip/tooltip.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Root {...restProps}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</TooltipPrimitive.Root>
|
||||||
43
src/lib/hermes.ts
Normal file
43
src/lib/hermes.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Hermes API client — talks to SvelteKit API routes that proxy to Hermes CLI
|
||||||
|
import type { HermesConfig, HermesStatus, SessionListItem, UploadResponse } from '$lib/types';
|
||||||
|
|
||||||
|
export const API_BASE = '/api/hermes';
|
||||||
|
|
||||||
|
export async function sendMessage(
|
||||||
|
history: { role: string; content: string }[],
|
||||||
|
config: HermesConfig,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`${API_BASE}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages: history, config }),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatus(): Promise<HermesStatus> {
|
||||||
|
const res = await fetch(`${API_BASE}/status`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModels(): Promise<string[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/models`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessions(limit = 50): Promise<SessionListItem[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/sessions?limit=${limit}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
|
await fetch(`${API_BASE}/sessions/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File): Promise<UploadResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await fetch(`${API_BASE}/upload`, { method: 'POST', body: form });
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
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.
|
||||||
206
src/lib/stores/chat.ts
Normal file
206
src/lib/stores/chat.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { ChatMessage, HermesConfig, FileAttachment } from '$lib/types';
|
||||||
|
import { DEFAULT_CONFIG } from '$lib/types';
|
||||||
|
import { sendMessage } from '$lib/hermes';
|
||||||
|
import { toast } from '$lib/stores/toast';
|
||||||
|
|
||||||
|
let msgCounter = 0;
|
||||||
|
function genId() {
|
||||||
|
return `msg_${Date.now()}_${msgCounter++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session 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 });
|
||||||
|
|
||||||
|
export const messages = derived(
|
||||||
|
[sessions, activeSessionId],
|
||||||
|
([$sessions, $activeSessionId]) => {
|
||||||
|
if (!$activeSessionId) return [];
|
||||||
|
return $sessions.find((s) => s.id === $activeSessionId)?.messages ?? [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const darkMode = writable(false);
|
||||||
|
export const availableModels = writable<string[]>([]);
|
||||||
|
export const isStreaming = writable(false);
|
||||||
|
export const abortController = writable<AbortController | null>(null);
|
||||||
|
export const uploadedFiles = writable<FileAttachment[]>([]);
|
||||||
|
export const sidebarOpen = writable(false);
|
||||||
|
|
||||||
|
// ─── Actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createSession(initialMessages: ChatMessage[] = []) {
|
||||||
|
const id = `session_${Date.now()}`;
|
||||||
|
const s = { id, title: 'New Chat', preview: '', timestamp: Date.now(), messages: initialMessages };
|
||||||
|
sessions.update((list) => [s, ...list]);
|
||||||
|
activeSessionId.set(id);
|
||||||
|
sidebarOpen.set(false);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSessionAction(id: string) {
|
||||||
|
sessions.update((list) => list.filter((s) => s.id !== id));
|
||||||
|
activeSessionId.update((active) => (active === id ? null : active));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMessage(msg: ChatMessage) {
|
||||||
|
sessions.update((list) =>
|
||||||
|
list.map((s) =>
|
||||||
|
s.id === getActiveId()
|
||||||
|
? { ...s, messages: [...s.messages, msg], preview: msg.content.slice(0, 80) }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMessage(msgId: string, updates: Partial<ChatMessage>) {
|
||||||
|
sessions.update((list) =>
|
||||||
|
list.map((s) => {
|
||||||
|
if (s.id !== getActiveId()) return s;
|
||||||
|
const msgs = s.messages.map((m) => (m.id === msgId ? { ...m, ...updates } : m));
|
||||||
|
return { ...s, messages: msgs };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ───────────────────────────────────────
|
||||||
|
let _activeId: string | null = null;
|
||||||
|
activeSessionId.subscribe((v) => (_activeId = v));
|
||||||
|
function getActiveId() {
|
||||||
|
return _activeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _config: HermesConfig = DEFAULT_CONFIG;
|
||||||
|
config.subscribe((v) => (_config = v));
|
||||||
|
|
||||||
|
export async function submitMessage(userText: string) {
|
||||||
|
const text = userText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
// Ensure session exists
|
||||||
|
if (!getActiveId()) createSession();
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: genId(),
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
status: 'complete'
|
||||||
|
};
|
||||||
|
addMessage(userMsg);
|
||||||
|
|
||||||
|
// Create assistant placeholder
|
||||||
|
const assistantMsg: ChatMessage = {
|
||||||
|
id: genId(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
status: 'streaming'
|
||||||
|
};
|
||||||
|
addMessage(assistantMsg);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortController.set(controller);
|
||||||
|
isStreaming.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build history from current session (only complete messages)
|
||||||
|
let $messages: ChatMessage[] = [];
|
||||||
|
const unsub = messages.subscribe((v) => ($messages = v));
|
||||||
|
unsub();
|
||||||
|
|
||||||
|
const history = $messages
|
||||||
|
.filter((m) => m.status === 'complete' && m.content)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
|
||||||
|
const response = await sendMessage(history, _config, controller.signal);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
updateMessage(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;
|
||||||
|
updateMessage(assistantMsg.id, { content: fullContent, status: 'streaming' });
|
||||||
|
} catch {
|
||||||
|
// partial JSON, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMessage(assistantMsg.id, { content: fullContent, status: 'complete' });
|
||||||
|
|
||||||
|
// Update session title from first message
|
||||||
|
sessions.update((list) =>
|
||||||
|
list.map((s) => {
|
||||||
|
if (s.id === getActiveId()) {
|
||||||
|
const title = userMsg.content.slice(0, 50) + (userMsg.content.length > 50 ? '...' : '');
|
||||||
|
return { ...s, title, preview: userMsg.content.slice(0, 80) };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
updateMessage(assistantMsg.id, { content: fullContent || '', status: 'complete' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateMessage(assistantMsg.id, {
|
||||||
|
content: `**Connection Error**: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
status: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isStreaming.set(false);
|
||||||
|
abortController.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopGeneration() {
|
||||||
|
const ctrl = getAbortCtrl();
|
||||||
|
if (ctrl) {
|
||||||
|
ctrl.abort();
|
||||||
|
toast('Generation stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ctrl: AbortController | null = null;
|
||||||
|
abortController.subscribe((v) => (_ctrl = v));
|
||||||
|
function getAbortCtrl() {
|
||||||
|
return _ctrl;
|
||||||
|
}
|
||||||
24
src/lib/stores/toast.ts
Normal file
24
src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissToast(id: string) {
|
||||||
|
toasts.update((t) => t.filter((x) => x.id !== id));
|
||||||
|
}
|
||||||
75
src/lib/types.ts
Normal file
75
src/lib/types.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/** Core types for the Hermes Chat v2 app */
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
status: 'sending' | 'streaming' | 'complete' | 'error';
|
||||||
|
artifacts?: Artifact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
timestamp: number;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesConfig {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
maxTokens: number;
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artifact {
|
||||||
|
id: string;
|
||||||
|
type: 'code' | 'html' | 'svg' | 'mermaid' | 'markdown' | 'diff' | 'unknown';
|
||||||
|
language: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesStatus {
|
||||||
|
available: boolean;
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
previewUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionListItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: HermesConfig = {
|
||||||
|
model: '',
|
||||||
|
provider: 'openrouter',
|
||||||
|
systemPrompt: 'You are Hermes AI Agent, a helpful AI assistant.',
|
||||||
|
maxTokens: 4096,
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.95
|
||||||
|
};
|
||||||
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));
|
||||||
|
}
|
||||||
113
src/routes/+layout.svelte
Normal file
113
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import Sidebar from '$lib/components/chat/Sidebar.svelte';
|
||||||
|
import SettingsDialog from '$lib/components/chat/SettingsDialog.svelte';
|
||||||
|
import ToastContainer from '$lib/components/chat/ToastContainer.svelte';
|
||||||
|
import { darkMode, sidebarOpen, availableModels } from '$lib/stores/chat';
|
||||||
|
import { getModels } from '$lib/hermes';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { children }: { children?: import('svelte').Snippet } = $props();
|
||||||
|
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');
|
||||||
|
darkMode.set(stored !== null ? stored === 'true' : prefersDark);
|
||||||
|
|
||||||
|
getModels().then((m) => availableModels.set(m)).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
$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">
|
||||||
|
<!-- Desktop sidebar -->
|
||||||
|
<div class="hidden md:block h-full">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile sidebar -->
|
||||||
|
{#if sidebarOpen}
|
||||||
|
<div class="fixed inset-0 z-40 md:hidden">
|
||||||
|
<div class="fixed inset-0 bg-black/80" onclick={() => sidebarOpen.set(false)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') sidebarOpen.set(false); }}>
|
||||||
|
</div>
|
||||||
|
<aside class="fixed left-0 top-0 z-50 h-full w-72 border-r bg-background">
|
||||||
|
<Sidebar onClose={() => sidebarOpen.set(false)} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Main area -->
|
||||||
|
<div class="flex flex-1 flex-col min-w-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<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.update((v) => !v)}
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<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 -->
|
||||||
|
<main class="flex-1 overflow-hidden">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
<SettingsDialog bind:open={settingsOpen} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
:global(*) { box-sizing: border-box; }
|
||||||
|
</style>
|
||||||
132
src/routes/+page.svelte
Normal file
132
src/routes/+page.svelte
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { messages, activeSessionId, submitMessage, isStreaming, stopGeneration } 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';
|
||||||
|
import ImagePreview from '$lib/components/chat/ImagePreview.svelte';
|
||||||
|
import ArtifactViewer from '$lib/components/chat/ArtifactViewer.svelte';
|
||||||
|
import type { Artifact } from '$lib/types';
|
||||||
|
|
||||||
|
let messagesContainer: HTMLDivElement | undefined = $state(undefined);
|
||||||
|
let previewImg = $state('');
|
||||||
|
let previewOpen = $state(false);
|
||||||
|
let activeArtifact = $state<Artifact | null>(null);
|
||||||
|
let artifactOpen = $state(false);
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImage(url: string) {
|
||||||
|
previewImg = url;
|
||||||
|
previewOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openArtifact(a: Artifact) {
|
||||||
|
activeArtifact = a;
|
||||||
|
artifactOpen = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- Messages -->
|
||||||
|
<div
|
||||||
|
bind:this={messagesContainer}
|
||||||
|
class="flex-1 overflow-y-auto"
|
||||||
|
role="application"
|
||||||
|
aria-label="Chat messages"
|
||||||
|
onclick={(e) => {
|
||||||
|
// Handle image clicks in rendered markdown
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'IMG' && target.getAttribute('src')) {
|
||||||
|
openImage(target.getAttribute('src')!);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') { /* dismiss */ }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#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 v2</h1>
|
||||||
|
<p class="text-muted-foreground mb-8 text-sm leading-relaxed">
|
||||||
|
Enhanced interface for Hermes Agent with file attachments,
|
||||||
|
code artifacts, 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 cursor-pointer"
|
||||||
|
onclick={() => submitMessage("Write a Python web scraper using requests and BeautifulSoup that fetches news headlines.")}
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium mb-1">🐍 Python Scraper</div>
|
||||||
|
<p class="text-xs text-muted-foreground">Web scraping with requests</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-border/60 bg-card p-4 hover:bg-accent/50 transition-colors text-left cursor-pointer"
|
||||||
|
onclick={() => submitMessage("Explain the architecture of Hermes Agent and how its tool system works.")}
|
||||||
|
>
|
||||||
|
<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 cursor-pointer"
|
||||||
|
onclick={() => submitMessage("Create a responsive navigation bar in HTML/CSS with a hamburger menu for mobile.")}
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium mb-1">🎨 HTML/CSS Nav</div>
|
||||||
|
<p class="text-xs text-muted-foreground">Responsive UI component</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-border/60 bg-card p-4 hover:bg-accent/50 transition-colors text-left cursor-pointer"
|
||||||
|
onclick={() => submitMessage("Design a REST API 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">REST API structure</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-center gap-2 text-xs text-muted-foreground/60">
|
||||||
|
<span>Drop files anywhere</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Monaco code viewer</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Syntax highlighting</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Messages -->
|
||||||
|
<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 -->
|
||||||
|
<ChatInput onsubmit={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Preview Modal -->
|
||||||
|
<ImagePreview bind:open={previewOpen} src={previewImg} />
|
||||||
|
|
||||||
|
<!-- Artifact Viewer -->
|
||||||
|
<ArtifactViewer artifact={activeArtifact} bind:open={artifactOpen} />
|
||||||
99
src/routes/api/hermes/chat/+server.ts
Normal file
99
src/routes/api/hermes/chat/+server.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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 tempFlag = config?.temperature ? ['--temperature', String(config.temperature)] : [];
|
||||||
|
const maxTokensFlag = config?.maxTokens ? ['--max-tokens', String(config.maxTokens)] : [];
|
||||||
|
|
||||||
|
const hermes = spawn('hermes', [
|
||||||
|
'chat',
|
||||||
|
'-q', lastMsg,
|
||||||
|
...modelFlag,
|
||||||
|
...providerFlag,
|
||||||
|
...tempFlag,
|
||||||
|
...maxTokensFlag,
|
||||||
|
'-Q',
|
||||||
|
'--source', 'hermes-chat-v2'
|
||||||
|
], {
|
||||||
|
timeout: 120000,
|
||||||
|
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 });
|
||||||
|
if (text.trim()) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
choices: [{ delta: { content: `*${text.trim()}* ` } }]
|
||||||
|
});
|
||||||
|
controller.enqueue(new TextEncoder().encode(`data: ${payload}\n\n`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hermes.on('close', () => {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/routes/api/hermes/models/+server.ts
Normal file
28
src/routes/api/hermes/models/+server.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
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.length > 0 ? lines : FALLBACK_MODELS);
|
||||||
|
} catch {
|
||||||
|
return json(FALLBACK_MODELS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_MODELS = [
|
||||||
|
'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'
|
||||||
|
];
|
||||||
23
src/routes/api/hermes/sessions/+server.ts
Normal file
23
src/routes/api/hermes/sessions/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
export async function GET({ url }) {
|
||||||
|
try {
|
||||||
|
const limit = url.searchParams.get('limit') || '20';
|
||||||
|
const output = execSync(
|
||||||
|
'hermes sessions list --limit ' + limit + ' --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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/routes/api/hermes/sessions/[id]/+server.ts
Normal file
16
src/routes/api/hermes/sessions/[id]/+server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function DELETE({ params }: RequestEvent) {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) {
|
||||||
|
return json({ error: 'Session ID required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { execSync } = await import('child_process');
|
||||||
|
execSync('hermes sessions delete ' + id + ' 2>/dev/null || true', { timeout: 5000 });
|
||||||
|
return json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return json({ success: true }); // soft-fail for local sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/routes/api/hermes/upload/+server.ts
Normal file
39
src/routes/api/hermes/upload/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export async function POST({ request }: { request: Request }) {
|
||||||
|
try {
|
||||||
|
const form = await request.formData();
|
||||||
|
const file = form.get('file') as File;
|
||||||
|
if (!file) {
|
||||||
|
return json({ error: 'No file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = join(tmpdir(), 'hermes-chat-uploads');
|
||||||
|
await mkdir(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
const ext = file.name.split('.').pop() || '';
|
||||||
|
const filename = `${randomUUID()}.${ext}`;
|
||||||
|
const filepath = join(uploadDir, filename);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
return json({
|
||||||
|
url: `/uploads/${filename}`,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
...(isImage ? { previewUrl: `/uploads/${filename}` } : {})
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return json(
|
||||||
|
{ error: err instanceof Error ? err.message : 'Upload failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/routes/uploads/[...path]/+server.ts
Normal file
27
src/routes/uploads/[...path]/+server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
|
export async function GET({ params }: { params: { path: string[] } }) {
|
||||||
|
try {
|
||||||
|
const filename = params.path.join('/');
|
||||||
|
const filepath = join(tmpdir(), 'hermes-chat-uploads', filename);
|
||||||
|
const data = await readFile(filepath);
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||||
|
pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown',
|
||||||
|
json: 'application/json', py: 'text/x-python', ts: 'text/typescript',
|
||||||
|
js: 'text/javascript', html: 'text/html', css: 'text/css'
|
||||||
|
};
|
||||||
|
return new Response(data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mime[ext] || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'public, max-age=3600'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
12
svelte.config.js
Normal file
12
svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
|
||||||
|
/** @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: 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
|
||||||
|
}
|
||||||
5
vite.config.ts
Normal file
5
vite.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||||
Reference in New Issue
Block a user