mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(playground): real LLM playground module backed by mana-llm + saved snippets
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) <noreply@anthropic.com>
This commit is contained in:
parent
d3a1f00072
commit
93748c0c9c
11 changed files with 670 additions and 78 deletions
|
|
@ -264,6 +264,13 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,32 +1,88 @@
|
|||
<!--
|
||||
Playground — Workbench ListView
|
||||
Minimal LLM prompt interface with model selector.
|
||||
Playground — Workbench ListView (embedded variant).
|
||||
|
||||
Compact version of the standalone /playground page, used by the module
|
||||
registry when the playground is mounted inside split-screen / app-grid
|
||||
containers. Both UIs share the same backend wrapper in `./llm.ts` so
|
||||
there's no risk of one drifting from the other.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { PLAYGROUND_MODELS, type PlaygroundMessage } from './index';
|
||||
import { listModels, streamCompletion, type ChatMessage } from './llm';
|
||||
|
||||
let selectedModel = $state(PLAYGROUND_MODELS[0].id);
|
||||
type ModelOption = { id: string; label: string; provider: string };
|
||||
const fallbackOptions: ModelOption[] = PLAYGROUND_MODELS.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
provider: m.provider,
|
||||
}));
|
||||
|
||||
let modelOptions = $state<ModelOption[]>(fallbackOptions);
|
||||
let selectedModel = $state<string>(fallbackOptions[0].id);
|
||||
let prompt = $state('');
|
||||
let messages = $state<PlaygroundMessage[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const modelLabel = $derived(
|
||||
PLAYGROUND_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel
|
||||
modelOptions.find((m) => m.id === selectedModel)?.label ?? selectedModel
|
||||
);
|
||||
|
||||
function send() {
|
||||
onMount(async () => {
|
||||
const remote = await listModels();
|
||||
if (remote.length === 0) return;
|
||||
modelOptions = remote.map((m) => ({ id: m.id, label: m.id, provider: m.owned_by }));
|
||||
if (!modelOptions.some((m) => m.id === selectedModel)) {
|
||||
selectedModel = modelOptions[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
async function send() {
|
||||
if (!prompt.trim() || isLoading) return;
|
||||
messages = [...messages, { role: 'user', content: prompt, timestamp: Date.now() }];
|
||||
// Placeholder — actual API integration happens in full app
|
||||
messages = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '(Playground-Antwort — verbinde mit mana-llm)',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const userMsg: PlaygroundMessage = { role: 'user', content: prompt, timestamp: Date.now() };
|
||||
messages = [...messages, userMsg];
|
||||
prompt = '';
|
||||
|
||||
const wire: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
|
||||
|
||||
const placeholder: PlaygroundMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
messages = [...messages, placeholder];
|
||||
const idx = messages.length - 1;
|
||||
|
||||
isLoading = true;
|
||||
abortController = new AbortController();
|
||||
try {
|
||||
for await (const delta of streamCompletion({
|
||||
model: selectedModel,
|
||||
messages: wire,
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
const next = [...messages];
|
||||
next[idx] = { ...next[idx], content: next[idx].content + delta };
|
||||
messages = next;
|
||||
}
|
||||
} catch (err) {
|
||||
const next = [...messages];
|
||||
const reason = err instanceof Error ? err.message : 'Fehler';
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
content: next[idx].content || `⚠ ${reason}`,
|
||||
};
|
||||
messages = next;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
abortController?.abort();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -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}
|
||||
<option value={model.id} class="bg-neutral-900">{model.label} ({model.provider})</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#each messages as msg, i}
|
||||
{#each messages as msg}
|
||||
<div
|
||||
class="mb-2 min-h-[44px] rounded-md px-3 py-2 {msg.role === 'user'
|
||||
? 'bg-white/5'
|
||||
: 'bg-blue-500/10'}"
|
||||
>
|
||||
<p class="text-[10px] text-white/30">{msg.role === 'user' ? 'Du' : modelLabel}</p>
|
||||
<p class="text-sm text-white/70">{msg.content}</p>
|
||||
{#if msg.content}
|
||||
<p class="whitespace-pre-wrap text-sm text-white/70">{msg.content}</p>
|
||||
{:else}
|
||||
<p class="text-sm text-white/30">…</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="rounded-md bg-white/10 px-3 py-1.5 text-sm text-white/70 transition-colors hover:bg-white/15 disabled:opacity-50"
|
||||
>▶</button
|
||||
>
|
||||
{#if isLoading}
|
||||
<button
|
||||
type="button"
|
||||
onclick={stop}
|
||||
class="rounded-md bg-red-500/20 px-3 py-1.5 text-sm text-red-200 transition-colors hover:bg-red-500/30"
|
||||
>Stop</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!prompt.trim()}
|
||||
class="rounded-md bg-white/10 px-3 py-1.5 text-sm text-white/70 transition-colors hover:bg-white/15 disabled:opacity-50"
|
||||
>▶</button
|
||||
>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
12
apps/mana/apps/web/src/lib/modules/playground/collections.ts
Normal file
12
apps/mana/apps/web/src/lib/modules/playground/collections.ts
Normal file
|
|
@ -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<LocalPlaygroundSnippet>('playgroundSnippets');
|
||||
126
apps/mana/apps/web/src/lib/modules/playground/llm.ts
Normal file
126
apps/mana/apps/web/src/lib/modules/playground/llm.ts
Normal file
|
|
@ -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<RemoteModel[]> {
|
||||
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<string> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const playgroundModuleConfig: ModuleConfig = {
|
||||
appId: 'playground',
|
||||
tables: [{ name: 'playgroundSnippets', syncName: 'snippets' }],
|
||||
};
|
||||
45
apps/mana/apps/web/src/lib/modules/playground/queries.ts
Normal file
45
apps/mana/apps/web/src/lib/modules/playground/queries.ts
Normal file
|
|
@ -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<LocalPlaygroundSnippet>('playgroundSnippets')
|
||||
.orderBy('order')
|
||||
.toArray();
|
||||
const visible = locals.filter((s) => !s.deletedAt);
|
||||
const decrypted = await decryptRecords<LocalPlaygroundSnippet>('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[]);
|
||||
}
|
||||
|
|
@ -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<PlaygroundSnippet> {
|
||||
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<Pick<PlaygroundSnippet, 'name' | 'systemPrompt' | 'model' | 'temperature'>>
|
||||
): Promise<void> {
|
||||
const diff: Partial<LocalPlaygroundSnippet> & Record<string, unknown> = {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('playgroundSnippets', diff);
|
||||
await playgroundSnippetTable.update(id, diff);
|
||||
},
|
||||
|
||||
async togglePin(id: string): Promise<void> {
|
||||
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<void> {
|
||||
const now = new Date().toISOString();
|
||||
await playgroundSnippetTable.update(id, {
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
};
|
||||
39
apps/mana/apps/web/src/lib/modules/playground/types.ts
Normal file
39
apps/mana/apps/web/src/lib/modules/playground/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,19 +1,57 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
PLAYGROUND_MODELS,
|
||||
type PlaygroundModel,
|
||||
type PlaygroundMessage,
|
||||
} from '$lib/modules/playground';
|
||||
import { PaperPlaneRight, Trash, Robot } from '@mana/shared-icons';
|
||||
import { onMount } from 'svelte';
|
||||
import { PLAYGROUND_MODELS, type PlaygroundMessage } from '$lib/modules/playground';
|
||||
import { listModels, streamCompletion, type ChatMessage } from '$lib/modules/playground/llm';
|
||||
import { useAllSnippets } from '$lib/modules/playground/queries';
|
||||
import { playgroundSnippetsStore } from '$lib/modules/playground/stores/snippets.svelte';
|
||||
import type { PlaygroundSnippet } from '$lib/modules/playground/types';
|
||||
import { PaperPlaneRight, Trash, Robot, FloppyDisk, PushPin, X } from '@mana/shared-icons';
|
||||
|
||||
let selectedModel: PlaygroundModel = $state('claude-sonnet');
|
||||
const snippets$ = useAllSnippets();
|
||||
const snippets = $derived(snippets$.value);
|
||||
|
||||
let snippetName = $state('');
|
||||
let saveOpen = $state(false);
|
||||
|
||||
// Model list is dynamic — fetched from mana-llm/v1/models on mount.
|
||||
// We seed with the hardcoded fallback so the selector is never empty
|
||||
// during the first paint or when the service is unreachable.
|
||||
type ModelOption = { id: string; label: string; provider: string };
|
||||
const fallbackOptions: ModelOption[] = PLAYGROUND_MODELS.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
provider: m.provider,
|
||||
}));
|
||||
|
||||
let modelOptions = $state<ModelOption[]>(fallbackOptions);
|
||||
let selectedModel = $state<string>(fallbackOptions[0].id);
|
||||
let systemPrompt = $state('');
|
||||
let userInput = $state('');
|
||||
let messages: PlaygroundMessage[] = $state([]);
|
||||
let messages = $state<PlaygroundMessage[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let temperature = $state(0.7);
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
function handleSend() {
|
||||
onMount(async () => {
|
||||
const remote = await listModels();
|
||||
if (remote.length === 0) return;
|
||||
modelOptions = remote.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.id,
|
||||
provider: m.owned_by,
|
||||
}));
|
||||
// Keep the previously-selected model if it still exists, otherwise
|
||||
// fall back to the first one in the live list.
|
||||
if (!modelOptions.some((m) => m.id === selectedModel)) {
|
||||
selectedModel = modelOptions[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
const currentModelLabel = $derived(
|
||||
modelOptions.find((m) => m.id === selectedModel)?.label ?? selectedModel
|
||||
);
|
||||
|
||||
async function handleSend() {
|
||||
if (!userInput.trim() || isLoading) return;
|
||||
|
||||
const userMessage: PlaygroundMessage = {
|
||||
|
|
@ -24,20 +62,102 @@
|
|||
messages = [...messages, userMessage];
|
||||
userInput = '';
|
||||
|
||||
// Simulate response (real API integration comes later)
|
||||
// Build the wire-format message list. System prompt is optional;
|
||||
// only include it when the user has actually typed one.
|
||||
const wire: ChatMessage[] = [];
|
||||
if (systemPrompt.trim()) {
|
||||
wire.push({ role: 'system', content: systemPrompt.trim() });
|
||||
}
|
||||
for (const m of messages) {
|
||||
wire.push({ role: m.role, content: m.content });
|
||||
}
|
||||
|
||||
// Push an empty assistant placeholder that the stream fills in.
|
||||
// Keeping it in the array (instead of a separate `streaming` slot)
|
||||
// means the UI render path stays the same for in-flight and final
|
||||
// messages.
|
||||
const assistantMessage: PlaygroundMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
messages = [...messages, assistantMessage];
|
||||
const assistantIdx = messages.length - 1;
|
||||
|
||||
isLoading = true;
|
||||
setTimeout(() => {
|
||||
const assistantMessage: PlaygroundMessage = {
|
||||
role: 'assistant',
|
||||
content: `[${selectedModel}] Playground ist noch nicht mit dem Backend verbunden. Konfiguriere MANA_LLM_URL um Antworten zu erhalten.`,
|
||||
timestamp: Date.now(),
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
for await (const delta of streamCompletion({
|
||||
model: selectedModel,
|
||||
messages: wire,
|
||||
temperature,
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
const next = [...messages];
|
||||
next[assistantIdx] = { ...next[assistantIdx], content: next[assistantIdx].content + delta };
|
||||
messages = next;
|
||||
}
|
||||
} catch (err) {
|
||||
const next = [...messages];
|
||||
const reason = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
const existing = next[assistantIdx].content;
|
||||
next[assistantIdx] = {
|
||||
...next[assistantIdx],
|
||||
content: existing
|
||||
? `${existing}\n\n⚠ Stream abgebrochen: ${reason}`
|
||||
: `⚠ ${reason}\n\nIst mana-llm erreichbar (PUBLIC_MANA_LLM_URL)?`,
|
||||
};
|
||||
messages = [...messages, assistantMessage];
|
||||
messages = next;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}, 800);
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
abortController?.abort();
|
||||
}
|
||||
|
||||
// ─── Snippets ────────────────────────────────────────────
|
||||
|
||||
async function saveCurrentAsSnippet() {
|
||||
const name = snippetName.trim();
|
||||
const body = systemPrompt.trim();
|
||||
if (!name || !body) return;
|
||||
await playgroundSnippetsStore.create({
|
||||
name,
|
||||
systemPrompt: body,
|
||||
model: selectedModel,
|
||||
temperature,
|
||||
});
|
||||
snippetName = '';
|
||||
saveOpen = false;
|
||||
}
|
||||
|
||||
function loadSnippet(snippet: PlaygroundSnippet) {
|
||||
systemPrompt = snippet.systemPrompt;
|
||||
// Only adopt the snippet's model if we actually have it in the
|
||||
// current options list — otherwise the selector would silently
|
||||
// jump to a model that's not available on this mana-llm instance.
|
||||
if (modelOptions.some((m) => m.id === snippet.model)) {
|
||||
selectedModel = snippet.model;
|
||||
}
|
||||
temperature = snippet.temperature;
|
||||
}
|
||||
|
||||
async function deleteSnippet(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
await playgroundSnippetsStore.remove(id);
|
||||
}
|
||||
|
||||
async function togglePinSnippet(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
await playgroundSnippetsStore.togglePin(id);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
abortController?.abort();
|
||||
messages = [];
|
||||
systemPrompt = '';
|
||||
userInput = '';
|
||||
|
|
@ -49,10 +169,6 @@
|
|||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
const currentModelLabel = $derived(
|
||||
PLAYGROUND_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -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}
|
||||
<option value={model.id}>{model.label} ({model.provider})</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
|
@ -109,16 +225,102 @@
|
|||
<label for="system-prompt" class="text-xs font-medium text-muted-foreground">
|
||||
System Prompt
|
||||
</label>
|
||||
<input
|
||||
id="system-prompt"
|
||||
type="text"
|
||||
bind:value={systemPrompt}
|
||||
placeholder="Optional: System-Anweisung..."
|
||||
class="rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="system-prompt"
|
||||
type="text"
|
||||
bind:value={systemPrompt}
|
||||
placeholder="Optional: System-Anweisung..."
|
||||
class="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (saveOpen = !saveOpen)}
|
||||
disabled={!systemPrompt.trim()}
|
||||
title="Als Snippet speichern"
|
||||
class="flex items-center gap-1 rounded-lg border border-border px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save snippet inline form (toggled by FloppyDisk button) -->
|
||||
{#if saveOpen}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveCurrentAsSnippet();
|
||||
}}
|
||||
class="mb-4 flex gap-2 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<input
|
||||
bind:value={snippetName}
|
||||
placeholder="Snippet-Name (z.B. 'JSON-Extraktor')"
|
||||
class="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!snippetName.trim()}
|
||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (saveOpen = false)}
|
||||
class="rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Saved snippets row — pill list, click to load, hover for pin/delete -->
|
||||
{#if snippets.length > 0}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-medium text-muted-foreground">Snippets:</span>
|
||||
{#each snippets as snippet (snippet.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => loadSnippet(snippet)}
|
||||
title={snippet.systemPrompt}
|
||||
class="group flex items-center gap-1 rounded-full border border-border bg-card px-3 py-1 text-xs text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{#if snippet.isPinned}
|
||||
<PushPin size={10} weight="fill" class="text-primary" />
|
||||
{/if}
|
||||
<span class="max-w-[140px] truncate">{snippet.name}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => togglePinSnippet(e, snippet.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') togglePinSnippet(e as unknown as MouseEvent, snippet.id);
|
||||
}}
|
||||
title={snippet.isPinned ? 'Lösen' : 'Pinnen'}
|
||||
class="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-background group-hover:opacity-60"
|
||||
>
|
||||
<PushPin size={10} />
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => deleteSnippet(e, snippet.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') deleteSnippet(e as unknown as MouseEvent, snippet.id);
|
||||
}}
|
||||
title="Löschen"
|
||||
class="rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/20 hover:text-destructive group-hover:opacity-60"
|
||||
>
|
||||
<X size={10} />
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 space-y-4 overflow-y-auto pb-4">
|
||||
{#if messages.length === 0}
|
||||
|
|
@ -143,26 +345,26 @@
|
|||
<div class="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{message.role === 'user' ? 'Du' : currentModelLabel}
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap text-sm text-foreground">{message.content}</div>
|
||||
{#if message.content}
|
||||
<div class="whitespace-pre-wrap text-sm text-foreground">{message.content}</div>
|
||||
{:else}
|
||||
<!-- Empty placeholder while waiting for the first delta. The
|
||||
bubble is already in the list, so the loading state lives
|
||||
inline rather than as a separate row. -->
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]"
|
||||
></span>
|
||||
<span
|
||||
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]"
|
||||
></span>
|
||||
<span
|
||||
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="mr-8 rounded-xl border border-border bg-card p-4">
|
||||
<div class="mb-1 text-xs font-medium text-muted-foreground">{currentModelLabel}</div>
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]"
|
||||
></span>
|
||||
<span
|
||||
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]"
|
||||
></span>
|
||||
<span
|
||||
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -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"
|
||||
></textarea>
|
||||
<button
|
||||
onclick={handleSend}
|
||||
disabled={!userInput.trim() || isLoading}
|
||||
class="flex items-center gap-2 self-end rounded-xl bg-primary px-4 py-3 text-sm font-medium text-primary-foreground transition-opacity disabled:opacity-50"
|
||||
>
|
||||
<PaperPlaneRight size={18} />
|
||||
</button>
|
||||
{#if isLoading}
|
||||
<button
|
||||
onclick={handleStop}
|
||||
class="flex items-center gap-2 self-end rounded-xl bg-destructive px-4 py-3 text-sm font-medium text-destructive-foreground transition-opacity"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleSend}
|
||||
disabled={!userInput.trim()}
|
||||
class="flex items-center gap-2 self-end rounded-xl bg-primary px-4 py-3 text-sm font-medium text-primary-foreground transition-opacity disabled:opacity-50"
|
||||
>
|
||||
<PaperPlaneRight size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue