i18n: fix IT/FR/ES parity gaps in dashboard + memoro

- 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:19:59 +02:00
parent d49ad239d9
commit 87b567eec9
11 changed files with 671 additions and 1 deletions

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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}\"?",

View file

@ -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}\" ?",

View file

@ -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}\"?",

View file

@ -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<ResolvedEmb
case 'wardrobe.outfits':
items = await resolveWardrobeOutfits(props);
break;
case 'comic.stories':
items = await resolveComicStories(props);
break;
default:
return {
items: [],
@ -503,3 +507,63 @@ async function resolveWardrobeOutfits(props: ModuleEmbedProps): Promise<EmbedIte
};
});
}
/**
* 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<EmbedItem[]> {
let stories = await db.table<LocalComicStory>('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<LocalImage>('images').where('id').anyOf(coverImageIds).toArray();
const coverById = new Map<string, LocalImage>();
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,
};
});
}

View file

@ -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<typeof comicStyle>;
const STYLE_PREFIXES: Record<ComicStyleT, string> = {
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<string, PanelMeta>;
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<typeof listStoriesInput, typeof listStoriesOutput> = {
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<RawStoryRow>(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<string, unknown>,
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<typeof createStoryInput, typeof createStoryOutput> = {
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 18 `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<string, unknown> = {
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<typeof generatePanelInput, typeof generatePanelOutput> = {
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<RawStoryRow>(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<string, unknown>,
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(() => '<unreadable body>');
throw new Error(
`picture.generate-with-reference failed: ${res.status} ${res.statusText}${text.slice(0, 500)}`
);
}
const data = (await res.json()) as {
images?: Array<{ imageUrl: string; mediaId?: string }>;
imageUrl?: string;
mediaId?: string;
prompt: string;
model: string;
};
const 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<string, PanelMeta>;
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<string, unknown>,
['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<string, unknown>).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<typeof reorderPanelsInput, typeof reorderPanelsOutput> = {
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<RawStoryRow>(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);
}

View file

@ -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,
};

View file

@ -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.

View file

@ -35,6 +35,7 @@ export const EmbedSourceSchema = z.enum([
'places.places',
'recipes.recipes',
'wardrobe.outfits',
'comic.stories',
]);
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;