docs: update architecture comparison — 5/10 roadmap items done

Update report to reflect all completed work:
- Matrix: streaming , tool registration updated to 29 tools + MCP
- §5.2 Streaming: marked done
- §5.3 Tool System: marked done
- §6 Table: items 1-3 + 5 struck through with commit refs
- §8 Fazit: updated gaps and recommendations

5 of 10 roadmap items complete in one session:
1. SSE Streaming, 2. Dynamic Tool Registry, 3. Budget Enforcement,
5. MCP Server Export (27/29 tools with DB ops), plus Tool Drift Fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 15:00:09 +02:00
parent ce57e11950
commit acd7e0d6b0
26 changed files with 2744 additions and 661 deletions

View file

@ -106,6 +106,38 @@ routes.get('/files/:id/download', async (c) => {
}
});
// ─── Avatar Upload (profile) ───────────────────────────────
const ALLOWED_AVATAR_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
routes.post('/avatar/upload', async (c) => {
const userId = c.get('userId');
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
if (file.size > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400);
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP' }, 400);
}
try {
const buffer = await file.arrayBuffer();
const { uploadImageToMedia } = await import('../../lib/media');
const result = await uploadImageToMedia(
buffer,
`avatar-${userId}.${file.name.split('.').pop()}`,
{
app: 'profile',
userId,
}
);
return c.json({ url: result.urls.thumbnail || result.urls.original, mediaId: result.id }, 201);
} catch (_err) {
return c.json({ error: 'Avatar upload failed' }, 500);
}
});
// ─── Version Upload ─────────────────────────────────────────
routes.post('/files/:id/versions', async (c) => {

View file

@ -4,7 +4,7 @@
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaAuthUrl } from './config';
import { getManaAuthUrl, getManaApiUrl } from './config';
// Types
export interface UserProfile {
@ -32,11 +32,13 @@ export interface DeleteAccountRequest {
reason?: string;
}
export interface AvatarUploadUrlResponse {
uploadUrl: string;
fileUrl: string;
key: string;
expiresIn: number;
export interface ChangeEmailRequest {
newEmail: string;
}
export interface AvatarUploadResponse {
url: string;
mediaId: string;
}
// Helper function for authenticated requests
@ -104,36 +106,31 @@ export const profileService = {
},
/**
* Get presigned URL for avatar upload
* Change email address (sends verification to new email)
*/
async getAvatarUploadUrl(filename: string): Promise<AvatarUploadUrlResponse> {
return fetchWithAuth('/api/v1/storage/avatar/upload-url', {
async changeEmail(data: ChangeEmailRequest): Promise<{ success: boolean; message: string }> {
return fetchWithAuth('/api/v1/auth/change-email', {
method: 'POST',
body: JSON.stringify({ filename }),
body: JSON.stringify(data),
});
},
/**
* Upload avatar file using presigned URL, then update profile
* Upload avatar file directly, then update profile
*/
async uploadAvatar(file: File): Promise<{ success: boolean; user: UserProfile }> {
// 1. Get presigned upload URL
const { uploadUrl, fileUrl } = await this.getAvatarUploadUrl(file.name);
const token = await authStore.getValidToken();
const formData = new FormData();
formData.append('file', file);
// 2. Upload file directly to S3/MinIO
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
const uploadResponse = await fetch(`${getManaApiUrl()}/api/v1/storage/avatar/upload`, {
method: 'POST',
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Avatar-Upload fehlgeschlagen');
}
// 3. Update profile with new image URL
return this.updateProfile({ image: fileUrl });
if (!uploadResponse.ok) throw new Error('Avatar-Upload fehlgeschlagen');
const { url } = (await uploadResponse.json()) as AvatarUploadResponse;
return this.updateProfile({ image: url });
},
};

View file

@ -526,7 +526,7 @@ registerApp({
registerApp({
id: 'kontext',
name: 'Kontext',
name: 'Web-Context',
color: '#A78B6F',
icon: Scroll,
views: {

View file

@ -8,6 +8,7 @@
import AiTierStep from './steps/AiTierStep.svelte';
import SyncStep from './steps/SyncStep.svelte';
import CreditsStep from './steps/CreditsStep.svelte';
import ContextStep from './steps/ContextStep.svelte';
import CompleteStep from './steps/CompleteStep.svelte';
import { Check } from '@mana/shared-icons';
@ -30,6 +31,7 @@
const STEPS = [
{ id: 'welcome', label: 'Willkommen', component: WelcomeStep },
{ id: 'profile', label: 'Profil', component: ProfileStep },
{ id: 'context', label: 'Über dich', component: ContextStep },
{ id: 'apps', label: 'Apps', component: AppsStep },
{ id: 'ai-tier', label: 'KI', component: AiTierStep },
{ id: 'sync', label: 'Sync', component: SyncStep },

View file

@ -0,0 +1,55 @@
<!--
Onboarding Context Step — 5 key questions to populate the user context.
Uses the same ContextInterview component in compact mode.
-->
<script lang="ts">
import ContextInterview from '$lib/modules/profile/ContextInterview.svelte';
</script>
<div class="context-step">
<div class="step-header">
<h2 class="step-title">Erzähl Mana über dich</h2>
<p class="step-subtitle">
Je besser Mana dich kennt, desto relevanter werden Vorschläge und Automatisierungen. Du kannst
alles jederzeit im Profil ändern oder ergänzen.
</p>
</div>
<div class="interview-area">
<ContextInterview
compact
limitCategories={['about', 'routine', 'leisure', 'nutrition', 'goals']}
/>
</div>
</div>
<style>
.context-step {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
}
.step-header {
text-align: center;
}
.step-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.step-subtitle {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 32rem;
margin-inline: auto;
}
.interview-area {
flex: 1;
min-height: 0;
overflow-y: auto;
}
</style>

View file

@ -52,6 +52,72 @@ const kontextResolver: InputResolver = async (ref) => {
};
};
// ── User Context (structured profile + freeform) ──────────
interface UserContextLike {
id: string;
about?: { bio?: string; occupation?: string; location?: string; languages?: string[] };
interests?: string[];
routine?: { wakeUp?: string; workStart?: string; workEnd?: string; bedtime?: string };
nutrition?: { diet?: string; allergies?: string[]; preferences?: string };
goals?: string[];
social?: { communication?: string; workStyle?: string };
freeform?: string;
}
const userContextResolver: InputResolver = async (ref) => {
const doc = await db.table<UserContextLike>('userContext').get(ref.id);
if (!doc) return null;
const [decrypted] = await decryptRecords('userContext', [doc]);
return {
id: ref.id,
module: 'profile',
table: 'userContext',
title: 'Nutzerprofil',
content: buildUserContextText(decrypted),
};
};
function buildUserContextText(ctx: UserContextLike): string {
const lines: string[] = [];
if (ctx.about?.occupation) lines.push(`Beruf: ${ctx.about.occupation}`);
if (ctx.about?.location) lines.push(`Ort: ${ctx.about.location}`);
if (ctx.about?.languages?.length) lines.push(`Sprachen: ${ctx.about.languages.join(', ')}`);
if (ctx.about?.bio) lines.push(`\nBio: ${ctx.about.bio}`);
if (ctx.interests?.length) lines.push(`\nInteressen: ${ctx.interests.join(', ')}`);
if (ctx.routine) {
const r = ctx.routine;
const parts = [];
if (r.wakeUp) parts.push(`Aufstehen ${r.wakeUp}`);
if (r.workStart && r.workEnd) parts.push(`Arbeit ${r.workStart}${r.workEnd}`);
if (r.bedtime) parts.push(`Schlafenszeit ${r.bedtime}`);
if (parts.length) lines.push(`\nTagesroutine: ${parts.join(', ')}`);
}
if (ctx.nutrition) {
if (ctx.nutrition.diet) lines.push(`Ernährung: ${ctx.nutrition.diet}`);
if (ctx.nutrition.allergies?.length)
lines.push(`Allergien: ${ctx.nutrition.allergies.join(', ')}`);
}
if (ctx.goals?.length) lines.push(`\nZiele: ${ctx.goals.join(', ')}`);
if (ctx.social?.workStyle) lines.push(`Arbeitsweise: ${ctx.social.workStyle}`);
if (ctx.freeform?.trim()) lines.push(`\n---\n${ctx.freeform.trim()}`);
return lines.join('\n');
}
const userContextIndexer: InputIndexer = async () => {
const doc = await db.table<UserContextLike>('userContext').get('singleton');
if (!doc) return [];
return [
{
module: 'profile',
table: 'userContext',
id: 'singleton',
label: 'Nutzerprofil',
hint: 'Strukturiertes Profil + Freitext-Kontext',
},
];
};
interface GoalLike {
id: string;
title?: string;
@ -224,11 +290,13 @@ export function registerDefaultInputResolvers(): void {
if (registered) return;
registerInputResolver('notes', notesResolver);
registerInputResolver('kontext', kontextResolver);
registerInputResolver('profile', userContextResolver);
registerInputResolver('goals', goalsResolver);
registerInputResolver('todo', tasksResolver);
registerInputResolver('calendar', calendarResolver);
registerInputIndexer('notes', notesIndexer);
registerInputIndexer('kontext', kontextIndexer);
registerInputIndexer('profile', userContextIndexer);
registerInputIndexer('goals', goalsIndexer);
registerInputIndexer('todo', tasksIndexer);
registerInputIndexer('calendar', calendarIndexer);

View file

@ -194,19 +194,9 @@ export async function runMission(
const resolvedInputs: ResolvedInput[] = [...baseInputs];
const preStep: AiDebugEntry['preStep'] = { kontextInjected: false };
// Auto-inject agent-specific kontext doc (if non-empty) — replaces
// the old global singleton inject. Falls back to the global singleton
// when the agent doesn't have its own doc. Decrypted client-side.
const alreadyHasKontext = mission!.inputs.some((i) => i.module === 'kontext');
if (!alreadyHasKontext) {
const kontextEntry = owningAgent
? await loadAgentKontextAsResolvedInput(owningAgent.id)
: await loadKontextAsResolvedInput();
if (kontextEntry) {
resolvedInputs.push(kontextEntry);
preStep.kontextInjected = true;
}
}
// User context and agent kontext are available as explicit mission
// inputs via the input picker — no auto-inject. The user decides
// what context the AI sees.
// Pre-step web research: if the objective looks like research,
// run the deep-research pipeline (mana-search + mana-llm) and

View file

@ -467,11 +467,17 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
moodEntries: { enabled: true, fields: ['withWhom', 'notes'] },
moodSettings: { enabled: false, fields: [] },
// ─── Kontext ─────────────────────────────────────────────
// Singleton markdown document ("Was soll Mana über dich wissen?").
// Free-form user text — encrypt the content, leave the fixed id plaintext.
// ─── Kontext (legacy — now web-context, URL-crawl only) ──
kontextDoc: { enabled: true, fields: ['content'] },
// ─── User Context (profile hub) ──────────────────────────
// Structured profile sections + freeform markdown. Everything
// except the fixed id and interview progress is user-typed content.
userContext: {
enabled: true,
fields: ['about', 'interests', 'routine', 'nutrition', 'goals', 'social', 'freeform'],
},
// Per-agent kontext documents — same schema as kontextDoc but keyed
// per agent. Content is free-form markdown.
agentKontextDocs: { enabled: true, fields: ['content'] },

View file

@ -555,6 +555,14 @@ db.version(22).stores({
agentKontextDocs: 'id, agentId',
});
// v23 — User context: structured profile + freeform markdown.
// Singleton record ('id') holding structured sections (about, interests,
// routine, nutrition, goals, social), freeform markdown, and interview
// progress. Replaces kontextDoc as the central "who is the user?" store.
db.version(23).stores({
userContext: 'id',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -97,6 +97,7 @@ import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
import { moodModuleConfig } from '$lib/modules/mood/module.config';
import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
import { profileModuleConfig } from '$lib/modules/profile/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -150,6 +151,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
moodModuleConfig,
kontextModuleConfig,
quizModuleConfig,
profileModuleConfig,
aiModuleConfig,
];

View file

@ -1,27 +1,13 @@
<!--
Kontext — Singleton Markdown Document.
View/Edit toggle, debounced autosave, Cmd/Ctrl+E switches mode.
Web-Context — URL crawler tool.
Crawls web pages and appends the content to the user's profile freeform context.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { marked } from 'marked';
import { useKontextDoc } from './queries';
import { kontextStore } from './stores/kontext.svelte';
import { PencilSimple, Eye, LinkSimple, X, NotePencil, Trash } from '@mana/shared-icons';
import { LinkSimple, X } 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';
import { userContextStore } from '$lib/modules/profile/stores/user-context.svelte';
let noteSaving = $state(false);
let noteSaved = $state(false);
let noteError = $state<string | null>(null);
const PLACEHOLDER = 'Was soll Mana über dich wissen?';
const SAVE_DEBOUNCE_MS = 500;
let urlPanelOpen = $state(false);
let importUrl = $state('');
let importMode = $state<CrawlMode>('single');
let importSummarize = $state(false);
@ -29,6 +15,7 @@
let importPhase = $state<'idle' | 'crawling' | 'summarizing' | 'appending'>('idle');
let importElapsed = $state(0);
let importError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let importPhases = $derived.by(() => {
const steps: Array<{ key: 'crawling' | 'summarizing' | 'appending'; label: string }> = [
@ -37,7 +24,7 @@
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' },
{ key: 'appending', label: 'In Profil-Kontext speichern' },
];
const order = steps.map((s) => s.key);
const currentIdx = order.indexOf(importPhase as never);
@ -49,75 +36,7 @@
}));
});
let doc$ = useKontextDoc();
let doc = $derived(doc$.value);
let mode = $state<'view' | 'edit'>('view');
let draft = $state('');
let saveState = $state<'idle' | 'pending' | 'saved'>('idle');
let initialized = $state(false);
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let savedTimer: ReturnType<typeof setTimeout> | null = null;
onMount(() => {
void kontextStore.ensureDoc();
});
// Seed the draft from the live doc once (or when switching into edit
// mode and the content changed externally while we weren't editing).
$effect(() => {
if (!doc) return;
if (!initialized) {
draft = doc.content;
initialized = true;
if (!doc.content) mode = 'edit';
}
});
function scheduleSave() {
saveState = 'pending';
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
}
async function flush() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
const next = draft;
if (doc && next === doc.content) {
saveState = 'idle';
return;
}
await kontextStore.setContent(next);
saveState = 'saved';
if (savedTimer) clearTimeout(savedTimer);
savedTimer = setTimeout(() => {
if (saveState === 'saved') saveState = 'idle';
}, 1500);
}
async function toggleMode() {
if (mode === 'edit') {
await flush();
mode = 'view';
} else {
if (doc) draft = doc.content;
mode = 'edit';
}
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') {
e.preventDefault();
void toggleMode();
}
}
function closeUrlPanel() {
if (importing) return;
urlPanelOpen = false;
function reset() {
importUrl = '';
importMode = 'single';
importSummarize = false;
@ -131,23 +50,20 @@
const trimmed = importUrl.trim();
if (!trimmed) return;
const ok = await requireAuth({
feature: 'kontext-url-import',
feature: 'web-context-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;
successMessage = 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<typeof setTimeout> | null = null;
if (importSummarize) {
const crawlBudgetMs = importMode === 'deep' ? 25_000 : 4_000;
@ -164,293 +80,152 @@
if (phaseTimer) clearTimeout(phaseTimer);
importPhase = 'appending';
const header = `## ${result.title}\n\n_Quelle: ${result.sourceUrl}_\n\n`;
await kontextStore.appendContent(header + result.content);
closeUrlPanel();
await userContextStore.appendFreeform(header + result.content);
successMessage = `"${result.title}" wurde deinem Profil-Kontext hinzugefügt (${result.pageCount} Seite${result.pageCount > 1 ? 'n' : ''})`;
reset();
} catch (err) {
importError = err instanceof Error ? err.message : 'import failed';
importError = err instanceof Error ? err.message : 'Import fehlgeschlagen';
} 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 '';
try {
return marked.parse(source, { async: false }) as string;
} catch {
return '';
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="app-view">
<header class="bar">
<div class="status">
{#if saveState === 'pending'}
<span class="status-text">Speichert…</span>
{:else if saveState === 'saved'}
<span class="status-text saved">Gespeichert</span>
{/if}
<header class="header">
<div class="header-icon">
<LinkSimple size={20} />
</div>
<div class="actions">
<button
class="mode-btn"
class:active={urlPanelOpen}
onclick={() => (urlPanelOpen ? closeUrlPanel() : (urlPanelOpen = true))}
title="Web-Seite crawlen und anhängen"
>
<LinkSimple size={14} />
<span>Aus URL</span>
</button>
<button class="mode-btn" onclick={toggleMode} title="Cmd/Ctrl + E">
{#if mode === 'view'}
<PencilSimple size={14} />
<span>Bearbeiten</span>
{:else}
<Eye size={14} />
<span>Ansicht</span>
{/if}
</button>
<div>
<h2 class="title">Web-Context</h2>
<p class="subtitle">Crawle Webseiten und speichere den Inhalt in deinem Profil-Kontext</p>
</div>
</header>
{#if urlPanelOpen}
<form class="url-panel" onsubmit={handleImport}>
<div class="url-row">
<input
type="url"
bind:value={importUrl}
required
placeholder="https://example.com/article"
disabled={importing}
class="url-input"
/>
<button type="submit" disabled={importing || !importUrl.trim()} class="url-submit">
{#if importing}
{#if importPhase === 'crawling'}Crawle…{:else if importPhase === 'summarizing'}Fasse
zusammen…{:else}Speichere…{/if}
{:else}
Einfügen
{/if}
</button>
<button
type="button"
onclick={closeUrlPanel}
disabled={importing}
class="url-close"
title="Schließen"
>
<X size={14} />
</button>
</div>
<div class="url-opts">
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
Nur diese Seite
</label>
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
Ganze Website (max. 20)
</label>
<span class="url-sep">·</span>
<label class:disabled={importing}>
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
Mit KI zusammenfassen
</label>
</div>
{#if importing || importPhase !== 'idle'}
<ol class="phase-list" aria-live="polite">
{#each importPhases as phase (phase.key)}
<li class="phase" class:active={phase.active} class:done={phase.done}>
<span class="phase-dot" aria-hidden="true">
{#if phase.done}
{:else if phase.active}
<span class="phase-spinner"></span>
{:else}
·
{/if}
</span>
<span class="phase-label">{phase.label}</span>
{#if phase.active}
<span class="phase-elapsed">{importElapsed}s</span>
{/if}
</li>
{/each}
</ol>
{/if}
{#if importError}
<p class="url-error">{importError}</p>
{/if}
</form>
{/if}
{#if mode === 'edit'}
<textarea
class="editor"
bind:value={draft}
oninput={scheduleSave}
onblur={flush}
placeholder={PLACEHOLDER}
></textarea>
{:else if renderedHtml}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<article class="prose">{@html renderedHtml}</article>
{:else}
<button class="empty" onclick={() => (mode = 'edit')}>
<span>{PLACEHOLDER}</span>
<span class="hint">Klicken zum Bearbeiten</span>
</button>
{/if}
{#if doc?.content?.trim()}
<footer class="footer">
{#if noteSaved}
<button class="footer-btn ghost" onclick={handleClearKontext} title="Kontext leeren">
<Trash size={14} />
<span>Inhalt löschen</span>
</button>
{/if}
<button
class="footer-btn primary"
onclick={handleSaveAsNote}
disabled={noteSaving}
title="Aktuellen Kontext als Notiz kopieren"
>
<NotePencil size={14} />
<span
>{noteSaving
? 'Speichert…'
: noteSaved
? 'Als Notiz gespeichert ✓'
: 'Als Notiz speichern'}</span
>
<form class="crawl-form" onsubmit={handleImport}>
<div class="url-row">
<input
type="url"
bind:value={importUrl}
required
placeholder="https://example.com/article"
disabled={importing}
class="url-input"
/>
<button type="submit" disabled={importing || !importUrl.trim()} class="url-submit">
{#if importing}
{importPhase === 'crawling'
? 'Crawle…'
: importPhase === 'summarizing'
? 'Fasse zusammen…'
: 'Speichere…'}
{:else}
Importieren
{/if}
</button>
{#if noteError}
<p class="footer-error">{noteError}</p>
{/if}
</footer>
</div>
<div class="url-opts">
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
Nur diese Seite
</label>
<label class:disabled={importing}>
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
Ganze Website (max. 20)
</label>
<span class="url-sep">·</span>
<label class:disabled={importing}>
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
Mit KI zusammenfassen
</label>
</div>
{#if importing || importPhase !== 'idle'}
<ol class="phase-list" aria-live="polite">
{#each importPhases as phase (phase.key)}
<li class="phase" class:active={phase.active} class:done={phase.done}>
<span class="phase-dot" aria-hidden="true">
{#if phase.done}
{:else if phase.active}
<span class="phase-spinner"></span>
{:else}
·
{/if}
</span>
<span class="phase-label">{phase.label}</span>
{#if phase.active}
<span class="phase-elapsed">{importElapsed}s</span>
{/if}
</li>
{/each}
</ol>
{/if}
{#if importError}
<p class="error">{importError}</p>
{/if}
</form>
{#if successMessage}
<div class="success">
<p>{successMessage}</p>
</div>
{/if}
<div class="info">
<p>Importierte Inhalte werden im <strong>Freitext-Tab</strong> deines Profils gespeichert.</p>
<p>Der Crawler respektiert robots.txt und nutzt Rate-Limits.</p>
</div>
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1rem;
padding: 1rem;
height: 100%;
min-height: 0;
}
.bar {
.header {
display: flex;
gap: 0.75rem;
align-items: center;
}
.header-icon {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
flex-shrink: 0;
}
.status {
min-height: 1rem;
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.actions {
display: flex;
gap: 0.375rem;
}
.status-text {
font-size: 0.6875rem;
.subtitle {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.status-text.saved {
color: hsl(var(--color-success, var(--color-primary)));
}
.mode-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.mode-btn:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-ring));
}
.mode-btn.active {
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.url-panel {
.crawl-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
padding: 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.35);
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.url-row {
display: flex;
@ -460,53 +235,38 @@
.url-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.625rem;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-size: 0.875rem;
outline: none;
}
.url-input:focus {
border-color: hsl(var(--color-ring));
}
.url-submit {
padding: 0.375rem 0.75rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.url-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.url-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.url-close:hover:not(:disabled) {
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-ring));
}
.url-opts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.url-opts label {
@ -521,6 +281,7 @@
.url-sep {
opacity: 0.4;
}
.phase-list {
list-style: none;
margin: 0;
@ -534,9 +295,8 @@
grid-template-columns: 1rem 1fr auto;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
transition: color 0.15s;
}
.phase.active {
color: hsl(var(--color-primary));
@ -551,7 +311,6 @@
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
}
.phase-spinner {
width: 0.75rem;
@ -559,192 +318,43 @@
border: 1.5px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: phase-spin 0.8s linear infinite;
animation: spin 0.8s linear infinite;
}
@keyframes phase-spin {
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.phase-elapsed {
font-variant-numeric: tabular-nums;
font-size: 0.6875rem;
font-size: 0.75rem;
opacity: 0.7;
}
.url-error {
.error {
margin: 0;
font-size: 0.6875rem;
font-size: 0.8125rem;
color: hsl(var(--color-destructive, 0 84% 60%));
}
.editor {
flex: 1;
min-height: 0;
width: 100%;
background: transparent;
border: 1px solid hsl(var(--color-border));
.success {
padding: 0.75rem 1rem;
border: 1px solid hsl(142 71% 45% / 0.3);
border-radius: 0.5rem;
padding: 0.75rem 0.875rem;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
background: hsl(142 71% 45% / 0.08);
color: hsl(142 71% 45%);
font-size: 0.8125rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
resize: none;
outline: none;
}
.editor:focus {
border-color: hsl(var(--color-ring));
}
.editor::placeholder {
color: hsl(var(--color-muted-foreground));
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.375rem;
background: transparent;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.5rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: text;
}
.empty:hover {
border-color: hsl(var(--color-ring));
color: hsl(var(--color-foreground));
}
.empty .hint {
font-size: 0.6875rem;
opacity: 0.75;
}
.prose {
flex: 1;
overflow-y: auto;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
line-height: 1.6;
padding-right: 0.25rem;
}
.prose :global(h1),
.prose :global(h2),
.prose :global(h3),
.prose :global(h4) {
margin: 1.25em 0 0.5em;
font-weight: 600;
line-height: 1.3;
}
.prose :global(h1) {
font-size: 1.375rem;
}
.prose :global(h2) {
font-size: 1.15rem;
}
.prose :global(h3) {
font-size: 1rem;
}
.prose :global(p) {
margin: 0.5em 0;
}
.prose :global(ul),
.prose :global(ol) {
margin: 0.5em 0;
padding-left: 1.25rem;
}
.prose :global(li) {
margin: 0.125em 0;
}
.prose :global(code) {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
font-size: 0.8125em;
padding: 0.1em 0.35em;
border-radius: 0.25rem;
background: hsl(var(--color-muted) / 0.5);
}
.prose :global(pre) {
padding: 0.75rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.5);
overflow-x: auto;
font-size: 0.8125em;
line-height: 1.5;
}
.prose :global(pre code) {
background: transparent;
padding: 0;
}
.prose :global(blockquote) {
margin: 0.75em 0;
padding: 0.25em 0.75em;
border-left: 3px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
.prose :global(a) {
color: hsl(var(--color-primary));
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(hr) {
border: none;
border-top: 1px solid hsl(var(--color-border));
margin: 1.25em 0;
}
.prose :global(strong) {
font-weight: 600;
}
.footer {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--color-border) / 0.6);
}
.footer-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition:
background 0.15s,
border-color 0.15s;
}
.footer-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.footer-btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.footer-btn.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.footer-btn.ghost {
background: transparent;
border-color: hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
.footer-btn.ghost:hover {
color: hsl(var(--color-destructive, 0 84% 60%));
border-color: hsl(var(--color-destructive, 0 84% 60%) / 0.5);
}
.footer-error {
.success p {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-destructive, 0 84% 60%));
}
.info {
margin-top: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.info p {
margin: 0.25rem 0;
}
</style>

View file

@ -0,0 +1,471 @@
<!--
Context Freeform — Markdown editor for userContext.freeform.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { marked } from 'marked';
import { useUserContext } from './queries';
import { userContextStore } from './stores/user-context.svelte';
import { PencilSimple, Eye, LinkSimple, X } from '@mana/shared-icons';
import { crawlUrlViaApi, type CrawlMode } from '$lib/modules/kontext/api';
import { requireAuth } from '$lib/auth/require-auth.svelte';
const PLACEHOLDER = 'Was soll Mana sonst noch über dich wissen?';
const SAVE_DEBOUNCE_MS = 500;
let urlPanelOpen = $state(false);
let importUrl = $state('');
let importMode = $state<CrawlMode>('single');
let importSummarize = $state(false);
let importing = $state(false);
let importPhase = $state<'idle' | 'crawling' | 'summarizing' | 'appending'>('idle');
let importElapsed = $state(0);
let importError = $state<string | null>(null);
let ctx$ = useUserContext();
let ctx = $derived(ctx$.value);
let mode = $state<'view' | 'edit'>('view');
let draft = $state('');
let saveState = $state<'idle' | 'pending' | 'saved'>('idle');
let initialized = $state(false);
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let savedTimer: ReturnType<typeof setTimeout> | null = null;
onMount(() => {
void userContextStore.ensureDoc();
});
$effect(() => {
if (!ctx) return;
if (!initialized) {
draft = ctx.freeform;
initialized = true;
if (!ctx.freeform) mode = 'edit';
}
});
function scheduleSave() {
saveState = 'pending';
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
}
async function flush() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (ctx && draft === ctx.freeform) {
saveState = 'idle';
return;
}
await userContextStore.setFreeform(draft);
saveState = 'saved';
if (savedTimer) clearTimeout(savedTimer);
savedTimer = setTimeout(() => {
if (saveState === 'saved') saveState = 'idle';
}, 1500);
}
async function toggleMode() {
if (mode === 'edit') {
await flush();
mode = 'view';
} else {
if (ctx) draft = ctx.freeform;
mode = 'edit';
}
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') {
e.preventDefault();
void toggleMode();
}
}
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: 'context-url-import',
reason: 'Das Crawlen einer Web-Seite läuft serverseitig 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);
let phaseTimer: ReturnType<typeof setTimeout> | null = null;
if (importSummarize) {
phaseTimer = setTimeout(
() => {
if (importing) importPhase = 'summarizing';
},
importMode === 'deep' ? 25_000 : 4_000
);
}
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 userContextStore.appendFreeform(header + result.content);
if (mode === 'edit' && ctx) draft = ctx.freeform;
closeUrlPanel();
} catch (err) {
importError = err instanceof Error ? err.message : 'Import fehlgeschlagen';
} finally {
if (phaseTimer) clearTimeout(phaseTimer);
clearInterval(tick);
importing = false;
}
}
let renderedHtml = $derived.by(() => {
const source = ctx?.freeform ?? '';
if (!source.trim()) return '';
try {
return marked.parse(source, { async: false }) as string;
} catch {
return '';
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="freeform">
<header class="bar">
<div class="status">
{#if saveState === 'pending'}<span class="status-text">Speichert…</span>
{:else if saveState === 'saved'}<span class="status-text saved">Gespeichert</span>{/if}
</div>
<div class="actions">
<button
class="mode-btn"
class:active={urlPanelOpen}
onclick={() => (urlPanelOpen ? closeUrlPanel() : (urlPanelOpen = true))}
title="Web-Seite crawlen und anhängen"
>
<LinkSimple size={14} /><span>Aus URL</span>
</button>
<button class="mode-btn" onclick={toggleMode} title="Cmd/Ctrl + E">
{#if mode === 'view'}<PencilSimple size={14} /><span>Bearbeiten</span>
{:else}<Eye size={14} /><span>Ansicht</span>{/if}
</button>
</div>
</header>
{#if urlPanelOpen}
<form class="url-panel" onsubmit={handleImport}>
<div class="url-row">
<input
type="url"
bind:value={importUrl}
required
placeholder="https://example.com/article"
disabled={importing}
class="url-input"
/>
<button type="submit" disabled={importing || !importUrl.trim()} class="url-submit">
{#if importing}{importPhase === 'crawling'
? 'Crawle…'
: importPhase === 'summarizing'
? 'Fasse zusammen…'
: 'Speichere…'}{:else}Einfügen{/if}
</button>
<button
type="button"
onclick={closeUrlPanel}
disabled={importing}
class="url-close"
title="Schließen"><X size={14} /></button
>
</div>
<div class="url-opts">
<label class:disabled={importing}
><input type="radio" bind:group={importMode} value="single" disabled={importing} /> Nur diese
Seite</label
>
<label class:disabled={importing}
><input type="radio" bind:group={importMode} value="deep" disabled={importing} /> Ganze Website
(max. 20)</label
>
<span class="url-sep">·</span>
<label class:disabled={importing}
><input type="checkbox" bind:checked={importSummarize} disabled={importing} /> Mit KI zusammenfassen</label
>
</div>
{#if importError}<p class="url-error">{importError}</p>{/if}
</form>
{/if}
{#if mode === 'edit'}
<textarea
class="editor"
bind:value={draft}
oninput={scheduleSave}
onblur={flush}
placeholder={PLACEHOLDER}
></textarea>
{:else if renderedHtml}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<article class="prose">{@html renderedHtml}</article>
{:else}
<button class="empty" onclick={() => (mode = 'edit')}
><span>{PLACEHOLDER}</span><span class="hint">Klicken zum Bearbeiten</span></button
>
{/if}
</div>
<style>
.freeform {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
min-height: 0;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.status {
min-height: 1rem;
}
.actions {
display: flex;
gap: 0.375rem;
}
.status-text {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.status-text.saved {
color: hsl(var(--color-success, var(--color-primary)));
}
.mode-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.mode-btn:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-ring));
}
.mode-btn.active {
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.url-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.35);
}
.url-row {
display: flex;
align-items: stretch;
gap: 0.375rem;
}
.url-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
outline: none;
}
.url-input:focus {
border-color: hsl(var(--color-ring));
}
.url-submit {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.375rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.url-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.url-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.url-close:hover:not(:disabled) {
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-ring));
}
.url-opts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.url-opts label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
}
.url-opts label.disabled {
opacity: 0.5;
}
.url-sep {
opacity: 0.4;
}
.url-error {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-destructive, 0 84% 60%));
}
.editor {
flex: 1;
min-height: 0;
width: 100%;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
padding: 0.75rem 0.875rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
resize: none;
outline: none;
}
.editor:focus {
border-color: hsl(var(--color-ring));
}
.editor::placeholder {
color: hsl(var(--color-muted-foreground));
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.375rem;
background: transparent;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.5rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: text;
}
.empty:hover {
border-color: hsl(var(--color-ring));
color: hsl(var(--color-foreground));
}
.empty .hint {
font-size: 0.6875rem;
opacity: 0.75;
}
.prose {
flex: 1;
overflow-y: auto;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
line-height: 1.6;
}
.prose :global(h1),
.prose :global(h2),
.prose :global(h3) {
margin: 1.25em 0 0.5em;
font-weight: 600;
line-height: 1.3;
}
.prose :global(h1) {
font-size: 1.375rem;
}
.prose :global(h2) {
font-size: 1.15rem;
}
.prose :global(p) {
margin: 0.5em 0;
}
.prose :global(ul),
.prose :global(ol) {
margin: 0.5em 0;
padding-left: 1.25rem;
}
.prose :global(code) {
font-family: ui-monospace, monospace;
font-size: 0.8125em;
padding: 0.1em 0.35em;
border-radius: 0.25rem;
background: hsl(var(--color-muted) / 0.5);
}
.prose :global(pre) {
padding: 0.75rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.5);
overflow-x: auto;
}
.prose :global(blockquote) {
margin: 0.75em 0;
padding: 0.25em 0.75em;
border-left: 3px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
.prose :global(a) {
color: hsl(var(--color-primary));
text-decoration: underline;
}
.prose :global(hr) {
border: none;
border-top: 1px solid hsl(var(--color-border));
margin: 1.25em 0;
}
</style>

View file

@ -0,0 +1,567 @@
<!--
Context Interview — Guided question flow that populates userContext.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { useUserContext } from './queries';
import { userContextStore } from './stores/user-context.svelte';
import {
CATEGORIES,
getQuestionsByCategory,
getProgress,
type ContextCategory,
type ContextQuestion,
} from './questions';
interface Props {
limitCategories?: ContextCategory[];
compact?: boolean;
}
let { limitCategories, compact = false }: Props = $props();
let ctx$ = useUserContext();
let ctx = $derived(ctx$.value);
let activeCategory = $state<ContextCategory>('about');
let currentQuestionIdx = $state(0);
let inputValue = $state<unknown>('');
let saving = $state(false);
let tagInput = $state('');
onMount(() => {
void userContextStore.ensureDoc();
});
let categories = $derived(
limitCategories ? CATEGORIES.filter((c) => limitCategories!.includes(c.key)) : CATEGORIES
);
let categoryQuestions = $derived(getQuestionsByCategory(activeCategory));
let currentQuestion = $derived(
categoryQuestions[currentQuestionIdx] as ContextQuestion | undefined
);
let progress = $derived(getProgress(ctx?.interview?.answeredIds ?? []));
let answeredSet = $derived(new Set(ctx?.interview?.answeredIds ?? []));
let categoryProgress = $derived.by(() => {
const result: Record<string, { answered: number; total: number }> = {};
for (const cat of categories) {
const qs = getQuestionsByCategory(cat.key);
result[cat.key] = {
total: qs.length,
answered: qs.filter((q) => answeredSet.has(q.id)).length,
};
}
return result;
});
$effect(() => {
if (!currentQuestion || !ctx) return;
const val = getFieldValue(currentQuestion.field);
inputValue = val ?? '';
});
function getFieldValue(path: string): unknown {
if (!ctx) return undefined;
const [section, field] = path.split('.') as [keyof typeof ctx, string];
if (field) {
const sectionObj = ctx[section] as Record<string, unknown> | undefined;
return sectionObj?.[field];
}
return ctx[section];
}
function selectCategory(key: ContextCategory) {
activeCategory = key;
currentQuestionIdx = 0;
}
async function handleAnswer() {
if (!currentQuestion) return;
saving = true;
try {
await userContextStore.setField(currentQuestion.field, inputValue);
await userContextStore.markAnswered(currentQuestion.id);
advanceQuestion();
} finally {
saving = false;
}
}
async function handleSkip() {
if (!currentQuestion) return;
await userContextStore.markSkipped(currentQuestion.id);
advanceQuestion();
}
function advanceQuestion() {
if (currentQuestionIdx < categoryQuestions.length - 1) {
currentQuestionIdx++;
} else {
const currentIdx = categories.findIndex((c) => c.key === activeCategory);
for (let i = 1; i <= categories.length; i++) {
const next = categories[(currentIdx + i) % categories.length];
const qs = getQuestionsByCategory(next.key);
if (qs.some((q) => !answeredSet.has(q.id))) {
activeCategory = next.key;
currentQuestionIdx = 0;
return;
}
}
currentQuestionIdx = 0;
}
}
function handleTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag();
}
}
function addTag() {
const tag = tagInput.trim().replace(/,$/, '');
if (!tag) return;
const current = Array.isArray(inputValue) ? (inputValue as string[]) : [];
if (!current.includes(tag)) inputValue = [...current, tag];
tagInput = '';
}
function removeTag(tag: string) {
if (Array.isArray(inputValue))
inputValue = (inputValue as string[]).filter((t: string) => t !== tag);
}
function toggleWeekday(day: number) {
const current = Array.isArray(inputValue) ? (inputValue as number[]) : [];
if (current.includes(day)) inputValue = current.filter((d: number) => d !== day);
else inputValue = [...current, day].sort();
}
const WEEKDAYS = [
{ value: 1, short: 'Mo' },
{ value: 2, short: 'Di' },
{ value: 3, short: 'Mi' },
{ value: 4, short: 'Do' },
{ value: 5, short: 'Fr' },
{ value: 6, short: 'Sa' },
{ value: 0, short: 'So' },
];
</script>
<div class="interview" class:compact>
{#if !compact}
<div class="progress-bar">
<div class="progress-fill" style:width="{progress.percent}%"></div>
</div>
<p class="progress-text">{progress.answered} von {progress.total} Fragen beantwortet</p>
{/if}
<div class="categories">
{#each categories as cat (cat.key)}
{@const cp = categoryProgress[cat.key]}
<button
class="cat-btn"
class:active={activeCategory === cat.key}
onclick={() => selectCategory(cat.key)}
>
<span class="cat-label">{cat.label}</span>
{#if cp && cp.answered > 0}<span class="cat-badge">{cp.answered}/{cp.total}</span>{/if}
</button>
{/each}
</div>
{#if currentQuestion}
<div class="question-card">
<h3 class="question-text">{currentQuestion.question}</h3>
{#if currentQuestion.hint}<p class="question-hint">{currentQuestion.hint}</p>{/if}
<div class="input-area">
{#if currentQuestion.inputType === 'text'}
<input
type="text"
class="text-input"
bind:value={inputValue}
placeholder={currentQuestion.hint ?? ''}
disabled={saving}
onkeydown={(e) => e.key === 'Enter' && handleAnswer()}
/>
{:else if currentQuestion.inputType === 'textarea'}
<textarea
class="textarea-input"
bind:value={inputValue}
placeholder={currentQuestion.hint ?? ''}
disabled={saving}
rows="3"
></textarea>
{:else if currentQuestion.inputType === 'time'}
<input type="time" class="time-input" bind:value={inputValue} disabled={saving} />
{:else if currentQuestion.inputType === 'choice'}
<div class="choices">
{#each currentQuestion.choices ?? [] as choice (choice)}
<button
class="choice-btn"
class:selected={inputValue === choice}
onclick={() => (inputValue = choice)}
disabled={saving}>{choice}</button
>
{/each}
</div>
{:else if currentQuestion.inputType === 'tags'}
<div class="tags-input">
{#if Array.isArray(inputValue)}
<div class="tags-list">
{#each inputValue as tag (tag)}<span class="tag"
>{tag}<button class="tag-remove" onclick={() => removeTag(tag as string)}
>&times;</button
></span
>{/each}
</div>
{/if}
<input
type="text"
class="text-input"
bind:value={tagInput}
placeholder={currentQuestion.hint ?? 'Eingabe + Enter'}
disabled={saving}
onkeydown={handleTagKeydown}
onblur={addTag}
/>
</div>
{:else if currentQuestion.inputType === 'weekdays'}
<div class="weekdays">
{#each WEEKDAYS as day (day.value)}
<button
class="weekday-btn"
class:selected={Array.isArray(inputValue) && inputValue.includes(day.value)}
onclick={() => toggleWeekday(day.value)}
disabled={saving}>{day.short}</button
>
{/each}
</div>
{/if}
</div>
{#if answeredSet.has(currentQuestion.id)}<p class="already-answered">
Bereits beantwortet — du kannst die Antwort aktualisieren
</p>{/if}
<div class="actions">
<button class="action-btn secondary" onclick={handleSkip} disabled={saving}
>Überspringen</button
>
<button class="action-btn primary" onclick={handleAnswer} disabled={saving}>
{saving
? 'Speichert...'
: answeredSet.has(currentQuestion.id)
? 'Aktualisieren'
: 'Speichern'}
</button>
</div>
<div class="pagination">
{#each categoryQuestions as _, i (i)}
<button
class="page-dot"
class:active={i === currentQuestionIdx}
class:answered={answeredSet.has(categoryQuestions[i].id)}
onclick={() => (currentQuestionIdx = i)}
></button>
{/each}
</div>
</div>
{:else}
<div class="all-done"><p>Alle Fragen in dieser Kategorie sind beantwortet!</p></div>
{/if}
</div>
<style>
.interview {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
}
.progress-bar {
height: 4px;
background: hsl(var(--color-border));
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: hsl(var(--color-primary));
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-text {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
.categories {
display: flex;
gap: 0.375rem;
overflow-x: auto;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.categories::-webkit-scrollbar {
display: none;
}
.cat-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 999px;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
white-space: nowrap;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.cat-btn:hover {
background: hsl(var(--color-surface-hover));
}
.cat-btn.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.cat-badge {
font-size: 0.625rem;
padding: 0 0.375rem;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
border-radius: 999px;
}
.question-card {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.question-text {
margin: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
}
.question-hint {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.input-area {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.text-input,
.time-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
}
.text-input:focus,
.time-input:focus {
border-color: hsl(var(--color-ring));
}
.time-input {
width: auto;
}
.textarea-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
resize: vertical;
font-family: inherit;
}
.textarea-input:focus {
border-color: hsl(var(--color-ring));
}
.choices {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.choice-btn {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 999px;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.choice-btn:hover {
background: hsl(var(--color-surface-hover));
}
.choice-btn.selected {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.tags-input {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
border-radius: 999px;
font-size: 0.75rem;
}
.tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: none;
background: transparent;
color: inherit;
font-size: 0.875rem;
cursor: pointer;
padding: 0;
line-height: 1;
opacity: 0.7;
}
.tag-remove:hover {
opacity: 1;
}
.weekdays {
display: flex;
gap: 0.375rem;
}
.weekday-btn {
width: 2.5rem;
height: 2.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.weekday-btn:hover {
background: hsl(var(--color-surface-hover));
}
.weekday-btn.selected {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.already-answered {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.actions {
display: flex;
gap: 0.5rem;
padding-top: 0.25rem;
}
.action-btn {
flex: 1;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.action-btn.primary {
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.action-btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.action-btn.primary:disabled {
opacity: 0.6;
}
.action-btn.secondary {
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.action-btn.secondary:hover {
background: hsl(var(--color-surface-hover));
}
.pagination {
display: flex;
justify-content: center;
gap: 0.375rem;
padding-top: 0.25rem;
}
.page-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
border: 1px solid hsl(var(--color-border));
background: transparent;
padding: 0;
cursor: pointer;
transition: background 0.15s;
}
.page-dot.active {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
}
.page-dot.answered {
background: hsl(var(--color-primary) / 0.4);
border-color: hsl(var(--color-primary) / 0.4);
}
.all-done {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.compact .question-card {
border: none;
padding: 0;
}
</style>

View file

@ -0,0 +1,471 @@
<!--
Context Overview — "Freundebuch" style profile cards.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { useUserContext } from './queries';
import { userContextStore } from './stores/user-context.svelte';
import { getProgress } from './questions';
import type { UserProfile } from '$lib/api/profile';
interface Props {
user: UserProfile | null;
onStartInterview: () => void;
}
let { user, onStartInterview }: Props = $props();
let ctx$ = useUserContext();
let ctx = $derived(ctx$.value);
let progress = $derived(getProgress(ctx?.interview?.answeredIds ?? []));
let editingField = $state<string | null>(null);
let editValue = $state<string | string[]>('');
let tagInput = $state('');
onMount(() => {
void userContextStore.ensureDoc();
});
function startEdit(field: string, current: unknown) {
editingField = field;
editValue = (current ?? '') as string | string[];
tagInput = '';
}
async function saveEdit(field: string) {
await userContextStore.setField(field, editValue);
editingField = null;
}
function cancelEdit() {
editingField = null;
}
function handleEditKeydown(e: KeyboardEvent, field: string) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveEdit(field);
} else if (e.key === 'Escape') cancelEdit();
}
function addEditTag() {
const tag = tagInput.trim().replace(/,$/, '');
if (!tag) return;
const current = Array.isArray(editValue) ? editValue : [];
if (!current.includes(tag)) editValue = [...current, tag];
tagInput = '';
}
function removeEditTag(tag: string) {
if (Array.isArray(editValue)) editValue = editValue.filter((t) => t !== tag);
}
const WEEKDAY_NAMES: Record<number, string> = {
0: 'So',
1: 'Mo',
2: 'Di',
3: 'Mi',
4: 'Do',
5: 'Fr',
6: 'Sa',
};
</script>
<div class="overview">
<div class="identity-card">
<div class="avatar-area">
{#if user?.image}<img src={user.image} alt="Avatar" class="avatar" />
{:else}<div class="avatar-placeholder">
{(user?.name ?? 'U').slice(0, 2).toUpperCase()}
</div>{/if}
</div>
<div class="identity-info">
<h2 class="user-name">{user?.name ?? 'Unbekannt'}</h2>
<p class="user-email">{user?.email ?? ''}</p>
{#if ctx?.about?.occupation}<p class="user-meta">{ctx.about.occupation}</p>{/if}
{#if ctx?.about?.location}<p class="user-meta">{ctx.about.location}</p>{/if}
</div>
</div>
{#if progress.percent < 50}
<button class="nudge-card" onclick={onStartInterview}>
<div class="nudge-bar"><div class="nudge-fill" style:width="{progress.percent}%"></div></div>
<p class="nudge-text">
Profil zu {progress.percent}% ausgefüllt — <strong>Interview starten</strong>
</p>
</button>
{/if}
<div class="sections">
{#if ctx?.about?.bio || editingField === 'about.bio'}
<section class="section-card">
<h3 class="section-title">Über mich</h3>
{#if editingField === 'about.bio'}
<textarea
class="edit-textarea"
bind:value={editValue}
onkeydown={(e) => e.key === 'Escape' && cancelEdit()}
rows="3"
></textarea>
<div class="edit-actions">
<button class="edit-btn" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn primary" onclick={() => saveEdit('about.bio')}>Speichern</button
>
</div>
{:else}<p class="section-text" onclick={() => startEdit('about.bio', ctx?.about?.bio)}>
{ctx?.about?.bio}
</p>{/if}
</section>
{/if}
<section class="section-card">
<h3 class="section-title">Interessen</h3>
{#if editingField === 'interests'}
<div class="tags-edit">
<div class="tags-list">
{#each editValue as string[] as tag (tag)}<span class="tag"
>{tag}<button class="tag-remove" onclick={() => removeEditTag(tag)}>&times;</button
></span
>{/each}
</div>
<input
type="text"
class="edit-input"
bind:value={tagInput}
placeholder="Neues Interesse + Enter"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addEditTag();
}
}}
onblur={addEditTag}
/>
<div class="edit-actions">
<button class="edit-btn" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn primary" onclick={() => saveEdit('interests')}>Speichern</button
>
</div>
</div>
{:else if ctx?.interests?.length}
<div class="tags-list" onclick={() => startEdit('interests', ctx?.interests ?? [])}>
{#each ctx.interests as tag (tag)}<span class="tag">{tag}</span>{/each}
</div>
{:else}
<button
class="empty-hint"
onclick={() => {
editValue = [];
editingField = 'interests';
}}>Interessen hinzufügen</button
>
{/if}
</section>
{#if ctx?.routine && (ctx.routine.wakeUp || ctx.routine.workStart || ctx.routine.bedtime || ctx.routine.workDays?.length)}
<section class="section-card">
<h3 class="section-title">Tagesablauf</h3>
<div class="routine-grid">
{#if ctx.routine.wakeUp}<div class="routine-item">
<span class="routine-label">Aufstehen</span><span class="routine-value"
>{ctx.routine.wakeUp}</span
>
</div>{/if}
{#if ctx.routine.workStart && ctx.routine.workEnd}<div class="routine-item">
<span class="routine-label">Arbeit</span><span class="routine-value"
>{ctx.routine.workStart} {ctx.routine.workEnd}</span
>
</div>{/if}
{#if ctx.routine.bedtime}<div class="routine-item">
<span class="routine-label">Schlafenszeit</span><span class="routine-value"
>{ctx.routine.bedtime}</span
>
</div>{/if}
{#if ctx.routine.workDays?.length}<div class="routine-item">
<span class="routine-label">Arbeitstage</span><span class="routine-value"
>{ctx.routine.workDays.map((d: number) => WEEKDAY_NAMES[d]).join(', ')}</span
>
</div>{/if}
</div>
</section>
{/if}
{#if ctx?.nutrition && (ctx.nutrition.diet || ctx.nutrition.allergies?.length)}
<section class="section-card">
<h3 class="section-title">Ernährung</h3>
{#if ctx.nutrition.diet}<p class="section-text">{ctx.nutrition.diet}</p>{/if}
{#if ctx.nutrition.allergies?.length}<div class="tags-list">
{#each ctx.nutrition.allergies as a (a)}<span class="tag warning">{a}</span>{/each}
</div>{/if}
{#if ctx.nutrition.preferences}<p class="section-detail">
{ctx.nutrition.preferences}
</p>{/if}
</section>
{/if}
{#if ctx?.goals?.length}
<section class="section-card">
<h3 class="section-title">Ziele</h3>
<div class="tags-list" onclick={() => startEdit('goals', ctx?.goals ?? [])}>
{#each ctx.goals as goal (goal)}<span class="tag accent">{goal}</span>{/each}
</div>
</section>
{/if}
{#if ctx?.social && (ctx.social.workStyle || ctx.social.communication)}
<section class="section-card">
<h3 class="section-title">Arbeitsstil</h3>
<div class="routine-grid">
{#if ctx.social.workStyle}<div class="routine-item">
<span class="routine-label">Arbeitsweise</span><span class="routine-value"
>{ctx.social.workStyle}</span
>
</div>{/if}
{#if ctx.social.communication}<div class="routine-item">
<span class="routine-label">Kommunikation</span><span class="routine-value"
>{ctx.social.communication}</span
>
</div>{/if}
</div>
</section>
{/if}
{#if ctx?.about?.languages?.length}
<section class="section-card">
<h3 class="section-title">Sprachen</h3>
<div class="tags-list">
{#each ctx.about.languages as lang (lang)}<span class="tag">{lang}</span>{/each}
</div>
</section>
{/if}
</div>
</div>
<style>
.overview {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.identity-card {
display: flex;
gap: 1rem;
align-items: center;
padding: 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.avatar,
.avatar-placeholder {
width: 4rem;
height: 4rem;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 1.25rem;
font-weight: 600;
}
.identity-info {
min-width: 0;
}
.user-name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.user-email,
.user-meta {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.nudge-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: 1px dashed hsl(var(--color-primary) / 0.4);
border-radius: 0.75rem;
background: hsl(var(--color-primary) / 0.04);
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
}
.nudge-card:hover {
border-color: hsl(var(--color-primary));
}
.nudge-bar {
height: 3px;
background: hsl(var(--color-border));
border-radius: 2px;
overflow: hidden;
}
.nudge-fill {
height: 100%;
background: hsl(var(--color-primary));
border-radius: 2px;
}
.nudge-text {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.sections {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-card {
padding: 0.75rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.section-title {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.section-text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
cursor: pointer;
border-radius: 0.25rem;
padding: 0.125rem 0;
}
.section-text:hover {
background: hsl(var(--color-surface-hover));
}
.section-detail {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
cursor: pointer;
border-radius: 0.25rem;
padding: 0.125rem 0;
}
.tags-list:hover {
background: hsl(var(--color-surface-hover));
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
border-radius: 999px;
font-size: 0.75rem;
}
.tag.warning {
background: hsl(var(--color-destructive, 0 84% 60%) / 0.1);
color: hsl(var(--color-destructive, 0 84% 60%));
}
.tag.accent {
background: hsl(142 71% 45% / 0.1);
color: hsl(142 71% 45%);
}
.tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
opacity: 0.7;
}
.tag-remove:hover {
opacity: 1;
}
.routine-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.routine-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.routine-label {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.routine-value {
font-size: 0.875rem;
font-weight: 500;
}
.empty-hint {
display: block;
width: 100%;
padding: 0.5rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
text-align: left;
}
.empty-hint:hover {
border-color: hsl(var(--color-ring));
color: hsl(var(--color-foreground));
}
.tags-edit {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.edit-input,
.edit-textarea {
width: 100%;
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-ring));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
font-family: inherit;
outline: none;
}
.edit-textarea {
resize: vertical;
}
.edit-actions {
display: flex;
gap: 0.375rem;
justify-content: flex-end;
}
.edit-btn {
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
}
.edit-btn.primary {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
</style>

View file

@ -1,27 +1,29 @@
<!--
Profile — Workbench-embedded profile page with account info,
edit/password/delete modals, and logout action.
Profile — Context Hub with tabs: Übersicht, Interview, Freitext, Konto.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@mana/shared-ui';
import type { UserProfile, ProfileActions } from '@mana/shared-ui';
import { profileService, type UserProfile as ApiUserProfile } from '$lib/api/profile';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { profileService, type UserProfile as ApiUserProfile } from '$lib/api/profile';
import {
EditProfileModal,
ChangePasswordModal,
DeleteAccountModal,
} from '$lib/components/profile';
import ContextOverview from './ContextOverview.svelte';
import ContextInterview from './ContextInterview.svelte';
import ContextFreeform from './ContextFreeform.svelte';
type Tab = 'overview' | 'interview' | 'freeform' | 'account';
let apiProfile = $state<ApiUserProfile | null>(null);
let loading = $state(true);
let activeTab = $state<Tab>('overview');
let showEditModal = $state(false);
let showPasswordModal = $state(false);
let showDeleteModal = $state(false);
let toastMessage = $state<string | null>(null);
onMount(async () => {
@ -34,23 +36,12 @@
}
});
let userProfile = $derived<UserProfile>({
id: apiProfile?.id || authStore.user?.id || '',
email: apiProfile?.email || authStore.user?.email || '',
displayName: apiProfile?.name || undefined,
role: apiProfile?.role || authStore.user?.role,
createdAt: apiProfile?.createdAt,
});
const actions: ProfileActions = {
onEditProfile: () => (showEditModal = true),
onChangePassword: () => (showPasswordModal = true),
onLogout: async () => {
await authStore.signOut();
goto('/login');
},
onDeleteAccount: () => (showDeleteModal = true),
};
const TABS: { key: Tab; label: string }[] = [
{ key: 'overview', label: 'Übersicht' },
{ key: 'interview', label: 'Interview' },
{ key: 'freeform', label: 'Freitext' },
{ key: 'account', label: 'Konto' },
];
function handleProfileUpdate(user: ApiUserProfile) {
apiProfile = user;
@ -79,24 +70,68 @@
<div class="spinner"></div>
</div>
{:else}
<ProfilePage
user={userProfile}
appName="Mana"
{actions}
pageTitle="Profil"
accountInfoTitle="Konto-Informationen"
actionsTitle="Aktionen"
emailLabel="E-Mail"
nameLabel="Name"
memberSinceLabel="Mitglied seit"
lastLoginLabel="Letzter Login"
roleLabel="Rolle"
editProfileLabel="Profil bearbeiten"
changePasswordLabel="Passwort ändern"
logoutLabel="Abmelden"
deleteAccountLabel="Konto löschen"
deleteAccountWarning="Diese Aktion kann nicht rückgängig gemacht werden."
/>
<!-- Tab bar -->
<nav class="tab-bar">
{#each TABS as tab (tab.key)}
<button
class="tab-btn"
class:active={activeTab === tab.key}
onclick={() => (activeTab = tab.key)}
>
{tab.label}
</button>
{/each}
</nav>
<!-- Tab content -->
<div class="tab-content">
{#if activeTab === 'overview'}
<ContextOverview user={apiProfile} onStartInterview={() => (activeTab = 'interview')} />
{:else if activeTab === 'interview'}
<ContextInterview />
{:else if activeTab === 'freeform'}
<ContextFreeform />
{:else if activeTab === 'account'}
<div class="account-section">
<div class="account-card">
<div class="account-header">
{#if apiProfile?.image}
<img src={apiProfile.image} alt="Avatar" class="account-avatar" />
{:else}
<div class="account-avatar-placeholder">
{(apiProfile?.name ?? 'U').slice(0, 2).toUpperCase()}
</div>
{/if}
<div>
<p class="account-name">{apiProfile?.name ?? ''}</p>
<p class="account-email">{apiProfile?.email ?? ''}</p>
</div>
</div>
</div>
<div class="account-actions">
<button class="account-btn" onclick={() => (showEditModal = true)}>
Profil bearbeiten
</button>
<button class="account-btn" onclick={() => (showPasswordModal = true)}>
Passwort ändern
</button>
<button
class="account-btn"
onclick={async () => {
await authStore.signOut();
goto('/login');
}}
>
Abmelden
</button>
<button class="account-btn danger" onclick={() => (showDeleteModal = true)}>
Konto löschen
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
@ -126,18 +161,17 @@
<style>
.profile-page {
padding: 0.75rem;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
overflow: hidden;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 0;
}
.spinner {
width: 2rem;
height: 2rem;
@ -146,13 +180,115 @@
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.tab-bar {
display: flex;
gap: 0;
padding: 0 0.75rem;
border-bottom: 1px solid hsl(var(--color-border));
flex-shrink: 0;
}
.tab-btn {
padding: 0.625rem 0.875rem;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.tab-btn:hover {
color: hsl(var(--color-foreground));
}
.tab-btn.active {
color: hsl(var(--color-primary));
border-bottom-color: hsl(var(--color-primary));
}
.tab-content {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
min-height: 0;
}
.account-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.account-card {
padding: 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-card));
}
.account-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.account-avatar,
.account-avatar-placeholder {
width: 3rem;
height: 3rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.account-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 1rem;
font-weight: 600;
}
.account-name {
margin: 0;
font-weight: 600;
font-size: 0.9375rem;
}
.account-email {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.account-actions {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.account-btn {
display: flex;
align-items: center;
width: 100%;
padding: 0.625rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s;
}
.account-btn:hover {
background: hsl(var(--color-surface-hover));
}
.account-btn.danger {
color: hsl(var(--color-destructive, 0 84% 60%));
border-color: hsl(var(--color-destructive, 0 84% 60%) / 0.3);
}
.account-btn.danger:hover {
background: hsl(var(--color-destructive, 0 84% 60%) / 0.08);
}
.toast {
position: fixed;
bottom: 1rem;
@ -166,7 +302,6 @@
font-size: 0.875rem;
animation: fade-in 0.2s ease-out;
}
@keyframes fade-in {
from {
opacity: 0;

View file

@ -0,0 +1,8 @@
/**
* Profile module Dexie table accessors.
*/
import { db } from '$lib/data/database';
import type { LocalUserContext } from './types';
export const userContextTable = db.table<LocalUserContext>('userContext');

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const profileModuleConfig: ModuleConfig = {
appId: 'profile',
tables: [{ name: 'userContext' }],
};

View file

@ -0,0 +1,18 @@
/**
* Profile module read-side queries.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { userContextTable } from './collections';
import { USER_CONTEXT_SINGLETON_ID, toUserContext, type UserContext } from './types';
/** Reactive live-query for the user context singleton. */
export function useUserContext() {
return useLiveQueryWithDefault<UserContext | null>(async () => {
const local = await userContextTable.get(USER_CONTEXT_SINGLETON_ID);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('userContext', [local]);
return toUserContext(decrypted);
}, null);
}

View file

@ -0,0 +1,325 @@
/**
* Context Interview Static question bank.
*
* ~30 questions across 7 categories. Each question maps to a field
* path in LocalUserContext so answers write directly into the store.
*/
export type ContextCategory =
| 'about'
| 'routine'
| 'nutrition'
| 'work'
| 'leisure'
| 'goals'
| 'social';
export type QuestionInputType = 'text' | 'textarea' | 'tags' | 'time' | 'choice' | 'weekdays';
export interface ContextQuestion {
id: string;
category: ContextCategory;
question: string;
hint?: string;
inputType: QuestionInputType;
choices?: string[];
/** Dot-path into LocalUserContext, e.g. 'about.occupation' or 'interests'. */
field: string;
/** Sort priority within category (lower = asked first). */
priority: number;
}
export interface CategoryMeta {
key: ContextCategory;
label: string;
icon: string;
description: string;
}
export const CATEGORIES: CategoryMeta[] = [
{
key: 'about',
label: 'Über mich',
icon: 'user',
description: 'Wer du bist — Hintergrund, Herkunft, Sprachen',
},
{
key: 'routine',
label: 'Tagesablauf',
icon: 'clock',
description: 'Dein typischer Tag — Aufstehen, Arbeiten, Schlafen',
},
{
key: 'nutrition',
label: 'Ernährung',
icon: 'bowl',
description: 'Wie du dich ernährst — Diät, Allergien, Vorlieben',
},
{
key: 'work',
label: 'Arbeit',
icon: 'briefcase',
description: 'Was du machst — Beruf, Projekte, Arbeitsstil',
},
{
key: 'leisure',
label: 'Freizeit',
icon: 'star',
description: 'Was dich begeistert — Hobbys, Interessen, Medien',
},
{
key: 'goals',
label: 'Ziele',
icon: 'target',
description: 'Wohin du willst — Persönliche und berufliche Ziele',
},
{
key: 'social',
label: 'Soziales',
icon: 'people',
description: 'Wie du mit anderen umgehst — Kommunikation, Stil',
},
];
export const QUESTIONS: ContextQuestion[] = [
// ── About
{
id: 'about.occupation',
category: 'about',
question: 'Was machst du beruflich?',
hint: 'z.B. Softwareentwicklerin, Student, Freelancerin',
inputType: 'text',
field: 'about.occupation',
priority: 1,
},
{
id: 'about.location',
category: 'about',
question: 'Wo lebst du?',
hint: 'z.B. Berlin, München, Wien',
inputType: 'text',
field: 'about.location',
priority: 2,
},
{
id: 'about.languages',
category: 'about',
question: 'Welche Sprachen sprichst du?',
hint: 'z.B. Deutsch, Englisch, Spanisch',
inputType: 'tags',
field: 'about.languages',
priority: 3,
},
{
id: 'about.birthday',
category: 'about',
question: 'Wann hast du Geburtstag?',
hint: 'Damit Mana dich rechtzeitig erinnern kann',
inputType: 'text',
field: 'about.birthday',
priority: 4,
},
{
id: 'about.bio',
category: 'about',
question: 'Erzähl kurz von dir',
hint: 'Ein paar Sätze — was macht dich aus?',
inputType: 'textarea',
field: 'about.bio',
priority: 5,
},
// ── Routine
{
id: 'routine.wakeUp',
category: 'routine',
question: 'Wann stehst du normalerweise auf?',
inputType: 'time',
field: 'routine.wakeUp',
priority: 1,
},
{
id: 'routine.workStart',
category: 'routine',
question: 'Wann fängt dein Arbeitstag an?',
inputType: 'time',
field: 'routine.workStart',
priority: 2,
},
{
id: 'routine.workEnd',
category: 'routine',
question: 'Wann ist Feierabend?',
inputType: 'time',
field: 'routine.workEnd',
priority: 3,
},
{
id: 'routine.bedtime',
category: 'routine',
question: 'Wann gehst du normalerweise schlafen?',
inputType: 'time',
field: 'routine.bedtime',
priority: 4,
},
{
id: 'routine.workDays',
category: 'routine',
question: 'An welchen Tagen arbeitest du?',
inputType: 'weekdays',
field: 'routine.workDays',
priority: 5,
},
// ── Nutrition
{
id: 'nutrition.diet',
category: 'nutrition',
question: 'Wie ernährst du dich?',
inputType: 'choice',
choices: ['Omnivor', 'Vegetarisch', 'Vegan', 'Pescetarisch', 'Flexitarisch', 'Anderes'],
field: 'nutrition.diet',
priority: 1,
},
{
id: 'nutrition.allergies',
category: 'nutrition',
question: 'Hast du Allergien oder Unverträglichkeiten?',
hint: 'z.B. Nüsse, Laktose, Gluten',
inputType: 'tags',
field: 'nutrition.allergies',
priority: 2,
},
{
id: 'nutrition.preferences',
category: 'nutrition',
question: 'Gibt es Ernährungs-Vorlieben oder -Ziele?',
hint: 'z.B. weniger Zucker, mehr Protein, intermittierendes Fasten',
inputType: 'textarea',
field: 'nutrition.preferences',
priority: 3,
},
// ── Work
{
id: 'social.workStyle',
category: 'work',
question: 'Wie arbeitest du am liebsten?',
inputType: 'choice',
choices: ['Solo / Deep Work', 'Im Team', 'Hybrid', 'Kommt drauf an'],
field: 'social.workStyle',
priority: 1,
},
{
id: 'social.communication',
category: 'work',
question: 'Wie würdest du deinen Kommunikationsstil beschreiben?',
inputType: 'choice',
choices: ['Direkt & klar', 'Diplomatisch', 'Zurückhaltend', 'Ausführlich'],
field: 'social.communication',
priority: 2,
},
// ── Leisure
{
id: 'interests',
category: 'leisure',
question: 'Was sind deine Interessen und Hobbys?',
hint: 'z.B. Kochen, Fotografie, Wandern, Gaming, Musik',
inputType: 'tags',
field: 'interests',
priority: 1,
},
{
id: 'leisure.favoriteMedia',
category: 'leisure',
question: 'Welche Medien konsumierst du gerne?',
hint: 'z.B. Podcasts, Bücher, Serien, YouTube',
inputType: 'tags',
field: 'about.bio',
priority: 2,
},
{
id: 'leisure.sports',
category: 'leisure',
question: 'Treibst du Sport? Wenn ja, welchen?',
hint: 'z.B. Laufen, Yoga, Krafttraining, Fußball',
inputType: 'tags',
field: 'interests',
priority: 3,
},
// ── Goals
{
id: 'goals.current',
category: 'goals',
question: 'Was sind deine aktuellen Ziele?',
hint: 'z.B. Mehr Sport, Buch schreiben, Neues lernen',
inputType: 'tags',
field: 'goals',
priority: 1,
},
{
id: 'goals.focus',
category: 'goals',
question: 'Worauf fokussierst du dich gerade am meisten?',
hint: 'Was treibt dich aktuell an?',
inputType: 'textarea',
field: 'about.bio',
priority: 2,
},
{
id: 'goals.learn',
category: 'goals',
question: 'Gibt es etwas, das du gerade lernst oder lernen willst?',
hint: 'z.B. eine Sprache, ein Instrument, Programmieren',
inputType: 'tags',
field: 'goals',
priority: 3,
},
// ── Social
{
id: 'social.living',
category: 'social',
question: 'Wie lebst du?',
inputType: 'choice',
choices: ['Allein', 'Mit Partner/in', 'WG', 'Familie', 'Anderes'],
field: 'about.bio',
priority: 1,
},
{
id: 'social.pets',
category: 'social',
question: 'Hast du Haustiere?',
hint: 'z.B. Hund, Katze, Fische',
inputType: 'text',
field: 'about.bio',
priority: 2,
},
];
/** Get questions for a specific category, sorted by priority. */
export function getQuestionsByCategory(category: ContextCategory): ContextQuestion[] {
return QUESTIONS.filter((q) => q.category === category).sort((a, b) => a.priority - b.priority);
}
/** Get the next unanswered question across all categories. */
export function getNextUnanswered(
answeredIds: string[],
skippedIds: string[]
): ContextQuestion | null {
const done = new Set([...answeredIds, ...skippedIds]);
return QUESTIONS.find((q) => !done.has(q.id)) ?? null;
}
/** Completion stats. */
export function getProgress(answeredIds: string[]): {
answered: number;
total: number;
percent: number;
} {
const total = QUESTIONS.length;
const answered = answeredIds.length;
return { answered, total, percent: total > 0 ? Math.round((answered / total) * 100) : 0 };
}

View file

@ -0,0 +1,154 @@
/**
* User Context Store structured profile + freeform markdown.
*
* All encrypted fields are encrypted before write, decrypted on read.
* The interview progress field is NOT encrypted (structural metadata only).
*/
import { userContextTable } from '../collections';
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
import {
USER_CONTEXT_SINGLETON_ID,
emptyUserContext,
type LocalUserContext,
type UserContextAbout,
type UserContextRoutine,
type UserContextNutrition,
type UserContextSocial,
} from '../types';
async function ensureDoc(): Promise<void> {
const existing = await userContextTable.get(USER_CONTEXT_SINGLETON_ID);
if (existing) return;
const doc = emptyUserContext() as LocalUserContext;
await encryptRecord('userContext', doc);
await userContextTable.add(doc);
}
async function readDecrypted(): Promise<LocalUserContext> {
await ensureDoc();
const local = (await userContextTable.get(USER_CONTEXT_SINGLETON_ID))!;
const [decrypted] = await decryptRecords('userContext', [local]);
return decrypted;
}
export const userContextStore = {
ensureDoc,
/** Replace a full section (about, routine, nutrition, social). */
async updateSection<K extends 'about' | 'routine' | 'nutrition' | 'social'>(
section: K,
value: K extends 'about'
? UserContextAbout
: K extends 'routine'
? UserContextRoutine
: K extends 'nutrition'
? UserContextNutrition
: UserContextSocial
): Promise<void> {
await ensureDoc();
const current = await readDecrypted();
const merged = { ...current[section], ...value };
const diff: Partial<LocalUserContext> = {
[section]: merged,
updatedAt: new Date().toISOString(),
};
await encryptRecord('userContext', diff);
await userContextTable.update(USER_CONTEXT_SINGLETON_ID, diff);
},
/** Set a single field within a section. */
async setField(path: string, value: unknown): Promise<void> {
await ensureDoc();
const current = await readDecrypted();
const [section, field] = path.split('.') as [keyof LocalUserContext, string];
let diff: Partial<LocalUserContext>;
if (field && typeof current[section] === 'object' && !Array.isArray(current[section])) {
// Nested field: e.g. 'about.occupation'
const sectionObj = { ...(current[section] as Record<string, unknown>) };
sectionObj[field] = value;
diff = { [section]: sectionObj, updatedAt: new Date().toISOString() };
} else {
// Top-level field: e.g. 'interests', 'goals'
diff = { [section]: value, updatedAt: new Date().toISOString() } as Partial<LocalUserContext>;
}
await encryptRecord('userContext', diff);
await userContextTable.update(USER_CONTEXT_SINGLETON_ID, diff);
},
/** Replace the interests array. */
async setInterests(interests: string[]): Promise<void> {
await ensureDoc();
const diff: Partial<LocalUserContext> = {
interests,
updatedAt: new Date().toISOString(),
};
await encryptRecord('userContext', diff);
await userContextTable.update(USER_CONTEXT_SINGLETON_ID, diff);
},
/** Replace the goals array. */
async setGoals(goals: string[]): Promise<void> {
await ensureDoc();
const diff: Partial<LocalUserContext> = {
goals,
updatedAt: new Date().toISOString(),
};
await encryptRecord('userContext', diff);
await userContextTable.update(USER_CONTEXT_SINGLETON_ID, diff);
},
/** Set freeform markdown content. */
async setFreeform(content: string): Promise<void> {
await ensureDoc();
const diff: Partial<LocalUserContext> = {
freeform: content,
updatedAt: new Date().toISOString(),
};
await encryptRecord('userContext', diff);
await userContextTable.update(USER_CONTEXT_SINGLETON_ID, diff);
},
/** Append text to freeform with a separator. */
async appendFreeform(chunk: string): Promise<void> {
const current = await readDecrypted();
const existing = current.freeform?.trim() ?? '';
const separator = existing ? '\n\n---\n\n' : '';
await this.setFreeform(`${existing}${separator}${chunk}`);
},
/** Mark a question as answered in the interview progress. */
async markAnswered(questionId: string): Promise<void> {
await ensureDoc();
const current = await readDecrypted();
const interview = { ...current.interview };
if (!interview.answeredIds.includes(questionId)) {
interview.answeredIds = [...interview.answeredIds, questionId];
}
interview.skippedIds = interview.skippedIds.filter((id) => id !== questionId);
interview.lastSessionAt = new Date().toISOString();
// interview is not encrypted — update directly
await userContextTable.update(USER_CONTEXT_SINGLETON_ID, {
interview,
updatedAt: new Date().toISOString(),
});
},
/** Mark a question as skipped. */
async markSkipped(questionId: string): Promise<void> {
await ensureDoc();
const current = await readDecrypted();
const interview = { ...current.interview };
if (!interview.skippedIds.includes(questionId)) {
interview.skippedIds = [...interview.skippedIds, questionId];
}
interview.lastSessionAt = new Date().toISOString();
await userContextTable.update(USER_CONTEXT_SINGLETON_ID, {
interview,
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,110 @@
/**
* User Context types structured profile + freeform markdown.
*
* Single singleton record that holds everything Mana knows about the user.
* Encrypted at rest (all fields except id and interview state).
*/
import type { BaseRecord } from '@mana/local-store';
export const USER_CONTEXT_SINGLETON_ID = 'singleton' as const;
// ── Structured sections ────────────────────────────────────
export interface UserContextAbout {
bio?: string;
occupation?: string;
location?: string;
birthday?: string; // YYYY-MM-DD
languages?: string[];
}
export interface UserContextRoutine {
wakeUp?: string; // "07:00"
workStart?: string;
workEnd?: string;
bedtime?: string;
workDays?: number[]; // [1,2,3,4,5] = Mon-Fri
}
export interface UserContextNutrition {
diet?: string; // "Vegetarisch", "Vegan", "Omnivor", …
allergies?: string[];
preferences?: string; // freeform
}
export interface UserContextSocial {
communication?: string; // "Direkt", "Diplomatisch"
workStyle?: string; // "Solo", "Team", "Hybrid"
}
export interface InterviewProgress {
answeredIds: string[];
skippedIds: string[];
lastSessionAt?: string; // ISO
}
// ── Main record ────────────────────────────────────────────
export interface LocalUserContext extends BaseRecord {
id: typeof USER_CONTEXT_SINGLETON_ID;
about: UserContextAbout;
interests: string[];
routine: UserContextRoutine;
nutrition: UserContextNutrition;
goals: string[];
social: UserContextSocial;
/** Freeform markdown — "Was soll Mana sonst noch wissen?" */
freeform: string;
/** Interview progress tracking (not encrypted) */
interview: InterviewProgress;
}
export interface UserContext {
id: string;
about: UserContextAbout;
interests: string[];
routine: UserContextRoutine;
nutrition: UserContextNutrition;
goals: string[];
social: UserContextSocial;
freeform: string;
interview: InterviewProgress;
createdAt: string;
updatedAt: string;
}
/** Convert a LocalUserContext to the public UserContext type. */
export function toUserContext(local: LocalUserContext): UserContext {
return {
id: local.id,
about: local.about ?? {},
interests: local.interests ?? [],
routine: local.routine ?? {},
nutrition: local.nutrition ?? {},
goals: local.goals ?? [],
social: local.social ?? {},
freeform: local.freeform ?? '',
interview: local.interview ?? { answeredIds: [], skippedIds: [] },
createdAt: local.createdAt ?? '',
updatedAt: local.updatedAt ?? '',
};
}
/** Empty defaults for a new user context. */
export function emptyUserContext(): LocalUserContext {
return {
id: USER_CONTEXT_SINGLETON_ID,
about: {},
interests: [],
routine: {},
nutrition: {},
goals: [],
social: {},
freeform: '',
interview: { answeredIds: [], skippedIds: [] },
} as LocalUserContext;
}

View file

@ -16,7 +16,7 @@ Mana implementiert ein **Dual-Runtime AI-Agent-System**:
| **Foreground Runner** (Browser) | `runner.ts` — Reasoning-Loop mit bis zu 5 Planner-Iterationen, direkte Dexie-Writes, E2E-verschlüsselt |
| **Background Runner** (mana-ai, Port 3067) | `tick.ts` — 60s Cron-Tick, scannt fällige Missions, plant via mana-llm, schreibt über sync_changes zurück |
| **Planner** | Shared Prompt-Template (`@mana/shared-ai/src/planner/`) → OpenAI-kompatible API auf mana-llm |
| **Tool-System** | 13 Tools (todo, calendar, places, notes, news, drink), policy-gated (auto/propose/deny) |
| **Tool-System** | 29 Tools (11 Module), AI_TOOL_CATALOG + MCP-Server, policy-gated (auto/propose/deny) |
| **Agents** | Named Personas mit eigener systemPrompt, memory, policy, Budget, Concurrency-Limits |
| **Proposals** | Mutationen unter `propose`-Policy erzeugen Proposals → User muss approven |
| **Actor-System** | Jeder Write trägt einen immutablen Actor (user/ai/system) mit frozen displayName |
@ -115,12 +115,12 @@ Mission (title, objective, inputs, cadence)
| Dimension | Mana | A2A | MCP | OpenAI SDK | LangGraph | CrewAI |
|-----------|------|-----|-----|------------|-----------|--------|
| **Agent-Definition** | Agent(name, role, systemPrompt, memory, policy) | Agent Card (JSON, signiert) | N/A (Protokoll) | Agent(instructions, tools, handoffs) | Node-Funktionen | Agent(role, goal, backstory) |
| **Tool-Registration** | Hardcoded Allow-List (13 Tools) | Skills in Agent Card | tools/list + tools/call | @function_tool + MCP | Node context | tools= Parameter |
| **Tool-Registration** | AI_TOOL_CATALOG (29 Tools) + MCP-Server | Skills in Agent Card | tools/list + tools/call | @function_tool + MCP | Node context | tools= Parameter |
| **Agent↔Agent** | ❌ Nicht vorhanden | ✅ Kernzweck | ❌ Nicht designed dafür | Handoffs | Edges/Routing | Delegation + Hierarchie |
| **Agent↔Tool** | Policy-gated Executor | Via MCP | ✅ Kernzweck | Function calls + MCP | Node-Aufrufe | Direkte Zuweisung |
| **State/Memory** | LWW Sync + encrypted IndexedDB | Task contextId | Stateful Sessions | Sessions (SQLite/Redis) | StateGraph + Checkpoints | 4 Memory-Typen |
| **Orchestrierung** | Dual-Runtime (Browser + Server Cron) | Task Lifecycle | Host koordiniert | Runner Loop | DAG Engine | Sequential/Hierarchical |
| **Streaming** | ❌ Kein Streaming | SSE, gRPC, JSON-RPC | JSON-RPC Notifications | Built-in | Native Token-Stream | Log-basiert |
| **Streaming** | ✅ SSE Token-Streaming | SSE, gRPC, JSON-RPC | JSON-RPC Notifications | Built-in | Native Token-Stream | Log-basiert |
| **Observability** | Prometheus Metrics + Debug Logs | Agent Cards Metadata | Server Logging | Built-in Tracing (OTel) | LangSmith | Built-in Logging |
| **HITL** | Proposal-System (approve/reject) | INPUT_REQUIRED State | Elicitation | Guardrails | Interrupt/Resume | Task-Guardrails |
| **Encryption** | ✅ AES-GCM + Key-Grants | ❌ | ❌ | ❌ | ❌ | ❌ |
@ -337,7 +337,7 @@ Exportierbar nach Grafana Tempo oder ähnlichem.
|---|----------|---------|--------|
| 1 | **Streaming für Foreground Runner** — SSE vom Planner, live Step-Status im UI | Mittel | Hoch — UX-Sprung |
| 2 | **Dynamisches Tool-Registry** — Module registrieren Tools deklarativ, Server synchronisiert | Mittel | Hoch — Skalierbarkeit |
| 3 | **Budget-Enforcement serverseitig** — Token-Counting pro Agent im tick.ts | Klein | Mittel — Sicherheit |
| ~~3~~ | ~~**Budget-Enforcement serverseitig**~~ — ERLEDIGT `ce57e1195` (rolling 24h, token_usage Tabelle) | ~~Klein~~ | ~~Mittel~~ |
### Mittelfristig (1-3 Monate)
@ -408,9 +408,9 @@ Exportierbar nach Grafana Tempo oder ähnlichem.
**Die größten Lücken gegenüber der Industrie:**
1. **Kein Agent-to-Agent** — Die wichtigste fehlende Capability für echte Multi-Agent-Systeme
2. **Kein Streaming** — Standard in allen modernen Frameworks, fehlt komplett
3. **Statisches Tool-System** — Skaliert nicht, wenn neue Module Tools brauchen
2. ~~**Kein Streaming**~~ — ERLEDIGT (`be81d11dc`)
3. ~~**Statisches Tool-System**~~ — ERLEDIGT (`d40a61119`)
**Die Kernempfehlung:** Mana sollte nicht versuchen, ein General-Purpose-Agent-Framework zu werden (das machen LangGraph/CrewAI besser). Stattdessen die einzigartigen Stärken (Privacy, Local-First, Attribution) ausbauen und gezielt die Industrie-Standards adoptieren, die den größten UX-Impact haben: **Streaming**, **dynamische Tools**, und **Agent-Delegation**.
**Die Kernempfehlung:** Mana sollte nicht versuchen, ein General-Purpose-Agent-Framework zu werden (das machen LangGraph/CrewAI besser). Stattdessen die einzigartigen Stärken (Privacy, Local-First, Attribution) ausbauen und gezielt die Industrie-Standards adoptieren: **Agent-Delegation**, **Guardrails**, und **OpenTelemetry Tracing**.
MCP-Kompatibilität als mittelfristiges Ziel ist strategisch richtig — es ist das Protokoll, das sich als Standard für Agent↔Tool durchgesetzt hat (97M Downloads/Monat). A2A für Agent↔Agent ist das natürliche Pendant, aber erst relevant, wenn interne Multi-Agent-Kommunikation steht.
**Stand 2026-04-16:** Alle 3 kurzfristigen Maßnahmen umgesetzt + MCP-Server-Export (#5) + Budget-Enforcement (#3). 5 von 10 Roadmap-Punkten erledigt. Verbleibend: Agent-to-Agent (#4), Guardrails (#6), OTel (#7), A2A Agent Cards (#8), Graph-Workflows (#9), Agent Memory (#10).

View file

@ -5,14 +5,14 @@ import type { AiPolicy } from '../../policy/types';
/**
* Context agent tries to learn as much as possible about the user by
* asking questions + reading available context, then consolidates into
* the Kontext-document. Everything is propose so the user curates their
* own profile.
* the user's profile context. Everything is propose so the user curates
* their own profile.
*/
const CONTEXT_POLICY: AiPolicy = {
tools: Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose'])),
defaultsByModule: {
kontext: 'propose',
profile: 'propose',
notes: 'propose',
goals: 'auto',
},
@ -21,20 +21,20 @@ const CONTEXT_POLICY: AiPolicy = {
export const contextTemplate: AgentTemplate = {
id: 'context',
version: '1',
version: '2',
icon: '🧭',
label: 'Kontext-Agent',
tagline: 'Lernt dich kennen, damit andere Agents besser arbeiten',
description: `Der Agent fragt dich gezielt Fragen und destilliert die Antworten
in dein Kontext-Dokument. Andere Agents (Recherche, Today, ) lesen dieses
Dokument als Prompt-Zusatz je besser es gepflegt ist, desto relevanter werden
in dein Nutzerprofil. Andere Agents (Recherche, Today, ) lesen dieses
Profil als Prompt-Zusatz je besser es gepflegt ist, desto relevanter werden
ihre Vorschläge.
Was er tut:
1. Liest was schon in deinem Kontext + Notizen + Goals steht
1. Liest was schon in deinem Profil + Notizen + Goals steht
2. Stellt gezielt Fragen zu Lücken ("Was treibt dich aktuell um?", "Welche Projekte liegen an?")
3. Verdichtet deine Antworten zu einem strukturierten Kontext-Update (als Vorschlag)
3. Verdichtet deine Antworten zu einem strukturierten Profil-Update (als Vorschlag)
Alles läuft als Vorschlag du bestätigst welche Version deines Profils gespeichert wird.`,
category: 'ai',
@ -42,21 +42,22 @@ Alles läuft als Vorschlag — du bestätigst welche Version deines Profils gesp
agent: {
name: 'Kontext-Agent',
avatar: '🧭',
role: 'Lernt dich kennen und pflegt dein Kontext-Dokument',
systemPrompt: `Du bist ein neugieriger aber respektvoller Kontext-Agent. Ziel: verdichte was der User von sich selbst preisgibt zu einem gut strukturierten Kontext-Dokument, das andere AI-Agents als Prompt-Input nutzen können.
role: 'Lernt dich kennen und pflegt dein Nutzerprofil',
systemPrompt: `Du bist ein neugieriger aber respektvoller Kontext-Agent. Ziel: verdichte was der User von sich selbst preisgibt zu einem gut strukturierten Profil, das andere AI-Agents als Prompt-Input nutzen können.
Vorgehen:
1. Lies immer zuerst das existierende kontextDoc + die letzten 5 Notizen + Goals, bevor du Fragen stellst.
1. Lies immer zuerst das existierende Nutzerprofil (userContext) + die letzten 5 Notizen + Goals, bevor du Fragen stellst.
2. Frage pro Iteration höchstens 2-3 konkrete Fragen. Keine Massenbefragung.
3. Schlage beim Update des Kontext-Dokuments immer eine Diff-Ansicht vor nie Full-Replace.
3. Schlage beim Update des Profils immer eine Diff-Ansicht vor nie Full-Replace.
4. Respektiere Lücken: wenn der User etwas nicht teilen will, nimm das auf ("Thema nicht relevant für den Agent").
5. Schreibe das Kontext-Dokument auf Deutsch, in Ich-Form ("Ich bin…", "Mir ist wichtig…").
5. Schreibe das Profil auf Deutsch, in Ich-Form ("Ich bin…", "Mir ist wichtig…").
Struktur im Kontext-Dokument:
- # Wer ich bin (Rolle, Hintergrund)
- # Was mich umtreibt (aktuelle Projekte, Themen)
- # Wie ich arbeite (Arbeitsstil, Präferenzen)
- # Was ich lieber nicht teile (Opt-outs)`,
Struktur im Nutzerprofil:
- Beruf & Hintergrund (about.occupation, about.bio)
- Aktuelle Projekte & Themen (goals, freeform)
- Arbeitsstil & Präferenzen (social.workStyle, routine)
- Interessen (interests)
- Opt-outs (freeform: was der User nicht teilen will)`,
memory: `# Kontext-Ziele
(Hier kannst du festhalten welche Aspekte von dir der Agent priorisieren soll
@ -70,19 +71,19 @@ meinen Hobbys" etc.)
name: 'Kontext',
description: 'Dein Profil für alle anderen Agents',
openApps: [
{ appId: 'kontext', widthPx: 720 },
{ appId: 'profile', widthPx: 720 },
{ appId: 'ai-missions', widthPx: 440 },
{ appId: 'ai-workbench', widthPx: 440 },
],
},
missions: [
{
title: 'Kontext verdichten',
title: 'Profil-Check',
objective:
'Lies was schon da ist, identifiziere Lücken, stelle 2-3 Fragen und schlage ein Kontext-Update vor.',
conceptMarkdown: `# Kontext-Erkundung
'Lies das Nutzerprofil, identifiziere Lücken, stelle 2-3 Fragen und schlage ein Profil-Update vor.',
conceptMarkdown: `# Profil-Erkundung
Der Agent tickt wöchentlich und macht einen "Kontext-Check":
Der Agent tickt wöchentlich und macht einen "Profil-Check":
1. Was hat sich seit dem letzten Update geändert?
2. Welche Lücken sind noch im Profil?

View file

@ -188,3 +188,32 @@ export const kontextResolver = createEncryptedResolver({
formatContent: (r) =>
truncate(typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? ''), 1500),
});
export const userContextResolver = createEncryptedResolver({
module: 'profile',
appId: 'profile',
label: 'Nutzerprofil',
formatTitle: () => 'Nutzerprofil',
formatContent: (r) => {
const parts: string[] = [];
// Structured fields are stored as JSON objects within the encrypted record
for (const key of [
'about',
'interests',
'routine',
'nutrition',
'goals',
'social',
'freeform',
]) {
const val = r[key];
if (!val) continue;
if (typeof val === 'string' && val.trim()) {
parts.push(val.trim());
} else if (typeof val === 'object') {
parts.push(JSON.stringify(val));
}
}
return truncate(parts.join('\n'), 2000);
},
});

View file

@ -17,6 +17,7 @@ import {
kontextResolver,
notesResolver,
tasksResolver,
userContextResolver,
} from './encrypted';
const resolvers = new Map<string, ServerInputResolver>();
@ -44,6 +45,7 @@ registerServerResolver('todo', tasksResolver);
registerServerResolver('calendar', eventsResolver);
registerServerResolver('journal', journalResolver);
registerServerResolver('kontext', kontextResolver);
registerServerResolver('profile', userContextResolver);
export async function resolveServerInputs(
sql: Sql,

View file

@ -367,6 +367,22 @@ export function createAuthRoutes(
return c.json(result);
});
app.post('/change-email', async (c) => {
const body = await c.req.json();
if (!body.newEmail) {
return c.json({ error: 'newEmail is required' }, 400);
}
await auth.api.changeEmail({
body: { newEmail: body.newEmail, callbackURL: body.callbackURL },
headers: c.req.raw.headers,
});
security.logEvent({
eventType: 'EMAIL_CHANGE_REQUESTED',
metadata: { newEmail: body.newEmail },
});
return c.json({ success: true, message: 'Verification email sent to new address' });
});
app.post('/change-password', async (c) => {
const body = await c.req.json();
await auth.api.changePassword({