mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 23:01:25 +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` |
|
||||
| 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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,12 @@ import { scopedForModule } from '$lib/data/scope';
|
|||
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
|
||||
import { meImagesTable } from '$lib/modules/profile/collections';
|
||||
import { comicStoriesStore } from './stores/stories.svelte';
|
||||
import { comicCharactersStore } from './stores/characters.svelte';
|
||||
import { runPanelGenerate, DEFAULT_PANEL_MODEL, type PanelModel } from './api/generate-panel';
|
||||
import { toStory } from './types';
|
||||
import type { ComicStyle, LocalComicStory } from './types';
|
||||
import { runCharacterGenerate } from './api/generate-character';
|
||||
import { comicCharactersTable } from './collections';
|
||||
import { toStory, toCharacter } from './types';
|
||||
import type { ComicStyle, LocalComicStory, LocalComicCharacter } from './types';
|
||||
|
||||
const VALID_MODELS: readonly PanelModel[] = [
|
||||
'openai/gpt-image-2',
|
||||
|
|
@ -355,3 +358,285 @@ export const comicTools: ModuleTool[] = [
|
|||
// when the LocalMeImage reference in resolveCharacterMediaIds is
|
||||
// compile-time only.
|
||||
export type { LocalMeImage };
|
||||
|
||||
// ─── Character tools (Mc4) ────────────────────────────────────────
|
||||
|
||||
export const comicCharacterTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'list_comic_characters',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Listet Comic-Characters im aktiven Space (id, name, style, variantCount, pinnedVariantId, isFavorite). Optional nach Stil oder Favoriten filterbar.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Nur einen Stil zeigen',
|
||||
required: false,
|
||||
enum: [...VALID_STYLES],
|
||||
},
|
||||
{
|
||||
name: 'favoriteOnly',
|
||||
type: 'boolean',
|
||||
description: 'Nur Favoriten',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const styleFilter = params.style as ComicStyle | undefined;
|
||||
const favoriteOnly = params.favoriteOnly === true;
|
||||
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
|
||||
|
||||
try {
|
||||
const locals = await scopedForModule<LocalComicCharacter, string>(
|
||||
'comic',
|
||||
'comicCharacters'
|
||||
).toArray();
|
||||
const visible = locals.filter((c) => !c.deletedAt && !c.isArchived);
|
||||
const decrypted = await decryptRecords('comicCharacters', visible);
|
||||
const rows = decrypted
|
||||
.map(toCharacter)
|
||||
.filter((c) => (styleFilter ? c.style === styleFilter : true))
|
||||
.filter((c) => (favoriteOnly ? c.isFavorite === true : true))
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, limit)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
style: c.style,
|
||||
variantCount: c.variantMediaIds.length,
|
||||
pinnedVariantId: c.pinnedVariantId ?? null,
|
||||
isFavorite: c.isFavorite === true,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { characters: rows, total: rows.length },
|
||||
message: `${rows.length} Character${rows.length === 1 ? '' : 's'} gelistet`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Vault ist gesperrt — Comic-Characters können nicht entschlüsselt werden',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'create_comic_character',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Legt einen neuen Comic-Character an OHNE direkt Varianten zu rendern. Charakter-Refs werden automatisch aus dem primary face-ref + body-ref des aktiven Space aufgeloest. Stil ist fix nach Anlage.',
|
||||
parameters: [
|
||||
{ name: 'name', type: 'string', description: 'Name des Characters', required: true },
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Visueller Stil',
|
||||
required: true,
|
||||
enum: [...VALID_STYLES],
|
||||
},
|
||||
{
|
||||
name: 'addPrompt',
|
||||
type: 'string',
|
||||
description: 'Zusaetzlicher Prompt',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
description: 'Kurze Charakter-Beschreibung',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'tags', type: 'string', description: 'Tags durch Komma getrennt', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const name = String(params.name ?? '').trim();
|
||||
if (!name) return { success: false, message: 'name erforderlich' };
|
||||
|
||||
const style = params.style;
|
||||
if (!isValidStyle(style)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `style muss einer von ${VALID_STYLES.join(', ')} sein`,
|
||||
};
|
||||
}
|
||||
|
||||
const refs = await resolveCharacterMediaIds();
|
||||
if (!refs.faceRef) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Kein Gesichtsbild im aktiven Space. Lade eines in /profile/me-images hoch, dann erneut versuchen.',
|
||||
};
|
||||
}
|
||||
|
||||
const description =
|
||||
typeof params.description === 'string' && params.description.trim()
|
||||
? params.description.trim()
|
||||
: null;
|
||||
const addPrompt =
|
||||
typeof params.addPrompt === 'string' && params.addPrompt.trim()
|
||||
? params.addPrompt.trim()
|
||||
: null;
|
||||
const tags =
|
||||
typeof params.tags === 'string' && params.tags.trim()
|
||||
? params.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
: [];
|
||||
|
||||
try {
|
||||
const character = await comicCharactersStore.createCharacter({
|
||||
name,
|
||||
style,
|
||||
sourceFaceMediaId: refs.faceRef,
|
||||
sourceBodyMediaId: refs.bodyRef,
|
||||
addPrompt,
|
||||
description,
|
||||
tags,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
style: character.style,
|
||||
hasBodyRef: refs.bodyRef !== null,
|
||||
note: 'Character-Row angelegt — jetzt generate_character_variant aufrufen um Varianten zu rendern.',
|
||||
},
|
||||
message: `Character "${character.name}" angelegt (Stil: ${character.style})`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt — Character nicht gespeichert' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'generate_character_variant',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Rendert N (default 4) Variant-Portraits fuer einen existierenden Comic-Character via gpt-image-2. Konsumiert Credits × count. Auto-pinnt die erste Variante wenn noch keine gepinnt ist.',
|
||||
parameters: [
|
||||
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
|
||||
{
|
||||
name: 'count',
|
||||
type: 'number',
|
||||
description: 'Anzahl Varianten (1-4, default 4)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
type: 'string',
|
||||
description: 'Render-Qualitaet',
|
||||
required: false,
|
||||
enum: ['low', 'medium', 'high'],
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
description: 'Rendering-Backend',
|
||||
required: false,
|
||||
enum: [...VALID_MODELS],
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const characterId = String(params.characterId ?? '').trim();
|
||||
if (!characterId) return { success: false, message: 'characterId erforderlich' };
|
||||
|
||||
const count = Math.max(1, Math.min(4, Number(params.count) || 4));
|
||||
const quality =
|
||||
params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium';
|
||||
const model: PanelModel = isValidModel(params.model) ? params.model : DEFAULT_PANEL_MODEL;
|
||||
|
||||
try {
|
||||
const local = await comicCharactersTable.get(characterId);
|
||||
if (!local || local.deletedAt) {
|
||||
return { success: false, message: `Character ${characterId} nicht gefunden` };
|
||||
}
|
||||
const [decrypted] = await decryptRecords('comicCharacters', [local]);
|
||||
if (!decrypted) {
|
||||
return { success: false, message: 'Entschlüsselung des Characters fehlgeschlagen' };
|
||||
}
|
||||
const character = toCharacter(decrypted);
|
||||
|
||||
const result = await runCharacterGenerate({
|
||||
character,
|
||||
count,
|
||||
quality: quality as 'low' | 'medium' | 'high',
|
||||
model,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
characterId: character.id,
|
||||
newVariantMediaIds: result.variantMediaIds,
|
||||
imageUrls: result.imageUrls,
|
||||
model: result.model,
|
||||
},
|
||||
message: `${result.variantMediaIds.length} Variant${result.variantMediaIds.length === 1 ? '' : 's'} für "${character.name}" generiert`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt' };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Variant-Generierung fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'pin_character_variant',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Setzt einen anderen Variant als kanonischen Look. Stories danach erstellt nutzen den neuen Pin — bestehende Stories bleiben unveraendert (Snapshot-Pattern).',
|
||||
parameters: [
|
||||
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
|
||||
{
|
||||
name: 'variantMediaId',
|
||||
type: 'string',
|
||||
description: 'ID des neuen Pin-Variants',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const characterId = String(params.characterId ?? '').trim();
|
||||
const variantMediaId = String(params.variantMediaId ?? '').trim();
|
||||
if (!characterId || !variantMediaId) {
|
||||
return { success: false, message: 'characterId und variantMediaId erforderlich' };
|
||||
}
|
||||
|
||||
try {
|
||||
await comicCharactersStore.pinVariant(characterId, variantMediaId);
|
||||
return {
|
||||
success: true,
|
||||
data: { characterId, pinnedVariantId: variantMediaId },
|
||||
message: `Variant gepinned`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Pin fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Append character tools to the main export so init.ts picks them
|
||||
// up via the existing registerTools(comicTools) call.
|
||||
comicTools.push(...comicCharacterTools);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue