ima2-gen 1.0.6 → 1.0.8
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/.env.example +49 -2
- package/README.md +192 -152
- package/bin/commands/edit.js +1 -1
- package/bin/commands/gen.js +1 -1
- package/bin/ima2.js +15 -7
- package/bin/lib/star-prompt.js +97 -0
- package/config.js +167 -0
- package/lib/assetLifecycle.js +9 -6
- package/lib/db.js +11 -6
- package/lib/errorClassify.js +62 -0
- package/lib/historyList.js +67 -0
- package/lib/inflight.js +70 -6
- package/lib/logger.js +116 -0
- package/lib/nodeStore.js +9 -7
- package/lib/oauthLauncher.js +31 -0
- package/lib/oauthNormalize.js +30 -0
- package/lib/oauthProxy.js +311 -0
- package/lib/refs.js +35 -0
- package/lib/sessionStore.js +49 -0
- package/lib/storageMigration.js +41 -0
- package/lib/styleSheet.js +128 -0
- package/package.json +4 -2
- package/routes/edit.js +171 -0
- package/routes/generate.js +254 -0
- package/routes/health.js +89 -0
- package/routes/history.js +102 -0
- package/routes/index.js +16 -0
- package/routes/nodes.js +340 -0
- package/routes/sessions.js +281 -0
- package/server.js +121 -1083
- package/ui/dist/assets/index-CBrmEeD7.css +1 -0
- package/ui/dist/assets/index-DRST1V_0.js +22 -0
- package/ui/dist/assets/index-DRST1V_0.js.map +1 -0
- package/ui/dist/index.html +18 -2
- package/ui/dist/assets/index-B66MK5qN.css +0 -1
- package/ui/dist/assets/index-BIwLnT0j.js +0 -16
- package/ui/dist/assets/index-BIwLnT0j.js.map +0 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { listHistoryRows } from "../lib/historyList.js";
|
|
2
|
+
import { trashAsset, restoreAsset } from "../lib/assetLifecycle.js";
|
|
3
|
+
import { getSessionTitleMap } from "../lib/sessionStore.js";
|
|
4
|
+
import { logError, logEvent } from "../lib/logger.js";
|
|
5
|
+
|
|
6
|
+
export function registerHistoryRoutes(app, ctx) {
|
|
7
|
+
app.get("/api/history", async (req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
const limitRaw = parseInt(req.query.limit);
|
|
10
|
+
const limit = Math.min(
|
|
11
|
+
Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : ctx.config.history.defaultPageSize,
|
|
12
|
+
ctx.config.history.maxPageCap,
|
|
13
|
+
);
|
|
14
|
+
const beforeTs = parseInt(req.query.before);
|
|
15
|
+
const beforeFn = typeof req.query.beforeFilename === "string" ? req.query.beforeFilename : null;
|
|
16
|
+
const sinceTs = parseInt(req.query.since);
|
|
17
|
+
const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
|
|
18
|
+
const groupBy = req.query.groupBy === "session" ? "session" : null;
|
|
19
|
+
|
|
20
|
+
const rows = await listHistoryRows(ctx.config.storage.generatedDir);
|
|
21
|
+
|
|
22
|
+
let filtered = rows;
|
|
23
|
+
if (Number.isFinite(sinceTs)) {
|
|
24
|
+
filtered = filtered.filter((r) => r.createdAt > sinceTs);
|
|
25
|
+
}
|
|
26
|
+
if (Number.isFinite(beforeTs)) {
|
|
27
|
+
filtered = filtered.filter((r) => {
|
|
28
|
+
if (r.createdAt < beforeTs) return true;
|
|
29
|
+
if (r.createdAt === beforeTs && beforeFn) return r.filename < beforeFn;
|
|
30
|
+
return false;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (sessionId) {
|
|
34
|
+
filtered = filtered.filter((r) => r.sessionId === sessionId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const page = filtered.slice(0, limit);
|
|
38
|
+
const nextCursor = page.length === limit && filtered.length > limit
|
|
39
|
+
? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
|
|
40
|
+
: null;
|
|
41
|
+
|
|
42
|
+
if (groupBy === "session") {
|
|
43
|
+
const groups = new Map();
|
|
44
|
+
const loose = [];
|
|
45
|
+
for (const row of page) {
|
|
46
|
+
if (row.sessionId) {
|
|
47
|
+
let group = groups.get(row.sessionId);
|
|
48
|
+
if (!group) {
|
|
49
|
+
group = { sessionId: row.sessionId, items: [], lastUsedAt: row.createdAt };
|
|
50
|
+
groups.set(row.sessionId, group);
|
|
51
|
+
}
|
|
52
|
+
group.items.push(row);
|
|
53
|
+
if (row.createdAt > group.lastUsedAt) group.lastUsedAt = row.createdAt;
|
|
54
|
+
} else {
|
|
55
|
+
loose.push(row);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const titleMap = getSessionTitleMap(Array.from(groups.keys()));
|
|
59
|
+
const sessions = Array.from(groups.values())
|
|
60
|
+
.map((group) => ({
|
|
61
|
+
...group,
|
|
62
|
+
title: titleMap.get(group.sessionId) || null,
|
|
63
|
+
label: titleMap.get(group.sessionId) || group.sessionId.slice(0, 8),
|
|
64
|
+
}))
|
|
65
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
66
|
+
logEvent("history", "grouped", {
|
|
67
|
+
sessions: sessions.length,
|
|
68
|
+
loose: loose.length,
|
|
69
|
+
total: rows.length,
|
|
70
|
+
});
|
|
71
|
+
return res.json({ sessions, loose, total: rows.length, nextCursor });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
res.json({ items: page, total: rows.length, nextCursor });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logError("history", "error", err);
|
|
77
|
+
res.status(500).json({ error: err.message });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
app.delete("/api/history/:filename", async (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
84
|
+
const result = await trashAsset(ctx.rootDir, filename);
|
|
85
|
+
res.json(result);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
res.status(err.status || 500).json({ error: err.message, code: err.code });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.post("/api/history/:filename/restore", async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
94
|
+
const trashId = typeof req.body?.trashId === "string" ? req.body.trashId : null;
|
|
95
|
+
if (!trashId) return res.status(400).json({ error: "trashId required" });
|
|
96
|
+
const result = await restoreAsset(ctx.rootDir, trashId, filename);
|
|
97
|
+
res.json(result);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
package/routes/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { registerHealthRoutes } from "./health.js";
|
|
2
|
+
import { registerHistoryRoutes } from "./history.js";
|
|
3
|
+
import { registerSessionRoutes } from "./sessions.js";
|
|
4
|
+
import { registerEditRoutes } from "./edit.js";
|
|
5
|
+
import { registerNodeRoutes } from "./nodes.js";
|
|
6
|
+
import { registerGenerateRoutes } from "./generate.js";
|
|
7
|
+
|
|
8
|
+
export function configureRoutes(app, ctx) {
|
|
9
|
+
registerHealthRoutes(app, ctx);
|
|
10
|
+
registerHistoryRoutes(app, ctx);
|
|
11
|
+
registerSessionRoutes(app, ctx);
|
|
12
|
+
registerEditRoutes(app, ctx);
|
|
13
|
+
registerNodeRoutes(app, ctx);
|
|
14
|
+
registerGenerateRoutes(app, ctx);
|
|
15
|
+
}
|
|
16
|
+
|
package/routes/nodes.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { mkdir } from "fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
newNodeId,
|
|
4
|
+
saveNode,
|
|
5
|
+
loadNodeB64,
|
|
6
|
+
loadNodeMeta,
|
|
7
|
+
loadAssetB64,
|
|
8
|
+
} from "../lib/nodeStore.js";
|
|
9
|
+
import { startJob, finishJob } from "../lib/inflight.js";
|
|
10
|
+
import { validateAndNormalizeRefs } from "../lib/refs.js";
|
|
11
|
+
import { classifyUpstreamError } from "../lib/errorClassify.js";
|
|
12
|
+
import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
|
|
13
|
+
import { generateViaOAuth, editViaOAuth } from "../lib/oauthProxy.js";
|
|
14
|
+
import { getStyleSheet } from "../lib/sessionStore.js";
|
|
15
|
+
import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
|
|
16
|
+
import { logEvent, logError } from "../lib/logger.js";
|
|
17
|
+
|
|
18
|
+
function validateModeration(ctx, moderation) {
|
|
19
|
+
if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
|
|
20
|
+
return { error: "moderation must be one of: auto, low" };
|
|
21
|
+
}
|
|
22
|
+
return { moderation };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function wantsSse(req) {
|
|
26
|
+
const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
|
|
27
|
+
return accept.includes("text/event-stream");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeSse(res, event, data) {
|
|
31
|
+
res.write(`event: ${event}\n`);
|
|
32
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeNodeError(res, status, code, message, parentNodeId) {
|
|
36
|
+
if (res.headersSent) {
|
|
37
|
+
writeSse(res, "error", {
|
|
38
|
+
error: { code, message },
|
|
39
|
+
parentNodeId,
|
|
40
|
+
status,
|
|
41
|
+
});
|
|
42
|
+
res.end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.status(status).json({
|
|
46
|
+
error: { code, message },
|
|
47
|
+
parentNodeId,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dataUrlFromB64(format, b64) {
|
|
52
|
+
return `data:image/${format === "jpeg" ? "jpeg" : format};base64,${b64}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerNodeRoutes(app, ctx) {
|
|
56
|
+
app.post("/api/node/generate", async (req, res) => {
|
|
57
|
+
const body = req.body || {};
|
|
58
|
+
const streamResponse = wantsSse(req);
|
|
59
|
+
const parentNodeId = body.parentNodeId ?? null;
|
|
60
|
+
const requestId = typeof body.requestId === "string" ? body.requestId : null;
|
|
61
|
+
const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
|
|
62
|
+
const clientNodeId = typeof body.clientNodeId === "string" ? body.clientNodeId : null;
|
|
63
|
+
let finishMeta = {};
|
|
64
|
+
let finishStatus = "completed";
|
|
65
|
+
let finishHttpStatus;
|
|
66
|
+
let finishErrorCode;
|
|
67
|
+
startJob({
|
|
68
|
+
requestId,
|
|
69
|
+
kind: "node",
|
|
70
|
+
prompt: body.prompt,
|
|
71
|
+
meta: { kind: "node", sessionId, parentNodeId, clientNodeId },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const {
|
|
76
|
+
prompt,
|
|
77
|
+
quality: rawQuality = "medium",
|
|
78
|
+
size = "1024x1024",
|
|
79
|
+
format = "png",
|
|
80
|
+
moderation = "low",
|
|
81
|
+
references = [],
|
|
82
|
+
externalSrc = null,
|
|
83
|
+
mode: promptMode = "auto",
|
|
84
|
+
} = body;
|
|
85
|
+
const { provider = "oauth" } = body;
|
|
86
|
+
const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
|
|
87
|
+
const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
|
|
88
|
+
|
|
89
|
+
if (provider === "api") {
|
|
90
|
+
finishStatus = "error";
|
|
91
|
+
finishHttpStatus = 403;
|
|
92
|
+
finishErrorCode = "APIKEY_DISABLED";
|
|
93
|
+
return res.status(403).json({
|
|
94
|
+
error: { code: "APIKEY_DISABLED", message: "API key provider is disabled. Use OAuth." },
|
|
95
|
+
parentNodeId,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (!prompt || typeof prompt !== "string") {
|
|
99
|
+
finishStatus = "error";
|
|
100
|
+
finishHttpStatus = 400;
|
|
101
|
+
finishErrorCode = "INVALID_PROMPT";
|
|
102
|
+
return res.status(400).json({
|
|
103
|
+
error: { code: "INVALID_PROMPT", message: "Prompt is required" },
|
|
104
|
+
parentNodeId,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const refCheck = validateAndNormalizeRefs(references);
|
|
108
|
+
if (refCheck.error) {
|
|
109
|
+
finishStatus = "error";
|
|
110
|
+
finishHttpStatus = 400;
|
|
111
|
+
finishErrorCode = refCheck.code;
|
|
112
|
+
return res.status(400).json({
|
|
113
|
+
error: { code: refCheck.code, message: refCheck.error },
|
|
114
|
+
code: refCheck.code,
|
|
115
|
+
parentNodeId,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if ((parentNodeId || externalSrc) && refCheck.refs.length > 0) {
|
|
119
|
+
finishStatus = "error";
|
|
120
|
+
finishHttpStatus = 400;
|
|
121
|
+
finishErrorCode = "NODE_REFS_UNSUPPORTED_FOR_EDIT";
|
|
122
|
+
return res.status(400).json({
|
|
123
|
+
error: {
|
|
124
|
+
code: "NODE_REFS_UNSUPPORTED_FOR_EDIT",
|
|
125
|
+
message: "Extra references are only supported for root node generation.",
|
|
126
|
+
},
|
|
127
|
+
parentNodeId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const moderationCheck = validateModeration(ctx, moderation);
|
|
131
|
+
if (moderationCheck.error) {
|
|
132
|
+
finishStatus = "error";
|
|
133
|
+
finishHttpStatus = 400;
|
|
134
|
+
finishErrorCode = "INVALID_MODERATION";
|
|
135
|
+
return res.status(400).json({
|
|
136
|
+
error: { code: "INVALID_MODERATION", message: moderationCheck.error },
|
|
137
|
+
parentNodeId,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let effectivePrompt = prompt;
|
|
142
|
+
let styleSheetApplied = null;
|
|
143
|
+
if (sessionId) {
|
|
144
|
+
try {
|
|
145
|
+
const data = getStyleSheet(sessionId);
|
|
146
|
+
if (data && data.enabled && data.styleSheet) {
|
|
147
|
+
const prefix = renderStyleSheetPrefix(data.styleSheet);
|
|
148
|
+
if (prefix) {
|
|
149
|
+
effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
|
|
150
|
+
styleSheetApplied = data.styleSheet;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const startTime = Date.now();
|
|
157
|
+
let parentB64 = null;
|
|
158
|
+
if (parentNodeId) {
|
|
159
|
+
parentB64 = await loadNodeB64(ctx.rootDir, `${parentNodeId}.png`);
|
|
160
|
+
} else if (typeof externalSrc === "string" && externalSrc.length > 0) {
|
|
161
|
+
parentB64 = await loadAssetB64(ctx.rootDir, externalSrc);
|
|
162
|
+
}
|
|
163
|
+
logEvent("node", "request", {
|
|
164
|
+
requestId,
|
|
165
|
+
operation: parentB64 ? "edit" : "generate",
|
|
166
|
+
sessionId,
|
|
167
|
+
parentNodeId,
|
|
168
|
+
clientNodeId,
|
|
169
|
+
quality,
|
|
170
|
+
size,
|
|
171
|
+
moderation,
|
|
172
|
+
refs: refCheck.refs.length,
|
|
173
|
+
promptChars: prompt.length,
|
|
174
|
+
promptMode: normalizedPromptMode,
|
|
175
|
+
styleSheetApplied: !!styleSheetApplied,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (streamResponse) {
|
|
179
|
+
res.writeHead(200, {
|
|
180
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
181
|
+
"Cache-Control": "no-cache, no-transform",
|
|
182
|
+
Connection: "keep-alive",
|
|
183
|
+
});
|
|
184
|
+
writeSse(res, "phase", { requestId, phase: "streaming" });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let b64, usage, webSearchCalls = 0, revisedPrompt = null;
|
|
188
|
+
const MAX_RETRIES = 1;
|
|
189
|
+
let lastErr;
|
|
190
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
191
|
+
try {
|
|
192
|
+
const r = parentB64
|
|
193
|
+
? await editViaOAuth(effectivePrompt, parentB64, quality, size, moderation, normalizedPromptMode, ctx, requestId)
|
|
194
|
+
: await generateViaOAuth(
|
|
195
|
+
effectivePrompt,
|
|
196
|
+
quality,
|
|
197
|
+
size,
|
|
198
|
+
moderation,
|
|
199
|
+
refCheck.refs,
|
|
200
|
+
requestId,
|
|
201
|
+
normalizedPromptMode,
|
|
202
|
+
ctx,
|
|
203
|
+
{
|
|
204
|
+
partialImages: streamResponse ? 2 : 0,
|
|
205
|
+
onPartialImage: streamResponse
|
|
206
|
+
? (partial) =>
|
|
207
|
+
writeSse(res, "partial", {
|
|
208
|
+
requestId,
|
|
209
|
+
image: dataUrlFromB64(format, partial.b64),
|
|
210
|
+
index: partial.index,
|
|
211
|
+
})
|
|
212
|
+
: null,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
if (r.b64) {
|
|
216
|
+
b64 = r.b64;
|
|
217
|
+
usage = r.usage;
|
|
218
|
+
webSearchCalls = r.webSearchCalls || 0;
|
|
219
|
+
revisedPrompt = r.revisedPrompt || null;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
lastErr = new Error("Empty response (safety refusal)");
|
|
223
|
+
} catch (e) {
|
|
224
|
+
lastErr = e;
|
|
225
|
+
}
|
|
226
|
+
if (attempt < MAX_RETRIES) {
|
|
227
|
+
logEvent("node", "retry", { requestId, attempt: attempt + 1, errorCode: lastErr?.code });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!b64) {
|
|
232
|
+
finishStatus = "error";
|
|
233
|
+
finishHttpStatus = 422;
|
|
234
|
+
finishErrorCode = "SAFETY_REFUSAL";
|
|
235
|
+
return writeNodeError(
|
|
236
|
+
res,
|
|
237
|
+
422,
|
|
238
|
+
"SAFETY_REFUSAL",
|
|
239
|
+
lastErr?.message || "Empty response after retry",
|
|
240
|
+
parentNodeId,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const nodeId = newNodeId();
|
|
245
|
+
const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
|
|
246
|
+
const meta = {
|
|
247
|
+
nodeId,
|
|
248
|
+
parentNodeId,
|
|
249
|
+
sessionId,
|
|
250
|
+
clientNodeId,
|
|
251
|
+
prompt,
|
|
252
|
+
userPrompt: prompt,
|
|
253
|
+
revisedPrompt,
|
|
254
|
+
promptMode: normalizedPromptMode,
|
|
255
|
+
effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
|
|
256
|
+
styleSheetApplied: styleSheetApplied || undefined,
|
|
257
|
+
options: { quality, size, format, moderation },
|
|
258
|
+
createdAt: Date.now(),
|
|
259
|
+
createdAtIso: new Date().toISOString(),
|
|
260
|
+
elapsed,
|
|
261
|
+
usage: usage || null,
|
|
262
|
+
webSearchCalls,
|
|
263
|
+
provider: "oauth",
|
|
264
|
+
kind: parentB64 ? "edit" : "generate",
|
|
265
|
+
requestId,
|
|
266
|
+
refsCount: refCheck.refs.length,
|
|
267
|
+
quality,
|
|
268
|
+
size,
|
|
269
|
+
format,
|
|
270
|
+
moderation,
|
|
271
|
+
};
|
|
272
|
+
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
273
|
+
const { filename } = await saveNode(ctx.rootDir, { nodeId, b64, meta, ext: format });
|
|
274
|
+
finishMeta = { nodeId, filename, imageChars: b64.length };
|
|
275
|
+
finishHttpStatus = 200;
|
|
276
|
+
logEvent("node", "saved", {
|
|
277
|
+
requestId,
|
|
278
|
+
nodeId,
|
|
279
|
+
filename,
|
|
280
|
+
imageChars: b64.length,
|
|
281
|
+
elapsedMs: Date.now() - startTime,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const payload = {
|
|
285
|
+
nodeId,
|
|
286
|
+
parentNodeId,
|
|
287
|
+
requestId,
|
|
288
|
+
image: dataUrlFromB64(format, b64),
|
|
289
|
+
filename,
|
|
290
|
+
url: `/generated/${filename}`,
|
|
291
|
+
elapsed,
|
|
292
|
+
usage,
|
|
293
|
+
webSearchCalls,
|
|
294
|
+
provider: "oauth",
|
|
295
|
+
moderation,
|
|
296
|
+
refsCount: refCheck.refs.length,
|
|
297
|
+
warnings: qualityWarnings,
|
|
298
|
+
revisedPrompt,
|
|
299
|
+
promptMode: normalizedPromptMode,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (streamResponse) {
|
|
303
|
+
writeSse(res, "done", payload);
|
|
304
|
+
res.end();
|
|
305
|
+
} else {
|
|
306
|
+
res.json(payload);
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
const code = err.code || classifyUpstreamError(err.message) || "NODE_GEN_FAILED";
|
|
310
|
+
finishStatus = "error";
|
|
311
|
+
finishHttpStatus = err.status || 500;
|
|
312
|
+
finishErrorCode = code;
|
|
313
|
+
logError("node", "error", err, { requestId, code, parentNodeId, sessionId, clientNodeId });
|
|
314
|
+
writeNodeError(res, err.status || 500, code, err.message, parentNodeId);
|
|
315
|
+
} finally {
|
|
316
|
+
finishJob(requestId, {
|
|
317
|
+
status: finishStatus,
|
|
318
|
+
httpStatus: finishHttpStatus,
|
|
319
|
+
errorCode: finishErrorCode,
|
|
320
|
+
meta: finishMeta,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
app.get("/api/node/:nodeId", async (req, res) => {
|
|
326
|
+
try {
|
|
327
|
+
const { nodeId } = req.params;
|
|
328
|
+
const meta = await loadNodeMeta(ctx.rootDir, nodeId);
|
|
329
|
+
if (!meta) {
|
|
330
|
+
return res.status(404).json({ error: { code: "NODE_NOT_FOUND", message: "Node metadata missing" } });
|
|
331
|
+
}
|
|
332
|
+
const ext = meta?.options?.format || meta?.format || "png";
|
|
333
|
+
res.json({ nodeId, meta, url: `/generated/${nodeId}.${ext}` });
|
|
334
|
+
} catch (err) {
|
|
335
|
+
res.status(err.status || 500).json({
|
|
336
|
+
error: { code: err.code || "NODE_FETCH_FAILED", message: err.message },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|