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/lib/inflight.js CHANGED
@@ -4,6 +4,8 @@ import { publish } from "./eventBus.js";
4
4
  import { logEvent } from "./logger.js";
5
5
  const terminalJobs = new Map(); // requestId -> terminal snapshot, active-only API stays default
6
6
  const abortControllers = new Map();
7
+ const controllerRegisteredAt = new Map();
8
+ const ORPHAN_CONTROLLER_TTL_MS = Math.max(config.inflight.ttlMs * 6, 60 * 60 * 1000);
7
9
  export const MAX_CONCURRENT_JOBS = Math.max(1, Math.trunc(Number(config.limits.maxParallel) || 24));
8
10
  export const INFLIGHT_RETRY_AFTER_SECONDS = 5;
9
11
  export function isStartJobFailure(r) {
@@ -52,6 +54,7 @@ export function startJob({ requestId, kind, prompt, meta = {} }) {
52
54
  }
53
55
  terminalJobs.delete(requestId);
54
56
  abortControllers.delete(requestId);
57
+ controllerRegisteredAt.delete(requestId);
55
58
  logEvent("inflight", "start", {
56
59
  requestId,
57
60
  kind,
@@ -66,6 +69,7 @@ export function registerJobAbortController(requestId, controller) {
66
69
  if (!requestId)
67
70
  return;
68
71
  abortControllers.set(requestId, controller);
72
+ controllerRegisteredAt.set(requestId, Date.now());
69
73
  }
70
74
  export function abortJob(requestId) {
71
75
  if (!requestId)
@@ -112,8 +116,8 @@ export function finishJob(requestId, options = {}) {
112
116
  if (!requestId)
113
117
  return;
114
118
  const j = getJob(requestId);
119
+ const finishedAt = Date.now();
115
120
  if (j) {
116
- const finishedAt = Date.now();
117
121
  const status = options.canceled ? "canceled" : options.status || "completed";
118
122
  terminalJobs.set(requestId, {
119
123
  requestId,
@@ -140,16 +144,38 @@ export function finishJob(requestId, options = {}) {
140
144
  errorCode: options.errorCode,
141
145
  });
142
146
  }
147
+ else if (options.canceled && !terminalJobs.has(requestId)) {
148
+ terminalJobs.set(requestId, {
149
+ requestId,
150
+ kind: "unknown",
151
+ status: "canceled",
152
+ startedAt: finishedAt,
153
+ finishedAt,
154
+ durationMs: 0,
155
+ phase: "unknown",
156
+ phaseAt: finishedAt,
157
+ httpStatus: options.httpStatus,
158
+ errorCode: options.errorCode,
159
+ meta: {},
160
+ });
161
+ }
143
162
  getDb().prepare("DELETE FROM inflight WHERE request_id = ?").run(requestId);
144
163
  abortControllers.delete(requestId);
164
+ controllerRegisteredAt.delete(requestId);
145
165
  reapTerminalJobs();
146
166
  }
147
- export function reapTerminalJobs() {
148
- const now = Date.now();
167
+ export function reapTerminalJobs(now = Date.now()) {
149
168
  for (const [id, j] of terminalJobs) {
150
169
  if (now - j.finishedAt > config.inflight.terminalTtlMs)
151
170
  terminalJobs.delete(id);
152
171
  }
172
+ for (const [id, registeredAt] of controllerRegisteredAt) {
173
+ if (now - registeredAt <= ORPHAN_CONTROLLER_TTL_MS || getJob(id))
174
+ continue;
175
+ abortControllers.get(id)?.abort();
176
+ abortControllers.delete(id);
177
+ controllerRegisteredAt.delete(id);
178
+ }
153
179
  }
154
180
  export function listJobs(filters = {}) {
155
181
  purgeStaleJobs();
@@ -187,6 +213,7 @@ export function _resetForTests() {
187
213
  getDb().prepare("DELETE FROM inflight").run();
188
214
  terminalJobs.clear();
189
215
  abortControllers.clear();
216
+ controllerRegisteredAt.clear();
190
217
  }
191
218
  export function purgeStaleJobs(now = Date.now()) {
192
219
  getDb()
@@ -0,0 +1,462 @@
1
+ import { mkdir, writeFile } from "fs/promises";
2
+ import { safeWriteSidecar } from "./atomicWrite.js";
3
+ import { join } from "path";
4
+ import { randomBytes } from "crypto";
5
+ import { detectImageMimeFromB64, summarizeReferencePayload, validateAndNormalizeRefs } from "./refs.js";
6
+ import { generateImageThumbnailFromBuffer } from "./imageThumb.js";
7
+ import { classifyUpstreamError } from "./errorClassify.js";
8
+ import { normalizeOAuthParams } from "./oauthNormalize.js";
9
+ import { resolveProviderOptions } from "./providerOptions.js";
10
+ import { generateMultimodeViaResponses } from "./responsesImageAdapter.js";
11
+ import { generateMultimodeViaGrok } from "./grokMultimodeAdapter.js";
12
+ import { generateViaAgy } from "./agyImageAdapter.js";
13
+ import { generateViaGeminiApi } from "./geminiApiImageAdapter.js";
14
+ import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "./inflight.js";
15
+ import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "./generationCancel.js";
16
+ import { logEvent, logError } from "./logger.js";
17
+ import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
18
+ import { invalidateHistoryIndex } from "./historyIndex.js";
19
+ import { normalizeComposerInsertedPrompts, normalizeComposerPrompt, } from "./composerSnapshot.js";
20
+ import { errInfo } from "./errInfo.js";
21
+ import { requireRuntimeContext } from "./runtimeContext.js";
22
+ import { validateModeration, imageFormatFromMime, writeSse } from "./routeHelpers.js";
23
+ import { publish } from "./eventBus.js";
24
+ import { publishJobEvent } from "./ssePublish.js";
25
+ import { normalizeMaxImages, sequenceStatus, } from "./multimodeHelpers.js";
26
+ import { normalizeBodyRequestId, validateBoundedCount, validateGenerationPrompt } from "./generationInputValidation.js";
27
+ function dualEmitMultimode(res, requestId, event, data) {
28
+ if (!res.writableEnded)
29
+ writeSse(res, event, data);
30
+ if (event === "done") {
31
+ publishJobEvent(requestId, event, data);
32
+ }
33
+ else {
34
+ publish(requestId, event, data);
35
+ }
36
+ }
37
+ function respondMultimodeValidationError(res, requestId, asyncMode, status, payload) {
38
+ publish(requestId, "error", payload);
39
+ if (asyncMode && !res.headersSent) {
40
+ return res.status(status).json(payload);
41
+ }
42
+ if (!res.writableEnded) {
43
+ writeSse(res, "error", payload);
44
+ res.end();
45
+ }
46
+ }
47
+ export async function runMultimodePipeline(req, res, ctx) {
48
+ const requestId = normalizeBodyRequestId(req.body?.requestId, req.id);
49
+ const asyncMode = req.body?.async === true;
50
+ const promptError = validateGenerationPrompt(req.body?.prompt);
51
+ if (promptError)
52
+ return res.status(400).json(promptError);
53
+ const maxAllowedImages = Math.max(1, Math.trunc(Number(ctx.config.limits.maxGeneratedImages) || 1));
54
+ const maxImagesResult = validateBoundedCount(req.body?.maxImages, 1, maxAllowedImages, "maxImages");
55
+ if ("error" in maxImagesResult)
56
+ return res.status(400).json(maxImagesResult);
57
+ let finishStatus = "completed";
58
+ let finishHttpStatus = 200;
59
+ let finishErrorCode;
60
+ let finishMeta = {};
61
+ let finishCanceled = false;
62
+ let jobOwned = false;
63
+ const cancelController = new AbortController();
64
+ const images = [];
65
+ const persistedIndexes = new Set();
66
+ let routeMaxImages = 0;
67
+ let routeSequenceId = "";
68
+ let routeStartTime = Date.now();
69
+ let routeActiveProvider = "auto";
70
+ let routeQuality = "medium";
71
+ let routeEffectiveSize = "1024x1024";
72
+ let routeModeration = "low";
73
+ let routeImageModel = null;
74
+ let routeWebSearchEnabled = true;
75
+ let routePromptMode = "auto";
76
+ let routeQualityWarnings = [];
77
+ let routeComposerPrompt = null;
78
+ let routeComposerInsertedPrompts = [];
79
+ let latestUsage = null;
80
+ let latestWebSearchCalls = 0;
81
+ let latestExtraIgnored = 0;
82
+ if (!asyncMode) {
83
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
84
+ res.setHeader("Cache-Control", "no-cache, no-transform");
85
+ res.setHeader("Connection", "keep-alive");
86
+ res.flushHeaders?.();
87
+ }
88
+ try {
89
+ const { prompt, quality: rawQuality = "medium", size = "1024x1024", format = "png", moderation = "low", provider = "auto", references = [], mode: promptMode = "auto", model: rawModel, reasoningEffort: rawReasoningEffort, webSearchEnabled: rawWebSearchEnabled = true, } = req.body;
90
+ const composerPrompt = normalizeComposerPrompt(req.body?.composerPrompt);
91
+ const composerInsertedPrompts = normalizeComposerInsertedPrompts(req.body?.composerInsertedPrompts);
92
+ const maxImages = normalizeMaxImages(maxImagesResult.value, maxAllowedImages);
93
+ const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
94
+ const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
95
+ const providerOptions = resolveProviderOptions(ctx, {
96
+ provider,
97
+ rawModel,
98
+ rawReasoningEffort,
99
+ rawSize: size,
100
+ rawWebSearchEnabled,
101
+ });
102
+ if (providerOptions.error) {
103
+ finishStatus = "error";
104
+ finishHttpStatus = providerOptions.status;
105
+ finishErrorCode = providerOptions.code;
106
+ return respondMultimodeValidationError(res, requestId, asyncMode, providerOptions.status, {
107
+ error: providerOptions.error,
108
+ code: providerOptions.code,
109
+ status: providerOptions.status,
110
+ requestId,
111
+ });
112
+ }
113
+ const imageModel = providerOptions.model;
114
+ const reasoningEffort = providerOptions.reasoningEffort;
115
+ const effectiveSize = providerOptions.size;
116
+ const webSearchEnabled = providerOptions.webSearchEnabled;
117
+ const activeProvider = providerOptions.provider;
118
+ const moderationCheck = validateModeration(ctx, moderation);
119
+ if (moderationCheck.error) {
120
+ finishStatus = "error";
121
+ finishHttpStatus = 400;
122
+ finishErrorCode = "INVALID_MODERATION";
123
+ return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
124
+ error: moderationCheck.error,
125
+ code: finishErrorCode,
126
+ status: 400,
127
+ requestId,
128
+ });
129
+ }
130
+ const refCheckResult = validateAndNormalizeRefs(references);
131
+ if (refCheckResult.error) {
132
+ finishStatus = "error";
133
+ finishHttpStatus = 400;
134
+ finishErrorCode = refCheckResult.code;
135
+ return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
136
+ error: refCheckResult.error,
137
+ code: refCheckResult.code,
138
+ status: 400,
139
+ requestId,
140
+ });
141
+ }
142
+ const refCheck = refCheckResult;
143
+ const incomingProviderUrl = typeof req.body?.providerUrl === "string" && req.body.providerUrl.startsWith("http") ? req.body.providerUrl : null;
144
+ const referencePayload = summarizeReferencePayload(references);
145
+ const started = startJob({
146
+ requestId,
147
+ kind: "multimode",
148
+ prompt,
149
+ meta: {
150
+ kind: "multimode",
151
+ quality,
152
+ model: imageModel,
153
+ size: effectiveSize,
154
+ maxImages,
155
+ refsCount: referencePayload.refsCount,
156
+ referenceBytes: referencePayload.referenceBytes,
157
+ referenceB64Chars: referencePayload.referenceB64Chars,
158
+ composerPrompt,
159
+ composerInsertedPrompts,
160
+ },
161
+ });
162
+ if (started && isStartJobFailure(started)) {
163
+ finishStatus = "error";
164
+ finishHttpStatus = started.code === "TOO_MANY_JOBS" ? 429 : 409;
165
+ finishErrorCode = started.code;
166
+ if (started.code === "TOO_MANY_JOBS") {
167
+ res.setHeader("Retry-After", String(INFLIGHT_RETRY_AFTER_SECONDS));
168
+ }
169
+ return respondMultimodeValidationError(res, requestId, asyncMode, finishHttpStatus, {
170
+ error: started.code === "TOO_MANY_JOBS"
171
+ ? "Too many concurrent generation jobs"
172
+ : "Request ID already in use",
173
+ code: started.code,
174
+ status: finishHttpStatus,
175
+ requestId,
176
+ });
177
+ }
178
+ jobOwned = true;
179
+ registerJobAbortController(requestId, cancelController);
180
+ if (asyncMode)
181
+ res.status(202).json({ requestId });
182
+ logEvent("multimode", "request", { requestId, quality, model: imageModel, size: effectiveSize, moderation, maxImages, refs: refCheck.refs.length, referenceBytes: referencePayload.referenceBytes, promptChars: typeof prompt === "string" ? prompt.length : 0, webSearchEnabled, });
183
+ const startTime = Date.now();
184
+ const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
185
+ const mmFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : String(format);
186
+ const mime = mimeMap[mmFormat] || "image/png";
187
+ const sequenceId = `seq_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
188
+ routeMaxImages = maxImages;
189
+ routeSequenceId = sequenceId;
190
+ routeStartTime = startTime;
191
+ routeActiveProvider = activeProvider ?? "auto";
192
+ routeQuality = quality;
193
+ routeEffectiveSize = effectiveSize;
194
+ routeModeration = moderation;
195
+ routeImageModel = imageModel ?? null;
196
+ routeWebSearchEnabled = webSearchEnabled ?? false;
197
+ routePromptMode = normalizedPromptMode;
198
+ routeQualityWarnings = qualityWarnings;
199
+ routeComposerPrompt = composerPrompt;
200
+ routeComposerInsertedPrompts = composerInsertedPrompts;
201
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
202
+ const persistAndSendImage = async (image, index, totalReturned, status) => {
203
+ if (persistedIndexes.has(index))
204
+ return;
205
+ throwIfJobCanceled(requestId);
206
+ const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
207
+ ? (image.mime || detectImageMimeFromB64(image.b64) || mime)
208
+ : mime;
209
+ const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(resultMime) : mmFormat;
210
+ const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
211
+ const filename = `${Date.now()}_${rand}_multimode_${index}.${resultFormat}`;
212
+ const createdAt = Date.now();
213
+ const meta = {
214
+ kind: "multimode-image",
215
+ generationStrategy: "one-call-text-sequence",
216
+ sequenceId,
217
+ sequenceIndex: index + 1,
218
+ sequenceTotalRequested: maxImages,
219
+ sequenceTotalReturned: totalReturned,
220
+ sequenceStatus: status,
221
+ stageLabel: String.fromCharCode(65 + index),
222
+ requestId,
223
+ prompt,
224
+ userPrompt: prompt,
225
+ revisedPrompt: image.revisedPrompt || null,
226
+ promptMode: normalizedPromptMode,
227
+ composerPrompt,
228
+ composerInsertedPrompts,
229
+ quality,
230
+ size: effectiveSize,
231
+ format: resultFormat,
232
+ moderation,
233
+ model: activeProvider === "grok" ? (quality === "high" ? "grok-imagine-image-quality" : imageModel) : imageModel,
234
+ provider: activeProvider,
235
+ createdAt,
236
+ usage: latestUsage,
237
+ webSearchCalls: latestWebSearchCalls,
238
+ webSearchEnabled,
239
+ refsCount: refCheck.refs.length,
240
+ ...(image.providerUrl ? { providerUrl: image.providerUrl } : {}),
241
+ };
242
+ const rawBuffer = Buffer.from(image.b64, "base64");
243
+ const embedded = await embedImageMetadataBestEffort(rawBuffer, resultFormat, meta, {
244
+ version: ctx.packageVersion,
245
+ });
246
+ const mmFilePath = join(ctx.config.storage.generatedDir, filename);
247
+ await writeFile(mmFilePath, embedded.buffer);
248
+ await safeWriteSidecar(mmFilePath + ".json", meta);
249
+ generateImageThumbnailFromBuffer(embedded.buffer, mmFilePath).catch(() => { });
250
+ invalidateHistoryIndex();
251
+ const item = {
252
+ image: `data:${resultMime};base64,${image.b64}`,
253
+ filename,
254
+ createdAt,
255
+ ...(image.providerUrl ? { providerUrl: image.providerUrl } : {}),
256
+ revisedPrompt: image.revisedPrompt || null,
257
+ sequenceId,
258
+ sequenceIndex: index + 1,
259
+ sequenceTotalRequested: maxImages,
260
+ sequenceTotalReturned: totalReturned,
261
+ sequenceStatus: status,
262
+ };
263
+ persistedIndexes.add(index);
264
+ images.push(item);
265
+ dualEmitMultimode(res, requestId, "image", item);
266
+ };
267
+ dualEmitMultimode(res, requestId, "phase", { phase: "streaming", requestId, sequenceId, maxImages });
268
+ let generated;
269
+ if (activeProvider === "gemini-api") {
270
+ const r = await generateViaGeminiApi(prompt, requireRuntimeContext(ctx), {
271
+ model: imageModel,
272
+ size: effectiveSize,
273
+ signal: cancelController.signal,
274
+ requestId,
275
+ references: refCheck.refDetails,
276
+ });
277
+ generated = {
278
+ images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
279
+ usage: r.usage,
280
+ webSearchCalls: r.webSearchCalls,
281
+ };
282
+ }
283
+ else if (activeProvider === "agy") {
284
+ const r = await generateViaAgy(prompt, {
285
+ references: refCheck.refDetails,
286
+ signal: cancelController.signal,
287
+ requestId,
288
+ });
289
+ generated = {
290
+ images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
291
+ usage: r.usage,
292
+ webSearchCalls: r.webSearchCalls,
293
+ };
294
+ }
295
+ else if (activeProvider === "grok" || activeProvider === "grok-api") {
296
+ const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
297
+ const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
298
+ const grokRefs = incomingProviderUrl
299
+ ? [{ b64: "", url: incomingProviderUrl }, ...refCheck.refDetails]
300
+ : refCheck.refDetails;
301
+ generated = await generateMultimodeViaGrok(prompt, ctx, {
302
+ model: grokModel,
303
+ maxImages,
304
+ size: effectiveSize,
305
+ signal: cancelController.signal,
306
+ requestId,
307
+ references: grokRefs,
308
+ directApiKey,
309
+ onFinalImage: async (image, index) => {
310
+ const totalReturned = Math.max(index + 1, images.length + 1);
311
+ await persistAndSendImage(image, index, totalReturned, sequenceStatus(totalReturned, maxImages));
312
+ },
313
+ });
314
+ }
315
+ else {
316
+ generated = await generateMultimodeViaResponses(activeProvider, prompt, quality, effectiveSize, moderation, refCheck.refDetails || refCheck.refs, requestId, normalizedPromptMode, ctx, {
317
+ model: imageModel,
318
+ maxImages,
319
+ reasoningEffort,
320
+ webSearchEnabled,
321
+ onPartialImage: (partial) => {
322
+ if (isJobCanceled(requestId))
323
+ return;
324
+ const pd = { image: `data:${mime};base64,${partial.b64}`, requestId, sequenceId, index: partial.index };
325
+ if (!res.writableEnded && !res.destroyed)
326
+ writeSse(res, "partial", pd);
327
+ publish(requestId, "partial", pd);
328
+ },
329
+ onFinalImage: async (image, index) => {
330
+ const totalReturned = Math.max(index + 1, images.length + 1);
331
+ await persistAndSendImage(image, index, totalReturned, sequenceStatus(totalReturned, maxImages));
332
+ },
333
+ signal: cancelController.signal,
334
+ });
335
+ }
336
+ throwIfJobCanceled(requestId);
337
+ latestUsage = generated.usage || null;
338
+ latestWebSearchCalls = generated.webSearchCalls || 0;
339
+ latestExtraIgnored = generated.extraIgnored || 0;
340
+ for (const [index, image] of generated.images.entries()) {
341
+ await persistAndSendImage(image, index, generated.images.length, sequenceStatus(generated.images.length, maxImages));
342
+ }
343
+ const returned = images.length;
344
+ const status = sequenceStatus(returned, maxImages);
345
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
346
+ if (returned === 0) {
347
+ finishStatus = "error";
348
+ finishHttpStatus = 422;
349
+ finishErrorCode = "EMPTY_RESPONSE";
350
+ finishMeta = { sequenceId, filenames: [], imageCount: 0, maxImages, status, composerPrompt: routeComposerPrompt, composerInsertedPrompts: routeComposerInsertedPrompts };
351
+ dualEmitMultimode(res, requestId, "error", { error: "No image data returned from the multimode stream", code: finishErrorCode, status: finishHttpStatus, requestId, sequenceId, requested: maxImages, returned, });
352
+ logEvent("multimode", "empty_response", { requestId, sequenceId, maxImages, elapsedMs: Date.now() - startTime });
353
+ return;
354
+ }
355
+ finishMeta = {
356
+ sequenceId,
357
+ filenames: images.map((image) => image.filename),
358
+ imageCount: returned,
359
+ maxImages,
360
+ status,
361
+ composerPrompt: routeComposerPrompt,
362
+ composerInsertedPrompts: routeComposerInsertedPrompts,
363
+ };
364
+ finishHttpStatus = 200;
365
+ dualEmitMultimode(res, requestId, "done", {
366
+ ok: true,
367
+ requestId,
368
+ sequenceId,
369
+ requested: maxImages,
370
+ returned,
371
+ status,
372
+ elapsed,
373
+ images,
374
+ provider: activeProvider,
375
+ quality,
376
+ size: effectiveSize,
377
+ moderation,
378
+ model: imageModel,
379
+ usage: latestUsage,
380
+ webSearchCalls: latestWebSearchCalls,
381
+ webSearchEnabled,
382
+ warnings: qualityWarnings,
383
+ extraIgnored: latestExtraIgnored,
384
+ promptMode: normalizedPromptMode,
385
+ });
386
+ logEvent("multimode", "saved", { requestId, sequenceId, imageCount: returned, maxImages, status, elapsedMs: Date.now() - startTime, });
387
+ }
388
+ catch (e) {
389
+ const err = errInfo(e);
390
+ const ext = (err.raw && typeof err.raw === "object" ? err.raw : {});
391
+ const fallbackCode = err.code || classifyUpstreamError(err.message);
392
+ if (isGenerationCanceledError(err.raw) || isJobCanceled(requestId)) {
393
+ const canceled = makeGenerationCanceledError();
394
+ finishCanceled = true;
395
+ finishHttpStatus = canceled.status;
396
+ finishErrorCode = canceled.code;
397
+ dualEmitMultimode(res, requestId, "error", { error: canceled.message, code: canceled.code, status: canceled.status, requestId, });
398
+ return;
399
+ }
400
+ if ((fallbackCode === "RESPONSES_IMAGE_TIMEOUT" || err.status === 504) && images.length > 0) {
401
+ const status = sequenceStatus(images.length, routeMaxImages);
402
+ const elapsed = ((Date.now() - routeStartTime) / 1000).toFixed(1);
403
+ finishStatus = "completed";
404
+ finishHttpStatus = 206;
405
+ finishMeta = {
406
+ sequenceId: routeSequenceId,
407
+ filenames: images.map((image) => image.filename),
408
+ imageCount: images.length,
409
+ maxImages: routeMaxImages,
410
+ status,
411
+ partialErrorCode: "RESPONSES_IMAGE_TIMEOUT",
412
+ composerPrompt: routeComposerPrompt,
413
+ composerInsertedPrompts: routeComposerInsertedPrompts,
414
+ };
415
+ dualEmitMultimode(res, requestId, "done", {
416
+ ok: true,
417
+ partial: true,
418
+ requestId,
419
+ sequenceId: routeSequenceId,
420
+ requested: routeMaxImages,
421
+ returned: images.length,
422
+ status,
423
+ elapsed,
424
+ images,
425
+ provider: routeActiveProvider,
426
+ quality: routeQuality,
427
+ size: routeEffectiveSize,
428
+ moderation: routeModeration,
429
+ model: routeImageModel,
430
+ usage: latestUsage,
431
+ webSearchCalls: latestWebSearchCalls,
432
+ webSearchEnabled: routeWebSearchEnabled,
433
+ warnings: routeQualityWarnings,
434
+ extraIgnored: latestExtraIgnored,
435
+ promptMode: routePromptMode,
436
+ warning: {
437
+ code: "RESPONSES_IMAGE_TIMEOUT",
438
+ message: "The provider timed out after returning partial multimode results.",
439
+ },
440
+ });
441
+ logEvent("multimode", "partial_timeout", { requestId, sequenceId: routeSequenceId, imageCount: images.length, maxImages: routeMaxImages, });
442
+ return;
443
+ }
444
+ finishStatus = "error";
445
+ finishHttpStatus = err.status || 500;
446
+ finishErrorCode = fallbackCode || "MULTIMODE_GENERATE_FAILED";
447
+ logError("multimode", "error", err.raw, { requestId, code: finishErrorCode });
448
+ dualEmitMultimode(res, requestId, "error", { error: err.message, code: finishErrorCode, status: finishHttpStatus, requestId, upstreamCode: ext.upstreamCode || null, upstreamType: ext.upstreamType || null, upstreamParam: ext.upstreamParam || null, });
449
+ }
450
+ finally {
451
+ if (jobOwned)
452
+ finishJob(requestId, {
453
+ canceled: finishCanceled,
454
+ status: finishStatus,
455
+ httpStatus: finishHttpStatus,
456
+ errorCode: finishErrorCode,
457
+ meta: finishMeta,
458
+ });
459
+ if (!res.writableEnded)
460
+ res.end();
461
+ }
462
+ }