feat(writing): M5 — cross-module references in the briefing

Drafts can now pull in saved articles, notes, library entries, and raw
URLs as prompt context. This is the Writing module's main differentiator
against standalone LLM chat: the user's own SSOT flows straight into the
ghostwriter without copy-paste.

- utils/reference-resolver.ts: resolveReference() per kind (article,
  note, library, url) via scopedGet + decryptRecords + module type
  converter. Each ref truncates to MAX_CHARS_PER_REF=1500 (with a
  "[… gekürzt …]" marker); resolveReferences() caps the aggregate at
  MAX_TOTAL_REFERENCE_CHARS=8000 and drops extras rather than slicing
  mid-sentence. Deleted or missing refs silently fall out.
- prompt-builder: buildDraftPrompt() takes resolvedReferences and
  renders them as a "--- Quellen ---" block in the user message with
  [Quelle N] headers + optional "Kontext:" lines (the user's own
  per-ref note). System prompt gets a sentence instructing the model
  to paraphrase from the sources and not fabricate facts when a source
  has nothing useful.
- generations store: startDraftGeneration resolves references in
  parallel before building the prompt. No changes to the refineSelection
  path — M5 keeps selection-refinement context-free on purpose.
- UI: ReferencePicker.svelte inline in the BriefingForm with four kind
  tabs (Artikel / Notiz / Library / URL). Searchable lists per kind for
  module refs (max 20 visible, debounced); URL kind takes a url + an
  optional context note. ReferenceChip.svelte pills render live-
  resolved titles; parent resolves labels via the module queries. Hard
  cap at 6 references per draft.
- Scope limits: kontext / goal / me-image refs are on the roadmap but
  deliberately skipped in M5 — they require different resolution paths
  (singletons, structured metadata, image descriptors) that would
  sprawl this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 15:53:35 +02:00
parent 8c43c119ea
commit bfa923dc22
6 changed files with 722 additions and 2 deletions

View file

@ -8,7 +8,8 @@
import { KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
import { draftsStore } from '../stores/drafts.svelte';
import StylePicker from './StylePicker.svelte';
import type { Draft, DraftKind, DraftBriefing } from '../types';
import ReferencePicker from './ReferencePicker.svelte';
import type { Draft, DraftKind, DraftBriefing, DraftReference } from '../types';
let {
mode,
@ -62,6 +63,8 @@
let extraInstructions = $state(draft?.briefing.extraInstructions ?? '');
/* svelte-ignore state_referenced_locally */
let styleId = $state<string | null>(draft?.styleId ?? null);
/* svelte-ignore state_referenced_locally */
let references = $state<DraftReference[]>([...(draft?.references ?? [])]);
let saving = $state(false);
let error = $state<string | null>(null);
@ -100,6 +103,7 @@
title: title.trim(),
briefing,
styleId,
references,
});
oncreated?.(created);
} else if (draft) {
@ -107,6 +111,7 @@
title: title.trim(),
kind,
styleId,
references,
});
await draftsStore.updateBriefing(draft.id, briefing);
}
@ -189,6 +194,13 @@
<StylePicker value={styleId} onchange={(next) => (styleId = next)} />
</label>
<div class="references-field">
<span class="field-label">
Quellen <small>(optional — flowen als Kontext in den Prompt ein)</small>
</span>
<ReferencePicker {references} onchange={(next) => (references = next)} />
</div>
<label>
<span>Zusatzhinweise <small>(optional)</small></span>
<textarea
@ -237,6 +249,15 @@
label > span {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.references-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
}
.field-label {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
small {
font-weight: normal;
opacity: 0.7;

View file

@ -0,0 +1,80 @@
<!--
ReferenceChip — compact pill for a single attached DraftReference in
the briefing form. Purely presentational: the parent resolves the
display label by looking up the target in the relevant module's live
query.
-->
<script lang="ts">
import type { DraftReference } from '../types';
let {
kind,
label,
note = null,
onremove,
}: {
kind: DraftReference['kind'];
label: string;
note?: string | null;
onremove: () => void;
} = $props();
const KIND_ICON: Record<DraftReference['kind'], string> = {
article: '📄',
note: '📝',
library: '📚',
kontext: '🗂',
goal: '🎯',
url: '🔗',
'me-image': '🖼',
};
const KIND_LABEL: Record<DraftReference['kind'], string> = {
article: 'Artikel',
note: 'Notiz',
library: 'Library',
kontext: 'Kontext',
goal: 'Ziel',
url: 'Link',
'me-image': 'Bild',
};
</script>
<span class="chip" title={note ?? KIND_LABEL[kind]}>
<span aria-hidden="true">{KIND_ICON[kind]}</span>
<span class="label">{label}</span>
<button type="button" class="remove" onclick={onremove} aria-label="Quelle entfernen">×</button>
</span>
<style>
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(0, 0, 0, 0.03));
font-size: 0.8rem;
max-width: 100%;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 18rem;
}
.remove {
background: transparent;
border: none;
padding: 0 0.2rem;
cursor: pointer;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font: inherit;
line-height: 1;
font-size: 1rem;
}
.remove:hover {
color: #ef4444;
}
</style>

View file

@ -0,0 +1,398 @@
<!--
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:
- article → searchable list of saved articles
- note → searchable list of notes
- library → searchable list of library entries
- url → freeform URL input + optional context note
The parent owns the references array and wires it to the draft via
BriefingForm's save handler.
-->
<script lang="ts">
import { useAllArticles } from '$lib/modules/articles/queries';
import { useAllNotes } from '$lib/modules/notes/queries';
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
import ReferenceChip from './ReferenceChip.svelte';
import type { DraftReference, DraftReferenceKind } from '../types';
const SUPPORTED_KINDS: DraftReferenceKind[] = ['article', 'note', 'library', 'url'];
const MAX_REFERENCES = 6;
let {
references,
onchange,
}: {
references: DraftReference[];
onchange: (next: DraftReference[]) => void;
} = $props();
const articles$ = useAllArticles();
const notes$ = useAllNotes();
const library$ = useAllLibraryEntries();
// 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])));
function labelFor(ref: DraftReference): string {
if (ref.kind === 'url') return ref.url ?? 'Link';
if (!ref.targetId) return '—';
if (ref.kind === 'article') {
const a = articlesById.get(ref.targetId);
return a ? a.title : 'Artikel (fehlt)';
}
if (ref.kind === 'note') {
const n = notesById.get(ref.targetId);
return n ? n.title || 'Ohne Titel' : 'Notiz (fehlt)';
}
if (ref.kind === 'library') {
const e = libraryById.get(ref.targetId);
return e ? e.title : 'Library-Eintrag (fehlt)';
}
return ref.targetId;
}
type PickerMode = 'closed' | 'article' | 'note' | 'library' | 'url';
let mode = $state<PickerMode>('closed');
let searchQuery = $state('');
let urlInput = $state('');
let urlNote = $state('');
const canAddMore = $derived(references.length < MAX_REFERENCES);
function openMode(next: PickerMode) {
mode = mode === next ? 'closed' : next;
searchQuery = '';
urlInput = '';
urlNote = '';
}
function removeAt(idx: number) {
const next = references.filter((_, i) => i !== idx);
onchange(next);
}
function addRef(ref: DraftReference) {
if (!canAddMore) return;
// De-dupe: same kind + targetId/url → skip
const duplicate = references.some(
(r) => r.kind === ref.kind && (ref.targetId ? r.targetId === ref.targetId : r.url === ref.url)
);
if (duplicate) {
mode = 'closed';
return;
}
onchange([...references, ref]);
mode = 'closed';
searchQuery = '';
urlInput = '';
urlNote = '';
}
const filteredArticles = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
const all = articles$.value ?? [];
if (!q) return all.slice(0, 20);
return all
.filter(
(a) => a.title.toLowerCase().includes(q) || (a.siteName?.toLowerCase().includes(q) ?? false)
)
.slice(0, 20);
});
const filteredNotes = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
const all = notes$.value ?? [];
if (!q) return all.slice(0, 20);
return all
.filter((n) => n.title.toLowerCase().includes(q) || n.content.toLowerCase().includes(q))
.slice(0, 20);
});
const filteredLibrary = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
const all = library$.value ?? [];
if (!q) return all.slice(0, 20);
return all
.filter(
(e) =>
e.title.toLowerCase().includes(q) || e.creators.some((c) => c.toLowerCase().includes(q))
)
.slice(0, 20);
});
function addUrl() {
const url = urlInput.trim();
if (!url) return;
addRef({ kind: 'url', url, note: urlNote.trim() || null });
}
</script>
<div class="picker">
{#if references.length > 0}
<div class="chips">
{#each references as ref, idx (`${ref.kind}:${ref.targetId ?? ref.url ?? idx}`)}
<ReferenceChip
kind={ref.kind}
label={labelFor(ref)}
note={ref.note}
onremove={() => removeAt(idx)}
/>
{/each}
</div>
{/if}
{#if canAddMore}
<div class="add-row">
<span class="add-label">+ Quelle:</span>
{#each SUPPORTED_KINDS as k (k)}
<button
type="button"
class="kind-btn"
class:active={mode === k}
onclick={() => openMode(k as PickerMode)}
>
{#if k === 'article'}📄 Artikel
{:else if k === 'note'}📝 Notiz
{:else if k === 'library'}📚 Library
{:else}🔗 URL{/if}
</button>
{/each}
</div>
{:else}
<p class="muted">
Max. {MAX_REFERENCES} Quellen pro Draft erreicht. Entferne eine, um eine neue hinzuzufügen.
</p>
{/if}
{#if mode === 'article' || mode === 'note' || mode === 'library'}
<div class="search">
<!-- svelte-ignore a11y_autofocus -->
<input type="search" bind:value={searchQuery} placeholder="Suche…" autofocus />
<div class="results">
{#if mode === 'article'}
{#if filteredArticles.length === 0}
<p class="muted small">Keine Treffer.</p>
{:else}
{#each filteredArticles as a (a.id)}
<button
type="button"
class="result"
onclick={() => addRef({ kind: 'article', targetId: a.id, note: null })}
>
<strong>{a.title}</strong>
{#if a.siteName}
<span class="meta">{a.siteName}</span>
{/if}
</button>
{/each}
{/if}
{:else if mode === 'note'}
{#if filteredNotes.length === 0}
<p class="muted small">Keine Treffer.</p>
{:else}
{#each filteredNotes as n (n.id)}
<button
type="button"
class="result"
onclick={() => addRef({ kind: 'note', targetId: n.id, note: null })}
>
<strong>{n.title || 'Ohne Titel'}</strong>
{#if n.content}
<span class="meta">
{n.content.slice(0, 80).replace(/\s+/g, ' ')}
{n.content.length > 80 ? '…' : ''}
</span>
{/if}
</button>
{/each}
{/if}
{:else if mode === 'library'}
{#if filteredLibrary.length === 0}
<p class="muted small">Keine Treffer.</p>
{:else}
{#each filteredLibrary as e (e.id)}
<button
type="button"
class="result"
onclick={() => addRef({ kind: 'library', targetId: e.id, note: null })}
>
<strong>{e.title}</strong>
<span class="meta">
{e.kind}
{#if e.creators.length}· {e.creators[0]}{/if}
{#if e.year}· {e.year}{/if}
</span>
</button>
{/each}
{/if}
{/if}
</div>
</div>
{:else if mode === 'url'}
<div class="url-row">
<!-- svelte-ignore a11y_autofocus -->
<input type="url" bind:value={urlInput} placeholder="https://…" autofocus />
<input type="text" bind:value={urlNote} placeholder="Kontext (optional)" class="note-input" />
<button type="button" class="primary" disabled={!urlInput.trim()} onclick={addUrl}>
Hinzufügen
</button>
</div>
{/if}
</div>
<style>
.picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.add-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.add-label {
font-size: 0.8rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
margin-right: 0.2rem;
}
.kind-btn {
padding: 0.25rem 0.6rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
color: inherit;
}
.kind-btn:hover:not(.active) {
border-color: #0ea5e9;
color: #0ea5e9;
}
.kind-btn.active {
background: color-mix(in srgb, #0ea5e9 12%, transparent);
border-color: #0ea5e9;
color: #0ea5e9;
}
.search {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, #0ea5e9 25%, transparent);
background: color-mix(in srgb, #0ea5e9 3%, transparent);
}
.search input[type='search'] {
padding: 0.4rem 0.6rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
font: inherit;
font-size: 0.85rem;
color: inherit;
}
.search input[type='search']:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.results {
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 240px;
overflow-y: auto;
}
.result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
padding: 0.4rem 0.6rem;
border-radius: 0.35rem;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
text-align: left;
}
.result:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
border-color: color-mix(in srgb, #0ea5e9 40%, transparent);
}
.result strong {
font-size: 0.9rem;
line-height: 1.25;
}
.result .meta {
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.url-row {
display: flex;
gap: 0.4rem;
padding: 0.5rem;
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, #0ea5e9 25%, transparent);
background: color-mix(in srgb, #0ea5e9 3%, transparent);
flex-wrap: wrap;
}
.url-row input {
flex: 1;
min-width: 9rem;
padding: 0.4rem 0.6rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
font: inherit;
font-size: 0.85rem;
color: inherit;
}
.url-row input:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.url-row .note-input {
flex: 2;
}
.url-row .primary {
padding: 0.4rem 0.9rem;
border-radius: 0.4rem;
border: 1px solid #0ea5e9;
background: #0ea5e9;
color: white;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
font-weight: 500;
}
.url-row .primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-size: 0.8rem;
margin: 0;
}
.muted.small {
padding: 0.5rem;
text-align: center;
}
</style>

View file

@ -32,6 +32,7 @@ import {
type RewriteParams,
type TranslateParams,
} from '../utils/prompt-builder';
import { resolveReferences } from '../utils/reference-resolver';
import { getStylePreset, type StylePreset } from '../presets/styles';
import type {
LocalDraftVersion,
@ -114,12 +115,18 @@ export const generationsStore = {
const styleExtracted =
resolved?.source === 'custom' ? (resolved.row.extractedPrinciples ?? undefined) : undefined;
// Resolve any attached references in parallel. Deleted / unsupported
// refs drop out silently via resolver; the aggregate budget cap is
// enforced inside resolveReferences() so the prompt can't balloon.
const resolvedReferences = await resolveReferences(draft.references ?? []);
const { system, user } = buildDraftPrompt({
kind: draft.kind,
title: draft.title,
briefing: draft.briefing,
stylePreset,
styleExtracted,
resolvedReferences,
});
const maxTokens = opts.maxTokens ?? estimateMaxTokens(draft.briefing);

View file

@ -13,6 +13,7 @@
import { KIND_LABELS } from '../constants';
import type { DraftBriefing, DraftKind, StyleExtractedPrinciples } from '../types';
import type { StylePreset } from '../presets/styles';
import type { ResolvedReference } from './reference-resolver';
export interface PromptPair {
system: string;
@ -79,6 +80,34 @@ export interface BuildDraftPromptInput {
briefing: DraftBriefing;
stylePreset?: StylePreset;
styleExtracted?: StyleExtractedPrinciples;
/**
* Resolved references (M5). Already trimmed to the aggregate budget
* by resolveReferences(); the builder only formats them into the
* user-message "Quellen" block.
*/
resolvedReferences?: ResolvedReference[];
}
/**
* Format resolved references as a "Quellen" block appended to the user
* message. Empty content (e.g. plain URLs without a saved article) still
* gets a line so the model knows the pointer exists; user notes are
* prefixed with "Kontext:" so they read as guidance, not body text.
*/
function renderReferencesBlock(refs: ResolvedReference[]): string {
if (refs.length === 0) return '';
const blocks = refs.map((ref, idx) => {
const head = `[Quelle ${idx + 1}] ${ref.sourceLabel}`;
const noteLine = ref.note ? `Kontext: ${ref.note}` : '';
const body = ref.content ? `\n${ref.content}` : '';
return [head, noteLine, body].filter(Boolean).join('\n');
});
return [
'',
'--- Quellen (vom Nutzer verknüpft) ---',
blocks.join('\n\n'),
'--- Ende Quellen ---',
].join('\n');
}
/**
@ -88,14 +117,20 @@ export interface BuildDraftPromptInput {
* the returned text is ready to paste into a version.
*/
export function buildDraftPrompt(input: BuildDraftPromptInput): PromptPair {
const { kind, title, briefing, stylePreset, styleExtracted } = input;
const { kind, title, briefing, stylePreset, styleExtracted, resolvedReferences } = input;
const lang = languageLabel(briefing.language);
const kindLbl = kindLabel(kind);
const refs = resolvedReferences ?? [];
const systemLines: string[] = [
`Du bist ein professioneller Ghostwriter. Deine Aufgabe: Schreibe einen fertigen ${kindLbl} auf ${lang} basierend auf dem Briefing des Nutzers.`,
`Gib ausschließlich den fertigen Text zurück. Keine Einleitung, keine Metakommentare, kein "Hier ist dein Text", keine Abschlussphrase nach dem Text. Markdown ist erlaubt, aber nicht erzwungen.`,
];
if (refs.length > 0) {
systemLines.push(
`Der Nutzer hat ${refs.length} Quelle${refs.length === 1 ? '' : 'n'} verknüpft. Ziehe Aussagen, Zahlen, Beispiele und Haltungen aus diesen Quellen heran, wenn sie zum Briefing passen. Erfinde keine Fakten; wenn eine Quelle nichts Passendes hergibt, lass sie weg. Paraphrasiere — kein wörtliches Zitieren ohne Anführungszeichen.`
);
}
const styleBlock = renderStyle(stylePreset, styleExtracted);
if (styleBlock) systemLines.push(styleBlock);
@ -112,6 +147,8 @@ export function buildDraftPrompt(input: BuildDraftPromptInput): PromptPair {
if (briefing.extraInstructions) {
userLines.push(`Zusätzliche Hinweise: ${briefing.extraInstructions}`);
}
const referencesBlock = renderReferencesBlock(refs);
if (referencesBlock) userLines.push(referencesBlock);
userLines.push('');
userLines.push(`Schreibe den ${kindLbl} jetzt.`);

View file

@ -0,0 +1,177 @@
/**
* Reference resolver turns a DraftReference into plaintext content the
* prompt builder can inject. Each kind has its own fetch path (scoped
* Dexie read + decryptRecords + module type converter) so we don't drag
* the per-module store layer into the writing module.
*
* M5 scope: `article`, `note`, `library`, `url`. Other kinds
* (`kontext`, `goal`, `me-image`) fall through to `null` and are
* silently skipped by the generations store they are on the roadmap
* but not required for M5 to be useful.
*
* Each resolved reference is truncated to MAX_CHARS_PER_REF so a
* long-form article can't blow out the token window. MAX_TOTAL_CHARS
* caps the aggregate across all references; any extras are dropped
* with a note, not truncated mid-sentence.
*/
import { scopedGet } from '$lib/data/scope';
import { decryptRecords } from '$lib/data/crypto';
import { toArticle } from '$lib/modules/articles/queries';
import { toNote } from '$lib/modules/notes/queries';
import { toLibraryEntry } from '$lib/modules/library/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 { DraftReference } from '../types';
const MAX_CHARS_PER_REF = 1500;
const TRUNCATION_MARKER = '\n[… gekürzt …]';
/** Soft cap — extras past this are dropped rather than partially truncated. */
export const MAX_TOTAL_REFERENCE_CHARS = 8000;
export interface ResolvedReference {
kind: DraftReference['kind'];
/** Human-readable source label shown in the prompt (e.g. "Artikel: New York Times — Title"). */
sourceLabel: string;
/** Short title fragment used in logs / chips. */
title: string;
/** Plaintext body — already truncated. */
content: string;
/** Optional user-written note about why this reference matters. */
note: string | null;
}
function truncate(text: string, max = MAX_CHARS_PER_REF): string {
const trimmed = text.trim();
if (trimmed.length <= max) return trimmed;
return trimmed.slice(0, max) + TRUNCATION_MARKER;
}
async function resolveArticle(
id: string
): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
const local = await scopedGet<LocalArticle>('articles', id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('articles', [local]);
if (!decrypted) return null;
const article = toArticle(decrypted);
const siteName = article.siteName ? `${article.siteName}` : '';
return {
sourceLabel: `Artikel: ${siteName}${article.title}`,
title: article.title,
content: truncate(article.content ?? article.excerpt ?? ''),
};
}
async function resolveNote(id: string): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
const local = await scopedGet<LocalNote>('notes', id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('notes', [local]);
if (!decrypted) return null;
const note = toNote(decrypted);
return {
sourceLabel: `Notiz: ${note.title || 'Ohne Titel'}`,
title: note.title || 'Notiz',
content: truncate(note.content ?? ''),
};
}
async function resolveLibrary(
id: string
): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
const local = await scopedGet<LocalLibraryEntry>('libraryEntries', id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('libraryEntries', [local]);
if (!decrypted) return null;
const entry = toLibraryEntry(decrypted);
const kindLabel =
entry.kind === 'book'
? 'Buch'
: entry.kind === 'movie'
? 'Film'
: entry.kind === 'series'
? 'Serie'
: entry.kind === 'comic'
? 'Comic'
: entry.kind;
const parts: string[] = [];
if (entry.creators.length) parts.push(`von ${entry.creators.join(', ')}`);
if (entry.year) parts.push(`${entry.year}`);
if (entry.rating !== null) parts.push(`Rating: ${entry.rating}/5`);
const meta = parts.length ? ` (${parts.join(', ')})` : '';
// Use the user's review as the body if present — it's the user's own
// distilled take; otherwise fall back to the title+metadata line so
// the reference still tells the model what the user engaged with.
const body = entry.review ? `Meine Notiz zu diesem ${kindLabel}:\n${entry.review}` : '';
return {
sourceLabel: `${kindLabel}: ${entry.title}${meta}`,
title: entry.title,
content: truncate(body),
};
}
function resolveUrl(ref: DraftReference): Omit<ResolvedReference, 'kind' | 'note'> | null {
if (!ref.url) return null;
// For M5 we don't fetch the URL — that would need a server proxy
// (CORS) and a Readability pass. The URL + optional user note is
// handed straight to the model; the user can save the article via
// the articles module first for full-content injection. The label
// mentions "Link" so the model knows it's a pointer, not a body.
return {
sourceLabel: `Link: ${ref.url}`,
title: ref.url,
content: '',
};
}
export async function resolveReference(ref: DraftReference): Promise<ResolvedReference | null> {
switch (ref.kind) {
case 'article': {
if (!ref.targetId) return null;
const base = await resolveArticle(ref.targetId);
return base ? { ...base, kind: 'article', note: ref.note ?? null } : null;
}
case 'note': {
if (!ref.targetId) return null;
const base = await resolveNote(ref.targetId);
return base ? { ...base, kind: 'note', note: ref.note ?? null } : null;
}
case 'library': {
if (!ref.targetId) return null;
const base = await resolveLibrary(ref.targetId);
return base ? { ...base, kind: 'library', note: ref.note ?? null } : null;
}
case 'url': {
const base = resolveUrl(ref);
return base ? { ...base, kind: 'url', note: ref.note ?? null } : null;
}
// kontext / goal / me-image — roadmap, not M5.
default:
return null;
}
}
/**
* Resolve a list of references in parallel, dropping any that can't be
* loaded (deleted row, missing target, unsupported kind). Enforces the
* aggregate character budget further references beyond the cap are
* dropped so the prompt never silently exceeds the budget.
*/
export async function resolveReferences(
refs: readonly DraftReference[]
): Promise<ResolvedReference[]> {
const resolved = (await Promise.all(refs.map((r) => resolveReference(r)))).filter(
(r): r is ResolvedReference => r !== null
);
const out: ResolvedReference[] = [];
let total = 0;
for (const ref of resolved) {
const size = ref.sourceLabel.length + ref.content.length + (ref.note?.length ?? 0);
if (total + size > MAX_TOTAL_REFERENCE_CHARS && out.length > 0) break;
out.push(ref);
total += size;
}
return out;
}