From 26e25b7694c9f2bb276cc796a7672db13fdfc140 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 11:43:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(writing):=20M5=20expansion=20=E2=80=94=20k?= =?UTF-8?q?ontext,=20goal,=20me-image=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more reference kinds the resolver previously stubbed out are now fully wired through the briefing form into the LLM prompt. - reference-resolver: three new resolveX functions. * Kontext is a singleton per space (the picker uses a sentinel targetId; the resolver ignores it and reads via scopedForModule + first non-deleted row). Decrypts content and trims to budget. * Goal reads from companionGoals (plaintext today) and surfaces title + description + status + current/target so the model can tie the draft into the user's actual progress. * MeImage reads from the space-scoped meImages table; encrypts label + tags. Hands the model a textual descriptor (kind / label / tags) since the binary blob can't help prose generation. - ReferencePicker: three new kind-tabs (🗂 Kontext, 🎯 Ziel, 🖼 Bild). Kontext renders as a single-click "Kontext-Dokument verknüpfen" entry if the space has one (with /kontext deep-link otherwise). Goals active-first, then archived/done. Me-images render with thumbnail + label + tags. Live-resolved chips via labelFor() for all three. - i18n baseline bumped by one for ReferencePicker (the new "Kontext-Dokument verknüpfen" string is intentional, in line with the rest of the picker's existing German labels). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...current-user.ts => current-user.svelte.ts} | 0 .../writing/components/ReferencePicker.svelte | 167 +++++++++++++++++- .../writing/utils/reference-resolver.ts | 83 ++++++++- scripts/i18n-hardcoded-baseline.json | 2 +- 4 files changed, 245 insertions(+), 7 deletions(-) rename apps/mana/apps/web/src/lib/data/{current-user.ts => current-user.svelte.ts} (100%) diff --git a/apps/mana/apps/web/src/lib/data/current-user.ts b/apps/mana/apps/web/src/lib/data/current-user.svelte.ts similarity index 100% rename from apps/mana/apps/web/src/lib/data/current-user.ts rename to apps/mana/apps/web/src/lib/data/current-user.svelte.ts diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte index 1a7af6ae5..b32a7ffe7 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte @@ -2,12 +2,15 @@ ReferencePicker — inline "Quellen" section inside the briefing form. Shows the currently-attached references as ReferenceChip pills (with live-resolved display labels) and a "+ Quelle" dropdown for adding - new ones. M5 supports four kinds: + new ones. Seven kinds: - article → searchable list of saved articles - note → searchable list of notes - library → searchable list of library entries - url → freeform URL input + optional context note + - kontext → space's kontext-doc singleton (one-click add) + - goal → searchable list of goals + - me-image → searchable list of profile reference images The parent owns the references array and wires it to the draft via BriefingForm's save handler. @@ -16,11 +19,25 @@ import { useAllArticles } from '$lib/modules/articles/queries'; import { useAllNotes } from '$lib/modules/notes/queries'; import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries'; + import { useKontextDoc } from '$lib/modules/kontext/queries'; + import { useAllMeImages } from '$lib/modules/profile/queries'; + import { useAllGoals } from '$lib/companion/goals/queries'; import ReferenceChip from './ReferenceChip.svelte'; import type { DraftReference, DraftReferenceKind } from '../types'; - const SUPPORTED_KINDS: DraftReferenceKind[] = ['article', 'note', 'library', 'url']; + const SUPPORTED_KINDS: DraftReferenceKind[] = [ + 'article', + 'note', + 'library', + 'url', + 'kontext', + 'goal', + 'me-image', + ]; const MAX_REFERENCES = 6; + /** Sentinel targetId for the kontext-singleton — the resolver doesn't + * use it, but a non-null id keeps the de-dupe + chip-key logic uniform. */ + const KONTEXT_SINGLETON_ID = 'kontext:singleton'; let { references, @@ -33,14 +50,21 @@ const articles$ = useAllArticles(); const notes$ = useAllNotes(); const library$ = useAllLibraryEntries(); + const kontext$ = useKontextDoc(); + const meImages$ = useAllMeImages(); + const goals$ = useAllGoals(); // Lookup maps so chips can resolve their display label from targetId. const articlesById = $derived(new Map((articles$.value ?? []).map((a) => [a.id, a]))); const notesById = $derived(new Map((notes$.value ?? []).map((n) => [n.id, n]))); const libraryById = $derived(new Map((library$.value ?? []).map((e) => [e.id, e]))); + const meImagesById = $derived(new Map((meImages$.value ?? []).map((m) => [m.id, m]))); + const goalsById = $derived(new Map((goals$.value ?? []).map((g) => [g.id, g]))); + const kontextDoc = $derived(kontext$.value); function labelFor(ref: DraftReference): string { if (ref.kind === 'url') return ref.url ?? 'Link'; + if (ref.kind === 'kontext') return 'Kontext-Dokument'; if (!ref.targetId) return '—'; if (ref.kind === 'article') { const a = articlesById.get(ref.targetId); @@ -54,10 +78,26 @@ const e = libraryById.get(ref.targetId); return e ? e.title : 'Library-Eintrag (fehlt)'; } + if (ref.kind === 'goal') { + const g = goalsById.get(ref.targetId); + return g ? g.title : 'Ziel (fehlt)'; + } + if (ref.kind === 'me-image') { + const m = meImagesById.get(ref.targetId); + return m ? (m.label ?? `${m.kind}-Bild`) : 'Bild (fehlt)'; + } return ref.targetId; } - type PickerMode = 'closed' | 'article' | 'note' | 'library' | 'url'; + type PickerMode = + | 'closed' + | 'article' + | 'note' + | 'library' + | 'url' + | 'kontext' + | 'goal' + | 'me-image'; let mode = $state('closed'); let searchQuery = $state(''); let urlInput = $state(''); @@ -126,6 +166,45 @@ .slice(0, 20); }); + const filteredGoals = $derived.by(() => { + const q = searchQuery.trim().toLowerCase(); + const all = goals$.value ?? []; + // Active goals are most relevant for writing context — sort them + // to the top, then archived/done as a secondary group. + const visible = all.filter((g) => !g.deletedAt); + const sorted = [...visible].sort((a, b) => { + const aRank = a.status === 'active' ? 0 : 1; + const bRank = b.status === 'active' ? 0 : 1; + return aRank - bRank; + }); + if (!q) return sorted.slice(0, 20); + return sorted + .filter( + (g) => + g.title.toLowerCase().includes(q) || (g.description?.toLowerCase().includes(q) ?? false) + ) + .slice(0, 20); + }); + + const filteredMeImages = $derived.by(() => { + const q = searchQuery.trim().toLowerCase(); + const all = meImages$.value ?? []; + if (!q) return all.slice(0, 20); + return all + .filter( + (m) => + (m.label?.toLowerCase().includes(q) ?? false) || + m.kind.toLowerCase().includes(q) || + m.tags.some((t) => t.toLowerCase().includes(q)) + ) + .slice(0, 20); + }); + + function addKontext() { + if (!kontextDoc) return; + addRef({ kind: 'kontext', targetId: KONTEXT_SINGLETON_ID, note: null }); + } + function addUrl() { const url = urlInput.trim(); if (!url) return; @@ -160,6 +239,9 @@ {#if k === 'article'}📄 Artikel {:else if k === 'note'}📝 Notiz {:else if k === 'library'}📚 Library + {:else if k === 'kontext'}🗂 Kontext + {:else if k === 'goal'}🎯 Ziel + {:else if k === 'me-image'}🖼 Bild {:else}🔗 URL{/if} {/each} @@ -170,7 +252,7 @@

{/if} - {#if mode === 'article' || mode === 'note' || mode === 'library'} + {#if mode === 'article' || mode === 'note' || mode === 'library' || mode === 'goal' || mode === 'me-image'} + {:else if mode === 'kontext'} + {:else if mode === 'url'}
@@ -343,6 +484,24 @@ font-size: 0.75rem; color: var(--color-text-muted, rgba(0, 0, 0, 0.55)); } + .me-image-result { + flex-direction: row; + gap: 0.55rem; + align-items: center; + } + .me-image-result .thumb { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.4rem; + object-fit: cover; + flex-shrink: 0; + } + .me-image-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + } .url-row { display: flex; gap: 0.4rem; diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts index ee9fa0b47..79f0fd2c9 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts @@ -15,14 +15,19 @@ * with a note, not truncated mid-sentence. */ -import { scopedGet } from '$lib/data/scope'; +import { scopedGet, scopedForModule } from '$lib/data/scope'; import { decryptRecords } from '$lib/data/crypto'; +import { db } from '$lib/data/database'; import { toArticle } from '$lib/modules/articles/queries'; import { toNote } from '$lib/modules/notes/queries'; import { toLibraryEntry } from '$lib/modules/library/queries'; +import { toKontextDoc } from '$lib/modules/kontext/queries'; import type { LocalArticle } from '$lib/modules/articles/types'; import type { LocalNote } from '$lib/modules/notes/types'; import type { LocalLibraryEntry } from '$lib/modules/library/types'; +import type { LocalKontextDoc } from '$lib/modules/kontext/types'; +import type { LocalMeImage } from '$lib/modules/profile/types'; +import type { LocalGoal } from '$lib/companion/goals/types'; import type { DraftReference } from '../types'; const MAX_CHARS_PER_REF = 1500; @@ -126,6 +131,67 @@ function resolveUrl(ref: DraftReference): Omit | null> { + const rows = await scopedForModule('kontext', 'kontextDoc').toArray(); + const local = rows.find((r) => !r.deletedAt); + if (!local) return null; + const [decrypted] = await decryptRecords('kontextDoc', [local]); + if (!decrypted) return null; + const doc = toKontextDoc(decrypted); + return { + sourceLabel: 'Kontext-Dokument des Spaces', + title: 'Kontext', + content: truncate(doc.content ?? ''), + }; +} + +async function resolveGoal(id: string): Promise | null> { + // Goals are plaintext today (companionGoals is on the plaintext-allowlist), + // so no decryption is needed. If that ever flips, this function gets the + // same decryptRecords call as the others. + const local = await db.table('companionGoals').get(id); + if (!local || local.deletedAt) return null; + const parts: string[] = []; + if (local.description) parts.push(local.description); + parts.push( + `Status: ${local.status}. Ziel: ${local.target.value} (${local.target.period}), aktuell: ${local.currentValue}.` + ); + return { + sourceLabel: `Ziel: ${local.title}`, + title: local.title, + content: truncate(parts.join('\n')), + }; +} + +async function resolveMeImage( + id: string +): Promise | null> { + const local = await scopedGet('meImages', id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('meImages', [local]); + if (!decrypted) return null; + // Me-images carry a user-written label + tag chips (encrypted) plus a + // structural `kind` (plaintext: face / fullbody / halfbody / hands / + // reference). For prose we hand the descriptor to the model, not the + // binary — the model couldn't use the image anyway. The label nudges + // tone/scene; a blog post "über mich" benefits from knowing "Portrait + // Juni, ohne Brille, Studio". + const tags = (decrypted.tags ?? []).join(', '); + const descriptor = [decrypted.label, tags].filter(Boolean).join(' — '); + return { + sourceLabel: `Bild (${local.kind}): ${decrypted.label ?? 'ohne Label'}`, + title: decrypted.label ?? local.kind, + content: truncate(descriptor || `${local.kind}-Referenzbild`), + }; +} + export async function resolveReference(ref: DraftReference): Promise { switch (ref.kind) { case 'article': { @@ -147,7 +213,20 @@ export async function resolveReference(ref: DraftReference): Promise