mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(writing): M5 expansion — kontext, goal, me-image references
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) <noreply@anthropic.com>
This commit is contained in:
parent
fbbadc91f0
commit
26e25b7694
4 changed files with 245 additions and 7 deletions
|
|
@ -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<PickerMode>('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}
|
||||
</button>
|
||||
{/each}
|
||||
|
|
@ -170,7 +252,7 @@
|
|||
</p>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'article' || mode === 'note' || mode === 'library'}
|
||||
{#if mode === 'article' || mode === 'note' || mode === 'library' || mode === 'goal' || mode === 'me-image'}
|
||||
<div class="search">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input type="search" bind:value={searchQuery} placeholder="Suche…" autofocus />
|
||||
|
|
@ -231,9 +313,68 @@
|
|||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if mode === 'goal'}
|
||||
{#if filteredGoals.length === 0}
|
||||
<p class="muted small">Keine Ziele angelegt.</p>
|
||||
{:else}
|
||||
{#each filteredGoals as g (g.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="result"
|
||||
onclick={() => addRef({ kind: 'goal', targetId: g.id, note: null })}
|
||||
>
|
||||
<strong>{g.title}</strong>
|
||||
<span class="meta">
|
||||
{g.status} · {g.currentValue}/{g.target.value}
|
||||
{g.target.period}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if mode === 'me-image'}
|
||||
{#if filteredMeImages.length === 0}
|
||||
<p class="muted small">Keine Bilder. Lege welche unter /profile/me-images an.</p>
|
||||
{:else}
|
||||
{#each filteredMeImages as m (m.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="result me-image-result"
|
||||
onclick={() => addRef({ kind: 'me-image', targetId: m.id, note: null })}
|
||||
>
|
||||
{#if m.thumbnailUrl || m.publicUrl}
|
||||
<img src={m.thumbnailUrl ?? m.publicUrl} alt="" class="thumb" />
|
||||
{/if}
|
||||
<span class="me-image-text">
|
||||
<strong>{m.label ?? `${m.kind}-Bild`}</strong>
|
||||
<span class="meta">
|
||||
{m.kind}{#if m.tags.length}
|
||||
· {m.tags.join(', ')}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'kontext'}
|
||||
<div class="search">
|
||||
{#if !kontextDoc}
|
||||
<p class="muted small">
|
||||
Dieser Space hat noch kein Kontext-Dokument. Lege eines unter
|
||||
<a href="/kontext">/kontext</a> an.
|
||||
</p>
|
||||
{:else}
|
||||
<button type="button" class="result" onclick={addKontext}>
|
||||
<strong>Kontext-Dokument verknüpfen</strong>
|
||||
<span class="meta">
|
||||
{(kontextDoc.content ?? '').slice(0, 100).replace(/\s+/g, ' ')}
|
||||
{(kontextDoc.content ?? '').length > 100 ? '…' : ''}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if mode === 'url'}
|
||||
<div class="url-row">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ResolvedReference, 'kind' | 'note
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontext is a per-space singleton — the picker stores a sentinel
|
||||
* targetId ('singleton'), and the resolver ignores it and picks the
|
||||
* first non-deleted row scoped to the active space. Legacy rows use
|
||||
* id='singleton' explicitly; fresh rows use a uuid but are still
|
||||
* singular per space.
|
||||
*/
|
||||
async function resolveKontext(): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
|
||||
const rows = await scopedForModule<LocalKontextDoc, string>('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<Omit<ResolvedReference, 'kind' | 'note'> | 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<LocalGoal>('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<Omit<ResolvedReference, 'kind' | 'note'> | null> {
|
||||
const local = await scopedGet<LocalMeImage>('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<ResolvedReference | null> {
|
||||
switch (ref.kind) {
|
||||
case 'article': {
|
||||
|
|
@ -147,7 +213,20 @@ export async function resolveReference(ref: DraftReference): Promise<ResolvedRef
|
|||
const base = resolveUrl(ref);
|
||||
return base ? { ...base, kind: 'url', note: ref.note ?? null } : null;
|
||||
}
|
||||
// kontext / goal / me-image — roadmap, not M5.
|
||||
case 'kontext': {
|
||||
const base = await resolveKontext();
|
||||
return base ? { ...base, kind: 'kontext', note: ref.note ?? null } : null;
|
||||
}
|
||||
case 'goal': {
|
||||
if (!ref.targetId) return null;
|
||||
const base = await resolveGoal(ref.targetId);
|
||||
return base ? { ...base, kind: 'goal', note: ref.note ?? null } : null;
|
||||
}
|
||||
case 'me-image': {
|
||||
if (!ref.targetId) return null;
|
||||
const base = await resolveMeImage(ref.targetId);
|
||||
return base ? { ...base, kind: 'me-image', note: ref.note ?? null } : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@
|
|||
"apps/mana/apps/web/src/lib/modules/wishes/views/DetailView.svelte": 3,
|
||||
"apps/mana/apps/web/src/lib/modules/writing/components/BriefingForm.svelte": 11,
|
||||
"apps/mana/apps/web/src/lib/modules/writing/components/ExportMenu.svelte": 1,
|
||||
"apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte": 3,
|
||||
"apps/mana/apps/web/src/lib/modules/writing/components/ReferencePicker.svelte": 4,
|
||||
"apps/mana/apps/web/src/lib/modules/writing/components/RefinementPanel.svelte": 12,
|
||||
"apps/mana/apps/web/src/lib/modules/writing/components/StyleForm.svelte": 2,
|
||||
"apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte": 1,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue