mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
c5ff7e1d33
commit
ef96948ea0
5 changed files with 908 additions and 14 deletions
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (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)
|
- 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,
|
||||||
|
|
|
||||||
|
|
@ -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) ──────────────────────
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue