Fix: real Hermes session integration, artifact detection, SSE robustness
- Parse plain-text 'hermes sessions list' output for real session IDs - Pass sessionId to chat API for --resume continuity across page loads - Filter session_id: lines from SSE content into separate metadata events - Fix crash: 'Controller is already closed' with proper error handling - Auto-detect code blocks, HTML, SVG, diff artifacts from responses - Add diff rendering support in Monaco editor - Fix status/models API for 'hermes config show' output format - Load real Hermes sessions in sidebar on mount - Link local sessions to Hermes session IDs for resumability
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
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 {
|
||||
@@ -18,19 +17,24 @@
|
||||
let editorInstance: any = null;
|
||||
|
||||
$effect(() => {
|
||||
if (open && artifact && artifact.type === 'code' && editorContainer && !editorInstance) {
|
||||
if (open && artifact && (artifact.type === 'code' || artifact.type === 'diff') && editorContainer && !editorInstance) {
|
||||
loadMonaco();
|
||||
}
|
||||
// Cleanup on close
|
||||
if (!open && editorInstance) {
|
||||
editorInstance.dispose();
|
||||
editorInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMonaco() {
|
||||
try {
|
||||
const monaco = await import('monaco-editor');
|
||||
monacoReady = true;
|
||||
if (editorContainer) {
|
||||
if (editorContainer && artifact) {
|
||||
editorInstance = monaco.editor.create(editorContainer, {
|
||||
value: artifact?.content || '',
|
||||
language: mapLang(artifact?.language || ''),
|
||||
value: artifact.content,
|
||||
language: artifact.type === 'diff' ? 'diff' : mapLang(artifact.language),
|
||||
readOnly: true,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
|
||||
minimap: { enabled: false },
|
||||
@@ -53,8 +57,7 @@
|
||||
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'
|
||||
bash: 'shell', sql: 'sql', xml: 'xml', svelte: 'html', svg: 'xml'
|
||||
};
|
||||
return map[lang] || lang || 'plaintext';
|
||||
}
|
||||
@@ -94,12 +97,8 @@
|
||||
<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={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" />
|
||||
@@ -116,8 +115,8 @@
|
||||
class="w-full h-full border-0"
|
||||
sandbox="allow-scripts"
|
||||
title={artifact.title}
|
||||
/>
|
||||
{:else if artifact.type === 'code'}
|
||||
></iframe>
|
||||
{:else if artifact.type === 'code' || artifact.type === 'diff'}
|
||||
{#if monacoReady}
|
||||
<div bind:this={editorContainer} class="w-full h-full"></div>
|
||||
{:else}
|
||||
@@ -125,6 +124,10 @@
|
||||
<pre class="text-sm overflow-auto max-h-[70vh]"><code>{artifact.content}</code></pre>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if artifact.type === 'mermaid'}
|
||||
<div class="p-8 flex items-center justify-center h-full">
|
||||
<pre class="text-sm bg-muted/30 rounded-lg p-6 border border-border max-w-full overflow-auto">{artifact.content}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 overflow-auto h-full">
|
||||
<pre class="text-sm whitespace-pre-wrap">{artifact.content}</pre>
|
||||
|
||||
@@ -4,18 +4,24 @@
|
||||
import hljs from 'highlight.js';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Artifact } from '$lib/types';
|
||||
|
||||
let {
|
||||
content = '',
|
||||
role = 'assistant' as 'user' | 'assistant',
|
||||
status = 'complete' as 'sending' | 'streaming' | 'complete' | 'error'
|
||||
status = 'complete' as 'sending' | 'streaming' | 'complete' | 'error',
|
||||
artifacts = [] as Artifact[],
|
||||
onArtifactClick
|
||||
}: {
|
||||
content?: string;
|
||||
role?: 'user' | 'assistant';
|
||||
status?: 'sending' | 'streaming' | 'complete' | 'error';
|
||||
artifacts?: Artifact[];
|
||||
onArtifactClick?: (artifact: Artifact) => void;
|
||||
} = $props();
|
||||
|
||||
let ready = $state(false);
|
||||
let copiedId = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
marked.setOptions({
|
||||
@@ -40,10 +46,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
function copyCode(code: string, e: Event) {
|
||||
e.stopPropagation();
|
||||
function copyCode(code: string, id: string) {
|
||||
navigator.clipboard.writeText(code);
|
||||
copiedId = id;
|
||||
toast('Copied to clipboard');
|
||||
setTimeout(() => { copiedId = null; }, 2000);
|
||||
}
|
||||
|
||||
function handleArtifactClick(a: Artifact) {
|
||||
onArtifactClick?.(a);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -60,9 +71,9 @@
|
||||
)}>
|
||||
{#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" />
|
||||
<span class="h-2 w-2 rounded-full bg-foreground/40 animate-bounce" style="animation-delay: 0ms"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-foreground/40 animate-bounce" style="animation-delay: 150ms"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-foreground/40 animate-bounce" style="animation-delay: 300ms"></span>
|
||||
</div>
|
||||
{:else if content}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
@@ -70,13 +81,48 @@
|
||||
</div>
|
||||
|
||||
{#if status === 'streaming'}
|
||||
<span class="inline-block h-3 w-1.5 bg-foreground/50 animate-pulse ml-0.5" />
|
||||
<span class="inline-block h-3 w-1.5 bg-foreground/50 animate-pulse ml-0.5"></span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if status === 'error'}
|
||||
<div class="mt-1 text-xs text-destructive">Error generating response</div>
|
||||
{/if}
|
||||
|
||||
<!-- Artifact badges -->
|
||||
{#if artifacts && artifacts.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-border/30">
|
||||
{#each artifacts as a (a.id)}
|
||||
<button
|
||||
onclick={() => handleArtifactClick(a)}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-muted/60 border border-border/50 px-2 py-0.5 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
{#if a.type === 'code'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
{:else if a.type === 'html'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 19l7-7 3 3-7 7-3-3z" /><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
|
||||
</svg>
|
||||
{:else if a.type === 'svg'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" />
|
||||
</svg>
|
||||
{:else if a.type === 'diff'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3v18" /><path d="M5 12h14" /><path d="M5 7l3-3 3 3" /><path d="M19 17l-3 3-3-3" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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}
|
||||
{a.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if role === 'user'}
|
||||
@@ -99,23 +145,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { sessions, activeSessionId, createSession, deleteSessionAction, sidebarOpen } from '$lib/stores/chat';
|
||||
import { sessions, activeSessionId, createSession, deleteSessionAction, sidebarOpen, resumeHermesSession } 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 { getStatus, getSessions as fetchHermesSessions } from '$lib/hermes';
|
||||
import { onMount } from 'svelte';
|
||||
import type { SessionListItem } from '$lib/types';
|
||||
|
||||
let { onClose }: { onClose?: () => void } = $props();
|
||||
|
||||
@@ -11,6 +12,7 @@
|
||||
let hermesModel = $state('');
|
||||
let sessionList = $state<any[]>([]);
|
||||
let activeId = $state<string | null>(null);
|
||||
let hermesSessions = $state<SessionListItem[]>([]);
|
||||
|
||||
sessions.subscribe((v) => (sessionList = v));
|
||||
activeSessionId.subscribe((v) => (activeId = v));
|
||||
@@ -19,13 +21,22 @@
|
||||
isStreaming.subscribe((v) => (streamBusy = v));
|
||||
|
||||
onMount(async () => {
|
||||
// Check Hermes status
|
||||
try {
|
||||
const status = await getHermesStatus();
|
||||
const status = await getStatus();
|
||||
hermesAvailable = status.available;
|
||||
hermesModel = status.model || status.provider || 'connected';
|
||||
} catch {
|
||||
hermesAvailable = false;
|
||||
}
|
||||
|
||||
// Load real Hermes sessions
|
||||
try {
|
||||
const hs = await fetchHermesSessions(20);
|
||||
hermesSessions = hs;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(ts: number) {
|
||||
@@ -36,6 +47,11 @@
|
||||
if (diff < 604800000) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function resumeSession(hermesId: string, title: string, preview: string) {
|
||||
resumeHermesSession(hermesId, title, preview);
|
||||
onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-72 flex-col border-r bg-muted/30">
|
||||
@@ -55,42 +71,72 @@
|
||||
|
||||
<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}
|
||||
<!-- Local sessions (active in this browser) -->
|
||||
{#if sessionList.length > 0}
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">This Browser</span>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{#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>
|
||||
{/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>
|
||||
<!-- Hermes sessions (from CLI) -->
|
||||
{#if hermesSessions.length > 0}
|
||||
<div class="px-3 pt-2 pb-1 mt-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">Hermes Sessions</span>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{#each hermesSessions as hs (hs.id)}
|
||||
<button
|
||||
class="w-full text-left rounded-lg px-3 py-2.5 transition-colors group cursor-pointer hover:bg-accent/50 text-muted-foreground hover:text-foreground"
|
||||
onclick={() => resumeSession(hs.id, hs.title, hs.preview)}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate flex-1">{hs.title}</span>
|
||||
<span class="text-[10px] text-muted-foreground/60 whitespace-nowrap mt-0.5">
|
||||
{formatDate(hs.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{#if hs.preview}
|
||||
<p class="text-xs text-muted-foreground/70 mt-1 truncate">{hs.preview}</p>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if sessionList.length === 0 && hermesSessions.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}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t p-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.ts";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
class: className = "",
|
||||
|
||||
@@ -6,12 +6,13 @@ export const API_BASE = '/api/hermes';
|
||||
export async function sendMessage(
|
||||
history: { role: string; content: string }[],
|
||||
config: HermesConfig,
|
||||
sessionId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Response> {
|
||||
return fetch(`${API_BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: history, config }),
|
||||
body: JSON.stringify({ messages: history, config, sessionId }),
|
||||
signal
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ function genId() {
|
||||
}
|
||||
|
||||
// ─── Session store ──────────────────────────────────────────
|
||||
export const sessions = writable<{
|
||||
id: string;
|
||||
export interface StoredSession {
|
||||
id: string; // Our local ID
|
||||
hermesSessionId?: string; // Real Hermes session ID (YYYYMMDD_HHMMSS_hex)
|
||||
title: string;
|
||||
preview: string;
|
||||
timestamp: number;
|
||||
messages: ChatMessage[];
|
||||
}[]>([]);
|
||||
}
|
||||
|
||||
export const sessions = writable<StoredSession[]>([]);
|
||||
|
||||
export const activeSessionId = writable<string | null>(null);
|
||||
|
||||
@@ -41,7 +44,46 @@ export const sidebarOpen = writable(false);
|
||||
|
||||
export function createSession(initialMessages: ChatMessage[] = []) {
|
||||
const id = `session_${Date.now()}`;
|
||||
const s = { id, title: 'New Chat', preview: '', timestamp: Date.now(), messages: initialMessages };
|
||||
const s: StoredSession = {
|
||||
id,
|
||||
title: 'New Chat',
|
||||
preview: '',
|
||||
timestamp: Date.now(),
|
||||
messages: initialMessages
|
||||
};
|
||||
sessions.update((list) => [s, ...list]);
|
||||
activeSessionId.set(id);
|
||||
sidebarOpen.set(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Resume an existing Hermes session by its real session ID */
|
||||
export function resumeHermesSession(hermesId: string, title: string, preview: string) {
|
||||
// Check if we already have this session locally
|
||||
let existing: StoredSession | null = null;
|
||||
const current = getFromStore(sessions);
|
||||
for (const s of current) {
|
||||
if (s.hermesSessionId === hermesId) {
|
||||
existing = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
activeSessionId.set(existing.id);
|
||||
sidebarOpen.set(false);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = `session_${Date.now()}`;
|
||||
const s: StoredSession = {
|
||||
id,
|
||||
hermesSessionId: hermesId,
|
||||
title,
|
||||
preview,
|
||||
timestamp: Date.now(),
|
||||
messages: []
|
||||
};
|
||||
sessions.update((list) => [s, ...list]);
|
||||
activeSessionId.set(id);
|
||||
sidebarOpen.set(false);
|
||||
@@ -73,6 +115,13 @@ export function updateMessage(msgId: string, updates: Partial<ChatMessage>) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Store the Hermes session ID once it's assigned */
|
||||
export function setHermesSessionId(localId: string, hermesId: string) {
|
||||
sessions.update((list) =>
|
||||
list.map((s) => (s.id === localId ? { ...s, hermesSessionId: hermesId } : s))
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────
|
||||
let _activeId: string | null = null;
|
||||
activeSessionId.subscribe((v) => (_activeId = v));
|
||||
@@ -80,9 +129,20 @@ function getActiveId() {
|
||||
return _activeId;
|
||||
}
|
||||
|
||||
function getActiveSession(): StoredSession | null {
|
||||
const current = getFromStore(sessions);
|
||||
return current.find((s) => s.id === _activeId) || null;
|
||||
}
|
||||
|
||||
let _config: HermesConfig = DEFAULT_CONFIG;
|
||||
config.subscribe((v) => (_config = v));
|
||||
|
||||
function getFromStore<T>(store: import('svelte/store').Writable<T>): T {
|
||||
let val: T;
|
||||
store.subscribe((v) => { val = v; })();
|
||||
return val!;
|
||||
}
|
||||
|
||||
export async function submitMessage(userText: string) {
|
||||
const text = userText.trim();
|
||||
if (!text) return;
|
||||
@@ -99,7 +159,6 @@ export async function submitMessage(userText: string) {
|
||||
};
|
||||
addMessage(userMsg);
|
||||
|
||||
// Create assistant placeholder
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
@@ -114,16 +173,18 @@ export async function submitMessage(userText: string) {
|
||||
isStreaming.set(true);
|
||||
|
||||
try {
|
||||
// Build history from current session (only complete messages)
|
||||
let $messages: ChatMessage[] = [];
|
||||
const unsub = messages.subscribe((v) => ($messages = v));
|
||||
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);
|
||||
const activeSess = getActiveSession();
|
||||
const hermesId = activeSess?.hermesSessionId;
|
||||
|
||||
const response = await sendMessage(history, _config, hermesId, controller.signal);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
@@ -140,6 +201,7 @@ export async function submitMessage(userText: string) {
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
let hermesSessionId = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -154,6 +216,13 @@ export async function submitMessage(userText: string) {
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// Check for session_id metadata
|
||||
if (parsed.session_id) {
|
||||
hermesSessionId = parsed.session_id;
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = parsed.choices?.[0]?.delta?.content || '';
|
||||
fullContent += delta;
|
||||
updateMessage(assistantMsg.id, { content: fullContent, status: 'streaming' });
|
||||
@@ -164,9 +233,20 @@ export async function submitMessage(userText: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Save the Hermes session ID so we can resume later
|
||||
if (hermesSessionId) {
|
||||
setHermesSessionId(getActiveId() || '', hermesSessionId);
|
||||
}
|
||||
|
||||
updateMessage(assistantMsg.id, { content: fullContent, status: 'complete' });
|
||||
|
||||
// Update session title from first message
|
||||
// Auto-detect artifacts from the response
|
||||
const artifacts = detectArtifacts(fullContent);
|
||||
if (artifacts.length > 0) {
|
||||
updateMessage(assistantMsg.id, { artifacts });
|
||||
}
|
||||
|
||||
// Update session title
|
||||
sessions.update((list) =>
|
||||
list.map((s) => {
|
||||
if (s.id === getActiveId()) {
|
||||
@@ -204,3 +284,80 @@ abortController.subscribe((v) => (_ctrl = v));
|
||||
function getAbortCtrl() {
|
||||
return _ctrl;
|
||||
}
|
||||
|
||||
// ─── Artifact detection ─────────────────────────────────────
|
||||
|
||||
import type { Artifact } from '$lib/types';
|
||||
|
||||
function detectArtifacts(content: string): Artifact[] {
|
||||
const artifacts: Artifact[] = [];
|
||||
let artifactId = 0;
|
||||
|
||||
// Detect fenced code blocks with language
|
||||
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
const lang = match[1] || 'text';
|
||||
const code = match[2].trim();
|
||||
if (code.length < 10) continue; // skip trivial blocks
|
||||
|
||||
const type = inferArtifactType(lang, code);
|
||||
artifacts.push({
|
||||
id: `artifact_${artifactId++}`,
|
||||
type,
|
||||
language: lang,
|
||||
title: `${lang}.${type === 'code' ? 'txt' : type}`,
|
||||
content: code
|
||||
});
|
||||
}
|
||||
|
||||
// Detect HTML blocks (standalone <html> or <!DOCTYPE)
|
||||
const htmlMatch = content.match(/<!DOCTYPE html>([\s\S]*?)<\/html>/i);
|
||||
if (htmlMatch) {
|
||||
artifacts.push({
|
||||
id: `artifact_${artifactId++}`,
|
||||
type: 'html',
|
||||
language: 'html',
|
||||
title: 'page.html',
|
||||
content: htmlMatch[0]
|
||||
});
|
||||
}
|
||||
|
||||
// Detect SVG blocks
|
||||
const svgMatch = content.match(/<svg[\s\S]*?<\/svg>/i);
|
||||
if (svgMatch && !htmlMatch) {
|
||||
artifacts.push({
|
||||
id: `artifact_${artifactId++}`,
|
||||
type: 'svg',
|
||||
language: 'svg',
|
||||
title: 'image.svg',
|
||||
content: svgMatch[0]
|
||||
});
|
||||
}
|
||||
|
||||
// Detect diff/patch blocks
|
||||
if (content.includes('--- ') && content.includes('+++ ') && content.includes('@@ ')) {
|
||||
const diffMatch = content.match(/--- [^\n]+\n\+\+\+ [^\n]+\n[\s\S]*?(?=\n```|\n\n|$)/);
|
||||
if (diffMatch) {
|
||||
artifacts.push({
|
||||
id: `artifact_${artifactId++}`,
|
||||
type: 'diff',
|
||||
language: 'diff',
|
||||
title: 'changes.diff',
|
||||
content: diffMatch[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
function inferArtifactType(lang: string, code: string): Artifact['type'] {
|
||||
if (lang === 'html' || lang === 'htm') return 'html';
|
||||
if (lang === 'svg') return 'svg';
|
||||
if (lang === 'diff' || lang === 'patch') return 'diff';
|
||||
if (lang === 'mermaid') return 'mermaid';
|
||||
if (code.includes('<!DOCTYPE') || code.includes('<html')) return 'html';
|
||||
if (code.includes('<svg')) return 'svg';
|
||||
return 'code';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { messages, activeSessionId, submitMessage, isStreaming, stopGeneration } from '$lib/stores/chat';
|
||||
import { messages, activeSessionId, submitMessage, isStreaming } from '$lib/stores/chat';
|
||||
import { get } from 'svelte/store';
|
||||
import ChatMessage from '$lib/components/chat/ChatMessage.svelte';
|
||||
import ChatInput from '$lib/components/chat/ChatInput.svelte';
|
||||
@@ -35,7 +35,7 @@
|
||||
previewOpen = true;
|
||||
}
|
||||
|
||||
function openArtifact(a: Artifact) {
|
||||
function handleArtifactClick(a: Artifact) {
|
||||
activeArtifact = a;
|
||||
artifactOpen = true;
|
||||
}
|
||||
@@ -49,7 +49,6 @@
|
||||
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')!);
|
||||
@@ -106,7 +105,7 @@
|
||||
<span>•</span>
|
||||
<span>Monaco code viewer</span>
|
||||
<span>•</span>
|
||||
<span>Syntax highlighting</span>
|
||||
<span>Artifact detection</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,9 +113,15 @@
|
||||
<!-- 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} />
|
||||
<ChatMessage
|
||||
content={msg.content}
|
||||
role={msg.role}
|
||||
status={msg.status}
|
||||
artifacts={msg.artifacts || []}
|
||||
onArtifactClick={handleArtifactClick}
|
||||
/>
|
||||
{/each}
|
||||
<div class="h-4" />
|
||||
<div class="h-4"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,81 +4,111 @@ import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function POST({ request }: RequestEvent) {
|
||||
try {
|
||||
const { messages, config } = await request.json();
|
||||
const { messages, config, sessionId } = 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 args: string[] = ['chat'];
|
||||
|
||||
// Resume real Hermes session if we have one
|
||||
if (sessionId && /^\d{8}_\d{6}_/.test(sessionId)) {
|
||||
args.push('--resume', sessionId);
|
||||
}
|
||||
|
||||
args.push('-q', lastMsg, ...modelFlag, ...providerFlag, '-Q', '--source', 'hermes-chat-v2');
|
||||
|
||||
const hermes = spawn('hermes', args, {
|
||||
timeout: 300000,
|
||||
env: { ...process.env, HOME: process.env.HOME || '/home/qadroid' }
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const processChunk = (chunk: Buffer) => {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
function enqueueLine(line: string) {
|
||||
if (closed) return;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Extract session_id as metadata, not content
|
||||
if (trimmed.startsWith('session_id:')) {
|
||||
const sid = trimmed.slice('session_id:'.length).trim();
|
||||
if (sid) {
|
||||
try {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify({ session_id: sid })}\n\n`)
|
||||
);
|
||||
} catch { /* ignore if closed */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
choices: [{ delta: { content: trimmed + '\n' } }]
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${payload}\n\n`));
|
||||
} catch { /* ignore if closed */ }
|
||||
}
|
||||
|
||||
function flushBuffer() {
|
||||
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`));
|
||||
}
|
||||
enqueueLine(line);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
hermes.stdout?.on('data', processChunk);
|
||||
function finalFlush() {
|
||||
if (buffer.trim()) {
|
||||
enqueueLine(buffer);
|
||||
buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
function done() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
} catch { /* ignore if already closed */ }
|
||||
}
|
||||
|
||||
hermes.stdout?.on('data', (chunk: Buffer) => {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
flushBuffer();
|
||||
});
|
||||
|
||||
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`));
|
||||
enqueueLine(text.trim());
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
finalFlush();
|
||||
done();
|
||||
});
|
||||
|
||||
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();
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`data: ${JSON.stringify({ error: err.message })}\n\n`
|
||||
)
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
done();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,25 +3,35 @@ import { execSync } from 'child_process';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const output = execSync('hermes config get model 2>/dev/null', {
|
||||
// Parse the config show for model info
|
||||
const output = execSync('hermes config show 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);
|
||||
|
||||
// Extract the model dict - it's a Python-style dict
|
||||
const modelMatch = output.match(/Model:\s*\{([^}]+)\}/);
|
||||
if (modelMatch) {
|
||||
// Extract 'default' value
|
||||
const defaultMatch = modelMatch[1].match(/'default':\s*'([^']+)'/);
|
||||
if (defaultMatch) {
|
||||
return json([defaultMatch[1]]);
|
||||
}
|
||||
}
|
||||
return json(FALLBACK_MODELS);
|
||||
} catch {
|
||||
return json(FALLBACK_MODELS);
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK_MODELS = [
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-pro',
|
||||
'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'
|
||||
|
||||
@@ -3,21 +3,84 @@ 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()
|
||||
}))
|
||||
);
|
||||
const limit = url.searchParams.get('limit') || '50';
|
||||
const output = execSync(`hermes sessions list --limit ${limit} 2>/dev/null`, {
|
||||
timeout: 5000,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const sessions = parseSessions(output);
|
||||
return json(sessions);
|
||||
} catch {
|
||||
return json([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the plain-text output of `hermes sessions list`.
|
||||
*
|
||||
* Expected format (column-aligned with spaces):
|
||||
* Title Preview Last Active ID
|
||||
* ──────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* — create a way to connect to hermes via 2m ago 20260522_045714_48480d
|
||||
* Self-Improvement and Token Eff find me good self improvement skills. 45m ago 20260522_045210_2381af
|
||||
*
|
||||
* The ID column is always the last column and matches: YYYYMMDD_HHMMSS_[hex]
|
||||
* The "Last Active" column is second-to-last.
|
||||
*/
|
||||
function parseSessions(output: string) {
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
const sessions: { id: string; title: string; preview: string; timestamp: number }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header and separator lines
|
||||
if (line.startsWith('Title') || line.startsWith('──')) continue;
|
||||
|
||||
// Match the session ID at the end: YYYYMMDD_HHMMSS_[hex]
|
||||
const idMatch = line.match(/(\d{8}_\d{6}_[0-9a-f]+)\s*$/);
|
||||
if (!idMatch) continue;
|
||||
|
||||
const id = idMatch[1];
|
||||
const beforeId = line.slice(0, line.lastIndexOf(id)).trimEnd();
|
||||
|
||||
// Match the "Last Active" column (relative time like "2m ago", "1h ago", "3d ago")
|
||||
const timeMatch = beforeId.match(/(\d+[smhdw]\s+ago)\s*$/);
|
||||
let timestamp = Date.now();
|
||||
let beforeTime = beforeId;
|
||||
if (timeMatch) {
|
||||
const timeStr = timeMatch[1];
|
||||
beforeTime = beforeId.slice(0, beforeId.lastIndexOf(timeStr)).trimEnd();
|
||||
timestamp = parseRelativeTime(timeStr);
|
||||
}
|
||||
|
||||
// Everything before the time column is Title + Preview
|
||||
// Title is typically up to ~32 chars, Preview fills the rest
|
||||
// We'll split on the first long gap of spaces
|
||||
const parts = beforeTime.split(/\s{3,}/);
|
||||
let title = parts[0] || 'Untitled';
|
||||
let preview = parts.slice(1).join(' ') || '';
|
||||
|
||||
// Clean up title (replace dash-only titles)
|
||||
if (/^[—\-]+$/.test(title)) title = 'Untitled';
|
||||
|
||||
sessions.push({ id, title, preview, timestamp });
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function parseRelativeTime(str: string): number {
|
||||
const now = Date.now();
|
||||
const match = str.match(/^(\d+)\s*([smhdw])\s+ago$/);
|
||||
if (!match) return now;
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2];
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
w: 7 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
return now - value * (multipliers[unit] || 0);
|
||||
}
|
||||
@@ -8,20 +8,31 @@ export async function GET() {
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
const modelOutput = execSync(
|
||||
'hermes config get model.default 2>/dev/null || echo "not set"',
|
||||
{ timeout: 5000, encoding: 'utf-8' }
|
||||
).trim();
|
||||
// Parse config show for model info
|
||||
let model = '';
|
||||
let provider = 'openrouter';
|
||||
|
||||
const providerOutput = execSync(
|
||||
'hermes config get model.provider 2>/dev/null || echo "openrouter"',
|
||||
{ timeout: 5000, encoding: 'utf-8' }
|
||||
).trim();
|
||||
try {
|
||||
const configOut = execSync('hermes config show 2>/dev/null', {
|
||||
timeout: 5000,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
// Extract model default
|
||||
const modelMatch = configOut.match(/'default':\s*'([^']+)'/);
|
||||
if (modelMatch) model = modelMatch[1];
|
||||
|
||||
// Extract provider
|
||||
const providerMatch = configOut.match(/'provider':\s*'([^']+)'/);
|
||||
if (providerMatch) provider = providerMatch[1];
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
|
||||
return json({
|
||||
available: version !== 'not found',
|
||||
model: modelOutput === 'not set' ? '' : modelOutput,
|
||||
provider: providerOutput,
|
||||
model,
|
||||
provider,
|
||||
version
|
||||
});
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user