ima2-gen 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +47 -7
  2. package/assets/card-news/templates/academy-lesson-square/base.png +0 -0
  3. package/assets/card-news/templates/academy-lesson-square/preview.png +0 -0
  4. package/assets/card-news/templates/academy-lesson-square/template.json +20 -0
  5. package/assets/card-news/templates/clean-report-square/base.png +0 -0
  6. package/assets/card-news/templates/clean-report-square/preview.png +0 -0
  7. package/assets/card-news/templates/clean-report-square/template.json +20 -0
  8. package/bin/commands/cancel.js +45 -0
  9. package/bin/commands/edit.js +33 -4
  10. package/bin/commands/gen.js +26 -3
  11. package/bin/commands/ps.js +48 -16
  12. package/bin/ima2.js +56 -12
  13. package/bin/lib/client.js +4 -1
  14. package/bin/lib/error-hints.js +23 -0
  15. package/bin/lib/output.js +10 -0
  16. package/config.js +19 -1
  17. package/docs/API.md +67 -0
  18. package/docs/FAQ.ko.md +248 -0
  19. package/docs/FAQ.md +256 -0
  20. package/docs/README.ja.md +4 -0
  21. package/docs/README.ko.md +14 -1
  22. package/docs/README.zh-CN.md +4 -0
  23. package/docs/RECOVER_OLD_IMAGES.md +2 -0
  24. package/lib/cardNewsGenerator.js +162 -0
  25. package/lib/cardNewsJobStore.js +107 -0
  26. package/lib/cardNewsManifestStore.js +112 -0
  27. package/lib/cardNewsPlanner.js +180 -0
  28. package/lib/cardNewsPlannerClient.js +112 -0
  29. package/lib/cardNewsPlannerPrompt.js +60 -0
  30. package/lib/cardNewsPlannerSchema.js +259 -0
  31. package/lib/cardNewsRoleTemplateStore.js +47 -0
  32. package/lib/cardNewsTemplateStore.js +210 -0
  33. package/lib/db.js +20 -3
  34. package/lib/errorClassify.js +2 -2
  35. package/lib/generationErrors.js +51 -0
  36. package/lib/historyList.js +82 -8
  37. package/lib/inflight.js +117 -34
  38. package/lib/logger.js +37 -3
  39. package/lib/oauthLauncher.js +52 -19
  40. package/lib/oauthProxy.js +81 -14
  41. package/lib/requestLogger.js +48 -0
  42. package/lib/runtimePorts.js +93 -0
  43. package/lib/sessionStore.js +48 -7
  44. package/package.json +3 -2
  45. package/routes/cardNews.js +183 -0
  46. package/routes/edit.js +1 -1
  47. package/routes/generate.js +10 -10
  48. package/routes/health.js +27 -3
  49. package/routes/index.js +2 -0
  50. package/routes/nodes.js +93 -26
  51. package/server.js +91 -18
  52. package/ui/dist/assets/index-BjX_nzuK.js +23 -0
  53. package/ui/dist/assets/index-BjX_nzuK.js.map +1 -0
  54. package/ui/dist/assets/index-DHyUax4_.css +1 -0
  55. package/ui/dist/index.html +2 -2
  56. package/ui/dist/assets/index-CqpVoXpZ.css +0 -1
  57. package/ui/dist/assets/index-IHSd1z1a.js +0 -22
  58. package/ui/dist/assets/index-IHSd1z1a.js.map +0 -1
@@ -0,0 +1,259 @@
1
+ export const CARD_NEWS_TEXT_KINDS = ["headline", "body", "caption", "cta", "badge", "number"];
2
+ export const CARD_NEWS_RENDER_MODES = ["in-image", "ui-only"];
3
+ export const CARD_NEWS_PLACEMENTS = [
4
+ "top-left",
5
+ "top-center",
6
+ "top-right",
7
+ "center-left",
8
+ "center",
9
+ "center-right",
10
+ "bottom-left",
11
+ "bottom-center",
12
+ "bottom-right",
13
+ "free",
14
+ ];
15
+ export const CARD_NEWS_HIERARCHIES = ["primary", "secondary", "supporting"];
16
+ export const CARD_NEWS_TEXT_SOURCES = ["planner", "user"];
17
+
18
+ const TEXT_FIELD_SCHEMA = {
19
+ type: "object",
20
+ additionalProperties: false,
21
+ required: [
22
+ "id",
23
+ "kind",
24
+ "text",
25
+ "renderMode",
26
+ "placement",
27
+ "slotId",
28
+ "hierarchy",
29
+ "maxChars",
30
+ "language",
31
+ "source",
32
+ ],
33
+ properties: {
34
+ id: { type: "string" },
35
+ kind: { type: "string", enum: CARD_NEWS_TEXT_KINDS },
36
+ text: { type: "string" },
37
+ renderMode: { type: "string", enum: CARD_NEWS_RENDER_MODES },
38
+ placement: { type: "string", enum: CARD_NEWS_PLACEMENTS },
39
+ slotId: { type: ["string", "null"] },
40
+ hierarchy: { type: "string", enum: CARD_NEWS_HIERARCHIES },
41
+ maxChars: { type: ["integer", "null"] },
42
+ language: { type: ["string", "null"] },
43
+ source: { type: "string", enum: CARD_NEWS_TEXT_SOURCES },
44
+ },
45
+ };
46
+
47
+ export const CARD_NEWS_PLANNER_SCHEMA = {
48
+ type: "object",
49
+ additionalProperties: false,
50
+ required: ["title", "topic", "cards"],
51
+ properties: {
52
+ title: { type: "string" },
53
+ topic: { type: "string" },
54
+ audience: { type: "string" },
55
+ goal: { type: "string" },
56
+ cards: {
57
+ type: "array",
58
+ items: {
59
+ type: "object",
60
+ additionalProperties: false,
61
+ required: ["order", "role", "headline", "body", "visualPrompt", "textFields", "references", "locked"],
62
+ properties: {
63
+ order: { type: "integer" },
64
+ role: { type: "string" },
65
+ headline: { type: "string" },
66
+ body: { type: "string" },
67
+ visualPrompt: { type: "string" },
68
+ textFields: { type: "array", items: TEXT_FIELD_SCHEMA },
69
+ references: { type: "array", items: { type: "string" } },
70
+ locked: { type: "boolean" },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ };
76
+
77
+ function asText(value, fallback = "") {
78
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
79
+ }
80
+
81
+ function detectBriefLanguage(input = {}) {
82
+ const text = [input.topic, input.audience, input.goal, input.contentBrief].filter(Boolean).join(" ");
83
+ if (/[가-힣]/.test(text)) return "ko";
84
+ if (/[A-Za-z]/.test(text)) return "en";
85
+ return "und";
86
+ }
87
+
88
+ function fallbackCopy(input = {}, kind = "body") {
89
+ const lang = detectBriefLanguage(input);
90
+ const topic = asText(input.topic, asText(input.title, "Card news"));
91
+ const goal = asText(input.goal, topic);
92
+ const brief = asText(input.contentBrief, goal);
93
+ if (kind === "headline") return topic;
94
+ if (lang === "ko") return brief || `${topic} 핵심 내용을 정리합니다.`;
95
+ if (lang === "en") return brief || `Summarize the key point for ${topic}.`;
96
+ return brief || topic;
97
+ }
98
+
99
+ function normalizeTextField(field, index) {
100
+ if (!field || typeof field !== "object") return null;
101
+ const text = asText(field.text);
102
+ if (!text) return null;
103
+ return {
104
+ id: asText(field.id, `tf_${index + 1}`),
105
+ kind: CARD_NEWS_TEXT_KINDS.includes(field.kind) ? field.kind : "body",
106
+ text,
107
+ renderMode: CARD_NEWS_RENDER_MODES.includes(field.renderMode) ? field.renderMode : "in-image",
108
+ placement: CARD_NEWS_PLACEMENTS.includes(field.placement) ? field.placement : "free",
109
+ slotId: typeof field.slotId === "string" && field.slotId.trim() ? field.slotId.trim() : null,
110
+ hierarchy: CARD_NEWS_HIERARCHIES.includes(field.hierarchy) ? field.hierarchy : "supporting",
111
+ maxChars: Number.isInteger(field.maxChars) ? field.maxChars : null,
112
+ language: typeof field.language === "string" && field.language.trim() ? field.language.trim() : null,
113
+ source: CARD_NEWS_TEXT_SOURCES.includes(field.source) ? field.source : "planner",
114
+ };
115
+ }
116
+
117
+ function normalizeTextFields(value) {
118
+ if (!Array.isArray(value)) return [];
119
+ return value.map(normalizeTextField).filter(Boolean);
120
+ }
121
+
122
+ function stripExactVisibleText(visualPrompt, textFields) {
123
+ let next = asText(visualPrompt);
124
+ for (const field of textFields) {
125
+ const text = asText(field.text);
126
+ if (text.length >= 4 && next.includes(text)) {
127
+ next = next.split(text).join("visible text box");
128
+ }
129
+ }
130
+ return next.trim();
131
+ }
132
+
133
+ function normalizeCard(card, role, index, input) {
134
+ const topic = asText(input.topic, "Card news");
135
+ const textFields = normalizeTextFields(card?.textFields);
136
+ return {
137
+ order: index + 1,
138
+ role: role.role,
139
+ headline: asText(card?.headline, index === 0 ? topic : fallbackCopy(input, "headline")),
140
+ body: asText(card?.body, fallbackCopy(input, "body")),
141
+ visualPrompt: stripExactVisibleText(
142
+ asText(card?.visualPrompt, `${asText(role.promptHint, role.role)}, ${topic}`),
143
+ textFields,
144
+ ),
145
+ textFields,
146
+ references: Array.isArray(card?.references)
147
+ ? card.references.filter((ref) => typeof ref === "string")
148
+ : [],
149
+ locked: false,
150
+ };
151
+ }
152
+
153
+ export function repairPlannerOutput(output, input = {}) {
154
+ const roles = input.roleTemplate?.roles || [];
155
+ const cards = roles.map((role, index) => {
156
+ const original = Array.isArray(output?.cards) ? output.cards[index] : null;
157
+ return normalizeCard(original, role, index, input);
158
+ });
159
+ return {
160
+ ok: true,
161
+ repaired: true,
162
+ errors: [],
163
+ plan: {
164
+ title: asText(output?.title, asText(input.topic, "Untitled card news")),
165
+ topic: asText(output?.topic, asText(input.topic, "Untitled card news")),
166
+ audience: asText(output?.audience, asText(input.audience)),
167
+ goal: asText(output?.goal, asText(input.goal)),
168
+ cards,
169
+ },
170
+ };
171
+ }
172
+
173
+ function validateTextField(field, path, errors) {
174
+ if (!field || typeof field !== "object") {
175
+ errors.push(`${path} must be object`);
176
+ return;
177
+ }
178
+ if (typeof field.id !== "string") errors.push(`${path}.id must be string`);
179
+ if (!CARD_NEWS_TEXT_KINDS.includes(field.kind)) errors.push(`${path}.kind invalid`);
180
+ if (typeof field.text !== "string") errors.push(`${path}.text must be string`);
181
+ if (typeof field.text === "string" && !field.text.trim()) errors.push(`${path}.text must not be empty`);
182
+ if (!CARD_NEWS_RENDER_MODES.includes(field.renderMode)) errors.push(`${path}.renderMode invalid`);
183
+ if (!CARD_NEWS_PLACEMENTS.includes(field.placement)) errors.push(`${path}.placement invalid`);
184
+ if (!(typeof field.slotId === "string" || field.slotId === null)) errors.push(`${path}.slotId invalid`);
185
+ if (!CARD_NEWS_HIERARCHIES.includes(field.hierarchy)) errors.push(`${path}.hierarchy invalid`);
186
+ if (!(Number.isInteger(field.maxChars) || field.maxChars === null)) errors.push(`${path}.maxChars invalid`);
187
+ if (!(typeof field.language === "string" || field.language === null)) errors.push(`${path}.language invalid`);
188
+ if (!CARD_NEWS_TEXT_SOURCES.includes(field.source)) errors.push(`${path}.source invalid`);
189
+ }
190
+
191
+ export function validatePlannerOutput(output, roleTemplate) {
192
+ const errors = [];
193
+ if (!output || typeof output !== "object" || Array.isArray(output)) {
194
+ return { ok: false, repaired: false, errors: ["output must be an object"] };
195
+ }
196
+ if (typeof output.title !== "string") errors.push("title must be a string");
197
+ if (typeof output.topic !== "string") errors.push("topic must be a string");
198
+ if (!Array.isArray(output.cards)) errors.push("cards must be an array");
199
+
200
+ const roles = roleTemplate?.roles || [];
201
+ if (Array.isArray(output.cards) && output.cards.length !== roles.length) {
202
+ errors.push("cards length must match role template");
203
+ }
204
+
205
+ const cards = Array.isArray(output.cards) ? output.cards : [];
206
+ cards.forEach((card, index) => {
207
+ const expected = roles[index];
208
+ if (!card || typeof card !== "object") {
209
+ errors.push(`card ${index + 1} must be an object`);
210
+ return;
211
+ }
212
+ if (card.order !== index + 1) errors.push(`card ${index + 1} order mismatch`);
213
+ if (expected && card.role !== expected.role) errors.push(`card ${index + 1} role mismatch`);
214
+ for (const key of ["headline", "body", "visualPrompt"]) {
215
+ if (typeof card[key] !== "string") errors.push(`card ${index + 1} ${key} must be string`);
216
+ }
217
+ if (!Array.isArray(card.textFields)) errors.push(`card ${index + 1} textFields must be array`);
218
+ if (Array.isArray(card.textFields)) {
219
+ card.textFields.forEach((field, fieldIndex) =>
220
+ validateTextField(field, `card ${index + 1} textFields ${fieldIndex + 1}`, errors));
221
+ for (const field of card.textFields) {
222
+ if (
223
+ field?.renderMode === "in-image" &&
224
+ typeof field.text === "string" &&
225
+ field.text.trim().length >= 4 &&
226
+ typeof card.visualPrompt === "string" &&
227
+ card.visualPrompt.includes(field.text.trim())
228
+ ) {
229
+ errors.push(`card ${index + 1} visualPrompt must not duplicate exact visible text`);
230
+ }
231
+ }
232
+ }
233
+ if (!Array.isArray(card.references)) errors.push(`card ${index + 1} references must be array`);
234
+ if (card.locked !== false) errors.push(`card ${index + 1} locked must be false`);
235
+ });
236
+
237
+ if (errors.length) return { ok: false, repaired: false, errors };
238
+ return {
239
+ ok: true,
240
+ repaired: false,
241
+ errors: [],
242
+ plan: {
243
+ title: output.title.trim(),
244
+ topic: output.topic.trim(),
245
+ audience: asText(output.audience),
246
+ goal: asText(output.goal),
247
+ cards: cards.map((card) => ({
248
+ order: card.order,
249
+ role: card.role,
250
+ headline: card.headline.trim(),
251
+ body: card.body.trim(),
252
+ visualPrompt: card.visualPrompt.trim(),
253
+ textFields: normalizeTextFields(card.textFields),
254
+ references: card.references.filter((ref) => typeof ref === "string"),
255
+ locked: false,
256
+ })),
257
+ },
258
+ };
259
+ }
@@ -0,0 +1,47 @@
1
+ const ROLE_TEMPLATES = [
2
+ {
3
+ id: "short-3",
4
+ name: "Short 3",
5
+ defaultCount: 3,
6
+ roles: [
7
+ { role: "hook", required: true, promptHint: "strong opening card", preferredSlots: ["title", "visual"] },
8
+ { role: "core", required: true, promptHint: "main explanation card", preferredSlots: ["visual", "body"] },
9
+ { role: "cta", required: true, promptHint: "clear call to action card", preferredSlots: ["cta"] },
10
+ ],
11
+ },
12
+ {
13
+ id: "mid-5",
14
+ name: "Mid 5",
15
+ defaultCount: 5,
16
+ roles: [
17
+ { role: "cover", required: true, promptHint: "cover card with strong headline", preferredSlots: ["title", "visual"] },
18
+ { role: "problem", required: true, promptHint: "problem framing card", preferredSlots: ["title", "body"] },
19
+ { role: "insight", required: true, promptHint: "insight or solution card", preferredSlots: ["visual", "body"] },
20
+ { role: "example", required: true, promptHint: "example or proof card", preferredSlots: ["visual", "body"] },
21
+ { role: "cta", required: true, promptHint: "closing call to action card", preferredSlots: ["cta"] },
22
+ ],
23
+ },
24
+ {
25
+ id: "long-8",
26
+ name: "Long 8",
27
+ defaultCount: 8,
28
+ roles: [
29
+ { role: "cover", required: true, promptHint: "cover card", preferredSlots: ["title"] },
30
+ { role: "problem", required: true, promptHint: "problem card", preferredSlots: ["body"] },
31
+ { role: "data", required: true, promptHint: "data card", preferredSlots: ["visual"] },
32
+ { role: "tip1", required: true, promptHint: "first tip card", preferredSlots: ["body"] },
33
+ { role: "tip2", required: true, promptHint: "second tip card", preferredSlots: ["body"] },
34
+ { role: "example", required: true, promptHint: "example card", preferredSlots: ["visual"] },
35
+ { role: "summary", required: true, promptHint: "summary card", preferredSlots: ["body"] },
36
+ { role: "cta", required: true, promptHint: "call to action card", preferredSlots: ["cta"] },
37
+ ],
38
+ },
39
+ ];
40
+
41
+ export function listRoleTemplates() {
42
+ return ROLE_TEMPLATES;
43
+ }
44
+
45
+ export function getRoleTemplate(roleTemplateId = "mid-5") {
46
+ return ROLE_TEMPLATES.find((t) => t.id === roleTemplateId) || ROLE_TEMPLATES[1];
47
+ }
@@ -0,0 +1,210 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, join, normalize, sep } from "node:path";
4
+
5
+ const TEMPLATE_ROOT = ["assets", "card-news", "templates"];
6
+ const IMAGE_TEMPLATE_REGISTRY = [
7
+ {
8
+ id: "clean-report-square",
9
+ label: "Clean editorial report",
10
+ recommendedOutputSizes: ["1024x1024", "2048x2048"],
11
+ },
12
+ {
13
+ id: "academy-lesson-square",
14
+ label: "Academy lesson carousel",
15
+ recommendedOutputSizes: ["1024x1024", "2048x2048"],
16
+ },
17
+ ];
18
+ const OUTPUT_SIZE_RE = /^(1024|2048|[1-3][0-9]{3})x(1024|2048|[1-3][0-9]{3})$/;
19
+ const PLACEMENTS = new Set([
20
+ "top-left",
21
+ "top-center",
22
+ "top-right",
23
+ "center-left",
24
+ "center",
25
+ "center-right",
26
+ "bottom-left",
27
+ "bottom-center",
28
+ "bottom-right",
29
+ "free",
30
+ ]);
31
+
32
+ function assertSafeId(id) {
33
+ if (typeof id !== "string" || !/^[a-z0-9][a-z0-9-]{1,80}$/.test(id)) {
34
+ const err = new Error("Invalid template id");
35
+ err.status = 400;
36
+ err.code = "CARD_NEWS_BAD_TEMPLATE_ID";
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ function templateDir(ctx, templateId) {
42
+ assertSafeId(templateId);
43
+ const root = join(ctx.rootDir, ...TEMPLATE_ROOT);
44
+ const dir = join(root, templateId);
45
+ const normalizedRoot = normalize(root + sep);
46
+ const normalizedDir = normalize(dir + sep);
47
+ if (!normalizedDir.startsWith(normalizedRoot)) {
48
+ const err = new Error("Template path escapes root");
49
+ err.status = 400;
50
+ err.code = "CARD_NEWS_BAD_TEMPLATE_PATH";
51
+ throw err;
52
+ }
53
+ return dir;
54
+ }
55
+
56
+ function publicTemplate(t) {
57
+ return {
58
+ id: t.id,
59
+ name: t.name,
60
+ description: t.description || "",
61
+ size: t.size,
62
+ previewUrl: `/api/cardnews/image-templates/${encodeURIComponent(t.id)}/preview`,
63
+ stylePrompt: t.stylePrompt,
64
+ negativePrompt: t.negativePrompt || "",
65
+ slots: normalizeSlots(t.slots),
66
+ palette: t.palette || [],
67
+ typography: t.typography || null,
68
+ recommendedOutputSizes: Array.isArray(t.recommendedOutputSizes) ? t.recommendedOutputSizes : [],
69
+ authoringLabel: t.authoringLabel || t.name,
70
+ recommendedRoleNodeIds: t.recommendedRoleNodeIds || [],
71
+ createdBy: t.createdBy || "system",
72
+ };
73
+ }
74
+
75
+ function normalizeLegacySlotKind(kind) {
76
+ if (kind === "title") return { kind: "text", textKind: "headline" };
77
+ if (kind === "body") return { kind: "text", textKind: "body" };
78
+ if (kind === "cta") return { kind: "text", textKind: "cta" };
79
+ if (kind === "image") return { kind: "image", textKind: null };
80
+ if (kind === "text" || kind === "mixed" || kind === "safe-area") return { kind, textKind: null };
81
+ return { kind: "mixed", textKind: null };
82
+ }
83
+
84
+ function normalizeSlot(slot = {}) {
85
+ const legacy = normalizeLegacySlotKind(slot.kind);
86
+ return {
87
+ ...slot,
88
+ id: typeof slot.id === "string" && slot.id ? slot.id : "slot",
89
+ kind: legacy.kind,
90
+ textKind: slot.textKind || legacy.textKind || null,
91
+ label: typeof slot.label === "string" && slot.label ? slot.label : (slot.id || "slot"),
92
+ placement: PLACEMENTS.has(slot.placement) ? slot.placement : "free",
93
+ x: Number.isFinite(slot.x) ? slot.x : 0,
94
+ y: Number.isFinite(slot.y) ? slot.y : 0,
95
+ w: Number.isFinite(slot.w) ? slot.w : 100,
96
+ h: Number.isFinite(slot.h) ? slot.h : 100,
97
+ required: Boolean(slot.required),
98
+ maxChars: Number.isFinite(slot.maxChars) ? slot.maxChars : null,
99
+ safeArea: Boolean(slot.safeArea),
100
+ };
101
+ }
102
+
103
+ function normalizeSlots(slots) {
104
+ return Array.isArray(slots) ? slots.map(normalizeSlot) : [];
105
+ }
106
+
107
+ function registryEntry(templateId) {
108
+ return IMAGE_TEMPLATE_REGISTRY.find((entry) => entry.id === templateId);
109
+ }
110
+
111
+ function validateTemplateAuthoring(template) {
112
+ const problems = [];
113
+ if (typeof template.name !== "string" || !template.name.trim()) problems.push("name");
114
+ if (typeof template.size !== "string" || !OUTPUT_SIZE_RE.test(template.size)) problems.push("size");
115
+ if (typeof template.stylePrompt !== "string" || !template.stylePrompt.trim()) problems.push("stylePrompt");
116
+ if (!Array.isArray(template.slots) || template.slots.length === 0) problems.push("slots");
117
+ const ids = new Set();
118
+ for (const slot of normalizeSlots(template.slots)) {
119
+ if (ids.has(slot.id)) problems.push(`duplicate slot ${slot.id}`);
120
+ ids.add(slot.id);
121
+ if (!PLACEMENTS.has(slot.placement)) problems.push(`slot ${slot.id} placement`);
122
+ if ((slot.kind === "text" || slot.textKind) && !slot.maxChars) problems.push(`slot ${slot.id} maxChars`);
123
+ }
124
+ if (
125
+ template.recommendedOutputSizes &&
126
+ (!Array.isArray(template.recommendedOutputSizes) ||
127
+ template.recommendedOutputSizes.some((size) => typeof size !== "string" || !OUTPUT_SIZE_RE.test(size)))
128
+ ) {
129
+ problems.push("recommendedOutputSizes");
130
+ }
131
+ if (problems.length) {
132
+ const err = new Error(`Template authoring metadata invalid: ${problems.join(", ")}`);
133
+ err.status = 500;
134
+ err.code = "CARD_NEWS_TEMPLATE_AUTHORING_INVALID";
135
+ throw err;
136
+ }
137
+ }
138
+
139
+ async function readTemplate(ctx, templateId) {
140
+ const dir = templateDir(ctx, templateId);
141
+ const raw = await readFile(join(dir, "template.json"), "utf8");
142
+ const parsed = JSON.parse(raw);
143
+ const id = parsed.id || templateId;
144
+ if (id !== templateId) {
145
+ const err = new Error("Template id mismatch");
146
+ err.status = 500;
147
+ err.code = "CARD_NEWS_TEMPLATE_ID_MISMATCH";
148
+ throw err;
149
+ }
150
+ const entry = registryEntry(templateId);
151
+ if (!entry) {
152
+ const err = new Error("Template is not registered");
153
+ err.status = 500;
154
+ err.code = "CARD_NEWS_TEMPLATE_NOT_REGISTERED";
155
+ throw err;
156
+ }
157
+ validateTemplateAuthoring(parsed);
158
+ return {
159
+ ...parsed,
160
+ authoringLabel: parsed.authoringLabel || entry.label,
161
+ recommendedOutputSizes: parsed.recommendedOutputSizes || entry.recommendedOutputSizes,
162
+ slots: normalizeSlots(parsed.slots),
163
+ previewFilename: parsed.previewFilename || "preview.png",
164
+ baseFilename: parsed.baseFilename || "base.png",
165
+ createdBy: "system",
166
+ };
167
+ }
168
+
169
+ export async function listImageTemplates(ctx) {
170
+ const templates = [];
171
+ for (const entry of IMAGE_TEMPLATE_REGISTRY) {
172
+ templates.push(publicTemplate(await readTemplate(ctx, entry.id)));
173
+ }
174
+ return templates;
175
+ }
176
+
177
+ export async function getImageTemplate(ctx, templateId) {
178
+ return readTemplate(ctx, templateId);
179
+ }
180
+
181
+ export async function readTemplatePreview(ctx, templateId) {
182
+ const template = await readTemplate(ctx, templateId);
183
+ const filename = basename(template.previewFilename || "preview.png");
184
+ const path = join(templateDir(ctx, templateId), filename);
185
+ if (!existsSync(path)) {
186
+ const err = new Error("Template preview not found");
187
+ err.status = 404;
188
+ err.code = "CARD_NEWS_TEMPLATE_PREVIEW_NOT_FOUND";
189
+ throw err;
190
+ }
191
+ return readFile(path);
192
+ }
193
+
194
+ export async function readTemplateBaseB64(ctx, templateId) {
195
+ const template = await readTemplate(ctx, templateId);
196
+ const filename = basename(template.baseFilename || "base.png");
197
+ const path = join(templateDir(ctx, templateId), filename);
198
+ if (!existsSync(path)) {
199
+ const err = new Error("Template base image not found");
200
+ err.status = 404;
201
+ err.code = "CARD_NEWS_TEMPLATE_BASE_NOT_FOUND";
202
+ throw err;
203
+ }
204
+ const buf = await readFile(path);
205
+ return {
206
+ template,
207
+ templateBase: join(...TEMPLATE_ROOT, templateId, filename),
208
+ b64: buf.toString("base64"),
209
+ };
210
+ }
package/lib/db.js CHANGED
@@ -59,6 +59,23 @@ function migrate(database) {
59
59
 
60
60
  CREATE INDEX IF NOT EXISTS idx_nodes_session ON nodes(session_id);
61
61
  CREATE INDEX IF NOT EXISTS idx_edges_session ON edges(session_id);
62
+
63
+ CREATE TABLE IF NOT EXISTS inflight (
64
+ request_id TEXT PRIMARY KEY,
65
+ kind TEXT NOT NULL,
66
+ prompt TEXT NOT NULL DEFAULT '',
67
+ meta TEXT NOT NULL DEFAULT '{}',
68
+ session_id TEXT,
69
+ parent_node_id TEXT,
70
+ client_node_id TEXT,
71
+ started_at INTEGER NOT NULL,
72
+ phase TEXT NOT NULL DEFAULT 'queued',
73
+ phase_at INTEGER NOT NULL
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_inflight_started ON inflight(started_at);
77
+ CREATE INDEX IF NOT EXISTS idx_inflight_kind ON inflight(kind);
78
+ CREATE INDEX IF NOT EXISTS idx_inflight_session ON inflight(session_id);
62
79
  `);
63
80
 
64
81
  const sessionColumns = database
@@ -81,10 +98,10 @@ function migrate(database) {
81
98
 
82
99
  const row = database.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get();
83
100
  if (!row) {
84
- database.prepare("INSERT INTO _meta (key, value) VALUES ('schema_version', '2')").run();
85
- } else if (row.value !== "2") {
101
+ database.prepare("INSERT INTO _meta (key, value) VALUES ('schema_version', '3')").run();
102
+ } else if (row.value !== "3") {
86
103
  database
87
- .prepare("UPDATE _meta SET value = '2' WHERE key = 'schema_version'")
104
+ .prepare("UPDATE _meta SET value = '3' WHERE key = 'schema_version'")
88
105
  .run();
89
106
  }
90
107
  }
@@ -15,7 +15,7 @@ export function classifyUpstreamError(msg) {
15
15
  const s = String(msg || "").toLowerCase();
16
16
  if (!s) return "UNKNOWN";
17
17
 
18
- if (s.includes("content generation refused") || s.includes("moderation_blocked") || s.includes("moderation refused")) {
18
+ if (s.includes("moderation_blocked") || s.includes("moderation refused")) {
19
19
  return "MODERATION_REFUSED";
20
20
  }
21
21
 
@@ -50,7 +50,7 @@ export function classifyUpstreamError(msg) {
50
50
  return "NETWORK_FAILED";
51
51
  }
52
52
 
53
- if (s.includes("oauth") && (s.includes("not running") || s.includes("unavailable"))) {
53
+ if (s.includes("oauth") && (s.includes("not running") || s.includes("unavailable") || s.includes("not ready"))) {
54
54
  return "OAUTH_UNAVAILABLE";
55
55
  }
56
56
 
@@ -0,0 +1,51 @@
1
+ import { classifyUpstreamError } from "./errorClassify.js";
2
+
3
+ const PASSTHROUGH_CODES = new Set([
4
+ "OAUTH_UNAVAILABLE",
5
+ "NETWORK_FAILED",
6
+ "AUTH_CHATGPT_EXPIRED",
7
+ "AUTH_API_KEY_INVALID",
8
+ "UPSTREAM_5XX",
9
+ ]);
10
+
11
+ const SAFETY_CODES = new Set(["SAFETY_REFUSAL", "MODERATION_REFUSED", "moderation_blocked"]);
12
+
13
+ export function errorCodeFrom(err) {
14
+ if (!err) return "UNKNOWN";
15
+ if (typeof err.code === "string" && err.code) return err.code;
16
+ const direct = classifyUpstreamError(err.message);
17
+ if (direct !== "UNKNOWN") return direct;
18
+ if (err.cause) return errorCodeFrom(err.cause);
19
+ return "UNKNOWN";
20
+ }
21
+
22
+ export function statusForErrorCode(code, fallback = 500) {
23
+ if (code === "OAUTH_UNAVAILABLE" || code === "NETWORK_FAILED") return 503;
24
+ if (code === "AUTH_CHATGPT_EXPIRED" || code === "AUTH_API_KEY_INVALID") return 401;
25
+ if (code === "UPSTREAM_5XX") return 502;
26
+ if (code === "SAFETY_REFUSAL" || code === "MODERATION_REFUSED" || code === "moderation_blocked") return 422;
27
+ return fallback;
28
+ }
29
+
30
+ export function normalizeGenerationFailure(lastErr, options = {}) {
31
+ const code = errorCodeFrom(lastErr);
32
+ if (PASSTHROUGH_CODES.has(code)) {
33
+ const err = new Error(lastErr?.message || options.proxyMessage || "OAuth proxy/network failure");
34
+ err.code = code;
35
+ err.status = lastErr?.status || statusForErrorCode(code);
36
+ err.cause = lastErr;
37
+ return err;
38
+ }
39
+ if (SAFETY_CODES.has(code)) {
40
+ const err = new Error(options.safetyMessage || lastErr?.message || "Content generation refused after retries");
41
+ err.code = "SAFETY_REFUSAL";
42
+ err.status = 422;
43
+ err.cause = lastErr;
44
+ return err;
45
+ }
46
+ const err = new Error(options.safetyMessage || "Content generation refused after retries");
47
+ err.code = "SAFETY_REFUSAL";
48
+ err.status = 422;
49
+ err.cause = lastErr;
50
+ return err;
51
+ }