diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-estimator.ts b/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-estimator.ts new file mode 100644 index 000000000..e0093f684 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-estimator.ts @@ -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 }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-parser.ts b/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-parser.ts new file mode 100644 index 000000000..440be2b00 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-parser.ts @@ -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 = { + 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 = { + 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 = { + 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(' · '); +} diff --git a/apps/manacore/apps/web/src/routes/(app)/llm-test/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/llm-test/+page.svelte new file mode 100644 index 000000000..cc866cd7b --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/llm-test/+page.svelte @@ -0,0 +1,466 @@ + + + + Local LLM Test - ManaCore + + +
+ +
+

Local LLM Test

+

+ Browser-basierte KI-Inferenz via WebGPU + WebLLM +

+
+ + + {#if !supported} +
+

WebGPU nicht verfügbar

+

+ Dieses Feature benötigt einen Browser mit WebGPU-Support (Chrome 113+, Edge 113+). Safari + und Firefox haben experimentelle Unterstützung. +

+
+ {:else} + +
+
+ +
+ + +
+ + +
+ Download: ~{modelInfo.downloadSizeMb} MB + RAM: ~{modelInfo.ramUsageMb} MB +
+ + +
+ {#if isReady} + + {:else} + + {/if} +
+ + +
+
+ {statusText()} +
+
+ + + {#if progress !== null} +
+
+
+ {/if} +
+ + +
+ {#each [{ id: 'chat', label: 'Chat' }, { id: 'extract', label: 'JSON Extract' }, { id: 'classify', label: 'Classify' }] as tab} + + {/each} +
+ + + {#if activeTab === 'chat'} +
+ + + + +
+ {#if messages.length === 0 && !streamingContent} +
+
+ +
+

+ {isReady + ? 'Modell bereit! Schreib einen Prompt.' + : 'Lade zuerst ein Modell, dann kannst du chatten.'} +

+
+ {:else} + {#each messages as msg} +
+
+ {msg.role === 'user' ? 'Du' : modelInfo.displayName} +
+
{msg.content}
+
+ {/each} + + {#if streamingContent} +
+
+ {modelInfo.displayName} +
+
+ {streamingContent}| +
+
+ {/if} + {/if} +
+ + + {#if lastLatency !== null} +
+ Latenz: {lastLatency}ms + {#if lastTokens} + Prompt: {lastTokens.prompt} tokens + Completion: {lastTokens.completion} tokens + Speed: {lastLatency > 0 + ? Math.round((lastTokens.completion / lastLatency) * 1000) + : 0} tok/s + {/if} +
+ {/if} + + +
+ +
+ + +
+
+
+ {/if} + + + {#if activeTab === 'extract'} +
+
+

+ Extrahiere strukturiertes JSON aus beliebigem Text. Das LLM analysiert den Text und gibt + ein JSON-Objekt zurück. +

+ + + +
+ + {#if extractResult} +
+
Ergebnis
+
{extractResult}
+
+ {/if} +
+ {/if} + + + {#if activeTab === 'classify'} +
+
+

+ Klassifiziere Text in eine von mehreren Kategorien. Das LLM wählt die passendste + Kategorie. +

+ + + +
+ + {#if classifyResult} +
+
Ergebnis
+
+ {classifyResult} +
+
+ {/if} +
+ {/if} + {/if} +