ima2-gen 2.0.15 → 2.0.16

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 (125) hide show
  1. package/README.md +30 -0
  2. package/bin/commands/skill.js +336 -17
  3. package/bin/ima2.js +22 -2
  4. package/bin/lib/doctor-checks.js +6 -4
  5. package/config.js +1 -0
  6. package/docs/CLI.md +25 -3
  7. package/docs/PROMPT_STUDIO.ko.md +29 -0
  8. package/docs/PROMPT_STUDIO.md +31 -0
  9. package/docs/migration/runtime-test-inventory.md +7 -1
  10. package/lib/agentImageVideoGen.js +8 -0
  11. package/lib/agentQueueStore.js +36 -10
  12. package/lib/agentQueueWorker.js +79 -7
  13. package/lib/agentRuntime.js +2 -0
  14. package/lib/assetLifecycle.js +40 -8
  15. package/lib/canvasVersionStore.js +86 -0
  16. package/lib/cardNewsGenerator.js +37 -3
  17. package/lib/cardNewsJobStore.js +10 -0
  18. package/lib/cardNewsManifestStore.js +6 -12
  19. package/lib/cardNewsPath.js +25 -0
  20. package/lib/db.js +2 -0
  21. package/lib/generatePipeline.js +474 -0
  22. package/lib/generationInputValidation.js +32 -0
  23. package/lib/grokImageCore.js +18 -1
  24. package/lib/historyIndex.js +11 -4
  25. package/lib/historyList.js +3 -0
  26. package/lib/imageMetadata.js +3 -0
  27. package/lib/inflight.js +30 -3
  28. package/lib/multimodePipeline.js +462 -0
  29. package/lib/nodeGeneration.js +440 -0
  30. package/lib/nodeValidation.js +23 -0
  31. package/lib/oauthProxy/generators.js +3 -246
  32. package/lib/oauthProxy/multimodeGenerators.js +251 -0
  33. package/lib/routeHelpers.js +9 -2
  34. package/package.json +2 -2
  35. package/routes/agent.js +4 -4
  36. package/routes/canvasVersions.js +21 -1
  37. package/routes/cardNews.js +2 -1
  38. package/routes/edit.js +25 -9
  39. package/routes/events.js +5 -1
  40. package/routes/generate.js +2 -481
  41. package/routes/keys.js +31 -39
  42. package/routes/multimode.js +2 -502
  43. package/routes/nodes.js +3 -448
  44. package/routes/video.js +12 -3
  45. package/routes/videoExtended.js +19 -4
  46. package/server.js +53 -1
  47. package/skills/ima2/SKILL.md +204 -0
  48. package/skills/ima2-front/SKILL.md +611 -0
  49. package/skills/ima2-front/references/a11y-patterns.md +134 -0
  50. package/skills/ima2-front/references/aesthetics.md +327 -0
  51. package/skills/ima2-front/references/anti-slop.md +241 -0
  52. package/skills/ima2-front/references/asset-requirements.md +431 -0
  53. package/skills/ima2-front/references/brand-asset-sourcing.md +122 -0
  54. package/skills/ima2-front/references/color-system.md +132 -0
  55. package/skills/ima2-front/references/consistency-locks.md +20 -0
  56. package/skills/ima2-front/references/crud-ui.md +53 -0
  57. package/skills/ima2-front/references/dropdown-layer.md +110 -0
  58. package/skills/ima2-front/references/i18n-global.md +104 -0
  59. package/skills/ima2-front/references/iterative-design.md +90 -0
  60. package/skills/ima2-front/references/korea-2026.md +174 -0
  61. package/skills/ima2-front/references/layout-discipline.md +161 -0
  62. package/skills/ima2-front/references/liquid-glass.md +181 -0
  63. package/skills/ima2-front/references/logo-sections.md +194 -0
  64. package/skills/ima2-front/references/mobile-ux.md +144 -0
  65. package/skills/ima2-front/references/performance-budget.md +106 -0
  66. package/skills/ima2-front/references/preflight-full.md +58 -0
  67. package/skills/ima2-front/references/product-density.md +53 -0
  68. package/skills/ima2-front/references/prototype-variants.md +54 -0
  69. package/skills/ima2-front/references/reference-capture.md +120 -0
  70. package/skills/ima2-front/references/responsive-viewport.md +139 -0
  71. package/skills/ima2-front/references/seo-baseline.md +123 -0
  72. package/skills/ima2-front/references/soft-3d-asset-gates.md +70 -0
  73. package/skills/ima2-front/references/stacks/astro.md +204 -0
  74. package/skills/ima2-front/references/stacks/mobile-native.md +281 -0
  75. package/skills/ima2-front/references/stacks/nextjs.md +219 -0
  76. package/skills/ima2-front/references/stacks/react.md +264 -0
  77. package/skills/ima2-front/references/stacks/svelte.md +235 -0
  78. package/skills/ima2-front/references/stacks/vanilla.md +226 -0
  79. package/skills/ima2-front/references/theme-switching.md +82 -0
  80. package/skills/ima2-front/references/top-bar.md +126 -0
  81. package/skills/ima2-front/references/typography-wrapping.md +270 -0
  82. package/skills/ima2-front/references/ux-writing-ko.md +62 -0
  83. package/skills/ima2-front/references/visual-verification.md +102 -0
  84. package/skills/ima2-uiux/SKILL.md +477 -0
  85. package/skills/ima2-uiux/references/anti-slop-judgment.md +39 -0
  86. package/skills/ima2-uiux/references/color-system.md +80 -0
  87. package/skills/ima2-uiux/references/design-isms.md +194 -0
  88. package/skills/ima2-uiux/references/design-read-example.md +33 -0
  89. package/skills/ima2-uiux/references/design-system-bootstrap.md +133 -0
  90. package/skills/ima2-uiux/references/favicon-logo.md +355 -0
  91. package/skills/ima2-uiux/references/form-patterns.md +118 -0
  92. package/skills/ima2-uiux/references/intent-discovery-ladder.md +86 -0
  93. package/skills/ima2-uiux/references/korean-design-vocabulary.md +52 -0
  94. package/skills/ima2-uiux/references/layout-macrostructures.md +54 -0
  95. package/skills/ima2-uiux/references/logo-trust-sections.md +96 -0
  96. package/skills/ima2-uiux/references/mobile-native-ux.md +207 -0
  97. package/skills/ima2-uiux/references/product-personalities.md +118 -0
  98. package/skills/ima2-uiux/references/responsive-nav.md +66 -0
  99. package/skills/ima2-uiux/references/typography-line-breaks.md +193 -0
  100. package/skills/ima2-uiux/references/ux-preflight.md +65 -0
  101. package/skills/ima2-uiux/references/ux-states.md +197 -0
  102. package/skills/ima2-uiux/references/visual-hierarchy.md +115 -0
  103. package/ui/dist/.vite/manifest.json +13 -13
  104. package/ui/dist/assets/AgentWorkspace-CtYt4SF5.js +3 -0
  105. package/ui/dist/assets/{CardNewsWorkspace-Cdxn1ohU.js → CardNewsWorkspace-DN5cVqG5.js} +1 -1
  106. package/ui/dist/assets/GenerationRequestLogPanel-DnmUQdKR.js +1 -0
  107. package/ui/dist/assets/{NodeCanvas-C67sLboA.js → NodeCanvas-CxpfIBI0.js} +1 -1
  108. package/ui/dist/assets/PromptBuilderPanel-C2R3ObtR.js +2 -0
  109. package/ui/dist/assets/{PromptImportDialog-DeQXsEEv.js → PromptImportDialog-BIxz_yEx.js} +2 -2
  110. package/ui/dist/assets/PromptImportDiscoverySection-BQTlPMME.js +1 -0
  111. package/ui/dist/assets/PromptImportFolderSection-Ds_Th9O3.js +1 -0
  112. package/ui/dist/assets/{PromptLibraryPanel-Db3xEPM_.js → PromptLibraryPanel-Rs4l45pV.js} +2 -2
  113. package/ui/dist/assets/{SettingsWorkspace-BDk_1W-V.js → SettingsWorkspace-p1AZ6uT9.js} +1 -1
  114. package/ui/dist/assets/index-0-_vgFGs.js +4 -0
  115. package/ui/dist/assets/index-Dm3pFxV4.js +23 -0
  116. package/ui/dist/assets/index-J8yDF3Ch.css +1 -0
  117. package/ui/dist/index.html +2 -2
  118. package/ui/dist/assets/AgentWorkspace-DAg_7zpY.js +0 -3
  119. package/ui/dist/assets/GenerationRequestLogPanel-C_qvEUi2.js +0 -1
  120. package/ui/dist/assets/PromptBuilderPanel-DjvVdAie.js +0 -2
  121. package/ui/dist/assets/PromptImportDiscoverySection-CrwyUKYI.js +0 -1
  122. package/ui/dist/assets/PromptImportFolderSection-CToGMqYE.js +0 -1
  123. package/ui/dist/assets/index-2oG6in1i.css +0 -1
  124. package/ui/dist/assets/index-CoxMOXAU.js +0 -23
  125. package/ui/dist/assets/index-p8egnGHk.js +0 -4
@@ -0,0 +1,440 @@
1
+ import { mkdir } from "fs/promises";
2
+ import { newNodeId, saveNode, loadAssetB64, } from "./nodeStore.js";
3
+ import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "./inflight.js";
4
+ import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "./generationCancel.js";
5
+ import { detectImageMimeFromB64, summarizeReferencePayload } from "./refs.js";
6
+ import { classifyUpstreamError } from "./errorClassify.js";
7
+ import { normalizeOAuthParams } from "./oauthNormalize.js";
8
+ import { resolveProviderOptions } from "./providerOptions.js";
9
+ import { generateViaResponses, editViaResponses } from "./responsesImageAdapter.js";
10
+ import { generateViaGrok } from "./grokImageAdapter.js";
11
+ import { generateViaAgy } from "./agyImageAdapter.js";
12
+ import { generateViaGeminiApi } from "./geminiApiImageAdapter.js";
13
+ import { isNonRetryableGenerationError, normalizeGenerationFailure } from "./generationErrors.js";
14
+ import { logEvent, logError } from "./logger.js";
15
+ import { errInfo } from "./errInfo.js";
16
+ import { requireRuntimeContext } from "./runtimeContext.js";
17
+ import { imageFormatFromMime, writeSse, dataUrlFromB64 } from "./routeHelpers.js";
18
+ import { validateNodeInputs } from "./nodeValidation.js";
19
+ import { publish } from "./eventBus.js";
20
+ import { publishJobEvent } from "./ssePublish.js";
21
+ import { asUpstream, wantsSse, writeNodeError, loadParentNodeB64, toGrokReferences, nodeErrorDetails, } from "./nodeHelpers.js";
22
+ import { normalizeBodyRequestId, validateGenerationPrompt } from "./generationInputValidation.js";
23
+ export async function runNodeGeneration(req, res, ctx) {
24
+ const body = (req.body ?? {});
25
+ const promptError = validateGenerationPrompt(body.prompt);
26
+ if (promptError)
27
+ return res.status(400).json(promptError);
28
+ const asyncMode = body.async === true;
29
+ const streamResponse = !asyncMode && wantsSse(req);
30
+ const parentNodeId = (typeof body.parentNodeId === "string" ? body.parentNodeId : null);
31
+ const requestId = normalizeBodyRequestId(body.requestId, req.id);
32
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
33
+ const clientNodeId = typeof body.clientNodeId === "string" ? body.clientNodeId : null;
34
+ let finishMeta = {};
35
+ let finishStatus = "completed";
36
+ let finishHttpStatus;
37
+ let finishErrorCode;
38
+ let finishCanceled = false;
39
+ let jobOwned = false;
40
+ const cancelController = new AbortController();
41
+ const referencePayload = summarizeReferencePayload(body.references);
42
+ try {
43
+ const { prompt: rawPrompt, quality: rawQuality = "medium", size = "1024x1024", format = "png", moderation = "low", references = [], externalSrc = null, mode: promptMode = "auto", contextMode: rawContextMode = "parent-plus-refs", searchMode: rawSearchMode = "on", model: rawModel, reasoningEffort: rawReasoningEffort, } = body;
44
+ const { provider = "oauth" } = body;
45
+ const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
46
+ const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
47
+ const contextMode = ["parent-plus-refs", "parent-only", "ancestry"].includes(rawContextMode)
48
+ ? rawContextMode
49
+ : "parent-plus-refs";
50
+ const searchMode = ["off", "auto", "on"].includes(rawSearchMode) ? rawSearchMode : "on";
51
+ const providerOptions = resolveProviderOptions(ctx, {
52
+ provider,
53
+ rawModel,
54
+ rawReasoningEffort,
55
+ rawSize: size,
56
+ rawWebSearchEnabled: body.webSearchEnabled,
57
+ searchMode,
58
+ });
59
+ if (providerOptions.error) {
60
+ finishStatus = "error";
61
+ finishHttpStatus = providerOptions.status;
62
+ finishErrorCode = providerOptions.code;
63
+ return res.status(providerOptions.status).json({
64
+ error: { code: providerOptions.code, message: providerOptions.error },
65
+ parentNodeId,
66
+ });
67
+ }
68
+ const imageModel = providerOptions.model;
69
+ const reasoningEffort = providerOptions.reasoningEffort;
70
+ const effectiveSize = providerOptions.size;
71
+ const webSearchEnabled = providerOptions.webSearchEnabled;
72
+ const activeProvider = providerOptions.provider;
73
+ const effectiveImageModel = (activeProvider === "grok" || activeProvider === "grok-api") && quality === "high"
74
+ ? "grok-imagine-image-quality"
75
+ : imageModel;
76
+ if (contextMode === "ancestry") {
77
+ finishStatus = "error";
78
+ finishHttpStatus = 400;
79
+ finishErrorCode = "CONTEXT_MODE_UNSUPPORTED";
80
+ return res.status(400).json({
81
+ error: { code: "CONTEXT_MODE_UNSUPPORTED", message: "Ancestry context is not supported yet." },
82
+ parentNodeId,
83
+ });
84
+ }
85
+ const validation = validateNodeInputs(ctx, rawPrompt, references, moderation);
86
+ if (validation.error) {
87
+ finishStatus = "error";
88
+ finishHttpStatus = 400;
89
+ finishErrorCode = validation.error.code;
90
+ return res.status(400).json({
91
+ error: validation.error,
92
+ ...(validation.code ? { code: validation.code } : {}),
93
+ parentNodeId,
94
+ });
95
+ }
96
+ const prompt = validation.prompt;
97
+ const refCheck = validation.refCheck;
98
+ const startTime = Date.now();
99
+ let parentB64 = null;
100
+ if (parentNodeId) {
101
+ parentB64 = await loadParentNodeB64(ctx, parentNodeId);
102
+ }
103
+ else if (typeof externalSrc === "string" && externalSrc.length > 0) {
104
+ parentB64 = await loadAssetB64(ctx.rootDir, externalSrc, ctx.config.storage.generatedDir);
105
+ }
106
+ const operation = parentB64 ? "edit" : "generate";
107
+ const referenceDiagnostics = refCheck.referenceDiagnostics || [];
108
+ const generateReferenceDiagnostics = operation === "generate" ? referenceDiagnostics : [];
109
+ const referenceMismatchCount = generateReferenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
110
+ const refsForRequest = contextMode === "parent-only" ? [] : (refCheck.refDetails || refCheck.refs);
111
+ const parentImagePresent = !!parentB64;
112
+ const inputImageCount = (parentImagePresent ? 1 : 0) + refsForRequest.length;
113
+ if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && inputImageCount > 3) {
114
+ finishStatus = "error";
115
+ finishHttpStatus = 400;
116
+ const code = activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY";
117
+ return res.status(400).json({
118
+ error: {
119
+ code,
120
+ message: `${activeProvider === "agy" ? "Agy" : "Grok"} image editing supports up to 3 reference images.`,
121
+ },
122
+ code,
123
+ parentNodeId,
124
+ });
125
+ }
126
+ const started = startJob({
127
+ requestId,
128
+ kind: "node",
129
+ prompt: body.prompt,
130
+ meta: {
131
+ kind: "node",
132
+ sessionId,
133
+ parentNodeId,
134
+ clientNodeId,
135
+ refsCount: referencePayload.refsCount,
136
+ referenceBytes: referencePayload.referenceBytes,
137
+ referenceB64Chars: referencePayload.referenceB64Chars,
138
+ },
139
+ });
140
+ if (started && isStartJobFailure(started)) {
141
+ finishStatus = "error";
142
+ finishHttpStatus = started.code === "TOO_MANY_JOBS" ? 429 : 409;
143
+ finishErrorCode = started.code;
144
+ if (started.code === "TOO_MANY_JOBS") {
145
+ res.setHeader("Retry-After", String(INFLIGHT_RETRY_AFTER_SECONDS));
146
+ }
147
+ return writeNodeError(res, finishHttpStatus, started.code, started.code === "TOO_MANY_JOBS"
148
+ ? "Too many concurrent generation jobs"
149
+ : "Request ID already in use", parentNodeId, {}, requestId);
150
+ }
151
+ jobOwned = true;
152
+ registerJobAbortController(requestId, cancelController);
153
+ if (asyncMode)
154
+ res.status(202).json({ requestId });
155
+ logEvent("node", "request", {
156
+ requestId,
157
+ operation,
158
+ sessionId,
159
+ parentNodeId,
160
+ clientNodeId,
161
+ quality,
162
+ model: effectiveImageModel,
163
+ size: effectiveSize,
164
+ moderation,
165
+ refs: refsForRequest.length,
166
+ referenceBytes: referencePayload.referenceBytes,
167
+ referenceMismatchCount,
168
+ refDetectedMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
169
+ refDeclaredMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.declaredMime).filter(Boolean))].join(","),
170
+ inputImageCount,
171
+ parentImagePresent,
172
+ contextMode,
173
+ searchMode,
174
+ webSearchEnabled,
175
+ promptChars: prompt.length,
176
+ promptMode: normalizedPromptMode,
177
+ });
178
+ const emitProgress = streamResponse || asyncMode;
179
+ if (streamResponse) {
180
+ res.writeHead(200, {
181
+ "Content-Type": "text/event-stream; charset=utf-8",
182
+ "Cache-Control": "no-cache, no-transform",
183
+ Connection: "keep-alive",
184
+ });
185
+ writeSse(res, "phase", { requestId, phase: "streaming" });
186
+ publish(requestId, "phase", { requestId, phase: "streaming" });
187
+ }
188
+ else if (asyncMode) {
189
+ publish(requestId, "phase", { requestId, phase: "streaming" });
190
+ }
191
+ let b64, usage, webSearchCalls = 0, revisedPrompt = null;
192
+ const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
193
+ let resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : format;
194
+ const maxAttempts = inputImageCount > 0 ? 1 : 2;
195
+ let lastErr = null;
196
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
197
+ try {
198
+ logEvent("node", "attempt", {
199
+ requestId,
200
+ attempt,
201
+ operation,
202
+ sessionId,
203
+ parentNodeId,
204
+ clientNodeId,
205
+ model: effectiveImageModel,
206
+ moderation,
207
+ quality,
208
+ size: effectiveSize,
209
+ refs: refsForRequest.length,
210
+ inputImageCount,
211
+ parentImagePresent,
212
+ contextMode,
213
+ searchMode,
214
+ webSearchEnabled,
215
+ });
216
+ const r = activeProvider === "gemini-api"
217
+ ? await generateViaGeminiApi(parentB64 ? `Edit this image: ${prompt}` : prompt, requireRuntimeContext(ctx), {
218
+ model: effectiveImageModel,
219
+ size: effectiveSize,
220
+ signal: cancelController.signal,
221
+ requestId,
222
+ references: parentB64
223
+ ? [{ b64: parentB64, declaredMime: null, detectedMime: null }, ...(refCheck.refDetails || [])]
224
+ : refCheck.refDetails,
225
+ })
226
+ : activeProvider === "agy"
227
+ ? await generateViaAgy(parentB64 ? `Edit this image: ${prompt}` : prompt, {
228
+ references: parentB64
229
+ ? [{ b64: parentB64, declaredMime: null, detectedMime: null }]
230
+ : undefined,
231
+ signal: cancelController.signal,
232
+ requestId,
233
+ })
234
+ : activeProvider === "grok" || activeProvider === "grok-api"
235
+ ? await generateViaGrok(prompt, ctx, {
236
+ model: effectiveImageModel,
237
+ size: effectiveSize,
238
+ requestId,
239
+ signal: cancelController.signal,
240
+ references: toGrokReferences(parentB64, refsForRequest),
241
+ directApiKey: grokDirectApiKey,
242
+ })
243
+ : parentB64
244
+ ? await editViaResponses(activeProvider, prompt, parentB64, quality, effectiveSize, moderation, normalizedPromptMode, ctx, requestId, {
245
+ model: effectiveImageModel,
246
+ references: refsForRequest,
247
+ searchMode,
248
+ reasoningEffort,
249
+ webSearchEnabled,
250
+ signal: cancelController.signal,
251
+ })
252
+ : await generateViaResponses(activeProvider, prompt, quality, effectiveSize, moderation, refsForRequest, requestId, normalizedPromptMode, ctx, {
253
+ model: effectiveImageModel,
254
+ reasoningEffort,
255
+ webSearchEnabled,
256
+ signal: cancelController.signal,
257
+ partialImages: emitProgress ? 2 : 0,
258
+ onPartialImage: emitProgress
259
+ ? (partial) => {
260
+ if (isJobCanceled(requestId))
261
+ return;
262
+ const pd = { requestId, image: dataUrlFromB64(format, partial.b64), index: partial.index };
263
+ if (streamResponse)
264
+ writeSse(res, "partial", pd);
265
+ publish(requestId, "partial", pd);
266
+ }
267
+ : null,
268
+ });
269
+ throwIfJobCanceled(requestId);
270
+ if (r.b64) {
271
+ b64 = r.b64;
272
+ usage = r.usage;
273
+ webSearchCalls = r.webSearchCalls || 0;
274
+ revisedPrompt = r.revisedPrompt || null;
275
+ if (activeProvider === "grok" || activeProvider === "grok-api" || activeProvider === "gemini-api") {
276
+ resultFormat = imageFormatFromMime(("mime" in r ? r.mime : undefined) || detectImageMimeFromB64(r.b64) || "image/jpeg");
277
+ }
278
+ break;
279
+ }
280
+ lastErr = { message: "Empty response (safety refusal)" };
281
+ }
282
+ catch (e) {
283
+ lastErr = asUpstream(e);
284
+ if (isNonRetryableGenerationError(lastErr))
285
+ break;
286
+ }
287
+ if (attempt + 1 < maxAttempts) {
288
+ logEvent("node", "retry", {
289
+ requestId,
290
+ attempt: attempt + 1,
291
+ operation,
292
+ parentNodeId,
293
+ clientNodeId,
294
+ errorCode: lastErr?.code,
295
+ errorEventType: lastErr?.eventType,
296
+ errorEventCount: lastErr?.eventCount,
297
+ });
298
+ }
299
+ }
300
+ if (!b64) {
301
+ const finalErr = normalizeGenerationFailure(lastErr, {
302
+ safetyMessage: lastErr?.message || "Empty response after generation attempt",
303
+ });
304
+ finishStatus = "error";
305
+ finishHttpStatus = finalErr.status || 500;
306
+ finishErrorCode = finalErr.code || "NODE_GEN_FAILED";
307
+ logEvent("node", "final_error", {
308
+ requestId,
309
+ operation,
310
+ finalCode: finishErrorCode,
311
+ upstreamCode: lastErr?.upstreamCode || lastErr?.code,
312
+ errorEventType: lastErr?.eventType,
313
+ errorEventCount: lastErr?.eventCount,
314
+ diagnosticReason: lastErr?.diagnosticReason,
315
+ retryKind: lastErr?.retryKind,
316
+ referencesDroppedOnRetry: lastErr?.referencesDroppedOnRetry,
317
+ attempts: maxAttempts,
318
+ outerHttpAlreadyCommitted: res.headersSent,
319
+ sseErrorSent: streamResponse,
320
+ });
321
+ return writeNodeError(res, finishHttpStatus ?? 500, finishErrorCode ?? "NODE_GEN_FAILED", finalErr.message, parentNodeId, nodeErrorDetails(finalErr, lastErr), requestId);
322
+ }
323
+ const nodeId = newNodeId();
324
+ throwIfJobCanceled(requestId);
325
+ const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
326
+ const meta = {
327
+ nodeId,
328
+ parentNodeId,
329
+ sessionId,
330
+ clientNodeId,
331
+ prompt,
332
+ userPrompt: prompt,
333
+ revisedPrompt,
334
+ promptMode: normalizedPromptMode,
335
+ options: { quality, size: effectiveSize, format: resultFormat, moderation },
336
+ model: effectiveImageModel,
337
+ reasoningEffort,
338
+ createdAt: Date.now(),
339
+ createdAtIso: new Date().toISOString(),
340
+ elapsed,
341
+ usage: usage || null,
342
+ webSearchCalls,
343
+ webSearchEnabled,
344
+ contextMode,
345
+ searchMode,
346
+ provider: activeProvider,
347
+ kind: parentB64 ? "edit" : "generate",
348
+ requestId,
349
+ refsCount: refsForRequest.length,
350
+ quality,
351
+ size: effectiveSize,
352
+ format: resultFormat,
353
+ moderation,
354
+ };
355
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
356
+ throwIfJobCanceled(requestId);
357
+ const { filename } = await saveNode(ctx.rootDir, {
358
+ nodeId,
359
+ b64,
360
+ meta,
361
+ ext: resultFormat,
362
+ generatedDir: ctx.config.storage.generatedDir,
363
+ });
364
+ finishMeta = { nodeId, filename, imageChars: b64.length };
365
+ finishHttpStatus = 200;
366
+ logEvent("node", "saved", {
367
+ requestId,
368
+ nodeId,
369
+ filename,
370
+ imageChars: b64.length,
371
+ elapsedMs: Date.now() - startTime,
372
+ });
373
+ const payload = {
374
+ nodeId,
375
+ parentNodeId,
376
+ requestId,
377
+ image: dataUrlFromB64(resultFormat, b64),
378
+ filename,
379
+ url: `/generated/${filename}`,
380
+ elapsed,
381
+ usage,
382
+ webSearchCalls,
383
+ webSearchEnabled,
384
+ provider: activeProvider,
385
+ model: effectiveImageModel,
386
+ reasoningEffort,
387
+ size: effectiveSize,
388
+ format: resultFormat,
389
+ moderation,
390
+ refsCount: refsForRequest.length,
391
+ contextMode,
392
+ searchMode,
393
+ warnings: qualityWarnings,
394
+ revisedPrompt,
395
+ promptMode: normalizedPromptMode,
396
+ };
397
+ publishJobEvent(requestId, "done", payload);
398
+ if (res.writableEnded) {
399
+ // async mode — response already sent
400
+ }
401
+ else if (streamResponse) {
402
+ writeSse(res, "done", payload);
403
+ res.end();
404
+ }
405
+ else {
406
+ res.json(payload);
407
+ }
408
+ }
409
+ catch (e) {
410
+ const err = errInfo(e);
411
+ const ext = (err.raw && typeof err.raw === "object" ? err.raw : {});
412
+ const code = err.code || classifyUpstreamError(err.message) || "NODE_GEN_FAILED";
413
+ if (isGenerationCanceledError(err.raw) || isJobCanceled(requestId)) {
414
+ const canceled = makeGenerationCanceledError();
415
+ finishCanceled = true;
416
+ finishHttpStatus = canceled.status;
417
+ finishErrorCode = canceled.code;
418
+ return writeNodeError(res, canceled.status, canceled.code, canceled.message, parentNodeId, {}, requestId);
419
+ }
420
+ finishStatus = "error";
421
+ finishHttpStatus = err.status || 500;
422
+ finishErrorCode = code;
423
+ logError("node", "error", err.raw, { requestId, code, parentNodeId, sessionId, clientNodeId });
424
+ writeNodeError(res, err.status || 500, code, err.message, parentNodeId, {
425
+ upstreamCode: ext.upstreamCode || null,
426
+ upstreamType: ext.upstreamType || null,
427
+ upstreamParam: ext.upstreamParam || null,
428
+ }, requestId);
429
+ }
430
+ finally {
431
+ if (jobOwned)
432
+ finishJob(requestId, {
433
+ canceled: finishCanceled,
434
+ status: finishStatus,
435
+ httpStatus: finishHttpStatus,
436
+ errorCode: finishErrorCode,
437
+ meta: finishMeta,
438
+ });
439
+ }
440
+ }
@@ -0,0 +1,23 @@
1
+ import { validateAndNormalizeRefs } from "./refs.js";
2
+ import { validateModeration } from "./routeHelpers.js";
3
+ import { validateGenerationPrompt } from "./generationInputValidation.js";
4
+ export function validateNodeInputs(ctx, prompt, references, moderation) {
5
+ const promptError = validateGenerationPrompt(prompt);
6
+ if (promptError)
7
+ return promptError;
8
+ const refCheckResult = validateAndNormalizeRefs(references);
9
+ if (refCheckResult.error) {
10
+ return {
11
+ error: { code: refCheckResult.code, message: refCheckResult.error },
12
+ code: refCheckResult.code,
13
+ };
14
+ }
15
+ const moderationCheck = validateModeration(ctx, moderation);
16
+ if (moderationCheck.error) {
17
+ return { error: { code: "INVALID_MODERATION", message: moderationCheck.error } };
18
+ }
19
+ return {
20
+ prompt: prompt,
21
+ refCheck: refCheckResult,
22
+ };
23
+ }