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'}
@@ -231,9 +313,68 @@
{/each}
{/if}
+ {:else if mode === 'goal'}
+ {#if filteredGoals.length === 0}
+
Keine Ziele angelegt.
+ {:else}
+ {#each filteredGoals as g (g.id)}
+
+ {/each}
+ {/if}
+ {:else if mode === 'me-image'}
+ {#if filteredMeImages.length === 0}
+
Keine Bilder. Lege welche unter /profile/me-images an.
+ {:else}
+ {#each filteredMeImages as m (m.id)}
+
+ {/each}
+ {/if}
{/if}
+ {:else if mode === 'kontext'}
+
+ {#if !kontextDoc}
+
+ Dieser Space hat noch kein Kontext-Dokument. Lege eines unter
+ /kontext an.
+
+ {:else}
+
+ {/if}
+
{: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