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.
- package/README.md +47 -7
- package/assets/card-news/templates/academy-lesson-square/base.png +0 -0
- package/assets/card-news/templates/academy-lesson-square/preview.png +0 -0
- package/assets/card-news/templates/academy-lesson-square/template.json +20 -0
- package/assets/card-news/templates/clean-report-square/base.png +0 -0
- package/assets/card-news/templates/clean-report-square/preview.png +0 -0
- package/assets/card-news/templates/clean-report-square/template.json +20 -0
- package/bin/commands/cancel.js +45 -0
- package/bin/commands/edit.js +33 -4
- package/bin/commands/gen.js +26 -3
- package/bin/commands/ps.js +48 -16
- package/bin/ima2.js +56 -12
- package/bin/lib/client.js +4 -1
- package/bin/lib/error-hints.js +23 -0
- package/bin/lib/output.js +10 -0
- package/config.js +19 -1
- package/docs/API.md +67 -0
- package/docs/FAQ.ko.md +248 -0
- package/docs/FAQ.md +256 -0
- package/docs/README.ja.md +4 -0
- package/docs/README.ko.md +14 -1
- package/docs/README.zh-CN.md +4 -0
- package/docs/RECOVER_OLD_IMAGES.md +2 -0
- package/lib/cardNewsGenerator.js +162 -0
- package/lib/cardNewsJobStore.js +107 -0
- package/lib/cardNewsManifestStore.js +112 -0
- package/lib/cardNewsPlanner.js +180 -0
- package/lib/cardNewsPlannerClient.js +112 -0
- package/lib/cardNewsPlannerPrompt.js +60 -0
- package/lib/cardNewsPlannerSchema.js +259 -0
- package/lib/cardNewsRoleTemplateStore.js +47 -0
- package/lib/cardNewsTemplateStore.js +210 -0
- package/lib/db.js +20 -3
- package/lib/errorClassify.js +2 -2
- package/lib/generationErrors.js +51 -0
- package/lib/historyList.js +82 -8
- package/lib/inflight.js +117 -34
- package/lib/logger.js +37 -3
- package/lib/oauthLauncher.js +52 -19
- package/lib/oauthProxy.js +81 -14
- package/lib/requestLogger.js +48 -0
- package/lib/runtimePorts.js +93 -0
- package/lib/sessionStore.js +48 -7
- package/package.json +3 -2
- package/routes/cardNews.js +183 -0
- package/routes/edit.js +1 -1
- package/routes/generate.js +10 -10
- package/routes/health.js +27 -3
- package/routes/index.js +2 -0
- package/routes/nodes.js +93 -26
- package/server.js +91 -18
- package/ui/dist/assets/index-BjX_nzuK.js +23 -0
- package/ui/dist/assets/index-BjX_nzuK.js.map +1 -0
- package/ui/dist/assets/index-DHyUax4_.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CqpVoXpZ.css +0 -1
- package/ui/dist/assets/index-IHSd1z1a.js +0 -22
- package/ui/dist/assets/index-IHSd1z1a.js.map +0 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function writeCardNewsManifest(generatedDir, manifest) {
|
|
5
|
+
const dir = join(generatedDir, "cardnews", manifest.setId);
|
|
6
|
+
await mkdir(dir, { recursive: true });
|
|
7
|
+
await writeFile(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
8
|
+
return { dir, manifestFilename: "manifest.json" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function writeCardSidecar(dir, filename, sidecar) {
|
|
12
|
+
await writeFile(join(dir, filename), JSON.stringify(sidecar, null, 2));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function cardUrl(setId, imageFilename) {
|
|
16
|
+
if (!imageFilename) return undefined;
|
|
17
|
+
return `/generated/cardnews/${encodeURIComponent(setId)}/${encodeURIComponent(imageFilename)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertSafeSetId(setId) {
|
|
21
|
+
if (typeof setId === "string" && /^[a-zA-Z0-9_-]{3,120}$/.test(setId)) return setId;
|
|
22
|
+
const err = new Error("Card News set not found");
|
|
23
|
+
err.status = 404;
|
|
24
|
+
err.code = "CARD_NEWS_SET_NOT_FOUND";
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function manifestToPlan(manifest) {
|
|
29
|
+
return {
|
|
30
|
+
setId: manifest.setId,
|
|
31
|
+
title: manifest.title || "Untitled card news",
|
|
32
|
+
topic: manifest.topic || manifest.title || "Untitled card news",
|
|
33
|
+
imageTemplateId: manifest.imageTemplateId || "academy-lesson-square",
|
|
34
|
+
roleTemplateId: manifest.roleTemplateId || "mid-5",
|
|
35
|
+
size: manifest.size || "2048x2048",
|
|
36
|
+
generationStrategy: manifest.generationStrategy || "parallel-template-i2i",
|
|
37
|
+
cards: (manifest.cards || []).map((card, index) => ({
|
|
38
|
+
id: card.cardId || card.id || `card_${index + 1}`,
|
|
39
|
+
order: card.cardOrder || card.order || index + 1,
|
|
40
|
+
role: card.role || "card",
|
|
41
|
+
headline: card.headline || "",
|
|
42
|
+
body: card.body || "",
|
|
43
|
+
visualPrompt: card.visualPrompt || "",
|
|
44
|
+
textFields: Array.isArray(card.textFields) ? card.textFields : [],
|
|
45
|
+
references: card.references || [],
|
|
46
|
+
locked: !!card.locked,
|
|
47
|
+
status: card.status || "generated",
|
|
48
|
+
error: card.error?.message || card.error || undefined,
|
|
49
|
+
imageFilename: card.imageFilename || undefined,
|
|
50
|
+
url: card.url || cardUrl(manifest.setId, card.imageFilename),
|
|
51
|
+
})),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readCardNewsSetPlan(ctx, setId) {
|
|
56
|
+
return manifestToPlan(await readCardNewsManifest(ctx, setId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readCardNewsManifest(ctx, setId) {
|
|
60
|
+
const safeSetId = assertSafeSetId(setId);
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFile(
|
|
63
|
+
join(ctx.config.storage.generatedDir, "cardnews", safeSetId, "manifest.json"),
|
|
64
|
+
"utf8",
|
|
65
|
+
);
|
|
66
|
+
return JSON.parse(raw);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === "CARD_NEWS_SET_NOT_FOUND") throw err;
|
|
69
|
+
const notFound = new Error("Card News set not found");
|
|
70
|
+
notFound.status = 404;
|
|
71
|
+
notFound.code = "CARD_NEWS_SET_NOT_FOUND";
|
|
72
|
+
throw notFound;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function listCardNewsSets(ctx) {
|
|
77
|
+
const root = join(ctx.config.storage.generatedDir, "cardnews");
|
|
78
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
79
|
+
const sets = [];
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.isDirectory()) continue;
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readFile(join(root, entry.name, "manifest.json"), "utf8");
|
|
84
|
+
const manifest = JSON.parse(raw);
|
|
85
|
+
const first = (manifest.cards || []).find((card) => card.imageFilename);
|
|
86
|
+
sets.push({
|
|
87
|
+
setId: manifest.setId || entry.name,
|
|
88
|
+
title: manifest.title || "Untitled card news",
|
|
89
|
+
cardCount: manifest.cardCount || manifest.cards?.length || 0,
|
|
90
|
+
createdAt: manifest.createdAt || 0,
|
|
91
|
+
sessionId: manifest.sessionId || null,
|
|
92
|
+
manifestUrl: `/api/cardnews/sets/${encodeURIComponent(manifest.setId || entry.name)}/manifest`,
|
|
93
|
+
folderLabel: `generated/cardnews/${manifest.setId || entry.name}`,
|
|
94
|
+
url: cardUrl(manifest.setId || entry.name, first?.imageFilename),
|
|
95
|
+
cards: (manifest.cards || []).map((card) => ({
|
|
96
|
+
id: card.cardId,
|
|
97
|
+
order: card.cardOrder,
|
|
98
|
+
headline: card.headline,
|
|
99
|
+
body: card.body,
|
|
100
|
+
textFields: Array.isArray(card.textFields) ? card.textFields : [],
|
|
101
|
+
imageFilename: card.imageFilename,
|
|
102
|
+
status: card.status || "generated",
|
|
103
|
+
url: cardUrl(manifest.setId || entry.name, card.imageFilename),
|
|
104
|
+
})),
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn("[card-news] set manifest read failed", entry.name, err.message);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
sets.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
111
|
+
return sets;
|
|
112
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ulid } from "ulid";
|
|
2
|
+
import { getRoleTemplate } from "./cardNewsRoleTemplateStore.js";
|
|
3
|
+
import { getImageTemplate } from "./cardNewsTemplateStore.js";
|
|
4
|
+
import { buildCardNewsPlannerMessages } from "./cardNewsPlannerPrompt.js";
|
|
5
|
+
import { requestCardNewsPlannerJson } from "./cardNewsPlannerClient.js";
|
|
6
|
+
import { repairPlannerOutput, validatePlannerOutput } from "./cardNewsPlannerSchema.js";
|
|
7
|
+
import { waitForOAuthReady } from "./oauthProxy.js";
|
|
8
|
+
|
|
9
|
+
function compactText(value, fallback) {
|
|
10
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
11
|
+
return text || fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function detectBriefLanguage(input) {
|
|
15
|
+
const text = [input.topic, input.audience, input.goal, input.contentBrief].filter(Boolean).join(" ");
|
|
16
|
+
if (/[가-힣]/.test(text)) return "ko";
|
|
17
|
+
if (/[A-Za-z]/.test(text)) return "en";
|
|
18
|
+
return "und";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fallbackLabel(role, lang) {
|
|
22
|
+
const ko = {
|
|
23
|
+
cta: "다음 행동",
|
|
24
|
+
problem: "왜 중요한가",
|
|
25
|
+
insight: "핵심 인사이트",
|
|
26
|
+
example: "예시로 보기",
|
|
27
|
+
data: "숫자로 확인",
|
|
28
|
+
summary: "요약",
|
|
29
|
+
};
|
|
30
|
+
const en = {
|
|
31
|
+
cta: "Next action",
|
|
32
|
+
problem: "Why it matters",
|
|
33
|
+
insight: "Key insight",
|
|
34
|
+
example: "Example",
|
|
35
|
+
data: "By the numbers",
|
|
36
|
+
summary: "Summary",
|
|
37
|
+
};
|
|
38
|
+
if (lang === "ko") return ko[role] || role;
|
|
39
|
+
if (lang === "en") return en[role] || role;
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function headlineFor(role, topic, lang) {
|
|
44
|
+
const label = compactText(topic, "Card news");
|
|
45
|
+
if (role === "cover" || role === "hook") return label;
|
|
46
|
+
return fallbackLabel(role, lang) || label;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bodyFor(role, brief, lang) {
|
|
50
|
+
const content = compactText(brief.content, "");
|
|
51
|
+
if (content) return content;
|
|
52
|
+
const target = compactText(brief.audience, lang === "ko" ? "독자" : "reader");
|
|
53
|
+
const goal = compactText(brief.goal, lang === "ko" ? "핵심 메시지" : "the key message");
|
|
54
|
+
if (lang === "ko") {
|
|
55
|
+
if (role === "cta") return `${target}가 바로 실행할 수 있는 다음 단계를 제안합니다.`;
|
|
56
|
+
if (role === "problem") return `${target}가 겪는 문제를 짧고 분명하게 보여줍니다.`;
|
|
57
|
+
if (role === "insight") return `${goal}을 이해하기 쉬운 한 문장으로 정리합니다.`;
|
|
58
|
+
return `${goal}을 카드 역할에 맞춰 전달합니다.`;
|
|
59
|
+
}
|
|
60
|
+
if (role === "cta") return `Suggest a next step ${target} can take immediately.`;
|
|
61
|
+
if (role === "problem") return `Show the problem ${target} faces in a concise way.`;
|
|
62
|
+
if (role === "insight") return `Explain ${goal} in one clear sentence.`;
|
|
63
|
+
return `Present ${goal} for this card.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeTextFields(fields) {
|
|
67
|
+
return Array.isArray(fields) ? fields : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toCardNewsPlan(plannerOutput, input, roleTemplate) {
|
|
71
|
+
const topic = compactText(plannerOutput.topic, compactText(input.topic, input.title || "Untitled card news"));
|
|
72
|
+
return {
|
|
73
|
+
setId: input.setId || `cs_${ulid()}`,
|
|
74
|
+
title: compactText(plannerOutput.title, topic),
|
|
75
|
+
topic,
|
|
76
|
+
imageTemplateId: input.imageTemplateId || "academy-lesson-square",
|
|
77
|
+
roleTemplateId: roleTemplate.id,
|
|
78
|
+
size: input.size || "2048x2048",
|
|
79
|
+
generationStrategy: "parallel-template-i2i",
|
|
80
|
+
cards: plannerOutput.cards.map((card, index) => ({
|
|
81
|
+
id: `card_${index + 1}`,
|
|
82
|
+
order: index + 1,
|
|
83
|
+
role: card.role,
|
|
84
|
+
headline: card.headline,
|
|
85
|
+
body: card.body,
|
|
86
|
+
visualPrompt: card.visualPrompt,
|
|
87
|
+
textFields: normalizeTextFields(card.textFields),
|
|
88
|
+
templateSlotAssignments: {
|
|
89
|
+
title: "headline",
|
|
90
|
+
body: "body",
|
|
91
|
+
image: "visual",
|
|
92
|
+
},
|
|
93
|
+
references: card.references || [],
|
|
94
|
+
locked: false,
|
|
95
|
+
status: "draft",
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createDeterministicCardNewsDraft(input = {}) {
|
|
101
|
+
const roleTemplate = getRoleTemplate(input.roleTemplateId);
|
|
102
|
+
const topic = compactText(input.topic, input.title || "Untitled card news");
|
|
103
|
+
const title = compactText(input.title, topic);
|
|
104
|
+
const brief = {
|
|
105
|
+
audience: input.audience,
|
|
106
|
+
goal: input.goal,
|
|
107
|
+
content: input.contentBrief,
|
|
108
|
+
};
|
|
109
|
+
const lang = detectBriefLanguage(input);
|
|
110
|
+
const output = {
|
|
111
|
+
title,
|
|
112
|
+
topic,
|
|
113
|
+
audience: compactText(input.audience, ""),
|
|
114
|
+
goal: compactText(input.goal, ""),
|
|
115
|
+
cards: roleTemplate.roles.map((role, idx) => ({
|
|
116
|
+
order: idx + 1,
|
|
117
|
+
role: role.role,
|
|
118
|
+
headline: headlineFor(role.role, topic, lang),
|
|
119
|
+
body: bodyFor(role.role, brief, lang),
|
|
120
|
+
visualPrompt: `${role.promptHint}, ${topic}`,
|
|
121
|
+
textFields: [],
|
|
122
|
+
references: [],
|
|
123
|
+
locked: false,
|
|
124
|
+
})),
|
|
125
|
+
};
|
|
126
|
+
return toCardNewsPlan(output, input, roleTemplate);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function plannerError(message, code, status) {
|
|
130
|
+
const err = new Error(message);
|
|
131
|
+
err.code = code;
|
|
132
|
+
err.status = status;
|
|
133
|
+
return err;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function createCardNewsDraft(ctxOrInput = {}, maybeInput = {}) {
|
|
137
|
+
const hasCtx = !!ctxOrInput?.config;
|
|
138
|
+
const ctx = hasCtx ? ctxOrInput : null;
|
|
139
|
+
const input = hasCtx ? maybeInput : ctxOrInput;
|
|
140
|
+
const roleTemplate = getRoleTemplate(input.roleTemplateId);
|
|
141
|
+
|
|
142
|
+
if (!ctx) return createDeterministicCardNewsDraft(input);
|
|
143
|
+
if (!ctx.config.cardNewsPlanner?.enabled) {
|
|
144
|
+
return {
|
|
145
|
+
plan: createDeterministicCardNewsDraft(input),
|
|
146
|
+
planner: { mode: "deterministic-fallback", model: "none", repaired: false },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const imageTemplate = await getImageTemplate(ctx, input.imageTemplateId || "academy-lesson-square");
|
|
151
|
+
try {
|
|
152
|
+
await waitForOAuthReady(ctx);
|
|
153
|
+
const messages = buildCardNewsPlannerMessages({ ...input, roleTemplate, imageTemplate });
|
|
154
|
+
const raw = await requestCardNewsPlannerJson({ messages }, {
|
|
155
|
+
oauthUrl: ctx.oauthUrl,
|
|
156
|
+
model: ctx.config.cardNewsPlanner.model,
|
|
157
|
+
timeoutMs: ctx.config.cardNewsPlanner.timeoutMs,
|
|
158
|
+
});
|
|
159
|
+
let result = validatePlannerOutput(raw.output, roleTemplate);
|
|
160
|
+
if (!result.ok) result = repairPlannerOutput(raw.output, { ...input, roleTemplate });
|
|
161
|
+
if (!result.ok) throw plannerError("Planner schema invalid", "PLANNER_SCHEMA_INVALID", 422);
|
|
162
|
+
return {
|
|
163
|
+
plan: toCardNewsPlan(result.plan, input, roleTemplate),
|
|
164
|
+
planner: { mode: raw.mode, model: raw.model, repaired: result.repaired },
|
|
165
|
+
};
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (ctx.config.cardNewsPlanner.deterministicFallback) {
|
|
168
|
+
return {
|
|
169
|
+
plan: createDeterministicCardNewsDraft(input),
|
|
170
|
+
planner: {
|
|
171
|
+
mode: "deterministic-fallback",
|
|
172
|
+
model: ctx.config.cardNewsPlanner.model,
|
|
173
|
+
repaired: true,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (err.code) throw err;
|
|
178
|
+
throw plannerError(err.message || "Planner unavailable", "PLANNER_UNAVAILABLE", 503);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { CARD_NEWS_PLANNER_SCHEMA } from "./cardNewsPlannerSchema.js";
|
|
3
|
+
import { logEvent } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
function plannerError(message, code, status = 502) {
|
|
6
|
+
const err = new Error(message);
|
|
7
|
+
err.code = code;
|
|
8
|
+
err.status = status;
|
|
9
|
+
return err;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function extractText(json) {
|
|
13
|
+
if (typeof json.output_text === "string") return json.output_text;
|
|
14
|
+
for (const item of json.output || []) {
|
|
15
|
+
for (const content of item.content || []) {
|
|
16
|
+
if (typeof content.text === "string") return content.text;
|
|
17
|
+
if (typeof content.value === "string") return content.value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function requestJson({ oauthUrl, model, messages, timeoutMs, structured }) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
26
|
+
try {
|
|
27
|
+
const body = {
|
|
28
|
+
model,
|
|
29
|
+
input: messages,
|
|
30
|
+
stream: false,
|
|
31
|
+
...(structured
|
|
32
|
+
? {
|
|
33
|
+
text: {
|
|
34
|
+
format: {
|
|
35
|
+
type: "json_schema",
|
|
36
|
+
name: "card_news_planner_output",
|
|
37
|
+
strict: true,
|
|
38
|
+
schema: CARD_NEWS_PLANNER_SCHEMA,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
: { text: { format: { type: "json_object" } } }),
|
|
43
|
+
};
|
|
44
|
+
const res = await fetch(`${oauthUrl}/v1/responses`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
logEvent("card-news-planner", "response", { model, status: res.status, structured });
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
const err = plannerError("Planner upstream failed", "PLANNER_UPSTREAM_FAILED", 502);
|
|
54
|
+
err.upstreamStatus = res.status;
|
|
55
|
+
err.upstreamBodyChars = text.length;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
const json = await res.json();
|
|
59
|
+
return extractText(json);
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function requestChatJson({ oauthUrl, model, messages, timeoutMs }) {
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`${oauthUrl}/v1/chat/completions`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model,
|
|
75
|
+
messages,
|
|
76
|
+
response_format: { type: "json_object" },
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
logEvent("card-news-planner", "chat_response", { model, status: res.status });
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const text = await res.text();
|
|
82
|
+
const err = plannerError("Planner upstream failed", "PLANNER_UPSTREAM_FAILED", 502);
|
|
83
|
+
err.upstreamStatus = res.status;
|
|
84
|
+
err.upstreamBodyChars = text.length;
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
const json = await res.json();
|
|
88
|
+
return json.choices?.[0]?.message?.content || "";
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function requestCardNewsPlannerJson(input, options = {}) {
|
|
95
|
+
const oauthUrl = options.oauthUrl || `http://127.0.0.1:${config.oauth.proxyPort}`;
|
|
96
|
+
const model = options.model || config.cardNewsPlanner.model;
|
|
97
|
+
const timeoutMs = options.timeoutMs || config.cardNewsPlanner.timeoutMs;
|
|
98
|
+
let text = "";
|
|
99
|
+
let mode = "structured-output";
|
|
100
|
+
try {
|
|
101
|
+
text = await requestJson({ oauthUrl, model, messages: input.messages, timeoutMs, structured: true });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code !== "PLANNER_UPSTREAM_FAILED") throw err;
|
|
104
|
+
mode = "json-mode";
|
|
105
|
+
text = await requestChatJson({ oauthUrl, model, messages: input.messages, timeoutMs });
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return { output: JSON.parse(text), mode, model };
|
|
109
|
+
} catch {
|
|
110
|
+
throw plannerError("Planner returned invalid JSON", "PLANNER_INVALID_JSON", 502);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function buildCardNewsPlannerMessages(input = {}) {
|
|
2
|
+
const roleTemplate = input.roleTemplate || {};
|
|
3
|
+
const imageTemplate = input.imageTemplate || {};
|
|
4
|
+
return [
|
|
5
|
+
{
|
|
6
|
+
role: "developer",
|
|
7
|
+
content: [
|
|
8
|
+
"You are a Card News planning assistant.",
|
|
9
|
+
"Return JSON only. Do not call image tools. Do not generate images.",
|
|
10
|
+
"Keep the selected card count and role order exactly.",
|
|
11
|
+
"Preserve the user's original language for headline, body, textFields, and visualPrompt.",
|
|
12
|
+
"Do not translate to English unless explicitly requested.",
|
|
13
|
+
"If the user mixes languages, preserve the mix.",
|
|
14
|
+
"If the user provides exact text, preserve it exactly.",
|
|
15
|
+
"Role names such as cover/problem/cta are structural labels, not visible design text.",
|
|
16
|
+
"Keep headline and body as UI/manifest text.",
|
|
17
|
+
"Only textFields[].text with renderMode=\"in-image\" is intended to appear inside the image.",
|
|
18
|
+
"Create textFields only for text that should be readable inside the image.",
|
|
19
|
+
"Prefer template slot ids and placements when assigning textFields.",
|
|
20
|
+
"Use headline textFields for hook/cover cards, body or caption textFields for explanation cards, and cta textFields only for action cards.",
|
|
21
|
+
"Never put structural role labels such as CTA, cover, problem, insight, or example into textFields unless the user explicitly requested that exact visible wording.",
|
|
22
|
+
"Placement examples: top-right badge, top-center headline, center-left supporting caption, bottom-center CTA.",
|
|
23
|
+
"Make visualPrompt describe scene, layout, style, spatial composition, and text-box placement only.",
|
|
24
|
+
"Do not duplicate visible text in visualPrompt except to reference a text box position.",
|
|
25
|
+
"visualPrompt may mention a text box location, but the exact readable copy must live only in textFields[].text.",
|
|
26
|
+
].join("\n"),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
role: "user",
|
|
30
|
+
content: JSON.stringify({
|
|
31
|
+
topic: input.topic || "",
|
|
32
|
+
audience: input.audience || "",
|
|
33
|
+
goal: input.goal || "",
|
|
34
|
+
contentBrief: input.contentBrief || "",
|
|
35
|
+
size: input.size || "2048x2048",
|
|
36
|
+
imageTemplate: {
|
|
37
|
+
id: imageTemplate.id,
|
|
38
|
+
name: imageTemplate.name,
|
|
39
|
+
stylePrompt: imageTemplate.stylePrompt,
|
|
40
|
+
recommendedOutputSizes: imageTemplate.recommendedOutputSizes || [],
|
|
41
|
+
slots: imageTemplate.slots || [],
|
|
42
|
+
palette: imageTemplate.palette || [],
|
|
43
|
+
},
|
|
44
|
+
roleTemplate: {
|
|
45
|
+
id: roleTemplate.id,
|
|
46
|
+
roles: (roleTemplate.roles || []).map((role) => ({
|
|
47
|
+
role: role.role,
|
|
48
|
+
promptHint: role.promptHint,
|
|
49
|
+
preferredSlots: role.preferredSlots || [],
|
|
50
|
+
})),
|
|
51
|
+
},
|
|
52
|
+
textFieldPolicy: {
|
|
53
|
+
visibleTextSource: "textFields[].text",
|
|
54
|
+
renderableMode: "in-image",
|
|
55
|
+
structuralRoleLabelsAreNotVisibleText: true,
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}
|