diff --git a/apps/web/package.json b/apps/web/package.json index 53761c9..4b6cbee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,5 +35,8 @@ "typescript": "^5.6.0", "vite": "^6.0.0", "vitest": "^2.1.9" + }, + "dependencies": { + "marked": "^17.0.4" } } diff --git a/apps/web/src/routes/chat/[id]/+page.svelte b/apps/web/src/routes/chat/[id]/+page.svelte index d569dd0..b80c6d3 100644 --- a/apps/web/src/routes/chat/[id]/+page.svelte +++ b/apps/web/src/routes/chat/[id]/+page.svelte @@ -3,10 +3,17 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { api } from '$lib/api.js'; import type { ContentDetail } from '$lib/api.js'; -import { onMount } from 'svelte'; +import { afterUpdate, onMount } from 'svelte'; +import { marked } from 'marked'; let item: ContentDetail | null = null; let messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; +// Rendered HTML for completed assistant messages (populated after stream ends) +let renderedHtml: string[] = []; + +let bottomAnchor: HTMLElement; +afterUpdate(() => { bottomAnchor?.scrollIntoView({ behavior: 'instant' }); }); + let input = ''; let loading = false; let finishing = false; @@ -28,16 +35,32 @@ onMount(async () => { { role: 'assistant', content: `Continuing from last session:\n\n${item.session_summary}` }, ]; } + renderedHtml = messages.map((m) => + m.role === 'assistant' ? String(marked.parse(m.content)) : '' + ); }); async function save() { if (messages.length === 0) return; saving = true; + error = ''; try { const conversation = messages.map((m) => `${m.role}: ${m.content}`).join('\n'); const { summary } = await api.summarise(conversation); - await api.patch(id, { session_summary: summary }); + // Format conversation as markdown and append to existing document body + const sessionMd = [ + `## Session — ${new Date().toLocaleString()}`, + '', + ...messages.map((m) => + m.role === 'user' ? `**You:** ${m.content}` : `**Minime:** ${m.content}` + ), + ].join('\n\n'); + const currentBody = (item?.body ?? '').trimEnd(); + const newBody = currentBody ? `${currentBody}\n\n${sessionMd}` : sessionMd; + await api.patch(id, { session_summary: summary, body: newBody }); savedAt = new Date(); + } catch { + error = 'Failed to save. Please try again.'; } finally { saving = false; } @@ -76,17 +99,19 @@ async function send() { const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; for (const part of parts) { - const eventLine = part.split('\n').find((l) => l.startsWith('event:')); - const dataLine = part.split('\n').find((l) => l.startsWith('data:')); - if (!dataLine) continue; - const data = dataLine.slice(5).trim(); + const lines = part.split('\n'); + const eventLine = lines.find((l) => l.startsWith('event:')); + const dataLines = lines.filter((l) => l.startsWith('data:')); + if (!dataLines.length) continue; + // SSE encodes \n in data as multiple "data:" lines — reassemble them + const data = dataLines.map((l) => l.slice(6)).join('\n'); if (eventLine?.includes('context')) { try { contextTitles = JSON.parse(data).map((e: { title: string }) => e.title); } catch { /* ignore */ } - } else if (data !== '[DONE]') { + } else if (data.trimEnd() !== '[DONE]') { assistantMsg.content += data; messages = [...messages]; } @@ -94,9 +119,12 @@ async function send() { } } catch { error = 'Failed to send message. Please try again.'; - messages = messages.slice(0, -1); // remove empty assistant message + messages = messages.slice(0, -1); } finally { loading = false; + renderedHtml = messages.map((m) => + m.role === 'assistant' ? String(marked.parse(m.content)) : '' + ); } } @@ -143,18 +171,32 @@ async function finish() { } + +
{error}
{/if} +