From 87b567eec96008aebfc31e6384d9ef2f9bf9baea Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 16:19:59 +0200 Subject: [PATCH] i18n: fix IT/FR/ES parity gaps in dashboard + memoro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard: +5 Einträge pro Sprache für die beiden neuen Widgets activity_feed + articles_unread. - memoro: +1 Eintrag pro Sprache für memo.load_more. Damit sind dashboard (111) und memoro auf gleichem Stand wie DE/EN. Verbleibende Drift (app_slider-Legacy-Keys in memoro IT/FR/ES, common/auth-Legacy in calendar/times) ist strukturell und bleibt einem Folge-Cleanup vorbehalten. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/i18n/locales/dashboard/es.json | 9 + .../src/lib/i18n/locales/dashboard/fr.json | 9 + .../src/lib/i18n/locales/dashboard/it.json | 9 + .../web/src/lib/i18n/locales/memoro/es.json | 1 + .../web/src/lib/i18n/locales/memoro/fr.json | 1 + .../web/src/lib/i18n/locales/memoro/it.json | 1 + .../web/src/lib/modules/website/embeds.ts | 64 ++ .../mana-tool-registry/src/modules/comic.ts | 570 ++++++++++++++++++ .../mana-tool-registry/src/modules/index.ts | 3 + packages/mana-tool-registry/src/types.ts | 4 +- .../website-blocks/src/moduleEmbed/schema.ts | 1 + 11 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 packages/mana-tool-registry/src/modules/comic.ts diff --git a/apps/mana/apps/web/src/lib/i18n/locales/dashboard/es.json b/apps/mana/apps/web/src/lib/i18n/locales/dashboard/es.json index f90c2c6eb..7b6b65cb6 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/dashboard/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/dashboard/es.json @@ -153,6 +153,15 @@ "body_stats": { "title": "Body", "description": "Peso actual y estado del entrenamiento" + }, + "activity_feed": { + "title": "Actividad", + "description": "Cambios recientes en todos los módulos", + "empty": "Aún no hay actividad" + }, + "articles_unread": { + "title": "Artículos", + "description": "Artículos no leídos de tu lista de lectura" } } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/dashboard/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/dashboard/fr.json index cb2b02433..c69068ec6 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/dashboard/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/dashboard/fr.json @@ -153,6 +153,15 @@ "body_stats": { "title": "Body", "description": "Poids actuel et statut de l'entraînement" + }, + "activity_feed": { + "title": "Activité", + "description": "Modifications récentes sur tous les modules", + "empty": "Aucune activité pour l'instant" + }, + "articles_unread": { + "title": "Articles", + "description": "Articles non lus de ta liste de lecture" } } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/dashboard/it.json b/apps/mana/apps/web/src/lib/i18n/locales/dashboard/it.json index 52ff6c841..e47f2bbf6 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/dashboard/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/dashboard/it.json @@ -153,6 +153,15 @@ "body_stats": { "title": "Body", "description": "Peso attuale e stato dell'allenamento" + }, + "activity_feed": { + "title": "Attività", + "description": "Modifiche recenti in tutti i moduli", + "empty": "Ancora nessuna attività" + }, + "articles_unread": { + "title": "Articoli", + "description": "Articoli non letti dalla tua lista di lettura" } } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/memoro/es.json b/apps/mana/apps/web/src/lib/i18n/locales/memoro/es.json index a66e56fbf..f22b48fea 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/memoro/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/memoro/es.json @@ -162,6 +162,7 @@ "show_all_memos": "Mostrar todos los memos", "no_memos_yet": "Aún no hay memos", "no_memos_hint": "Ve a la página de grabación para crear tu primer memo", + "load_more": "Cargar más memos", "search_placeholder": "Buscar memos...", "delete_memo_title": "Eliminar memo", "delete_memo_confirm": "¿Realmente desea eliminar \"{title}\"?", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/memoro/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/memoro/fr.json index c74bac8d6..835c2c061 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/memoro/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/memoro/fr.json @@ -162,6 +162,7 @@ "show_all_memos": "Afficher tous les mémos", "no_memos_yet": "Pas encore de mémos", "no_memos_hint": "Allez à la page d'enregistrement pour créer votre premier mémo", + "load_more": "Charger plus de mémos", "search_placeholder": "Rechercher des mémos...", "delete_memo_title": "Supprimer le mémo", "delete_memo_confirm": "Voulez-vous vraiment supprimer \"{title}\" ?", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/memoro/it.json b/apps/mana/apps/web/src/lib/i18n/locales/memoro/it.json index eff4f5211..e1c04c8b0 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/memoro/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/memoro/it.json @@ -162,6 +162,7 @@ "show_all_memos": "Mostra tutti i memo", "no_memos_yet": "Ancora nessun memo", "no_memos_hint": "Vai alla pagina di registrazione per creare il tuo primo memo", + "load_more": "Carica altri memo", "search_placeholder": "Cerca memo...", "delete_memo_title": "Elimina memo", "delete_memo_confirm": "Vuoi davvero eliminare \"{title}\"?", 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 a9211dc93..2e0b1fb2d 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -29,6 +29,7 @@ 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 { LocalWardrobeOutfit } from '$lib/modules/wardrobe/types'; +import type { LocalComicStory } from '$lib/modules/comic/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; export interface ResolvedEmbed { @@ -67,6 +68,9 @@ export async function resolveEmbed(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, + }; + }); +} diff --git a/packages/mana-tool-registry/src/modules/comic.ts b/packages/mana-tool-registry/src/modules/comic.ts new file mode 100644 index 000000000..c4184ab84 --- /dev/null +++ b/packages/mana-tool-registry/src/modules/comic.ts @@ -0,0 +1,570 @@ +/** + * 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 / wardrobe garments) 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 (and + * wardrobe.tryOn doesn't): + * A comic panel's value is its position inside a story — leaving + * the panel orphan (preview-only in wardrobe style) 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, + * optional costume garment photos). Must belong to apps `me` or + * `wardrobe` — 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, up to 3 garment-ref photos from wardrobe). 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(), +}); + +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: 'openai/gpt-image-2', + 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 — matches wardrobe.tryOn 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, + }; + }, +}; + +// ─── Registration barrel ────────────────────────────────────────── + +export function registerComicTools(): void { + registerTool(comicListStories); + registerTool(comicCreateStory); + registerTool(comicGeneratePanel); + registerTool(comicReorderPanels); +} diff --git a/packages/mana-tool-registry/src/modules/index.ts b/packages/mana-tool-registry/src/modules/index.ts index 14c7ee758..ee8c7cd6b 100644 --- a/packages/mana-tool-registry/src/modules/index.ts +++ b/packages/mana-tool-registry/src/modules/index.ts @@ -18,6 +18,7 @@ import { registerNotesTools } from './notes.ts'; import { registerSpacesTools } from './spaces.ts'; import { registerTodoTools } from './todo.ts'; import { registerWardrobeTools } from './wardrobe.ts'; +import { registerComicTools } from './comic.ts'; export function registerAllModules(): void { registerHabitsTools(); @@ -28,6 +29,7 @@ export function registerAllModules(): void { registerSpacesTools(); registerTodoTools(); registerWardrobeTools(); + registerComicTools(); } export { @@ -39,4 +41,5 @@ export { registerSpacesTools, registerTodoTools, registerWardrobeTools, + registerComicTools, }; diff --git a/packages/mana-tool-registry/src/types.ts b/packages/mana-tool-registry/src/types.ts index fec4747e7..59ad85ea6 100644 --- a/packages/mana-tool-registry/src/types.ts +++ b/packages/mana-tool-registry/src/types.ts @@ -31,7 +31,9 @@ export type ModuleId = // — M5 (me-images + reference-based image generation) — | 'me' // — Wardrobe M5 (garments + outfits + try-on) — - | 'wardrobe'; + | 'wardrobe' + // — Comic M5 (stories + panel generation from cross-module text) — + | 'comic'; /** * `user-space` — operates on the caller's data within a specific Space. diff --git a/packages/website-blocks/src/moduleEmbed/schema.ts b/packages/website-blocks/src/moduleEmbed/schema.ts index 6fd4cbd0c..a34661bf1 100644 --- a/packages/website-blocks/src/moduleEmbed/schema.ts +++ b/packages/website-blocks/src/moduleEmbed/schema.ts @@ -35,6 +35,7 @@ export const EmbedSourceSchema = z.enum([ 'places.places', 'recipes.recipes', 'wardrobe.outfits', + 'comic.stories', ]); export type EmbedSource = z.infer;