mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 11:49:41 +02:00
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:
parent
ce57e11950
commit
acd7e0d6b0
26 changed files with 2744 additions and 661 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -526,7 +526,7 @@ registerApp({
|
|||
|
||||
registerApp({
|
||||
id: 'kontext',
|
||||
name: 'Kontext',
|
||||
name: 'Web-Context',
|
||||
color: '#A78B6F',
|
||||
icon: Scroll,
|
||||
views: {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)}
|
||||
>×</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>
|
||||
|
|
@ -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)}>×</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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const profileModuleConfig: ModuleConfig = {
|
||||
appId: 'profile',
|
||||
tables: [{ name: 'userContext' }],
|
||||
};
|
||||
18
apps/mana/apps/web/src/lib/modules/profile/queries.ts
Normal file
18
apps/mana/apps/web/src/lib/modules/profile/queries.ts
Normal 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);
|
||||
}
|
||||
325
apps/mana/apps/web/src/lib/modules/profile/questions.ts
Normal file
325
apps/mana/apps/web/src/lib/modules/profile/questions.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
110
apps/mana/apps/web/src/lib/modules/profile/types.ts
Normal file
110
apps/mana/apps/web/src/lib/modules/profile/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue