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.
- package/README.md +30 -0
- package/bin/commands/skill.js +336 -17
- package/bin/ima2.js +22 -2
- package/bin/lib/doctor-checks.js +6 -4
- package/config.js +1 -0
- package/docs/CLI.md +25 -3
- package/docs/PROMPT_STUDIO.ko.md +29 -0
- package/docs/PROMPT_STUDIO.md +31 -0
- package/docs/migration/runtime-test-inventory.md +7 -1
- package/lib/agentImageVideoGen.js +8 -0
- package/lib/agentQueueStore.js +36 -10
- package/lib/agentQueueWorker.js +79 -7
- package/lib/agentRuntime.js +2 -0
- package/lib/assetLifecycle.js +40 -8
- package/lib/canvasVersionStore.js +86 -0
- package/lib/cardNewsGenerator.js +37 -3
- package/lib/cardNewsJobStore.js +10 -0
- package/lib/cardNewsManifestStore.js +6 -12
- package/lib/cardNewsPath.js +25 -0
- package/lib/db.js +2 -0
- package/lib/generatePipeline.js +474 -0
- package/lib/generationInputValidation.js +32 -0
- package/lib/grokImageCore.js +18 -1
- package/lib/historyIndex.js +11 -4
- package/lib/historyList.js +3 -0
- package/lib/imageMetadata.js +3 -0
- package/lib/inflight.js +30 -3
- package/lib/multimodePipeline.js +462 -0
- package/lib/nodeGeneration.js +440 -0
- package/lib/nodeValidation.js +23 -0
- package/lib/oauthProxy/generators.js +3 -246
- package/lib/oauthProxy/multimodeGenerators.js +251 -0
- package/lib/routeHelpers.js +9 -2
- package/package.json +2 -2
- package/routes/agent.js +4 -4
- package/routes/canvasVersions.js +21 -1
- package/routes/cardNews.js +2 -1
- package/routes/edit.js +25 -9
- package/routes/events.js +5 -1
- package/routes/generate.js +2 -481
- package/routes/keys.js +31 -39
- package/routes/multimode.js +2 -502
- package/routes/nodes.js +3 -448
- package/routes/video.js +12 -3
- package/routes/videoExtended.js +19 -4
- package/server.js +53 -1
- package/skills/ima2/SKILL.md +204 -0
- package/skills/ima2-front/SKILL.md +611 -0
- package/skills/ima2-front/references/a11y-patterns.md +134 -0
- package/skills/ima2-front/references/aesthetics.md +327 -0
- package/skills/ima2-front/references/anti-slop.md +241 -0
- package/skills/ima2-front/references/asset-requirements.md +431 -0
- package/skills/ima2-front/references/brand-asset-sourcing.md +122 -0
- package/skills/ima2-front/references/color-system.md +132 -0
- package/skills/ima2-front/references/consistency-locks.md +20 -0
- package/skills/ima2-front/references/crud-ui.md +53 -0
- package/skills/ima2-front/references/dropdown-layer.md +110 -0
- package/skills/ima2-front/references/i18n-global.md +104 -0
- package/skills/ima2-front/references/iterative-design.md +90 -0
- package/skills/ima2-front/references/korea-2026.md +174 -0
- package/skills/ima2-front/references/layout-discipline.md +161 -0
- package/skills/ima2-front/references/liquid-glass.md +181 -0
- package/skills/ima2-front/references/logo-sections.md +194 -0
- package/skills/ima2-front/references/mobile-ux.md +144 -0
- package/skills/ima2-front/references/performance-budget.md +106 -0
- package/skills/ima2-front/references/preflight-full.md +58 -0
- package/skills/ima2-front/references/product-density.md +53 -0
- package/skills/ima2-front/references/prototype-variants.md +54 -0
- package/skills/ima2-front/references/reference-capture.md +120 -0
- package/skills/ima2-front/references/responsive-viewport.md +139 -0
- package/skills/ima2-front/references/seo-baseline.md +123 -0
- package/skills/ima2-front/references/soft-3d-asset-gates.md +70 -0
- package/skills/ima2-front/references/stacks/astro.md +204 -0
- package/skills/ima2-front/references/stacks/mobile-native.md +281 -0
- package/skills/ima2-front/references/stacks/nextjs.md +219 -0
- package/skills/ima2-front/references/stacks/react.md +264 -0
- package/skills/ima2-front/references/stacks/svelte.md +235 -0
- package/skills/ima2-front/references/stacks/vanilla.md +226 -0
- package/skills/ima2-front/references/theme-switching.md +82 -0
- package/skills/ima2-front/references/top-bar.md +126 -0
- package/skills/ima2-front/references/typography-wrapping.md +270 -0
- package/skills/ima2-front/references/ux-writing-ko.md +62 -0
- package/skills/ima2-front/references/visual-verification.md +102 -0
- package/skills/ima2-uiux/SKILL.md +477 -0
- package/skills/ima2-uiux/references/anti-slop-judgment.md +39 -0
- package/skills/ima2-uiux/references/color-system.md +80 -0
- package/skills/ima2-uiux/references/design-isms.md +194 -0
- package/skills/ima2-uiux/references/design-read-example.md +33 -0
- package/skills/ima2-uiux/references/design-system-bootstrap.md +133 -0
- package/skills/ima2-uiux/references/favicon-logo.md +355 -0
- package/skills/ima2-uiux/references/form-patterns.md +118 -0
- package/skills/ima2-uiux/references/intent-discovery-ladder.md +86 -0
- package/skills/ima2-uiux/references/korean-design-vocabulary.md +52 -0
- package/skills/ima2-uiux/references/layout-macrostructures.md +54 -0
- package/skills/ima2-uiux/references/logo-trust-sections.md +96 -0
- package/skills/ima2-uiux/references/mobile-native-ux.md +207 -0
- package/skills/ima2-uiux/references/product-personalities.md +118 -0
- package/skills/ima2-uiux/references/responsive-nav.md +66 -0
- package/skills/ima2-uiux/references/typography-line-breaks.md +193 -0
- package/skills/ima2-uiux/references/ux-preflight.md +65 -0
- package/skills/ima2-uiux/references/ux-states.md +197 -0
- package/skills/ima2-uiux/references/visual-hierarchy.md +115 -0
- package/ui/dist/.vite/manifest.json +13 -13
- package/ui/dist/assets/AgentWorkspace-CtYt4SF5.js +3 -0
- package/ui/dist/assets/{CardNewsWorkspace-Cdxn1ohU.js → CardNewsWorkspace-DN5cVqG5.js} +1 -1
- package/ui/dist/assets/GenerationRequestLogPanel-DnmUQdKR.js +1 -0
- package/ui/dist/assets/{NodeCanvas-C67sLboA.js → NodeCanvas-CxpfIBI0.js} +1 -1
- package/ui/dist/assets/PromptBuilderPanel-C2R3ObtR.js +2 -0
- package/ui/dist/assets/{PromptImportDialog-DeQXsEEv.js → PromptImportDialog-BIxz_yEx.js} +2 -2
- package/ui/dist/assets/PromptImportDiscoverySection-BQTlPMME.js +1 -0
- package/ui/dist/assets/PromptImportFolderSection-Ds_Th9O3.js +1 -0
- package/ui/dist/assets/{PromptLibraryPanel-Db3xEPM_.js → PromptLibraryPanel-Rs4l45pV.js} +2 -2
- package/ui/dist/assets/{SettingsWorkspace-BDk_1W-V.js → SettingsWorkspace-p1AZ6uT9.js} +1 -1
- package/ui/dist/assets/index-0-_vgFGs.js +4 -0
- package/ui/dist/assets/index-Dm3pFxV4.js +23 -0
- package/ui/dist/assets/index-J8yDF3Ch.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/AgentWorkspace-DAg_7zpY.js +0 -3
- package/ui/dist/assets/GenerationRequestLogPanel-C_qvEUi2.js +0 -1
- package/ui/dist/assets/PromptBuilderPanel-DjvVdAie.js +0 -2
- package/ui/dist/assets/PromptImportDiscoverySection-CrwyUKYI.js +0 -1
- package/ui/dist/assets/PromptImportFolderSection-CToGMqYE.js +0 -1
- package/ui/dist/assets/index-2oG6in1i.css +0 -1
- package/ui/dist/assets/index-CoxMOXAU.js +0 -23
- 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
|
+
}
|