feat(playground): persistent chat history, token display, model comparison

Three major features for the LLM playground module:

1. Chat history persistence — conversations and messages are saved to
   IndexedDB (encrypted at rest), survive page reload, and sync via
   mana-sync. Sidebar shows conversation list with load/delete. Auto-
   titles from first user message. Lazy conversation creation on first
   send.

2. Token/usage display — llm.ts now yields a StreamChunk union type
   (delta | usage). Token counts (prompt + completion) are shown beneath
   each assistant message and persisted per message record.

3. Model comparison — toggle comparison mode in the config bar, select
   2-4 models, and see responses streamed side-by-side in a CSS grid.
   Each comparison round is tied by a comparisonGroupId. All streams
   have independent AbortControllers. Follow-up messages use the first
   model's response as conversation context.

New files: stores/conversations.svelte.ts
New tables: playgroundConversations, playgroundMessages (encrypted)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 18:02:27 +02:00
parent 28c10246e3
commit d7663e95b1
9 changed files with 977 additions and 279 deletions

View file

@ -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) {

View file

@ -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<LocalPlaygroundSnippet>('playgroundSnippets');
export const playgroundConversationTable =
db.table<LocalPlaygroundConversation>('playgroundConversations');
export const playgroundMessageTable = db.table<LocalPlaygroundMessage>('playgroundMessages');

View file

@ -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 = [

View file

@ -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<string> {
export async function* streamCompletion(opts: CompletionOptions): AsyncGenerator<StreamChunk> {
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

View file

@ -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' },
],
};

View file

@ -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<LocalPlaygroundConversation>('playgroundConversations').toArray();
const visible = locals.filter((c) => !c.deletedAt);
const decrypted = await decryptRecords<LocalPlaygroundConversation>(
'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<LocalPlaygroundMessage>('playgroundMessages')
.where('conversationId')
.equals(cid)
.sortBy('order');
const visible = locals.filter((m) => !m.deletedAt);
const decrypted = await decryptRecords<LocalPlaygroundMessage>('playgroundMessages', visible);
return decrypted.map(toMessage);
}, [] as PlaygroundConversationMessage[]);
}

View file

@ -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<LocalPlaygroundConversation> = {
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;
},
};

View file

@ -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;
}

File diff suppressed because it is too large Load diff