feat(comic): Mc4 — MCP + AI-Catalog für Character-System

Persona-Runner / Claude Desktop / Web-App-Mission-Runner können jetzt
Comic-Characters bauen, iterieren und pinnen — same Auto/Propose-
Pattern wie die Story-Tools.

MCP (packages/mana-tool-registry/src/modules/comic.ts):
- comic.listCharacters (read/auto): Pull, decrypt, filter (style?,
  favoriteOnly?), liefert {id, name, style, addPrompt, source-Refs,
  variantMediaIds, pinnedVariantId, variantCount, tags, isFavorite}.
- comic.createCharacter (write/propose): legt nur die Row an —
  trennt Anlegen von Generierung damit der Agent reviewen kann
  bevor Credits fließen. Liefert characterId zurück.
- comic.generateVariant (write/propose, kostet Credits): pullt
  Character-Row, dekodiert, ruft /picture/generate-with-reference
  mit n=count (default 4) + Stil-Prefix + Identity-Anchor-Prompt,
  schreibt N picture.images mit comicCharacterId-Back-Ref, pusht
  field-level Update auf variantMediaIds + pinnedVariantId
  (auto-pin auf erste neue Variant wenn vorher null).
- comic.pinVariant (write/propose): Set-Equality-Check (variantMediaId
  muss in variantMediaIds sein), field-level Update auf
  pinnedVariantId. Snapshot-Pattern: bestehende Stories bleiben
  unverändert, nur neue Stories nutzen den neuen Pin.

AI_TOOL_CATALOG (packages/shared-ai/src/tools/schemas.ts):
- list_comic_characters (auto)
- create_comic_character (propose) — auto-resolvt face/body-refs aus
  meImages-primaries, Agent muss keine mediaIds kennen
- generate_character_variant (propose, count 1-4)
- pin_character_variant (propose)

Web-App-Executors (apps/mana/apps/web/src/lib/modules/comic/tools.ts):
- 4 ModuleTool-Einträge, die an comicCharactersStore +
  runCharacterGenerate delegieren — gleicher Code-Pfad wie die UI,
  also keine Divergenz zwischen Klick und Agent-Call.

Comic-Autor-Template (packages/shared-ai/src/agents/templates/
comic-author.ts):
- Policy bi-lingual erweitert: snake_case + dot-case Namen für
  alle 4 neuen Character-Tools.
- System-Prompt Schritt 3 ergänzt: "Wenn der User noch keinen
  passenden Comic-Character hat → list_comic_characters →
  create_comic_character → generate_character_variant → pin.
  Das ist EINMALIG — der gepinnte Character bleibt für viele
  Stories der stabile Identity-Anchor."
- Tool-Liste am Ende vom System-Prompt um den Character-Pfad
  ergänzt.

apps/mana/CLAUDE.md Tool-Coverage-Zeile für comic erweitert:
+ create_comic_character / generate_character_variant /
+ pin_character_variant (propose)
+ list_comic_characters (auto)

Tool-Count: comic 3→7. Module 23 unverändert.

107 shared-ai-Tests weiter grün. check für comic-Files clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 19:27:15 +02:00
parent c5ff7e1d33
commit ef96948ea0
5 changed files with 908 additions and 14 deletions

View file

@ -238,7 +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` |
| comic | `create_comic_story`, `generate_comic_panel`, `create_comic_character`, `generate_character_variant`, `pin_character_variant` | `list_comic_stories`, `list_comic_characters` |
**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

@ -30,9 +30,12 @@ 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 { comicCharactersStore } from './stores/characters.svelte';
import { runPanelGenerate, DEFAULT_PANEL_MODEL, type PanelModel } from './api/generate-panel';
import { toStory } from './types';
import type { ComicStyle, LocalComicStory } from './types';
import { runCharacterGenerate } from './api/generate-character';
import { comicCharactersTable } from './collections';
import { toStory, toCharacter } from './types';
import type { ComicStyle, LocalComicStory, LocalComicCharacter } from './types';
const VALID_MODELS: readonly PanelModel[] = [
'openai/gpt-image-2',
@ -355,3 +358,285 @@ export const comicTools: ModuleTool[] = [
// when the LocalMeImage reference in resolveCharacterMediaIds is
// compile-time only.
export type { LocalMeImage };
// ─── Character tools (Mc4) ────────────────────────────────────────
export const comicCharacterTools: ModuleTool[] = [
{
name: 'list_comic_characters',
module: 'comic',
description:
'Listet Comic-Characters im aktiven Space (id, name, style, variantCount, pinnedVariantId, 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<LocalComicCharacter, string>(
'comic',
'comicCharacters'
).toArray();
const visible = locals.filter((c) => !c.deletedAt && !c.isArchived);
const decrypted = await decryptRecords('comicCharacters', visible);
const rows = decrypted
.map(toCharacter)
.filter((c) => (styleFilter ? c.style === styleFilter : true))
.filter((c) => (favoriteOnly ? c.isFavorite === true : true))
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit)
.map((c) => ({
id: c.id,
name: c.name,
style: c.style,
variantCount: c.variantMediaIds.length,
pinnedVariantId: c.pinnedVariantId ?? null,
isFavorite: c.isFavorite === true,
}));
return {
success: true,
data: { characters: rows, total: rows.length },
message: `${rows.length} Character${rows.length === 1 ? '' : 's'} gelistet`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return {
success: false,
message: 'Vault ist gesperrt — Comic-Characters können nicht entschlüsselt werden',
};
}
throw err;
}
},
},
{
name: 'create_comic_character',
module: 'comic',
description:
'Legt einen neuen Comic-Character an OHNE direkt Varianten zu rendern. Charakter-Refs werden automatisch aus dem primary face-ref + body-ref des aktiven Space aufgeloest. Stil ist fix nach Anlage.',
parameters: [
{ name: 'name', type: 'string', description: 'Name des Characters', required: true },
{
name: 'style',
type: 'string',
description: 'Visueller Stil',
required: true,
enum: [...VALID_STYLES],
},
{
name: 'addPrompt',
type: 'string',
description: 'Zusaetzlicher Prompt',
required: false,
},
{
name: 'description',
type: 'string',
description: 'Kurze Charakter-Beschreibung',
required: false,
},
{ name: 'tags', type: 'string', description: 'Tags durch Komma getrennt', required: false },
],
async execute(params) {
const name = String(params.name ?? '').trim();
if (!name) return { success: false, message: 'name 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.faceRef) {
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 addPrompt =
typeof params.addPrompt === 'string' && params.addPrompt.trim()
? params.addPrompt.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 character = await comicCharactersStore.createCharacter({
name,
style,
sourceFaceMediaId: refs.faceRef,
sourceBodyMediaId: refs.bodyRef,
addPrompt,
description,
tags,
});
return {
success: true,
data: {
id: character.id,
name: character.name,
style: character.style,
hasBodyRef: refs.bodyRef !== null,
note: 'Character-Row angelegt — jetzt generate_character_variant aufrufen um Varianten zu rendern.',
},
message: `Character "${character.name}" angelegt (Stil: ${character.style})`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return { success: false, message: 'Vault ist gesperrt — Character nicht gespeichert' };
}
throw err;
}
},
},
{
name: 'generate_character_variant',
module: 'comic',
description:
'Rendert N (default 4) Variant-Portraits fuer einen existierenden Comic-Character via gpt-image-2. Konsumiert Credits × count. Auto-pinnt die erste Variante wenn noch keine gepinnt ist.',
parameters: [
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
{
name: 'count',
type: 'number',
description: 'Anzahl Varianten (1-4, default 4)',
required: false,
},
{
name: 'quality',
type: 'string',
description: 'Render-Qualitaet',
required: false,
enum: ['low', 'medium', 'high'],
},
{
name: 'model',
type: 'string',
description: 'Rendering-Backend',
required: false,
enum: [...VALID_MODELS],
},
],
async execute(params) {
const characterId = String(params.characterId ?? '').trim();
if (!characterId) return { success: false, message: 'characterId erforderlich' };
const count = Math.max(1, Math.min(4, Number(params.count) || 4));
const quality =
params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium';
const model: PanelModel = isValidModel(params.model) ? params.model : DEFAULT_PANEL_MODEL;
try {
const local = await comicCharactersTable.get(characterId);
if (!local || local.deletedAt) {
return { success: false, message: `Character ${characterId} nicht gefunden` };
}
const [decrypted] = await decryptRecords('comicCharacters', [local]);
if (!decrypted) {
return { success: false, message: 'Entschlüsselung des Characters fehlgeschlagen' };
}
const character = toCharacter(decrypted);
const result = await runCharacterGenerate({
character,
count,
quality: quality as 'low' | 'medium' | 'high',
model,
});
return {
success: true,
data: {
characterId: character.id,
newVariantMediaIds: result.variantMediaIds,
imageUrls: result.imageUrls,
model: result.model,
},
message: `${result.variantMediaIds.length} Variant${result.variantMediaIds.length === 1 ? '' : 's'} für "${character.name}" generiert`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return { success: false, message: 'Vault ist gesperrt' };
}
return {
success: false,
message: err instanceof Error ? err.message : 'Variant-Generierung fehlgeschlagen',
};
}
},
},
{
name: 'pin_character_variant',
module: 'comic',
description:
'Setzt einen anderen Variant als kanonischen Look. Stories danach erstellt nutzen den neuen Pin — bestehende Stories bleiben unveraendert (Snapshot-Pattern).',
parameters: [
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
{
name: 'variantMediaId',
type: 'string',
description: 'ID des neuen Pin-Variants',
required: true,
},
],
async execute(params) {
const characterId = String(params.characterId ?? '').trim();
const variantMediaId = String(params.variantMediaId ?? '').trim();
if (!characterId || !variantMediaId) {
return { success: false, message: 'characterId und variantMediaId erforderlich' };
}
try {
await comicCharactersStore.pinVariant(characterId, variantMediaId);
return {
success: true,
data: { characterId, pinnedVariantId: variantMediaId },
message: `Variant gepinned`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : 'Pin fehlgeschlagen',
};
}
},
},
];
// Append character tools to the main export so init.ts picks them
// up via the existing registerTools(comicTools) call.
comicTools.push(...comicCharacterTools);