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 febadb8b9..1560a6f3c 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/playground/ListView.svelte @@ -58,13 +58,14 @@ isLoading = true; abortController = new AbortController(); try { - for await (const delta of streamCompletion({ + for await (const chunk of streamCompletion({ model: selectedModel, messages: wire, signal: abortController.signal, })) { + if (chunk.type !== 'delta') continue; const next = [...messages]; - next[idx] = { ...next[idx], content: next[idx].content + delta }; + next[idx] = { ...next[idx], content: next[idx].content + chunk.content }; messages = next; } } catch (err) { diff --git a/apps/mana/apps/web/src/lib/modules/playground/collections.ts b/apps/mana/apps/web/src/lib/modules/playground/collections.ts index e0af6640c..dcffef604 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/collections.ts +++ b/apps/mana/apps/web/src/lib/modules/playground/collections.ts @@ -1,12 +1,17 @@ /** * 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. + * Three tables: snippets (saved system prompts), conversations, and messages. */ import { db } from '$lib/data/database'; -import type { LocalPlaygroundSnippet } from './types'; +import type { + LocalPlaygroundSnippet, + LocalPlaygroundConversation, + LocalPlaygroundMessage, +} from './types'; export const playgroundSnippetTable = db.table('playgroundSnippets'); +export const playgroundConversationTable = + db.table('playgroundConversations'); +export const playgroundMessageTable = db.table('playgroundMessages'); diff --git a/apps/mana/apps/web/src/lib/modules/playground/index.ts b/apps/mana/apps/web/src/lib/modules/playground/index.ts index b929b37a9..c6a7405dd 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/index.ts +++ b/apps/mana/apps/web/src/lib/modules/playground/index.ts @@ -1,8 +1,8 @@ /** * Playground module — barrel exports. * - * Stateless LLM playground for testing prompts against different models. - * No local-first collections needed (no persistent data model). + * LLM playground for testing prompts against different models with + * persistent chat history, token tracking, and model comparison. */ export const PLAYGROUND_MODELS = [ diff --git a/apps/mana/apps/web/src/lib/modules/playground/llm.ts b/apps/mana/apps/web/src/lib/modules/playground/llm.ts index 3e1015fb8..a4fd84327 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/llm.ts +++ b/apps/mana/apps/web/src/lib/modules/playground/llm.ts @@ -61,16 +61,19 @@ export interface CompletionOptions { signal?: AbortSignal; } +/** A chunk yielded during streaming — either a content delta or final usage stats. */ +export type StreamChunk = + | { type: 'delta'; content: string } + | { type: 'usage'; promptTokens: number; completionTokens: number }; + /** - * 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. + * Streams a chat completion from mana-llm and yields StreamChunks as + * they arrive. Content deltas have `type: 'delta'`, and the final usage + * stats (if the provider includes them) have `type: 'usage'`. * * 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 { +export async function* streamCompletion(opts: CompletionOptions): AsyncGenerator { const res = await fetch(`${llmUrl()}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -112,9 +115,19 @@ export async function* streamCompletion(opts: CompletionOptions): AsyncGenerator try { const json = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; }; const delta = json.choices?.[0]?.delta?.content; - if (delta) yield delta; + if (delta) yield { type: 'delta', content: delta }; + + // Usage stats — typically in the final chunk + if (json.usage?.prompt_tokens != null) { + yield { + type: 'usage', + promptTokens: json.usage.prompt_tokens, + completionTokens: json.usage.completion_tokens ?? 0, + }; + } } catch { // Malformed frame — skip silently. mana-llm occasionally // emits keepalive comments and we don't want them to 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 index 53786290d..d38874d5c 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/module.config.ts +++ b/apps/mana/apps/web/src/lib/modules/playground/module.config.ts @@ -2,5 +2,9 @@ import type { ModuleConfig } from '$lib/data/module-registry'; export const playgroundModuleConfig: ModuleConfig = { appId: 'playground', - tables: [{ name: 'playgroundSnippets', syncName: 'snippets' }], + tables: [ + { name: 'playgroundSnippets', syncName: 'snippets' }, + { name: 'playgroundConversations', syncName: 'conversations' }, + { name: 'playgroundMessages', syncName: 'messages' }, + ], }; diff --git a/apps/mana/apps/web/src/lib/modules/playground/queries.ts b/apps/mana/apps/web/src/lib/modules/playground/queries.ts index dd0481c64..d686a762c 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/playground/queries.ts @@ -9,7 +9,14 @@ 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'; +import type { + LocalPlaygroundSnippet, + PlaygroundSnippet, + LocalPlaygroundConversation, + PlaygroundConversation, + LocalPlaygroundMessage, + PlaygroundConversationMessage, +} from './types'; export function toSnippet(local: LocalPlaygroundSnippet): PlaygroundSnippet { return { @@ -43,3 +50,62 @@ export function useAllSnippets() { return sorted.map(toSnippet); }, [] as PlaygroundSnippet[]); } + +// ─── Conversations ────────────────────────────────────── + +export function toConversation(local: LocalPlaygroundConversation): PlaygroundConversation { + return { + id: local.id, + title: local.title, + model: local.model, + systemPrompt: local.systemPrompt, + temperature: local.temperature, + snippetId: local.snippetId, + isPinned: local.isPinned ?? false, + comparisonModels: local.comparisonModels ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toMessage(local: LocalPlaygroundMessage): PlaygroundConversationMessage { + return { + id: local.id, + conversationId: local.conversationId, + role: local.role, + content: local.content, + model: local.model, + promptTokens: local.promptTokens, + completionTokens: local.completionTokens, + comparisonGroupId: local.comparisonGroupId, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + }; +} + +export function useAllConversations() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('playgroundConversations').toArray(); + const visible = locals.filter((c) => !c.deletedAt); + const decrypted = await decryptRecords( + 'playgroundConversations', + visible + ); + return decrypted.map(toConversation).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + }, [] as PlaygroundConversation[]); +} + +export function useConversationMessages(conversationId: () => string | null) { + return useLiveQueryWithDefault(async () => { + const cid = conversationId(); + if (!cid) return []; + const locals = await db + .table('playgroundMessages') + .where('conversationId') + .equals(cid) + .sortBy('order'); + const visible = locals.filter((m) => !m.deletedAt); + const decrypted = await decryptRecords('playgroundMessages', visible); + return decrypted.map(toMessage); + }, [] as PlaygroundConversationMessage[]); +} diff --git a/apps/mana/apps/web/src/lib/modules/playground/stores/conversations.svelte.ts b/apps/mana/apps/web/src/lib/modules/playground/stores/conversations.svelte.ts new file mode 100644 index 000000000..445976bfa --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/playground/stores/conversations.svelte.ts @@ -0,0 +1,89 @@ +/** + * Conversations Store — Mutations for playground conversations + messages. + * + * Reads come from liveQuery hooks in queries.ts. + */ + +import { playgroundConversationTable, playgroundMessageTable } from '../collections'; +import { toConversation, toMessage } from '../queries'; +import type { LocalPlaygroundConversation, LocalPlaygroundMessage } from '../types'; +import { encryptRecord } from '$lib/data/crypto'; + +export const conversationsStore = { + async create(data: { + model: string; + systemPrompt: string; + temperature: number; + snippetId?: string; + comparisonModels?: string[]; + }) { + const now = new Date().toISOString(); + const newLocal: LocalPlaygroundConversation = { + id: crypto.randomUUID(), + title: null, + model: data.model, + systemPrompt: data.systemPrompt, + temperature: data.temperature, + snippetId: data.snippetId ?? null, + isPinned: false, + comparisonModels: data.comparisonModels ?? null, + createdAt: now, + updatedAt: now, + }; + const snapshot = toConversation(newLocal); + await encryptRecord('playgroundConversations', newLocal); + await playgroundConversationTable.add(newLocal); + return snapshot; + }, + + async updateTitle(id: string, title: string) { + const diff: Partial = { + title, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('playgroundConversations', diff); + await playgroundConversationTable.update(id, diff); + }, + + async touch(id: string) { + await playgroundConversationTable.update(id, { + updatedAt: new Date().toISOString(), + }); + }, + + async remove(id: string) { + const now = new Date().toISOString(); + await playgroundConversationTable.update(id, { deletedAt: now, updatedAt: now }); + }, + + async addMessage( + conversationId: string, + data: { + role: 'user' | 'assistant'; + content: string; + model?: string; + promptTokens?: number; + completionTokens?: number; + comparisonGroupId?: string; + order: number; + } + ) { + const now = new Date().toISOString(); + const newLocal: LocalPlaygroundMessage = { + id: crypto.randomUUID(), + conversationId, + role: data.role, + content: data.content, + model: data.model ?? null, + promptTokens: data.promptTokens ?? null, + completionTokens: data.completionTokens ?? null, + comparisonGroupId: data.comparisonGroupId ?? null, + order: data.order, + createdAt: now, + }; + const snapshot = toMessage(newLocal); + await encryptRecord('playgroundMessages', newLocal); + await playgroundMessageTable.add(newLocal); + return snapshot; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/playground/types.ts b/apps/mana/apps/web/src/lib/modules/playground/types.ts index d4848764d..7b55fe02e 100644 --- a/apps/mana/apps/web/src/lib/modules/playground/types.ts +++ b/apps/mana/apps/web/src/lib/modules/playground/types.ts @@ -1,11 +1,11 @@ /** * 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. + * Two persisted surfaces: + * 1. **Snippets** — saved system-prompt templates (name, model, temperature) + * 2. **Conversations** + **Messages** — full chat history that survives reload + * + * Conversations and messages are encrypted at rest (content, title, systemPrompt). */ import type { BaseRecord } from '@mana/local-store'; @@ -37,3 +37,57 @@ export interface PlaygroundSnippet { createdAt: string; updatedAt: string; } + +// ─── Conversations ────────────────────────────────────── + +export interface LocalPlaygroundConversation extends BaseRecord { + title: string | null; + model: string; + systemPrompt: string; + temperature: number; + snippetId: string | null; + isPinned?: boolean; + /** When non-null, the conversation is in comparison mode. */ + comparisonModels: string[] | null; +} + +export interface PlaygroundConversation { + id: string; + title: string | null; + model: string; + systemPrompt: string; + temperature: number; + snippetId: string | null; + isPinned: boolean; + comparisonModels: string[] | null; + createdAt: string; + updatedAt: string; +} + +// ─── Messages ─────────────────────────────────────────── + +export interface LocalPlaygroundMessage extends BaseRecord { + conversationId: string; + role: 'user' | 'assistant' | 'system'; + content: string; + /** Which model produced this response (null for user messages). */ + model: string | null; + promptTokens: number | null; + completionTokens: number | null; + /** Ties together N assistant responses from a single comparison round. */ + comparisonGroupId: string | null; + order: number; +} + +export interface PlaygroundConversationMessage { + id: string; + conversationId: string; + role: 'user' | 'assistant' | 'system'; + content: string; + model: string | null; + promptTokens: number | null; + completionTokens: number | null; + comparisonGroupId: string | null; + order: number; + createdAt: 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 59b3be039..0651a38f0 100644 --- a/apps/mana/apps/web/src/routes/(app)/playground/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/playground/+page.svelte @@ -1,21 +1,49 @@ Playground - Mana -
- -
-
-

Playground

-

LLM-Modelle testen & vergleichen

-
- -
- - -
-
- - -
+
+ + {/if} -
- - -
+ +
+ +
+
+ +
+

Playground

+

LLM-Modelle testen & vergleichen

+
+
+
-
- -
+ +
+
+ + +
+ +
+ +
+ +
+ +
+ + +
+
+ + +
+ Vergleich + +
+
+ + + {#if comparisonMode} +
+ Modelle vergleichen (2-4): +
+ {#each modelOptions as model} + + {/each} +
+
+ {/if} + + + {#if saveOpen} +
{ + e.preventDefault(); + saveCurrentAsSnippet(); + }} + class="mb-4 flex gap-2 rounded-xl border border-border bg-card p-3" + > + -
-
-
- - - {#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} - -
- {#if messages.length === 0} -
-
- -
-

Bereit zum Testen

-

- Wähle ein Modell, schreibe einen Prompt und teste verschiedene LLMs. Aktuell: {currentModelLabel} + {#if snippets.length > 0} +

+ Snippets: + {#each snippets as snippet (snippet.id)} + + {/each}
- {:else} - {#each messages as message} -
-
- {message.role === 'user' ? 'Du' : currentModelLabel} + {/if} + + +
+ {#if displayMessages.length === 0 && !streamingContent && comparisonStreams.length === 0} +
+
+
- {#if message.content} -
{message.content}
+

Bereit zum Testen

+

+ Wähle ein Modell, schreibe einen Prompt und teste verschiedene LLMs. Aktuell: {currentModelLabel} +

+
+ {:else} + {#each displayMessages as message (message.id)} + {@const tokens = formatTokens(message)} + {@const isComparisonMsg = !!message.comparisonGroupId} + {@const group = isComparisonMsg ? comparisonGroups.get(message.comparisonGroupId!) : null} + {@const isFirstInGroup = isComparisonMsg && group ? group[0]?.id === message.id : false} + + {#if isComparisonMsg && !isFirstInGroup} + + {:else if isComparisonMsg && isFirstInGroup && group} + +
+ {#each group as gMsg (gMsg.id)} + {@const gTokens = formatTokens(gMsg)} +
+
+ {gMsg.model ?? 'Assistent'} +
+
+ {gMsg.content} +
+ {#if gTokens} +
{gTokens}
+ {/if} +
+ {/each} +
{:else} - -
- - - + +
+
+ {message.role === 'user' ? 'Du' : (message.model ?? currentModelLabel)} +
+
+ {message.content} +
+ {#if tokens} +
{tokens}
+ {/if}
{/if} -
- {/each} - {/if} -
+ {/each} - -
-
- - {#if isLoading} - - {:else} - + + {#if streamingContent || (isLoading && !comparisonMode)} +
+
+ {streamingModel || currentModelLabel} +
+ {#if streamingContent} +
+ {streamingContent} +
+ {:else} +
+ + + +
+ {/if} +
+ {/if} + + + {#if comparisonStreams.length > 0} +
+ {#each comparisonStreams as stream} +
+
+ {stream.model} +
+ {#if stream.content} +
+ {stream.content} +
+ {:else} +
+ + + +
+ {/if} +
+ {/each} +
+ {/if} {/if}
+ + +
+
+ + {#if isLoading} + + {:else} + + {/if} +
+
+ +