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.
Files changed (64) hide show
  1. package/README.md +2 -11
  2. package/bin/commands/backfillThumbs.js +18 -0
  3. package/bin/commands/edit.js +7 -6
  4. package/bin/commands/gen.js +7 -6
  5. package/bin/commands/multimode.js +5 -4
  6. package/bin/commands/node.js +4 -4
  7. package/bin/ima2.js +7 -1
  8. package/bin/lib/config-store.js +1 -1
  9. package/docs/API.md +55 -4
  10. package/docs/CLI.md +9 -3
  11. package/docs/PROMPT_STUDIO.md +3 -1
  12. package/docs/migration/runtime-test-inventory.md +3 -1
  13. package/lib/agentRuntime.js +22 -16
  14. package/lib/agentSettings.js +1 -1
  15. package/lib/agyImageAdapter.js +232 -0
  16. package/lib/capabilities.js +2 -1
  17. package/lib/configKeys.js +1 -1
  18. package/lib/geminiApiImageAdapter.js +183 -0
  19. package/lib/grokImageAdapter.js +16 -9
  20. package/lib/grokMultimodeAdapter.js +2 -1
  21. package/lib/grokRuntime.js +3 -0
  22. package/lib/grokSizeMapper.js +13 -1
  23. package/lib/grokVideoAdapter.js +14 -7
  24. package/lib/historyList.js +18 -2
  25. package/lib/imageModels.js +15 -0
  26. package/lib/imageThumb.js +38 -0
  27. package/lib/providerOptions.js +36 -1
  28. package/lib/responsesFallback.js +52 -44
  29. package/lib/runtimeContext.js +27 -0
  30. package/lib/storageMigration.js +1 -1
  31. package/lib/thumbBackfill.js +59 -0
  32. package/lib/vertexAuth.js +44 -0
  33. package/lib/videoThumb.js +60 -0
  34. package/package.json +4 -2
  35. package/routes/auth.js +238 -0
  36. package/routes/edit.js +41 -7
  37. package/routes/generate.js +40 -12
  38. package/routes/history.js +13 -0
  39. package/routes/index.js +4 -0
  40. package/routes/keys.js +254 -0
  41. package/routes/multimode.js +39 -6
  42. package/routes/nodes.js +57 -35
  43. package/routes/quota.js +58 -7
  44. package/routes/video.js +7 -3
  45. package/server.js +123 -0
  46. package/ui/dist/.vite/manifest.json +12 -12
  47. package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +3 -0
  48. package/ui/dist/assets/{CardNewsWorkspace-BN-ga1lG.js → CardNewsWorkspace-Dqyc1WZ1.js} +2 -2
  49. package/ui/dist/assets/{NodeCanvas-BbMa4IhI.js → NodeCanvas-ChEXzQbb.js} +2 -2
  50. package/ui/dist/assets/{PromptBuilderPanel-DRwBJRDQ.js → PromptBuilderPanel-B95ZufnR.js} +1 -1
  51. package/ui/dist/assets/{PromptImportDialog-Dp85kHCq.js → PromptImportDialog-DGOwFQET.js} +2 -2
  52. package/ui/dist/assets/{PromptImportDiscoverySection-BE8Q8MLD.js → PromptImportDiscoverySection-CgvdnR49.js} +1 -1
  53. package/ui/dist/assets/{PromptImportFolderSection-PtH5x0sc.js → PromptImportFolderSection-CfUye9J8.js} +1 -1
  54. package/ui/dist/assets/{PromptLibraryPanel-FnM9tHI9.js → PromptLibraryPanel-B9kndPw1.js} +2 -2
  55. package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +1 -0
  56. package/ui/dist/assets/index-BhcvL0g-.js +1 -0
  57. package/ui/dist/assets/index-BtK3YhJc.js +39 -0
  58. package/ui/dist/assets/index-ClOLOjnA.css +1 -0
  59. package/ui/dist/index.html +2 -2
  60. package/ui/dist/assets/AgentWorkspace-C21zqdTZ.js +0 -3
  61. package/ui/dist/assets/SettingsWorkspace-MARPGyBL.js +0 -1
  62. package/ui/dist/assets/index-BAFI6htx.js +0 -42
  63. package/ui/dist/assets/index-BSXxr_Bt.js +0 -1
  64. 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
- finishErrorCode = "GROK_MASK_UNSUPPORTED";
131
- return res.status(400).json({ error: "Grok provider does not support mask editing", code: "GROK_MASK_UNSUPPORTED" });
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 === "grok") {
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
- await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(resultB64, "base64"));
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,
@@ -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 sharedGrokPlan = activeProvider === "grok"
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 === "grok") {
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
- await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
289
- await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
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
  }