From ef96948ea0f15ced84f3fdde6aac66d84188e887 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 26 Apr 2026 19:27:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(comic):=20Mc4=20=E2=80=94=20MCP=20+=20AI-C?= =?UTF-8?q?atalog=20f=C3=BCr=20Character-System?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/mana/CLAUDE.md | 2 +- .../apps/web/src/lib/modules/comic/tools.ts | 289 +++++++++- .../mana-tool-registry/src/modules/comic.ts | 493 ++++++++++++++++++ .../src/agents/templates/comic-author.ts | 30 +- packages/shared-ai/src/tools/schemas.ts | 108 ++++ 5 files changed, 908 insertions(+), 14 deletions(-) diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 94beabd30..835caa1bd 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -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`. diff --git a/apps/mana/apps/web/src/lib/modules/comic/tools.ts b/apps/mana/apps/web/src/lib/modules/comic/tools.ts index 4db0dc2d7..82392ec50 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/tools.ts @@ -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( + '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); diff --git a/packages/mana-tool-registry/src/modules/comic.ts b/packages/mana-tool-registry/src/modules/comic.ts index f4820ffbc..737e5c212 100644 --- a/packages/mana-tool-registry/src/modules/comic.ts +++ b/packages/mana-tool-registry/src/modules/comic.ts @@ -568,6 +568,495 @@ export const comicReorderPanels: ToolSpec = { + 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(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, + 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 = { + 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(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, + 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(() => ''); + 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 = { + 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(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); } diff --git a/packages/shared-ai/src/agents/templates/comic-author.ts b/packages/shared-ai/src/agents/templates/comic-author.ts index 46f855a68..336592a8d 100644 --- a/packages/shared-ai/src/agents/templates/comic-author.ts +++ b/packages/shared-ai/src/agents/templates/comic-author.ts @@ -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 (2–6 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 (2–6 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, diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 2338218df..2d02a999e 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -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) ────────────────────── {