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);

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

View file

@ -43,14 +43,16 @@ const COMIC_AUTHOR_POLICY: AiPolicy = {
tools: {
...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose' as const])),
// 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).
// covers propose-defaults; read tools (list_*) get pinned to
// auto explicitly for clarity.
list_comic_stories: 'auto',
create_comic_story: 'propose',
generate_comic_panel: 'propose',
// Character tools (Mc4) — same auto/propose split as story tools.
list_comic_characters: 'auto',
create_comic_character: 'propose',
generate_character_variant: 'propose',
pin_character_variant: '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.
@ -60,6 +62,10 @@ const COMIC_AUTHOR_POLICY: AiPolicy = {
'comic.createStory': 'propose',
'comic.generatePanel': 'propose',
'comic.reorderPanels': 'propose',
'comic.listCharacters': 'auto',
'comic.createCharacter': 'propose',
'comic.generateVariant': 'propose',
'comic.pinVariant': 'propose',
},
defaultsByModule: {
// Read-only companions the agent uses to find source material.
@ -103,12 +109,13 @@ Jeder Generate-Schritt ist ein Vorschlag — du bestimmst, wann Credits fließen
Vorgehen:
1. Lies den Ausgangstext zu Ende, bevor du mit Panels anfängst Details aus der Mitte oder dem Ende sind oft der Kern.
2. Wähle einen Stil, der zum Ton passt: 'comic' für Humor/Alltag, 'manga' für Drama, 'cartoon' für Kinder/Leichtigkeit, 'graphic-novel' für Reflexion/Melancholie, 'webtoon' für vertikale Long-Reads.
3. Schlage 4 Panels vor (26 je nach Textlänge). Jedes Panel hat:
3. Wenn der User noch keinen passenden Comic-Character hat: nutze list_comic_characters um zu prüfen, dann create_comic_character (legt Row an) generate_character_variant (rendert 4 Varianten) User wählt eine pin_character_variant. Das ist EINMALIG der gepinnte Character bleibt für viele Stories der stabile Identity-Anchor.
4. Schlage 4 Panels vor (26 je nach Textlänge). Jedes Panel hat:
- prompt: was passiert bildlich (kurze englische Sätze, Komposition + Aktion + Stimmung)
- caption (optional): kurze Erzählzeile über/unter dem Bild
- dialogue (optional): was der Protagonist sagt, in Sprechblase
4. Protagonist ist IMMER der User selbst (seine face-ref liegt schon in der Story).
5. Kein Panel-Nummerieren, keine Meta-Kommentare, keine Style-Beschreibungen im Prompt (Stil kommt aus der Story).
5. Protagonist ist IMMER der User selbst (sein gepinnter Character bzw. sein face-ref im Quick-Mode).
6. Kein Panel-Nummerieren, keine Meta-Kommentare, keine Style-Beschreibungen im Prompt (Stil kommt aus der Story / aus dem Character).
Ton:
- Humor wenn der User es leicht nimmt, ernst wenn er es ernst nimmt. Nicht belehrend.
@ -117,9 +124,10 @@ Ton:
Tools:
- journal.listEntries / notes.list / library.listEntries um Quelle zu finden
- comic.listStories um bestehende Stories zu sehen (nicht jede Quelle braucht eine neue)
- comic.createStory um eine Story anzulegen (Titel + Stil + characterMediaIds)
- comic.generatePanel um einen Panel anzuhängen (teurer Call nur nach Bestätigung)`,
- list_comic_characters / list_comic_stories um Bestand zu prüfen (nicht jede Quelle braucht neuen Character oder neue Story)
- create_comic_character generate_character_variant pin_character_variant: Character-Aufbau-Pfad (einmalig pro Stil)
- create_comic_story um eine Story anzulegen (mit existierendem Character als Anchor oder im Quick-Mode mit face-ref)
- generate_comic_panel um einen Panel anzuhängen (teurer Call nur nach Bestätigung)`,
memory: `# Comic-Richtlinien
(Hier kannst du festhalten wie du Comics magst z.B. bevorzugter Stil,

View file

@ -1987,6 +1987,114 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
},
],
},
{
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.',
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_character',
module: 'comic',
description:
'Legt einen neuen Comic-Character an OHNE direkt Varianten zu rendern (Splittet Anlegen von Generierung — User reviewt erst). Charakter-Refs werden automatisch aus dem primary face-ref + body-ref des aktiven Space aufgeloest. Stil ist fix nach Anlage. Gibt characterId zurueck — danach generate_character_variant aufrufen.',
defaultPolicy: 'propose',
parameters: [
{ name: 'name', type: 'string', description: 'Name des Characters', required: true },
{
name: 'style',
type: 'string',
description: 'Visueller Stil',
required: true,
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
},
{
name: 'addPrompt',
type: 'string',
description: 'Zusaetzlicher Prompt (z.B. "freundlicher Ausdruck", "casual outfit")',
required: false,
},
{
name: 'description',
type: 'string',
description: 'Kurze Charakter-Beschreibung',
required: false,
},
{ name: 'tags', type: 'string', description: 'Tags durch Komma getrennt', required: false },
],
},
{
name: 'generate_character_variant',
module: 'comic',
description:
'Rendert N (default 4) Variant-Portraits fuer einen existierenden Comic-Character und appended sie an den Variant-Pool. Konsumiert Credits × count (medium=10c). Auto-pinnt die erste Variante wenn noch keine gepinnt ist. Stil + Source-Refs kommen aus dem Character — nur count + quality + model sind hier waehlbar.',
defaultPolicy: 'propose',
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 — hoeher = mehr Credits',
required: false,
enum: ['low', 'medium', 'high'],
},
{
name: 'model',
type: 'string',
description: 'Rendering-Backend (default openai/gpt-image-2).',
required: false,
enum: [
'openai/gpt-image-2',
'google/gemini-3-pro-image-preview',
'google/gemini-3.1-flash-image-preview',
],
},
],
},
{
name: 'pin_character_variant',
module: 'comic',
description:
'Setzt einen anderen Variant als kanonischen Look des Comic-Characters. Stories die DANACH erstellt werden nutzen den neuen Pin; bestehende Stories bleiben unveraendert (sie haben den alten Variant zum Story-Create-Zeitpunkt fix gespeichert).',
defaultPolicy: 'propose',
parameters: [
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
{
name: 'variantMediaId',
type: 'string',
description: 'ID der Variante die zum neuen Pin werden soll (muss in variantMediaIds sein)',
required: true,
},
],
},
// ── Augur (signs / fortunes / hunches) ──────────────────────
{