diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5ad6a3a44..2881f3fcc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -39,7 +39,6 @@ import { articlesRoutes } from './modules/articles/routes'; import { startArticleImportWorker } from './modules/articles/import-worker'; import { tracesRoutes } from './modules/traces/routes'; import { writingRoutes } from './modules/writing/routes'; -import { comicRoutes } from './modules/comic/routes'; import { presiRoutes } from './modules/presi/routes'; import { researchRoutes } from './modules/research/routes'; import { websiteRoutes } from './modules/website/routes'; @@ -137,7 +136,6 @@ app.route('/api/v1/research', researchRoutes); app.route('/api/v1/website', websiteRoutes); app.route('/api/v1/unlisted', unlistedRoutes); app.route('/api/v1/writing', writingRoutes); -app.route('/api/v1/comic', comicRoutes); app.route('/api/v1/personas/admin', personasAdminRoutes); // ─── Background Workers ───────────────────────────────────── diff --git a/apps/api/src/lib/media.ts b/apps/api/src/lib/media.ts index fa68f0701..2e553fe04 100644 --- a/apps/api/src/lib/media.ts +++ b/apps/api/src/lib/media.ts @@ -91,10 +91,9 @@ export async function getMediaBufferAsPng( * doesn't land in the owned set — the caller turns that into an HTTP * response. * - * Accepts a single app string or an array. Comic character-ref flows - * pass `['me', 'comic']` in one call when both face/body portraits and - * comic-specific anchors are legitimate inputs for the same - * `/v1/images/edits` POST. + * Accepts a single app string or an array. `['me']` covers the + * portrait flow; future apps may extend the list with their own + * upload tags. * * One `list()` round-trip per app. For N apps this is N calls, each * capped at 500 rows — far beyond the product's intended per-app shape diff --git a/apps/api/src/modules/comic/routes.ts b/apps/api/src/modules/comic/routes.ts deleted file mode 100644 index 0998b97c3..000000000 --- a/apps/api/src/modules/comic/routes.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Comic module — server endpoints. - * - * Current scope (M4): - * - POST /storyboard — one-shot panel-sequence suggestion from a text - * input (journal entry, note, library review, writing draft, - * calendar event description). The client decrypts the source - * locally, sends the plaintext + style, and we round-trip to - * mana-llm with a JSON-schema system prompt, returning - * `{ panels: Array<{ prompt, caption?, dialogue? }> }`. Panel - * rendering itself still happens through /picture/generate-with- - * reference — this endpoint is pure text → plan. - * - * Future (M5+): - * - Upload endpoint for comic-specific anchor / backdrop images if - * M6 character-cast scope happens; the 'comic' upload slot is - * already allowed by verifyMediaOwnership (set in M1). - * - * Why not reuse /api/v1/writing/generations? - * That endpoint is a free-text prose endpoint (no JSON parsing) and - * is wired for one-shot writing drafts. Comic storyboarding wants a - * structured Panel[] envelope the client can iterate over cheaply — - * different prompt shape, different parser, different observability - * tag. Keeping them apart avoids prompt-contamination between the - * two use-cases and keeps each module's logs grep-able. - */ - -import { Hono } from 'hono'; -import { llmJson, LlmError } from '../../lib/llm'; -import { MANA_LLM } from '@mana/shared-ai'; -import { logger, type AuthVariables } from '@mana/shared-hono'; - -const STORYBOARD_MODEL = MANA_LLM.STRUCTURED; - -type ComicStyle = 'comic' | 'manga' | 'cartoon' | 'graphic-novel' | 'webtoon'; - -const STYLE_HINTS: Record = { - comic: 'US comic book, bold linework, cell-shading, dramatic framing', - manga: 'Japanese manga, black-and-white with screen tones, dynamic perspective', - cartoon: 'soft pastel cartoon, rounded shapes, Saturday-morning animation', - 'graphic-novel': 'graphic novel, painterly watercolor, muted atmospheric palette', - webtoon: 'webtoon, vertical framing, bright saturated colors, soft cel-shading', -}; - -const VALID_STYLES = Object.keys(STYLE_HINTS) as readonly ComicStyle[]; -const MAX_SOURCE_TEXT_CHARS = 8_000; -const MIN_PANEL_COUNT = 2; -const MAX_PANEL_COUNT = 8; - -interface StoryboardRequest { - style: ComicStyle; - sourceText: string; - /** Optional — if omitted we ask for 4 panels (plan default). */ - panelCount?: number; - /** Optional story-level briefing the author wrote at create-time. - * Gets prepended to the source-text so Claude knows the tonal - * register ("make it funny" / "stay serious"). */ - storyContext?: string | null; - /** Where this text came from — logged only, not sent to the LLM. - * Useful for observability ("which module drives most storyboards"). */ - sourceModule?: string; -} - -interface StoryboardPanel { - prompt: string; - caption?: string; - dialogue?: string; -} - -interface StoryboardResponse { - panels: StoryboardPanel[]; - model: string; - durationMs: number; -} - -function isValidStyle(v: unknown): v is ComicStyle { - return typeof v === 'string' && (VALID_STYLES as readonly string[]).includes(v); -} - -function buildSystemPrompt(style: ComicStyle): string { - const hint = STYLE_HINTS[style]; - return [ - `You are a comic-story editor. Given a short piece of text (journal entry, note, review, or event description), break it into a sequence of visual comic panels.`, - `Style: ${hint}.`, - `Return ONLY a JSON object with this exact shape:`, - `{"panels": [{"prompt": string, "caption"?: string, "dialogue"?: string}, ...]}`, - `Rules:`, - `- "prompt" is the visual scene description (what the artist draws). One or two short English sentences. Focus on composition, action, mood, setting. Do NOT describe style — the style prefix is added downstream.`, - `- "caption" (optional) is a short narration line rendered at the top or bottom of the panel, max 80 chars. Use sparingly — only when scene-setting or transitions need it.`, - `- "dialogue" (optional) is what the protagonist says inside a speech bubble, max 80 chars. Use when the scene has a spoken moment.`, - `- Do not number panels. Do not add meta commentary. Do not explain your choices.`, - `- The protagonist of every panel is the same person (the story's author).`, - ].join('\n'); -} - -function buildUserPrompt( - sourceText: string, - panelCount: number, - storyContext: string | null | undefined -): string { - const trimmed = sourceText.trim().slice(0, MAX_SOURCE_TEXT_CHARS); - const contextBlock = storyContext?.trim() - ? `Story briefing from the author:\n${storyContext.trim()}\n\n---\n\n` - : ''; - return [ - contextBlock, - `Source text:\n${trimmed}\n\n---\n\n`, - `Generate exactly ${panelCount} panels that tell this as a comic. Output the JSON object described in the system message.`, - ].join(''); -} - -const routes = new Hono<{ Variables: AuthVariables }>(); - -routes.post('/storyboard', async (c) => { - const userId = c.get('userId'); - const body = (await c.req.json()) as Partial; - - if (!isValidStyle(body.style)) { - return c.json({ error: `Invalid style, expected one of: ${VALID_STYLES.join(', ')}` }, 400); - } - if (!body.sourceText || typeof body.sourceText !== 'string') { - return c.json({ error: 'sourceText required' }, 400); - } - if (body.sourceText.trim().length === 0) { - return c.json({ error: 'sourceText must not be blank' }, 400); - } - - const panelCount = Math.max( - MIN_PANEL_COUNT, - Math.min(MAX_PANEL_COUNT, Number(body.panelCount) || 4) - ); - - const startedAt = Date.now(); - try { - const parsed = await llmJson<{ panels?: unknown }>({ - model: STORYBOARD_MODEL, - system: buildSystemPrompt(body.style), - user: buildUserPrompt(body.sourceText, panelCount, body.storyContext), - temperature: 0.7, - maxTokens: 2000, - }); - - const rawPanels = Array.isArray(parsed?.panels) ? parsed.panels : []; - // Defense-in-depth: coerce + strip unknown shapes, clamp to - // requested count. If the model returns more panels than asked - // for we keep the first N; less is fine (fewer credits later). - const panels: StoryboardPanel[] = rawPanels - .map((raw): StoryboardPanel | null => { - if (!raw || typeof raw !== 'object') return null; - const entry = raw as Record; - const prompt = typeof entry.prompt === 'string' ? entry.prompt.trim() : ''; - if (!prompt) return null; - const caption = - typeof entry.caption === 'string' && entry.caption.trim().length > 0 - ? entry.caption.trim().slice(0, 200) - : undefined; - const dialogue = - typeof entry.dialogue === 'string' && entry.dialogue.trim().length > 0 - ? entry.dialogue.trim().slice(0, 200) - : undefined; - return { prompt: prompt.slice(0, 800), caption, dialogue }; - }) - .filter((p): p is StoryboardPanel => p !== null) - .slice(0, panelCount); - - const durationMs = Date.now() - startedAt; - - if (panels.length === 0) { - logger.warn('comic.storyboard_empty', { - userId, - style: body.style, - sourceModule: body.sourceModule, - model: STORYBOARD_MODEL, - durationMs, - }); - return c.json( - { - error: 'Model returned no usable panels', - detail: 'Try again, shorten the input, or pick a different style', - durationMs, - }, - 502 - ); - } - - logger.info('comic.storyboard_ok', { - userId, - style: body.style, - sourceModule: body.sourceModule, - panelCount: panels.length, - model: STORYBOARD_MODEL, - durationMs, - }); - - const response: StoryboardResponse = { - panels, - model: STORYBOARD_MODEL, - durationMs, - }; - return c.json(response); - } catch (err) { - const durationMs = Date.now() - startedAt; - const message = err instanceof Error ? err.message : String(err); - logger.error('comic.storyboard_failed', { - userId, - style: body.style, - sourceModule: body.sourceModule, - model: STORYBOARD_MODEL, - error: message, - status: err instanceof LlmError ? err.status : undefined, - durationMs, - }); - return c.json({ error: 'Storyboard generation failed', detail: message, durationMs }, 500); - } -}); - -export { routes as comicRoutes }; diff --git a/apps/api/src/modules/picture/routes.ts b/apps/api/src/modules/picture/routes.ts index 5ca66ddd5..852da58c0 100644 --- a/apps/api/src/modules/picture/routes.ts +++ b/apps/api/src/modules/picture/routes.ts @@ -315,14 +315,12 @@ routes.post('/generate-with-reference', async (c) => { } // Ownership check before we spend credits or burn OpenAI quota. - // References span two upload tags today: - // - `me` — face/body portraits from the profile module - // - `comic` — comic-specific anchor / backdrop uploads - // Anything outside these apps is treated as not-owned regardless of - // mana-media's own view. + // Currently only `me` (face/body portraits from the profile module) + // is a valid upload tag — anything else is treated as not-owned + // regardless of mana-media's own view. try { const { verifyMediaOwnership } = await import('../../lib/media'); - await verifyMediaOwnership(userId, refIds, ['me', 'comic']); + await verifyMediaOwnership(userId, refIds, ['me']); } catch (err) { const e = err as Error & { status?: number; missing?: string[] }; if (e.status === 404) { diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index e1c5f601d..10690f0ea 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -72,7 +72,6 @@ import { Exam, Globe, NotePencil, - FilmStrip, Hourglass, HeartHalf, Eye, @@ -96,7 +95,7 @@ import { // mood · sleep · activity · times · finance // Knowledge: chat · kontext · cards · quiz · guides · // news-research · research-lab · articles · -// library · writing · comic · presi +// library · writing · presi // Body & life: body · meditate · stretch · period · // dreams · firsts · lasts · habits · recipes // Places & ev.: places · events @@ -1300,30 +1299,6 @@ registerApp({ }), }); -registerApp({ - id: 'comic', - name: 'Comic', - color: '#f97316', - icon: FilmStrip, - views: { - // /comic/new (StoryForm) and /comic/[id] (DetailView) live as - // SvelteKit routes; the workbench card hosts the ListView root. - // Quick-action "Neue Story" navigates to /comic/new directly — - // the create flow has its own page, no inline modal in the card. - list: { load: () => import('$lib/modules/comic/ListView.svelte') }, - }, - contextMenuActions: [ - { - id: 'new-story', - label: 'Neue Story', - icon: Plus, - action: () => { - window.location.href = '/comic/new'; - }, - }, - ], -}); - registerApp({ id: 'spaces', name: 'Spaces', diff --git a/apps/mana/apps/web/src/lib/app-registry/help-content.ts b/apps/mana/apps/web/src/lib/app-registry/help-content.ts index 200bdfd99..8f90b895f 100644 --- a/apps/mana/apps/web/src/lib/app-registry/help-content.ts +++ b/apps/mana/apps/web/src/lib/app-registry/help-content.ts @@ -881,24 +881,6 @@ export const MODULE_HELP: Record = { ], tips: ['System-Auto folgt deinem OS-Dark-Mode automatisch zur richtigen Uhrzeit'], }, - comic: { - description: - 'Aus Text wird ein Comic — Tagebuch-Eintrag, Notiz oder Library-Review als Vorlage, gpt-image-2 oder Nano Banana rendert Panels in fünf Stilen (Comic, Manga, Cartoon, Graphic Novel, Webtoon). Du selbst bist der Protagonist — Face-Ref aus deinem Profil-Modul wird automatisch genutzt.', - features: [ - 'Drei Generate-Modi: Einzel-Panel, Batch (2-4 parallel), KI-Storyboard aus existierendem Text', - 'Fünf Stil-Presets pro Story fix gewählt — alle Panels nutzen denselben Prefix für Konsistenz', - 'Sprechblasen + Captions werden direkt ins Bild gerendert (kein SVG-Overlay)', - 'Modell wählbar pro Klick: OpenAI gpt-image-2, Nano Banana Pro, Nano Banana 2', - 'Cross-Modul-Storyboard: Claude liest Journal/Notes/Library und schlägt 4-6 Panels vor', - 'MCP-Tools: listStories / createStory / generatePanel / reorderPanels für Agents', - ], - tips: [ - 'Ohne Face-Ref im aktiven Space kein Comic — Banner führt direkt zum Upload.', - 'Englische Captions/Dialoge rendern stabiler als deutsche; kurze Sätze funktionieren am besten.', - 'Style-Wechsel ist nicht möglich nach Story-Create — dafür einfach neue Story anlegen.', - 'Ab ~8 Panels pro Story wird Character-Konsistenz spürbar schwerer (gpt-image-2-Limit).', - ], - }, 'research-lab': { description: 'Web-Research-Anbieter Seite-an-Seite vergleichen: gleiche Query an bis zu fünf Provider parallel, Antworten + Latenz + Kosten nebeneinander. Alle Runs werden serverseitig persistiert für spätere Auswertung.', diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 7f59bd6cb..ec7a04b5b 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -95,7 +95,6 @@ import type { LocalGeneration, LocalWritingStyle, } from '../../modules/writing/types'; -import type { LocalComicStory, LocalComicCharacter } from '../../modules/comic/types'; import type { LocalAugurEntry } from '../../modules/augur/types'; import type { LocalForm, LocalFormResponse } from '../../modules/forms/types'; @@ -544,47 +543,6 @@ export const ENCRYPTION_REGISTRY: Record = { // lives in MinIO behind owner-RLS, not in Dexie. meImages: entry(['label', 'tags']), - // ─── Comic (stories + inline panel metadata) ───────────── - // docs/plans/comic-module.md M1. Single space-scoped table. - // - // `title`, `description`, `storyContext`, `tags` are user-typed - // prose and get the same treatment as journal.title / notes.content. - // `panelMeta` is the per-panel sidecar (Record) — aes.ts JSON- - // stringifies the whole blob before wrap, same pattern as - // food.foods / recipes.ingredients / quiz.options. Caption + - // dialogue are prose fragments the user authored; promptUsed is - // the reproduce-key (would-be-convenient for regeneration but - // leaks story content if plaintext); sourceInput FKs are - // low-risk but ship inside the encrypted blob anyway because - // splitting the Record per-field would double the storage cost. - // - // Plaintext (intentional): id, style enum (drives listStories - // filter + per-style prompt-prefix lookup), characterMediaIds - // (FKs to meImages / wardrobeGarments), panelImageIds (ordered - // FKs to picture.images), isFavorite / isArchived / visibility - // fields — all needed by the index or query layer. - comicStories: entry([ - 'title', - 'description', - 'storyContext', - 'tags', - 'panelMeta', - ]), - - // ─── Comic-Characters (variant pool + pinned identity) ──── - // docs/plans/comic-module.md §11. User-scoped sibling table to - // comicStories. Encrypted: `name` (display label), `description` - // (optional context), `addPrompt` (the user's free-text prompt - // add-on like "freundlicher Ausdruck"), `tags`. Plaintext: - // `style` (filter discriminator), `sourceFaceMediaId` / - // `sourceBodyMediaId` (FKs to meImages), `variantMediaIds` (FK - // array to picture.images), `pinnedVariantId`, booleans. - // Same encryption envelope as a wardrobe-outfit — name + free- - // text + tags travel encrypted, structural fields stay plaintext - // for query/sort. - comicCharacters: entry(['name', 'description', 'addPrompt', 'tags']), - // ─── Augur (signs: omens / fortunes / hunches) ─────────── // docs/plans/augur-module.md M1. Single space-scoped table. // diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 3fc8d445d..045a0624c 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1591,6 +1591,29 @@ db.version(65).stores({ newsCachedFeed: null, }); +// v66 — Comic module retirement (2026-05-18). +// Comic-Surface ist nach Comicello (comicello.mana.how / comicello.com) +// umgezogen, das mit eigener Postgres-DB läuft. Tabellen werden hier +// komplett gedroppt; Picture-Image back-ref-Properties (comicStoryId / +// comicPanelIndex / comicCharacterId) waren nie indiziert und werden +// per .upgrade() aus alten Image-Rows gestrippt, damit keine orphane +// FKs auf nicht-mehr-existierende Comic-Records zurückbleiben. +db.version(66) + .stores({ + comicStories: null, + comicCharacters: null, + }) + .upgrade(async (tx) => { + await tx + .table('images') + .toCollection() + .modify((image) => { + if ('comicStoryId' in image) delete image.comicStoryId; + if ('comicPanelIndex' in image) delete image.comicPanelIndex; + if ('comicCharacterId' in image) delete image.comicCharacterId; + }); + }); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.test.ts b/apps/mana/apps/web/src/lib/data/module-registry.test.ts index f6bff8a96..0204fa380 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.test.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.test.ts @@ -270,7 +270,6 @@ describe('module-registry — snapshot', () => { wetter: ['wetterLocations', 'wetterSettings'], website: ['websites', 'websitePages', 'websiteBlocks'], writing: ['writingDrafts', 'writingDraftVersions', 'writingGenerations', 'writingStyles'], - comic: ['comicStories', 'comicCharacters'], augur: ['augurEntries'], forms: ['forms', 'formResponses'], ai: ['aiMissions', 'agents', 'agentKontextDocs'], diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 6413db838..ac073f03f 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -96,7 +96,6 @@ import { broadcastModuleConfig } from '$lib/modules/broadcasts/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; import { websiteModuleConfig } from '$lib/modules/website/module.config'; import { writingModuleConfig } from '$lib/modules/writing/module.config'; -import { comicModuleConfig } from '$lib/modules/comic/module.config'; import { augurModuleConfig } from '$lib/modules/augur/module.config'; import { formsModuleConfig } from '$lib/modules/forms/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; @@ -151,7 +150,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ wetterModuleConfig, websiteModuleConfig, writingModuleConfig, - comicModuleConfig, augurModuleConfig, formsModuleConfig, aiModuleConfig, diff --git a/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts b/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts index b4b9dcada..390dd4a7d 100644 --- a/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts +++ b/apps/mana/apps/web/src/lib/data/privacy/exposed-records.ts @@ -139,18 +139,6 @@ const TABLES: TableConfig[] = [ return recipesStore.setVisibility(id, next); }, }, - { - module: 'comic', - collection: 'comicStories', - moduleLabel: 'Comics', - encrypted: true, - title: (r) => asString(r.title), - href: () => '/comic', - setVisibility: async (id, next) => { - const { comicStoriesStore } = await import('$lib/modules/comic/stores/stories.svelte'); - return comicStoriesStore.setVisibility(id, next); - }, - }, { module: 'habits', collection: 'habits', diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index 347f5c86a..5d6c4b244 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -43,7 +43,6 @@ import { libraryTools } from '$lib/modules/library/tools'; import { broadcastTools } from '$lib/modules/broadcasts/tools'; import { websiteTools } from '$lib/modules/website/tools'; import { writingTools } from '$lib/modules/writing/tools'; -import { comicTools } from '$lib/modules/comic/tools'; import { augurTools } from '$lib/modules/augur/tools'; import { formsTools } from '$lib/modules/forms/tools'; @@ -90,7 +89,6 @@ export function initTools(): void { registerTools(broadcastTools); registerTools(websiteTools); registerTools(writingTools); - registerTools(comicTools); registerTools(augurTools); registerTools(formsTools); initialized = true; diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json index 01fb8328e..922723f15 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/de.json @@ -67,6 +67,5 @@ "spaces": "Bereiche", "website": "Website", "quiz": "Quiz", - "guides": "Anleitungen", - "comic": "Comic" + "guides": "Anleitungen" } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json index feffff50d..8e3dfe909 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/en.json @@ -67,6 +67,5 @@ "spaces": "Spaces", "website": "Website", "quiz": "Quiz", - "guides": "Guides", - "comic": "Comic" + "guides": "Guides" } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/es.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/es.json index 5517c2cda..6090b4419 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/apps/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/es.json @@ -67,6 +67,5 @@ "spaces": "Espacios", "website": "Sitio web", "quiz": "Quiz", - "guides": "Guías", - "comic": "Cómic" + "guides": "Guías" } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/fr.json index c422b6f5f..ea75a3d1c 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/apps/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/fr.json @@ -67,6 +67,5 @@ "spaces": "Espaces", "website": "Site web", "quiz": "Quiz", - "guides": "Guides", - "comic": "Bande dessinée" + "guides": "Guides" } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/apps/it.json b/apps/mana/apps/web/src/lib/i18n/locales/apps/it.json index d2e6d3a21..a6f4e941f 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/apps/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/apps/it.json @@ -67,6 +67,5 @@ "spaces": "Spazi", "website": "Sito web", "quiz": "Quiz", - "guides": "Guide", - "comic": "Fumetto" + "guides": "Guide" } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/comic/de.json b/apps/mana/apps/web/src/lib/i18n/locales/comic/de.json deleted file mode 100644 index 950350448..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/comic/de.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "detail": { - "back_aria": "Zurück zu Comics", - "breadcrumb": "Comics", - "loading": "Lädt…", - "not_found": "Story nicht gefunden.", - "not_found_hint": "Gelöscht oder in einem anderen Space.", - "panel_one": "{n} Panel", - "panel_other": "{n} Panels", - "reference_one": "{n} Referenz", - "reference_other": "{n} Referenzen", - "favorite_remove": "Favorit entfernen", - "favorite_set": "Als Favorit markieren", - "context_label": "Kontext:", - "section_panels": "Panels", - "add_panel": "Panel", - "add_batch": "Batch", - "add_batch_title": "2–4 Panels in einem Rutsch generieren", - "add_ai": "Mit KI", - "add_ai_title": "KI schlägt Panels aus einem Tagebuch-Eintrag, Notiz oder Review vor", - "unarchive": "Wieder aktiv", - "archive": "Archivieren", - "delete": "Löschen", - "archived_hint": "Archivierte Story — keine Panel-Generierung möglich, bis wieder aktiviert.", - "confirm_delete_story": "Story \"{title}\" wirklich löschen?", - "confirm_remove_panel": "Panel aus der Story entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden." - }, - "styles": { - "comic": "US-Comic", - "manga": "Manga", - "cartoon": "Cartoon", - "graphic-novel": "Graphic Novel", - "webtoon": "Webtoon" - }, - "picker": { - "section_title": "Protagonist", - "section_hint": "Dein Gesicht ist Pflicht. Body-Ref und bis zu {max} Kostüm-Fotos sind optional — klicke ein Bild oder das ✕, um es wieder zu entfernen.", - "face_required_title": "Face-Ref ist Pflicht — kann nicht entfernt werden", - "face_required_badge": "Pflicht", - "face_alt": "Face-Ref", - "face_missing": "Face fehlt", - "face_label": "Face", - "body_alt": "Body-Ref", - "body_missing": "Body fehlt", - "body_label": "Body", - "body_no_in_space": "Kein Body-Ref im aktiven Space", - "toggle_remove": "Klick zum Entfernen", - "toggle_add": "Klick zum Hinzufügen", - "garment_remove_aria": "{name} entfernen", - "garment_label": "Kostüm", - "garment_picker_title": "Kostüm aus dem Schrank wählen", - "garment_picker_close": "Schließen", - "garment_picker_empty_html": "Keine weiteren Kleidungsstücke verfügbar — lade welche in /wardrobe hoch.", - "no_face_alert_html": "Kein Gesichtsbild in diesem Space. Lade eins in Profil → Bilder hoch — ohne Face-Ref kein Comic.", - "body_tip": "Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen soll." - }, - "character_detail": { - "back_aria": "Zurück zu Characters", - "breadcrumb": "Comic · Characters", - "loading": "Lädt…", - "not_found": "Character nicht gefunden.", - "not_found_hint": "Gelöscht oder in einem anderen Space.", - "variant_one": "{n} Variante", - "variant_other": "{n} Varianten", - "pin_open": "Pin offen", - "favorite_remove": "Favorit entfernen", - "favorite_set": "Als Favorit markieren", - "prompt_add_label": "Prompt-Add:", - "section_variants": "Varianten", - "action_more_variants": "Mehr Varianten", - "empty_variants_title": "Noch keine Varianten.", - "empty_variants_hint_html": "Klick oben rechts auf + Mehr Varianten, um die ersten 4 zu generieren.", - "unarchive": "Wieder aktiv", - "archive": "Archivieren", - "delete": "Löschen", - "archived_hint": "Archivierter Character — keine Variant-Generierung möglich, bis wieder aktiviert.", - "confirm_delete_character": "Character \"{name}\" wirklich löschen?", - "confirm_remove_variant": "Variante aus dem Character entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden." - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/comic/en.json b/apps/mana/apps/web/src/lib/i18n/locales/comic/en.json deleted file mode 100644 index 3da8a4942..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/comic/en.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "detail": { - "back_aria": "Back to comics", - "breadcrumb": "Comics", - "loading": "Loading…", - "not_found": "Story not found.", - "not_found_hint": "Deleted or in another space.", - "panel_one": "{n} panel", - "panel_other": "{n} panels", - "reference_one": "{n} reference", - "reference_other": "{n} references", - "favorite_remove": "Remove favorite", - "favorite_set": "Mark as favorite", - "context_label": "Context:", - "section_panels": "Panels", - "add_panel": "Panel", - "add_batch": "Batch", - "add_batch_title": "Generate 2–4 panels in one go", - "add_ai": "With AI", - "add_ai_title": "AI suggests panels from a journal entry, note, or review", - "unarchive": "Reactivate", - "archive": "Archive", - "delete": "Delete", - "archived_hint": "Archived story — no panel generation possible until reactivated.", - "confirm_delete_story": "Really delete story \"{title}\"?", - "confirm_remove_panel": "Remove panel from the story? The image stays in your Picture gallery and can be deleted there." - }, - "styles": { - "comic": "US Comic", - "manga": "Manga", - "cartoon": "Cartoon", - "graphic-novel": "Graphic Novel", - "webtoon": "Webtoon" - }, - "picker": { - "section_title": "Protagonist", - "section_hint": "Your face is required. Body-ref and up to {max} costume photos are optional — click an image or the ✕ to remove it.", - "face_required_title": "Face-ref is required — cannot be removed", - "face_required_badge": "Required", - "face_alt": "Face ref", - "face_missing": "No face", - "face_label": "Face", - "body_alt": "Body ref", - "body_missing": "No body", - "body_label": "Body", - "body_no_in_space": "No body-ref in active space", - "toggle_remove": "Click to remove", - "toggle_add": "Click to add", - "garment_remove_aria": "Remove {name}", - "garment_label": "Costume", - "garment_picker_title": "Pick a costume from the wardrobe", - "garment_picker_close": "Close", - "garment_picker_empty_html": "No more garments available — upload some in /wardrobe.", - "no_face_alert_html": "No face photo in this space. Upload one in Profile → Images — without a face ref no comic.", - "body_tip": "Tip: a body ref helps when the comic shows full-body panels." - }, - "character_detail": { - "back_aria": "Back to characters", - "breadcrumb": "Comic · Characters", - "loading": "Loading…", - "not_found": "Character not found.", - "not_found_hint": "Deleted or in another space.", - "variant_one": "{n} variant", - "variant_other": "{n} variants", - "pin_open": "Pin pending", - "favorite_remove": "Remove favorite", - "favorite_set": "Mark as favorite", - "prompt_add_label": "Prompt add:", - "section_variants": "Variants", - "action_more_variants": "More variants", - "empty_variants_title": "No variants yet.", - "empty_variants_hint_html": "Click + More variants in the top right to generate the first 4.", - "unarchive": "Reactivate", - "archive": "Archive", - "delete": "Delete", - "archived_hint": "Archived character — no variant generation possible until reactivated.", - "confirm_delete_character": "Really delete character \"{name}\"?", - "confirm_remove_variant": "Remove variant from this character? The image stays in your Picture gallery and can be deleted there." - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/comic/es.json b/apps/mana/apps/web/src/lib/i18n/locales/comic/es.json deleted file mode 100644 index a95ab69a0..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/comic/es.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "detail": { - "back_aria": "Volver a cómics", - "breadcrumb": "Cómics", - "loading": "Cargando…", - "not_found": "Historia no encontrada.", - "not_found_hint": "Eliminada o en otro espacio.", - "panel_one": "{n} panel", - "panel_other": "{n} paneles", - "reference_one": "{n} referencia", - "reference_other": "{n} referencias", - "favorite_remove": "Quitar favorito", - "favorite_set": "Marcar como favorito", - "context_label": "Contexto:", - "section_panels": "Paneles", - "add_panel": "Panel", - "add_batch": "Lote", - "add_batch_title": "Generar 2–4 paneles de una vez", - "add_ai": "Con IA", - "add_ai_title": "La IA sugiere paneles a partir de un diario, nota o reseña", - "unarchive": "Reactivar", - "archive": "Archivar", - "delete": "Eliminar", - "archived_hint": "Historia archivada — no se pueden generar paneles hasta que se reactive.", - "confirm_delete_story": "¿Eliminar realmente la historia \"{title}\"?", - "confirm_remove_panel": "¿Quitar panel de la historia? La imagen permanece en tu galería de Picture y puede eliminarse allí." - }, - "styles": { - "comic": "Cómic US", - "manga": "Manga", - "cartoon": "Cartoon", - "graphic-novel": "Novela gráfica", - "webtoon": "Webtoon" - }, - "picker": { - "section_title": "Protagonista", - "section_hint": "Tu cara es obligatoria. La body-ref y hasta {max} fotos de vestuario son opcionales — haz clic en una imagen o en la ✕ para quitarla.", - "face_required_title": "Face-ref es obligatoria — no se puede quitar", - "face_required_badge": "Obligatorio", - "face_alt": "Face ref", - "face_missing": "Sin cara", - "face_label": "Cara", - "body_alt": "Body ref", - "body_missing": "Sin cuerpo", - "body_label": "Cuerpo", - "body_no_in_space": "No hay body-ref en el espacio activo", - "toggle_remove": "Clic para quitar", - "toggle_add": "Clic para añadir", - "garment_remove_aria": "Quitar {name}", - "garment_label": "Vestuario", - "garment_picker_title": "Elegir vestuario del armario", - "garment_picker_close": "Cerrar", - "garment_picker_empty_html": "No hay más prendas disponibles — sube algunas en /wardrobe.", - "no_face_alert_html": "No hay foto de cara en este espacio. Sube una en Perfil → Imágenes — sin face-ref no hay cómic.", - "body_tip": "Tip: una body-ref ayuda cuando el cómic muestra paneles de cuerpo entero." - }, - "character_detail": { - "back_aria": "Volver a personajes", - "breadcrumb": "Cómic · Personajes", - "loading": "Cargando…", - "not_found": "Personaje no encontrado.", - "not_found_hint": "Eliminado o en otro espacio.", - "variant_one": "{n} variante", - "variant_other": "{n} variantes", - "pin_open": "Pin pendiente", - "favorite_remove": "Quitar favorito", - "favorite_set": "Marcar como favorito", - "prompt_add_label": "Prompt extra:", - "section_variants": "Variantes", - "action_more_variants": "Más variantes", - "empty_variants_title": "Aún sin variantes.", - "empty_variants_hint_html": "Haz clic en + Más variantes arriba a la derecha para generar las primeras 4.", - "unarchive": "Reactivar", - "archive": "Archivar", - "delete": "Eliminar", - "archived_hint": "Personaje archivado — no se pueden generar variantes hasta reactivar.", - "confirm_delete_character": "¿Eliminar realmente el personaje \"{name}\"?", - "confirm_remove_variant": "¿Quitar variante del personaje? La imagen permanece en tu galería de Picture y puede eliminarse allí." - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/comic/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/comic/fr.json deleted file mode 100644 index 45d6490df..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/comic/fr.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "detail": { - "back_aria": "Retour aux comics", - "breadcrumb": "Comics", - "loading": "Chargement…", - "not_found": "Histoire introuvable.", - "not_found_hint": "Supprimée ou dans un autre espace.", - "panel_one": "{n} panneau", - "panel_other": "{n} panneaux", - "reference_one": "{n} référence", - "reference_other": "{n} références", - "favorite_remove": "Retirer des favoris", - "favorite_set": "Marquer comme favori", - "context_label": "Contexte :", - "section_panels": "Panneaux", - "add_panel": "Panneau", - "add_batch": "Lot", - "add_batch_title": "Générer 2 à 4 panneaux d'un coup", - "add_ai": "Avec IA", - "add_ai_title": "L'IA propose des panneaux à partir d'une entrée de journal, note ou critique", - "unarchive": "Réactiver", - "archive": "Archiver", - "delete": "Supprimer", - "archived_hint": "Histoire archivée — pas de génération de panneaux possible jusqu'à réactivation.", - "confirm_delete_story": "Vraiment supprimer l'histoire « {title} » ?", - "confirm_remove_panel": "Retirer le panneau de l'histoire ? L'image reste dans ta galerie Picture et peut y être supprimée." - }, - "styles": { - "comic": "Comic US", - "manga": "Manga", - "cartoon": "Cartoon", - "graphic-novel": "Roman graphique", - "webtoon": "Webtoon" - }, - "picker": { - "section_title": "Protagoniste", - "section_hint": "Ton visage est obligatoire. La body-ref et jusqu'à {max} photos de costume sont optionnelles — clique sur une image ou la ✕ pour la retirer.", - "face_required_title": "Face-ref est obligatoire — impossible à retirer", - "face_required_badge": "Obligatoire", - "face_alt": "Face ref", - "face_missing": "Pas de visage", - "face_label": "Visage", - "body_alt": "Body ref", - "body_missing": "Pas de corps", - "body_label": "Corps", - "body_no_in_space": "Aucune body-ref dans l'espace actif", - "toggle_remove": "Clic pour retirer", - "toggle_add": "Clic pour ajouter", - "garment_remove_aria": "Retirer {name}", - "garment_label": "Costume", - "garment_picker_title": "Choisir un costume dans la garde-robe", - "garment_picker_close": "Fermer", - "garment_picker_empty_html": "Aucun autre vêtement disponible — ajoutes-en dans /wardrobe.", - "no_face_alert_html": "Aucune photo de visage dans cet espace. Ajoute-en une dans Profil → Images — sans face-ref pas de comic.", - "body_tip": "Astuce : une body-ref aide quand le comic montre des panneaux corps entier." - }, - "character_detail": { - "back_aria": "Retour aux personnages", - "breadcrumb": "Comic · Personnages", - "loading": "Chargement…", - "not_found": "Personnage introuvable.", - "not_found_hint": "Supprimé ou dans un autre espace.", - "variant_one": "{n} variante", - "variant_other": "{n} variantes", - "pin_open": "Pin en attente", - "favorite_remove": "Retirer des favoris", - "favorite_set": "Marquer comme favori", - "prompt_add_label": "Prompt extra :", - "section_variants": "Variantes", - "action_more_variants": "Plus de variantes", - "empty_variants_title": "Pas encore de variantes.", - "empty_variants_hint_html": "Clique sur + Plus de variantes en haut à droite pour générer les 4 premières.", - "unarchive": "Réactiver", - "archive": "Archiver", - "delete": "Supprimer", - "archived_hint": "Personnage archivé — pas de génération de variantes possible jusqu'à réactivation.", - "confirm_delete_character": "Vraiment supprimer le personnage « {name} » ?", - "confirm_remove_variant": "Retirer la variante du personnage ? L'image reste dans ta galerie Picture et peut y être supprimée." - } -} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/comic/it.json b/apps/mana/apps/web/src/lib/i18n/locales/comic/it.json deleted file mode 100644 index 5d0f6654f..000000000 --- a/apps/mana/apps/web/src/lib/i18n/locales/comic/it.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "detail": { - "back_aria": "Torna ai fumetti", - "breadcrumb": "Fumetti", - "loading": "Caricamento…", - "not_found": "Storia non trovata.", - "not_found_hint": "Eliminata o in un altro spazio.", - "panel_one": "{n} pannello", - "panel_other": "{n} pannelli", - "reference_one": "{n} riferimento", - "reference_other": "{n} riferimenti", - "favorite_remove": "Rimuovi preferito", - "favorite_set": "Segna come preferito", - "context_label": "Contesto:", - "section_panels": "Pannelli", - "add_panel": "Pannello", - "add_batch": "Batch", - "add_batch_title": "Genera 2–4 pannelli in una volta", - "add_ai": "Con IA", - "add_ai_title": "L'IA propone pannelli da un diario, nota o recensione", - "unarchive": "Riattiva", - "archive": "Archivia", - "delete": "Elimina", - "archived_hint": "Storia archiviata — generazione pannelli non possibile finché non viene riattivata.", - "confirm_delete_story": "Eliminare davvero la storia \"{title}\"?", - "confirm_remove_panel": "Rimuovere il pannello dalla storia? L'immagine resta nella tua galleria Picture e può essere eliminata lì." - }, - "styles": { - "comic": "Comic USA", - "manga": "Manga", - "cartoon": "Cartoon", - "graphic-novel": "Graphic Novel", - "webtoon": "Webtoon" - }, - "picker": { - "section_title": "Protagonista", - "section_hint": "Il tuo volto è obbligatorio. La body-ref e fino a {max} foto di costume sono opzionali — clicca un'immagine o la ✕ per rimuoverla.", - "face_required_title": "Face-ref è obbligatoria — non rimovibile", - "face_required_badge": "Obbligatorio", - "face_alt": "Face ref", - "face_missing": "Nessun volto", - "face_label": "Volto", - "body_alt": "Body ref", - "body_missing": "Nessun corpo", - "body_label": "Corpo", - "body_no_in_space": "Nessuna body-ref nello spazio attivo", - "toggle_remove": "Clic per rimuovere", - "toggle_add": "Clic per aggiungere", - "garment_remove_aria": "Rimuovi {name}", - "garment_label": "Costume", - "garment_picker_title": "Scegli un costume dall'armadio", - "garment_picker_close": "Chiudi", - "garment_picker_empty_html": "Nessun altro indumento disponibile — caricane in /wardrobe.", - "no_face_alert_html": "Nessuna foto del volto in questo spazio. Caricane una in Profilo → Immagini — senza face-ref niente comic.", - "body_tip": "Suggerimento: una body-ref aiuta quando il comic mostra pannelli a figura intera." - }, - "character_detail": { - "back_aria": "Torna ai personaggi", - "breadcrumb": "Comic · Personaggi", - "loading": "Caricamento…", - "not_found": "Personaggio non trovato.", - "not_found_hint": "Eliminato o in un altro spazio.", - "variant_one": "{n} variante", - "variant_other": "{n} varianti", - "pin_open": "Pin in attesa", - "favorite_remove": "Rimuovi preferito", - "favorite_set": "Segna come preferito", - "prompt_add_label": "Prompt extra:", - "section_variants": "Varianti", - "action_more_variants": "Altre varianti", - "empty_variants_title": "Ancora nessuna variante.", - "empty_variants_hint_html": "Clicca + Altre varianti in alto a destra per generare le prime 4.", - "unarchive": "Riattiva", - "archive": "Archivia", - "delete": "Elimina", - "archived_hint": "Personaggio archiviato — generazione varianti non possibile fino alla riattivazione.", - "confirm_delete_character": "Eliminare davvero il personaggio \"{name}\"?", - "confirm_remove_variant": "Rimuovere la variante dal personaggio? L'immagine resta nella tua galleria Picture e può essere eliminata lì." - } -} diff --git a/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte b/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte deleted file mode 100644 index ed149b6f1..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/ListView.svelte +++ /dev/null @@ -1,290 +0,0 @@ - - - -
- - - {#if showBanner} -
- {#if uploadPhase === 'success'} -
- {#if uploadedPreviewUrl} - - {:else} - - - - {/if} -
-

- - Gesichtsbild gespeichert -

-

- Perfekt — als nächstes baust du deinen ersten Comic-Character oder legst direkt eine - Story an. -

-
- -
- {:else} -
- -
-

Lade ein Gesichtsbild hoch

-

- Wir brauchen dich auf Bild, damit Comic-Panels und Charakter-Varianten von dir - gerendert werden können. Das Bild bleibt lokal und wird nur für deine eigenen - Generierungen genutzt. -

-
-
-
- - {#if uploadPhase === 'uploading'} - - - Lade… - - {/if} -
- {#if faceUploadError} - - {/if} - {/if} -
- {/if} - -
- {#if activeTab === 'stories'} - - {:else} - - {/if} -
-
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/api/generate-character.ts b/apps/mana/apps/web/src/lib/modules/comic/api/generate-character.ts deleted file mode 100644 index 24d92c4a0..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/api/generate-character.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Character-variant generation. Renders N stylised portraits of the - * user from face/body meImages with the chosen ComicStyle prefix, - * persists each into `picture.images` with a `comicCharacterId` - * back-ref, and appends each to the character's `variantMediaIds`. - * - * The endpoint and the HTTP shape are identical to panel-generation - * (`api/generate-panel.ts`); only the prompt-template differs (panel - * = "what happens in this panel", character = "portrait of the same - * person, identity anchor"). One call with `n=4` returns all four - * variants in a single batch — that's the gpt-image-2 multi-image - * response shape (`{images: [{imageUrl, mediaId}, ...]}`). - * - * Plan: docs/plans/comic-module.md §11 (Mc2). - */ - -import { getManaApiUrl } from '$lib/api/config'; -import { authStore } from '$lib/stores/auth.svelte'; -import { imagesStore } from '$lib/modules/picture/stores/images.svelte'; -import { comicCharactersStore } from '../stores/characters.svelte'; -import { STYLE_PREFIXES } from '../styles'; -import { DEFAULT_PANEL_MODEL, type PanelModel } from './generate-panel'; -import type { ComicCharacter, ComicStyle } from '../types'; - -export type CharacterSize = '1024x1024' | '1024x1536'; - -export interface RunCharacterGenerateParams { - character: ComicCharacter; - /** How many variants to render in one batch — 1-4 (gpt-image-2's - * hard server cap). Default 4: the picker shows enough options - * for a real choice without burning credits on speculative noise. */ - count?: number; - quality?: 'low' | 'medium' | 'high'; - size?: CharacterSize; - model?: PanelModel; -} - -export interface RunCharacterGenerateResult { - variantMediaIds: string[]; - imageUrls: string[]; - prompt: string; - model: string; -} - -function dimsForSize(size: CharacterSize): { width: number; height: number } { - if (size === '1024x1536') return { width: 1024, height: 1536 }; - return { width: 1024, height: 1024 }; -} - -/** - * Compose the gpt-image-2 prompt for a character variant. The - * style-prefix sets the visual register; the identity-anchor - * instruction biases the model toward keeping face features - * recognisable across the four variants of one batch. - * - * Caption / dialogue strings are deliberately left out — characters - * are bare portraits, not panels with text. - */ -export function composeCharacterPrompt( - style: ComicStyle, - addPrompt: string | null | undefined -): string { - const parts: string[] = [ - STYLE_PREFIXES[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('. '); -} - -/** - * Generate N variants and append them to the character. Caller - * passes the snapshot character (post-create), this function - * mutates Dexie via `imagesStore.insert` + `comicCharactersStore.appendVariant`. - */ -export async function runCharacterGenerate( - params: RunCharacterGenerateParams -): Promise { - const { character } = params; - const count = Math.max(1, Math.min(4, params.count ?? 4)); - const quality = params.quality ?? 'medium'; - const size: CharacterSize = params.size ?? '1024x1024'; - const model: PanelModel = params.model ?? DEFAULT_PANEL_MODEL; - - if (!character.sourceFaceMediaId) { - throw new Error('Character braucht ein Source-Face-Bild.'); - } - - const referenceMediaIds: string[] = [character.sourceFaceMediaId]; - if (character.sourceBodyMediaId) { - referenceMediaIds.push(character.sourceBodyMediaId); - } - - const composed = composeCharacterPrompt(character.style, character.addPrompt); - - const token = await authStore.getValidToken(); - const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - prompt: composed, - referenceMediaIds, - model, - quality, - size, - n: count, - }), - }); - - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { - error?: string; - detail?: string; - required?: number; - }; - if (res.status === 402) { - throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`); - } - if (res.status === 404) { - throw new Error( - 'Source-Bilder im Server-Ownership-Check durchgefallen — Face-/Body-Refs fehlen im aktiven Space.' - ); - } - const label = body.error ?? `Character-Generierung fehlgeschlagen (${res.status})`; - throw new Error(body.detail ? `${label}: ${body.detail}` : label); - } - - const data = (await res.json()) as { - images?: Array<{ imageUrl: string; mediaId?: string }>; - imageUrl?: string; - mediaId?: string; - prompt: string; - model: string; - }; - - // Normalise: the endpoint returns either `images: [...]` (n>=1 - // path) or a legacy `imageUrl + mediaId` flat shape. Both go - // through the same persist loop below. - 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('Keine Variant-Bilder zurückgegeben'); - } - - const dims = dimsForSize(size); - const variantMediaIds: string[] = []; - const imageUrls: string[] = []; - - // Persist each variant in order — auto-pin auf erste Variant - // passiert in `appendVariant` falls noch keine gepinnt ist, der - // User kann später re-pinnen. - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (!item.imageUrl || !item.mediaId) continue; - const localImageId = crypto.randomUUID(); - const nowIso = new Date().toISOString(); - const variantIndex = (character.variantMediaIds?.length ?? 0) + i; - - await imagesStore.insert({ - id: localImageId, - prompt: data.prompt, - negativePrompt: null, - model: data.model, - publicUrl: item.imageUrl, - storagePath: item.mediaId, - filename: `comic-character-${character.id}-${variantIndex + 1}.png`, - format: 'png', - width: dims.width, - height: dims.height, - visibility: 'private', - isFavorite: false, - downloadCount: 0, - generationMode: 'reference', - referenceImageIds: referenceMediaIds, - comicCharacterId: character.id, - createdAt: nowIso, - }); - - await comicCharactersStore.appendVariant(character.id, localImageId); - - variantMediaIds.push(localImageId); - imageUrls.push(item.imageUrl); - } - - if (variantMediaIds.length === 0) { - throw new Error('Server lieferte Bilder ohne mediaId — kein Variant gespeichert'); - } - - return { - variantMediaIds, - imageUrls, - prompt: data.prompt, - model: data.model, - }; -} diff --git a/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts b/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts deleted file mode 100644 index 776778b48..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/api/generate-panel.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Panel generation client. Composes a reference-based image-edit call - * against `/api/v1/picture/generate-with-reference` using the story's - * fixed `characterMediaIds` plus the story-wide style-prefix, then - * persists the result into `picture.images` with `comicStoryId` + - * `comicPanelIndex` back-refs and appends the panel to the story via - * `comicStoriesStore.appendPanel`. - * - * Same HTTP shape as `wardrobe/api/try-on.ts` — Comics reuse the - * endpoint verbatim. Only difference: character refs come from the - * story row (not reactively from useImageByPrimary), and the result - * goes through appendPanel into the story's ordered panel list. - * - * Plan: docs/plans/comic-module.md M2. - */ - -import { getManaApiUrl } from '$lib/api/config'; -import { authStore } from '$lib/stores/auth.svelte'; -import { imagesStore } from '$lib/modules/picture/stores/images.svelte'; -import { comicStoriesStore } from '../stores/stories.svelte'; -import { composePanelPrompt } from '../styles'; -import type { ComicPanelMeta, ComicStory } from '../types'; - -/** - * Panel size. 1024×1024 is the comic-default — square panels compose - * into a strip or grid cleanly. 1024×1536 is available for verticaly- - * oriented "Webtoon"-style long shots. The backend supports more but - * M2 keeps the picker small. - */ -export type PanelSize = '1024x1024' | '1024x1536'; - -/** - * Models that can drive panel rendering. Same closed set as - * Wardrobe's Try-On picker so character consistency between a - * user's outfit try-ons and their comic panels stays comparable - * (different models ≈ different faces). - * - * - `openai/gpt-image-2` — existing default, mid-tier cost. - * Server-side transparent fallback to gpt-image-1 for - * unverified OpenAI orgs; see apps/api picture/routes.ts. - * - `google/gemini-3-pro-image-preview` — Nano Banana Pro. - * Strong character consistency across panels, higher cost. - * - `google/gemini-3.1-flash-image-preview` — Nano Banana 2. - * Newest + fast + cheap, good default for drafts. - * - * Credit tarifs are set by creditsFor() in picture/routes.ts. - */ -export type PanelModel = - | 'openai/gpt-image-2' - | 'google/gemini-3-pro-image-preview' - | 'google/gemini-3.1-flash-image-preview'; - -export const DEFAULT_PANEL_MODEL: PanelModel = 'openai/gpt-image-2'; - -export interface RunPanelGenerateParams { - story: ComicStory; - panelPrompt: string; - caption?: string; - dialogue?: string; - /** Tags the panel with the module-entry it was seeded from (M4 AI- - * Storyboard). Ignored in M2 single-panel flow. */ - sourceInput?: ComicPanelMeta['sourceInput']; - quality?: 'low' | 'medium' | 'high'; - size?: PanelSize; - /** Rendering backend — defaults to `DEFAULT_PANEL_MODEL`. Mirrored - * from Wardrobe so users can pick per-call without a story-level - * schema change. See `PanelModelPicker.svelte`. */ - model?: PanelModel; -} - -export interface RunPanelGenerateResult { - imageId: string; - imageUrl: string; - prompt: string; - model: string; - panelIndex: number; -} - -function dimsForSize(size: PanelSize): { width: number; height: number } { - if (size === '1024x1536') return { width: 1024, height: 1536 }; - return { width: 1024, height: 1024 }; -} - -/** - * Shared low-level POST. Mirrors wardrobe's callGenerateWithReference - * so the error matrix stays identical across the two consumers of - * this endpoint. - */ -async function callGenerateWithReference(opts: { - prompt: string; - referenceMediaIds: string[]; - quality: 'low' | 'medium' | 'high'; - size: PanelSize; - model: PanelModel; -}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> { - const token = await authStore.getValidToken(); - const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - prompt: opts.prompt, - referenceMediaIds: opts.referenceMediaIds, - model: opts.model, - quality: opts.quality, - size: opts.size, - n: 1, - }), - }); - - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { - error?: string; - detail?: string; - required?: number; - missing?: string[]; - }; - if (res.status === 402) { - throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`); - } - if (res.status === 404) { - throw new Error( - 'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — prüfe, ob Face/Body in diesem Space existieren.' - ); - } - const label = body.error ?? `Panel-Generierung fehlgeschlagen (${res.status})`; - throw new Error(body.detail ? `${label}: ${body.detail}` : label); - } - - const data = (await res.json()) as { - images?: Array<{ imageUrl: string; mediaId?: string }>; - imageUrl?: string; - mediaId?: string; - prompt: string; - model: string; - }; - const first = - (data.images && data.images[0]) ?? - (data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null); - if (!first?.imageUrl || !first.mediaId) { - throw new Error('Keine Bilder zurückgegeben'); - } - return { - imageUrl: first.imageUrl, - mediaId: first.mediaId, - prompt: data.prompt, - model: data.model, - }; -} - -/** - * Generate one panel for a story. The story provides the fixed - * reference-image list (face + optional body + optional garments — - * chosen once at story-create time); this call only adds the panel - * prompt + caption + dialogue on top of the story's style prefix. - */ -export async function runPanelGenerate( - params: RunPanelGenerateParams -): Promise { - const { story, panelPrompt, caption, dialogue, sourceInput } = params; - - if (story.characterMediaIds.length === 0) { - throw new Error('Story hat keine Character-Referenz — bitte Face-Ref hinterlegen.'); - } - if (!panelPrompt.trim()) { - throw new Error('Panel-Prompt ist leer.'); - } - - // Style-prefix + panelPrompt + caption/dialog hints, composed in - // styles.ts. The backend never sees the style enum — it only sees - // the final prompt string. - const composedPrompt = composePanelPrompt({ - style: story.style, - panelPrompt, - caption, - dialogue, - }); - - const effectiveSize: PanelSize = - params.size ?? (story.style === 'webtoon' ? '1024x1536' : '1024x1024'); - const effectiveQuality = params.quality ?? 'medium'; - const effectiveModel: PanelModel = params.model ?? DEFAULT_PANEL_MODEL; - - // Cap at 8 references (server limit). If the story somehow has more - // in its characterMediaIds (shouldn't — UI caps at ~5), truncate and - // warn. Face-ref is [0] by convention. - const referenceMediaIds = story.characterMediaIds.slice(0, 8); - - const result = await callGenerateWithReference({ - prompt: composedPrompt, - referenceMediaIds, - quality: effectiveQuality, - size: effectiveSize, - model: effectiveModel, - }); - - const now = new Date().toISOString(); - const localImageId = crypto.randomUUID(); - const dims = dimsForSize(effectiveSize); - const panelIndex = story.panelImageIds.length; // zero-based - - await imagesStore.insert({ - id: localImageId, - prompt: result.prompt, - negativePrompt: null, - model: result.model, - publicUrl: result.imageUrl, - storagePath: result.mediaId, - filename: `comic-panel-${story.id}-${panelIndex + 1}.png`, - format: 'png', - width: dims.width, - height: dims.height, - visibility: 'private', - isFavorite: false, - downloadCount: 0, - generationMode: 'reference', - referenceImageIds: referenceMediaIds, - comicStoryId: story.id, - comicPanelIndex: panelIndex, - createdAt: now, - }); - - await comicStoriesStore.appendPanel(story.id, localImageId, { - caption: caption?.trim() || undefined, - dialogue: dialogue?.trim() || undefined, - promptUsed: composedPrompt, - sourceInput, - }); - - return { - imageId: localImageId, - imageUrl: result.imageUrl, - prompt: result.prompt, - model: result.model, - panelIndex, - }; -} diff --git a/apps/mana/apps/web/src/lib/modules/comic/api/storyboard.ts b/apps/mana/apps/web/src/lib/modules/comic/api/storyboard.ts deleted file mode 100644 index ffe48426c..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/api/storyboard.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Storyboard client. Calls `/api/v1/comic/storyboard` with the - * decrypted source text (journal entry, note, library review, - * writing draft, calendar event description) and the chosen style, - * receives an ordered `Panel[]` suggestion that the user reviews + - * edits before firing the batch-gen flow (M3). - * - * Cross-module decrypt stays client-side — the browser loads the - * source module's row, passes it through its own decryptor, and - * hands us plaintext. No Key-Grants / server-side decrypts involved - * (matches the plan §6 decision: M4 is interactive client-side). - * - * Plan: docs/plans/comic-module.md M4. - */ - -import { getManaApiUrl } from '$lib/api/config'; -import { authStore } from '$lib/stores/auth.svelte'; -import type { ComicStyle } from '../types'; - -export type StoryboardSourceModule = 'journal' | 'notes' | 'library' | 'writing' | 'calendar'; - -export interface StoryboardPanel { - prompt: string; - caption?: string; - dialogue?: string; -} - -export interface SuggestPanelsParams { - style: ComicStyle; - sourceText: string; - panelCount: number; - /** Story-level briefing the author typed when creating the story. - * Gets prepended server-side so Claude knows the tonal register. */ - storyContext?: string | null; - /** Logged for observability only — not sent to the LLM. */ - sourceModule?: StoryboardSourceModule; -} - -export interface SuggestPanelsResult { - panels: StoryboardPanel[]; - model: string; - durationMs: number; -} - -export async function suggestPanels(params: SuggestPanelsParams): Promise { - const token = await authStore.getValidToken(); - const res = await fetch(`${getManaApiUrl()}/api/v1/comic/storyboard`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - style: params.style, - sourceText: params.sourceText, - panelCount: params.panelCount, - storyContext: params.storyContext, - sourceModule: params.sourceModule, - }), - }); - - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { error?: string; detail?: string }; - const label = body.error ?? `Storyboard fehlgeschlagen (${res.status})`; - throw new Error(body.detail ? `${label}: ${body.detail}` : label); - } - - const data = (await res.json()) as SuggestPanelsResult; - if (!Array.isArray(data.panels) || data.panels.length === 0) { - throw new Error('Keine Panels vom Modell zurück — versuche es mit anderem Text oder Stil.'); - } - return data; -} diff --git a/apps/mana/apps/web/src/lib/modules/comic/collections.ts b/apps/mana/apps/web/src/lib/modules/comic/collections.ts deleted file mode 100644 index dce84e3dd..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/collections.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Comic module — Dexie table accessors. - */ - -import { db } from '$lib/data/database'; -import type { LocalComicStory, LocalComicCharacter } from './types'; - -export const comicStoriesTable = db.table('comicStories'); -export const comicCharactersTable = db.table('comicCharacters'); diff --git a/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts b/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts deleted file mode 100644 index 9209eecf3..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Comic encryption roundtrip test. - * - * `comicStories` ships with `panelMeta: Record` as an encrypted JSON blob via the - * registry entry `entry(['title', 'description', - * 'storyContext', 'tags', 'panelMeta'])`. This test locks in the - * roundtrip contract: every encrypted field recovers its exact value - * after an encrypt→decrypt cycle, the structural fields (id, style, - * characterMediaIds, panelImageIds, booleans, timestamps) stay - * plaintext, and the nested panelMeta object (including its - * sourceInput.module enum and sourceInput.entryId FK) survives - * untouched. - * - * Modeled after notes-encryption.test.ts but uses encryptRecord / - * decryptRecord directly — no Dexie round-trip needed to prove the - * registry contract, and skipping fake-indexeddb keeps the test fast. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; - -import { - encryptRecord, - decryptRecord, - generateMasterKey, - MemoryKeyProvider, - setKeyProvider, - isEncrypted, -} from '$lib/data/crypto'; -import { setCurrentUserId } from '$lib/data/current-user'; -import type { ComicPanelMeta, LocalComicCharacter, LocalComicStory } from './types'; - -const TABLE = 'comicStories'; - -let provider: MemoryKeyProvider; - -beforeEach(async () => { - const key = await generateMasterKey(); - provider = new MemoryKeyProvider(); - provider.setKey(key); - setKeyProvider(provider); - setCurrentUserId('test-user'); -}); - -afterEach(() => { - provider.setKey(null); - setCurrentUserId(null); -}); - -function makeStory(overrides: Partial = {}): LocalComicStory { - return { - id: 'story-1', - title: 'Bug-Hunt-Frust', - description: 'Ein 4-Panel-Comic zum Sync-Bug vom Dienstag', - style: 'comic', - characterMediaIds: ['me-face-123', 'wardrobe-tee-456'], - storyContext: 'Ich ärgere mich über einen Off-by-one in der LWW-Logik.', - panelImageIds: ['img-a', 'img-b'], - panelMeta: { - 'img-a': { - caption: 'Montag, 9 Uhr.', - dialogue: 'Der Test ist grün.', - promptUsed: 'developer sitting at desk, confident expression', - sourceInput: { module: 'journal', entryId: 'journal-42' }, - }, - 'img-b': { - caption: 'Eine Stunde später...', - dialogue: 'Der Test ist rot. WARUM.', - promptUsed: 'same developer, panicked expression, dark lighting', - }, - }, - tags: ['frust', 'devlog', '2026'], - isFavorite: true, - isArchived: false, - visibility: 'private', - ...overrides, - }; -} - -describe('comicStories encryption registry', () => { - it('encrypts title, description, storyContext, tags, panelMeta; leaves structural fields plaintext', async () => { - const row = makeStory(); - await encryptRecord(TABLE, row as unknown as Record); - - // Encrypted fields are ciphertext - expect(isEncrypted(row.title)).toBe(true); - expect(isEncrypted(row.description)).toBe(true); - expect(isEncrypted(row.storyContext)).toBe(true); - // tags is a string[] — aes.ts JSON-stringifies before wrap, the - // resulting value is still detected as encrypted via isEncrypted. - expect(isEncrypted(row.tags)).toBe(true); - // panelMeta is a nested object — same array-path pattern. - expect(isEncrypted(row.panelMeta)).toBe(true); - - // Nothing user-typed slipped through - expect(String(row.title)).not.toContain('Bug-Hunt'); - expect(String(row.description)).not.toContain('4-Panel'); - expect(String(row.storyContext)).not.toContain('Off-by-one'); - expect(JSON.stringify(row.panelMeta)).not.toContain('grün'); - expect(JSON.stringify(row.panelMeta)).not.toContain('WARUM'); - expect(JSON.stringify(row.tags)).not.toContain('devlog'); - - // Structural fields untouched - expect(row.id).toBe('story-1'); - expect(row.style).toBe('comic'); - expect(row.characterMediaIds).toEqual(['me-face-123', 'wardrobe-tee-456']); - expect(row.panelImageIds).toEqual(['img-a', 'img-b']); - expect(row.isFavorite).toBe(true); - expect(row.isArchived).toBe(false); - expect(row.visibility).toBe('private'); - }); - - it('roundtrips the full panelMeta nested object', async () => { - const row = makeStory(); - const originalMeta: Record = JSON.parse(JSON.stringify(row.panelMeta)); - - await encryptRecord(TABLE, row as unknown as Record); - await decryptRecord(TABLE, row as unknown as Record); - - expect(row.title).toBe('Bug-Hunt-Frust'); - expect(row.description).toBe('Ein 4-Panel-Comic zum Sync-Bug vom Dienstag'); - expect(row.storyContext).toBe('Ich ärgere mich über einen Off-by-one in der LWW-Logik.'); - expect(row.tags).toEqual(['frust', 'devlog', '2026']); - // Nested shape survives intact — caption / dialogue / promptUsed / - // sourceInput (module + entryId) all present and equal. - expect(row.panelMeta).toEqual(originalMeta); - }); - - it('handles an empty panelMeta record (freshly created story with no panels yet)', async () => { - const row = makeStory({ - panelImageIds: [], - panelMeta: {}, - }); - await encryptRecord(TABLE, row as unknown as Record); - // Even the empty object ships encrypted — registry doesn't skip - // empty non-null values. - expect(isEncrypted(row.panelMeta)).toBe(true); - - await decryptRecord(TABLE, row as unknown as Record); - expect(row.panelMeta).toEqual({}); - expect(row.panelImageIds).toEqual([]); - }); - - it('handles a panelMeta entry without sourceInput (manual panel, not AI-Storyboard)', async () => { - const row = makeStory({ - panelMeta: { - 'img-a': { - caption: 'Manuell geschrieben', - promptUsed: 'character looking at sunset', - // no dialogue, no sourceInput - }, - }, - }); - await encryptRecord(TABLE, row as unknown as Record); - await decryptRecord(TABLE, row as unknown as Record); - expect(row.panelMeta['img-a']).toEqual({ - caption: 'Manuell geschrieben', - promptUsed: 'character looking at sunset', - }); - }); - - it('leaves null-valued description unchanged (no crash, no wrap)', async () => { - const row = makeStory({ description: null }); - await encryptRecord(TABLE, row as unknown as Record); - expect(row.description).toBe(null); - await decryptRecord(TABLE, row as unknown as Record); - expect(row.description).toBe(null); - }); -}); - -// ─── Comic-Characters ───────────────────────────────────────────── - -const CHAR_TABLE = 'comicCharacters'; - -function makeCharacter(overrides: Partial = {}): LocalComicCharacter { - return { - id: 'char-1', - name: 'Manga-Me', - description: 'Mein Manga-Stil mit freundlichem Ausdruck', - style: 'manga', - addPrompt: 'Casual Outfit, freundliches Lächeln', - sourceFaceMediaId: 'me-face-99', - sourceBodyMediaId: 'me-body-77', - variantMediaIds: ['variant-a', 'variant-b', 'variant-c'], - pinnedVariantId: 'variant-b', - tags: ['casual', 'manga', 'standard'], - isFavorite: true, - isArchived: false, - ...overrides, - }; -} - -describe('comicCharacters encryption registry', () => { - it('encrypts name + description + addPrompt + tags; leaves structural fields plaintext', async () => { - const row = makeCharacter(); - await encryptRecord(CHAR_TABLE, row as unknown as Record); - - expect(isEncrypted(row.name)).toBe(true); - expect(isEncrypted(row.description)).toBe(true); - expect(isEncrypted(row.addPrompt)).toBe(true); - expect(isEncrypted(row.tags)).toBe(true); - - // User-typed prose nicht im Klartext durchgerutscht - expect(String(row.name)).not.toContain('Manga-Me'); - expect(String(row.description)).not.toContain('freundlichem'); - expect(String(row.addPrompt)).not.toContain('Lächeln'); - expect(JSON.stringify(row.tags)).not.toContain('manga'); - - // Strukturelle Felder unangetastet — Style-Filter, Source-FKs, - // Variant-Liste und Pin müssen im Index lesbar bleiben. - expect(row.id).toBe('char-1'); - expect(row.style).toBe('manga'); - expect(row.sourceFaceMediaId).toBe('me-face-99'); - expect(row.sourceBodyMediaId).toBe('me-body-77'); - expect(row.variantMediaIds).toEqual(['variant-a', 'variant-b', 'variant-c']); - expect(row.pinnedVariantId).toBe('variant-b'); - expect(row.isFavorite).toBe(true); - expect(row.isArchived).toBe(false); - }); - - it('roundtrips name / description / addPrompt / tags', async () => { - const row = makeCharacter(); - await encryptRecord(CHAR_TABLE, row as unknown as Record); - await decryptRecord(CHAR_TABLE, row as unknown as Record); - - expect(row.name).toBe('Manga-Me'); - expect(row.description).toBe('Mein Manga-Stil mit freundlichem Ausdruck'); - expect(row.addPrompt).toBe('Casual Outfit, freundliches Lächeln'); - expect(row.tags).toEqual(['casual', 'manga', 'standard']); - }); - - it('handles a build-in-progress character with no variants yet', async () => { - const row = makeCharacter({ - variantMediaIds: [], - pinnedVariantId: null, - addPrompt: null, - description: null, - }); - await encryptRecord(CHAR_TABLE, row as unknown as Record); - // addPrompt and description are null — no-wrap path - expect(row.addPrompt).toBe(null); - expect(row.description).toBe(null); - await decryptRecord(CHAR_TABLE, row as unknown as Record); - expect(row.variantMediaIds).toEqual([]); - expect(row.pinnedVariantId).toBe(null); - }); -}); diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/BatchPanelEditor.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/BatchPanelEditor.svelte deleted file mode 100644 index a0e20f007..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/BatchPanelEditor.svelte +++ /dev/null @@ -1,404 +0,0 @@ - - - -
-
-
-

Batch-Panels

-

- {filledRows.length} - {filledRows.length === 1 ? 'Panel' : 'Panels'} · {story.characterMediaIds.length} Referenz{story - .characterMediaIds.length === 1 - ? '' - : 'en'} · {roomLeft} - {roomLeft === 1 ? 'Slot' : 'Slots'} frei -

-
- -
- - {#if atCap} - - {:else if warn} -

- Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz spürbar schwerer. -

- {/if} - -
-
- {#each effectiveRows as row, index (row.id)} - {@const status = rowStatus[row.id]} -
-
-
- - {panelCount + index + 1} - - Panel {index + 1} - {#if status?.status === 'pending'} - - - Wird generiert… - - {:else if status?.status === 'ok'} - - - Fertig - - {:else if status?.status === 'error'} - - - Fehlgeschlagen - - {/if} -
-
- {#if status?.status === 'error'} - - {/if} - {#if rows.length > 1} - - {/if} -
-
- - - -
- - -
- - {#if status?.status === 'error' && status.error} - - {/if} -
- {/each} -
- -
- -
- - (model = m)} disabled={submitting} /> - -
-
- Qualität: - {#each QUALITIES as q (q)} - - {/each} -
-
- Format: - - -
-
- -
- -
- -
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterBuilder.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterBuilder.svelte deleted file mode 100644 index 1e6f8eeae..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterBuilder.svelte +++ /dev/null @@ -1,326 +0,0 @@ - - - -
-
-
-

- {isExtend ? 'Mehr Varianten generieren' : 'Neuer Character'} -

-

- {isExtend - ? `Erweitert "${existing?.name}" um ${VARIANT_COUNT} weitere Varianten — gleicher Stil, gleiche Source.` - : `Erstellt einen Character und rendert direkt ${VARIANT_COUNT} Varianten zur Auswahl.`} -

-
- {#if onClose} - - {/if} -
- -
- {#if !isExtend} - -
- - -
- - -
-
- Stil -
- (style = next)} disabled={busy} /> -
- {/if} - - -
- - -

- Englisch rendert stabiler. Wird auf alle {VARIANT_COUNT} Varianten in dieser Runde angewendet. -

-
- - {#if !hasFace} - - {:else if !isExtend} - -
-
- Quelle -
-
- {#if face?.publicUrl} -
-
- Face-Ref -
- Face -
- {/if} - {#if body?.publicUrl} -
- - Body -
- {/if} -
-
- {/if} - - (model = m)} disabled={busy} /> - -
- Qualität: - {#each QUALITIES as q (q)} - - {/each} -
- - {#if errorMsg} - - {/if} - -
- -
- -
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterCard.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterCard.svelte deleted file mode 100644 index dbe7eadd7..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterCard.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - - -
- {#if cover?.publicUrl} - {character.name} - {:else} -
- - Noch keine Variante -
- {/if} - - - {STYLE_LABELS[character.style].de} - - - {#if character.isFavorite} - - - - {/if} - - {#if !isPinned && variantCount > 0} - - Pin offen - - {/if} -
- -
-

{character.name}

-

- {variantCount} - {variantCount === 1 ? 'Variante' : 'Varianten'} -

-
-
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte deleted file mode 100644 index 9c322adbd..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterPicker.svelte +++ /dev/null @@ -1,155 +0,0 @@ - - - -
-
-

- {$_('comic.picker.section_title')} -

-
- -
- -
- {#if face?.publicUrl} -
- {$_('comic.picker.face_alt')} - - {$_('comic.picker.face_required_badge')} - -
- {:else} -
- - {$_('comic.picker.face_missing')} -
- {/if} - {$_('comic.picker.face_label')} -
- - -
- {#if body?.publicUrl} - - {:else} -
- - {$_('comic.picker.body_missing')} -
- {/if} - {$_('comic.picker.body_label')} -
-
- - {#if !hasFace} - - {:else if !hasBody} -

- - {$_('comic.picker.body_tip')} -

- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterRefPicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/CharacterRefPicker.svelte deleted file mode 100644 index 2defd322a..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/CharacterRefPicker.svelte +++ /dev/null @@ -1,233 +0,0 @@ - - - -
- - {#if usableCharacters.length > 0} -
- - -
- {/if} - - {#if mode === 'character'} -
-
-

- Comic-Character wählen -

-

- Iterier vorher einen Character mit deinem Stil — alle Panels nutzen dann denselben - gepinnten Look. -

-
- - {#if usableCharacters.length === 0} - {#if !hasFace} - - {:else} -
-

Noch keine Characters mit Pin.

-

- Bau einen Comic-Character aus deinem Foto — Stil wählen, 4 Varianten generieren, beste - pinnen. -

- - - Character bauen - -
- {/if} - {:else} -
- {#each usableCharacters as character (character.id)} - {@const isSelected = selectedCharacterId === character.id} - {@const cover$ = usePanelImage(character.pinnedVariantId ?? null)} - {@const cover = cover$.value} - - {/each} - - - - Neuer Character - -
- {/if} -
- {:else} - -
-
-

- Quick-Modus (Roh-Refs) -

-

- Direkt face-ref + optional body-ref + Garments aus dem Schrank — ohne Character-Iteration. - Konsistenz zwischen Panels schwächer. -

-
- -
- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte deleted file mode 100644 index 2457ade9d..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/PanelCard.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - -
-
- {#if image?.publicUrl} - Panel {panelIndex + 1} - {:else if image$.loading} -
- Lädt… -
- {:else} -
- Panel nicht gefunden -
- {/if} - - - #{panelIndex + 1} - - - {#if onRemove} - - {/if} -
- - {#if meta?.caption || meta?.dialogue} -
- {#if meta.caption} -

{meta.caption}

- {/if} - {#if meta.dialogue} -

„{meta.dialogue}"

- {/if} -
- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte deleted file mode 100644 index 2c1ad083d..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/PanelEditor.svelte +++ /dev/null @@ -1,281 +0,0 @@ - - - -
-
-
-

Neues Panel

-

- Panel {panelCount + 1} · nutzt {story.characterMediaIds.length} Referenz{story - .characterMediaIds.length === 1 - ? '' - : 'en'} -

-
- -
- - {#if atCap} - - {:else if warn} -

- Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz mit gpt-image-2 spürbar - schwerer. -

- {/if} - -
-
- - -
- -
-
- - -
-
- - -
-
- -

- Caption und Dialog werden direkt in das Bild gerendert. Englische Texte rendern stabiler als - deutsche, kurze Sätze funktionieren am besten. -

- - (model = m)} disabled={submitting} /> - -
-
- Qualität: - {#each QUALITIES as q (q)} - - {/each} -
-
- Format: - - -
-
- - {#if errorMsg} - - {/if} - -
- -
- -
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelModelPicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelModelPicker.svelte deleted file mode 100644 index d1540e085..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/PanelModelPicker.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - - -
- Modell -
- {#each OPTIONS as opt (opt.id)} - - {/each} -
-
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte deleted file mode 100644 index 668c70806..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/PanelStrip.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - -{#if panelImageIds.length === 0} -
-

Noch keine Panels.

-

- Klick unten auf + Panel, um die erste Szene zu - generieren. -

-
-{:else} -
- {#each panelImageIds as panelId, index (panelId)} - onRemove(panelId) : undefined} - /> - {/each} -
-{/if} diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/ReferenceInputPicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/ReferenceInputPicker.svelte deleted file mode 100644 index 7f6d51aba..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/ReferenceInputPicker.svelte +++ /dev/null @@ -1,236 +0,0 @@ - - - -
-
-

Quelle wählen

-

- Aus welchem Text soll die KI eine Panel-Folge bauen? Alles bleibt lokal — erst der - verschlüsselte Klartext wird an das Modell gesendet, nur für diesen einen Call. -

-
- - - -
- - -
- -
- {#if activeTab === 'journal'} - {#if journalFiltered.length === 0} -

- {journal.length === 0 - ? 'Noch keine Tagebuch-Einträge in diesem Space.' - : 'Keine Einträge passen zur Suche.'} -

- {:else} - {#each journalFiltered as entry (entry.id)} - - {/each} - {/if} - {:else if activeTab === 'notes'} - {#if notesFiltered.length === 0} -

- {notes.length === 0 - ? 'Noch keine Notizen in diesem Space.' - : 'Keine Notizen passen zur Suche.'} -

- {:else} - {#each notesFiltered as note (note.id)} - - {/each} - {/if} - {:else if activeTab === 'library'} - {#if libraryFiltered.length === 0} -

- {library.length === 0 - ? 'Noch keine Bibliotheks-Einträge in diesem Space.' - : 'Keine Einträge passen zur Suche.'} -

- {:else} - {#each libraryFiltered as entry (entry.id)} - {@const hasReview = entry.review && entry.review.trim().length > 0} - - {/each} - {/if} - {/if} -
-
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte deleted file mode 100644 index eef824a89..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/StoryCard.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - - - -
- {#if cover?.publicUrl} - {story.title} - {:else} -
- - Noch kein Panel -
- {/if} - - - - {STYLE_LABELS[story.style].de} - - - {#if story.isFavorite} - - - - {/if} -
- -
-

{story.title}

-

- {panelCount} - {panelCount === 1 ? 'Panel' : 'Panels'} -

-
-
diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte deleted file mode 100644 index 1ac68f2fa..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/StoryForm.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - - -
- -
- - -
- - -
-
Stil
- (style = next)} disabled={submitting} /> -

- Der Stil gilt für alle Panels der Geschichte. Wechsel ist später nicht möglich — dafür neue - Story anlegen. -

-
- - - { - characterId = nextId; - characterMediaIds = nextRefs; - }} - disabled={submitting} - /> - - -
- - -
- - {#if activeSpace && activeSpace.type !== 'personal'} -

- Diese Story gehört zu {activeSpace.name} — nur Mitglieder - dieses Space sehen sie. -

- {/if} - - {#if submitError} - - {/if} - -
- - - Abbrechen - -
- diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StoryboardSuggester.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StoryboardSuggester.svelte deleted file mode 100644 index ab9f69f54..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/StoryboardSuggester.svelte +++ /dev/null @@ -1,485 +0,0 @@ - - - -
-
-
-

- - Mit KI aus Text generieren -

-

- {#if step === 'pick-source'} - Schritt 1 · Quelle auswählen - {:else if step === 'generating-plan'} - Schritt 2 · Panels werden vorgeschlagen… - {:else if step === 'review-plan'} - Schritt 3 · Vorschläge prüfen und generieren - {:else} - Schritt 4 · Panels werden gerendert… - {/if} -

-
- -
- - {#if step === 'pick-source'} -
-
- - - - ({MIN_STORYBOARD_PANEL_COUNT}–{MAX_STORYBOARD_PANEL_COUNT}) - -
- - {#if planError} - - {/if} - - -
- {:else if step === 'generating-plan'} -
- -

- Das Modell denkt über deine {requestedCount} Panels nach… -

-
- {:else if step === 'review-plan' || step === 'rendering'} -
- {#if selection} -
-
-

- Quelle: {selection.label} -

-

{selection.module}

-
- {#if !renderBusy} - - {/if} -
- {/if} - - {#if warn && !renderBusy} -

- Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz spürbar schwerer. -

- {/if} - - {#if roomLeft < rows.length} - - {/if} - -
- {#each rows as row, index (row.id)} - {@const status = rowStatus[row.id]} - {@const overRoom = index >= roomLeft} -
-
-
- - {panelCount + index + 1} - - Panel {index + 1} - {#if status?.status === 'pending'} - - - Wird generiert… - - {:else if status?.status === 'ok'} - - - Fertig - - {:else if status?.status === 'error'} - - - Fehlgeschlagen - - {/if} -
-
- {#if status?.status === 'error'} - - {/if} - {#if rows.length > 1 && !renderBusy} - - {/if} -
-
- - - -
- - -
- - {#if status?.status === 'error' && status.error} - - {/if} -
- {/each} -
- - {#if !renderBusy} - - {/if} - - (model = m)} disabled={renderBusy} /> - -
-
- Qualität: - {#each QUALITIES as q (q)} - - {/each} -
-
- Format: - - -
-
- -
- -
-
- {/if} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte deleted file mode 100644 index cdf703843..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/StylePicker.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - - -
- {#each STYLE_ORDER as style (style)} - - {/each} -
- - diff --git a/apps/mana/apps/web/src/lib/modules/comic/components/VariantTile.svelte b/apps/mana/apps/web/src/lib/modules/comic/components/VariantTile.svelte deleted file mode 100644 index fd17c2070..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/components/VariantTile.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - - -
- {#if image?.publicUrl} - Variante {variantIndex + 1} - {:else if image$.loading} -
- Lädt… -
- {:else} -
- Variante nicht gefunden -
- {/if} - - - - #{variantIndex + 1} - - - - {#if isPinned} - - - - {/if} - - -
- {#if !isPinned} - - {:else} - Aktiv - {/if} - - {#if onRemove} - - {/if} -
-
diff --git a/apps/mana/apps/web/src/lib/modules/comic/constants.ts b/apps/mana/apps/web/src/lib/modules/comic/constants.ts deleted file mode 100644 index a3bc13d20..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/constants.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Comic module — labels, caps, defaults. - */ - -import type { ComicStyle } from './types'; - -export const STYLE_LABELS: Record = { - comic: { de: 'US-Comic', en: 'US-Comic' }, - manga: { de: 'Manga', en: 'Manga' }, - cartoon: { de: 'Cartoon', en: 'Cartoon' }, - 'graphic-novel': { de: 'Graphic Novel', en: 'Graphic Novel' }, - webtoon: { de: 'Webtoon', en: 'Webtoon' }, -}; - -export const STYLE_ORDER: readonly ComicStyle[] = [ - 'comic', - 'manga', - 'cartoon', - 'graphic-novel', - 'webtoon', -] as const; - -/** - * Hard client-side cap on panels per story. gpt-image-2 consistency - * degrades beyond ~8–10 panels even with identical refs; 12 is the - * "long comic" ceiling before restyling. UI warns softly ≥ 8. Plan - * offene-frage #1. - */ -export const MAX_PANELS_PER_STORY = 12; -export const PANEL_COUNT_WARN_THRESHOLD = 8; - -/** - * Default panel count the AI-Storyboard flow (M4) asks Claude to - * generate when no explicit number is chosen. Slider range 2–8 in UI. - */ -export const DEFAULT_STORYBOARD_PANEL_COUNT = 4; -export const MIN_STORYBOARD_PANEL_COUNT = 2; -export const MAX_STORYBOARD_PANEL_COUNT = 8; diff --git a/apps/mana/apps/web/src/lib/modules/comic/index.ts b/apps/mana/apps/web/src/lib/modules/comic/index.ts deleted file mode 100644 index 47472869d..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Comic module — public surface. - * - * Plan: docs/plans/comic-module.md. M1 ships the datenschicht only - * (types, collections, queries, stores, module registration). UI + - * generate-flow follows in M2. - */ - -export * from './types'; -export { comicStoriesTable, comicCharactersTable } from './collections'; -export { comicStoriesStore } from './stores/stories.svelte'; -export { comicCharactersStore } from './stores/characters.svelte'; -export { - useAllStories, - useStoriesByStyle, - useStory, - useStoryPanels, - useStoriesByInput, - usePanelImage, - useAllCharacters, - useCharactersByStyle, - useCharacter, -} from './queries'; -export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants'; -export { STYLE_PREFIXES, composePanelPrompt } from './styles'; diff --git a/apps/mana/apps/web/src/lib/modules/comic/module.config.ts b/apps/mana/apps/web/src/lib/modules/comic/module.config.ts deleted file mode 100644 index 841201d32..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/module.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ModuleConfig } from '$lib/data/module-registry'; - -export const comicModuleConfig: ModuleConfig = { - appId: 'comic', - tables: [{ name: 'comicStories' }, { name: 'comicCharacters' }], -}; diff --git a/apps/mana/apps/web/src/lib/modules/comic/queries.ts b/apps/mana/apps/web/src/lib/modules/comic/queries.ts deleted file mode 100644 index bda3cd68f..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/queries.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Comic module — read-side queries. - * - * Stories are space-scoped: switching the active space swaps the - * visible pool automatically via `scopedForModule`. Panel history - * lives in `picture.images` filtered by `comicStoryId` — kept on the - * picture side rather than here (decision #1 in the plan: one table - * in this module, panels are picture rows). - */ - -import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; -import { scopedForModule } from '$lib/data/scope'; -import { decryptRecords } from '$lib/data/crypto'; -import type { LocalImage, Image } from '$lib/modules/picture/types'; -import { toImage } from '$lib/modules/picture/queries'; -import { - toStory, - toCharacter, - type ComicStory, - type ComicStyle, - type ComicCharacter, - type LocalComicStory, - type LocalComicCharacter, -} from './types'; - -/** All non-archived, non-deleted stories in the active space, newest first. */ -export function useAllStories() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule( - 'comic', - 'comicStories' - ).toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('comicStories', visible); - return decrypted.map(toStory); - }, [] as ComicStory[]); -} - -/** Stories filtered by style — used by the style-tabs view in M5 list tool. */ -export function useStoriesByStyle(style: ComicStyle) { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('comic', 'comicStories') - .and((row) => row.style === style) - .toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('comicStories', visible); - return decrypted.map(toStory); - }, [] as ComicStory[]); -} - -/** - * Load a single picture.images row by id — used for panel rendering - * (cover on StoryCard, thumbnails on PanelStrip, full-size on - * PanelCard). Lives here (not in picture/queries) because it's - * comic-specific convenience; picture's own queries don't need a - * single-image hook today. - */ -export function usePanelImage(imageId: string | null) { - return useScopedLiveQuery(async () => { - if (!imageId) return null; - const locals = await scopedForModule('picture', 'images') - .and((row) => row.id === imageId) - .toArray(); - const [local] = locals; - if (!local || local.deletedAt) return null; - const [decrypted] = await decryptRecords('images', [local]); - return toImage(decrypted); - }, null); -} - -/** A single story by id, live-updating. Null while loading / missing. */ -export function useStory(id: string | null) { - return useScopedLiveQuery(async () => { - if (!id) return null; - const locals = await scopedForModule('comic', 'comicStories') - .and((row) => row.id === id) - .toArray(); - const [local] = locals; - if (!local || local.deletedAt) return null; - const [decrypted] = await decryptRecords('comicStories', [local]); - return toStory(decrypted); - }, null); -} - -/** - * Every panel rendered for a story, newest first. Pulls from - * `picture.images` filtered by `comicStoryId`. Typically the Detail- - * View uses `story.panelImageIds` directly for ordered rendering; this - * query is for gallery-style "all renders across regenerations" views - * where users want to see panels that were dropped from the story's - * ordered list but not deleted. - */ -export function useStoryPanels(storyId: string | null) { - return useScopedLiveQuery(async () => { - if (!storyId) return []; - const locals = await scopedForModule('picture', 'images') - .and((row) => row.comicStoryId === storyId) - .toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('images', visible); - return decrypted.map(toImage); - }, [] as Image[]); -} - -// ─── Characters ────────────────────────────────────────────────── - -/** All non-archived, non-deleted comic-characters in the active space, - * newest first. Characters travel with their source meImages, so they're - * space-scoped. */ -export function useAllCharacters() { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule( - 'comic', - 'comicCharacters' - ).toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('comicCharacters', visible); - return decrypted.map(toCharacter); - }, [] as ComicCharacter[]); -} - -/** Characters filtered by style — used by style-tabs in the picker. */ -export function useCharactersByStyle(style: ComicStyle) { - return useScopedLiveQuery(async () => { - const locals = await scopedForModule('comic', 'comicCharacters') - .and((row) => row.style === style) - .toArray(); - const visible = locals - .filter((row) => !row.deletedAt && !row.isArchived) - .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); - const decrypted = await decryptRecords('comicCharacters', visible); - return decrypted.map(toCharacter); - }, [] as ComicCharacter[]); -} - -/** A single character by id, live-updating. Null while loading / missing. */ -export function useCharacter(id: string | null) { - return useScopedLiveQuery(async () => { - if (!id) return null; - const locals = await scopedForModule('comic', 'comicCharacters') - .and((row) => row.id === id) - .toArray(); - const [local] = locals; - if (!local || local.deletedAt) return null; - const [decrypted] = await decryptRecords('comicCharacters', [local]); - return toCharacter(decrypted); - }, null); -} - -/** - * Stories that were seeded by a given module entry (M4 AI-Storyboard - * back-reference). Matches when *any* panel in the story has a - * `panelMeta[id].sourceInput` pointing at the given {module, entryId}. - * Used for the "Comics zu diesem Journal-Eintrag" cross-reference - * widget that renders on module detail pages. - */ -export function useStoriesByInput( - module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar' | null, - entryId: string | null -) { - return useScopedLiveQuery(async () => { - if (!module || !entryId) return []; - const locals = await scopedForModule( - 'comic', - 'comicStories' - ).toArray(); - const visible = locals.filter((row) => !row.deletedAt && !row.isArchived); - const decrypted = await decryptRecords('comicStories', visible); - const stories = decrypted.map(toStory); - return stories.filter((s) => { - const metas = Object.values(s.panelMeta); - return metas.some( - (meta) => meta.sourceInput?.module === module && meta.sourceInput.entryId === entryId - ); - }); - }, [] as ComicStory[]); -} diff --git a/apps/mana/apps/web/src/lib/modules/comic/stores/characters.svelte.ts b/apps/mana/apps/web/src/lib/modules/comic/stores/characters.svelte.ts deleted file mode 100644 index 1fe394dbc..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/stores/characters.svelte.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Comic-Characters store — mutation-only service. - * - * A character holds an unbounded `variantMediaIds: string[]` of - * generated picture.images-rows plus a `pinnedVariantId` that - * picks one as the canonical look. Variant generation itself - * lives in `api/generate-character.ts` (Mc2.1) — this store only - * mutates the row. - */ - -import { encryptRecord } from '$lib/data/crypto'; -import { emitDomainEvent } from '$lib/data/events'; -import { comicCharactersTable } from '../collections'; -import { toCharacter } from '../types'; -import type { ComicCharacter, ComicStyle, LocalComicCharacter } from '../types'; - -export interface CreateCharacterInput { - name: string; - style: ComicStyle; - sourceFaceMediaId: string; - sourceBodyMediaId?: string | null; - description?: string | null; - addPrompt?: string | null; - tags?: string[]; -} - -export const comicCharactersStore = { - /** - * Create a fresh character row WITHOUT any variants yet — the - * builder calls this first to obtain an id, then runs N variant - * generations and pushes each through `appendVariant`. The user - * pins one once they're happy. - */ - async createCharacter(input: CreateCharacterInput): Promise { - const trimmedName = input.name.trim(); - if (!trimmedName) { - throw new Error('Character braucht einen Namen'); - } - if (!input.sourceFaceMediaId) { - throw new Error('Character braucht ein Face-Bild als Quelle'); - } - // Spread incoming arrays to break any Svelte 5 $state proxies - // the form might pass through. Same defense as comicStoriesStore. - const newLocal: LocalComicCharacter = { - id: crypto.randomUUID(), - name: trimmedName, - description: input.description ?? null, - style: input.style, - addPrompt: input.addPrompt ?? null, - sourceFaceMediaId: input.sourceFaceMediaId, - sourceBodyMediaId: input.sourceBodyMediaId ?? null, - variantMediaIds: [], - pinnedVariantId: null, - tags: input.tags ? [...input.tags] : [], - isFavorite: false, - }; - const snapshot = toCharacter({ ...newLocal }); - await encryptRecord('comicCharacters', newLocal); - await comicCharactersTable.add(newLocal); - emitDomainEvent('ComicCharacterCreated', 'comic', 'comicCharacters', newLocal.id, { - characterId: newLocal.id, - style: input.style, - }); - return snapshot; - }, - - /** - * Append a freshly generated variant to the character's variant - * list. Called by the builder after each gpt-image-2 / Nano Banana - * call lands a picture.images row. The first variant auto-pins - * (build-in-progress fallback) so the character has a cover even - * before the user explicitly chooses. - */ - async appendVariant(characterId: string, variantMediaId: string): Promise { - const existing = await comicCharactersTable.get(characterId); - if (!existing) throw new Error(`Character ${characterId} not found`); - const nextIds = [...(existing.variantMediaIds ?? []), variantMediaId]; - const patch: Partial = { - variantMediaIds: nextIds, - }; - // Auto-pin the first variant so the cover isn't blank during - // build. User can re-pin afterwards. - if (!existing.pinnedVariantId) { - patch.pinnedVariantId = variantMediaId; - } - await comicCharactersTable.update(characterId, patch); - emitDomainEvent('ComicCharacterVariantAdded', 'comic', 'comicCharacters', characterId, { - characterId, - variantMediaId, - variantIndex: nextIds.length - 1, - }); - }, - - /** Pin a different variant as the canonical look. Stories generated - * AFTER the re-pin get the new variant; existing stories are - * unchanged because they snapshot the mediaId at story-create. */ - async pinVariant(characterId: string, variantMediaId: string): Promise { - const existing = await comicCharactersTable.get(characterId); - if (!existing) throw new Error(`Character ${characterId} not found`); - if (!(existing.variantMediaIds ?? []).includes(variantMediaId)) { - throw new Error(`Variant ${variantMediaId} not in this character`); - } - await comicCharactersTable.update(characterId, { - pinnedVariantId: variantMediaId, - }); - emitDomainEvent('ComicCharacterVariantPinned', 'comic', 'comicCharacters', characterId, { - characterId, - variantMediaId, - }); - }, - - /** Remove a variant from the character's pool. Doesn't touch the - * underlying picture.images-row (user can keep the render in their - * Picture gallery). If the removed variant was pinned, falls back - * to the first remaining variant; if none remain, pin = null. */ - async removeVariant(characterId: string, variantMediaId: string): Promise { - const existing = await comicCharactersTable.get(characterId); - if (!existing) return; - const nextIds = (existing.variantMediaIds ?? []).filter((id) => id !== variantMediaId); - const patch: Partial = { - variantMediaIds: nextIds, - }; - if (existing.pinnedVariantId === variantMediaId) { - patch.pinnedVariantId = nextIds[0] ?? null; - } - await comicCharactersTable.update(characterId, patch); - }, - - async updateCharacter( - id: string, - patch: Partial> - ): Promise { - const wrapped: Partial = { ...patch }; - if (Array.isArray(wrapped.tags)) { - wrapped.tags = [...wrapped.tags]; - } - await encryptRecord('comicCharacters', wrapped); - await comicCharactersTable.update(id, wrapped); - }, - - async toggleFavorite(id: string): Promise { - const existing = await comicCharactersTable.get(id); - if (!existing) return; - await comicCharactersTable.update(id, { - isFavorite: !existing.isFavorite, - }); - }, - - async archiveCharacter(id: string, archived: boolean): Promise { - await comicCharactersTable.update(id, { - isArchived: archived, - }); - }, - - async deleteCharacter(id: string): Promise { - const nowIso = new Date().toISOString(); - await comicCharactersTable.update(id, { - deletedAt: nowIso, - }); - emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, { - characterId: id, - }); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/comic/stores/stories.svelte.ts b/apps/mana/apps/web/src/lib/modules/comic/stores/stories.svelte.ts deleted file mode 100644 index 14abe6bbe..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/stores/stories.svelte.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Comic stories store — mutation-only service. - * - * A story holds an ordered `panelImageIds: string[]` plus a - * `panelMeta` record keyed by panel id. Panel mutations (append, - * reorder, remove, updateMeta) are the M2+ shape; M1 covers the - * shell: create/update/archive/delete/setVisibility. - */ - -import { encryptRecord } from '$lib/data/crypto'; -import { emitDomainEvent } from '$lib/data/events'; -import { getActiveSpace } from '$lib/data/scope'; -import { getEffectiveUserId } from '$lib/data/current-user'; -import { - defaultVisibilityFor, - generateUnlistedToken, - type VisibilityLevel, -} from '@mana/shared-privacy'; -import { comicStoriesTable } from '../collections'; -import { toStory } from '../types'; -import type { ComicPanelMeta, ComicStory, ComicStyle, LocalComicStory } from '../types'; - -export interface CreateStoryInput { - title: string; - style: ComicStyle; - characterMediaIds: string[]; - /** When the story is bound to a comicCharacter (Character-Mode), the - * FK lands here for display + cross-ref. Quick-Mode stories pass - * `null` and only fill `characterMediaIds` with raw face/body/garments. */ - characterId?: string | null; - description?: string | null; - storyContext?: string | null; - tags?: string[]; - isFavorite?: boolean; -} - -export const comicStoriesStore = { - async createStory(input: CreateStoryInput): Promise { - if (input.characterMediaIds.length === 0) { - throw new Error('Story needs at least one character reference image'); - } - // Spread incoming arrays to break Svelte 5 $state proxies — the - // caller (StoryForm) declares `characterMediaIds`/`tags` as - // `$state([])` and passes them directly. IndexedDB's - // structured-clone refuses to clone proxies, so without this - // `comicStoriesTable.add(...)` throws DataCloneError. - const newLocal: LocalComicStory = { - id: crypto.randomUUID(), - title: input.title, - description: input.description ?? null, - style: input.style, - characterId: input.characterId ?? null, - characterMediaIds: [...input.characterMediaIds], - storyContext: input.storyContext ?? null, - panelImageIds: [], - panelMeta: {}, - tags: input.tags ? [...input.tags] : [], - isFavorite: input.isFavorite ?? false, - visibility: defaultVisibilityFor(getActiveSpace()?.type), - }; - const snapshot = toStory({ ...newLocal }); - await encryptRecord('comicStories', newLocal); - await comicStoriesTable.add(newLocal); - emitDomainEvent('ComicStoryCreated', 'comic', 'comicStories', newLocal.id, { - storyId: newLocal.id, - style: input.style, - }); - return snapshot; - }, - - async updateStory( - id: string, - patch: Partial< - Pick - > - ): Promise { - // Same proxy-breaking copy as createStory: any array on the patch - // might be a $state proxy if the caller is a Svelte 5 component. - const wrapped: Partial = { ...patch }; - if (Array.isArray(wrapped.characterMediaIds)) { - wrapped.characterMediaIds = [...wrapped.characterMediaIds]; - } - if (Array.isArray(wrapped.tags)) { - wrapped.tags = [...wrapped.tags]; - } - await encryptRecord('comicStories', wrapped); - await comicStoriesTable.update(id, wrapped); - }, - - async toggleFavorite(id: string): Promise { - const existing = await comicStoriesTable.get(id); - if (!existing) return; - await comicStoriesTable.update(id, { - isFavorite: !existing.isFavorite, - }); - }, - - async archiveStory(id: string, archived: boolean): Promise { - await comicStoriesTable.update(id, { - isArchived: archived, - }); - }, - - async deleteStory(id: string): Promise { - const nowIso = new Date().toISOString(); - await comicStoriesTable.update(id, { - deletedAt: nowIso, - }); - emitDomainEvent('ComicStoryDeleted', 'comic', 'comicStories', id, { - storyId: id, - }); - }, - - /** - * Flip a story's visibility. Comics are a natural share-surface - * (4-panel jokes, work-anecdotes) — marking a story `public` makes - * it eligible for `/embed/comic/:id` in M5. - */ - async setVisibility(id: string, next: VisibilityLevel): Promise { - const existing = await comicStoriesTable.get(id); - if (!existing) throw new Error(`Comic story ${id} not found`); - const before: VisibilityLevel = existing.visibility ?? 'space'; - if (before === next) return; - - const now = new Date().toISOString(); - const patch: Partial = { - visibility: next, - visibilityChangedAt: now, - visibilityChangedBy: getEffectiveUserId(), - }; - if (next === 'unlisted' && !existing.unlistedToken) { - patch.unlistedToken = generateUnlistedToken(); - } else if (next !== 'unlisted' && existing.unlistedToken) { - patch.unlistedToken = undefined; - } - await comicStoriesTable.update(id, patch); - - emitDomainEvent('VisibilityChanged', 'comic', 'comicStories', id, { - recordId: id, - collection: 'comicStories', - before, - after: next, - }); - }, - - /** - * Append a freshly generated panel to the end of the story. Called - * by `runPanelGenerate` (M2) right after `picture.images` lands the - * new row. `meta` carries the prompt used + optional caption / - * dialogue / sourceInput. - * - * Re-encrypts the whole panelMeta Record because it's one JSON - * blob in the registry — we can't partially update individual keys - * without decrypting first. - */ - async appendPanel(storyId: string, panelImageId: string, meta: ComicPanelMeta): Promise { - const existing = await comicStoriesTable.get(storyId); - if (!existing) throw new Error(`Comic story ${storyId} not found`); - const nextIds = [...(existing.panelImageIds ?? []), panelImageId]; - const nextMeta = { ...(existing.panelMeta ?? {}), [panelImageId]: meta }; - const patch = { - panelImageIds: nextIds, - panelMeta: nextMeta, - } as Partial; - await encryptRecord('comicStories', patch); - await comicStoriesTable.update(storyId, patch); - emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, { - storyId, - panelImageId, - panelIndex: nextIds.length - 1, - }); - }, -}; diff --git a/apps/mana/apps/web/src/lib/modules/comic/styles.ts b/apps/mana/apps/web/src/lib/modules/comic/styles.ts deleted file mode 100644 index a82df8601..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/styles.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Prompt-prefix templates per visual style. The prefix is prepended to - * every panel prompt in `runPanelGenerate` (M2); gpt-image-2 sees the - * composite (stylePrefix + panelPrompt + captionHint + dialogueHint), - * never the enum itself. Keep prefixes short and directive — they're - * spent on every call. - * - * Adding a style = extending `ComicStyle` in types.ts + `STYLE_LABELS` - * in constants.ts + a prefix here. The three stay in lockstep because - * Record forces exhaustive coverage. - */ - -import type { ComicStyle } from './types'; - -export const STYLE_PREFIXES: Record = { - comic: - 'US comic book illustration, bold clean linework, vivid cell-shaded coloring, dramatic lighting, high contrast, comic-panel framing', - manga: - 'Japanese manga illustration, black and white line art with screen tones, dynamic perspective, expressive character design, dramatic motion lines', - cartoon: - 'soft pastel cartoon illustration, rounded friendly shapes, warm saturated colors, Saturday-morning animation style, simple clean backgrounds', - 'graphic-novel': - 'graphic novel illustration, painterly watercolor style, muted atmospheric palette, cinematic composition, moody naturalistic lighting', - webtoon: - 'modern webtoon illustration, clean vertical-scroll framing, bright saturated colors, soft cel-shading, expressive character close-ups', -}; - -/** - * Compose the final gpt-image-2 prompt for a single panel. Caption and - * dialogue (both optional) are rendered directly into the image by - * gpt-image-2 — no SVG overlay. Decision #4 in docs/plans/comic-module.md. - * - * The text-rendering language is whatever the user typed (gpt-image-2 - * handles multiple languages, English is most stable but German works - * for short strings). UI surfaces an English-preferred hint. - */ -export function composePanelPrompt(input: { - style: ComicStyle; - panelPrompt: string; - caption?: string; - dialogue?: string; -}): string { - const parts: string[] = [STYLE_PREFIXES[input.style], input.panelPrompt.trim()]; - const caption = input.caption?.trim(); - const dialogue = input.dialogue?.trim(); - if (caption) { - parts.push(`narration caption at the top reading: "${caption}"`); - } - if (dialogue) { - parts.push(`character speaking in a speech bubble saying: "${dialogue}"`); - } - return parts.join('. '); -} diff --git a/apps/mana/apps/web/src/lib/modules/comic/tools.ts b/apps/mana/apps/web/src/lib/modules/comic/tools.ts deleted file mode 100644 index 82392ec50..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/tools.ts +++ /dev/null @@ -1,642 +0,0 @@ -/** - * Comic module tools — AI-accessible operations over comic stories. - * - * Auto (read-only): - * - list_comic_stories - * - * Propose (human approval per the agent's policy — generate burns - * credits so it's never auto): - * - create_comic_story - * - generate_comic_panel - * - * Character references (face-ref + optional body-ref) resolve - * automatically from the active space's primary meImages — the AI - * caller doesn't have to know about mediaIds. That's a deliberate - * simplification versus the MCP layer (packages/mana-tool-registry/ - * src/modules/comic.ts) which accepts an explicit `characterMediaIds` - * array; the webapp-runner pattern is "compose for the user, then - * propose", and forcing the planner to list mediaIds before creating - * a story was friction with no upside. - * - * Panel rendering delegates to the existing `runPanelGenerate` from - * api/generate-panel.ts, which is the same code path the DetailView's - * PanelEditor uses — so the encryption + picture.images insertion + - * story appendPanel happen exactly once regardless of whether the - * user or the agent triggered it. - */ - -import type { ModuleTool } from '$lib/data/tools/types'; -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 { 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', - 'google/gemini-3-pro-image-preview', - 'google/gemini-3.1-flash-image-preview', -] as const; - -function isValidModel(v: unknown): v is PanelModel { - return typeof v === 'string' && (VALID_MODELS as readonly string[]).includes(v); -} -import type { LocalMeImage } from '$lib/modules/profile/types'; -import { getActiveSpace } from '$lib/data/scope'; - -const VALID_STYLES: ComicStyle[] = ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon']; - -function isValidStyle(v: unknown): v is ComicStyle { - return typeof v === 'string' && (VALID_STYLES as string[]).includes(v); -} - -/** - * Resolve the active space's primary face-ref (+ optional body-ref) to - * a mediaId array suitable for `characterMediaIds`. Non-reactive — we - * scan meImagesTable directly instead of going through the svelte - * `useImageByPrimary` hook because tools run outside the Svelte - * reactivity graph. - */ -async function resolveCharacterMediaIds(): Promise<{ - mediaIds: string[]; - faceRef: string | null; - bodyRef: string | null; -}> { - const space = getActiveSpace(); - if (!space) return { mediaIds: [], faceRef: null, bodyRef: null }; - const all = await meImagesTable.toArray(); - const inSpace = all.filter((m) => !m.deletedAt && m.spaceId === space.id); - const face = inSpace.find((m) => m.primaryFor === 'face-ref') ?? null; - const body = inSpace.find((m) => m.primaryFor === 'body-ref') ?? null; - const mediaIds: string[] = []; - if (face?.mediaId) mediaIds.push(face.mediaId); - if (body?.mediaId) mediaIds.push(body.mediaId); - return { mediaIds, faceRef: face?.mediaId ?? null, bodyRef: body?.mediaId ?? null }; -} - -export const comicTools: ModuleTool[] = [ - { - name: 'list_comic_stories', - module: 'comic', - description: - 'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, 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', - 'comicStories' - ).toArray(); - const visible = locals.filter((s) => !s.deletedAt && !s.isArchived); - const decrypted = await decryptRecords('comicStories', visible); - const rows = decrypted - .map(toStory) - .filter((s) => (styleFilter ? s.style === styleFilter : true)) - .filter((s) => (favoriteOnly ? s.isFavorite === true : true)) - .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) - .slice(0, limit) - .map((s) => ({ - id: s.id, - title: s.title, - style: s.style, - panelCount: s.panelImageIds.length, - isFavorite: s.isFavorite === true, - description: s.description ?? null, - storyContext: s.storyContext ?? null, - })); - - return { - success: true, - data: { stories: rows, total: rows.length }, - message: `${rows.length} Stor${rows.length === 1 ? 'y' : 'ies'} gelistet`, - }; - } catch (err) { - if (err instanceof VaultLockedError) { - return { - success: false, - message: 'Vault ist gesperrt — Comic-Stories können nicht entschlüsselt werden', - }; - } - throw err; - } - }, - }, - - { - name: 'create_comic_story', - module: 'comic', - description: - 'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.', - parameters: [ - { name: 'title', type: 'string', description: 'Titel der Story', required: true }, - { - name: 'style', - type: 'string', - description: 'Visueller Stil', - required: true, - enum: [...VALID_STYLES], - }, - { - name: 'description', - type: 'string', - description: 'Kurze Story-Beschreibung', - required: false, - }, - { - name: 'storyContext', - type: 'string', - description: 'Freitext-Briefing — Ton, Ziel, Hintergrund', - required: false, - }, - { - name: 'tags', - type: 'string', - description: 'Tags durch Komma getrennt', - required: false, - }, - ], - async execute(params) { - const title = String(params.title ?? '').trim(); - if (!title) return { success: false, message: 'title 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.mediaIds.length === 0) { - 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 storyContext = - typeof params.storyContext === 'string' && params.storyContext.trim() - ? params.storyContext.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 story = await comicStoriesStore.createStory({ - title, - style, - characterMediaIds: refs.mediaIds, - description, - storyContext, - tags, - }); - return { - success: true, - data: { - id: story.id, - title: story.title, - style: story.style, - characterRefCount: refs.mediaIds.length, - hasBodyRef: refs.bodyRef !== null, - }, - message: `Story "${story.title}" angelegt (Stil: ${story.style})`, - }; - } catch (err) { - if (err instanceof VaultLockedError) { - return { success: false, message: 'Vault ist gesperrt — Story nicht gespeichert' }; - } - throw err; - } - }, - }, - - { - name: 'generate_comic_panel', - module: 'comic', - description: - 'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.', - parameters: [ - { name: 'storyId', type: 'string', description: 'ID der Story', required: true }, - { - name: 'panelPrompt', - type: 'string', - description: - 'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.', - required: true, - }, - { - name: 'caption', - type: 'string', - description: 'Erzaehl-Zeile ueber/unter dem Bild', - required: false, - }, - { - name: 'dialogue', - type: 'string', - description: 'Sprechblasen-Text', - 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). Alternativen: google/gemini-3-pro-image-preview (Nano Banana Pro), google/gemini-3.1-flash-image-preview (Nano Banana 2).', - required: false, - enum: [...VALID_MODELS], - }, - ], - async execute(params) { - const storyId = String(params.storyId ?? '').trim(); - if (!storyId) return { success: false, message: 'storyId erforderlich' }; - - const panelPrompt = String(params.panelPrompt ?? '').trim(); - if (!panelPrompt) return { success: false, message: 'panelPrompt erforderlich' }; - - const caption = - typeof params.caption === 'string' && params.caption.trim() - ? params.caption.trim() - : undefined; - const dialogue = - typeof params.dialogue === 'string' && params.dialogue.trim() - ? params.dialogue.trim() - : undefined; - const quality = - params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium'; - const model = isValidModel(params.model) ? params.model : DEFAULT_PANEL_MODEL; - - try { - // Load the story for runPanelGenerate — same code path as the - // PanelEditor in the web UI. - const locals = await scopedForModule('comic', 'comicStories') - .and((s) => s.id === storyId) - .toArray(); - const [local] = locals; - if (!local || local.deletedAt) { - return { success: false, message: `Story ${storyId} nicht gefunden` }; - } - const [decrypted] = await decryptRecords('comicStories', [local]); - if (!decrypted) { - return { success: false, message: 'Entschlüsselung der Story fehlgeschlagen' }; - } - const story = toStory(decrypted); - - const result = await runPanelGenerate({ - story, - panelPrompt, - caption, - dialogue, - quality: quality as 'low' | 'medium' | 'high', - model, - }); - - return { - success: true, - data: { - imageId: result.imageId, - imageUrl: result.imageUrl, - panelIndex: result.panelIndex, - model: result.model, - }, - message: `Panel ${result.panelIndex + 1} für Story "${story.title}" generiert`, - }; - } catch (err) { - if (err instanceof VaultLockedError) { - return { success: false, message: 'Vault ist gesperrt — Panel nicht angehängt' }; - } - return { - success: false, - message: err instanceof Error ? err.message : 'Panel-Generierung fehlgeschlagen', - }; - } - }, - }, -]; - -// Imported for side-effect types — keeps unused-import warnings quiet -// 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/apps/mana/apps/web/src/lib/modules/comic/types.ts b/apps/mana/apps/web/src/lib/modules/comic/types.ts deleted file mode 100644 index 99de2bd2f..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/types.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Comic module types — one table: - * - * - `comicStories`: a comic story with title, style, fixed character - * reference list, and an ordered `panelImageIds: string[]` pointing - * at `picture.images` rows generated via the reference-edit flow. - * - * Panels themselves live in `picture.images` with `comicStoryId` + - * `comicPanelIndex` plaintext back-refs — see apps/mana/apps/web/src/ - * lib/modules/picture/types.ts. No second table in this module. - * - * Plan: docs/plans/comic-module.md. - */ - -import type { BaseRecord } from '@mana/local-store'; -import { deriveUpdatedAt } from '$lib/data/sync'; -import type { VisibilityLevel } from '@mana/shared-privacy'; - -// ─── Style ──────────────────────────────────────────────────────── - -/** - * Closed enum of five visual presets. Each preset is mapped to a - * prompt-prefix template in `styles.ts`; the backend never sees the - * enum, only the final composed prompt. Chosen at story-create time - * and fixed — restyling = new story (or regenerate panels one by one). - */ -export type ComicStyle = - | 'comic' // US-Comic: Linework + Cell-Shading, kräftige Farben - | 'manga' // Schwarz/weiß, Screen-Tones, dynamische Perspektiven - | 'cartoon' // Weich, pastellig, Saturday-Morning-Cartoon - | 'graphic-novel' // Realistischer, Aquarell/Painterly, stimmungsvoll - | 'webtoon'; // Vertikal-lesbar, moderne Farbpalette, Soft-Shading - -// ─── Panel-Meta ─────────────────────────────────────────────────── - -/** - * Per-panel sidecar data that sits on the story (keyed by the panel's - * `picture.images.id`). The image itself carries only the rendered - * pixels + structural fields; everything that describes *why* the - * panel exists — user caption, dialogue text, the exact prompt used, - * and optional Cross-Module source ref — lives here. - * - * Whole object is encrypted as one JSON blob via the encryption - * registry (same pattern as food.foods / recipes.ingredients). - */ -export interface ComicPanelMeta { - caption?: string; - dialogue?: string; - /** The final prompt passed to gpt-image-2, stored so a user can - * regenerate or tweak without retyping. */ - promptUsed?: string; - /** Which module-entry, if any, seeded this panel in the AI-Storyboard - * flow (M4). Lets `useStoriesByInput` answer "which comics did I - * make from this journal entry?". Plaintext FKs inside the - * encrypted blob. */ - sourceInput?: { - module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar'; - entryId: string; - }; -} - -// ─── Story ──────────────────────────────────────────────────────── - -export interface LocalComicStory extends BaseRecord { - id: string; - title: string; - description?: string | null; - style: ComicStyle; - /** - * FK to the comicCharacter that drives this story (Character-Mode, - * Mc3+). Plaintext — used for "Charakter: "-Anzeige im - * DetailView und für `useStoriesByCharacter`-Cross-Refs. - * - * `null` when the story was created in **Quick-Mode** (rohes - * face/body/garments-Setup ohne Character-Iteration). Beide Modi - * funktionieren parallel — die Story hängt am `characterMediaIds`- - * Array für die eigentliche Render-Logik (Snapshot-Pattern). - */ - characterId?: string | null; - /** - * Reference-image IDs passed unchanged to every panel-generate call. - * Character-Mode: enthält genau die `pinnedVariantMediaId` des - * referenzierten comicCharacters zum Story-Create-Zeitpunkt - * (Snapshot — Re-Pinning ändert das nicht rückwirkend). - * Quick-Mode: enthält face-ref + optional body-ref + Wardrobe- - * Garment-Photos. - * Capped at 8 by the backend (MAX_REFERENCE_IMAGES in the /picture/ - * generate-with-reference endpoint). - */ - characterMediaIds: string[]; - /** - * Free-text briefing the author writes once, surfaced in the - * AI-Storyboard flow (M4) as context Claude sees before suggesting - * panel descriptions. Typical: 1–3 sentences ("Ich ärgere mich über - * einen Bug in unserer Sync-Logik — mach daraus einen 4-Panel- - * Frust-Comic."). - */ - storyContext?: string | null; - /** - * Ordered list of `picture.images.id` — the reading order of the - * comic. Reorder = rewrite this array. Length implicitly bounded - * by `MAX_PANELS_PER_STORY` at the UI layer; the type doesn't - * enforce it. - */ - panelImageIds: string[]; - /** Keyed by panel image id. Encrypted as a whole JSON blob. */ - panelMeta: Record; - tags: string[]; - isFavorite?: boolean; - isArchived?: boolean; - visibility?: VisibilityLevel; - visibilityChangedAt?: string; - visibilityChangedBy?: string; - unlistedToken?: string; -} - -export interface ComicStory { - id: string; - title: string; - description?: string; - style: ComicStyle; - /** FK to the comic-character driving this story; undefined in Quick-Mode. */ - characterId?: string; - characterMediaIds: string[]; - storyContext?: string; - panelImageIds: string[]; - panelMeta: Record; - tags: string[]; - isFavorite?: boolean; - isArchived?: boolean; - visibility: VisibilityLevel; - createdAt: string; - updatedAt: string; -} - -export function toStory(local: LocalComicStory): ComicStory { - return { - id: local.id, - title: local.title, - description: local.description ?? undefined, - style: local.style, - characterId: local.characterId ?? undefined, - characterMediaIds: local.characterMediaIds ?? [], - storyContext: local.storyContext ?? undefined, - panelImageIds: local.panelImageIds ?? [], - panelMeta: local.panelMeta ?? {}, - tags: local.tags ?? [], - isFavorite: local.isFavorite, - isArchived: local.isArchived, - visibility: local.visibility ?? 'space', - createdAt: local.createdAt ?? '', - updatedAt: deriveUpdatedAt(local), - }; -} - -/** Thumbnail / cover panel for a story. `null` for stories without any - * generated panel yet (they render a placeholder in StoryCard). */ -export function storyCoverPanelId(story: Pick): string | null { - return story.panelImageIds[0] ?? null; -} - -// ─── Character ──────────────────────────────────────────────────── - -/** - * A reusable comic-style stand-in for the user. Generated once, refined - * across N variant renders (gpt-image-2 / Nano Banana edits over the - * raw face/body meImages with a style-prefix), and pinned to one - * variant that becomes the character's canonical look. Stories then - * reference the pinned variant's mediaId rather than the raw face-ref - * — that's how a "Manga-Me" stays consistent across many stories. - * - * One character → many variants (all kept in `variantMediaIds[]`). - * The pinned variant is the cover + the ref every story-create - * snapshots into the new story. Re-pinning later doesn't touch - * existing stories (those snapshotted at story-create time). - * - * Variants are written into `picture.images` with a `comicCharacterId` - * back-ref so the gallery can show "all renders of Manga-Me" if the - * user ever wants that view. - */ -export interface LocalComicCharacter extends BaseRecord { - id: string; - name: string; - description?: string | null; - style: ComicStyle; - /** Optional add-on prompt the user typed during character-build, - * e.g. "freundlicher Ausdruck", "casual outfit", "action pose". - * Re-used as default when the user clicks "Mehr Varianten" later. */ - addPrompt?: string | null; - /** Source meImages that fed every variant generation. Pinned in the - * character so re-generating later keeps the same identity anchor. */ - sourceFaceMediaId: string; - sourceBodyMediaId?: string | null; - /** All generated variant images (mana-media ids on `picture.images`). - * Newest-first by convention; a future "regenerate" appends to the - * end. Unbounded but rendered as a paginated grid in the detail view. */ - variantMediaIds: string[]; - /** Which variant IS the character — used as the cover and as the - * ref every story-create snapshots. `null` if the user hasn't - * picked one yet (build-in-progress). */ - pinnedVariantId?: string | null; - tags: string[]; - isFavorite?: boolean; - isArchived?: boolean; -} - -export interface ComicCharacter { - id: string; - name: string; - description?: string; - style: ComicStyle; - addPrompt?: string; - sourceFaceMediaId: string; - sourceBodyMediaId?: string; - variantMediaIds: string[]; - pinnedVariantId?: string; - tags: string[]; - isFavorite?: boolean; - isArchived?: boolean; - createdAt: string; - updatedAt: string; -} - -export function toCharacter(local: LocalComicCharacter): ComicCharacter { - return { - id: local.id, - name: local.name, - description: local.description ?? undefined, - style: local.style, - addPrompt: local.addPrompt ?? undefined, - sourceFaceMediaId: local.sourceFaceMediaId, - sourceBodyMediaId: local.sourceBodyMediaId ?? undefined, - variantMediaIds: local.variantMediaIds ?? [], - pinnedVariantId: local.pinnedVariantId ?? undefined, - tags: local.tags ?? [], - isFavorite: local.isFavorite, - isArchived: local.isArchived, - createdAt: local.createdAt ?? '', - updatedAt: deriveUpdatedAt(local), - }; -} - -/** Cover variant for a character — pinned variant if set, otherwise - * the first variant in `variantMediaIds` (so a build-in-progress - * character still shows something). `null` if no variants generated. */ -export function characterCoverVariantId( - character: Pick -): string | null { - return character.pinnedVariantId ?? character.variantMediaIds[0] ?? null; -} diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/CharactersView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/CharactersView.svelte deleted file mode 100644 index 480eeccd5..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/views/CharactersView.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - -
-
-
-

Deine Comic-Characters

-

- {characters.length} - {characters.length === 1 ? 'Character' : 'Characters'} in - {activeSpace?.name ?? 'diesem Space'} -

-
- - - Neuer Character - -
- - {#if characters.length > 0} -
- {#each characters as character (character.id)} - - {/each} -
- {:else if !characters$.loading} -
-

Noch keine Characters.

-

- Bau deinen ersten Comic-Character aus deinem Foto — Stil wählen, 4 Varianten generieren, - beste pinnen, fertig. -

- - - Ersten Character bauen - -
- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/DetailCharacterView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/DetailCharacterView.svelte deleted file mode 100644 index 805e85ae8..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/views/DetailCharacterView.svelte +++ /dev/null @@ -1,233 +0,0 @@ - - - -
- - - {#if !character} - {#if character$.loading} -

{$_('comic.character_detail.loading')}

- {:else} -
-

- {$_('comic.character_detail.not_found')} -

-

- {$_('comic.character_detail.not_found_hint')} -

-
- {/if} - {:else} - -
-
-
-

{character.name}

-
- - {$_('comic.styles.' + character.style)} - - - {character.variantMediaIds.length === 1 - ? $_('comic.character_detail.variant_one', { - values: { n: character.variantMediaIds.length }, - }) - : $_('comic.character_detail.variant_other', { - values: { n: character.variantMediaIds.length }, - })} - - {#if !character.pinnedVariantId && character.variantMediaIds.length > 0} - {$_('comic.character_detail.pin_open')} - {/if} -
-
- -
- - {#if character.description} -

{character.description}

- {/if} - - {#if character.addPrompt} -
- {$_('comic.character_detail.prompt_add_label')} - {character.addPrompt} -
- {/if} -
- - -
-
-

- {$_('comic.character_detail.section_variants')} -

- {#if !showBuilder && !character.isArchived} - - {/if} -
- - {#if character.variantMediaIds.length === 0} -
-

- {$_('comic.character_detail.empty_variants_title')} -

-

- - {@html $_('comic.character_detail.empty_variants_hint_html')} -

-
- {:else} -
- {#each character.variantMediaIds as variantId, index (variantId)} - handlePin(variantId)} - onRemove={character.variantMediaIds.length > 1 - ? () => handleRemove(variantId) - : undefined} - /> - {/each} -
- {/if} - - {#if showBuilder && !character.isArchived} - (showBuilder = false)} - onCreated={() => { - // Keep the builder open so the user can iterate without - // having to re-open. New variants append + appear in - // the grid above via the liveQuery. - }} - /> - {/if} -
- - -
- - -
- - {#if character.isArchived} -

- - {$_('comic.character_detail.archived_hint')} -

- {/if} - {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte deleted file mode 100644 index bb9fa473c..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/views/DetailView.svelte +++ /dev/null @@ -1,261 +0,0 @@ - - - -
- - - {#if !story} - {#if story$.loading} -

{$_('comic.detail.loading')}

- {:else} -
-

{$_('comic.detail.not_found')}

-

{$_('comic.detail.not_found_hint')}

-
- {/if} - {:else} - -
-
-
-

{story.title}

-
- - {$_('comic.styles.' + story.style)} - - - {story.panelImageIds.length === 1 - ? $_('comic.detail.panel_one', { values: { n: story.panelImageIds.length } }) - : $_('comic.detail.panel_other', { values: { n: story.panelImageIds.length } })} - - {#if story.characterMediaIds.length > 0} - · - - {story.characterMediaIds.length === 1 - ? $_('comic.detail.reference_one', { - values: { n: story.characterMediaIds.length }, - }) - : $_('comic.detail.reference_other', { - values: { n: story.characterMediaIds.length }, - })} - - {/if} -
-
-
- - -
-
- - {#if story.description} -

{story.description}

- {/if} - - {#if story.storyContext} -
- {$_('comic.detail.context_label')} - {story.storyContext} -
- {/if} -
- - -
-
-

- {$_('comic.detail.section_panels')} -

- {#if editorMode === 'off' && !story.isArchived} -
- - - -
- {/if} -
- - - - {#if editorMode === 'single' && !story.isArchived} - (editorMode = 'off')} - onGenerated={() => { - // Keep the editor open for rapid iteration — the user - // usually wants to generate 3–5 panels in a row. Reset - // happens inside PanelEditor on success. - }} - /> - {:else if editorMode === 'batch' && !story.isArchived} - (editorMode = 'off')} /> - {:else if editorMode === 'ai' && !story.isArchived} - (editorMode = 'off')} /> - {/if} -
- - -
- - -
- - {#if story.isArchived} -

- - {$_('comic.detail.archived_hint')} -

- {/if} - {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte b/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte deleted file mode 100644 index d09759474..000000000 --- a/apps/mana/apps/web/src/lib/modules/comic/views/ListView.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - - -
-
-
-

Deine Comics

-

- {stories.length} - {stories.length === 1 ? 'Story' : 'Stories'} in - {activeSpace?.name ?? 'diesem Space'} -

-
- - - Neue Story - -
- - {#if stories.length > 0} -
- {#each stories as story (story.id)} - - {/each} -
- {:else if !stories$.loading} -
-

Noch keine Comics.

-

- Starte deine erste Geschichte — aus einem Gedanken, einem Tagebuch-Eintrag oder einfach - einer Idee. -

- - - Erste Story anlegen - -
- {/if} -
diff --git a/apps/mana/apps/web/src/lib/modules/picture/queries.ts b/apps/mana/apps/web/src/lib/modules/picture/queries.ts index 059b16d0d..6c7cc1a81 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/queries.ts @@ -48,9 +48,6 @@ export function toImage(local: LocalImage): Image { sourceImageId: local.sourceImageId ?? undefined, referenceImageIds: local.referenceImageIds ?? undefined, generationMode: local.generationMode ?? undefined, - comicStoryId: local.comicStoryId ?? undefined, - comicPanelIndex: local.comicPanelIndex ?? undefined, - comicCharacterId: local.comicCharacterId ?? undefined, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local), }; diff --git a/apps/mana/apps/web/src/lib/modules/picture/types.ts b/apps/mana/apps/web/src/lib/modules/picture/types.ts index ba576c927..acbd90bda 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/types.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/types.ts @@ -39,32 +39,6 @@ export interface LocalImage extends BaseRecord { /** mana-media ids of the me-images that fed a reference-edit. */ referenceImageIds?: string[] | null; generationMode?: ImageGenerationMode | null; - /** - * Back-reference to `comicStories.id` when this image was produced as - * a comic panel (docs/plans/comic-module.md). The canonical reading - * order lives on the story in `panelImageIds`; this field lets the - * Picture-gallery show a "Panel von Comic X" chip without having to - * load every story to check which one owns the image. Plaintext FK. - */ - comicStoryId?: string | null; - /** - * Zero-based reading position inside the owning story at write time. - * Denormalised copy of `panelImageIds.indexOf(imageId)` — used for - * the gallery's "Panel 3" label. Goes stale if the story is - * reordered (M3+); the Detail-View re-reads from `panelImageIds` so - * the canonical order is never wrong even if this drifts. - */ - comicPanelIndex?: number | null; - /** - * Back-reference to `comicCharacters.id` when this image was produced - * as a character-variant render (docs/plans/comic-module.md §11). - * Lets the Picture gallery show "Variant of " without - * loading every character row, and keeps the variant identifiable - * in cross-module embeds. Plaintext FK. Mutually exclusive with - * `comicStoryId` — a single image is either a panel OR a variant, - * never both. - */ - comicCharacterId?: string | null; } export interface LocalBoard extends BaseRecord { @@ -129,9 +103,6 @@ export interface Image { sourceImageId?: string; referenceImageIds?: string[]; generationMode?: ImageGenerationMode; - comicStoryId?: string; - comicPanelIndex?: number; - comicCharacterId?: string; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts index 69a9cc2c5..58aee76cf 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -29,7 +29,6 @@ import type { LocalTaskTag } from '$lib/modules/todo/types'; import type { LocalGoal } from '$lib/companion/goals/types'; import type { LocalPlace } from '$lib/modules/places/types'; import type { LocalRecipe } from '$lib/modules/recipes/types'; -import type { LocalComicStory } from '$lib/modules/comic/types'; import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types'; import type { LocalQuiz } from '$lib/modules/quiz/types'; import type { LocalSocialEvent } from '$lib/modules/events/types'; @@ -70,9 +69,6 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise { }); } -/** - * Comic-stories: public-comic-portfolio use case. Returns stories - * flipped to 'public' with their cover panel as the card image - * (panelImageIds[0] → picture.images.publicUrl). Hard-gated on - * canEmbedOnWebsite. - * - * Whitelist (plan §2): title + "N Panels" subtitle + cover-panel URL. - * Character references, panel captions/dialogues, storyContext, and - * the full panelMeta stay out of the snapshot — the cover image is - * already an AI-rendered artifact, the other fields would leak the - * author's briefing and source-entry linkage. - */ -async function resolveComicStories(props: ModuleEmbedProps): Promise { - let stories = await db.table('comicStories').toArray(); - stories = stories.filter( - (s) => !s.deletedAt && !s.isArchived && canEmbedOnWebsite(s.visibility ?? 'private') - ); - - if (props.filter?.isFavorite === true) { - stories = stories.filter((s) => s.isFavorite === true); - } - if (props.filter?.kind) { - // `kind` reuses the generic filter slot as a style filter so the - // website editor can restrict to e.g. only manga-style comics. - stories = stories.filter((s) => s.style === props.filter?.kind); - } - if (props.filter?.tagIds?.length) { - const wanted = new Set(props.filter.tagIds); - stories = stories.filter((s) => (s.tags ?? []).some((t) => wanted.has(t))); - } - - const decrypted = (await decryptRecords('comicStories', stories)) as LocalComicStory[]; - - // Favourites first, then newest. - decrypted.sort((a, b) => { - const favA = a.isFavorite ? 0 : 1; - const favB = b.isFavorite ? 0 : 1; - if (favA !== favB) return favA - favB; - return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''); - }); - - const coverImageIds = decrypted - .map((s) => s.panelImageIds?.[0]) - .filter((id): id is string => Boolean(id)); - const coverImages = await db - .table('images') - .where('id') - .anyOf(coverImageIds) - .toArray(); - const coverById = new Map(); - for (const img of coverImages) coverById.set(img.id, img); - - return decrypted.map((s) => { - const coverId = s.panelImageIds?.[0]; - const cover = coverId ? coverById.get(coverId) : undefined; - const panelCount = s.panelImageIds?.length ?? 0; - return { - title: s.title, - subtitle: `${panelCount} ${panelCount === 1 ? 'Panel' : 'Panels'}`, - imageUrl: cover?.publicUrl ?? undefined, - }; - }); -} - /** * Habits: build-in-public use case. Returns active habits flipped to * 'public' with their current streak as subtitle. diff --git a/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte deleted file mode 100644 index 7be93a212..000000000 --- a/apps/mana/apps/web/src/routes/(app)/comic/+page.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - Comic · Mana - - - - - diff --git a/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte deleted file mode 100644 index 4937a9d72..000000000 --- a/apps/mana/apps/web/src/routes/(app)/comic/[id]/+page.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - Comic · Mana - - - - - {#key id} - - {/key} - diff --git a/apps/mana/apps/web/src/routes/(app)/comic/character/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/character/+page.svelte deleted file mode 100644 index dfb03b8a7..000000000 --- a/apps/mana/apps/web/src/routes/(app)/comic/character/+page.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - - Comic-Characters · Mana - - - -
- -
-
diff --git a/apps/mana/apps/web/src/routes/(app)/comic/character/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/character/[id]/+page.svelte deleted file mode 100644 index a4fbe52ca..000000000 --- a/apps/mana/apps/web/src/routes/(app)/comic/character/[id]/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - - Comic-Character · Mana - - - - {#key id} - - {/key} - diff --git a/apps/mana/apps/web/src/routes/(app)/comic/character/new/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/character/new/+page.svelte deleted file mode 100644 index d5af160e3..000000000 --- a/apps/mana/apps/web/src/routes/(app)/comic/character/new/+page.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - - Neuer Comic-Character · Mana - - - -
-
-

Neuer Comic-Character

-

- Wähle Stil + optionalen Add-Prompt — wir rendern direkt 4 Varianten zur Auswahl. Aus dem - Detail kannst du jederzeit weitere generieren. -

-
- -
-
diff --git a/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte b/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte deleted file mode 100644 index 27ea01c45..000000000 --- a/apps/mana/apps/web/src/routes/(app)/comic/new/+page.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - Neuer Comic · Mana - - - -
-
-

Neuer Comic

-

- Wähle Stil + Protagonist, dann startest du mit dem ersten Panel. -

-
- -
-
diff --git a/packages/mana-tool-registry/src/modules/comic.ts b/packages/mana-tool-registry/src/modules/comic.ts deleted file mode 100644 index fc4f7a2b4..000000000 --- a/packages/mana-tool-registry/src/modules/comic.ts +++ /dev/null @@ -1,1070 +0,0 @@ -/** - * Comic — tools for agents to browse a user's comic stories and drive - * the panel-generation pipeline. Four tools: - * - * - comic.listStories (read) — what stories exist, filter by - * style / favorite - * - comic.createStory (write) — start a new story (empty, panels - * added later via generatePanel) - * - comic.generatePanel (write) — render + append a panel to a - * story; wraps picture/generate- - * with-reference with the story's - * fixed character refs + style - * prefix, consumes credits - * - comic.reorderPanels (write) — rewrite the reading order of an - * existing story - * - * Space scope: stories live in the active space. Character references - * (meImages face-ref) are likewise space-scoped after v40. Every tool - * filters `row.spaceId === ctx.spaceId` client-side mirroring the - * webapp's scopedForModule behaviour. - * - * Why generatePanel writes the story update server-side: - * A comic panel's value is its position inside a story — leaving - * the panel orphan loses the story linkage and defeats the tool's - * purpose. So we pull the story row, decrypt panelMeta, append, - * re-encrypt, and push a field-level update back. A user with the - * webapp open will see the new panel via liveQuery within one sync - * tick. - * - * Plan: docs/plans/comic-module.md M5. - */ - -import { z } from 'zod'; -import { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto'; -import { pullAll, push, pushInsert } from '../sync-client.ts'; -import { registerTool } from '../registry.ts'; -import type { ToolContext, ToolSpec } from '../types.ts'; - -const STORIES_APP_ID = 'comic'; -const STORIES_TABLE = 'comicStories'; -const STORY_ENCRYPTED_FIELDS = [ - 'title', - 'description', - 'storyContext', - 'tags', - 'panelMeta', -] as const; - -const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050'; -const PICTURE_API_URL = () => process.env.MANA_API_URL ?? 'http://localhost:3060'; -const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp'; - -function syncCfg(ctx: ToolContext) { - return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() }; -} - -// ─── Domain shapes (zod) ────────────────────────────────────────── - -const comicStyle = z.enum(['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon']); -type ComicStyleT = z.infer; - -const STYLE_PREFIXES: Record = { - comic: - 'US comic book illustration, bold clean linework, vivid cell-shaded coloring, dramatic lighting, high contrast, comic-panel framing', - manga: - 'Japanese manga illustration, black and white line art with screen tones, dynamic perspective, expressive character design, dramatic motion lines', - cartoon: - 'soft pastel cartoon illustration, rounded friendly shapes, warm saturated colors, Saturday-morning animation style, simple clean backgrounds', - 'graphic-novel': - 'graphic novel illustration, painterly watercolor style, muted atmospheric palette, cinematic composition, moody naturalistic lighting', - webtoon: - 'modern webtoon illustration, clean vertical-scroll framing, bright saturated colors, soft cel-shading, expressive character close-ups', -}; - -interface PanelMeta { - caption?: string; - dialogue?: string; - promptUsed?: string; - sourceInput?: { - module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar'; - entryId: string; - }; -} - -const storySchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string().nullable(), - style: comicStyle, - characterMediaIds: z.array(z.string()), - panelImageIds: z.array(z.string()), - panelCount: z.number().int().nonnegative(), - tags: z.array(z.string()), - isFavorite: z.boolean(), - storyContext: z.string().nullable(), -}); - -interface RawStoryRow { - id?: string; - title?: string; - description?: string | null; - style?: string; - characterMediaIds?: string[]; - storyContext?: string | null; - panelImageIds?: string[]; - panelMeta?: Record; - tags?: string[]; - isFavorite?: boolean; - isArchived?: boolean; - deletedAt?: string | null; - spaceId?: string | null; - updatedAt?: string; -} - -function composePanelPrompt( - style: ComicStyleT, - panelPrompt: string, - caption?: string, - dialogue?: string -): string { - const parts: string[] = [STYLE_PREFIXES[style], panelPrompt.trim()]; - if (caption?.trim()) parts.push(`narration caption at the top reading: "${caption.trim()}"`); - if (dialogue?.trim()) - parts.push(`character speaking in a speech bubble saying: "${dialogue.trim()}"`); - return parts.join('. '); -} - -// ─── comic.listStories ──────────────────────────────────────────── - -const listStoriesInput = z.object({ - style: comicStyle.optional(), - favoriteOnly: z.boolean().default(false), - limit: z.number().int().positive().max(200).default(50), -}); - -const listStoriesOutput = z.object({ - stories: z.array(storySchema), -}); - -export const comicListStories: ToolSpec = { - name: 'comic.listStories', - module: 'comic', - scope: 'user-space', - policyHint: 'read', - description: - "List the caller's comic stories in the active space. Filter by `style` and/or `favoriteOnly`. Returned rows include panelCount (for quick progress overviews) but NOT panelMeta — use the ids in `panelImageIds` to fetch individual panel images from picture.images if needed.", - input: listStoriesInput, - output: listStoriesOutput, - encryptedFields: { table: STORIES_TABLE, fields: [...STORY_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - const res = await pullAll(syncCfg(ctx), STORIES_APP_ID, STORIES_TABLE); - const alive = res.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawStoryRow) - .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, STORY_ENCRYPTED_FIELDS, key) - ) - )) as unknown as RawStoryRow[]; - - const filtered = decrypted - .filter((row): row is RawStoryRow & { id: string; title: string; style: string } => - Boolean(row.id && row.title && row.style) - ) - .filter((row) => !input.style || row.style === input.style) - .filter((row) => !input.favoriteOnly || row.isFavorite === true) - .slice(0, input.limit); - - const stories = filtered.map((row) => ({ - id: row.id, - title: row.title, - description: row.description ?? null, - style: row.style as ComicStyleT, - characterMediaIds: row.characterMediaIds ?? [], - panelImageIds: row.panelImageIds ?? [], - panelCount: (row.panelImageIds ?? []).length, - tags: row.tags ?? [], - isFavorite: row.isFavorite === true, - storyContext: row.storyContext ?? null, - })); - - ctx.logger.info('comic.listStories', { - count: stories.length, - style: input.style ?? 'all', - favoriteOnly: input.favoriteOnly, - }); - - return { stories }; - }, -}; - -// ─── comic.createStory ──────────────────────────────────────────── - -const createStoryInput = z.object({ - title: z.string().min(1).max(200), - style: comicStyle, - /** mediaIds of reference images (face-ref first, optional body-ref). - * Must belong to app `me` — validated server-side by the picture - * endpoint on the first generatePanel call. Cap 8 (server - * MAX_REFERENCE_IMAGES). */ - characterMediaIds: z.array(z.string()).min(1).max(8), - description: z.string().max(2000).nullable().default(null), - storyContext: z.string().max(2000).nullable().default(null), - tags: z.array(z.string()).max(20).default([]), -}); - -const createStoryOutput = z.object({ - story: storySchema, -}); - -export const comicCreateStory: ToolSpec = { - name: 'comic.createStory', - module: 'comic', - scope: 'user-space', - policyHint: 'write', - description: - 'Start a new comic story in the active space. The style and character references are fixed once written — every future `generatePanel` call against this story uses the same refs + style-prefix. Start with 1–8 `characterMediaIds` (face-ref at index 0, body-ref optional). Returns the empty story; add panels via `comic.generatePanel`.', - input: createStoryInput, - output: createStoryOutput, - encryptedFields: { table: STORIES_TABLE, fields: [...STORY_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - const id = crypto.randomUUID(); - const plaintext: Record = { - id, - title: input.title, - description: input.description, - style: input.style, - characterMediaIds: input.characterMediaIds, - storyContext: input.storyContext, - panelImageIds: [], - panelMeta: {}, - tags: input.tags, - isFavorite: false, - }; - - const encrypted = await encryptRecordFields(plaintext, STORY_ENCRYPTED_FIELDS, key); - - await pushInsert(syncCfg(ctx), STORIES_APP_ID, { - table: STORIES_TABLE, - id, - spaceId: ctx.spaceId, - data: encrypted, - }); - - ctx.logger.info('comic.createStory', { - storyId: id, - style: input.style, - refs: input.characterMediaIds.length, - }); - - return { - story: { - id, - title: input.title, - description: input.description, - style: input.style, - characterMediaIds: input.characterMediaIds, - panelImageIds: [], - panelCount: 0, - tags: input.tags, - isFavorite: false, - storyContext: input.storyContext, - }, - }; - }, -}; - -// ─── comic.generatePanel ────────────────────────────────────────── - -const generatePanelInput = z.object({ - storyId: z.string(), - panelPrompt: z.string().min(1).max(800), - caption: z.string().max(200).optional(), - dialogue: z.string().max(200).optional(), - quality: z.enum(['low', 'medium', 'high']).default('medium'), - /** 1024×1024 square is the default; pass `1024x1536` for vertical - * framings (e.g. webtoon tall panels). */ - size: z.enum(['1024x1024', '1024x1536']).optional(), - /** Rendering backend. Same closed set as Wardrobe's Try-On picker: - * - `openai/gpt-image-2` (default) — mid-tier cost, strong - * structure, server-side fallback to gpt-image-1 if org is - * unverified. - * - `google/gemini-3-pro-image-preview` — Nano Banana Pro, strong - * character consistency, higher cost. - * - `google/gemini-3.1-flash-image-preview` — Nano Banana 2, - * newest + fast + cheap. */ - 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 generatePanelOutput = z.object({ - imageUrl: z.string(), - mediaId: z.string(), - prompt: z.string(), - model: z.string(), - panelIndex: z.number().int().nonnegative(), - referenceMediaIds: z.array(z.string()), -}); - -export const comicGeneratePanel: ToolSpec = { - name: 'comic.generatePanel', - module: 'comic', - // `write` rather than `destructive`: the result is additive (a new - // picture.images row + an appended entry in the story's panelImageIds). - // No existing data is overwritten. - scope: 'user-space', - policyHint: 'write', - description: - "Render and append a new panel to an existing comic story using OpenAI gpt-image-2. The story's style prefix + character references are applied automatically — just pass the panel-specific `panelPrompt` (what happens in the panel) plus optional `caption`/`dialogue` strings which get rendered directly into the image. Consumes credits at the standard picture-generate tarif (medium = 10). The panel is persisted back into the story's `panelImageIds` + `panelMeta` so the web app shows it immediately.", - input: generatePanelInput, - output: generatePanelOutput, - encryptedFields: { table: STORIES_TABLE, fields: [...STORY_ENCRYPTED_FIELDS] }, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - - // 1. Fetch + decrypt the target story. - const storiesRes = await pullAll(syncCfg(ctx), STORIES_APP_ID, STORIES_TABLE); - const raw = storiesRes.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawStoryRow) - .find( - (row) => - row.id === input.storyId && - !row.deletedAt && - !row.isArchived && - row.spaceId === ctx.spaceId - ); - if (!raw) { - throw new Error(`Comic story ${input.storyId} not found in the active space`); - } - const story = (await decryptRecordFields( - raw as unknown as Record, - STORY_ENCRYPTED_FIELDS, - key - )) as unknown as RawStoryRow; - - const style = story.style as ComicStyleT | undefined; - if (!style || !(style in STYLE_PREFIXES)) { - throw new Error(`Story has invalid style "${story.style}"`); - } - const refs = story.characterMediaIds ?? []; - if (refs.length === 0) { - throw new Error('Story has no character references — cannot render a panel'); - } - - // 2. Compose prompt + call /picture/generate-with-reference. - const composed = composePanelPrompt(style, input.panelPrompt, input.caption, input.dialogue); - const effectiveSize = input.size ?? (style === 'webtoon' ? '1024x1536' : '1024x1024'); - const referenceMediaIds = refs.slice(0, 8); - - 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: effectiveSize, - n: 1, - }), - }); - - 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 first = - (data.images && data.images[0]) ?? - (data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null); - if (!first?.imageUrl || !first.mediaId) { - throw new Error('picture endpoint returned no image'); - } - - // 3. Persist the panel into picture.images so the web-app gallery - // shows it alongside other generated images. Uses the same - // plaintext-only upload channel mana-sync accepts (picture.images - // prompt/negativePrompt are encrypted client-side; MCP path here - // pushes plaintext prompts — NOT writing - // picture.images at all, but we go one step further because the - // story needs the panel linkage). - const panelImageId = crypto.randomUUID(); - const panelIndex = (story.panelImageIds ?? []).length; - const nowIso = new Date().toISOString(); - - await pushInsert(syncCfg(ctx), 'picture', { - table: 'images', - id: panelImageId, - spaceId: ctx.spaceId, - data: { - id: panelImageId, - prompt: data.prompt, // encrypted field — leaves plaintext for now - negativePrompt: null, - model: data.model, - publicUrl: first.imageUrl, - storagePath: first.mediaId, - filename: `comic-panel-${input.storyId}-${panelIndex + 1}.png`, - format: 'png', - width: effectiveSize === '1024x1536' ? 1024 : 1024, - height: effectiveSize === '1024x1536' ? 1536 : 1024, - visibility: 'private', - isFavorite: false, - downloadCount: 0, - generationMode: 'reference', - referenceImageIds: referenceMediaIds, - comicStoryId: input.storyId, - comicPanelIndex: panelIndex, - createdAt: nowIso, - updatedAt: nowIso, - }, - }); - - // 4. Append the panel to the story: decrypt panelMeta, mutate, - // re-encrypt as a whole, push a field-level LWW update so we - // only rewrite the two fields that changed (not the whole row). - const existingMeta = (story.panelMeta ?? {}) as Record; - const newMeta: PanelMeta = { - caption: input.caption?.trim() || undefined, - dialogue: input.dialogue?.trim() || undefined, - promptUsed: composed, - }; - const nextIds = [...(story.panelImageIds ?? []), panelImageId]; - const nextMeta = { ...existingMeta, [panelImageId]: newMeta }; - - const encryptedPatch = await encryptRecordFields( - { panelMeta: nextMeta } as Record, - ['panelMeta'] as const, - key - ); - - await push(syncCfg(ctx), STORIES_APP_ID, [ - { - table: STORIES_TABLE, - id: input.storyId, - op: 'update', - spaceId: ctx.spaceId, - fields: { - panelImageIds: { value: nextIds, updatedAt: nowIso }, - panelMeta: { - value: (encryptedPatch as Record).panelMeta, - updatedAt: nowIso, - }, - updatedAt: { value: nowIso, updatedAt: nowIso }, - }, - }, - ]); - - ctx.logger.info('comic.generatePanel', { - storyId: input.storyId, - panelIndex, - refs: referenceMediaIds.length, - quality: input.quality, - }); - - return { - imageUrl: first.imageUrl, - mediaId: first.mediaId, - prompt: data.prompt, - model: data.model, - panelIndex, - referenceMediaIds, - }; - }, -}; - -// ─── comic.reorderPanels ────────────────────────────────────────── - -const reorderPanelsInput = z.object({ - storyId: z.string(), - /** New reading order. Must be a permutation of the current - * `panelImageIds` — adding or removing ids is rejected so the tool - * stays purely reorder (add via generatePanel, remove is a separate - * concern not exposed via MCP yet). */ - panelImageIds: z.array(z.string()).min(1), -}); - -const reorderPanelsOutput = z.object({ - storyId: z.string(), - panelImageIds: z.array(z.string()), -}); - -export const comicReorderPanels: ToolSpec = { - name: 'comic.reorderPanels', - module: 'comic', - scope: 'user-space', - policyHint: 'write', - description: - "Change the reading order of an existing comic story's panels. `panelImageIds` must be a permutation of the story's current ids — adding or removing panels is rejected (use `comic.generatePanel` to add, and the web UI to remove from the story). Pure reorder, no new image rendering, no credits consumed.", - input: reorderPanelsInput, - output: reorderPanelsOutput, - async handler(input, ctx) { - const key = await ctx.getMasterKey(); - - const storiesRes = await pullAll(syncCfg(ctx), STORIES_APP_ID, STORIES_TABLE); - const raw = storiesRes.changes - .filter((c) => c.op !== 'delete' && c.data) - .map((c) => c.data as RawStoryRow) - .find((row) => row.id === input.storyId && !row.deletedAt && row.spaceId === ctx.spaceId); - if (!raw) { - throw new Error(`Comic story ${input.storyId} not found in the active space`); - } - - // panelImageIds is plaintext — no decrypt needed for the set- - // equality check. Still need the key if we later want to touch - // encrypted fields; here we only update one plaintext array. - void key; - - const current = new Set(raw.panelImageIds ?? []); - const next = new Set(input.panelImageIds); - if (current.size !== next.size) { - throw new Error( - `reorder rejected: expected ${current.size} panelImageIds, got ${input.panelImageIds.length}` - ); - } - for (const id of current) { - if (!next.has(id)) { - throw new Error(`reorder rejected: panel ${id} missing from new order`); - } - } - - const nowIso = new Date().toISOString(); - await push(syncCfg(ctx), STORIES_APP_ID, [ - { - table: STORIES_TABLE, - id: input.storyId, - op: 'update', - spaceId: ctx.spaceId, - fields: { - panelImageIds: { value: input.panelImageIds, updatedAt: nowIso }, - updatedAt: { value: nowIso, updatedAt: nowIso }, - }, - }, - ]); - - ctx.logger.info('comic.reorderPanels', { - storyId: input.storyId, - count: input.panelImageIds.length, - }); - - return { - storyId: input.storyId, - panelImageIds: input.panelImageIds, - }; - }, -}; - -// ═══════════════════════════════════════════════════════════════ -// 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(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 app `me` (face/body).', - 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 { - registerTool(comicListStories); - 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 deleted file mode 100644 index 336592a8d..000000000 --- a/packages/shared-ai/src/agents/templates/comic-author.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { AI_PROPOSABLE_TOOL_NAMES } from '../../policy/proposable-tools'; -import type { AgentTemplate } from './types'; -import type { AiPolicy } from '../../policy/types'; - -/** - * Comic-Autor — turns the user's text artifacts (journal entries, - * notes, library reviews) into short illustrated comics via the - * comic module's panel-generation pipeline. - * - * Why propose on every write: - * Each `comic.generatePanel` call consumes credits (3–25 each, 10 - * for the medium default). A mis-reading of the source text or an - * over-generous panel count would burn spend fast. Propose-on-write - * forces the user to approve the Panel[] suggestion list before any - * picture.images row lands. - * - * Reads (journal/notes/library + comic.listStories) stay auto — the - * agent may freely peek at existing content to pick which entry to - * illustrate or which story to append to, without nagging the user. - * - * Tools this template uses: - * - journal.list* (read) — browse source entries - * - notes.list* (read) — browse source notes - * - library.list* (read) — browse reviews - * - comic.listStories (read) — find an existing story to extend - * - comic.createStory (propose) — start a new comic - * - comic.generatePanel (propose) — render a single panel (credits!) - * - comic.reorderPanels (propose) — rearrange existing panels - * - * The comic.* tools live in mana-tool-registry (MCP) and are NOT - * part of AI_TOOL_CATALOG — they're reachable from persona-runner - * and external MCP clients (Claude Desktop). The foreground webapp - * runner will pick them up when the comic module gains its - * AI_TOOL_CATALOG entries in a later step; until then this template - * is primarily useful on the persona-runner side. - */ - -// Per-tool propose policy. Start from every proposable tool in the -// AI catalog (same seed as the Recherche-Agent) so cross-module -// writes this template doesn't anticipate (`create_note` for a -// sidecar summary note, etc.) still land as proposals. -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 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. - // Listing them keeps the policy intent consistent across both - // surfaces (foreground runner + MCP). - 'comic.listStories': 'auto', - '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. - journal: 'auto', - notes: 'auto', - library: 'auto', - // Kontext + goals are referenced as standing context the - // planner already auto-injects; keep them auto so the agent can - // skim them for tonal cues (is the user in a serious phase, is - // there a goal the comic should celebrate). - kontext: 'auto', - goals: 'auto', - // Every comic write requires approval. - comic: 'propose', - }, - defaultForAi: 'propose', -}; - -export const comicAuthorTemplate: AgentTemplate = { - id: 'comic-author', - version: '1', - icon: '🎨', - label: 'Comic-Autor', - tagline: 'Verwandelt Tagebuch-Einträge und Notizen in kurze Comics', - description: `Gib dem Agent einen Tagebuch-Eintrag, eine Notiz oder ein Review aus deiner Bibliothek — er schlägt daraus einen kurzen Comic vor: - -1. Liest den gewählten Text -2. Schlägt 4 Panels vor (Prompt + Caption + Dialog pro Panel) -3. Du bestätigst die Liste, optional mit Edits -4. Jedes Panel wird via gpt-image-2 gerendert und an die Story angehängt - -Jeder Generate-Schritt ist ein Vorschlag — du bestimmst, wann Credits fließen. Für 4 Panels mit Default-Qualität: 4 × 10 = 40 Credits.`, - category: 'ai', - color: '#F97316', - agent: { - name: 'Comic-Autor', - avatar: '🎨', - role: 'Macht aus Text kurze Comics', - systemPrompt: `Du bist Comic-Autor. Wenn der User dir einen Moment, ein Erlebnis oder eine Idee gibt, verwandelst du das in einen kurzen Comic. - -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. 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 -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. -- Niemals urteilen über das was der User erlebt hat. -- Deutsch als Sprache in Captions/Dialogen ist ok; englische Text-Prompts rendern aber stabiler. - -Tools: -- journal.listEntries / notes.list / library.listEntries um Quelle zu finden -- 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, -Panel-Anzahl, wieviel Dialog vs. Caption, Tabu-Themen die nie vorkommen sollen.) -`, - policy: COMIC_AUTHOR_POLICY, - maxConcurrentMissions: 1, - }, - scene: { - name: 'Comic-Werkstatt', - description: 'Texte lesen, Panels vorschlagen, Comic-Stories bauen', - openApps: [ - { appId: 'comic', widthPx: 540 }, - { appId: 'journal', widthPx: 440 }, - { appId: 'ai-missions', widthPx: 360 }, - { appId: 'ai-workbench', widthPx: 360 }, - ], - }, - missions: [ - { - title: 'Comic aus einem Tagebuch-Eintrag', - objective: - 'Wähle einen Tagebuch-Eintrag, schlage Titel + Stil vor, und generiere eine Panel-Folge (Default 4). Jeder Generate-Schritt ist ein Vorschlag.', - conceptMarkdown: `# Comic-Auftrag - -Ersetze diesen Block mit: - -- **Eintrag:** _Link auf den Tagebuch-Eintrag (oder Zitat daraus)_ -- **Stil:** _comic / manga / cartoon / graphic-novel / webtoon — oder leer lassen, damit der Agent vorschlägt_ -- **Panels:** _2-8, default 4_ -- **Ton:** _frei — "leicht und selbstironisch" / "ernst und reflektiert" / "melancholisch"_ - -Der Agent liest den Eintrag, legt eine neue Comic-Story an (als Vorschlag), -schlägt die Panel-Folge vor, und rendert die Panels einzeln nach deiner -Bestätigung.`, - cadence: { kind: 'manual' }, - startPaused: true, - }, - ], -}; diff --git a/packages/shared-ai/src/agents/templates/index.ts b/packages/shared-ai/src/agents/templates/index.ts index 75653219f..ab88ceb5f 100644 --- a/packages/shared-ai/src/agents/templates/index.ts +++ b/packages/shared-ai/src/agents/templates/index.ts @@ -14,7 +14,6 @@ import { calmnessTemplate } from './calmness'; import { fitnessTemplate } from './fitness'; import { deepWorkTemplate } from './deep-work'; import { eventScoutTemplate } from './event-scout'; -import { comicAuthorTemplate } from './comic-author'; export type { // Generalised names (T1 of workbench-templates plan): @@ -41,7 +40,6 @@ export const ALL_TEMPLATES = [ fitnessTemplate, deepWorkTemplate, eventScoutTemplate, - comicAuthorTemplate, ] as const; export { @@ -52,7 +50,6 @@ export { fitnessTemplate, deepWorkTemplate, eventScoutTemplate, - comicAuthorTemplate, }; /** Lookup helper — returns the template matching the given id, or diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 1fafe0084..58c37e125 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -1835,223 +1835,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }], }, - // ── Comic ─────────────────────────────────────────────── - { - name: 'list_comic_stories', - module: 'comic', - description: - 'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, 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_story', - module: 'comic', - description: - 'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.', - defaultPolicy: 'propose', - parameters: [ - { name: 'title', type: 'string', description: 'Titel der Story', required: true }, - { - name: 'style', - type: 'string', - description: 'Visueller Stil', - required: true, - enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'], - }, - { - name: 'description', - type: 'string', - description: 'Kurze Story-Beschreibung', - required: false, - }, - { - name: 'storyContext', - type: 'string', - description: - 'Freitext-Briefing — Ton, Ziel, Hintergrund. Wird im AI-Storyboard-Flow als Briefing genutzt.', - required: false, - }, - { - name: 'tags', - type: 'string', - description: 'Tags durch Komma getrennt', - required: false, - }, - ], - }, - { - name: 'generate_comic_panel', - module: 'comic', - description: - 'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.', - defaultPolicy: 'propose', - parameters: [ - { name: 'storyId', type: 'string', description: 'ID der Story', required: true }, - { - name: 'panelPrompt', - type: 'string', - description: - 'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.', - required: true, - }, - { - name: 'caption', - type: 'string', - description: 'Erzaehl-Zeile ueber/unter dem Bild (optional)', - required: false, - }, - { - name: 'dialogue', - type: 'string', - description: 'Sprechblasen-Text (optional)', - 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. openai/gpt-image-2 ist Standard. google/gemini-3-pro-image-preview = Nano Banana Pro (hoehere Charakter-Konsistenz, teurer). google/gemini-3.1-flash-image-preview = Nano Banana 2 (neuestes, schnell, guenstig).', - required: false, - enum: [ - 'openai/gpt-image-2', - 'google/gemini-3-pro-image-preview', - 'google/gemini-3.1-flash-image-preview', - ], - }, - ], - }, - { - 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) ────────────────────── { name: 'capture_sign', diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 3fbf24bac..dadca6097 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -63,10 +63,6 @@ const timesSvg = ``; -// Comic icon — speech bubble with a lightning-bolt panel marker on -// orange→red gradient. Warm creative-family tone for the Mana launcher. -const comicSvg = ``; - // Augur icon — open eye with a small star in the iris and three drifting // dots ("signs in the air") on indigo→violet gradient. Sits in the cosmic // family next to Dreams (indigo) and Cards (violet) so the launcher reads @@ -99,7 +95,6 @@ export const APP_ICONS = { todo: svgToDataUrl(todoSvg), mail: svgToDataUrl(mailSvg), inventory: svgToDataUrl(inventorySvg), - comic: svgToDataUrl(comicSvg), augur: svgToDataUrl(augurSvg), questions: svgToDataUrl(questionsSvg), times: svgToDataUrl(timesSvg), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 635d19d05..dcecd0dc3 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -345,23 +345,6 @@ export const MANA_APPS: ManaApp[] = [ status: 'beta', requiredTier: 'guest', }, - { - id: 'comic', - name: 'Comic', - description: { - de: 'Aus Text wird ein Comic', - en: 'Turn text into comics', - }, - longDescription: { - de: 'Erstelle mehrseitige Comics mit KI. Starte mit einem Tagebuch-Eintrag, einer Notiz oder einem Kalender-Event und generiere Panels in fünf Stilen — Comic, Manga, Cartoon, Graphic Novel oder Webtoon. Du selbst bist der Protagonist.', - en: 'Create multi-panel comics with AI. Start from a journal entry, note, or calendar event and generate panels in five styles — comic, manga, cartoon, graphic novel, or webtoon. You are the protagonist.', - }, - icon: APP_ICONS.comic, - color: '#f97316', - comingSoon: false, - status: 'beta', - requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release - }, { id: 'questions', name: 'Questions', diff --git a/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte b/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte index a0d5b34f6..a4cd565e8 100644 --- a/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte +++ b/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte @@ -24,7 +24,6 @@ - diff --git a/packages/website-blocks/src/moduleEmbed/schema.ts b/packages/website-blocks/src/moduleEmbed/schema.ts index d45341599..087e61c2a 100644 --- a/packages/website-blocks/src/moduleEmbed/schema.ts +++ b/packages/website-blocks/src/moduleEmbed/schema.ts @@ -34,7 +34,6 @@ export const EmbedSourceSchema = z.enum([ 'goals.goals', 'places.places', 'recipes.recipes', - 'comic.stories', 'habits.habits', 'quiz.quizzes', 'events.socialEvents',