diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index ca3b5ac48..94beabd30 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -238,6 +238,7 @@ Agents interact with the app through tools — each one either auto (executes si | invoices | `create_invoice`, `mark_invoice_paid` | `list_invoices`, `get_invoice_stats` | | library | `create_library_entry`, `update_library_entry_status`, `rate_library_entry` | `list_library_entries` | | writing | `create_draft`, `generate_draft_content`, `refine_draft_selection`, `set_draft_status`, `save_draft_as_article` | `list_drafts`, `get_draft`, `list_writing_styles` | +| comic | `create_comic_story`, `generate_comic_panel` | `list_comic_stories` | **Server-side web-research**: mana-ai calls mana-api's `/api/v1/news-research/discover` + `/search` directly before the planner prompt is built (pre-planning injection). Missions with research-keyword objectives get real article URLs + excerpts injected as a synthetic ResolvedInput. See `services/mana-ai/src/planner/news-research-client.ts`. diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index ae6ae12e3..58983e8cd 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -47,6 +47,7 @@ import { libraryTools } from '$lib/modules/library/tools'; import { broadcastTools } from '$lib/modules/broadcast/tools'; import { websiteTools } from '$lib/modules/website/tools'; import { writingTools } from '$lib/modules/writing/tools'; +import { comicTools } from '$lib/modules/comic/tools'; let initialized = false; @@ -95,5 +96,6 @@ export function initTools(): void { registerTools(broadcastTools); registerTools(websiteTools); registerTools(writingTools); + registerTools(comicTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/comic/tools.ts b/apps/mana/apps/web/src/lib/modules/comic/tools.ts new file mode 100644 index 000000000..27ac82079 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/tools.ts @@ -0,0 +1,337 @@ +/** + * Comic module tools — AI-accessible operations over comic stories. + * + * Auto (read-only): + * - list_comic_stories + * + * Propose (human approval per the agent's policy — generate burns + * credits so it's never auto): + * - create_comic_story + * - generate_comic_panel + * + * Character references (face-ref + optional body-ref) resolve + * automatically from the active space's primary meImages — the AI + * caller doesn't have to know about mediaIds. That's a deliberate + * simplification versus the MCP layer (packages/mana-tool-registry/ + * src/modules/comic.ts) which accepts an explicit `characterMediaIds` + * array; the webapp-runner pattern is "compose for the user, then + * propose", and forcing the planner to list mediaIds before creating + * a story was friction with no upside. + * + * Panel rendering delegates to the existing `runPanelGenerate` from + * api/generate-panel.ts, which is the same code path the DetailView's + * PanelEditor uses — so the encryption + picture.images insertion + + * story appendPanel happen exactly once regardless of whether the + * user or the agent triggered it. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords, VaultLockedError } from '$lib/data/crypto'; +import { meImagesTable } from '$lib/modules/profile/collections'; +import { comicStoriesStore } from './stores/stories.svelte'; +import { runPanelGenerate } from './api/generate-panel'; +import { toStory } from './types'; +import type { ComicStyle, LocalComicStory } from './types'; +import type { LocalMeImage } from '$lib/modules/profile/types'; +import { getActiveSpace } from '$lib/data/scope'; + +const VALID_STYLES: ComicStyle[] = ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon']; + +function isValidStyle(v: unknown): v is ComicStyle { + return typeof v === 'string' && (VALID_STYLES as string[]).includes(v); +} + +/** + * Resolve the active space's primary face-ref (+ optional body-ref) to + * a mediaId array suitable for `characterMediaIds`. Non-reactive — we + * scan meImagesTable directly instead of going through the svelte + * `useImageByPrimary` hook because tools run outside the Svelte + * reactivity graph. + */ +async function resolveCharacterMediaIds(): Promise<{ + mediaIds: string[]; + faceRef: string | null; + bodyRef: string | null; +}> { + const space = getActiveSpace(); + if (!space) return { mediaIds: [], faceRef: null, bodyRef: null }; + const all = await meImagesTable.toArray(); + const inSpace = all.filter((m) => !m.deletedAt && m.spaceId === space.id); + const face = inSpace.find((m) => m.primaryFor === 'face-ref') ?? null; + const body = inSpace.find((m) => m.primaryFor === 'body-ref') ?? null; + const mediaIds: string[] = []; + if (face?.mediaId) mediaIds.push(face.mediaId); + if (body?.mediaId) mediaIds.push(body.mediaId); + return { mediaIds, faceRef: face?.mediaId ?? null, bodyRef: body?.mediaId ?? null }; +} + +export const comicTools: ModuleTool[] = [ + { + name: 'list_comic_stories', + module: 'comic', + description: + 'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, isFavorite). Optional nach Stil oder Favoriten filterbar.', + parameters: [ + { + name: 'style', + type: 'string', + description: 'Nur einen Stil zeigen', + required: false, + enum: [...VALID_STYLES], + }, + { + name: 'favoriteOnly', + type: 'boolean', + description: 'Nur Favoriten', + required: false, + }, + { name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false }, + ], + async execute(params) { + const styleFilter = params.style as ComicStyle | undefined; + const favoriteOnly = params.favoriteOnly === true; + const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100); + + try { + const locals = await scopedForModule( + 'comic', + 'comicStories' + ).toArray(); + const visible = locals.filter((s) => !s.deletedAt && !s.isArchived); + const decrypted = await decryptRecords('comicStories', visible); + const rows = decrypted + .map(toStory) + .filter((s) => (styleFilter ? s.style === styleFilter : true)) + .filter((s) => (favoriteOnly ? s.isFavorite === true : true)) + .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .slice(0, limit) + .map((s) => ({ + id: s.id, + title: s.title, + style: s.style, + panelCount: s.panelImageIds.length, + isFavorite: s.isFavorite === true, + description: s.description ?? null, + storyContext: s.storyContext ?? null, + })); + + return { + success: true, + data: { stories: rows, total: rows.length }, + message: `${rows.length} Stor${rows.length === 1 ? 'y' : 'ies'} gelistet`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { + success: false, + message: 'Vault ist gesperrt — Comic-Stories können nicht entschlüsselt werden', + }; + } + throw err; + } + }, + }, + + { + name: 'create_comic_story', + module: 'comic', + description: + 'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.', + parameters: [ + { name: 'title', type: 'string', description: 'Titel der Story', required: true }, + { + name: 'style', + type: 'string', + description: 'Visueller Stil', + required: true, + enum: [...VALID_STYLES], + }, + { + name: 'description', + type: 'string', + description: 'Kurze Story-Beschreibung', + required: false, + }, + { + name: 'storyContext', + type: 'string', + description: 'Freitext-Briefing — Ton, Ziel, Hintergrund', + required: false, + }, + { + name: 'tags', + type: 'string', + description: 'Tags durch Komma getrennt', + required: false, + }, + ], + async execute(params) { + const title = String(params.title ?? '').trim(); + if (!title) return { success: false, message: 'title erforderlich' }; + + const style = params.style; + if (!isValidStyle(style)) { + return { + success: false, + message: `style muss einer von ${VALID_STYLES.join(', ')} sein`, + }; + } + + const refs = await resolveCharacterMediaIds(); + if (refs.mediaIds.length === 0) { + return { + success: false, + message: + 'Kein Gesichtsbild im aktiven Space. Lade eines in /profile/me-images hoch, dann erneut versuchen.', + }; + } + + const description = + typeof params.description === 'string' && params.description.trim() + ? params.description.trim() + : null; + const storyContext = + typeof params.storyContext === 'string' && params.storyContext.trim() + ? params.storyContext.trim() + : null; + const tags = + typeof params.tags === 'string' && params.tags.trim() + ? params.tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0) + : []; + + try { + const story = await comicStoriesStore.createStory({ + title, + style, + characterMediaIds: refs.mediaIds, + description, + storyContext, + tags, + }); + return { + success: true, + data: { + id: story.id, + title: story.title, + style: story.style, + characterRefCount: refs.mediaIds.length, + hasBodyRef: refs.bodyRef !== null, + }, + message: `Story "${story.title}" angelegt (Stil: ${story.style})`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { success: false, message: 'Vault ist gesperrt — Story nicht gespeichert' }; + } + throw err; + } + }, + }, + + { + name: 'generate_comic_panel', + module: 'comic', + description: + 'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.', + parameters: [ + { name: 'storyId', type: 'string', description: 'ID der Story', required: true }, + { + name: 'panelPrompt', + type: 'string', + description: + 'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.', + required: true, + }, + { + name: 'caption', + type: 'string', + description: 'Erzaehl-Zeile ueber/unter dem Bild', + required: false, + }, + { + name: 'dialogue', + type: 'string', + description: 'Sprechblasen-Text', + required: false, + }, + { + name: 'quality', + type: 'string', + description: 'Render-Qualitaet — hoeher = mehr Credits', + required: false, + enum: ['low', 'medium', 'high'], + }, + ], + async execute(params) { + const storyId = String(params.storyId ?? '').trim(); + if (!storyId) return { success: false, message: 'storyId erforderlich' }; + + const panelPrompt = String(params.panelPrompt ?? '').trim(); + if (!panelPrompt) return { success: false, message: 'panelPrompt erforderlich' }; + + const caption = + typeof params.caption === 'string' && params.caption.trim() + ? params.caption.trim() + : undefined; + const dialogue = + typeof params.dialogue === 'string' && params.dialogue.trim() + ? params.dialogue.trim() + : undefined; + const quality = + params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium'; + + try { + // Load the story for runPanelGenerate — same code path as the + // PanelEditor in the web UI. + const locals = await scopedForModule('comic', 'comicStories') + .and((s) => s.id === storyId) + .toArray(); + const [local] = locals; + if (!local || local.deletedAt) { + return { success: false, message: `Story ${storyId} nicht gefunden` }; + } + const [decrypted] = await decryptRecords('comicStories', [local]); + if (!decrypted) { + return { success: false, message: 'Entschlüsselung der Story fehlgeschlagen' }; + } + const story = toStory(decrypted); + + const result = await runPanelGenerate({ + story, + panelPrompt, + caption, + dialogue, + quality: quality as 'low' | 'medium' | 'high', + }); + + return { + success: true, + data: { + imageId: result.imageId, + imageUrl: result.imageUrl, + panelIndex: result.panelIndex, + model: result.model, + }, + message: `Panel ${result.panelIndex + 1} für Story "${story.title}" generiert`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { success: false, message: 'Vault ist gesperrt — Panel nicht angehängt' }; + } + return { + success: false, + message: err instanceof Error ? err.message : 'Panel-Generierung fehlgeschlagen', + }; + } + }, + }, +]; + +// Imported for side-effect types — keeps unused-import warnings quiet +// when the LocalMeImage reference in resolveCharacterMediaIds is +// compile-time only. +export type { LocalMeImage }; diff --git a/packages/shared-ai/src/agents/templates/comic-author.ts b/packages/shared-ai/src/agents/templates/comic-author.ts index 291955297..46f855a68 100644 --- a/packages/shared-ai/src/agents/templates/comic-author.ts +++ b/packages/shared-ai/src/agents/templates/comic-author.ts @@ -42,10 +42,20 @@ import type { AiPolicy } from '../../policy/types'; const COMIC_AUTHOR_POLICY: AiPolicy = { tools: { ...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose' as const])), - // MCP-tools explicit: they're not in AI_TOOL_CATALOG so the - // spread above doesn't cover them. Listing them here is the - // only way to pin the policy — defaultsByModule wouldn't help - // because the tool-level entry wins over module defaults. + // Web-app catalog names (snake_case). The spread above already + // covers create_comic_story / generate_comic_panel because both + // are defaultPolicy='propose' in AI_TOOL_CATALOG, but we pin + // list_comic_stories explicitly as auto (read-only tools come + // from the catalog as 'auto' already, so this is defensive + // rather than strictly required). + list_comic_stories: 'auto', + create_comic_story: 'propose', + generate_comic_panel: 'propose', + // MCP-registry names (dot-case). The agent uses these when + // running inside persona-runner / Claude Desktop where the + // mana-tool-registry surface is what the MCP client sees. + // Listing them keeps the policy intent consistent across both + // surfaces (foreground runner + MCP). 'comic.listStories': 'auto', 'comic.createStory': 'propose', 'comic.generatePanel': 'propose', diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index e5e26f3e1..0f157f912 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -1878,6 +1878,103 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ defaultPolicy: 'propose', parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], }, + + // ── Comic ─────────────────────────────────────────────── + { + name: 'list_comic_stories', + module: 'comic', + description: + 'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, isFavorite). Optional nach Stil oder Favoriten filterbar.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'style', + type: 'string', + description: 'Nur einen Stil zeigen', + required: false, + enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'], + }, + { + name: 'favoriteOnly', + type: 'boolean', + description: 'Nur Favoriten', + required: false, + }, + { name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false }, + ], + }, + { + name: 'create_comic_story', + module: 'comic', + description: + 'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.', + defaultPolicy: 'propose', + parameters: [ + { name: 'title', type: 'string', description: 'Titel der Story', required: true }, + { + name: 'style', + type: 'string', + description: 'Visueller Stil', + required: true, + enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'], + }, + { + name: 'description', + type: 'string', + description: 'Kurze Story-Beschreibung', + required: false, + }, + { + name: 'storyContext', + type: 'string', + description: + 'Freitext-Briefing — Ton, Ziel, Hintergrund. Wird im AI-Storyboard-Flow als Briefing genutzt.', + required: false, + }, + { + name: 'tags', + type: 'string', + description: 'Tags durch Komma getrennt', + required: false, + }, + ], + }, + { + name: 'generate_comic_panel', + module: 'comic', + description: + 'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.', + defaultPolicy: 'propose', + parameters: [ + { name: 'storyId', type: 'string', description: 'ID der Story', required: true }, + { + name: 'panelPrompt', + type: 'string', + description: + 'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.', + required: true, + }, + { + name: 'caption', + type: 'string', + description: 'Erzaehl-Zeile ueber/unter dem Bild (optional)', + required: false, + }, + { + name: 'dialogue', + type: 'string', + description: 'Sprechblasen-Text (optional)', + required: false, + }, + { + name: 'quality', + type: 'string', + description: 'Render-Qualitaet — hoeher = mehr Credits', + required: false, + enum: ['low', 'medium', 'high'], + }, + ], + }, ]; // ═══════════════════════════════════════════════════════════════