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:
Till JS 2026-04-08 17:41:18 +02:00
parent d3a1f00072
commit 93748c0c9c
11 changed files with 670 additions and 78 deletions

View file

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

View file

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

View file

@ -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 ──────────────────────────────────────────

View file

@ -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"
>&#9654;</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"
>&#9654;</button
>
{/if}
</form>
</div>

View 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');

View 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.
}
}
}
}
}

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const playgroundModuleConfig: ModuleConfig = {
appId: 'playground',
tables: [{ name: 'playgroundSnippets', syncName: 'snippets' }],
};

View 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[]);
}

View file

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

View 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 (02). */
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;
}

View file

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