mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
28c10246e3
commit
d7663e95b1
9 changed files with 977 additions and 279 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue