ima2-gen 2.0.0 → 2.0.1
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 +2 -11
- package/bin/commands/backfillThumbs.js +18 -0
- package/bin/commands/edit.js +7 -6
- package/bin/commands/gen.js +7 -6
- package/bin/commands/multimode.js +5 -4
- package/bin/commands/node.js +4 -4
- package/bin/ima2.js +7 -1
- package/bin/lib/config-store.js +1 -1
- package/docs/API.md +55 -4
- package/docs/CLI.md +9 -3
- package/docs/PROMPT_STUDIO.md +3 -1
- package/docs/migration/runtime-test-inventory.md +3 -1
- package/lib/agentRuntime.js +22 -16
- package/lib/agentSettings.js +1 -1
- package/lib/agyImageAdapter.js +232 -0
- package/lib/capabilities.js +2 -1
- package/lib/configKeys.js +1 -1
- package/lib/geminiApiImageAdapter.js +183 -0
- package/lib/grokImageAdapter.js +16 -9
- package/lib/grokMultimodeAdapter.js +2 -1
- package/lib/grokRuntime.js +3 -0
- package/lib/grokSizeMapper.js +13 -1
- package/lib/grokVideoAdapter.js +14 -7
- package/lib/historyList.js +18 -2
- package/lib/imageModels.js +15 -0
- package/lib/imageThumb.js +38 -0
- package/lib/providerOptions.js +36 -1
- package/lib/responsesFallback.js +52 -44
- package/lib/runtimeContext.js +27 -0
- package/lib/storageMigration.js +1 -1
- package/lib/thumbBackfill.js +59 -0
- package/lib/vertexAuth.js +44 -0
- package/lib/videoThumb.js +60 -0
- package/package.json +4 -2
- package/routes/auth.js +238 -0
- package/routes/edit.js +41 -7
- package/routes/generate.js +40 -12
- package/routes/history.js +13 -0
- package/routes/index.js +4 -0
- package/routes/keys.js +254 -0
- package/routes/multimode.js +39 -6
- package/routes/nodes.js +57 -35
- package/routes/quota.js +58 -7
- package/routes/video.js +7 -3
- package/server.js +123 -0
- package/ui/dist/.vite/manifest.json +12 -12
- package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +3 -0
- package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dqyc1WZ1.js} +2 -2
- package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-ChEXzQbb.js} +2 -2
- package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-B95ZufnR.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-DGOwFQET.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CgvdnR49.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-CfUye9J8.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-B9kndPw1.js} +2 -2
- package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +1 -0
- package/ui/dist/assets/index-BhcvL0g-.js +1 -0
- package/ui/dist/assets/index-BtK3YhJc.js +39 -0
- package/ui/dist/assets/index-ClOLOjnA.css +1 -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,238 @@
|
|
|
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 } from "node:path";
|
|
6
|
+
const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
7
|
+
const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
8
|
+
const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token";
|
|
9
|
+
const CODEX_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
10
|
+
const MAX_CONCURRENT_SESSIONS = 20;
|
|
11
|
+
const sessions = new Map();
|
|
12
|
+
function sid() {
|
|
13
|
+
return randomBytes(16).toString("hex");
|
|
14
|
+
}
|
|
15
|
+
function cleanup(id) {
|
|
16
|
+
const s = sessions.get(id);
|
|
17
|
+
if (s?.pollTimer)
|
|
18
|
+
clearInterval(s.pollTimer);
|
|
19
|
+
if (s?.child && !s.child.killed)
|
|
20
|
+
s.child.kill();
|
|
21
|
+
if (s)
|
|
22
|
+
delete s.deviceCode;
|
|
23
|
+
setTimeout(() => sessions.delete(id), 120_000);
|
|
24
|
+
}
|
|
25
|
+
function stripAnsi(s) {
|
|
26
|
+
return s.replace(/\x1B\[[0-9;]*m/g, "");
|
|
27
|
+
}
|
|
28
|
+
function saveGrokTokens(tokens) {
|
|
29
|
+
const dir = join(homedir(), ".progrok");
|
|
30
|
+
if (!existsSync(dir))
|
|
31
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
let email;
|
|
33
|
+
if (typeof tokens.id_token === "string") {
|
|
34
|
+
try {
|
|
35
|
+
const payload = JSON.parse(Buffer.from(tokens.id_token.split(".")[1], "base64url").toString());
|
|
36
|
+
email = payload.email;
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore */ }
|
|
39
|
+
}
|
|
40
|
+
const data = {
|
|
41
|
+
accessToken: tokens.access_token,
|
|
42
|
+
refreshToken: tokens.refresh_token,
|
|
43
|
+
expiresAt: typeof tokens.expires_in === "number" ? Date.now() + tokens.expires_in * 1000 : undefined,
|
|
44
|
+
tokenEndpoint: GROK_TOKEN_URL,
|
|
45
|
+
};
|
|
46
|
+
if (email)
|
|
47
|
+
data.email = email;
|
|
48
|
+
// Atomic write: temp file (0600) + rename, so concurrent completions or a crash
|
|
49
|
+
// mid-flush can never truncate/corrupt the only credential file. Rename also
|
|
50
|
+
// guarantees final perms are 0600 even if a looser-perm file pre-existed.
|
|
51
|
+
const target = join(dir, "auth.json");
|
|
52
|
+
const tmp = join(dir, `auth.json.tmp-${randomBytes(6).toString("hex")}`);
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
54
|
+
renameSync(tmp, target);
|
|
55
|
+
}
|
|
56
|
+
async function startGrokDeviceCode() {
|
|
57
|
+
const discovery = await fetch("https://auth.x.ai/.well-known/openid-configuration", { signal: AbortSignal.timeout(10000) });
|
|
58
|
+
const disc = await discovery.json();
|
|
59
|
+
if (!disc.device_authorization_endpoint)
|
|
60
|
+
throw new Error("xAI does not expose device_authorization_endpoint");
|
|
61
|
+
const res = await fetch(disc.device_authorization_endpoint, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
64
|
+
body: new URLSearchParams({ client_id: GROK_CLIENT_ID, scope: GROK_SCOPE }),
|
|
65
|
+
signal: AbortSignal.timeout(15000),
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
throw new Error(`Device code request failed: ${res.status}`);
|
|
69
|
+
const dc = await res.json();
|
|
70
|
+
const id = sid();
|
|
71
|
+
const session = {
|
|
72
|
+
provider: "grok",
|
|
73
|
+
userCode: dc.user_code,
|
|
74
|
+
verificationUrl: dc.verification_uri_complete || dc.verification_uri,
|
|
75
|
+
expiresAt: Date.now() + dc.expires_in * 1000,
|
|
76
|
+
status: "pending",
|
|
77
|
+
deviceCode: dc.device_code,
|
|
78
|
+
};
|
|
79
|
+
sessions.set(id, session);
|
|
80
|
+
const interval = Math.max((dc.interval || 5) * 1000, 5000);
|
|
81
|
+
session.pollTimer = setInterval(async () => {
|
|
82
|
+
if (session.status !== "pending") {
|
|
83
|
+
cleanup(id);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (Date.now() > session.expiresAt) {
|
|
87
|
+
session.status = "expired";
|
|
88
|
+
cleanup(id);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const tokenRes = await fetch(disc.token_endpoint, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
95
|
+
body: new URLSearchParams({
|
|
96
|
+
grant_type: CODEX_DEVICE_CODE_GRANT,
|
|
97
|
+
client_id: GROK_CLIENT_ID,
|
|
98
|
+
device_code: dc.device_code,
|
|
99
|
+
}),
|
|
100
|
+
signal: AbortSignal.timeout(10000),
|
|
101
|
+
});
|
|
102
|
+
if (tokenRes.ok) {
|
|
103
|
+
const tokens = await tokenRes.json();
|
|
104
|
+
saveGrokTokens(tokens);
|
|
105
|
+
session.status = "complete";
|
|
106
|
+
cleanup(id);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const err = await tokenRes.json();
|
|
110
|
+
if (err.error !== "authorization_pending" && err.error !== "slow_down") {
|
|
111
|
+
session.status = "error";
|
|
112
|
+
session.error = err.error || "unknown";
|
|
113
|
+
cleanup(id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch { /* network error, keep polling */ }
|
|
117
|
+
}, interval);
|
|
118
|
+
return { sessionId: id, userCode: dc.user_code, verificationUrl: session.verificationUrl, expiresIn: dc.expires_in };
|
|
119
|
+
}
|
|
120
|
+
function startCodexDeviceCode() {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
// Don't hand other providers' secrets to the codex child — it only needs
|
|
123
|
+
// PATH/HOME/codex config to run the ChatGPT device-code login.
|
|
124
|
+
const childEnv = { ...process.env };
|
|
125
|
+
for (const k of ["OPENAI_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "VERTEX_SERVICE_ACCOUNT_JSON"]) {
|
|
126
|
+
delete childEnv[k];
|
|
127
|
+
}
|
|
128
|
+
const child = spawn("codex", ["login", "--device-auth"], {
|
|
129
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
130
|
+
env: childEnv,
|
|
131
|
+
});
|
|
132
|
+
let stdout = "";
|
|
133
|
+
let resolved = false;
|
|
134
|
+
const id = sid();
|
|
135
|
+
const session = {
|
|
136
|
+
provider: "codex",
|
|
137
|
+
userCode: "",
|
|
138
|
+
verificationUrl: "",
|
|
139
|
+
expiresAt: Date.now() + 15 * 60 * 1000,
|
|
140
|
+
status: "pending",
|
|
141
|
+
child,
|
|
142
|
+
};
|
|
143
|
+
sessions.set(id, session);
|
|
144
|
+
// Server-side reaper: if the client abandons the flow (closes browser, stops
|
|
145
|
+
// polling), kill the lingering codex child instead of waiting for it to self-exit.
|
|
146
|
+
const reaper = setTimeout(() => {
|
|
147
|
+
if (session.status === "pending") {
|
|
148
|
+
session.status = "expired";
|
|
149
|
+
cleanup(id);
|
|
150
|
+
}
|
|
151
|
+
}, 16 * 60 * 1000);
|
|
152
|
+
reaper.unref?.();
|
|
153
|
+
child.stdout?.on("data", (chunk) => {
|
|
154
|
+
stdout += chunk.toString();
|
|
155
|
+
if (resolved)
|
|
156
|
+
return;
|
|
157
|
+
const clean = stripAnsi(stdout);
|
|
158
|
+
const urlMatch = clean.match(/https:\/\/auth\.openai\.com\/codex\/device/);
|
|
159
|
+
const codeMatch = clean.match(/^\s+([A-Z0-9]{4}-[A-Z0-9]{4,5})\s*$/m);
|
|
160
|
+
if (urlMatch && codeMatch) {
|
|
161
|
+
resolved = true;
|
|
162
|
+
session.userCode = codeMatch[1];
|
|
163
|
+
session.verificationUrl = urlMatch[0];
|
|
164
|
+
resolve({
|
|
165
|
+
sessionId: id,
|
|
166
|
+
userCode: codeMatch[1],
|
|
167
|
+
verificationUrl: urlMatch[0],
|
|
168
|
+
expiresIn: 900,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
child.stderr?.on("data", () => { });
|
|
173
|
+
child.on("close", (code) => {
|
|
174
|
+
if (!resolved) {
|
|
175
|
+
sessions.delete(id);
|
|
176
|
+
reject(new Error(`codex login exited with code ${code} before providing device code`));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
session.status = code === 0 ? "complete" : "error";
|
|
180
|
+
if (code !== 0)
|
|
181
|
+
session.error = `codex exited with code ${code}`;
|
|
182
|
+
cleanup(id);
|
|
183
|
+
});
|
|
184
|
+
child.on("error", (err) => {
|
|
185
|
+
if (!resolved) {
|
|
186
|
+
sessions.delete(id);
|
|
187
|
+
reject(new Error(`codex not found: ${err.message}`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
session.status = "error";
|
|
191
|
+
session.error = err.message;
|
|
192
|
+
cleanup(id);
|
|
193
|
+
});
|
|
194
|
+
setTimeout(() => {
|
|
195
|
+
if (!resolved) {
|
|
196
|
+
sessions.delete(id);
|
|
197
|
+
if (!child.killed)
|
|
198
|
+
child.kill();
|
|
199
|
+
reject(new Error("Timed out waiting for codex device code output"));
|
|
200
|
+
}
|
|
201
|
+
}, 30000);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
export function registerAuthRoutes(app) {
|
|
205
|
+
app.post("/api/auth/switch", async (req, res) => {
|
|
206
|
+
const provider = req.body?.provider;
|
|
207
|
+
if (provider !== "grok" && provider !== "codex") {
|
|
208
|
+
return res.status(400).json({ error: "provider must be grok or codex" });
|
|
209
|
+
}
|
|
210
|
+
if (sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
211
|
+
return res.status(429).json({ error: "Too many pending auth sessions" });
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const result = provider === "grok"
|
|
215
|
+
? await startGrokDeviceCode()
|
|
216
|
+
: await startCodexDeviceCode();
|
|
217
|
+
res.json(result);
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
res.status(502).json({ error: e.message });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
app.get("/api/auth/switch/:sessionId", (req, res) => {
|
|
224
|
+
const session = sessions.get(req.params.sessionId);
|
|
225
|
+
if (!session)
|
|
226
|
+
return res.status(404).json({ status: "expired" });
|
|
227
|
+
if (session.status === "complete")
|
|
228
|
+
return res.json({ status: "complete" });
|
|
229
|
+
if (session.status === "error")
|
|
230
|
+
return res.json({ status: "error", error: session.error });
|
|
231
|
+
if (Date.now() > session.expiresAt) {
|
|
232
|
+
session.status = "expired";
|
|
233
|
+
cleanup(req.params.sessionId);
|
|
234
|
+
return res.json({ status: "expired" });
|
|
235
|
+
}
|
|
236
|
+
res.json({ status: "pending" });
|
|
237
|
+
});
|
|
238
|
+
}
|
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,13 +169,41 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
166
169
|
let revisedPrompt;
|
|
167
170
|
let webSearchCalls = 0;
|
|
168
171
|
let resultMimeFromProvider;
|
|
169
|
-
if (activeProvider === "
|
|
172
|
+
if (activeProvider === "gemini-api") {
|
|
173
|
+
const r = await generateViaGeminiApi(`Edit this image: ${prompt}`, requireRuntimeContext(ctx), {
|
|
174
|
+
model: imageModel,
|
|
175
|
+
size: effectiveSize,
|
|
176
|
+
signal: cancelController.signal,
|
|
177
|
+
requestId,
|
|
178
|
+
references: [{ b64: imageB64, declaredMime: null, detectedMime: detectImageMimeFromB64(imageB64) || null }],
|
|
179
|
+
});
|
|
180
|
+
resultB64 = r.b64;
|
|
181
|
+
usage = r.usage;
|
|
182
|
+
revisedPrompt = r.revisedPrompt;
|
|
183
|
+
webSearchCalls = r.webSearchCalls;
|
|
184
|
+
resultMimeFromProvider = r.mime;
|
|
185
|
+
}
|
|
186
|
+
else if (activeProvider === "agy") {
|
|
187
|
+
const r = await generateViaAgy(`Edit this image: ${prompt}`, {
|
|
188
|
+
references: [{ b64: imageB64, declaredMime: null, detectedMime: detectImageMimeFromB64(imageB64) || null }],
|
|
189
|
+
signal: cancelController.signal,
|
|
190
|
+
requestId,
|
|
191
|
+
});
|
|
192
|
+
resultB64 = r.b64;
|
|
193
|
+
usage = r.usage;
|
|
194
|
+
revisedPrompt = r.revisedPrompt;
|
|
195
|
+
webSearchCalls = r.webSearchCalls;
|
|
196
|
+
resultMimeFromProvider = r.mime;
|
|
197
|
+
}
|
|
198
|
+
else if (activeProvider === "grok" || activeProvider === "grok-api") {
|
|
199
|
+
const directApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
|
|
170
200
|
const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
|
|
171
201
|
const r = await editViaGrok(prompt, imageB64, ctx, {
|
|
172
202
|
model: grokModel,
|
|
173
203
|
size: effectiveSize,
|
|
174
204
|
signal: cancelController.signal,
|
|
175
205
|
requestId,
|
|
206
|
+
directApiKey,
|
|
176
207
|
});
|
|
177
208
|
resultB64 = r.b64;
|
|
178
209
|
usage = r.usage;
|
|
@@ -197,12 +228,15 @@ export function registerEditRoutes(app, ctxRaw) {
|
|
|
197
228
|
const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
|
|
198
229
|
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
199
230
|
throwIfJobCanceled(requestId);
|
|
200
|
-
const editMime = activeProvider === "grok"
|
|
231
|
+
const editMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
|
|
201
232
|
? (resultMimeFromProvider || detectImageMimeFromB64(resultB64) || "image/png")
|
|
202
233
|
: "image/png";
|
|
203
|
-
const editExt = activeProvider === "grok" ? imageFormatFromMime(editMime) : "png";
|
|
234
|
+
const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(editMime) : "png";
|
|
204
235
|
const filename = `${Date.now()}_${randomBytes(ctx.config.ids.generatedHexBytes).toString("hex")}.${editExt}`;
|
|
205
|
-
|
|
236
|
+
const editBuffer = Buffer.from(resultB64, "base64");
|
|
237
|
+
const editFilePath = join(ctx.config.storage.generatedDir, filename);
|
|
238
|
+
await writeFile(editFilePath, editBuffer);
|
|
239
|
+
generateImageThumbnailFromBuffer(editBuffer, editFilePath).catch(() => { });
|
|
206
240
|
const meta = {
|
|
207
241
|
prompt,
|
|
208
242
|
userPrompt: prompt,
|
package/routes/generate.js
CHANGED
|
@@ -3,11 +3,14 @@ import { safeWriteSidecar, atomicWriteJson } 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 { generateViaResponses } from "../lib/responsesImageAdapter.js";
|
|
10
11
|
import { generateViaGrok, planGrokImage } from "../lib/grokImageAdapter.js";
|
|
12
|
+
import { generateViaAgy } from "../lib/agyImageAdapter.js";
|
|
13
|
+
import { generateViaGeminiApi } from "../lib/geminiApiImageAdapter.js";
|
|
11
14
|
import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
|
|
12
15
|
import { startJob, finishJob, registerJobAbortController, isJobCanceled } from "../lib/inflight.js";
|
|
13
16
|
import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
|
|
@@ -127,13 +130,13 @@ export function registerGenerateRoutes(app, ctxRaw) {
|
|
|
127
130
|
return res.status(400).json({ error: refCheckResult.error, code: refCheckResult.code });
|
|
128
131
|
}
|
|
129
132
|
const refCheck = refCheckResult;
|
|
130
|
-
if (activeProvider === "grok" && refCheck.refs.length > 3) {
|
|
133
|
+
if ((activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api") && refCheck.refs.length > 3) {
|
|
131
134
|
finishStatus = "error";
|
|
132
135
|
finishHttpStatus = 400;
|
|
133
|
-
finishErrorCode = "GROK_REF_TOO_MANY";
|
|
136
|
+
finishErrorCode = activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY";
|
|
134
137
|
return res.status(400).json({
|
|
135
|
-
error: "Grok image editing supports up to 3 reference images
|
|
136
|
-
code: "GROK_REF_TOO_MANY",
|
|
138
|
+
error: `${activeProvider === "agy" ? "Agy" : "Grok"} image editing supports up to 3 reference images`,
|
|
139
|
+
code: activeProvider === "agy" ? "AGY_REF_TOO_MANY" : "GROK_REF_TOO_MANY",
|
|
137
140
|
requestId,
|
|
138
141
|
});
|
|
139
142
|
}
|
|
@@ -162,10 +165,11 @@ export function registerGenerateRoutes(app, ctxRaw) {
|
|
|
162
165
|
});
|
|
163
166
|
const startTime = Date.now();
|
|
164
167
|
const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
|
|
165
|
-
const effectiveFormat = activeProvider === "grok" ? "jpeg" : String(format);
|
|
168
|
+
const effectiveFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? "jpeg" : String(format);
|
|
166
169
|
const mime = mimeMap[effectiveFormat] || "image/png";
|
|
167
170
|
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
168
|
-
const
|
|
171
|
+
const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
|
|
172
|
+
const sharedGrokPlan = activeProvider === "grok" || activeProvider === "grok-api"
|
|
169
173
|
? await planGrokImage(generationPrompt, ctx, {
|
|
170
174
|
model: quality === "high" ? "grok-imagine-image-quality" : imageModel,
|
|
171
175
|
size: effectiveSize,
|
|
@@ -173,10 +177,31 @@ export function registerGenerateRoutes(app, ctxRaw) {
|
|
|
173
177
|
requestId,
|
|
174
178
|
referenceCount: refCheck.refs.length,
|
|
175
179
|
references: refCheck.refDetails,
|
|
180
|
+
directApiKey: grokDirectApiKey,
|
|
176
181
|
})
|
|
177
182
|
: null;
|
|
178
183
|
const generateOne = async () => {
|
|
179
|
-
if (activeProvider === "
|
|
184
|
+
if (activeProvider === "gemini-api") {
|
|
185
|
+
const r = await generateViaGeminiApi(generationPrompt, requireRuntimeContext(ctx), {
|
|
186
|
+
model: imageModel,
|
|
187
|
+
size: effectiveSize,
|
|
188
|
+
signal: cancelController.signal,
|
|
189
|
+
requestId,
|
|
190
|
+
references: refCheck.refDetails,
|
|
191
|
+
});
|
|
192
|
+
throwIfJobCanceled(requestId);
|
|
193
|
+
return r;
|
|
194
|
+
}
|
|
195
|
+
if (activeProvider === "agy") {
|
|
196
|
+
const r = await generateViaAgy(generationPrompt, {
|
|
197
|
+
references: refCheck.refDetails,
|
|
198
|
+
signal: cancelController.signal,
|
|
199
|
+
requestId,
|
|
200
|
+
});
|
|
201
|
+
throwIfJobCanceled(requestId);
|
|
202
|
+
return r;
|
|
203
|
+
}
|
|
204
|
+
if (activeProvider === "grok" || activeProvider === "grok-api") {
|
|
180
205
|
const grokModel = quality === "high" ? "grok-imagine-image-quality" : imageModel;
|
|
181
206
|
const r = await generateViaGrok(generationPrompt, ctx, {
|
|
182
207
|
model: grokModel,
|
|
@@ -186,6 +211,7 @@ export function registerGenerateRoutes(app, ctxRaw) {
|
|
|
186
211
|
plannedPrompt: sharedGrokPlan?.prompt,
|
|
187
212
|
webSearchCalls: sharedGrokPlan?.webSearchCalls,
|
|
188
213
|
references: refCheck.refDetails,
|
|
214
|
+
directApiKey: grokDirectApiKey,
|
|
189
215
|
});
|
|
190
216
|
throwIfJobCanceled(requestId);
|
|
191
217
|
return r;
|
|
@@ -232,10 +258,10 @@ export function registerGenerateRoutes(app, ctxRaw) {
|
|
|
232
258
|
if (r.status === "fulfilled" && r.value.b64) {
|
|
233
259
|
throwIfJobCanceled(requestId);
|
|
234
260
|
const valueWithMime = r.value;
|
|
235
|
-
const resultMime = activeProvider === "grok"
|
|
261
|
+
const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api"
|
|
236
262
|
? (valueWithMime.mime || detectImageMimeFromB64(r.value.b64) || mime)
|
|
237
263
|
: mime;
|
|
238
|
-
const resultFormat = activeProvider === "grok" ? imageFormatFromMime(resultMime) : effectiveFormat;
|
|
264
|
+
const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" ? imageFormatFromMime(resultMime) : effectiveFormat;
|
|
239
265
|
const retryValue = r.value;
|
|
240
266
|
if (!firstRetryMeta && retryValue.retryKind) {
|
|
241
267
|
firstRetryMeta = {
|
|
@@ -285,8 +311,10 @@ export function registerGenerateRoutes(app, ctxRaw) {
|
|
|
285
311
|
warning: embedded.warning,
|
|
286
312
|
});
|
|
287
313
|
}
|
|
288
|
-
|
|
289
|
-
await
|
|
314
|
+
const filePath = join(ctx.config.storage.generatedDir, filename);
|
|
315
|
+
await writeFile(filePath, embedded.buffer);
|
|
316
|
+
await safeWriteSidecar(filePath + ".json", meta);
|
|
317
|
+
generateImageThumbnailFromBuffer(embedded.buffer, filePath).catch(() => { });
|
|
290
318
|
invalidateHistoryIndex();
|
|
291
319
|
images.push({
|
|
292
320
|
image: `data:${resultMime};base64,${r.value.b64}`,
|
|
@@ -306,7 +334,7 @@ export function registerGenerateRoutes(app, ctxRaw) {
|
|
|
306
334
|
}
|
|
307
335
|
}
|
|
308
336
|
if (typeof r.value.webSearchCalls === "number") {
|
|
309
|
-
totalWebSearchCalls = activeProvider === "grok"
|
|
337
|
+
totalWebSearchCalls = activeProvider === "grok" || activeProvider === "grok-api"
|
|
310
338
|
? Math.max(totalWebSearchCalls, r.value.webSearchCalls)
|
|
311
339
|
: totalWebSearchCalls + r.value.webSearchCalls;
|
|
312
340
|
}
|
package/routes/history.js
CHANGED
|
@@ -5,6 +5,7 @@ import { logError, logEvent } from "../lib/logger.js";
|
|
|
5
5
|
import { getDb } from "../lib/db.js";
|
|
6
6
|
import { errInfo } from "../lib/errInfo.js";
|
|
7
7
|
import { requireRuntimeContext } from "../lib/runtimeContext.js";
|
|
8
|
+
import { backfillThumbnails } from "../lib/thumbBackfill.js";
|
|
8
9
|
function asStr(value) {
|
|
9
10
|
return typeof value === "string" ? value : "";
|
|
10
11
|
}
|
|
@@ -160,6 +161,18 @@ export function registerHistoryRoutes(app, ctxRaw) {
|
|
|
160
161
|
res.status(err.status || 500).json({ error: err.message });
|
|
161
162
|
}
|
|
162
163
|
});
|
|
164
|
+
app.post("/api/history/backfill-thumbnails", async (_req, res) => {
|
|
165
|
+
try {
|
|
166
|
+
const r = await backfillThumbnails(ctx.config.storage.generatedDir);
|
|
167
|
+
if (r.created > 0)
|
|
168
|
+
invalidateHistoryIndex();
|
|
169
|
+
res.json({ ok: true, ...r });
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
const err = errInfo(e);
|
|
173
|
+
res.status(500).json({ error: err.message });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
163
176
|
app.post("/api/history/favorite", async (req, res) => {
|
|
164
177
|
try {
|
|
165
178
|
const db = getDb();
|
package/routes/index.js
CHANGED
|
@@ -21,6 +21,8 @@ import { registerGrokRoutes } from "./grok.js";
|
|
|
21
21
|
import { registerVideoRoutes } from "./video.js";
|
|
22
22
|
import { registerVideoExtendedRoutes } from "./videoExtended.js";
|
|
23
23
|
import { registerQuotaRoutes } from "./quota.js";
|
|
24
|
+
import { registerAuthRoutes } from "./auth.js";
|
|
25
|
+
import { mountKeyRoutes } from "./keys.js";
|
|
24
26
|
import { requireRuntimeContext } from "../lib/runtimeContext.js";
|
|
25
27
|
export function configureRoutes(app, ctxRaw) {
|
|
26
28
|
const ctx = requireRuntimeContext(ctxRaw);
|
|
@@ -48,4 +50,6 @@ export function configureRoutes(app, ctxRaw) {
|
|
|
48
50
|
registerVideoRoutes(app, ctx);
|
|
49
51
|
registerVideoExtendedRoutes(app, ctx);
|
|
50
52
|
registerQuotaRoutes(app, ctx);
|
|
53
|
+
registerAuthRoutes(app);
|
|
54
|
+
mountKeyRoutes(app, ctx);
|
|
51
55
|
}
|