feat(manacore/web): add calendar event parser/estimator and LLM test page

Add natural language event parser and duration estimator utilities for calendar
module. Add /llm-test page for testing local LLM inference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 11:13:22 +02:00
parent 9c0613d920
commit 249cbc97a0
3 changed files with 1049 additions and 0 deletions

View file

@ -0,0 +1,218 @@
/**
* Event Duration Estimator & Conflict Detector
*
* Duration: suggests event duration based on historical events
* using weighted similarity (calendar, title overlap, tags).
*
* Conflict: checks for overlapping events in a given time range.
*
* Both run fully offline against local IndexedDB data.
*/
export interface HistoricalEventData {
title: string;
calendarId?: string | null;
startDate: string;
endDate: string;
allDay?: boolean;
tagIds?: string[];
}
export interface DurationEstimate {
minutes: number;
confidence: 'low' | 'medium' | 'high';
sampleSize: number;
}
const STOP_WORDS = new Set([
'der',
'die',
'das',
'ein',
'eine',
'und',
'oder',
'für',
'mit',
'von',
'zu',
'im',
'am',
'an',
'auf',
'in',
'den',
'dem',
'des',
'bei',
'nach',
'the',
'a',
'an',
'and',
'or',
'for',
'with',
'from',
'to',
'in',
'on',
'at',
]);
function tokenize(title: string): string[] {
return title
.toLowerCase()
.replace(/[^a-zäöüßàáâãèéêëìíîïòóôõùúûü0-9\s]/g, '')
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
}
function titleOverlap(a: string[], b: string[]): number {
if (a.length === 0 || b.length === 0) return 0;
const setB = new Set(b);
const shared = a.filter((w) => setB.has(w)).length;
return shared / Math.max(a.length, b.length);
}
function getEventDuration(event: HistoricalEventData): number | null {
if (event.allDay) return null;
const start = new Date(event.startDate).getTime();
const end = new Date(event.endDate).getTime();
const minutes = (end - start) / 60_000;
if (minutes >= 5 && minutes <= 720) return Math.round(minutes);
return null;
}
function similarity(
newEvent: { title: string; calendarId?: string | null; tagIds?: string[] },
historical: HistoricalEventData,
newTokens: string[]
): number {
let score = 0;
if (
newEvent.calendarId &&
historical.calendarId &&
newEvent.calendarId === historical.calendarId
) {
score += 3;
}
if (newEvent.tagIds && historical.tagIds) {
const histSet = new Set(historical.tagIds);
const shared = newEvent.tagIds.filter((id) => histSet.has(id)).length;
score += shared * 2;
}
const histTokens = tokenize(historical.title);
const overlap = titleOverlap(newTokens, histTokens);
if (overlap > 0.5) score += 4;
else if (overlap > 0.2) score += 2;
else if (overlap > 0) score += 1;
return score;
}
function roundToNice(minutes: number): number {
if (minutes <= 10) return Math.round(minutes / 5) * 5 || 5;
if (minutes <= 30) return Math.round(minutes / 5) * 5;
if (minutes <= 60) return Math.round(minutes / 15) * 15;
if (minutes <= 240) return Math.round(minutes / 30) * 30;
return Math.round(minutes / 60) * 60;
}
export function estimateEventDuration(
newEvent: { title: string; calendarId?: string | null; tagIds?: string[] },
history: HistoricalEventData[],
minSamples = 3
): DurationEstimate | null {
const newTokens = tokenize(newEvent.title);
const scored: { duration: number; score: number }[] = [];
for (const event of history) {
const duration = getEventDuration(event);
if (duration === null) continue;
const score = similarity(newEvent, event, newTokens);
if (score > 0) scored.push({ duration, score });
}
if (scored.length < minSamples) return null;
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, 20);
let totalWeight = 0;
let totalDuration = 0;
for (const { duration, score } of top) {
totalWeight += score;
totalDuration += duration * score;
}
const minutes = roundToNice(Math.round(totalDuration / totalWeight));
const maxScore = top[0].score;
const confidence: DurationEstimate['confidence'] =
top.length >= 10 && maxScore >= 5
? 'high'
: top.length >= 5 && maxScore >= 3
? 'medium'
: 'low';
return { minutes, confidence, sampleSize: top.length };
}
// ── Conflict Detection ───────────────────────────────────
export interface ConflictingEvent {
id: string;
title: string;
startDate: string;
endDate: string;
calendarId: string;
}
export interface ConflictResult {
hasConflict: boolean;
conflicts: ConflictingEvent[];
}
export function detectConflicts(
startDate: string | Date,
endDate: string | Date,
existingEvents: {
id: string;
title: string;
startDate: string;
endDate: string;
calendarId: string;
allDay?: boolean;
}[],
excludeEventId?: string
): ConflictResult {
const newStart = new Date(startDate).getTime();
const newEnd = new Date(endDate).getTime();
if (newStart >= newEnd) return { hasConflict: false, conflicts: [] };
const conflicts: ConflictingEvent[] = [];
for (const event of existingEvents) {
if (event.id === excludeEventId) continue;
if (event.allDay) continue;
const eventStart = new Date(event.startDate).getTime();
const eventEnd = new Date(event.endDate).getTime();
if (newStart < eventEnd && newEnd > eventStart) {
conflicts.push({
id: event.id,
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
calendarId: event.calendarId,
});
}
}
return { hasConflict: conflicts.length > 0, conflicts };
}

View file

@ -0,0 +1,365 @@
/**
* Event Parser for Calendar
*
* Natural language event creation (German-focused):
* - "Meeting morgen 14 Uhr 1h @Arbeit #wichtig"
* - "Arzttermin 15.12. 10:00 30min in Praxis Dr. Müller"
* - "Ganztägig Urlaub nächste Woche"
*/
import {
parseBaseInput,
extractAtReferences,
extractRecurrence,
combineDateAndTime,
formatDatePreview,
formatTimePreview,
type ParserLocale,
} from '@manacore/shared-utils';
import { addHours } from 'date-fns';
export interface ParsedEvent {
title: string;
startDate?: Date;
endDate?: Date;
duration?: number; // in minutes
isAllDay?: boolean;
calendarName?: string;
attendees: string[];
location?: string;
recurrenceRule?: string;
tagNames: string[];
}
export interface ParsedEventWithIds {
title: string;
startTime?: string;
endTime?: string;
isAllDay?: boolean;
calendarId?: string;
attendees: string[];
location?: string;
recurrenceRule?: string;
tagIds: string[];
}
// ── Time Range (14-16 Uhr, 10:00-11:30) ─────────────────
const TIME_RANGE_PATTERN =
/\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*[-]\s*(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
function extractTimeRange(text: string): {
startTime?: { hours: number; minutes: number };
endTime?: { hours: number; minutes: number };
remaining: string;
} {
const match = text.match(TIME_RANGE_PATTERN);
if (match) {
const startHours = parseInt(match[1]);
const startMinutes = match[2] ? parseInt(match[2]) : 0;
const endHours = parseInt(match[3]);
const endMinutes = match[4] ? parseInt(match[4]) : 0;
if (
startHours >= 0 &&
startHours <= 23 &&
endHours >= 0 &&
endHours <= 23 &&
startMinutes >= 0 &&
startMinutes <= 59 &&
endMinutes >= 0 &&
endMinutes <= 59
) {
return {
startTime: { hours: startHours, minutes: startMinutes },
endTime: { hours: endHours, minutes: endMinutes },
remaining: text.replace(TIME_RANGE_PATTERN, '').trim(),
};
}
}
return { remaining: text };
}
// ── Duration (1h, 30min, 2h30m) ─────────────────────────
const HOURS_WORDS: Record<ParserLocale, string> = {
de: 'stunde[n]?',
en: 'hours?',
fr: 'heures?',
es: 'horas?',
it: 'ore',
};
function getDurationPatterns(
locale: ParserLocale
): { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] {
const hoursWord = HOURS_WORDS[locale];
return [
{
pattern: /\b(\d+)\s*h\s*(\d+)\s*(?:m(?:in)?)\b/i,
getMinutes: (m) => parseInt(m[1]) * 60 + parseInt(m[2]),
},
{ pattern: /\b(\d+)\s*h\b/i, getMinutes: (m) => parseInt(m[1]) * 60 },
{
pattern: /\b(\d+)\s*(?:min(?:uten?|utes?)?)\b/i,
getMinutes: (m) => parseInt(m[1]),
},
{
pattern: new RegExp(`\\b(\\d+)\\s*${hoursWord}\\b`, 'i'),
getMinutes: (m) => parseInt(m[1]) * 60,
},
];
}
function extractDuration(
text: string,
locale: ParserLocale = 'de'
): { duration?: number; remaining: string } {
for (const { pattern, getMinutes } of getDurationPatterns(locale)) {
const match = text.match(pattern);
if (match) {
return {
duration: getMinutes(match),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { duration: undefined, remaining: text };
}
// ── Location (in Berlin, im Büro, bei Dr. Müller) ───────
const LOCATION_PATTERN = /\b(?:in|im|bei|am)\s+(.+?)(?=\s+(?:@|#)|$)/i;
const NOT_LOCATION_PATTERN =
/^\d+\s*(tage?n?|wochen?|stunde[n]?|minute[n]?|hours?|minutes?|heures?|horas?|ore|h|min)$/i;
function extractLocation(text: string): { location?: string; remaining: string } {
const match = text.match(LOCATION_PATTERN);
if (match) {
const location = match[1].trim();
if (NOT_LOCATION_PATTERN.test(location)) return { location: undefined, remaining: text };
if (/^\d+\s/.test(location) && location.length < 5)
return { location: undefined, remaining: text };
if (location.length >= 2) {
return { location, remaining: text.replace(LOCATION_PATTERN, '').trim() };
}
}
return { location: undefined, remaining: text };
}
// ── All-Day Detection ────────────────────────────────────
const ALL_DAY_PATTERNS: Record<ParserLocale, RegExp[]> = {
de: [/\bganzt[aä]gig\b/i, /\bganzer\s+tag\b/i],
en: [/\ball[- ]?day\b/i, /\bwhole\s+day\b/i],
fr: [/\btoute\s+la\s+journ[eé]e\b/i, /\bjour\s+entier\b/i],
es: [/\btodo\s+el\s+d[ií]a\b/i, /\bd[ií]a\s+completo\b/i],
it: [/\btutto\s+il\s+giorno\b/i, /\bgiornata\s+intera\b/i],
};
function extractAllDay(
text: string,
locale: ParserLocale = 'de'
): { isAllDay: boolean; remaining: string } {
for (const pattern of ALL_DAY_PATTERNS[locale]) {
if (pattern.test(text)) {
return { isAllDay: true, remaining: text.replace(pattern, '').trim() };
}
}
return { isAllDay: false, remaining: text };
}
// ── Main Parser ──────────────────────────────────────────
export function parseEventInput(input: string, locale: ParserLocale = 'de'): ParsedEvent {
let text = input.trim();
const recurrenceResult = extractRecurrence(text, locale);
text = recurrenceResult.remaining;
const recurrenceRule = recurrenceResult.value;
const allDayResult = extractAllDay(text, locale);
text = allDayResult.remaining;
const isAllDay = allDayResult.isAllDay;
const timeRangeResult = extractTimeRange(text);
text = timeRangeResult.remaining;
const durationResult = extractDuration(text, locale);
text = durationResult.remaining;
const duration = durationResult.duration;
const atRefsResult = extractAtReferences(text);
text = atRefsResult.remaining;
const atRefs = atRefsResult.value ?? [];
const calendarName = atRefs.length > 0 ? atRefs[0] : undefined;
const attendees = atRefs.length > 1 ? atRefs.slice(1) : [];
const base = parseBaseInput(text, locale);
const locationResult = extractLocation(base.title);
const title = locationResult.location ? locationResult.remaining : base.title;
const location = locationResult.location;
let startDate: Date | undefined;
let endDate: Date | undefined;
if (timeRangeResult.startTime && timeRangeResult.endTime) {
const dateForRange = base.date || new Date();
startDate = combineDateAndTime(dateForRange, timeRangeResult.startTime);
endDate = combineDateAndTime(dateForRange, timeRangeResult.endTime);
} else {
startDate = combineDateAndTime(base.date, isAllDay ? undefined : base.time);
if (startDate) {
if (isAllDay) {
endDate = new Date(startDate);
endDate.setHours(23, 59, 59);
} else if (duration) {
endDate = new Date(startDate.getTime() + duration * 60_000);
} else {
endDate = addHours(startDate, 1);
}
}
}
return {
title,
startDate,
endDate,
duration,
isAllDay: isAllDay || undefined,
calendarName,
attendees,
location,
recurrenceRule,
tagNames: base.tagNames,
};
}
// ── Multi-Event Splitting ────────────────────────────────
const EVENT_SPLITTERS =
/\s*(?:,\s*(?:danach|dann|und dann|anschließend|außerdem|afterwards|then|and then|also)\s+|;\s*|\s+(?:danach|dann|und dann|anschließend|afterwards|then|and then)\s+)/i;
export function parseMultiEventInput(input: string, locale: ParserLocale = 'de'): ParsedEvent[] {
const parts = input.split(EVENT_SPLITTERS).filter((s) => s.trim().length > 0);
if (parts.length <= 1) return [parseEventInput(input, locale)];
const results: ParsedEvent[] = [];
let contextDate: Date | undefined;
let contextCalendar: string | undefined;
let lastEndDate: Date | undefined;
for (let i = 0; i < parts.length; i++) {
const parsed = parseEventInput(parts[i].trim(), locale);
if (i === 0) {
contextDate = parsed.startDate;
contextCalendar = parsed.calendarName;
lastEndDate = parsed.endDate;
} else {
if (!parsed.calendarName && contextCalendar) parsed.calendarName = contextCalendar;
if (!parsed.startDate && lastEndDate) {
parsed.startDate = new Date(lastEndDate);
parsed.endDate = parsed.duration
? new Date(parsed.startDate.getTime() + parsed.duration * 60_000)
: addHours(parsed.startDate, 1);
} else if (!parsed.startDate && contextDate) {
parsed.startDate = new Date(contextDate);
parsed.endDate = parsed.duration
? new Date(parsed.startDate.getTime() + parsed.duration * 60_000)
: addHours(parsed.startDate, 1);
}
lastEndDate = parsed.endDate;
}
results.push(parsed);
}
return results;
}
// ── ID Resolution ────────────────────────────────────────
export function resolveEventIds(
parsed: ParsedEvent,
calendars: { id: string; name: string }[],
tags: { id: string; name: string }[],
defaultCalendarId?: string
): ParsedEventWithIds {
let calendarId: string | undefined;
const attendees: string[] = [...parsed.attendees];
const tagIds: string[] = [];
if (parsed.calendarName) {
const calendar = calendars.find(
(c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase()
);
if (calendar) {
calendarId = calendar.id;
} else {
attendees.unshift(parsed.calendarName);
}
}
if (!calendarId && defaultCalendarId) calendarId = defaultCalendarId;
for (const tagName of parsed.tagNames) {
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) tagIds.push(tag.id);
}
return {
title: parsed.title,
startTime: parsed.startDate?.toISOString(),
endTime: parsed.endDate?.toISOString(),
isAllDay: parsed.isAllDay,
calendarId,
attendees,
location: parsed.location,
recurrenceRule: parsed.recurrenceRule,
tagIds,
};
}
// ── Preview Formatting ───────────────────────────────────
const ALL_DAY_LABEL: Record<ParserLocale, string> = {
de: 'ganztägig',
en: 'all-day',
fr: 'toute la journée',
es: 'todo el día',
it: 'tutto il giorno',
};
export function formatParsedEventPreview(parsed: ParsedEvent, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
if (parsed.isAllDay && parsed.startDate) {
parts.push(`${formatDatePreview(parsed.startDate, locale)} (${ALL_DAY_LABEL[locale]})`);
} else if (parsed.startDate) {
let dateStr = formatDatePreview(parsed.startDate, locale);
if (parsed.startDate.getHours() !== 0 || parsed.startDate.getMinutes() !== 0) {
dateStr += ` ${formatTimePreview({
hours: parsed.startDate.getHours(),
minutes: parsed.startDate.getMinutes(),
})}`;
}
parts.push(dateStr);
}
if (parsed.duration) {
const hours = Math.floor(parsed.duration / 60);
const mins = parsed.duration % 60;
let durationStr = '';
if (hours > 0) durationStr += `${hours}h`;
if (mins > 0) durationStr += `${mins}min`;
parts.push(durationStr);
}
if (parsed.location) parts.push(parsed.location);
if (parsed.calendarName) parts.push(`@${parsed.calendarName}`);
if (parsed.tagNames.length > 0) parts.push(parsed.tagNames.map((t) => `#${t}`).join(' '));
return parts.join(' · ');
}

View file

@ -0,0 +1,466 @@
<script lang="ts">
import {
getLocalLlmStatus,
loadLocalLlm,
unloadLocalLlm,
isLocalLlmSupported,
generate,
generateText,
extractJson,
classify,
MODELS,
type ModelKey,
} from '@manacore/local-llm';
import { Robot, Trash, PaperPlaneRight } from '@manacore/shared-icons';
// --- State ---
let selectedModel: ModelKey = $state('qwen-2.5-1.5b');
let activeTab: 'chat' | 'extract' | 'classify' = $state('chat');
const supported = isLocalLlmSupported();
const status = getLocalLlmStatus();
// Chat tab
let systemPrompt = $state('');
let userInput = $state('');
let messages: { role: 'user' | 'assistant'; content: string }[] = $state([]);
let streamingContent = $state('');
let isGenerating = $state(false);
let lastLatency = $state<number | null>(null);
let lastTokens = $state<{ prompt: number; completion: number } | null>(null);
// Extract tab
let extractText = $state('');
let extractInstruction = $state(
'Extract all names and ages as a JSON array of objects with "name" and "age" fields.'
);
let extractResult = $state('');
let extractLoading = $state(false);
// Classify tab
let classifyText = $state('');
let classifyCategories = $state('positive, negative, neutral');
let classifyResult = $state('');
let classifyLoading = $state(false);
// --- Derived ---
let isReady = $derived(status.current.state === 'ready');
let isLoading = $derived(
status.current.state === 'downloading' ||
status.current.state === 'loading' ||
status.current.state === 'checking'
);
let progress = $derived(status.current.state === 'downloading' ? status.current.progress : null);
let statusText = $derived(() => {
const s = status.current;
switch (s.state) {
case 'idle':
return 'Nicht geladen';
case 'checking':
return 'Prüfe WebGPU...';
case 'downloading':
return `Lade Modell... ${Math.round(s.progress * 100)}%`;
case 'loading':
return s.text;
case 'ready':
return 'Bereit';
case 'error':
return `Fehler: ${s.error}`;
default:
return '';
}
});
let modelInfo = $derived(MODELS[selectedModel]);
// --- Actions ---
async function handleLoad() {
await loadLocalLlm(selectedModel);
}
async function handleUnload() {
await unloadLocalLlm();
messages = [];
streamingContent = '';
lastLatency = null;
lastTokens = null;
}
async function handleSend() {
if (!userInput.trim() || isGenerating) return;
const userMsg = userInput.trim();
messages = [...messages, { role: 'user', content: userMsg }];
userInput = '';
isGenerating = true;
streamingContent = '';
try {
const msgs: { role: 'system' | 'user' | 'assistant'; content: string }[] = [];
if (systemPrompt.trim()) {
msgs.push({ role: 'system', content: systemPrompt.trim() });
}
// Include conversation history
for (const m of messages) {
msgs.push({ role: m.role, content: m.content });
}
const result = await generate({
messages: msgs,
temperature: 0.7,
maxTokens: 1024,
onToken: (token) => {
streamingContent += token;
},
});
messages = [...messages, { role: 'assistant', content: result.content }];
lastLatency = result.latencyMs;
lastTokens = {
prompt: result.usage.prompt_tokens,
completion: result.usage.completion_tokens,
};
streamingContent = '';
} catch (err) {
messages = [
...messages,
{
role: 'assistant',
content: `Fehler: ${err instanceof Error ? err.message : String(err)}`,
},
];
} finally {
isGenerating = false;
}
}
async function handleExtract() {
if (!extractText.trim() || extractLoading) return;
extractLoading = true;
extractResult = '';
try {
const result = await extractJson(extractText, extractInstruction);
extractResult = JSON.stringify(result, null, 2);
} catch (err) {
extractResult = `Fehler: ${err instanceof Error ? err.message : String(err)}`;
} finally {
extractLoading = false;
}
}
async function handleClassify() {
if (!classifyText.trim() || classifyLoading) return;
classifyLoading = true;
classifyResult = '';
try {
const cats = classifyCategories
.split(',')
.map((c) => c.trim())
.filter(Boolean);
const result = await classify(classifyText, cats);
classifyResult = result;
} catch (err) {
classifyResult = `Fehler: ${err instanceof Error ? err.message : String(err)}`;
} finally {
classifyLoading = false;
}
}
function handleClear() {
messages = [];
streamingContent = '';
lastLatency = null;
lastTokens = null;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
</script>
<svelte:head>
<title>Local LLM Test - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Local LLM Test</h1>
<p class="mt-1 text-sm text-muted-foreground">
Browser-basierte KI-Inferenz via WebGPU + WebLLM
</p>
</header>
<!-- WebGPU Support Check -->
{#if !supported}
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-6 text-center">
<p class="text-lg font-semibold text-red-400">WebGPU nicht verfügbar</p>
<p class="mt-2 text-sm text-muted-foreground">
Dieses Feature benötigt einen Browser mit WebGPU-Support (Chrome 113+, Edge 113+). Safari
und Firefox haben experimentelle Unterstützung.
</p>
</div>
{:else}
<!-- Model Controls -->
<div class="mb-6 rounded-xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center gap-4">
<!-- Model Select -->
<div class="flex flex-col gap-1">
<label for="model-select" class="text-xs font-medium text-muted-foreground">Modell</label>
<select
id="model-select"
bind:value={selectedModel}
disabled={isLoading || isGenerating}
class="rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground disabled:opacity-50"
>
{#each Object.entries(MODELS) as [key, model]}
<option value={key}>{model.displayName}</option>
{/each}
</select>
</div>
<!-- Model Info -->
<div class="flex flex-col gap-0.5 text-xs text-muted-foreground">
<span>Download: ~{modelInfo.downloadSizeMb} MB</span>
<span>RAM: ~{modelInfo.ramUsageMb} MB</span>
</div>
<!-- Load/Unload Button -->
<div class="flex items-center gap-2">
{#if isReady}
<button
onclick={handleUnload}
class="rounded-lg border border-border px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted"
>
Entladen
</button>
{:else}
<button
onclick={handleLoad}
disabled={isLoading}
class="rounded-lg bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground transition-opacity disabled:opacity-50"
>
{isLoading ? 'Lädt...' : 'Modell laden'}
</button>
{/if}
</div>
<!-- Status -->
<div class="ml-auto flex items-center gap-2">
<div
class="h-2.5 w-2.5 rounded-full {isReady
? 'bg-green-500'
: isLoading
? 'bg-yellow-500 animate-pulse'
: status.current.state === 'error'
? 'bg-red-500'
: 'bg-muted-foreground/30'}"
></div>
<span class="text-xs text-muted-foreground">{statusText()}</span>
</div>
</div>
<!-- Progress Bar -->
{#if progress !== null}
<div class="mt-3 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
style="width: {Math.round(progress * 100)}%"
></div>
</div>
{/if}
</div>
<!-- Tabs -->
<div class="mb-4 flex gap-1 rounded-lg border border-border bg-card p-1">
{#each [{ id: 'chat', label: 'Chat' }, { id: 'extract', label: 'JSON Extract' }, { id: 'classify', label: 'Classify' }] as tab}
<button
onclick={() => (activeTab = tab.id as typeof activeTab)}
class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {activeTab ===
tab.id
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
{tab.label}
</button>
{/each}
</div>
<!-- Chat Tab -->
{#if activeTab === 'chat'}
<div class="flex flex-col gap-4">
<!-- System Prompt -->
<input
type="text"
bind:value={systemPrompt}
placeholder="System Prompt (optional)..."
class="rounded-xl border border-border bg-card px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
/>
<!-- Messages -->
<div class="min-h-[300px] space-y-3 rounded-xl border border-border bg-background/50 p-4">
{#if messages.length === 0 && !streamingContent}
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-3 rounded-full bg-primary/10 p-3">
<Robot size={32} class="text-primary" />
</div>
<p class="text-sm text-muted-foreground">
{isReady
? 'Modell bereit! Schreib einen Prompt.'
: 'Lade zuerst ein Modell, dann kannst du chatten.'}
</p>
</div>
{:else}
{#each messages as msg}
<div
class="rounded-lg border border-border p-3 {msg.role === 'user'
? 'ml-8 bg-primary/5'
: 'mr-8 bg-card'}"
>
<div class="mb-1 text-xs font-medium text-muted-foreground">
{msg.role === 'user' ? 'Du' : modelInfo.displayName}
</div>
<div class="whitespace-pre-wrap text-sm text-foreground">{msg.content}</div>
</div>
{/each}
{#if streamingContent}
<div class="mr-8 rounded-lg border border-border bg-card p-3">
<div class="mb-1 text-xs font-medium text-muted-foreground">
{modelInfo.displayName}
</div>
<div class="whitespace-pre-wrap text-sm text-foreground">
{streamingContent}<span class="animate-pulse">|</span>
</div>
</div>
{/if}
{/if}
</div>
<!-- Stats -->
{#if lastLatency !== null}
<div class="flex gap-4 text-xs text-muted-foreground">
<span>Latenz: {lastLatency}ms</span>
{#if lastTokens}
<span>Prompt: {lastTokens.prompt} tokens</span>
<span>Completion: {lastTokens.completion} tokens</span>
<span
>Speed: {lastLatency > 0
? Math.round((lastTokens.completion / lastLatency) * 1000)
: 0} tok/s</span
>
{/if}
</div>
{/if}
<!-- Input -->
<div class="flex gap-3">
<textarea
bind:value={userInput}
onkeydown={handleKeydown}
placeholder={isReady ? 'Prompt eingeben... (Enter zum Senden)' : 'Erst Modell laden...'}
disabled={!isReady || isGenerating}
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 disabled:opacity-50"
></textarea>
<div class="flex flex-col gap-2 self-end">
<button
onclick={handleSend}
disabled={!isReady || !userInput.trim() || isGenerating}
class="rounded-xl bg-primary px-4 py-3 text-sm font-medium text-primary-foreground transition-opacity disabled:opacity-50"
>
<PaperPlaneRight size={18} />
</button>
<button
onclick={handleClear}
class="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground transition-colors hover:bg-muted"
>
<Trash size={18} />
</button>
</div>
</div>
</div>
{/if}
<!-- Extract Tab -->
{#if activeTab === 'extract'}
<div class="flex flex-col gap-4">
<div class="rounded-xl border border-border bg-card p-4">
<p class="mb-3 text-sm text-muted-foreground">
Extrahiere strukturiertes JSON aus beliebigem Text. Das LLM analysiert den Text und gibt
ein JSON-Objekt zurück.
</p>
<input
type="text"
bind:value={extractInstruction}
placeholder="Extraction instruction..."
class="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
/>
<textarea
bind:value={extractText}
placeholder="Text zum Extrahieren eingeben...&#10;&#10;z.B.: Anna ist 28 Jahre alt und arbeitet mit Max (35) zusammen."
rows={5}
class="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
></textarea>
<button
onclick={handleExtract}
disabled={!isReady || !extractText.trim() || extractLoading}
class="mt-3 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{extractLoading ? 'Extrahiere...' : 'JSON extrahieren'}
</button>
</div>
{#if extractResult}
<div class="rounded-xl border border-border bg-card p-4">
<div class="mb-2 text-xs font-medium text-muted-foreground">Ergebnis</div>
<pre
class="overflow-x-auto rounded-lg bg-background p-3 text-sm text-foreground">{extractResult}</pre>
</div>
{/if}
</div>
{/if}
<!-- Classify Tab -->
{#if activeTab === 'classify'}
<div class="flex flex-col gap-4">
<div class="rounded-xl border border-border bg-card p-4">
<p class="mb-3 text-sm text-muted-foreground">
Klassifiziere Text in eine von mehreren Kategorien. Das LLM wählt die passendste
Kategorie.
</p>
<input
type="text"
bind:value={classifyCategories}
placeholder="Kategorien (kommagetrennt)..."
class="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
/>
<textarea
bind:value={classifyText}
placeholder="Text zum Klassifizieren eingeben...&#10;&#10;z.B.: Das Essen war fantastisch, ich komme definitiv wieder!"
rows={4}
class="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
></textarea>
<button
onclick={handleClassify}
disabled={!isReady || !classifyText.trim() || classifyLoading}
class="mt-3 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{classifyLoading ? 'Klassifiziere...' : 'Klassifizieren'}
</button>
</div>
{#if classifyResult}
<div class="rounded-xl border border-border bg-card p-4">
<div class="mb-2 text-xs font-medium text-muted-foreground">Ergebnis</div>
<div class="rounded-lg bg-background px-4 py-3 text-lg font-semibold text-foreground">
{classifyResult}
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>