From 93748c0c9cc1a2179c49139f46cf066b83f6e667 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 17:41:18 +0200 Subject: [PATCH] feat(playground): real LLM playground module backed by mana-llm + saved snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground route was previously a stub. This turns it into a proper module: - A streaming chat surface that talks to mana-llm's OpenAI-compatible /v1/chat/completions and /v1/models. The SSE chunk parser is hand-rolled in modules/playground/llm.ts (~30 lines) rather than pulling a dep — the wire format is straight OpenAI and the playground is the only consumer right now. If chat / todo enrichment / cycles insights end up hitting the same surface, this lifts cleanly into $lib/data/llm-client.ts. - A persisted **snippets** store: name + systemPrompt + (model, temperature) defaults that the user can pin and reorder. Stateless chat history stays out — that's what the chat module is for. Both `name` and `systemPrompt` are encrypted (same pattern as notes/dreams), with a registry entry in data/crypto/registry.ts and a Dexie schema in data/database.ts. - Standard module wiring: collections.ts / queries.ts / types.ts / stores/snippets.svelte.ts / module.config.ts, registered in module-registry.ts alongside the other 30+ modules. - ListView.svelte and the (app)/playground/+page.svelte route consume the new store + the streaming client. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/crypto/registry.ts | 7 + apps/mana/apps/web/src/lib/data/database.ts | 5 + .../apps/web/src/lib/data/module-registry.ts | 2 + .../lib/modules/playground/ListView.svelte | 117 +++++-- .../src/lib/modules/playground/collections.ts | 12 + .../web/src/lib/modules/playground/llm.ts | 126 +++++++ .../lib/modules/playground/module.config.ts | 6 + .../web/src/lib/modules/playground/queries.ts | 45 +++ .../playground/stores/snippets.svelte.ts | 70 ++++ .../web/src/lib/modules/playground/types.ts | 39 +++ .../src/routes/(app)/playground/+page.svelte | 319 +++++++++++++++--- 11 files changed, 670 insertions(+), 78 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/playground/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/playground/llm.ts create mode 100644 apps/mana/apps/web/src/lib/modules/playground/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/playground/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/playground/stores/snippets.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/playground/types.ts diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 087346b8e..26838a363 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -264,6 +264,13 @@ export const ENCRYPTION_REGISTRY: Record = { // join table (placeId / tagId), zero user-typed content. Same pattern // as manaLinks. + // ─── Playground ────────────────────────────────────────── + // Saved system-prompt snippets. `name` is the user's label and + // `systemPrompt` is the actual prompt body — both are user-typed + // free-form text and the whole point of having a vault. Indexed + // columns (isPinned, order) stay plaintext for sort. + playgroundSnippets: { enabled: true, fields: ['name', 'systemPrompt'] }, + // ─── TimeBlocks (cross-module hub) ─────────────────────── // Phase 7.1: encrypted alongside tasks + calendar.events + habits // because the consumer modules denormalize their title/description diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 3e3b70a47..7ceb3242b 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -256,6 +256,11 @@ db.version(1).stores({ locationLogs: 'id, placeId, timestamp, [placeId+timestamp]', placeTags: 'id, placeId, tagId, [placeId+tagId]', + // ─── Playground (appId: 'playground') ─── + // Saved system-prompt snippets. `name` IS encrypted but no .where('name') + // call site exists — same rationale as files.name / places.name above. + playgroundSnippets: 'id, isPinned, order, [isPinned+order]', + // ─── TimeBlocks (appId: 'timeblocks') — unified time model ─── // Cross-cutting scheduling table that calendar events, time entries, // habit logs and scheduled tasks all project into. See PROD_READINESS diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index c7ccad402..803d52c22 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -82,6 +82,7 @@ import { cyclesModuleConfig } from '$lib/modules/cycles/module.config'; import { eventsModuleConfig } from '$lib/modules/events/module.config'; import { financeModuleConfig } from '$lib/modules/finance/module.config'; import { placesModuleConfig } from '$lib/modules/places/module.config'; +import { playgroundModuleConfig } from '$lib/modules/playground/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ manaCoreConfig, @@ -119,6 +120,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ eventsModuleConfig, financeModuleConfig, placesModuleConfig, + playgroundModuleConfig, ]; // ─── Derived Maps ────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/playground/ListView.svelte b/apps/mana/apps/web/src/lib/modules/playground/ListView.svelte index e2dd37d67..febadb8b9 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/playground/ListView.svelte @@ -1,32 +1,88 @@ @@ -36,21 +92,25 @@ bind:value={selectedModel} class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/70 focus:border-white/20 focus:outline-none" > - {#each PLAYGROUND_MODELS as model} + {#each modelOptions as model} {/each}
- {#each messages as msg, i} + {#each messages as msg}

{msg.role === 'user' ? 'Du' : modelLabel}

-

{msg.content}

+ {#if msg.content} +

{msg.content}

+ {:else} +

+ {/if}
{/each} @@ -74,11 +134,20 @@ placeholder="Prompt eingeben..." class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none" /> - + {#if isLoading} + + {:else} + + {/if}
diff --git a/apps/mana/apps/web/src/lib/modules/playground/collections.ts b/apps/mana/apps/web/src/lib/modules/playground/collections.ts new file mode 100644 index 000000000..e0af6640c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/playground/collections.ts @@ -0,0 +1,12 @@ +/** + * Playground module — collection accessors. + * + * Only one table: snippets (saved system prompts). The chat history + * itself is intentionally NOT persisted — playground is for one-off + * exploration; chat module owns the persisted-conversation surface. + */ + +import { db } from '$lib/data/database'; +import type { LocalPlaygroundSnippet } from './types'; + +export const playgroundSnippetTable = db.table('playgroundSnippets'); diff --git a/apps/mana/apps/web/src/lib/modules/playground/llm.ts b/apps/mana/apps/web/src/lib/modules/playground/llm.ts new file mode 100644 index 000000000..3e1015fb8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/playground/llm.ts @@ -0,0 +1,126 @@ +/** + * Playground LLM client — thin wrapper around mana-llm's OpenAI-compatible + * `/v1/chat/completions` (streaming) and `/v1/models` endpoints. + * + * Lives next to the playground UI rather than in a shared package because + * the playground is the only consumer right now. If chat / todo enrichment + * / cycles insights end up calling the same surface in the future, lift + * this into `$lib/data/llm-client.ts`. + * + * The chunk parser is hand-rolled rather than pulled from a library: the + * SSE wire format from mana-llm is straight OpenAI (`data: {…}\n\n` lines + * with a sentinel `[DONE]`), so a 30-line reader is simpler than a dep. + */ + +const DEFAULT_LLM_URL = 'http://localhost:3025'; + +/** Resolve the mana-llm base URL from the window-injected env, falling + * back to the local-dev default. Mirrors the photos store pattern. */ +function llmUrl(): string { + if (typeof window !== 'undefined') { + const fromWindow = (window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }) + .__PUBLIC_MANA_LLM_URL__; + if (fromWindow) return fromWindow.replace(/\/$/, ''); + } + const fromEnv = import.meta.env.PUBLIC_MANA_LLM_URL as string | undefined; + return (fromEnv || DEFAULT_LLM_URL).replace(/\/$/, ''); +} + +// ─── Models ────────────────────────────────────────────── + +export interface RemoteModel { + id: string; + owned_by: string; +} + +/** Fetch the live model list from mana-llm. Returns an empty array on + * failure — the caller falls back to the hardcoded PLAYGROUND_MODELS so + * the UI never ends up with an empty selector. */ +export async function listModels(): Promise { + try { + const res = await fetch(`${llmUrl()}/v1/models`); + if (!res.ok) return []; + const payload = (await res.json()) as { data?: RemoteModel[] }; + return payload.data ?? []; + } catch { + return []; + } +} + +// ─── Chat completions (streaming) ──────────────────────── + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface CompletionOptions { + model: string; + messages: ChatMessage[]; + temperature?: number; + signal?: AbortSignal; +} + +/** + * Streams a chat completion from mana-llm and yields content deltas as + * they arrive. The caller concatenates deltas into the visible message — + * see `routes/(app)/playground/+page.svelte` for the consumer pattern. + * + * Errors propagate as thrown exceptions (network failure, non-2xx, abort). + * The playground page catches them and renders a friendly fallback rather + * than blanking the conversation. + */ +export async function* streamCompletion(opts: CompletionOptions): AsyncGenerator { + const res = await fetch(`${llmUrl()}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: opts.signal, + body: JSON.stringify({ + model: opts.model, + messages: opts.messages, + temperature: opts.temperature ?? 0.7, + stream: true, + }), + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ''); + throw new Error(`mana-llm: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // SSE frames are separated by blank lines. Process complete frames + // and leave any partial trailing frame in the buffer for the next + // chunk. + let sep: number; + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + + for (const line of frame.split('\n')) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + try { + const json = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string } }>; + }; + const delta = json.choices?.[0]?.delta?.content; + if (delta) yield delta; + } catch { + // Malformed frame — skip silently. mana-llm occasionally + // emits keepalive comments and we don't want them to + // crash the stream. + } + } + } + } +} diff --git a/apps/mana/apps/web/src/lib/modules/playground/module.config.ts b/apps/mana/apps/web/src/lib/modules/playground/module.config.ts new file mode 100644 index 000000000..53786290d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/playground/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const playgroundModuleConfig: ModuleConfig = { + appId: 'playground', + tables: [{ name: 'playgroundSnippets', syncName: 'snippets' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/playground/queries.ts b/apps/mana/apps/web/src/lib/modules/playground/queries.ts new file mode 100644 index 000000000..dd0481c64 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/playground/queries.ts @@ -0,0 +1,45 @@ +/** + * Playground module — reactive queries for snippets. + * + * `name` and `systemPrompt` are encrypted at rest, so the live query + * decrypts the visible set before mapping to the public DTO. Same + * pattern as notes / dreams / places. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import type { LocalPlaygroundSnippet, PlaygroundSnippet } from './types'; + +export function toSnippet(local: LocalPlaygroundSnippet): PlaygroundSnippet { + return { + id: local.id, + name: local.name, + systemPrompt: local.systemPrompt, + model: local.model, + temperature: local.temperature, + isPinned: local.isPinned ?? false, + order: local.order ?? 0, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function useAllSnippets() { + return useLiveQueryWithDefault(async () => { + const locals = await db + .table('playgroundSnippets') + .orderBy('order') + .toArray(); + const visible = locals.filter((s) => !s.deletedAt); + const decrypted = await decryptRecords('playgroundSnippets', visible); + // Pinned first, then by manual order — same convention as notes. + const sorted = decrypted.sort((a, b) => { + const ap = a.isPinned ? 1 : 0; + const bp = b.isPinned ? 1 : 0; + if (ap !== bp) return bp - ap; + return (a.order ?? 0) - (b.order ?? 0); + }); + return sorted.map(toSnippet); + }, [] as PlaygroundSnippet[]); +} diff --git a/apps/mana/apps/web/src/lib/modules/playground/stores/snippets.svelte.ts b/apps/mana/apps/web/src/lib/modules/playground/stores/snippets.svelte.ts new file mode 100644 index 000000000..4e6b6cc13 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/playground/stores/snippets.svelte.ts @@ -0,0 +1,70 @@ +/** + * Playground Snippets Store — Mutation-Only. + * + * Reads live in queries.ts. This store only writes. Both `name` and + * `systemPrompt` are encrypted before hitting Dexie — same pattern as + * notes/dreams. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { playgroundSnippetTable } from '../collections'; +import { toSnippet } from '../queries'; +import type { LocalPlaygroundSnippet, PlaygroundSnippet } from '../types'; + +export const playgroundSnippetsStore = { + async create(input: { + name: string; + systemPrompt: string; + model: string; + temperature: number; + }): Promise { + const now = new Date().toISOString(); + const newLocal: LocalPlaygroundSnippet = { + id: crypto.randomUUID(), + name: input.name, + systemPrompt: input.systemPrompt, + model: input.model, + temperature: input.temperature, + isPinned: false, + order: Date.now(), + createdAt: now, + updatedAt: now, + }; + + // Snapshot the plaintext DTO before encryption mutates the record + // in place — same pattern as places/notes/dreams. + const plaintextSnapshot = toSnippet({ ...newLocal }); + await encryptRecord('playgroundSnippets', newLocal); + await playgroundSnippetTable.add(newLocal); + return plaintextSnapshot; + }, + + async update( + id: string, + patch: Partial> + ): Promise { + const diff: Partial & Record = { + ...patch, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('playgroundSnippets', diff); + await playgroundSnippetTable.update(id, diff); + }, + + async togglePin(id: string): Promise { + const local = await playgroundSnippetTable.get(id); + if (!local) return; + await playgroundSnippetTable.update(id, { + isPinned: !local.isPinned, + updatedAt: new Date().toISOString(), + }); + }, + + async remove(id: string): Promise { + const now = new Date().toISOString(); + await playgroundSnippetTable.update(id, { + deletedAt: now, + updatedAt: now, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/playground/types.ts b/apps/mana/apps/web/src/lib/modules/playground/types.ts new file mode 100644 index 000000000..d4848764d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/playground/types.ts @@ -0,0 +1,39 @@ +/** + * Playground module types. + * + * The playground itself is stateless (no chat history is persisted — + * that's what the chat module is for), but the user can save reusable + * **snippets**: a name, a system prompt, and the model + temperature + * defaults to test it with. Snippets are the only persisted surface in + * the module. + */ + +import type { BaseRecord } from '@mana/local-store'; + +export interface LocalPlaygroundSnippet extends BaseRecord { + /** User-given label, e.g. "JSON extractor" or "Tone of voice". */ + name: string; + /** The actual system prompt text — the thing the user iterates on. */ + systemPrompt: string; + /** Last model the snippet was used with. Used as the default when + * the user clicks the snippet. */ + model: string; + /** Last temperature the snippet was used with (0–2). */ + temperature: number; + /** Pinned snippets sort to the top of the list. */ + isPinned?: boolean; + /** Manual sort order within (pinned / unpinned) groups. */ + order?: number; +} + +export interface PlaygroundSnippet { + id: string; + name: string; + systemPrompt: string; + model: string; + temperature: number; + isPinned: boolean; + order: number; + createdAt: string; + updatedAt: string; +} diff --git a/apps/mana/apps/web/src/routes/(app)/playground/+page.svelte b/apps/mana/apps/web/src/routes/(app)/playground/+page.svelte index 84e7e0e91..59b3be039 100644 --- a/apps/mana/apps/web/src/routes/(app)/playground/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/playground/+page.svelte @@ -1,19 +1,57 @@ @@ -84,7 +200,7 @@ bind:value={selectedModel} class="rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground" > - {#each PLAYGROUND_MODELS as model} + {#each modelOptions as model} {/each} @@ -109,16 +225,102 @@ - +
+ + +
+ + {#if saveOpen} +
{ + e.preventDefault(); + saveCurrentAsSnippet(); + }} + class="mb-4 flex gap-2 rounded-xl border border-border bg-card p-3" + > + + + +
+ {/if} + + + {#if snippets.length > 0} +
+ Snippets: + {#each snippets as snippet (snippet.id)} + + {/each} +
+ {/if} +
{#if messages.length === 0} @@ -143,26 +345,26 @@
{message.role === 'user' ? 'Du' : currentModelLabel}
-
{message.content}
+ {#if message.content} +
{message.content}
+ {:else} + +
+ + + +
+ {/if}
{/each} - - {#if isLoading} -
-
{currentModelLabel}
-
- - - -
-
- {/if} {/if} @@ -176,13 +378,22 @@ rows={2} class="flex-1 resize-none rounded-xl border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none" > - + {#if isLoading} + + {:else} + + {/if}