mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
9c0613d920
commit
249cbc97a0
3 changed files with 1049 additions and 0 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(' · ');
|
||||
}
|
||||
466
apps/manacore/apps/web/src/routes/(app)/llm-test/+page.svelte
Normal file
466
apps/manacore/apps/web/src/routes/(app)/llm-test/+page.svelte
Normal 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... 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... 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue