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() { } + +
{item?.title ?? '...'} {#if item} {#if item.type === 'idea'} - + {/if} - - + + {#if item.pr} - + {/if} {/if}
@@ -166,16 +208,30 @@ async function finish() { {/if}
- {#each messages as msg} -
{msg.content || (loading && msg.role === 'assistant' ? '…' : msg.content)}
+ {#each messages as msg, i} +
+ {#if msg.role === 'assistant'} + {#if renderedHtml[i]} + {@html renderedHtml[i]} + {:else} + {msg.content || '…'} + {/if} + {:else} + {msg.content} + {/if} +
{/each} {#if error}

{error}

{/if} +
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2e920e4..c21aa91 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,4 +3,10 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()], + server: { + proxy: { + '/api': 'http://localhost:8744', + '/auth': 'http://localhost:8744', + }, + }, }); diff --git a/package-lock.json b/package-lock.json index f31b60f..278c18d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,9 @@ "apps/web": { "name": "@minime/web", "version": "0.1.0", + "dependencies": { + "marked": "^17.0.4" + }, "devDependencies": { "@storybook/addon-a11y": "^8.6.18", "@storybook/addon-essentials": "^8.6.14", @@ -7802,6 +7805,18 @@ "dev": true, "license": "MIT" }, + "node_modules/marked": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",