diff --git a/apps/mana/apps/web/src/lib/components/ai/MissionInputPicker.svelte b/apps/mana/apps/web/src/lib/components/ai/MissionInputPicker.svelte new file mode 100644 index 000000000..1738b6205 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/ai/MissionInputPicker.svelte @@ -0,0 +1,241 @@ + + + +
+
+ {#if value.length === 0} + Keine Inputs verlinkt. + {:else} + {#each value as ref (keyOf(ref))} + + {ref.module} + {labelFor(ref)} + + + {/each} + {/if} +
+ +
+ + + {#if activeModule} +
+ {#if loading} +

lade…

+ {:else if candidates.length === 0} +

Nichts in "{activeModule}" vorhanden.

+ {:else} + {#each candidates as c (keyOf(c))} + + {/each} + {/if} +
+ {/if} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts index 70f786573..df686d42e 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts @@ -10,7 +10,9 @@ import { db } from '../../database'; import { decryptRecords } from '../../crypto'; import { registerInputResolver } from './input-resolvers'; +import { registerInputIndexer } from './input-index'; import type { InputResolver } from './input-resolvers'; +import type { InputCandidate, InputIndexer } from './input-index'; interface NoteLike { id: string; @@ -73,13 +75,59 @@ const goalsResolver: InputResolver = async (ref) => { }; }; +// ── Indexers: list candidates for the picker UI ──────────── + +const notesIndexer: InputIndexer = async () => { + const all = await db.table('notes').toArray(); + const visible = all.filter((n) => !n.deletedAt); + const decrypted = await decryptRecords('notes', visible); + return decrypted + .map((n) => ({ + module: 'notes', + table: 'notes', + id: n.id, + label: (n.title && n.title.trim()) || '(ohne Titel)', + hint: n.content ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…` : undefined, + })) + .slice(0, 200); // cap — Mission picker isn't meant to list thousands +}; + +const kontextIndexer: InputIndexer = async () => { + const doc = await db.table('kontextDoc').get('singleton'); + if (!doc) return []; + return [ + { + module: 'kontext', + table: 'kontextDoc', + id: 'singleton', + label: 'Kontext-Dokument', + hint: 'Dein zentrales Markdown-Dokument', + }, + ]; +}; + +const goalsIndexer: InputIndexer = async () => { + const all = await db.table('companionGoals').toArray(); + const visible = all.filter((g) => !g.deletedAt); + return visible.map((g) => ({ + module: 'goals', + table: 'companionGoals', + id: g.id, + label: g.title ?? 'Goal', + hint: `${g.currentValue ?? 0} / ${g.target?.value ?? '?'} (${g.period ?? '—'})`, + })); +}; + let registered = false; -/** Register the default resolvers once. Idempotent. */ +/** Register the default resolvers + indexers once. Idempotent. */ export function registerDefaultInputResolvers(): void { if (registered) return; registerInputResolver('notes', notesResolver); registerInputResolver('kontext', kontextResolver); registerInputResolver('goals', goalsResolver); + registerInputIndexer('notes', notesIndexer); + registerInputIndexer('kontext', kontextIndexer); + registerInputIndexer('goals', goalsIndexer); registered = true; } diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/input-index.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.test.ts new file mode 100644 index 000000000..0ad0aa0c0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + registerInputIndexer, + unregisterInputIndexer, + listIndexedModules, + getInputCandidates, +} from './input-index'; + +afterEach(() => { + unregisterInputIndexer('index_test_mod'); + unregisterInputIndexer('index_test_boom'); +}); + +describe('input-index registry', () => { + it('lists registered modules sorted', () => { + registerInputIndexer('index_test_mod', async () => []); + expect(listIndexedModules()).toContain('index_test_mod'); + }); + + it('returns candidates from the registered indexer', async () => { + registerInputIndexer('index_test_mod', async () => [ + { module: 'index_test_mod', table: 't', id: 'a', label: 'A' }, + { module: 'index_test_mod', table: 't', id: 'b', label: 'B', hint: 'note' }, + ]); + const list = await getInputCandidates('index_test_mod'); + expect(list).toHaveLength(2); + expect(list[0].label).toBe('A'); + expect(list[1].hint).toBe('note'); + }); + + it('returns empty array for unknown module', async () => { + expect(await getInputCandidates('nope')).toEqual([]); + }); + + it('catches indexer errors and returns empty', async () => { + registerInputIndexer('index_test_boom', async () => { + throw new Error('broken'); + }); + const list = await getInputCandidates('index_test_boom'); + expect(list).toEqual([]); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts new file mode 100644 index 000000000..81c5ff5b5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts @@ -0,0 +1,48 @@ +/** + * Input-index registry — "what can the user link to a Mission?" + * + * Pairs with `input-resolvers.ts`. The resolver turns a + * {@link MissionInputRef} into text content the Planner can prompt on; + * this index lists the *available* records the user picks from in the + * Missions UI. Separate registry so modules stay decoupled from the AI + * subsystem — each module can register its own candidate list. + */ + +import type { MissionInputRef } from './types'; + +export interface InputCandidate extends MissionInputRef { + /** Human label for the picker UI. */ + label: string; + /** Optional secondary text (e.g. "last edited 2d ago"). */ + hint?: string; +} + +export type InputIndexer = () => Promise; + +const indexers = new Map(); + +/** Register (or replace) the indexer for a module. */ +export function registerInputIndexer(moduleName: string, indexer: InputIndexer): void { + indexers.set(moduleName, indexer); +} + +export function unregisterInputIndexer(moduleName: string): void { + indexers.delete(moduleName); +} + +/** Names of all modules that have registered an indexer. */ +export function listIndexedModules(): string[] { + return [...indexers.keys()].sort(); +} + +/** Fetch candidates for a single module. Empty array when nothing's registered. */ +export async function getInputCandidates(moduleName: string): Promise { + const indexer = indexers.get(moduleName); + if (!indexer) return []; + try { + return await indexer(); + } catch (err) { + console.error(`[MissionInputPicker] indexer for ${moduleName} threw:`, err); + return []; + } +} diff --git a/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte b/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte index 78d503965..e3a9ebb46 100644 --- a/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/companion/missions/+page.svelte @@ -21,7 +21,8 @@ } from '$lib/data/ai/missions/store'; import { runMission } from '$lib/data/ai/missions/runner'; import { productionDeps } from '$lib/data/ai/missions/setup'; - import type { Mission, MissionCadence } from '$lib/data/ai/missions/types'; + import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte'; + import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types'; const missions = $derived(useMissions()); @@ -36,6 +37,7 @@ let formCadenceKind = $state('manual'); let formIntervalMin = $state(60); let formDailyHour = $state(9); + let formInputs = $state([]); let creating = $state(false); function buildCadence(): MissionCadence { @@ -61,11 +63,13 @@ title: formTitle.trim(), objective: formObjective.trim(), conceptMarkdown: formConcept, + inputs: formInputs, cadence: buildCadence(), }); formTitle = ''; formObjective = ''; formConcept = ''; + formInputs = []; formCadenceKind = 'manual'; showForm = false; selectedId = m.id; @@ -169,6 +173,11 @@ > +
+ Inputs (Kontext für die KI) + +
+
Cadence
@@ -285,6 +294,14 @@
{describeCadence(selected.cadence)}
Nächster Run
{formatRelative(selected.nextRunAt)}
+
Inputs
+
+ {#if selected.inputs.length === 0} + — + {:else} + {selected.inputs.map((i) => `${i.module}/${i.id}`).join(', ')} + {/if} +
{#if selected.conceptMarkdown}