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

@ -568,6 +568,495 @@ export const comicReorderPanels: ToolSpec<typeof reorderPanelsInput, typeof reor
},
};
// ═══════════════════════════════════════════════════════════════
// COMIC-CHARACTERS (Mc4)
// ═══════════════════════════════════════════════════════════════
//
// Stylised character variants the user iterates BEFORE building a
// story (face-ref → manga / cartoon / etc.) — see
// docs/plans/comic-module.md §11. Same crypto envelope as stories
// (name + description + addPrompt + tags encrypted; ids + style +
// variant-list + booleans plaintext).
//
// Three of the four tools land in the picture pipeline:
// - listCharacters: read-only (no credit cost)
// - createCharacter: row-only (no credit cost). Splits creation
// from generation so an agent can review the pinned-variant flow
// before burning credits.
// - generateVariant: fires `/picture/generate-with-reference` with
// n=1..4 (server cap) under the user's JWT, persists each output
// as a picture.images row + appends to the character's variant
// list. Auto-pins the first variant if none pinned yet (mirrors
// comicCharactersStore.appendVariant on the web side).
// - pinVariant: row update only.
const CHARACTERS_TABLE = 'comicCharacters';
const CHARACTER_ENCRYPTED_FIELDS = ['name', 'description', 'addPrompt', 'tags'] as const;
const characterSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
style: comicStyle,
addPrompt: z.string().nullable(),
sourceFaceMediaId: z.string(),
sourceBodyMediaId: z.string().nullable(),
variantMediaIds: z.array(z.string()),
pinnedVariantId: z.string().nullable(),
variantCount: z.number().int().nonnegative(),
tags: z.array(z.string()),
isFavorite: z.boolean(),
});
interface RawCharacterRow {
id?: string;
name?: string;
description?: string | null;
style?: string;
addPrompt?: string | null;
sourceFaceMediaId?: string;
sourceBodyMediaId?: string | null;
variantMediaIds?: string[];
pinnedVariantId?: string | null;
tags?: string[];
isFavorite?: boolean;
isArchived?: boolean;
deletedAt?: string | null;
spaceId?: string | null;
updatedAt?: string;
}
function characterStylePrefix(style: ComicStyleT): string {
return STYLE_PREFIXES[style];
}
function composeCharacterPrompt(style: ComicStyleT, addPrompt: string | null | undefined): string {
const parts: string[] = [
characterStylePrefix(style),
'portrait of the user',
'looking natural, head and shoulders visible',
'neutral background, clear identity anchor — same face, same eyes, recognisable across panels',
];
const trimmed = addPrompt?.trim();
if (trimmed) parts.push(trimmed);
return parts.join('. ');
}
// ─── comic.listCharacters ─────────────────────────────────────────
const listCharactersInput = z.object({
style: comicStyle.optional(),
favoriteOnly: z.boolean().default(false),
limit: z.number().int().positive().max(200).default(50),
});
const listCharactersOutput = z.object({
characters: z.array(characterSchema),
});
export const comicListCharacters: ToolSpec<
typeof listCharactersInput,
typeof listCharactersOutput
> = {
name: 'comic.listCharacters',
module: 'comic',
scope: 'user-space',
policyHint: 'read',
description:
"List the caller's comic-characters in the active space. Filter by `style` and/or `favoriteOnly`. Returned rows include `pinnedVariantId` (the canonical look — null if no variant pinned yet) and `variantCount` for quick state-overview without loading the full variantMediaIds array.",
input: listCharactersInput,
output: listCharactersOutput,
encryptedFields: { table: CHARACTERS_TABLE, fields: [...CHARACTER_ENCRYPTED_FIELDS] },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
const res = await pullAll<RawCharacterRow>(syncCfg(ctx), STORIES_APP_ID, CHARACTERS_TABLE);
const alive = res.changes
.filter((c) => c.op !== 'delete' && c.data)
.map((c) => c.data as RawCharacterRow)
.filter((row) => !row.deletedAt && !row.isArchived)
.filter((row) => row.spaceId === ctx.spaceId);
const decrypted = (await Promise.all(
alive.map((row) =>
decryptRecordFields(
row as unknown as Record<string, unknown>,
CHARACTER_ENCRYPTED_FIELDS,
key
)
)
)) as unknown as RawCharacterRow[];
const filtered = decrypted
.filter((row): row is RawCharacterRow & { id: string; name: string; style: string } =>
Boolean(row.id && row.name && row.style)
)
.filter((row) => !input.style || row.style === input.style)
.filter((row) => !input.favoriteOnly || row.isFavorite === true)
.slice(0, input.limit);
const characters = filtered.map((row) => ({
id: row.id,
name: row.name,
description: row.description ?? null,
style: row.style as ComicStyleT,
addPrompt: row.addPrompt ?? null,
sourceFaceMediaId: row.sourceFaceMediaId ?? '',
sourceBodyMediaId: row.sourceBodyMediaId ?? null,
variantMediaIds: row.variantMediaIds ?? [],
pinnedVariantId: row.pinnedVariantId ?? null,
variantCount: (row.variantMediaIds ?? []).length,
tags: row.tags ?? [],
isFavorite: row.isFavorite === true,
}));
ctx.logger.info('comic.listCharacters', {
count: characters.length,
style: input.style ?? 'all',
favoriteOnly: input.favoriteOnly,
});
return { characters };
},
};
// ─── comic.createCharacter ────────────────────────────────────────
const createCharacterInput = z.object({
name: z.string().min(1).max(200),
style: comicStyle,
sourceFaceMediaId: z.string(),
sourceBodyMediaId: z.string().nullable().default(null),
addPrompt: z.string().max(500).nullable().default(null),
description: z.string().max(2000).nullable().default(null),
tags: z.array(z.string()).max(20).default([]),
});
const createCharacterOutput = z.object({
character: characterSchema,
});
export const comicCreateCharacter: ToolSpec<
typeof createCharacterInput,
typeof createCharacterOutput
> = {
name: 'comic.createCharacter',
module: 'comic',
scope: 'user-space',
policyHint: 'write',
description:
'Create a fresh comic-character row WITHOUT generating any variants yet. Splits creation from rendering so the user can review name/style/source pick before any credits are spent. Use `comic.generateVariant` afterwards (typically n=4) to populate the variant pool. The first generated variant auto-pins; user re-pins via `comic.pinVariant`. Source mediaIds must reference rows owned by the caller in apps `me` (face/body) or `wardrobe` (garment-derived).',
input: createCharacterInput,
output: createCharacterOutput,
encryptedFields: { table: CHARACTERS_TABLE, fields: [...CHARACTER_ENCRYPTED_FIELDS] },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
const id = crypto.randomUUID();
const plaintext: Record<string, unknown> = {
id,
name: input.name,
description: input.description,
style: input.style,
addPrompt: input.addPrompt,
sourceFaceMediaId: input.sourceFaceMediaId,
sourceBodyMediaId: input.sourceBodyMediaId,
variantMediaIds: [],
pinnedVariantId: null,
tags: input.tags,
isFavorite: false,
};
const encrypted = await encryptRecordFields(plaintext, CHARACTER_ENCRYPTED_FIELDS, key);
await pushInsert(syncCfg(ctx), STORIES_APP_ID, {
table: CHARACTERS_TABLE,
id,
spaceId: ctx.spaceId,
data: encrypted,
});
ctx.logger.info('comic.createCharacter', {
characterId: id,
style: input.style,
hasBodyRef: input.sourceBodyMediaId !== null,
});
return {
character: {
id,
name: input.name,
description: input.description,
style: input.style,
addPrompt: input.addPrompt,
sourceFaceMediaId: input.sourceFaceMediaId,
sourceBodyMediaId: input.sourceBodyMediaId,
variantMediaIds: [],
pinnedVariantId: null,
variantCount: 0,
tags: input.tags,
isFavorite: false,
},
};
},
};
// ─── comic.generateVariant ────────────────────────────────────────
const generateVariantInput = z.object({
characterId: z.string(),
count: z.number().int().min(1).max(4).default(4),
quality: z.enum(['low', 'medium', 'high']).default('medium'),
size: z.enum(['1024x1024', '1024x1536']).default('1024x1024'),
model: z
.enum([
'openai/gpt-image-2',
'google/gemini-3-pro-image-preview',
'google/gemini-3.1-flash-image-preview',
])
.default('openai/gpt-image-2'),
});
const generateVariantOutput = z.object({
characterId: z.string(),
newVariantMediaIds: z.array(z.string()),
imageUrls: z.array(z.string()),
pinnedVariantId: z.string().nullable(),
});
export const comicGenerateVariant: ToolSpec<
typeof generateVariantInput,
typeof generateVariantOutput
> = {
name: 'comic.generateVariant',
module: 'comic',
scope: 'user-space',
policyHint: 'write',
description:
"Render N stylised portrait variants for an existing comic-character and append them to its variant pool. Wraps `/picture/generate-with-reference` with the character's source-face/body refs + style-prefix + identity-anchor instructions. Consumes credits at the standard picture-generate tarif × count (medium quality default = 10 credits per variant). Auto-pins the first variant if no variant was pinned before this call. Use `comic.pinVariant` afterwards if the user picks a different one.",
input: generateVariantInput,
output: generateVariantOutput,
encryptedFields: { table: CHARACTERS_TABLE, fields: [...CHARACTER_ENCRYPTED_FIELDS] },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
// 1. Fetch + decrypt the target character.
const charsRes = await pullAll<RawCharacterRow>(syncCfg(ctx), STORIES_APP_ID, CHARACTERS_TABLE);
const raw = charsRes.changes
.filter((c) => c.op !== 'delete' && c.data)
.map((c) => c.data as RawCharacterRow)
.find(
(row) =>
row.id === input.characterId &&
!row.deletedAt &&
!row.isArchived &&
row.spaceId === ctx.spaceId
);
if (!raw) {
throw new Error(`Comic-Character ${input.characterId} not found in the active space`);
}
const character = (await decryptRecordFields(
raw as unknown as Record<string, unknown>,
CHARACTER_ENCRYPTED_FIELDS,
key
)) as unknown as RawCharacterRow;
const style = character.style as ComicStyleT | undefined;
if (!style || !(style in STYLE_PREFIXES)) {
throw new Error(`Character has invalid style "${character.style}"`);
}
if (!character.sourceFaceMediaId) {
throw new Error('Character has no sourceFaceMediaId — cannot render variants');
}
// 2. Compose prompt + call the picture endpoint with n=count.
const composed = composeCharacterPrompt(style, character.addPrompt);
const referenceMediaIds: string[] = [character.sourceFaceMediaId];
if (character.sourceBodyMediaId) {
referenceMediaIds.push(character.sourceBodyMediaId);
}
const res = await fetch(`${PICTURE_API_URL()}/api/v1/picture/generate-with-reference`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${ctx.jwt}`,
},
body: JSON.stringify({
prompt: composed,
referenceMediaIds,
model: input.model,
quality: input.quality,
size: input.size,
n: input.count,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '<unreadable body>');
throw new Error(
`picture.generate-with-reference failed: ${res.status} ${res.statusText}${text.slice(0, 500)}`
);
}
const data = (await res.json()) as {
images?: Array<{ imageUrl: string; mediaId?: string }>;
imageUrl?: string;
mediaId?: string;
prompt: string;
model: string;
};
const items =
data.images && data.images.length > 0
? data.images
: data.imageUrl
? [{ imageUrl: data.imageUrl, mediaId: data.mediaId }]
: [];
if (items.length === 0) {
throw new Error('picture endpoint returned no images');
}
// 3. Persist each variant as a picture.images row + append to
// the character's variant list. Field-level update on the
// character: only variantMediaIds + pinnedVariantId (if it
// flips from null to first new variant) + updatedAt.
const newVariantMediaIds: string[] = [];
const imageUrls: string[] = [];
const sizeDims = input.size === '1024x1536' ? { w: 1024, h: 1536 } : { w: 1024, h: 1024 };
const startIndex = (character.variantMediaIds ?? []).length;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item.imageUrl || !item.mediaId) continue;
const variantImageId = crypto.randomUUID();
const nowIso = new Date().toISOString();
await pushInsert(syncCfg(ctx), 'picture', {
table: 'images',
id: variantImageId,
spaceId: ctx.spaceId,
data: {
id: variantImageId,
prompt: data.prompt,
negativePrompt: null,
model: data.model,
publicUrl: item.imageUrl,
storagePath: item.mediaId,
filename: `comic-character-${input.characterId}-${startIndex + i + 1}.png`,
format: 'png',
width: sizeDims.w,
height: sizeDims.h,
visibility: 'private',
isFavorite: false,
downloadCount: 0,
generationMode: 'reference',
referenceImageIds: referenceMediaIds,
comicCharacterId: input.characterId,
createdAt: nowIso,
updatedAt: nowIso,
},
});
newVariantMediaIds.push(variantImageId);
imageUrls.push(item.imageUrl);
}
// 4. Update the character row: variantMediaIds appended, pin
// the first new variant if no pin existed yet (web-side
// appendVariant has the same auto-pin fallback).
const nextIds = [...(character.variantMediaIds ?? []), ...newVariantMediaIds];
const nextPinnedId = character.pinnedVariantId ?? newVariantMediaIds[0] ?? null;
const nowIso = new Date().toISOString();
await push(syncCfg(ctx), STORIES_APP_ID, [
{
table: CHARACTERS_TABLE,
id: input.characterId,
op: 'update',
spaceId: ctx.spaceId,
fields: {
variantMediaIds: { value: nextIds, updatedAt: nowIso },
pinnedVariantId: { value: nextPinnedId, updatedAt: nowIso },
updatedAt: { value: nowIso, updatedAt: nowIso },
},
},
]);
ctx.logger.info('comic.generateVariant', {
characterId: input.characterId,
rendered: newVariantMediaIds.length,
pinned: nextPinnedId === character.pinnedVariantId ? 'unchanged' : 'auto-pinned-first-new',
quality: input.quality,
model: input.model,
});
return {
characterId: input.characterId,
newVariantMediaIds,
imageUrls,
pinnedVariantId: nextPinnedId,
};
},
};
// ─── comic.pinVariant ─────────────────────────────────────────────
const pinVariantInput = z.object({
characterId: z.string(),
variantMediaId: z.string(),
});
const pinVariantOutput = z.object({
characterId: z.string(),
pinnedVariantId: z.string(),
});
export const comicPinVariant: ToolSpec<typeof pinVariantInput, typeof pinVariantOutput> = {
name: 'comic.pinVariant',
module: 'comic',
scope: 'user-space',
policyHint: 'write',
description:
"Pin a different variant as the comic-character's canonical look. Stories generated AFTER the re-pin snapshot the new variant; stories created BEFORE keep their old snapshot (fixes via story-level edit, not by character mutation). The chosen variant must already be in the character's variantMediaIds — call `comic.generateVariant` first if needed.",
input: pinVariantInput,
output: pinVariantOutput,
async handler(input, ctx) {
const charsRes = await pullAll<RawCharacterRow>(syncCfg(ctx), STORIES_APP_ID, CHARACTERS_TABLE);
const raw = charsRes.changes
.filter((c) => c.op !== 'delete' && c.data)
.map((c) => c.data as RawCharacterRow)
.find((row) => row.id === input.characterId && !row.deletedAt && row.spaceId === ctx.spaceId);
if (!raw) {
throw new Error(`Comic-Character ${input.characterId} not found in the active space`);
}
if (!(raw.variantMediaIds ?? []).includes(input.variantMediaId)) {
throw new Error(`Variant ${input.variantMediaId} is not in this character's variantMediaIds`);
}
const nowIso = new Date().toISOString();
await push(syncCfg(ctx), STORIES_APP_ID, [
{
table: CHARACTERS_TABLE,
id: input.characterId,
op: 'update',
spaceId: ctx.spaceId,
fields: {
pinnedVariantId: { value: input.variantMediaId, updatedAt: nowIso },
updatedAt: { value: nowIso, updatedAt: nowIso },
},
},
]);
ctx.logger.info('comic.pinVariant', {
characterId: input.characterId,
variantMediaId: input.variantMediaId,
});
return {
characterId: input.characterId,
pinnedVariantId: input.variantMediaId,
};
},
};
// ─── Registration barrel ──────────────────────────────────────────
export function registerComicTools(): void {
@ -575,4 +1064,8 @@ export function registerComicTools(): void {
registerTool(comicCreateStory);
registerTool(comicGeneratePanel);
registerTool(comicReorderPanels);
registerTool(comicListCharacters);
registerTool(comicCreateCharacter);
registerTool(comicGenerateVariant);
registerTool(comicPinVariant);
}