mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(ai): input picker — link notes / kontext / goals to a Mission
Closes the "blind Planner" gap: users can now attach context records to a Mission, and the Runner resolves them through the existing resolver registry before calling the Planner. The LLM sees the actual linked note content, not just the mission objective. - `data/ai/missions/input-index.ts` — sibling registry to input-resolvers. Resolvers turn a ref into prompt text (Runner path); indexers list candidates for the picker UI (create-form path). Same shape, different direction; keeps modules decoupled from the AI layer on both ends. - `data/ai/missions/default-resolvers.ts` — registers indexers for notes (title + content preview, capped at 200), kontext (the singleton doc), goals (title + progress). Co-located with the resolvers so the two halves stay in sync. - `components/ai/MissionInputPicker.svelte` — drop-in picker: module selector → candidate list → chip-style selected display. Binds directly to `MissionInputRef[]` so forms use it as a single control. - `/companion/missions` — picker wired into the create form between Konzept and Cadence; detail view's meta block now lists the linked inputs so users can see what context the Planner will see. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a8c019ab0
commit
41052f769e
5 changed files with 398 additions and 2 deletions
|
|
@ -0,0 +1,241 @@
|
|||
<!--
|
||||
MissionInputPicker — lets the user link records (notes, goals, kontext,
|
||||
future modules…) as inputs for an AI Mission.
|
||||
|
||||
Binds to a `MissionInputRef[]` so the caller can drop it into any form:
|
||||
|
||||
<MissionInputPicker bind:value={formInputs} />
|
||||
|
||||
The list of modules that appear in the picker is driven by whoever
|
||||
registered an indexer via `registerInputIndexer(name, fn)` — today that's
|
||||
the default resolvers (notes / kontext / goals), but each module can
|
||||
register a richer projection later without this component changing.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus, X } from '@mana/shared-icons';
|
||||
import {
|
||||
listIndexedModules,
|
||||
getInputCandidates,
|
||||
type InputCandidate,
|
||||
} from '$lib/data/ai/missions/input-index';
|
||||
import { registerDefaultInputResolvers } from '$lib/data/ai/missions/default-resolvers';
|
||||
import type { MissionInputRef } from '$lib/data/ai/missions/types';
|
||||
|
||||
interface Props {
|
||||
value: MissionInputRef[];
|
||||
}
|
||||
|
||||
let { value = $bindable([]) }: Props = $props();
|
||||
|
||||
// Ensure defaults are registered even if the layout tick hasn't started
|
||||
// yet (e.g. the create form opens before the first tick).
|
||||
registerDefaultInputResolvers();
|
||||
|
||||
const modules = $derived(listIndexedModules());
|
||||
let activeModule = $state<string | null>(null);
|
||||
let candidates = $state<InputCandidate[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!activeModule) {
|
||||
candidates = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
void getInputCandidates(activeModule)
|
||||
.then((list) => {
|
||||
candidates = list;
|
||||
})
|
||||
.finally(() => {
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
function keyOf(ref: MissionInputRef): string {
|
||||
return `${ref.module}::${ref.table}::${ref.id}`;
|
||||
}
|
||||
|
||||
const selectedKeys = $derived(new Set(value.map(keyOf)));
|
||||
|
||||
function toggle(candidate: InputCandidate) {
|
||||
const key = keyOf(candidate);
|
||||
if (selectedKeys.has(key)) {
|
||||
value = value.filter((v) => keyOf(v) !== key);
|
||||
} else {
|
||||
value = [...value, { module: candidate.module, table: candidate.table, id: candidate.id }];
|
||||
}
|
||||
}
|
||||
|
||||
function removeInput(ref: MissionInputRef) {
|
||||
const key = keyOf(ref);
|
||||
value = value.filter((v) => keyOf(v) !== key);
|
||||
}
|
||||
|
||||
function labelFor(ref: MissionInputRef): string {
|
||||
const match = candidates.find(
|
||||
(c) => c.module === ref.module && c.table === ref.table && c.id === ref.id
|
||||
);
|
||||
return match?.label ?? `${ref.module}/${ref.id}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<div class="selected" aria-label="Verlinkte Inputs">
|
||||
{#if value.length === 0}
|
||||
<span class="hint">Keine Inputs verlinkt.</span>
|
||||
{:else}
|
||||
{#each value as ref (keyOf(ref))}
|
||||
<span class="chip">
|
||||
<span class="chip-mod">{ref.module}</span>
|
||||
<span class="chip-label">{labelFor(ref)}</span>
|
||||
<button type="button" onclick={() => removeInput(ref)} aria-label="Entfernen">
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="picker-controls">
|
||||
<label>
|
||||
<span class="small">Modul</span>
|
||||
<select bind:value={activeModule}>
|
||||
<option value={null}>— wählen —</option>
|
||||
{#each modules as m}
|
||||
<option value={m}>{m}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if activeModule}
|
||||
<div class="candidates">
|
||||
{#if loading}
|
||||
<p class="hint">lade…</p>
|
||||
{:else if candidates.length === 0}
|
||||
<p class="hint">Nichts in "{activeModule}" vorhanden.</p>
|
||||
{:else}
|
||||
{#each candidates as c (keyOf(c))}
|
||||
<button
|
||||
type="button"
|
||||
class="candidate"
|
||||
class:selected={selectedKeys.has(keyOf(c))}
|
||||
onclick={() => toggle(c)}
|
||||
>
|
||||
<Plus size={12} weight="bold" />
|
||||
<span class="cand-label">{c.label}</span>
|
||||
{#if c.hint}
|
||||
<span class="cand-hint">{c.hint}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.selected {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
min-height: 1.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.25rem 0.125rem 0.5rem;
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 10%, var(--color-bg, #fff));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.chip-mod {
|
||||
color: var(--color-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
.chip button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-muted, #888);
|
||||
display: inline-flex;
|
||||
}
|
||||
.chip button:hover {
|
||||
color: var(--color-fg, inherit);
|
||||
}
|
||||
|
||||
.picker-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.picker-controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
.picker-controls select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.25rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.candidates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.candidate {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.candidate:hover {
|
||||
border-color: var(--color-border, #ddd);
|
||||
}
|
||||
.candidate.selected {
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 10%, transparent);
|
||||
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
|
||||
}
|
||||
.cand-hint {
|
||||
color: var(--color-muted, #888);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--color-muted, #888);
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<NoteLike>('notes').toArray();
|
||||
const visible = all.filter((n) => !n.deletedAt);
|
||||
const decrypted = await decryptRecords('notes', visible);
|
||||
return decrypted
|
||||
.map<InputCandidate>((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<KontextDocLike>('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<GoalLike>('companionGoals').toArray();
|
||||
const visible = all.filter((g) => !g.deletedAt);
|
||||
return visible.map<InputCandidate>((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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
48
apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts
Normal file
48
apps/mana/apps/web/src/lib/data/ai/missions/input-index.ts
Normal file
|
|
@ -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<InputCandidate[]>;
|
||||
|
||||
const indexers = new Map<string, InputIndexer>();
|
||||
|
||||
/** 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<InputCandidate[]> {
|
||||
const indexer = indexers.get(moduleName);
|
||||
if (!indexer) return [];
|
||||
try {
|
||||
return await indexer();
|
||||
} catch (err) {
|
||||
console.error(`[MissionInputPicker] indexer for ${moduleName} threw:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MissionCadence['kind']>('manual');
|
||||
let formIntervalMin = $state(60);
|
||||
let formDailyHour = $state(9);
|
||||
let formInputs = $state<MissionInputRef[]>([]);
|
||||
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 @@
|
|||
></textarea>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Inputs (Kontext für die KI)</legend>
|
||||
<MissionInputPicker bind:value={formInputs} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cadence</legend>
|
||||
<div class="cadence-row">
|
||||
|
|
@ -285,6 +294,14 @@
|
|||
<dd>{describeCadence(selected.cadence)}</dd>
|
||||
<dt>Nächster Run</dt>
|
||||
<dd>{formatRelative(selected.nextRunAt)}</dd>
|
||||
<dt>Inputs</dt>
|
||||
<dd>
|
||||
{#if selected.inputs.length === 0}
|
||||
—
|
||||
{:else}
|
||||
{selected.inputs.map((i) => `${i.module}/${i.id}`).join(', ')}
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{#if selected.conceptMarkdown}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue