feat(comic): AI_TOOL_CATALOG bridge — webapp-runner kann jetzt Comics

Macht den Comic-Autor-Template (M6) auch im Web-App-Mission-Runner
nutzbar. Bisher war der Template nur über persona-runner/Claude
Desktop sinnvoll, weil die comic.*-Tools nur im mana-tool-registry
(MCP) lagen. Jetzt kennt die AI Workbench drei neue Tools und der
Template-Policy-Map trägt beide Naming-Konventionen.

AI_TOOL_CATALOG-Einträge (packages/shared-ai/src/tools/schemas.ts):
- list_comic_stories (auto) — filter style?/favoriteOnly?/limit?
- create_comic_story (propose) — title + style + optional
  description/storyContext/tags. Character-Refs werden vom Executor
  automatisch aus meImages primary face-ref + body-ref gezogen,
  also muss der Planner keine mediaIds kennen.
- generate_comic_panel (propose) — storyId + panelPrompt + optional
  caption/dialogue + quality. Kostet Credits.

Executors (apps/mana/apps/web/src/lib/modules/comic/tools.ts):
- list: scopedForModule pull + decrypt + filter + sort newest.
- create: resolveCharacterMediaIds() scannt meImagesTable für das
  aktive Space, nimmt face-ref+body-ref. Fehler wenn kein Face
  hinterlegt ("Lade eines in /profile/me-images hoch"). Delegiert
  an comicStoriesStore.createStory — gleiche encryption/event-
  pipeline wie StoryForm.
- generate: lädt Story decrypted, delegiert an runPanelGenerate
  (identischer Pfad wie PanelEditor in der UI), liefert
  panelIndex + imageUrl zurück.

Registrierung in data/tools/init.ts (registerTools(comicTools)).

Template-Policy (comic-author.ts) jetzt bi-lingual: snake_case
(AI_TOOL_CATALOG) UND dot-case (MCP) nebeneinander in tools-Map.
So gilt die Intent-Policy konsistent egal welche Runner-Oberfläche
das Tool nennt — auto für list_comic_stories / comic.listStories,
propose für create_comic_story / comic.createStory /
generate_comic_panel / comic.generatePanel / comic.reorderPanels.

apps/mana/CLAUDE.md Tool-Coverage-Tabelle bekommt eine Comic-Zeile.

Tool-Count jetzt 75→78, Module 22→23. 107 shared-ai tests
weiter grün. check + validate:all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:49:24 +02:00
parent 6545498dc2
commit 6f37e00bf4
5 changed files with 451 additions and 4 deletions

View file

@ -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`.

View file

@ -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;
}

View file

@ -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<LocalComicStory, string>(
'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<LocalComicStory, string>('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 };

View file

@ -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',

View file

@ -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'],
},
],
},
];
// ═══════════════════════════════════════════════════════════════