ima2-gen 2.0.0 → 2.0.2
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/CHANGELOG.md +150 -0
- package/README.md +12 -12
- package/bin/commands/backfillThumbs.js +24 -0
- package/bin/commands/edit.js +7 -6
- package/bin/commands/gen.js +13 -6
- package/bin/commands/multimode.js +5 -4
- package/bin/commands/node.js +4 -4
- package/bin/ima2.js +21 -11
- package/bin/lib/config-store.js +1 -1
- package/docs/API.md +184 -10
- package/docs/CLI.md +11 -4
- package/docs/FAQ.ko.md +16 -0
- package/docs/FAQ.md +30 -0
- package/docs/PROMPT_STUDIO.md +3 -1
- package/docs/README.ko.md +7 -3
- package/docs/migration/runtime-test-inventory.md +17 -1
- package/lib/agentImageVideoGen.js +261 -0
- package/lib/agentRuntime.js +11 -260
- package/lib/agentSettings.js +1 -1
- package/lib/agyImageAdapter.js +259 -0
- package/lib/capabilities.js +2 -1
- package/lib/configKeys.js +1 -1
- package/lib/errorClassify.js +8 -7
- package/lib/eventBus.js +71 -0
- package/lib/geminiApiImageAdapter.js +179 -0
- package/lib/generationErrors.js +3 -1
- package/lib/grokImageAdapter.js +74 -128
- package/lib/grokImageCore.js +153 -0
- package/lib/grokMultimodeAdapter.js +7 -4
- package/lib/grokRuntime.js +3 -0
- package/lib/grokSizeMapper.js +13 -1
- package/lib/grokVideoAdapter.js +14 -7
- package/lib/grokVideoCanvas.js +13 -0
- package/lib/grokVideoPlannerPrompt.js +53 -6
- package/lib/historyList.js +19 -2
- package/lib/imageModels.js +15 -0
- package/lib/imageThumb.js +38 -0
- package/lib/inflight.js +54 -17
- package/lib/multimodeHelpers.js +10 -0
- package/lib/nodeHelpers.js +59 -0
- package/lib/oauthProxy/prompts.js +30 -36
- package/lib/promptBuilder/systemPrompt.js +2 -5
- package/lib/promptSafetyPolicy.js +1 -5
- package/lib/providerOptions.js +36 -1
- package/lib/responsesFallback.js +53 -44
- package/lib/routeHelpers.js +44 -0
- package/lib/runtimeContext.js +27 -0
- package/lib/ssePublish.js +12 -0
- package/lib/storageMigration.js +1 -1
- package/lib/storyboardPrefix.js +28 -0
- package/lib/thumbBackfill.js +70 -0
- package/lib/vertexAuth.js +44 -0
- package/lib/videoThumb.js +60 -0
- package/package.json +7 -2
- package/routes/agy.js +44 -0
- package/routes/auth.js +242 -0
- package/routes/edit.js +48 -8
- package/routes/events.js +78 -0
- package/routes/generate.js +135 -135
- package/routes/history.js +13 -0
- package/routes/index.js +8 -0
- package/routes/keys.js +254 -0
- package/routes/multimode.js +138 -62
- package/routes/nodes.js +107 -129
- package/routes/quota.js +58 -7
- package/routes/video.js +107 -20
- package/server.js +123 -0
- package/skills/ima2/SKILL.md +98 -21
- package/ui/dist/.vite/manifest.json +12 -12
- package/ui/dist/assets/AgentWorkspace-Dth6YijN.js +3 -0
- package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dav3K5CT.js} +2 -2
- package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-C4ifFzB1.js} +2 -2
- package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-CEcyU9PL.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-CgQ94Gth.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CuzyzbNI.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-DHLGlO6l.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-BOe18we8.js} +2 -2
- package/ui/dist/assets/SettingsWorkspace-Cdgnm4Wa.js +1 -0
- package/ui/dist/assets/index-C5PSahkr.js +1 -0
- package/ui/dist/assets/index-Dn2AhL6d.css +1 -0
- package/ui/dist/assets/index-Tjqx6wUV.js +23 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
- package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
- package/ui/dist/assets/index-BAFI6htx.js +0 -42
- package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
- package/ui/dist/assets/index-DS-ADE7U.css +0 -1
package/routes/keys.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
2
|
+
import { initVertexAuth, clearVertexAuth } from "../lib/vertexAuth.js";
|
|
3
|
+
// Atomic + 0600 config write: temp file then rename, so a crash or concurrent
|
|
4
|
+
// save can't corrupt config.json (which may hold API keys). Rename also forces
|
|
5
|
+
// 0600 perms even if a looser-perm config pre-existed.
|
|
6
|
+
async function writeConfigAtomic(cfgPath, data) {
|
|
7
|
+
const tmp = `${cfgPath}.${process.pid}.tmp`;
|
|
8
|
+
await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
9
|
+
await rename(tmp, cfgPath);
|
|
10
|
+
}
|
|
11
|
+
const KEY_PREFIX_MAP = {
|
|
12
|
+
openai: ["sk-"],
|
|
13
|
+
xai: ["xai-"],
|
|
14
|
+
gemini: ["AI"],
|
|
15
|
+
};
|
|
16
|
+
const VALIDATE_URL_MAP = {
|
|
17
|
+
openai: "https://api.openai.com/v1/models",
|
|
18
|
+
xai: "https://api.x.ai/v1/models",
|
|
19
|
+
gemini: "https://generativelanguage.googleapis.com/v1beta/models",
|
|
20
|
+
};
|
|
21
|
+
const CONFIG_KEY_MAP = {
|
|
22
|
+
openai: "apiKey",
|
|
23
|
+
xai: "xaiApiKey",
|
|
24
|
+
gemini: "geminiApiKey",
|
|
25
|
+
};
|
|
26
|
+
function isKeyProvider(v) {
|
|
27
|
+
return v === "openai" || v === "xai" || v === "gemini";
|
|
28
|
+
}
|
|
29
|
+
function maskKey(key) {
|
|
30
|
+
if (key.length <= 10)
|
|
31
|
+
return "***";
|
|
32
|
+
return `${key.slice(0, 4)}..${key.slice(-2)}`;
|
|
33
|
+
}
|
|
34
|
+
function keySourceForProvider(ctx, provider) {
|
|
35
|
+
if (provider === "openai")
|
|
36
|
+
return { key: ctx.apiKey, source: ctx.apiKeySource || "none" };
|
|
37
|
+
if (provider === "xai")
|
|
38
|
+
return { key: ctx.xaiApiKey, source: ctx.xaiApiKeySource || "none" };
|
|
39
|
+
if (provider === "gemini")
|
|
40
|
+
return { key: ctx.geminiApiKey, source: ctx.geminiApiKeySource || "none" };
|
|
41
|
+
return { key: undefined, source: "none" };
|
|
42
|
+
}
|
|
43
|
+
export function mountKeyRoutes(app, ctx) {
|
|
44
|
+
app.get("/api/keys/status", (_req, res) => {
|
|
45
|
+
const status = {};
|
|
46
|
+
for (const provider of ["openai", "xai", "gemini"]) {
|
|
47
|
+
const { key, source } = keySourceForProvider(ctx, provider);
|
|
48
|
+
status[provider] = {
|
|
49
|
+
configured: !!key,
|
|
50
|
+
source,
|
|
51
|
+
valid: !!key,
|
|
52
|
+
maskedKey: key ? maskKey(key) : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const vertexJson = ctx.vertexServiceAccountJson;
|
|
56
|
+
const vertexSource = vertexJson
|
|
57
|
+
? (process.env.VERTEX_SERVICE_ACCOUNT_JSON ? "env" : "config")
|
|
58
|
+
: "none";
|
|
59
|
+
status.vertex = {
|
|
60
|
+
configured: !!vertexJson,
|
|
61
|
+
source: vertexSource,
|
|
62
|
+
valid: !!vertexJson,
|
|
63
|
+
maskedKey: ctx.vertexProjectId ? `project: ${ctx.vertexProjectId}` : null,
|
|
64
|
+
};
|
|
65
|
+
res.json(status);
|
|
66
|
+
});
|
|
67
|
+
// Vertex JSON — dedicated route (before generic :provider)
|
|
68
|
+
app.put("/api/keys/vertex", async (req, res) => {
|
|
69
|
+
const { serviceAccountJson } = req.body;
|
|
70
|
+
if (!serviceAccountJson || typeof serviceAccountJson !== "string") {
|
|
71
|
+
return res.status(400).json({ ok: false, error: "Missing serviceAccountJson", code: "MISSING_KEY" });
|
|
72
|
+
}
|
|
73
|
+
const trimmed = serviceAccountJson.trim();
|
|
74
|
+
if (trimmed.length > 50 * 1024) {
|
|
75
|
+
return res.status(400).json({ ok: false, error: "Service account JSON too large (max 50KB)", code: "KEY_TOO_LARGE" });
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(trimmed);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return res.status(400).json({ ok: false, error: "Invalid JSON", code: "INVALID_JSON" });
|
|
83
|
+
}
|
|
84
|
+
if (parsed.type !== "service_account" || !parsed.project_id) {
|
|
85
|
+
return res.status(400).json({
|
|
86
|
+
ok: false,
|
|
87
|
+
error: "JSON must be a Google Cloud service account (type: service_account, project_id required)",
|
|
88
|
+
code: "INVALID_SERVICE_ACCOUNT",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Validate by initializing auth (catches key format issues)
|
|
92
|
+
try {
|
|
93
|
+
initVertexAuth(trimmed);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return res.status(400).json({ ok: false, error: "Service account validation failed", code: "KEY_VALIDATION_FAILED" });
|
|
97
|
+
}
|
|
98
|
+
// Save to config.json
|
|
99
|
+
const cfgPath = ctx.config.storage.configFile;
|
|
100
|
+
let existing = {};
|
|
101
|
+
try {
|
|
102
|
+
existing = JSON.parse(await readFile(cfgPath, "utf-8"));
|
|
103
|
+
}
|
|
104
|
+
catch { /* new file */ }
|
|
105
|
+
existing.vertexServiceAccountJson = trimmed;
|
|
106
|
+
existing.geminiAuthMode = "vertex";
|
|
107
|
+
await writeConfigAtomic(cfgPath, existing);
|
|
108
|
+
// Hot-update runtime
|
|
109
|
+
ctx.vertexServiceAccountJson = trimmed;
|
|
110
|
+
ctx.vertexProjectId = parsed.project_id;
|
|
111
|
+
ctx.hasVertexKey = true;
|
|
112
|
+
ctx.geminiAuthMode = "vertex";
|
|
113
|
+
return res.json({ ok: true, provider: "vertex", source: "config", valid: true, projectId: parsed.project_id });
|
|
114
|
+
});
|
|
115
|
+
app.delete("/api/keys/vertex", async (_req, res) => {
|
|
116
|
+
const source = ctx.vertexServiceAccountJson
|
|
117
|
+
? (process.env.VERTEX_SERVICE_ACCOUNT_JSON ? "env" : "config")
|
|
118
|
+
: "none";
|
|
119
|
+
if (source === "env") {
|
|
120
|
+
return res.status(400).json({ ok: false, error: "Cannot remove env-sourced key", code: "ENV_KEY_IMMUTABLE" });
|
|
121
|
+
}
|
|
122
|
+
const cfgPath = ctx.config.storage.configFile;
|
|
123
|
+
let existing = {};
|
|
124
|
+
try {
|
|
125
|
+
existing = JSON.parse(await readFile(cfgPath, "utf-8"));
|
|
126
|
+
}
|
|
127
|
+
catch { /* ignore */ }
|
|
128
|
+
delete existing.vertexServiceAccountJson;
|
|
129
|
+
await writeConfigAtomic(cfgPath, existing);
|
|
130
|
+
clearVertexAuth();
|
|
131
|
+
ctx.vertexServiceAccountJson = undefined;
|
|
132
|
+
ctx.vertexProjectId = undefined;
|
|
133
|
+
ctx.hasVertexKey = false;
|
|
134
|
+
return res.json({ ok: true, provider: "vertex", removed: true });
|
|
135
|
+
});
|
|
136
|
+
app.put("/api/keys/:provider", async (req, res) => {
|
|
137
|
+
const { provider } = req.params;
|
|
138
|
+
if (!isKeyProvider(provider)) {
|
|
139
|
+
return res.status(400).json({ ok: false, error: "Invalid provider", code: "INVALID_PROVIDER" });
|
|
140
|
+
}
|
|
141
|
+
const { apiKey } = req.body;
|
|
142
|
+
if (!apiKey || typeof apiKey !== "string" || apiKey.trim().length === 0) {
|
|
143
|
+
return res.status(400).json({ ok: false, error: "Missing apiKey", code: "MISSING_KEY" });
|
|
144
|
+
}
|
|
145
|
+
const trimmed = apiKey.trim();
|
|
146
|
+
if (trimmed.length > 512) {
|
|
147
|
+
return res.status(400).json({ ok: false, error: "API key too large", code: "KEY_TOO_LARGE" });
|
|
148
|
+
}
|
|
149
|
+
// Format check
|
|
150
|
+
const validPrefix = KEY_PREFIX_MAP[provider].some((p) => trimmed.startsWith(p));
|
|
151
|
+
if (!validPrefix) {
|
|
152
|
+
return res.status(400).json({
|
|
153
|
+
ok: false,
|
|
154
|
+
error: `Invalid key format for ${provider}: expected prefix ${KEY_PREFIX_MAP[provider].join(" or ")}`,
|
|
155
|
+
code: "INVALID_KEY_FORMAT",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Validate against provider API
|
|
159
|
+
try {
|
|
160
|
+
const url = VALIDATE_URL_MAP[provider];
|
|
161
|
+
const opts = { signal: AbortSignal.timeout(10_000) };
|
|
162
|
+
if (provider === "gemini") {
|
|
163
|
+
opts.headers = { "x-goog-api-key": trimmed };
|
|
164
|
+
const validateRes = await fetch(url, opts);
|
|
165
|
+
if (!validateRes.ok)
|
|
166
|
+
throw new Error(`HTTP ${validateRes.status}`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
opts.headers = { Authorization: `Bearer ${trimmed}` };
|
|
170
|
+
const validateRes = await fetch(url, opts);
|
|
171
|
+
if (!validateRes.ok)
|
|
172
|
+
throw new Error(`HTTP ${validateRes.status}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
return res.status(400).json({
|
|
177
|
+
ok: false,
|
|
178
|
+
error: `API key validation failed: ${e.message || "unknown"}`,
|
|
179
|
+
code: "KEY_VALIDATION_FAILED",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Save to config.json
|
|
183
|
+
const cfgPath = ctx.config.storage.configFile;
|
|
184
|
+
let existing = {};
|
|
185
|
+
try {
|
|
186
|
+
existing = JSON.parse(await readFile(cfgPath, "utf-8"));
|
|
187
|
+
}
|
|
188
|
+
catch { /* new file */ }
|
|
189
|
+
existing[CONFIG_KEY_MAP[provider]] = trimmed;
|
|
190
|
+
await writeConfigAtomic(cfgPath, existing);
|
|
191
|
+
// Hot-update runtime context
|
|
192
|
+
if (provider === "openai") {
|
|
193
|
+
ctx.apiKey = trimmed;
|
|
194
|
+
ctx.apiKeySource = "config";
|
|
195
|
+
ctx.hasApiKey = true;
|
|
196
|
+
try {
|
|
197
|
+
const OpenAI = (await import("openai")).default;
|
|
198
|
+
ctx.openai = new OpenAI({ apiKey: trimmed });
|
|
199
|
+
}
|
|
200
|
+
catch { /* ignore */ }
|
|
201
|
+
}
|
|
202
|
+
else if (provider === "xai") {
|
|
203
|
+
ctx.xaiApiKey = trimmed;
|
|
204
|
+
ctx.xaiApiKeySource = "config";
|
|
205
|
+
ctx.hasXaiApiKey = true;
|
|
206
|
+
}
|
|
207
|
+
else if (provider === "gemini") {
|
|
208
|
+
ctx.geminiApiKey = trimmed;
|
|
209
|
+
ctx.geminiApiKeySource = "config";
|
|
210
|
+
ctx.hasGeminiApiKey = true;
|
|
211
|
+
ctx.geminiAuthMode = "apikey";
|
|
212
|
+
existing[CONFIG_KEY_MAP[provider]] = trimmed;
|
|
213
|
+
existing.geminiAuthMode = "apikey";
|
|
214
|
+
}
|
|
215
|
+
return res.json({ ok: true, provider, source: "config", valid: true });
|
|
216
|
+
});
|
|
217
|
+
app.delete("/api/keys/:provider", async (req, res) => {
|
|
218
|
+
const { provider } = req.params;
|
|
219
|
+
if (!isKeyProvider(provider)) {
|
|
220
|
+
return res.status(400).json({ ok: false, error: "Invalid provider", code: "INVALID_PROVIDER" });
|
|
221
|
+
}
|
|
222
|
+
const { source } = keySourceForProvider(ctx, provider);
|
|
223
|
+
if (source === "env") {
|
|
224
|
+
return res.status(400).json({ ok: false, error: "Cannot remove env-sourced key", code: "ENV_KEY_IMMUTABLE" });
|
|
225
|
+
}
|
|
226
|
+
// Remove from config.json
|
|
227
|
+
const cfgPath = ctx.config.storage.configFile;
|
|
228
|
+
let existing = {};
|
|
229
|
+
try {
|
|
230
|
+
existing = JSON.parse(await readFile(cfgPath, "utf-8"));
|
|
231
|
+
}
|
|
232
|
+
catch { /* ignore */ }
|
|
233
|
+
delete existing[CONFIG_KEY_MAP[provider]];
|
|
234
|
+
await writeConfigAtomic(cfgPath, existing);
|
|
235
|
+
// Clear runtime
|
|
236
|
+
if (provider === "openai") {
|
|
237
|
+
ctx.apiKey = undefined;
|
|
238
|
+
ctx.apiKeySource = "none";
|
|
239
|
+
ctx.hasApiKey = false;
|
|
240
|
+
ctx.openai = null;
|
|
241
|
+
}
|
|
242
|
+
else if (provider === "xai") {
|
|
243
|
+
ctx.xaiApiKey = undefined;
|
|
244
|
+
ctx.xaiApiKeySource = "none";
|
|
245
|
+
ctx.hasXaiApiKey = false;
|
|
246
|
+
}
|
|
247
|
+
else if (provider === "gemini") {
|
|
248
|
+
ctx.geminiApiKey = undefined;
|
|
249
|
+
ctx.geminiApiKeySource = "none";
|
|
250
|
+
ctx.hasGeminiApiKey = false;
|
|
251
|
+
}
|
|
252
|
+
return res.json({ ok: true, provider, removed: true });
|
|
253
|
+
});
|
|
254
|
+
}
|
package/routes/multimode.js
CHANGED
|
@@ -3,12 +3,15 @@ import { safeWriteSidecar } from "../lib/atomicWrite.js";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
5
|
import { detectImageMimeFromB64, summarizeReferencePayload, validateAndNormalizeRefs } from "../lib/refs.js";
|
|
6
|
+
import { generateImageThumbnailFromBuffer } from "../lib/imageThumb.js";
|
|
6
7
|
import { classifyUpstreamError } from "../lib/errorClassify.js";
|
|
7
8
|
import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
|
|
8
9
|
import { resolveProviderOptions } from "../lib/providerOptions.js";
|
|
9
10
|
import { generateMultimodeViaResponses } from "../lib/responsesImageAdapter.js";
|
|
10
11
|
import { generateMultimodeViaGrok } from "../lib/grokMultimodeAdapter.js";
|
|
11
|
-
import {
|
|
12
|
+
import { generateViaAgy } from "../lib/agyImageAdapter.js";
|
|
13
|
+
import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
|
|
14
|
+
import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "../lib/inflight.js";
|
|
12
15
|
import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
|
|
13
16
|
import { logEvent, logError } from "../lib/logger.js";
|
|
14
17
|
import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
|
|
@@ -16,37 +19,35 @@ import { invalidateHistoryIndex } from "../lib/historyIndex.js";
|
|
|
16
19
|
import { normalizeComposerInsertedPrompts, normalizeComposerPrompt, } from "../lib/composerSnapshot.js";
|
|
17
20
|
import { errInfo } from "../lib/errInfo.js";
|
|
18
21
|
import { requireRuntimeContext } from "../lib/runtimeContext.js";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
function
|
|
24
|
-
if (
|
|
25
|
-
|
|
22
|
+
import { validateModeration, imageFormatFromMime, writeSse } from "../lib/routeHelpers.js";
|
|
23
|
+
import { publish } from "../lib/eventBus.js";
|
|
24
|
+
import { publishJobEvent } from "../lib/ssePublish.js";
|
|
25
|
+
import { normalizeMaxImages, sequenceStatus, } from "../lib/multimodeHelpers.js";
|
|
26
|
+
function dualEmitMultimode(res, requestId, event, data) {
|
|
27
|
+
if (!res.writableEnded)
|
|
28
|
+
writeSse(res, event, data);
|
|
29
|
+
if (event === "done") {
|
|
30
|
+
publishJobEvent(requestId, event, data);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
publish(requestId, event, data);
|
|
26
34
|
}
|
|
27
|
-
return { moderation };
|
|
28
|
-
}
|
|
29
|
-
function normalizeMaxImages(value) {
|
|
30
|
-
return Math.min(8, Math.max(1, Math.trunc(Number(value) || 1)));
|
|
31
|
-
}
|
|
32
|
-
function sequenceStatus(returned, requested) {
|
|
33
|
-
if (returned <= 0)
|
|
34
|
-
return "empty";
|
|
35
|
-
if (returned < requested)
|
|
36
|
-
return "partial";
|
|
37
|
-
return "complete";
|
|
38
35
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
function respondMultimodeValidationError(res, requestId, asyncMode, status, payload) {
|
|
37
|
+
publish(requestId, "error", payload);
|
|
38
|
+
if (asyncMode && !res.headersSent) {
|
|
39
|
+
return res.status(status).json(payload);
|
|
40
|
+
}
|
|
41
|
+
if (!res.writableEnded) {
|
|
42
|
+
writeSse(res, "error", payload);
|
|
43
|
+
res.end();
|
|
44
|
+
}
|
|
45
45
|
}
|
|
46
46
|
export function registerMultimodeRoutes(app, ctxRaw) {
|
|
47
47
|
const ctx = requireRuntimeContext(ctxRaw);
|
|
48
48
|
app.post("/api/generate/multimode", async (req, res) => {
|
|
49
49
|
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
|
|
50
|
+
const asyncMode = req.body?.async === true;
|
|
50
51
|
let finishStatus = "completed";
|
|
51
52
|
let finishHttpStatus = 200;
|
|
52
53
|
let finishErrorCode;
|
|
@@ -71,10 +72,12 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
71
72
|
let latestUsage = null;
|
|
72
73
|
let latestWebSearchCalls = 0;
|
|
73
74
|
let latestExtraIgnored = 0;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
if (!asyncMode) {
|
|
76
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
77
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
78
|
+
res.setHeader("Connection", "keep-alive");
|
|
79
|
+
res.flushHeaders?.();
|
|
80
|
+
}
|
|
78
81
|
try {
|
|
79
82
|
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;
|
|
80
83
|
const composerPrompt = normalizeComposerPrompt(req.body?.composerPrompt);
|
|
@@ -93,8 +96,12 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
93
96
|
finishStatus = "error";
|
|
94
97
|
finishHttpStatus = providerOptions.status;
|
|
95
98
|
finishErrorCode = providerOptions.code;
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
return respondMultimodeValidationError(res, requestId, asyncMode, providerOptions.status, {
|
|
100
|
+
error: providerOptions.error,
|
|
101
|
+
code: providerOptions.code,
|
|
102
|
+
status: providerOptions.status,
|
|
103
|
+
requestId,
|
|
104
|
+
});
|
|
98
105
|
}
|
|
99
106
|
const imageModel = providerOptions.model;
|
|
100
107
|
const reasoningEffort = providerOptions.reasoningEffort;
|
|
@@ -105,28 +112,41 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
105
112
|
finishStatus = "error";
|
|
106
113
|
finishHttpStatus = 400;
|
|
107
114
|
finishErrorCode = "PROMPT_REQUIRED";
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
|
|
116
|
+
error: "Prompt is required",
|
|
117
|
+
code: finishErrorCode,
|
|
118
|
+
status: 400,
|
|
119
|
+
requestId,
|
|
120
|
+
});
|
|
110
121
|
}
|
|
111
122
|
const moderationCheck = validateModeration(ctx, moderation);
|
|
112
123
|
if (moderationCheck.error) {
|
|
113
124
|
finishStatus = "error";
|
|
114
125
|
finishHttpStatus = 400;
|
|
115
126
|
finishErrorCode = "INVALID_MODERATION";
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
|
|
128
|
+
error: moderationCheck.error,
|
|
129
|
+
code: finishErrorCode,
|
|
130
|
+
status: 400,
|
|
131
|
+
requestId,
|
|
132
|
+
});
|
|
118
133
|
}
|
|
119
134
|
const refCheckResult = validateAndNormalizeRefs(references);
|
|
120
135
|
if (refCheckResult.error) {
|
|
121
136
|
finishStatus = "error";
|
|
122
137
|
finishHttpStatus = 400;
|
|
123
138
|
finishErrorCode = refCheckResult.code;
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
return respondMultimodeValidationError(res, requestId, asyncMode, 400, {
|
|
140
|
+
error: refCheckResult.error,
|
|
141
|
+
code: refCheckResult.code,
|
|
142
|
+
status: 400,
|
|
143
|
+
requestId,
|
|
144
|
+
});
|
|
126
145
|
}
|
|
127
146
|
const refCheck = refCheckResult;
|
|
147
|
+
const incomingProviderUrl = typeof req.body?.providerUrl === "string" && req.body.providerUrl.startsWith("http") ? req.body.providerUrl : null;
|
|
128
148
|
const referencePayload = summarizeReferencePayload(references);
|
|
129
|
-
startJob({
|
|
149
|
+
const started = startJob({
|
|
130
150
|
requestId,
|
|
131
151
|
kind: "multimode",
|
|
132
152
|
prompt,
|
|
@@ -143,7 +163,25 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
143
163
|
composerInsertedPrompts,
|
|
144
164
|
},
|
|
145
165
|
});
|
|
166
|
+
if (started && isStartJobFailure(started)) {
|
|
167
|
+
finishStatus = "error";
|
|
168
|
+
finishHttpStatus = started.code === "TOO_MANY_JOBS" ? 429 : 409;
|
|
169
|
+
finishErrorCode = started.code;
|
|
170
|
+
if (started.code === "TOO_MANY_JOBS") {
|
|
171
|
+
res.setHeader("Retry-After", String(INFLIGHT_RETRY_AFTER_SECONDS));
|
|
172
|
+
}
|
|
173
|
+
return respondMultimodeValidationError(res, requestId, asyncMode, finishHttpStatus, {
|
|
174
|
+
error: started.code === "TOO_MANY_JOBS"
|
|
175
|
+
? "Too many concurrent generation jobs"
|
|
176
|
+
: "Request ID already in use",
|
|
177
|
+
code: started.code,
|
|
178
|
+
status: finishHttpStatus,
|
|
179
|
+
requestId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
146
182
|
registerJobAbortController(requestId, cancelController);
|
|
183
|
+
if (asyncMode)
|
|
184
|
+
res.status(202).json({ requestId });
|
|
147
185
|
logEvent("multimode", "request", {
|
|
148
186
|
requestId,
|
|
149
187
|
quality,
|
|
@@ -158,7 +196,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
158
196
|
});
|
|
159
197
|
const startTime = Date.now();
|
|
160
198
|
const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
|
|
161
|
-
const mmFormat = activeProvider === "grok" ? "jpeg" : String(format);
|
|
199
|
+
const mmFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : String(format);
|
|
162
200
|
const mime = mimeMap[mmFormat] || "image/png";
|
|
163
201
|
const sequenceId = `seq_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
|
|
164
202
|
routeMaxImages = maxImages;
|
|
@@ -179,12 +217,13 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
179
217
|
if (persistedIndexes.has(index))
|
|
180
218
|
return;
|
|
181
219
|
throwIfJobCanceled(requestId);
|
|
182
|
-
const resultMime = activeProvider === "grok"
|
|
220
|
+
const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
|
|
183
221
|
? (image.mime || detectImageMimeFromB64(image.b64) || mime)
|
|
184
222
|
: mime;
|
|
185
|
-
const resultFormat = activeProvider === "grok" ? imageFormatFromMime(resultMime) : mmFormat;
|
|
223
|
+
const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(resultMime) : mmFormat;
|
|
186
224
|
const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
|
|
187
225
|
const filename = `${Date.now()}_${rand}_multimode_${index}.${resultFormat}`;
|
|
226
|
+
const createdAt = Date.now();
|
|
188
227
|
const meta = {
|
|
189
228
|
kind: "multimode-image",
|
|
190
229
|
generationStrategy: "one-call-text-sequence",
|
|
@@ -207,22 +246,27 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
207
246
|
moderation,
|
|
208
247
|
model: activeProvider === "grok" ? (quality === "high" ? "grok-imagine-image-quality" : imageModel) : imageModel,
|
|
209
248
|
provider: activeProvider,
|
|
210
|
-
createdAt
|
|
249
|
+
createdAt,
|
|
211
250
|
usage: latestUsage,
|
|
212
251
|
webSearchCalls: latestWebSearchCalls,
|
|
213
252
|
webSearchEnabled,
|
|
214
253
|
refsCount: refCheck.refs.length,
|
|
254
|
+
...(image.providerUrl ? { providerUrl: image.providerUrl } : {}),
|
|
215
255
|
};
|
|
216
256
|
const rawBuffer = Buffer.from(image.b64, "base64");
|
|
217
257
|
const embedded = await embedImageMetadataBestEffort(rawBuffer, resultFormat, meta, {
|
|
218
258
|
version: ctx.packageVersion,
|
|
219
259
|
});
|
|
220
|
-
|
|
221
|
-
await
|
|
260
|
+
const mmFilePath = join(ctx.config.storage.generatedDir, filename);
|
|
261
|
+
await writeFile(mmFilePath, embedded.buffer);
|
|
262
|
+
await safeWriteSidecar(mmFilePath + ".json", meta);
|
|
263
|
+
generateImageThumbnailFromBuffer(embedded.buffer, mmFilePath).catch(() => { });
|
|
222
264
|
invalidateHistoryIndex();
|
|
223
265
|
const item = {
|
|
224
266
|
image: `data:${resultMime};base64,${image.b64}`,
|
|
225
267
|
filename,
|
|
268
|
+
createdAt,
|
|
269
|
+
...(image.providerUrl ? { providerUrl: image.providerUrl } : {}),
|
|
226
270
|
revisedPrompt: image.revisedPrompt || null,
|
|
227
271
|
sequenceId,
|
|
228
272
|
sequenceIndex: index + 1,
|
|
@@ -232,19 +276,50 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
232
276
|
};
|
|
233
277
|
persistedIndexes.add(index);
|
|
234
278
|
images.push(item);
|
|
235
|
-
|
|
279
|
+
dualEmitMultimode(res, requestId, "image", item);
|
|
236
280
|
};
|
|
237
|
-
|
|
281
|
+
dualEmitMultimode(res, requestId, "phase", { phase: "streaming", requestId, sequenceId, maxImages });
|
|
238
282
|
let generated;
|
|
239
|
-
if (activeProvider === "
|
|
283
|
+
if (activeProvider === "gemini-api") {
|
|
284
|
+
const r = await generateViaGeminiApi(prompt, requireRuntimeContext(ctx), {
|
|
285
|
+
model: imageModel,
|
|
286
|
+
size: effectiveSize,
|
|
287
|
+
signal: cancelController.signal,
|
|
288
|
+
requestId,
|
|
289
|
+
references: refCheck.refDetails,
|
|
290
|
+
});
|
|
291
|
+
generated = {
|
|
292
|
+
images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
|
|
293
|
+
usage: r.usage,
|
|
294
|
+
webSearchCalls: r.webSearchCalls,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
else if (activeProvider === "agy") {
|
|
298
|
+
const r = await generateViaAgy(prompt, {
|
|
299
|
+
references: refCheck.refDetails,
|
|
300
|
+
signal: cancelController.signal,
|
|
301
|
+
requestId,
|
|
302
|
+
});
|
|
303
|
+
generated = {
|
|
304
|
+
images: [{ b64: r.b64, revisedPrompt: r.revisedPrompt }],
|
|
305
|
+
usage: r.usage,
|
|
306
|
+
webSearchCalls: r.webSearchCalls,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
else if (activeProvider === "grok" || activeProvider === "grok-api") {
|
|
310
|
+
const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
|
|
240
311
|
const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
|
|
312
|
+
const grokRefs = incomingProviderUrl
|
|
313
|
+
? [{ b64: "", url: incomingProviderUrl }, ...refCheck.refDetails]
|
|
314
|
+
: refCheck.refDetails;
|
|
241
315
|
generated = await generateMultimodeViaGrok(prompt, ctx, {
|
|
242
316
|
model: grokModel,
|
|
243
317
|
maxImages,
|
|
244
318
|
size: effectiveSize,
|
|
245
319
|
signal: cancelController.signal,
|
|
246
320
|
requestId,
|
|
247
|
-
references:
|
|
321
|
+
references: grokRefs,
|
|
322
|
+
directApiKey,
|
|
248
323
|
onFinalImage: async (image, index) => {
|
|
249
324
|
const totalReturned = Math.max(index + 1, images.length + 1);
|
|
250
325
|
await persistAndSendImage(image, index, totalReturned, sequenceStatus(totalReturned, maxImages));
|
|
@@ -257,14 +332,14 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
257
332
|
maxImages,
|
|
258
333
|
reasoningEffort,
|
|
259
334
|
webSearchEnabled,
|
|
260
|
-
onPartialImage: (partial) =>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
335
|
+
onPartialImage: (partial) => {
|
|
336
|
+
if (isJobCanceled(requestId))
|
|
337
|
+
return;
|
|
338
|
+
const pd = { image: `data:${mime};base64,${partial.b64}`, requestId, sequenceId, index: partial.index };
|
|
339
|
+
if (!res.writableEnded && !res.destroyed)
|
|
340
|
+
writeSse(res, "partial", pd);
|
|
341
|
+
publish(requestId, "partial", pd);
|
|
342
|
+
},
|
|
268
343
|
onFinalImage: async (image, index) => {
|
|
269
344
|
const totalReturned = Math.max(index + 1, images.length + 1);
|
|
270
345
|
await persistAndSendImage(image, index, totalReturned, sequenceStatus(totalReturned, maxImages));
|
|
@@ -287,7 +362,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
287
362
|
finishHttpStatus = 422;
|
|
288
363
|
finishErrorCode = "EMPTY_RESPONSE";
|
|
289
364
|
finishMeta = { sequenceId, filenames: [], imageCount: 0, maxImages, status, composerPrompt: routeComposerPrompt, composerInsertedPrompts: routeComposerInsertedPrompts };
|
|
290
|
-
|
|
365
|
+
dualEmitMultimode(res, requestId, "error", {
|
|
291
366
|
error: "No image data returned from the multimode stream",
|
|
292
367
|
code: finishErrorCode,
|
|
293
368
|
status: finishHttpStatus,
|
|
@@ -306,7 +381,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
306
381
|
composerInsertedPrompts: routeComposerInsertedPrompts,
|
|
307
382
|
};
|
|
308
383
|
finishHttpStatus = 200;
|
|
309
|
-
|
|
384
|
+
dualEmitMultimode(res, requestId, "done", {
|
|
310
385
|
ok: true,
|
|
311
386
|
requestId,
|
|
312
387
|
sequenceId,
|
|
@@ -345,7 +420,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
345
420
|
finishCanceled = true;
|
|
346
421
|
finishHttpStatus = canceled.status;
|
|
347
422
|
finishErrorCode = canceled.code;
|
|
348
|
-
|
|
423
|
+
dualEmitMultimode(res, requestId, "error", {
|
|
349
424
|
error: canceled.message,
|
|
350
425
|
code: canceled.code,
|
|
351
426
|
status: canceled.status,
|
|
@@ -368,7 +443,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
368
443
|
composerPrompt: routeComposerPrompt,
|
|
369
444
|
composerInsertedPrompts: routeComposerInsertedPrompts,
|
|
370
445
|
};
|
|
371
|
-
|
|
446
|
+
dualEmitMultimode(res, requestId, "done", {
|
|
372
447
|
ok: true,
|
|
373
448
|
partial: true,
|
|
374
449
|
requestId,
|
|
@@ -406,7 +481,7 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
406
481
|
finishHttpStatus = err.status || 500;
|
|
407
482
|
finishErrorCode = fallbackCode || "MULTIMODE_GENERATE_FAILED";
|
|
408
483
|
logError("multimode", "error", err.raw, { requestId, code: finishErrorCode });
|
|
409
|
-
|
|
484
|
+
dualEmitMultimode(res, requestId, "error", {
|
|
410
485
|
error: err.message,
|
|
411
486
|
code: finishErrorCode,
|
|
412
487
|
status: finishHttpStatus,
|
|
@@ -424,7 +499,8 @@ export function registerMultimodeRoutes(app, ctxRaw) {
|
|
|
424
499
|
errorCode: finishErrorCode,
|
|
425
500
|
meta: finishMeta,
|
|
426
501
|
});
|
|
427
|
-
res.
|
|
502
|
+
if (!res.writableEnded)
|
|
503
|
+
res.end();
|
|
428
504
|
}
|
|
429
505
|
});
|
|
430
506
|
}
|