diff --git a/apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte b/apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte index 4a7a5e134..c2386a75c 100644 --- a/apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte +++ b/apps/mana/apps/web/src/lib/modules/kontext/KontextView.svelte @@ -7,11 +7,48 @@ import { marked } from 'marked'; import { useKontextDoc } from './queries'; import { kontextStore } from './stores/kontext.svelte'; - import { PencilSimple, Eye } from '@mana/shared-icons'; + import { PencilSimple, Eye, LinkSimple, X, NotePencil, Trash } from '@mana/shared-icons'; + import { crawlUrlViaApi, type CrawlMode } from './api'; + import { requireAuth } from '$lib/auth/require-auth.svelte'; + import { notesStore } from '$lib/modules/notes/stores/notes.svelte'; + import { notesSelectionStore } from '$lib/modules/notes/stores/selection.svelte'; + import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte'; + + let noteSaving = $state(false); + let noteSaved = $state(false); + let noteError = $state(null); const PLACEHOLDER = 'Was soll Mana über dich wissen?'; const SAVE_DEBOUNCE_MS = 500; + let urlPanelOpen = $state(false); + let importUrl = $state(''); + let importMode = $state('single'); + let importSummarize = $state(false); + let importing = $state(false); + let importPhase = $state<'idle' | 'crawling' | 'summarizing' | 'appending'>('idle'); + let importElapsed = $state(0); + let importError = $state(null); + + let importPhases = $derived.by(() => { + const steps: Array<{ key: 'crawling' | 'summarizing' | 'appending'; label: string }> = [ + { + key: 'crawling', + label: importMode === 'deep' ? 'Website crawlen (bis 20 Seiten)' : 'Seite laden', + }, + ...(importSummarize ? [{ key: 'summarizing' as const, label: 'Mit KI zusammenfassen' }] : []), + { key: 'appending', label: 'In Kontext anhängen' }, + ]; + const order = steps.map((s) => s.key); + const currentIdx = order.indexOf(importPhase as never); + return steps.map((s, i) => ({ + key: s.key, + label: s.label, + active: currentIdx === i, + done: currentIdx > i, + })); + }); + let doc$ = useKontextDoc(); let doc = $derived(doc$.value); @@ -78,6 +115,113 @@ } } + function closeUrlPanel() { + if (importing) return; + urlPanelOpen = false; + importUrl = ''; + importMode = 'single'; + importSummarize = false; + importPhase = 'idle'; + importElapsed = 0; + importError = null; + } + + async function handleImport(e: Event) { + e.preventDefault(); + const trimmed = importUrl.trim(); + if (!trimmed) return; + const ok = await requireAuth({ + feature: 'kontext-url-import', + reason: + 'Das Crawlen einer Web-Seite läuft serverseitig (robots.txt, Rate-Limits, optionale KI-Zusammenfassung) und erfordert ein Mana-Konto.', + }); + if (!ok) return; + importing = true; + importError = null; + importPhase = 'crawling'; + importElapsed = 0; + const started = performance.now(); + const tick = setInterval(() => { + importElapsed = Math.floor((performance.now() - started) / 1000); + }, 250); + // The backend does crawl + (optional) LLM summary in one call, + // so we can't observe phase transitions from the wire. Advance + // the visual phase optimistically based on typical durations. + // Single-page crawl: ~2-4s. Deep: up to 30s. LLM summary: 5-15s. + let phaseTimer: ReturnType | null = null; + if (importSummarize) { + const crawlBudgetMs = importMode === 'deep' ? 25_000 : 4_000; + phaseTimer = setTimeout(() => { + if (importing) importPhase = 'summarizing'; + }, crawlBudgetMs); + } + try { + const result = await crawlUrlViaApi({ + url: trimmed, + mode: importMode, + summarize: importSummarize, + }); + if (phaseTimer) clearTimeout(phaseTimer); + importPhase = 'appending'; + const header = `## ${result.title}\n\n_Quelle: ${result.sourceUrl}_\n\n`; + await kontextStore.appendContent(header + result.content); + closeUrlPanel(); + } catch (err) { + importError = err instanceof Error ? err.message : 'import failed'; + } finally { + if (phaseTimer) clearTimeout(phaseTimer); + clearInterval(tick); + importing = false; + } + } + + function extractTitle(md: string): string { + const firstLine = md + .trim() + .split('\n') + .find((l) => l.trim()); + if (!firstLine) return `Kontext vom ${new Date().toLocaleDateString('de-DE')}`; + const stripped = firstLine.replace(/^#{1,6}\s*/, '').trim(); + return stripped.slice(0, 80) || `Kontext vom ${new Date().toLocaleDateString('de-DE')}`; + } + + async function handleSaveAsNote() { + const source = (doc?.content ?? '').trim(); + if (!source || noteSaving) return; + const ok = await requireAuth({ + feature: 'kontext-to-note', + reason: + 'Notizen werden verschlüsselt in deinem Konto gespeichert und über Geräte synchronisiert.', + }); + if (!ok) return; + noteSaving = true; + noteError = null; + try { + const note = await notesStore.createNote({ + title: extractTitle(source), + content: source, + }); + notesSelectionStore.focusNote(note.id); + await workbenchScenesStore.addAppAfter('notes', 'kontext'); + // Let the new Notes card mount in the carousel, then scroll. + setTimeout(() => { + const el = document.querySelector('[data-page-id="notes"]'); + el?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + }, 150); + noteSaved = true; + } catch (err) { + noteError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen'; + } finally { + noteSaving = false; + } + } + + async function handleClearKontext() { + await kontextStore.setContent(''); + draft = ''; + noteSaved = false; + } + let renderedHtml = $derived.by(() => { const source = doc?.content ?? ''; if (!source.trim()) return ''; @@ -100,17 +244,99 @@ Gespeichert {/if} - +
+ + +
+ {#if urlPanelOpen} +
+
+ + + +
+
+ + + · + +
+ {#if importing || importPhase !== 'idle'} +
    + {#each importPhases as phase (phase.key)} +
  1. + + {phase.label} + {#if phase.active} + {importElapsed}s + {/if} +
  2. + {/each} +
+ {/if} + {#if importError} +

{importError}

+ {/if} +
+ {/if} + {#if mode === 'edit'}