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/auth.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
8
|
+
const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
9
|
+
const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token";
|
|
10
|
+
const CODEX_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
11
|
+
// Bundled @openai/codex binary (npm dependency), resolved relative to this
|
|
12
|
+
// module so device-auth login works even when `codex` is not on the user's PATH.
|
|
13
|
+
const CODEX_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ".bin", process.platform === "win32" ? "codex.cmd" : "codex");
|
|
14
|
+
const MAX_CONCURRENT_SESSIONS = 20;
|
|
15
|
+
const sessions = new Map();
|
|
16
|
+
function sid() {
|
|
17
|
+
return randomBytes(16).toString("hex");
|
|
18
|
+
}
|
|
19
|
+
function cleanup(id) {
|
|
20
|
+
const s = sessions.get(id);
|
|
21
|
+
if (s?.pollTimer)
|
|
22
|
+
clearInterval(s.pollTimer);
|
|
23
|
+
if (s?.child && !s.child.killed)
|
|
24
|
+
s.child.kill();
|
|
25
|
+
if (s)
|
|
26
|
+
delete s.deviceCode;
|
|
27
|
+
setTimeout(() => sessions.delete(id), 120_000);
|
|
28
|
+
}
|
|
29
|
+
function stripAnsi(s) {
|
|
30
|
+
return s.replace(/\x1B\[[0-9;]*m/g, "");
|
|
31
|
+
}
|
|
32
|
+
function saveGrokTokens(tokens) {
|
|
33
|
+
const dir = join(homedir(), ".progrok");
|
|
34
|
+
if (!existsSync(dir))
|
|
35
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
36
|
+
let email;
|
|
37
|
+
if (typeof tokens.id_token === "string") {
|
|
38
|
+
try {
|
|
39
|
+
const payload = JSON.parse(Buffer.from(tokens.id_token.split(".")[1], "base64url").toString());
|
|
40
|
+
email = payload.email;
|
|
41
|
+
}
|
|
42
|
+
catch { /* ignore */ }
|
|
43
|
+
}
|
|
44
|
+
const data = {
|
|
45
|
+
accessToken: tokens.access_token,
|
|
46
|
+
refreshToken: tokens.refresh_token,
|
|
47
|
+
expiresAt: typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1000 : undefined,
|
|
48
|
+
tokenEndpoint: GROK_TOKEN_URL,
|
|
49
|
+
};
|
|
50
|
+
if (email)
|
|
51
|
+
data.email = email;
|
|
52
|
+
// Atomic write: temp file (0600) + rename, so concurrent completions or a crash
|
|
53
|
+
// mid-flush can never truncate/corrupt the only credential file. Rename also
|
|
54
|
+
// guarantees final perms are 0600 even if a looser-perm file pre-existed.
|
|
55
|
+
const target = join(dir, "auth.json");
|
|
56
|
+
const tmp = join(dir, `auth.json.tmp-${randomBytes(6).toString("hex")}`);
|
|
57
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
58
|
+
renameSync(tmp, target);
|
|
59
|
+
}
|
|
60
|
+
async function startGrokDeviceCode() {
|
|
61
|
+
const discovery = await fetch("https://auth.x.ai/.well-known/openid-configuration", { signal: AbortSignal.timeout(10000) });
|
|
62
|
+
const disc = await discovery.json();
|
|
63
|
+
if (!disc.device_authorization_endpoint)
|
|
64
|
+
throw new Error("xAI does not expose device_authorization_endpoint");
|
|
65
|
+
const res = await fetch(disc.device_authorization_endpoint, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
68
|
+
body: new URLSearchParams({ client_id: GROK_CLIENT_ID, scope: GROK_SCOPE }),
|
|
69
|
+
signal: AbortSignal.timeout(15000),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
throw new Error(`Device code request failed: ${res.status}`);
|
|
73
|
+
const dc = await res.json();
|
|
74
|
+
const id = sid();
|
|
75
|
+
const session = {
|
|
76
|
+
provider: "grok",
|
|
77
|
+
userCode: dc.user_code,
|
|
78
|
+
verificationUrl: dc.verification_uri_complete || dc.verification_uri,
|
|
79
|
+
expiresAt: Date.now() + dc.expires_in * 1000,
|
|
80
|
+
status: "pending",
|
|
81
|
+
deviceCode: dc.device_code,
|
|
82
|
+
};
|
|
83
|
+
sessions.set(id, session);
|
|
84
|
+
const interval = Math.max((dc.interval || 5) * 1000, 5000);
|
|
85
|
+
session.pollTimer = setInterval(async () => {
|
|
86
|
+
if (session.status !== "pending") {
|
|
87
|
+
cleanup(id);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (Date.now() > session.expiresAt) {
|
|
91
|
+
session.status = "expired";
|
|
92
|
+
cleanup(id);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const tokenRes = await fetch(disc.token_endpoint, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
99
|
+
body: new URLSearchParams({
|
|
100
|
+
grant_type: CODEX_DEVICE_CODE_GRANT,
|
|
101
|
+
client_id: GROK_CLIENT_ID,
|
|
102
|
+
device_code: dc.device_code,
|
|
103
|
+
}),
|
|
104
|
+
signal: AbortSignal.timeout(10000),
|
|
105
|
+
});
|
|
106
|
+
if (tokenRes.ok) {
|
|
107
|
+
const tokens = await tokenRes.json();
|
|
108
|
+
saveGrokTokens(tokens);
|
|
109
|
+
session.status = "complete";
|
|
110
|
+
cleanup(id);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const err = await tokenRes.json();
|
|
114
|
+
if (err.error !== "authorization_pending" && err.error !== "slow_down") {
|
|
115
|
+
session.status = "error";
|
|
116
|
+
session.error = err.error || "unknown";
|
|
117
|
+
cleanup(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch { /* network error, keep polling */ }
|
|
121
|
+
}, interval);
|
|
122
|
+
return { sessionId: id, userCode: dc.user_code, verificationUrl: session.verificationUrl, expiresIn: dc.expires_in };
|
|
123
|
+
}
|
|
124
|
+
function startCodexDeviceCode() {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
// Don't hand other providers' secrets to the codex child — it only needs
|
|
127
|
+
// PATH/HOME/codex config to run the ChatGPT device-code login.
|
|
128
|
+
const childEnv = { ...process.env };
|
|
129
|
+
for (const k of ["OPENAI_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "VERTEX_SERVICE_ACCOUNT_JSON"]) {
|
|
130
|
+
delete childEnv[k];
|
|
131
|
+
}
|
|
132
|
+
const child = spawn(CODEX_BIN, ["login", "--device-auth"], {
|
|
133
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
134
|
+
env: childEnv,
|
|
135
|
+
});
|
|
136
|
+
let stdout = "";
|
|
137
|
+
let resolved = false;
|
|
138
|
+
const id = sid();
|
|
139
|
+
const session = {
|
|
140
|
+
provider: "codex",
|
|
141
|
+
userCode: "",
|
|
142
|
+
verificationUrl: "",
|
|
143
|
+
expiresAt: Date.now() + 15 * 60 * 1000,
|
|
144
|
+
status: "pending",
|
|
145
|
+
child,
|
|
146
|
+
};
|
|
147
|
+
sessions.set(id, session);
|
|
148
|
+
// Server-side reaper: if the client abandons the flow (closes browser, stops
|
|
149
|
+
// polling), kill the lingering codex child instead of waiting for it to self-exit.
|
|
150
|
+
const reaper = setTimeout(() => {
|
|
151
|
+
if (session.status === "pending") {
|
|
152
|
+
session.status = "expired";
|
|
153
|
+
cleanup(id);
|
|
154
|
+
}
|
|
155
|
+
}, 16 * 60 * 1000);
|
|
156
|
+
reaper.unref?.();
|
|
157
|
+
child.stdout?.on("data", (chunk) => {
|
|
158
|
+
stdout += chunk.toString();
|
|
159
|
+
if (resolved)
|
|
160
|
+
return;
|
|
161
|
+
const clean = stripAnsi(stdout);
|
|
162
|
+
const urlMatch = clean.match(/https:\/\/auth\.openai\.com\/codex\/device/);
|
|
163
|
+
const codeMatch = clean.match(/^\s+([A-Z0-9]{4}-[A-Z0-9]{4,5})\s*$/m);
|
|
164
|
+
if (urlMatch && codeMatch) {
|
|
165
|
+
resolved = true;
|
|
166
|
+
session.userCode = codeMatch[1];
|
|
167
|
+
session.verificationUrl = urlMatch[0];
|
|
168
|
+
resolve({
|
|
169
|
+
sessionId: id,
|
|
170
|
+
userCode: codeMatch[1],
|
|
171
|
+
verificationUrl: urlMatch[0],
|
|
172
|
+
expiresIn: 900,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
child.stderr?.on("data", () => { });
|
|
177
|
+
child.on("close", (code) => {
|
|
178
|
+
if (!resolved) {
|
|
179
|
+
sessions.delete(id);
|
|
180
|
+
reject(new Error(`codex login exited with code ${code} before providing device code`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
session.status = code === 0 ? "complete" : "error";
|
|
184
|
+
if (code !== 0)
|
|
185
|
+
session.error = `codex exited with code ${code}`;
|
|
186
|
+
cleanup(id);
|
|
187
|
+
});
|
|
188
|
+
child.on("error", (err) => {
|
|
189
|
+
if (!resolved) {
|
|
190
|
+
sessions.delete(id);
|
|
191
|
+
reject(new Error(`codex not found: ${err.message}`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
session.status = "error";
|
|
195
|
+
session.error = err.message;
|
|
196
|
+
cleanup(id);
|
|
197
|
+
});
|
|
198
|
+
setTimeout(() => {
|
|
199
|
+
if (!resolved) {
|
|
200
|
+
sessions.delete(id);
|
|
201
|
+
if (!child.killed)
|
|
202
|
+
child.kill();
|
|
203
|
+
reject(new Error("Timed out waiting for codex device code output"));
|
|
204
|
+
}
|
|
205
|
+
}, 30000);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export function registerAuthRoutes(app) {
|
|
209
|
+
app.post("/api/auth/switch", async (req, res) => {
|
|
210
|
+
const provider = req.body?.provider;
|
|
211
|
+
if (provider !== "grok" && provider !== "codex") {
|
|
212
|
+
return res.status(400).json({ error: "provider must be grok or codex" });
|
|
213
|
+
}
|
|
214
|
+
if (sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
215
|
+
return res.status(429).json({ error: "Too many pending auth sessions" });
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const result = provider === "grok"
|
|
219
|
+
? await startGrokDeviceCode()
|
|
220
|
+
: await startCodexDeviceCode();
|
|
221
|
+
res.json(result);
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
res.status(502).json({ error: e.message });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
app.get("/api/auth/switch/:sessionId", (req, res) => {
|
|
228
|
+
const session = sessions.get(req.params.sessionId);
|
|
229
|
+
if (!session)
|
|
230
|
+
return res.status(404).json({ status: "expired" });
|
|
231
|
+
if (session.status === "complete")
|
|
232
|
+
return res.json({ status: "complete" });
|
|
233
|
+
if (session.status === "error")
|
|
234
|
+
return res.json({ status: "error", error: session.error });
|
|
235
|
+
if (Date.now() > session.expiresAt) {
|
|
236
|
+
session.status = "expired";
|
|
237
|
+
cleanup(req.params.sessionId);
|
|
238
|
+
return res.json({ status: "expired" });
|
|
239
|
+
}
|
|
240
|
+
res.json({ status: "pending" });
|
|
241
|
+
});
|
|
242
|
+
}
|
package/routes/edit.js
CHANGED
|
@@ -3,11 +3,14 @@ import { safeWriteSidecar } from "../lib/atomicWrite.js";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
5
|
import { detectImageMimeFromB64 } 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 { editViaResponses } from "../lib/responsesImageAdapter.js";
|
|
10
11
|
import { editViaGrok } from "../lib/grokImageAdapter.js";
|
|
12
|
+
import { generateViaAgy } from "../lib/agyImageAdapter.js";
|
|
13
|
+
import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
|
|
11
14
|
import { startJob, finishJob, registerJobAbortController, isJobCanceled } from "../lib/inflight.js";
|
|
12
15
|
import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
|
|
13
16
|
import { logEvent, logError } from "../lib/logger.js";
|
|
@@ -124,11 +127,11 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
124
127
|
finishErrorCode = "INVALID_EDIT_INPUT";
|
|
125
128
|
return res.status(400).json({ error: "Prompt and image are required" });
|
|
126
129
|
}
|
|
127
|
-
if (activeProvider === "grok" && rawMask) {
|
|
130
|
+
if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && rawMask) {
|
|
128
131
|
finishStatus = "error";
|
|
129
132
|
finishHttpStatus = 400;
|
|
130
|
-
|
|
131
|
-
return res.status(400).json({ error: "Grok provider does not support mask editing
|
|
133
|
+
const code = activeProvider === "agy" ? "AGY_MASK_UNSUPPORTED" : activeProvider === "gemini-api" ? "GEMINI_API_MASK_UNSUPPORTED" : "GROK_MASK_UNSUPPORTED";
|
|
134
|
+
return res.status(400).json({ error: `${activeProvider === "agy" ? "Agy" : activeProvider === "gemini-api" ? "Gemini API" : "Grok"} provider does not support mask editing`, code });
|
|
132
135
|
}
|
|
133
136
|
const maskCheck = validateEditMask(imageB64, rawMask);
|
|
134
137
|
if (maskCheck.error) {
|
|
@@ -166,15 +169,45 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
166
169
|
let revisedPrompt;
|
|
167
170
|
let webSearchCalls = 0;
|
|
168
171
|
let resultMimeFromProvider;
|
|
169
|
-
|
|
172
|
+
let providerUrl = null;
|
|
173
|
+
if (activeProvider === "gemini-api") {
|
|
174
|
+
const r = await generateViaGeminiApi(`Edit this image: ${prompt}`, requireRuntimeContext(ctx), {
|
|
175
|
+
model: imageModel,
|
|
176
|
+
size: effectiveSize,
|
|
177
|
+
signal: cancelController.signal,
|
|
178
|
+
requestId,
|
|
179
|
+
references: [{ b64: imageB64, declaredMime: null, detectedMime: detectImageMimeFromB64(imageB64) || null }],
|
|
180
|
+
});
|
|
181
|
+
resultB64 = r.b64;
|
|
182
|
+
usage = r.usage;
|
|
183
|
+
revisedPrompt = r.revisedPrompt;
|
|
184
|
+
webSearchCalls = r.webSearchCalls;
|
|
185
|
+
resultMimeFromProvider = r.mime;
|
|
186
|
+
}
|
|
187
|
+
else if (activeProvider === "agy") {
|
|
188
|
+
const r = await generateViaAgy(`Edit this image: ${prompt}`, {
|
|
189
|
+
references: [{ b64: imageB64, declaredMime: null, detectedMime: detectImageMimeFromB64(imageB64) || null }],
|
|
190
|
+
signal: cancelController.signal,
|
|
191
|
+
requestId,
|
|
192
|
+
});
|
|
193
|
+
resultB64 = r.b64;
|
|
194
|
+
usage = r.usage;
|
|
195
|
+
revisedPrompt = r.revisedPrompt;
|
|
196
|
+
webSearchCalls = r.webSearchCalls;
|
|
197
|
+
resultMimeFromProvider = r.mime;
|
|
198
|
+
}
|
|
199
|
+
else if (activeProvider === "grok" || activeProvider === "grok-api") {
|
|
200
|
+
const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
|
|
170
201
|
const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
|
|
171
202
|
const r = await editViaGrok(prompt, imageB64, ctx, {
|
|
172
203
|
model: grokModel,
|
|
173
204
|
size: effectiveSize,
|
|
174
205
|
signal: cancelController.signal,
|
|
175
206
|
requestId,
|
|
207
|
+
directApiKey,
|
|
176
208
|
});
|
|
177
209
|
resultB64 = r.b64;
|
|
210
|
+
providerUrl = r.providerUrl ?? null;
|
|
178
211
|
usage = r.usage;
|
|
179
212
|
revisedPrompt = r.revisedPrompt;
|
|
180
213
|
webSearchCalls = r.webSearchCalls;
|
|
@@ -197,12 +230,16 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
197
230
|
const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
|
|
198
231
|
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
199
232
|
throwIfJobCanceled(requestId);
|
|
200
|
-
const editMime = activeProvider === "grok"
|
|
233
|
+
const editMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
|
|
201
234
|
? (resultMimeFromProvider || detectImageMimeFromB64(resultB64) || "image/png")
|
|
202
235
|
: "image/png";
|
|
203
|
-
const editExt = activeProvider === "grok" ? imageFormatFromMime(editMime) : "png";
|
|
236
|
+
const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(editMime) : "png";
|
|
204
237
|
const filename = `${Date.now()}_${randomBytes(ctx.config.ids.generatedHexBytes).toString("hex")}.${editExt}`;
|
|
205
|
-
|
|
238
|
+
const editBuffer = Buffer.from(resultB64, "base64");
|
|
239
|
+
const editFilePath = join(ctx.config.storage.generatedDir, filename);
|
|
240
|
+
await writeFile(editFilePath, editBuffer);
|
|
241
|
+
generateImageThumbnailFromBuffer(editBuffer, editFilePath).catch(() => { });
|
|
242
|
+
const createdAt = Date.now();
|
|
206
243
|
const meta = {
|
|
207
244
|
prompt,
|
|
208
245
|
userPrompt: prompt,
|
|
@@ -218,10 +255,11 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
218
255
|
provider: activeProvider,
|
|
219
256
|
kind: "edit",
|
|
220
257
|
requestId,
|
|
221
|
-
createdAt
|
|
258
|
+
createdAt,
|
|
222
259
|
usage: usage || null,
|
|
223
260
|
webSearchCalls,
|
|
224
261
|
webSearchEnabled,
|
|
262
|
+
...(providerUrl ? { providerUrl } : {}),
|
|
225
263
|
};
|
|
226
264
|
await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
|
|
227
265
|
invalidateHistoryIndex();
|
|
@@ -247,6 +285,8 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
247
285
|
promptMode: normalizedPromptMode,
|
|
248
286
|
webSearchCalls,
|
|
249
287
|
webSearchEnabled,
|
|
288
|
+
providerUrl,
|
|
289
|
+
createdAt,
|
|
250
290
|
});
|
|
251
291
|
}
|
|
252
292
|
catch (e) {
|
package/routes/events.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { subscribe, replaySince, hasReplayGap, replayOldestId, MAX_SSE_LISTENERS } from "../lib/eventBus.js";
|
|
2
|
+
let activeConnections = 0;
|
|
3
|
+
function safeWrite(res, chunk) {
|
|
4
|
+
if (res.writableEnded || res.destroyed)
|
|
5
|
+
return false;
|
|
6
|
+
try {
|
|
7
|
+
res.write(chunk);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function formatSse(ev) {
|
|
15
|
+
return `id: ${ev.id}\nevent: ${ev.event}\ndata: ${JSON.stringify({ ...ev.data, jobId: ev.jobId })}\n\n`;
|
|
16
|
+
}
|
|
17
|
+
export function registerEventsRoute(app, _ctx) {
|
|
18
|
+
app.get("/api/events", (req, res) => {
|
|
19
|
+
if (activeConnections >= MAX_SSE_LISTENERS) {
|
|
20
|
+
return res.status(503).json({
|
|
21
|
+
error: { code: "SSE_CAPACITY", message: "Too many event stream connections" },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
25
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
26
|
+
res.setHeader("Connection", "keep-alive");
|
|
27
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
28
|
+
res.flushHeaders?.();
|
|
29
|
+
activeConnections++;
|
|
30
|
+
const headerLastId = parseInt(req.headers["last-event-id"], 10);
|
|
31
|
+
const queryLastId = parseInt(String(req.query.lastEventId ?? ""), 10);
|
|
32
|
+
const lastId = !Number.isNaN(headerLastId) ? headerLastId : queryLastId;
|
|
33
|
+
if (!Number.isNaN(lastId)) {
|
|
34
|
+
if (hasReplayGap(lastId)) {
|
|
35
|
+
const gapPayload = JSON.stringify({
|
|
36
|
+
lastEventId: lastId,
|
|
37
|
+
oldestAvailableId: replayOldestId(),
|
|
38
|
+
});
|
|
39
|
+
if (!safeWrite(res, `event: replay-gap\ndata: ${gapPayload}\n\n`)) {
|
|
40
|
+
activeConnections = Math.max(0, activeConnections - 1);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const ev of replaySince(lastId)) {
|
|
45
|
+
if (!safeWrite(res, formatSse(ev)))
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let cleaned = false;
|
|
50
|
+
const unsub = subscribe((ev) => {
|
|
51
|
+
if (!safeWrite(res, formatSse(ev)))
|
|
52
|
+
cleanup();
|
|
53
|
+
});
|
|
54
|
+
const heartbeat = setInterval(() => {
|
|
55
|
+
if (!safeWrite(res, ": ping\n\n"))
|
|
56
|
+
cleanup();
|
|
57
|
+
}, 15_000);
|
|
58
|
+
function cleanup() {
|
|
59
|
+
if (cleaned)
|
|
60
|
+
return;
|
|
61
|
+
cleaned = true;
|
|
62
|
+
unsub();
|
|
63
|
+
clearInterval(heartbeat);
|
|
64
|
+
activeConnections = Math.max(0, activeConnections - 1);
|
|
65
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
66
|
+
try {
|
|
67
|
+
res.end();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* socket already torn down */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
req.on("close", cleanup);
|
|
75
|
+
res.on("close", cleanup);
|
|
76
|
+
res.on("error", cleanup);
|
|
77
|
+
});
|
|
78
|
+
}
|