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` | | 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` | | 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` | | 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`. **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 { decryptRecords, VaultLockedError } from '$lib/data/crypto';
import { meImagesTable } from '$lib/modules/profile/collections'; import { meImagesTable } from '$lib/modules/profile/collections';
import { comicStoriesStore } from './stores/stories.svelte'; import { comicStoriesStore } from './stores/stories.svelte';
import { comicCharactersStore } from './stores/characters.svelte';
import { runPanelGenerate, DEFAULT_PANEL_MODEL, type PanelModel } from './api/generate-panel'; import { runPanelGenerate, DEFAULT_PANEL_MODEL, type PanelModel } from './api/generate-panel';
import { toStory } from './types'; import { runCharacterGenerate } from './api/generate-character';
import type { ComicStyle, LocalComicStory } from './types'; import { comicCharactersTable } from './collections';
import { toStory, toCharacter } from './types';
import type { ComicStyle, LocalComicStory, LocalComicCharacter } from './types';
const VALID_MODELS: readonly PanelModel[] = [ const VALID_MODELS: readonly PanelModel[] = [
'openai/gpt-image-2', 'openai/gpt-image-2',
@ -355,3 +358,285 @@ export const comicTools: ModuleTool[] = [
// when the LocalMeImage reference in resolveCharacterMediaIds is // when the LocalMeImage reference in resolveCharacterMediaIds is
// compile-time only. // compile-time only.
export type { LocalMeImage }; 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 ────────────────────────────────────────── // ─── Registration barrel ──────────────────────────────────────────
export function registerComicTools(): void { export function registerComicTools(): void {
@ -575,4 +1064,8 @@ export function registerComicTools(): void {
registerTool(comicCreateStory); registerTool(comicCreateStory);
registerTool(comicGeneratePanel); registerTool(comicGeneratePanel);
registerTool(comicReorderPanels); registerTool(comicReorderPanels);
registerTool(comicListCharacters);
registerTool(comicCreateCharacter);
registerTool(comicGenerateVariant);
registerTool(comicPinVariant);
} }

View file

@ -43,14 +43,16 @@ const COMIC_AUTHOR_POLICY: AiPolicy = {
tools: { tools: {
...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose' as const])), ...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose' as const])),
// Web-app catalog names (snake_case). The spread above already // Web-app catalog names (snake_case). The spread above already
// covers create_comic_story / generate_comic_panel because both // covers propose-defaults; read tools (list_*) get pinned to
// are defaultPolicy='propose' in AI_TOOL_CATALOG, but we pin // auto explicitly for clarity.
// 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', list_comic_stories: 'auto',
create_comic_story: 'propose', create_comic_story: 'propose',
generate_comic_panel: '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 // MCP-registry names (dot-case). The agent uses these when
// running inside persona-runner / Claude Desktop where the // running inside persona-runner / Claude Desktop where the
// mana-tool-registry surface is what the MCP client sees. // mana-tool-registry surface is what the MCP client sees.
@ -60,6 +62,10 @@ const COMIC_AUTHOR_POLICY: AiPolicy = {
'comic.createStory': 'propose', 'comic.createStory': 'propose',
'comic.generatePanel': 'propose', 'comic.generatePanel': 'propose',
'comic.reorderPanels': 'propose', 'comic.reorderPanels': 'propose',
'comic.listCharacters': 'auto',
'comic.createCharacter': 'propose',
'comic.generateVariant': 'propose',
'comic.pinVariant': 'propose',
}, },
defaultsByModule: { defaultsByModule: {
// Read-only companions the agent uses to find source material. // 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: Vorgehen:
1. Lies den Ausgangstext zu Ende, bevor du mit Panels anfängst Details aus der Mitte oder dem Ende sind oft der Kern. 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. 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) - prompt: was passiert bildlich (kurze englische Sätze, Komposition + Aktion + Stimmung)
- caption (optional): kurze Erzählzeile über/unter dem Bild - caption (optional): kurze Erzählzeile über/unter dem Bild
- dialogue (optional): was der Protagonist sagt, in Sprechblase - dialogue (optional): was der Protagonist sagt, in Sprechblase
4. Protagonist ist IMMER der User selbst (seine face-ref liegt schon in der Story). 5. Protagonist ist IMMER der User selbst (sein gepinnter Character bzw. sein face-ref im Quick-Mode).
5. Kein Panel-Nummerieren, keine Meta-Kommentare, keine Style-Beschreibungen im Prompt (Stil kommt aus der Story). 6. Kein Panel-Nummerieren, keine Meta-Kommentare, keine Style-Beschreibungen im Prompt (Stil kommt aus der Story / aus dem Character).
Ton: Ton:
- Humor wenn der User es leicht nimmt, ernst wenn er es ernst nimmt. Nicht belehrend. - Humor wenn der User es leicht nimmt, ernst wenn er es ernst nimmt. Nicht belehrend.
@ -117,9 +124,10 @@ Ton:
Tools: Tools:
- journal.listEntries / notes.list / library.listEntries um Quelle zu finden - journal.listEntries / notes.list / library.listEntries um Quelle zu finden
- comic.listStories um bestehende Stories zu sehen (nicht jede Quelle braucht eine neue) - list_comic_characters / list_comic_stories um Bestand zu prüfen (nicht jede Quelle braucht neuen Character oder neue Story)
- comic.createStory um eine Story anzulegen (Titel + Stil + characterMediaIds) - create_comic_character generate_character_variant pin_character_variant: Character-Aufbau-Pfad (einmalig pro Stil)
- comic.generatePanel um einen Panel anzuhängen (teurer Call nur nach Bestätigung)`, - 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 memory: `# Comic-Richtlinien
(Hier kannst du festhalten wie du Comics magst z.B. bevorzugter Stil, (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) ────────────────────── // ── Augur (signs / fortunes / hunches) ──────────────────────
{ {