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
package/routes/nodes.js CHANGED
@@ -1,455 +1,10 @@
1
- import { mkdir } from "fs/promises";
2
- import { newNodeId, saveNode, loadNodeMeta, loadAssetB64, } from "../lib/nodeStore.js";
3
- import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "../lib/inflight.js";
4
- import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
5
- import { detectImageMimeFromB64, summarizeReferencePayload, validateAndNormalizeRefs } from "../lib/refs.js";
6
- import { classifyUpstreamError } from "../lib/errorClassify.js";
7
- import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
8
- import { resolveProviderOptions } from "../lib/providerOptions.js";
9
- import { generateViaResponses, editViaResponses } from "../lib/responsesImageAdapter.js";
10
- import { generateViaGrok } from "../lib/grokImageAdapter.js";
11
- import { generateViaAgy } from "../lib/agyImageAdapter.js";
12
- import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
13
- import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
14
- import { logEvent, logError } from "../lib/logger.js";
1
+ import { loadNodeMeta } from "../lib/nodeStore.js";
15
2
  import { errInfo } from "../lib/errInfo.js";
3
+ import { runNodeGeneration } from "../lib/nodeGeneration.js";
16
4
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
17
- import { validateModeration, imageFormatFromMime, writeSse, dataUrlFromB64 } from "../lib/routeHelpers.js";
18
- import { publish } from "../lib/eventBus.js";
19
- import { publishJobEvent } from "../lib/ssePublish.js";
20
- import { asUpstream, wantsSse, writeNodeError, loadParentNodeB64, toGrokReferences, nodeErrorDetails, } from "../lib/nodeHelpers.js";
21
5
  export function registerNodeRoutes(app, ctxRaw) {
22
6
  const ctx = requireRuntimeContext(ctxRaw);
23
- app.post("/api/node/generate", async (req, res) => {
24
- const body = (req.body ?? {});
25
- const asyncMode = body.async === true;
26
- const streamResponse = !asyncMode && wantsSse(req);
27
- const parentNodeId = (typeof body.parentNodeId === "string" ? body.parentNodeId : null);
28
- const requestId = typeof body.requestId === "string" ? body.requestId : (req.id ?? "");
29
- const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
30
- const clientNodeId = typeof body.clientNodeId === "string" ? body.clientNodeId : null;
31
- let finishMeta = {};
32
- let finishStatus = "completed";
33
- let finishHttpStatus;
34
- let finishErrorCode;
35
- let finishCanceled = false;
36
- const cancelController = new AbortController();
37
- const referencePayload = summarizeReferencePayload(body.references);
38
- try {
39
- const { prompt, 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;
40
- const { provider = "oauth" } = body;
41
- const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
42
- const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
43
- const contextMode = ["parent-plus-refs", "parent-only", "ancestry"].includes(rawContextMode)
44
- ? rawContextMode
45
- : "parent-plus-refs";
46
- const searchMode = ["off", "auto", "on"].includes(rawSearchMode) ? rawSearchMode : "on";
47
- const providerOptions = resolveProviderOptions(ctx, {
48
- provider,
49
- rawModel,
50
- rawReasoningEffort,
51
- rawSize: size,
52
- rawWebSearchEnabled: body.webSearchEnabled,
53
- searchMode,
54
- });
55
- if (providerOptions.error) {
56
- finishStatus = "error";
57
- finishHttpStatus = providerOptions.status;
58
- finishErrorCode = providerOptions.code;
59
- return res.status(providerOptions.status).json({
60
- error: { code: providerOptions.code, message: providerOptions.error },
61
- parentNodeId,
62
- });
63
- }
64
- const imageModel = providerOptions.model;
65
- const reasoningEffort = providerOptions.reasoningEffort;
66
- const effectiveSize = providerOptions.size;
67
- const webSearchEnabled = providerOptions.webSearchEnabled;
68
- const activeProvider = providerOptions.provider;
69
- const effectiveImageModel = (activeProvider === "grok" || activeProvider === "grok-api") && quality === "high"
70
- ? "grok-imagine-image-quality"
71
- : imageModel;
72
- if (contextMode === "ancestry") {
73
- finishStatus = "error";
74
- finishHttpStatus = 400;
75
- finishErrorCode = "CONTEXT_MODE_UNSUPPORTED";
76
- return res.status(400).json({
77
- error: { code: "CONTEXT_MODE_UNSUPPORTED", message: "Ancestry context is not supported yet." },
78
- parentNodeId,
79
- });
80
- }
81
- if (!prompt || typeof prompt !== "string") {
82
- finishStatus = "error";
83
- finishHttpStatus = 400;
84
- finishErrorCode = "INVALID_PROMPT";
85
- return res.status(400).json({
86
- error: { code: "INVALID_PROMPT", message: "Prompt is required" },
87
- parentNodeId,
88
- });
89
- }
90
- const refCheckResult = validateAndNormalizeRefs(references);
91
- if (refCheckResult.error) {
92
- finishStatus = "error";
93
- finishHttpStatus = 400;
94
- finishErrorCode = refCheckResult.code;
95
- return res.status(400).json({
96
- error: { code: refCheckResult.code, message: refCheckResult.error },
97
- code: refCheckResult.code,
98
- parentNodeId,
99
- });
100
- }
101
- const refCheck = refCheckResult;
102
- const moderationCheck = validateModeration(ctx, moderation);
103
- if (moderationCheck.error) {
104
- finishStatus = "error";
105
- finishHttpStatus = 400;
106
- finishErrorCode = "INVALID_MODERATION";
107
- return res.status(400).json({
108
- error: { code: "INVALID_MODERATION", message: moderationCheck.error },
109
- parentNodeId,
110
- });
111
- }
112
- const startTime = Date.now();
113
- let parentB64 = null;
114
- if (parentNodeId) {
115
- parentB64 = await loadParentNodeB64(ctx, parentNodeId);
116
- }
117
- else if (typeof externalSrc === "string" && externalSrc.length > 0) {
118
- parentB64 = await loadAssetB64(ctx.rootDir, externalSrc, ctx.config.storage.generatedDir);
119
- }
120
- const operation = parentB64 ? "edit" : "generate";
121
- const referenceDiagnostics = refCheck.referenceDiagnostics || [];
122
- const generateReferenceDiagnostics = operation === "generate" ? referenceDiagnostics : [];
123
- const referenceMismatchCount = generateReferenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
124
- const refsForRequest = contextMode === "parent-only" ? [] : (refCheck.refDetails || refCheck.refs);
125
- const parentImagePresent = !!parentB64;
126
- const inputImageCount = (parentImagePresent ? 1 : 0) + refsForRequest.length;
127
- if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && inputImageCount > 3) {
128
- finishStatus = "error";
129
- finishHttpStatus = 400;
130
- const code = activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY";
131
- return res.status(400).json({
132
- error: {
133
- code,
134
- message: `${activeProvider === "agy" ? "Agy" : "Grok"} image editing supports up to 3 reference images.`,
135
- },
136
- code,
137
- parentNodeId,
138
- });
139
- }
140
- const started = startJob({
141
- requestId,
142
- kind: "node",
143
- prompt: body.prompt,
144
- meta: {
145
- kind: "node",
146
- sessionId,
147
- parentNodeId,
148
- clientNodeId,
149
- refsCount: referencePayload.refsCount,
150
- referenceBytes: referencePayload.referenceBytes,
151
- referenceB64Chars: referencePayload.referenceB64Chars,
152
- },
153
- });
154
- if (started && isStartJobFailure(started)) {
155
- finishStatus = "error";
156
- finishHttpStatus = started.code === "TOO_MANY_JOBS" ? 429 : 409;
157
- finishErrorCode = started.code;
158
- if (started.code === "TOO_MANY_JOBS") {
159
- res.setHeader("Retry-After", String(INFLIGHT_RETRY_AFTER_SECONDS));
160
- }
161
- return writeNodeError(res, finishHttpStatus, started.code, started.code === "TOO_MANY_JOBS"
162
- ? "Too many concurrent generation jobs"
163
- : "Request ID already in use", parentNodeId, {}, requestId);
164
- }
165
- registerJobAbortController(requestId, cancelController);
166
- if (asyncMode)
167
- res.status(202).json({ requestId });
168
- logEvent("node", "request", {
169
- requestId,
170
- operation,
171
- sessionId,
172
- parentNodeId,
173
- clientNodeId,
174
- quality,
175
- model: effectiveImageModel,
176
- size: effectiveSize,
177
- moderation,
178
- refs: refsForRequest.length,
179
- referenceBytes: referencePayload.referenceBytes,
180
- referenceMismatchCount,
181
- refDetectedMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
182
- refDeclaredMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.declaredMime).filter(Boolean))].join(","),
183
- inputImageCount,
184
- parentImagePresent,
185
- contextMode,
186
- searchMode,
187
- webSearchEnabled,
188
- promptChars: prompt.length,
189
- promptMode: normalizedPromptMode,
190
- });
191
- const emitProgress = streamResponse || asyncMode;
192
- if (streamResponse) {
193
- res.writeHead(200, {
194
- "Content-Type": "text/event-stream; charset=utf-8",
195
- "Cache-Control": "no-cache, no-transform",
196
- Connection: "keep-alive",
197
- });
198
- writeSse(res, "phase", { requestId, phase: "streaming" });
199
- publish(requestId, "phase", { requestId, phase: "streaming" });
200
- }
201
- else if (asyncMode) {
202
- publish(requestId, "phase", { requestId, phase: "streaming" });
203
- }
204
- let b64, usage, webSearchCalls = 0, revisedPrompt = null;
205
- const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
206
- let resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : format;
207
- const maxAttempts = inputImageCount > 0 ? 1 : 2;
208
- let lastErr = null;
209
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
210
- try {
211
- logEvent("node", "attempt", {
212
- requestId,
213
- attempt,
214
- operation,
215
- sessionId,
216
- parentNodeId,
217
- clientNodeId,
218
- model: effectiveImageModel,
219
- moderation,
220
- quality,
221
- size: effectiveSize,
222
- refs: refsForRequest.length,
223
- inputImageCount,
224
- parentImagePresent,
225
- contextMode,
226
- searchMode,
227
- webSearchEnabled,
228
- });
229
- const r = activeProvider === "gemini-api"
230
- ? await generateViaGeminiApi(parentB64 ? `Edit this image: ${prompt}` : prompt, requireRuntimeContext(ctx), {
231
- model: effectiveImageModel,
232
- size: effectiveSize,
233
- signal: cancelController.signal,
234
- requestId,
235
- references: parentB64
236
- ? [{ b64: parentB64, declaredMime: null, detectedMime: null }, ...(refCheck.refDetails || [])]
237
- : refCheck.refDetails,
238
- })
239
- : activeProvider === "agy"
240
- ? await generateViaAgy(parentB64 ? `Edit this image: ${prompt}` : prompt, {
241
- references: parentB64
242
- ? [{ b64: parentB64, declaredMime: null, detectedMime: null }]
243
- : undefined,
244
- signal: cancelController.signal,
245
- requestId,
246
- })
247
- : activeProvider === "grok" || activeProvider === "grok-api"
248
- ? await generateViaGrok(prompt, ctx, {
249
- model: effectiveImageModel,
250
- size: effectiveSize,
251
- requestId,
252
- signal: cancelController.signal,
253
- references: toGrokReferences(parentB64, refsForRequest),
254
- directApiKey: grokDirectApiKey,
255
- })
256
- : parentB64
257
- ? await editViaResponses(activeProvider, prompt, parentB64, quality, effectiveSize, moderation, normalizedPromptMode, ctx, requestId, {
258
- model: effectiveImageModel,
259
- references: refsForRequest,
260
- searchMode,
261
- reasoningEffort,
262
- webSearchEnabled,
263
- signal: cancelController.signal,
264
- })
265
- : await generateViaResponses(activeProvider, prompt, quality, effectiveSize, moderation, refsForRequest, requestId, normalizedPromptMode, ctx, {
266
- model: effectiveImageModel,
267
- reasoningEffort,
268
- webSearchEnabled,
269
- signal: cancelController.signal,
270
- partialImages: emitProgress ? 2 : 0,
271
- onPartialImage: emitProgress
272
- ? (partial) => {
273
- if (isJobCanceled(requestId))
274
- return;
275
- const pd = { requestId, image: dataUrlFromB64(format, partial.b64), index: partial.index };
276
- if (streamResponse)
277
- writeSse(res, "partial", pd);
278
- publish(requestId, "partial", pd);
279
- }
280
- : null,
281
- });
282
- throwIfJobCanceled(requestId);
283
- if (r.b64) {
284
- b64 = r.b64;
285
- usage = r.usage;
286
- webSearchCalls = r.webSearchCalls || 0;
287
- revisedPrompt = r.revisedPrompt || null;
288
- if (activeProvider === "grok" || activeProvider === "grok-api" || activeProvider === "gemini-api") {
289
- resultFormat = imageFormatFromMime(("mime" in r ? r.mime : undefined) || detectImageMimeFromB64(r.b64) || "image/jpeg");
290
- }
291
- break;
292
- }
293
- lastErr = { message: "Empty response (safety refusal)" };
294
- }
295
- catch (e) {
296
- lastErr = asUpstream(e);
297
- if (isNonRetryableGenerationError(lastErr))
298
- break;
299
- }
300
- if (attempt + 1 < maxAttempts) {
301
- logEvent("node", "retry", {
302
- requestId,
303
- attempt: attempt + 1,
304
- operation,
305
- parentNodeId,
306
- clientNodeId,
307
- errorCode: lastErr?.code,
308
- errorEventType: lastErr?.eventType,
309
- errorEventCount: lastErr?.eventCount,
310
- });
311
- }
312
- }
313
- if (!b64) {
314
- const finalErr = normalizeGenerationFailure(lastErr, {
315
- safetyMessage: lastErr?.message || "Empty response after generation attempt",
316
- });
317
- finishStatus = "error";
318
- finishHttpStatus = finalErr.status || 500;
319
- finishErrorCode = finalErr.code || "NODE_GEN_FAILED";
320
- logEvent("node", "final_error", {
321
- requestId,
322
- operation,
323
- finalCode: finishErrorCode,
324
- upstreamCode: lastErr?.upstreamCode || lastErr?.code,
325
- errorEventType: lastErr?.eventType,
326
- errorEventCount: lastErr?.eventCount,
327
- diagnosticReason: lastErr?.diagnosticReason,
328
- retryKind: lastErr?.retryKind,
329
- referencesDroppedOnRetry: lastErr?.referencesDroppedOnRetry,
330
- attempts: maxAttempts,
331
- outerHttpAlreadyCommitted: res.headersSent,
332
- sseErrorSent: streamResponse,
333
- });
334
- return writeNodeError(res, finishHttpStatus ?? 500, finishErrorCode ?? "NODE_GEN_FAILED", finalErr.message, parentNodeId, nodeErrorDetails(finalErr, lastErr), requestId);
335
- }
336
- const nodeId = newNodeId();
337
- throwIfJobCanceled(requestId);
338
- const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
339
- const meta = {
340
- nodeId,
341
- parentNodeId,
342
- sessionId,
343
- clientNodeId,
344
- prompt,
345
- userPrompt: prompt,
346
- revisedPrompt,
347
- promptMode: normalizedPromptMode,
348
- options: { quality, size: effectiveSize, format: resultFormat, moderation },
349
- model: effectiveImageModel,
350
- reasoningEffort,
351
- createdAt: Date.now(),
352
- createdAtIso: new Date().toISOString(),
353
- elapsed,
354
- usage: usage || null,
355
- webSearchCalls,
356
- webSearchEnabled,
357
- contextMode,
358
- searchMode,
359
- provider: activeProvider,
360
- kind: parentB64 ? "edit" : "generate",
361
- requestId,
362
- refsCount: refsForRequest.length,
363
- quality,
364
- size: effectiveSize,
365
- format: resultFormat,
366
- moderation,
367
- };
368
- await mkdir(ctx.config.storage.generatedDir, { recursive: true });
369
- throwIfJobCanceled(requestId);
370
- const { filename } = await saveNode(ctx.rootDir, {
371
- nodeId,
372
- b64,
373
- meta,
374
- ext: resultFormat,
375
- generatedDir: ctx.config.storage.generatedDir,
376
- });
377
- finishMeta = { nodeId, filename, imageChars: b64.length };
378
- finishHttpStatus = 200;
379
- logEvent("node", "saved", {
380
- requestId,
381
- nodeId,
382
- filename,
383
- imageChars: b64.length,
384
- elapsedMs: Date.now() - startTime,
385
- });
386
- const payload = {
387
- nodeId,
388
- parentNodeId,
389
- requestId,
390
- image: dataUrlFromB64(resultFormat, b64),
391
- filename,
392
- url: `/generated/${filename}`,
393
- elapsed,
394
- usage,
395
- webSearchCalls,
396
- webSearchEnabled,
397
- provider: activeProvider,
398
- model: effectiveImageModel,
399
- reasoningEffort,
400
- size: effectiveSize,
401
- format: resultFormat,
402
- moderation,
403
- refsCount: refsForRequest.length,
404
- contextMode,
405
- searchMode,
406
- warnings: qualityWarnings,
407
- revisedPrompt,
408
- promptMode: normalizedPromptMode,
409
- };
410
- publishJobEvent(requestId, "done", payload);
411
- if (res.writableEnded) {
412
- // async mode — response already sent
413
- }
414
- else if (streamResponse) {
415
- writeSse(res, "done", payload);
416
- res.end();
417
- }
418
- else {
419
- res.json(payload);
420
- }
421
- }
422
- catch (e) {
423
- const err = errInfo(e);
424
- const ext = (err.raw && typeof err.raw === "object" ? err.raw : {});
425
- const code = err.code || classifyUpstreamError(err.message) || "NODE_GEN_FAILED";
426
- if (isGenerationCanceledError(err.raw) || isJobCanceled(requestId)) {
427
- const canceled = makeGenerationCanceledError();
428
- finishCanceled = true;
429
- finishHttpStatus = canceled.status;
430
- finishErrorCode = canceled.code;
431
- return writeNodeError(res, canceled.status, canceled.code, canceled.message, parentNodeId, {}, requestId);
432
- }
433
- finishStatus = "error";
434
- finishHttpStatus = err.status || 500;
435
- finishErrorCode = code;
436
- logError("node", "error", err.raw, { requestId, code, parentNodeId, sessionId, clientNodeId });
437
- writeNodeError(res, err.status || 500, code, err.message, parentNodeId, {
438
- upstreamCode: ext.upstreamCode || null,
439
- upstreamType: ext.upstreamType || null,
440
- upstreamParam: ext.upstreamParam || null,
441
- }, requestId);
442
- }
443
- finally {
444
- finishJob(requestId, {
445
- canceled: finishCanceled,
446
- status: finishStatus,
447
- httpStatus: finishHttpStatus,
448
- errorCode: finishErrorCode,
449
- meta: finishMeta,
450
- });
451
- }
452
- });
7
+ app.post("/api/node/generate", (req, res) => runNodeGeneration(req, res, ctx));
453
8
  app.get("/api/node/:nodeId", async (req, res) => {
454
9
  try {
455
10
  const { nodeId } = req.params;
package/routes/video.js CHANGED
@@ -21,8 +21,14 @@ import { generateVideoThumbnail } from "../lib/videoThumb.js";
21
21
  import { publish } from "../lib/eventBus.js";
22
22
  import { publishJobEvent } from "../lib/ssePublish.js";
23
23
  function sendSse(res, event, data) {
24
- res.write(`event: ${event}\n`);
25
- res.write(`data: ${JSON.stringify(data)}\n\n`);
24
+ if (res.writableEnded || res.destroyed)
25
+ return;
26
+ try {
27
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
28
+ }
29
+ catch {
30
+ /* client went away mid-write; bus emit still delivers */
31
+ }
26
32
  }
27
33
  function dualEmitVideo(res, requestId, event, data) {
28
34
  if (!res.writableEnded)
@@ -107,6 +113,7 @@ export function registerVideoRoutes(app, ctxRaw) {
107
113
  let finishErrorCode;
108
114
  let finishMeta = {};
109
115
  let finishCanceled = false;
116
+ let jobOwned = false;
110
117
  const cancelController = new AbortController();
111
118
  if (!asyncMode) {
112
119
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
@@ -249,6 +256,7 @@ export function registerVideoRoutes(app, ctxRaw) {
249
256
  ? "Too many concurrent generation jobs"
250
257
  : "Request ID already in use");
251
258
  }
259
+ jobOwned = true;
252
260
  registerJobAbortController(requestId, cancelController);
253
261
  if (asyncMode)
254
262
  res.status(202).json({ requestId });
@@ -389,7 +397,8 @@ export function registerVideoRoutes(app, ctxRaw) {
389
397
  }
390
398
  }
391
399
  finally {
392
- finishJob(requestId, { canceled: finishCanceled, status: finishStatus, httpStatus: finishHttpStatus, errorCode: finishErrorCode, meta: finishMeta });
400
+ if (jobOwned)
401
+ finishJob(requestId, { canceled: finishCanceled, status: finishStatus, httpStatus: finishHttpStatus, errorCode: finishErrorCode, meta: finishMeta });
393
402
  if (!res.writableEnded)
394
403
  res.end();
395
404
  }
@@ -15,6 +15,15 @@ function routeError(message, status = 400) {
15
15
  return Object.assign(new Error(message), { status });
16
16
  }
17
17
  function sendError(res, err) {
18
+ if (err?.name === "TimeoutError") {
19
+ res.status(504).json({ error: "Video operation timed out", code: "VIDEO_TIMEOUT" });
20
+ return;
21
+ }
22
+ if (err?.name === "AbortError") {
23
+ if (!res.headersSent)
24
+ res.status(499).json({ error: "Request canceled", code: "REQUEST_CANCELED" });
25
+ return;
26
+ }
18
27
  res.status(typeof err?.status === "number" ? err.status : 500).json({ error: err?.message || String(err) });
19
28
  }
20
29
  async function safeGeneratedFile(ctx, file, options = {}) {
@@ -102,7 +111,11 @@ async function saveVideoResult(ctx, options) {
102
111
  invalidateHistoryIndex();
103
112
  return { filename, url: `/generated/${encodeURIComponent(filename)}`, sourceUrl: options.videoUrl };
104
113
  }
105
- function requestSignal(req, res) {
114
+ function envDeadline(name, fallbackMs) {
115
+ const value = Number(process.env[name]);
116
+ return Number.isFinite(value) && value >= 1000 ? value : fallbackMs;
117
+ }
118
+ function requestSignal(req, res, timeoutMs) {
106
119
  const ac = new AbortController();
107
120
  const abort = () => {
108
121
  if (!res.writableEnded)
@@ -110,7 +123,7 @@ function requestSignal(req, res) {
110
123
  };
111
124
  req.on("aborted", abort);
112
125
  res.on("close", abort);
113
- return ac.signal;
126
+ return AbortSignal.any([ac.signal, AbortSignal.timeout(timeoutMs)]);
114
127
  }
115
128
  function requirePrompt(value) {
116
129
  return typeof value === "string" && value.trim() ? value : null;
@@ -143,7 +156,7 @@ export function registerVideoExtendedRoutes(app, ctxRaw) {
143
156
  if (!videoUrl || typeof videoUrl !== "string")
144
157
  return res.status(400).json({ error: "videoUrl required" });
145
158
  const validModel = validateEditModel(model);
146
- const signal = requestSignal(req, res);
159
+ const signal = requestSignal(req, res, envDeadline("IMA2_VIDEO_EDIT_TIMEOUT_MS", 10 * 60_000));
147
160
  const { url, headers } = videoProxyUrl(ctx, "/v1/videos/edits");
148
161
  const video = await resolveVideoInput(ctx, videoUrl);
149
162
  const apiRes = await fetch(url, { method: "POST", headers, body: JSON.stringify({ model: validModel, prompt, video }), signal });
@@ -182,7 +195,7 @@ export function registerVideoExtendedRoutes(app, ctxRaw) {
182
195
  const dur = Number(duration);
183
196
  if (!Number.isInteger(dur) || dur < 2 || dur > 10)
184
197
  return res.status(400).json({ error: "duration must be an integer between 2 and 10" });
185
- const signal = requestSignal(req, res);
198
+ const signal = requestSignal(req, res, envDeadline("IMA2_VIDEO_EXTEND_TIMEOUT_MS", 10 * 60_000));
186
199
  const { url, headers } = videoProxyUrl(ctx, "/v1/videos/extensions");
187
200
  const video = await resolveVideoInput(ctx, videoUrl);
188
201
  const apiRes = await fetch(url, { method: "POST", headers, body: JSON.stringify({ model: validModel, prompt, duration: dur, video }), signal });
@@ -238,6 +251,7 @@ export function registerVideoExtendedRoutes(app, ctxRaw) {
238
251
  // --- Video Analysis (Grok 4.3 Vision) ---
239
252
  app.post("/api/video/analyze", async (req, res) => {
240
253
  try {
254
+ const signal = requestSignal(req, res, envDeadline("IMA2_VIDEO_ANALYZE_TIMEOUT_MS", 2 * 60_000));
241
255
  const { videoUrl } = req.body ?? {};
242
256
  if (!videoUrl || typeof videoUrl !== "string")
243
257
  return res.status(400).json({ error: "videoUrl required" });
@@ -268,6 +282,7 @@ export function registerVideoExtendedRoutes(app, ctxRaw) {
268
282
  ],
269
283
  }],
270
284
  }),
285
+ signal,
271
286
  });
272
287
  if (!apiRes.ok) {
273
288
  const t = await apiRes.text();
package/server.js CHANGED
@@ -10,7 +10,7 @@ import { startGrokProxy } from "./lib/grokProxyLauncher.js";
10
10
  import { startOAuthProxy } from "./lib/oauthLauncher.js";
11
11
  import { migrateGeneratedStorage } from "./lib/storageMigration.js";
12
12
  import { purgeStaleJobs } from "./lib/inflight.js";
13
- import { configureLogger } from "./lib/logger.js";
13
+ import { configureLogger, logError } from "./lib/logger.js";
14
14
  import { createRequestLogger } from "./lib/requestLogger.js";
15
15
  import { configureApiCachePolicy } from "./lib/apiCachePolicy.js";
16
16
  import { configureRoutes } from "./routes/index.js";
@@ -21,6 +21,7 @@ import { stopAgentQueueWorker } from "./lib/agentQueueWorker.js";
21
21
  import { reapCardNewsJobs } from "./lib/cardNewsJobStore.js";
22
22
  import { reapTerminalJobs } from "./lib/inflight.js";
23
23
  import { errInfo } from "./lib/errInfo.js";
24
+ import { timingSafeEqual } from "node:crypto";
24
25
  const rootDir = dirname(fileURLToPath(import.meta.url));
25
26
  async function loadApiKey() {
26
27
  if (process.env.OPENAI_API_KEY) {
@@ -152,11 +153,43 @@ function setUiStaticHeaders(res, filePath) {
152
153
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
153
154
  }
154
155
  }
156
+ export function isLoopbackHost(host) {
157
+ const normalized = String(host || "").trim().toLowerCase();
158
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
159
+ }
160
+ export function assertLanAccessConfiguration(host, token) {
161
+ if (isLoopbackHost(host) || token)
162
+ return;
163
+ const message = `[server.security] Refusing non-loopback host ${host || "<empty>"}: set IMA2_LAN_TOKEN to enable LAN access.`;
164
+ console.error(message);
165
+ throw new Error(message);
166
+ }
167
+ function tokenMatches(actual, expected) {
168
+ if (typeof actual !== "string")
169
+ return false;
170
+ const actualBuffer = Buffer.from(actual);
171
+ const expectedBuffer = Buffer.from(expected);
172
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
173
+ }
174
+ function createLanApiGuard(host, token) {
175
+ const requiredToken = isLoopbackHost(host) ? "" : String(token || "");
176
+ return function lanApiGuard(req, res, next) {
177
+ if (!requiredToken || !req.path.startsWith("/api"))
178
+ return next();
179
+ const supplied = req.get("x-ima2-token") ?? req.query.token;
180
+ if (tokenMatches(supplied, requiredToken))
181
+ return next();
182
+ return res.status(401).json({
183
+ error: { code: "LAN_TOKEN_REQUIRED", message: "A valid IMA2 LAN token is required" },
184
+ });
185
+ };
186
+ }
155
187
  export function buildApp(ctx) {
156
188
  const app = express();
157
189
  configureApiCachePolicy(app);
158
190
  configureLogger({ level: ctx.config.log.level });
159
191
  app.use(createRequestLogger());
192
+ app.use(createLanApiGuard(ctx.config.server.host, ctx.config.server.lanToken));
160
193
  app.use(express.json({ limit: ctx.config.server.bodyLimit }));
161
194
  app.use(express.static(join(ctx.rootDir, "ui", "dist"), {
162
195
  setHeaders: setUiStaticHeaders,
@@ -173,6 +206,24 @@ export function buildApp(ctx) {
173
206
  immutable: true,
174
207
  }));
175
208
  configureRoutes(app, ctx);
209
+ app.use((_req, res) => {
210
+ res.status(404).json({ error: { code: "NOT_FOUND", message: "Route not found" } });
211
+ });
212
+ app.use((error, _req, res, _next) => {
213
+ const info = errInfo(error);
214
+ const candidateStatus = Number(info.status);
215
+ const operational = Boolean(error?.isOperational)
216
+ || (Number.isInteger(candidateStatus) && candidateStatus >= 400 && candidateStatus < 500);
217
+ const status = operational ? candidateStatus : 500;
218
+ if (!operational)
219
+ logError("server", "unhandled:error", error);
220
+ res.status(status).json({
221
+ error: {
222
+ code: operational && info.code ? info.code : "INTERNAL_ERROR",
223
+ message: operational ? info.message : "Internal server error",
224
+ },
225
+ });
226
+ });
176
227
  return app;
177
228
  }
178
229
  function runtimeHostUrl(host) {
@@ -309,6 +360,7 @@ export async function createRuntimeContext(overrides = {}) {
309
360
  }
310
361
  export async function startServer(overrides = {}) {
311
362
  const ctx = await createRuntimeContext(overrides);
363
+ assertLanAccessConfiguration(ctx.config.server.host, ctx.config.server.lanToken);
312
364
  await migrateGeneratedStorage(ctx);
313
365
  purgeStaleJobs();
314
366
  const app = buildApp(ctx);