ima2-gen 1.1.0 → 1.1.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.
Files changed (58) hide show
  1. package/README.md +47 -7
  2. package/assets/card-news/templates/academy-lesson-square/base.png +0 -0
  3. package/assets/card-news/templates/academy-lesson-square/preview.png +0 -0
  4. package/assets/card-news/templates/academy-lesson-square/template.json +20 -0
  5. package/assets/card-news/templates/clean-report-square/base.png +0 -0
  6. package/assets/card-news/templates/clean-report-square/preview.png +0 -0
  7. package/assets/card-news/templates/clean-report-square/template.json +20 -0
  8. package/bin/commands/cancel.js +45 -0
  9. package/bin/commands/edit.js +33 -4
  10. package/bin/commands/gen.js +26 -3
  11. package/bin/commands/ps.js +48 -16
  12. package/bin/ima2.js +56 -12
  13. package/bin/lib/client.js +4 -1
  14. package/bin/lib/error-hints.js +23 -0
  15. package/bin/lib/output.js +10 -0
  16. package/config.js +19 -1
  17. package/docs/API.md +67 -0
  18. package/docs/FAQ.ko.md +248 -0
  19. package/docs/FAQ.md +256 -0
  20. package/docs/README.ja.md +4 -0
  21. package/docs/README.ko.md +14 -1
  22. package/docs/README.zh-CN.md +4 -0
  23. package/docs/RECOVER_OLD_IMAGES.md +2 -0
  24. package/lib/cardNewsGenerator.js +162 -0
  25. package/lib/cardNewsJobStore.js +107 -0
  26. package/lib/cardNewsManifestStore.js +112 -0
  27. package/lib/cardNewsPlanner.js +180 -0
  28. package/lib/cardNewsPlannerClient.js +112 -0
  29. package/lib/cardNewsPlannerPrompt.js +60 -0
  30. package/lib/cardNewsPlannerSchema.js +259 -0
  31. package/lib/cardNewsRoleTemplateStore.js +47 -0
  32. package/lib/cardNewsTemplateStore.js +210 -0
  33. package/lib/db.js +20 -3
  34. package/lib/errorClassify.js +2 -2
  35. package/lib/generationErrors.js +51 -0
  36. package/lib/historyList.js +82 -8
  37. package/lib/inflight.js +117 -34
  38. package/lib/logger.js +37 -3
  39. package/lib/oauthLauncher.js +52 -19
  40. package/lib/oauthProxy.js +81 -14
  41. package/lib/requestLogger.js +48 -0
  42. package/lib/runtimePorts.js +93 -0
  43. package/lib/sessionStore.js +48 -7
  44. package/package.json +3 -2
  45. package/routes/cardNews.js +183 -0
  46. package/routes/edit.js +1 -1
  47. package/routes/generate.js +10 -10
  48. package/routes/health.js +27 -3
  49. package/routes/index.js +2 -0
  50. package/routes/nodes.js +93 -26
  51. package/server.js +91 -18
  52. package/ui/dist/assets/index-BjX_nzuK.js +23 -0
  53. package/ui/dist/assets/index-BjX_nzuK.js.map +1 -0
  54. package/ui/dist/assets/index-DHyUax4_.css +1 -0
  55. package/ui/dist/index.html +2 -2
  56. package/ui/dist/assets/index-CqpVoXpZ.css +0 -1
  57. package/ui/dist/assets/index-IHSd1z1a.js +0 -22
  58. package/ui/dist/assets/index-IHSd1z1a.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { mkdir, readFile, readdir, stat } from "fs/promises";
2
- import { join } from "path";
2
+ import { dirname, join } from "path";
3
3
  import { config } from "../config.js";
4
4
 
5
5
  async function listImageFiles(baseDir) {
@@ -25,15 +25,10 @@ async function listImageFiles(baseDir) {
25
25
  export async function listHistoryRows(baseDir = config.storage.generatedDir) {
26
26
  await mkdir(baseDir, { recursive: true });
27
27
  const imgs = await listImageFiles(baseDir);
28
+ const setRows = await listCardNewsSetRows(baseDir);
28
29
  const rows = await Promise.all(imgs.map(async ({ full, rel, name }) => {
29
30
  const st = await stat(full).catch(() => null);
30
- let meta = null;
31
- try {
32
- const raw = await readFile(full + ".json", "utf-8");
33
- meta = JSON.parse(raw);
34
- } catch (e) {
35
- if (e.code !== "ENOENT") console.warn("[history] sidecar parse fail:", rel, e.message);
36
- }
31
+ const meta = await readImageSidecar(full, rel);
37
32
  return {
38
33
  filename: rel,
39
34
  url: `/generated/${rel.split("/").map(encodeURIComponent).join("/")}`,
@@ -55,10 +50,17 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
55
50
  clientNodeId: meta?.clientNodeId || null,
56
51
  requestId: meta?.requestId || null,
57
52
  kind: meta?.kind || null,
53
+ setId: meta?.setId || null,
54
+ cardId: meta?.cardId || null,
55
+ cardOrder: Number.isFinite(meta?.cardOrder) ? meta.cardOrder : null,
56
+ headline: meta?.headline || null,
57
+ body: meta?.body || null,
58
+ cards: meta?.cards || null,
58
59
  refsCount: Number.isFinite(meta?.refsCount) ? meta.refsCount : 0,
59
60
  };
60
61
  }));
61
62
 
63
+ rows.push(...setRows);
62
64
  rows.sort((a, b) => {
63
65
  if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt;
64
66
  return b.filename < a.filename ? -1 : b.filename > a.filename ? 1 : 0;
@@ -66,3 +68,75 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
66
68
 
67
69
  return rows;
68
70
  }
71
+
72
+ async function readImageSidecar(full, rel) {
73
+ const sibling = full.replace(/\.(png|jpe?g|webp)$/i, ".json");
74
+ for (const candidate of [`${full}.json`, sibling]) {
75
+ try {
76
+ return JSON.parse(await readFile(candidate, "utf-8"));
77
+ } catch (e) {
78
+ if (e.code !== "ENOENT") console.warn("[history] sidecar parse fail:", rel, e.message);
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ async function listCardNewsSetRows(baseDir) {
85
+ const root = join(baseDir, "cardnews");
86
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
87
+ const rows = [];
88
+ for (const entry of entries) {
89
+ if (!entry.isDirectory()) continue;
90
+ try {
91
+ const manifestPath = join(root, entry.name, "manifest.json");
92
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
93
+ const first = (manifest.cards || []).find((card) => card.imageFilename);
94
+ const filename = `cardnews/${entry.name}/manifest.json`;
95
+ rows.push({
96
+ filename,
97
+ url: first?.imageFilename
98
+ ? `/generated/cardnews/${encodeURIComponent(entry.name)}/${encodeURIComponent(first.imageFilename)}`
99
+ : "",
100
+ createdAt: manifest.createdAt || 0,
101
+ prompt: null,
102
+ userPrompt: null,
103
+ revisedPrompt: null,
104
+ promptMode: null,
105
+ quality: null,
106
+ size: manifest.size || null,
107
+ format: "card-news-set",
108
+ model: null,
109
+ provider: "oauth",
110
+ usage: null,
111
+ webSearchCalls: 0,
112
+ sessionId: manifest.sessionId || null,
113
+ nodeId: null,
114
+ parentNodeId: null,
115
+ clientNodeId: null,
116
+ requestId: manifest.requestId || null,
117
+ kind: "card-news-set",
118
+ setId: manifest.setId || entry.name,
119
+ cardId: null,
120
+ cardOrder: null,
121
+ title: manifest.title || "Untitled card news",
122
+ headline: manifest.title || "Untitled card news",
123
+ body: null,
124
+ cards: (manifest.cards || []).map((card) => ({
125
+ url: card.imageFilename
126
+ ? `/generated/cardnews/${encodeURIComponent(entry.name)}/${encodeURIComponent(card.imageFilename)}`
127
+ : "",
128
+ headline: card.headline,
129
+ body: card.body,
130
+ cardOrder: card.cardOrder,
131
+ imageFilename: card.imageFilename,
132
+ status: card.status || "generated",
133
+ })),
134
+ refsCount: 0,
135
+ dir: dirname(manifestPath),
136
+ });
137
+ } catch (e) {
138
+ if (e.code !== "ENOENT") console.warn("[history] card-news manifest parse fail:", entry.name, e.message);
139
+ }
140
+ }
141
+ return rows;
142
+ }
package/lib/inflight.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { config } from "../config.js";
2
+ import { getDb } from "./db.js";
2
3
  import { logEvent } from "./logger.js";
3
4
 
4
- // In-memory inflight job registry.
5
+ // SQLite-backed inflight job registry.
5
6
  // Tracks generation requests that are currently running on the server so clients
6
7
  // can reconcile optimistic UI state after a reload or across tabs.
7
8
  //
8
- // This is intentionally process-local: if the server restarts, inflight jobs
9
- // are lost (which is correct — the fetch they came from is already gone).
9
+ // A restarted process cannot continue the original upstream fetch, but keeping
10
+ // metadata durable lets the UI reconcile requestIds and eventually prune stale
11
+ // work without losing the recovery breadcrumb.
10
12
 
11
- const jobs = new Map(); // requestId -> { requestId, kind, prompt, meta, startedAt, phase, phaseAt }
12
13
  const terminalJobs = new Map(); // requestId -> terminal snapshot, active-only API stays default
13
14
 
14
15
  // Phases: "queued" → "streaming" (upstream connection open, waiting for image)
@@ -16,38 +17,60 @@ const terminalJobs = new Map(); // requestId -> terminal snapshot, active-only A
16
17
  export function startJob({ requestId, kind, prompt, meta = {} }) {
17
18
  if (!requestId) return;
18
19
  const startedAt = Date.now();
19
- jobs.set(requestId, {
20
- requestId,
21
- kind,
22
- prompt: typeof prompt === "string" ? prompt.slice(0, 500) : "",
23
- meta,
24
- startedAt,
25
- phase: "queued",
26
- phaseAt: startedAt,
27
- });
20
+ const normalizedPrompt = typeof prompt === "string" ? prompt.slice(0, 500) : "";
21
+ const normalizedMeta = normalizeMeta(meta);
22
+ getDb()
23
+ .prepare(`
24
+ INSERT OR REPLACE INTO inflight (
25
+ request_id,
26
+ kind,
27
+ prompt,
28
+ meta,
29
+ session_id,
30
+ parent_node_id,
31
+ client_node_id,
32
+ started_at,
33
+ phase,
34
+ phase_at
35
+ )
36
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
37
+ `)
38
+ .run(
39
+ requestId,
40
+ kind,
41
+ normalizedPrompt,
42
+ JSON.stringify(normalizedMeta),
43
+ stringOrNull(normalizedMeta.sessionId),
44
+ stringOrNull(normalizedMeta.parentNodeId),
45
+ stringOrNull(normalizedMeta.clientNodeId),
46
+ startedAt,
47
+ "queued",
48
+ startedAt,
49
+ );
28
50
  terminalJobs.delete(requestId);
29
51
  logEvent("inflight", "start", {
30
52
  requestId,
31
53
  kind,
32
- sessionId: meta?.sessionId || null,
33
- parentNodeId: meta?.parentNodeId || null,
34
- clientNodeId: meta?.clientNodeId || null,
54
+ sessionId: normalizedMeta.sessionId || null,
55
+ parentNodeId: normalizedMeta.parentNodeId || null,
56
+ clientNodeId: normalizedMeta.clientNodeId || null,
35
57
  promptChars: typeof prompt === "string" ? prompt.length : 0,
36
58
  });
37
59
  }
38
60
 
39
61
  export function setJobPhase(requestId, phase) {
40
62
  if (!requestId) return;
41
- const j = jobs.get(requestId);
63
+ const j = getJob(requestId);
42
64
  if (!j) return;
43
- j.phase = phase;
44
- j.phaseAt = Date.now();
65
+ getDb()
66
+ .prepare("UPDATE inflight SET phase = ?, phase_at = ? WHERE request_id = ?")
67
+ .run(phase, Date.now(), requestId);
45
68
  logEvent("inflight", "phase", { requestId, kind: j.kind, phase });
46
69
  }
47
70
 
48
71
  export function finishJob(requestId, options = {}) {
49
72
  if (!requestId) return;
50
- const j = jobs.get(requestId);
73
+ const j = getJob(requestId);
51
74
  if (j) {
52
75
  const finishedAt = Date.now();
53
76
  const status = options.canceled ? "canceled" : options.status || "completed";
@@ -76,7 +99,7 @@ export function finishJob(requestId, options = {}) {
76
99
  errorCode: options.errorCode,
77
100
  });
78
101
  }
79
- jobs.delete(requestId);
102
+ getDb().prepare("DELETE FROM inflight WHERE request_id = ?").run(requestId);
80
103
  reapTerminalJobs();
81
104
  }
82
105
 
@@ -88,19 +111,23 @@ function reapTerminalJobs() {
88
111
  }
89
112
 
90
113
  export function listJobs(filters = {}) {
91
- // Stale reaping: > TTL is almost certainly a crashed fetch.
92
- const now = Date.now();
93
- for (const [id, j] of jobs) {
94
- if (now - j.startedAt > config.inflight.ttlMs) jobs.delete(id);
95
- }
114
+ purgeStaleJobs();
96
115
  const { kind, sessionId } = filters;
97
- return Array.from(jobs.values())
98
- .filter((j) => {
99
- if (kind && j.kind !== kind) return false;
100
- if (sessionId && j.meta?.sessionId !== sessionId) return false;
101
- return true;
102
- })
103
- .sort((a, b) => a.startedAt - b.startedAt);
116
+ const clauses = [];
117
+ const params = [];
118
+ if (kind) {
119
+ clauses.push("kind = ?");
120
+ params.push(kind);
121
+ }
122
+ if (sessionId) {
123
+ clauses.push("session_id = ?");
124
+ params.push(sessionId);
125
+ }
126
+ const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
127
+ return getDb()
128
+ .prepare(`SELECT * FROM inflight${where} ORDER BY started_at ASC`)
129
+ .all(...params)
130
+ .map(rowToJob);
104
131
  }
105
132
 
106
133
  export function listTerminalJobs(filters = {}) {
@@ -116,6 +143,62 @@ export function listTerminalJobs(filters = {}) {
116
143
  }
117
144
 
118
145
  export function _resetForTests() {
119
- jobs.clear();
146
+ getDb().prepare("DELETE FROM inflight").run();
120
147
  terminalJobs.clear();
121
148
  }
149
+
150
+ export function purgeStaleJobs(now = Date.now()) {
151
+ getDb()
152
+ .prepare("DELETE FROM inflight WHERE started_at < ?")
153
+ .run(now - config.inflight.ttlMs);
154
+ }
155
+
156
+ function getJob(requestId) {
157
+ const row = getDb()
158
+ .prepare("SELECT * FROM inflight WHERE request_id = ?")
159
+ .get(requestId);
160
+ return row ? rowToJob(row) : null;
161
+ }
162
+
163
+ function rowToJob(row) {
164
+ const meta = normalizeMeta(parseMeta(row.meta));
165
+ const sessionId = stringOrNull(row.session_id) ?? stringOrNull(meta.sessionId);
166
+ const parentNodeId =
167
+ stringOrNull(row.parent_node_id) ?? stringOrNull(meta.parentNodeId);
168
+ const clientNodeId =
169
+ stringOrNull(row.client_node_id) ?? stringOrNull(meta.clientNodeId);
170
+ return {
171
+ requestId: row.request_id,
172
+ kind: row.kind,
173
+ prompt: row.prompt || "",
174
+ meta: {
175
+ ...meta,
176
+ ...(sessionId ? { sessionId } : {}),
177
+ ...(parentNodeId ? { parentNodeId } : {}),
178
+ ...(clientNodeId ? { clientNodeId } : {}),
179
+ },
180
+ startedAt: Number(row.started_at),
181
+ phase: row.phase || "queued",
182
+ phaseAt: Number(row.phase_at || row.started_at),
183
+ };
184
+ }
185
+
186
+ function parseMeta(raw) {
187
+ if (typeof raw !== "string" || !raw) return {};
188
+ try {
189
+ const parsed = JSON.parse(raw);
190
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
191
+ ? parsed
192
+ : {};
193
+ } catch {
194
+ return {};
195
+ }
196
+ }
197
+
198
+ function normalizeMeta(meta) {
199
+ return meta && typeof meta === "object" && !Array.isArray(meta) ? meta : {};
200
+ }
201
+
202
+ function stringOrNull(value) {
203
+ return typeof value === "string" && value.length > 0 ? value : null;
204
+ }
package/lib/logger.js CHANGED
@@ -1,5 +1,12 @@
1
1
  const REDACTED = "[redacted]";
2
2
  const MAX_VALUE_LEN = 240;
3
+ const LOG_LEVELS = {
4
+ debug: 10,
5
+ info: 20,
6
+ warn: 30,
7
+ error: 40,
8
+ silent: 50,
9
+ };
3
10
 
4
11
  const SECRET_KEYS = new Set([
5
12
  "authorization",
@@ -26,6 +33,9 @@ const SECRET_KEYS = new Set([
26
33
 
27
34
  const ALLOWED_PROMPT_METRICS = new Set(["promptChars", "promptMode"]);
28
35
 
36
+ let activeLevel = "info";
37
+ let activeSink = console;
38
+
29
39
  function shouldRedactKey(key) {
30
40
  if (ALLOWED_PROMPT_METRICS.has(key)) return false;
31
41
  if (SECRET_KEYS.has(key)) return true;
@@ -96,17 +106,41 @@ export function formatLog(scope, event, fields = {}) {
96
106
  return `[${scope}.${event}]${parts.length ? ` ${parts.join(" ")}` : ""}`;
97
107
  }
98
108
 
109
+ export function normalizeLogLevel(level) {
110
+ return typeof level === "string" && Object.hasOwn(LOG_LEVELS, level) ? level : "info";
111
+ }
112
+
113
+ export function configureLogger(options = {}) {
114
+ activeLevel = normalizeLogLevel(options.level);
115
+ activeSink = options.sink || console;
116
+ }
117
+
118
+ export function shouldLog(level) {
119
+ const normalized = normalizeLogLevel(level);
120
+ return LOG_LEVELS[normalized] >= LOG_LEVELS[activeLevel] && activeLevel !== "silent";
121
+ }
122
+
123
+ function writeLog(level, line) {
124
+ if (!shouldLog(level)) return;
125
+ const writer = activeSink[level] || activeSink.log || console.log;
126
+ writer.call(activeSink, line);
127
+ }
128
+
129
+ export function logDebug(scope, event, fields = {}) {
130
+ writeLog("debug", formatLog(scope, event, fields));
131
+ }
132
+
99
133
  export function logEvent(scope, event, fields = {}) {
100
- console.log(formatLog(scope, event, fields));
134
+ writeLog("info", formatLog(scope, event, fields));
101
135
  }
102
136
 
103
137
  export function logWarn(scope, event, fields = {}) {
104
- console.warn(formatLog(scope, event, fields));
138
+ writeLog("warn", formatLog(scope, event, fields));
105
139
  }
106
140
 
107
141
  export function logError(scope, event, err, fields = {}) {
108
142
  const safe = sanitizeError(err);
109
- console.error(formatLog(scope, event, {
143
+ writeLog("error", formatLog(scope, event, {
110
144
  ...fields,
111
145
  errorName: safe.name,
112
146
  errorCode: safe.code,
@@ -1,31 +1,64 @@
1
1
  import { spawnBin } from "../bin/lib/platform.js";
2
2
  import { config } from "../config.js";
3
+ import { parseLocalhostPortFromUrl, parseOAuthReadyUrl } from "./runtimePorts.js";
3
4
 
4
5
  export function startOAuthProxy(options = {}) {
5
6
  const oauthPort = options.oauthPort ?? config.oauth.proxyPort;
6
7
  const restartDelayMs = options.restartDelayMs ?? config.oauth.restartDelayMs;
8
+ let currentChild = null;
9
+ let stopping = false;
10
+ let restartTimer = null;
7
11
 
8
- console.log(`Starting openai-oauth on port ${oauthPort}...`);
9
- const child = spawnBin("npx", ["openai-oauth", "--port", String(oauthPort)], {
10
- stdio: ["ignore", "pipe", "pipe"],
11
- env: { ...process.env },
12
- });
12
+ const spawnProxy = () => {
13
+ console.log(`Starting openai-oauth on port ${oauthPort}...`);
14
+ const child = spawnBin("npx", ["openai-oauth", "--port", String(oauthPort)], {
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ env: { ...process.env },
17
+ });
18
+ currentChild = child;
13
19
 
14
- child.stdout.on("data", (d) => {
15
- const msg = d.toString().trim();
16
- if (msg) console.log(`[oauth] ${msg}`);
17
- });
20
+ child.stdout.on("data", (d) => {
21
+ const msg = d.toString().trim();
22
+ if (!msg) return;
23
+ console.log(`[oauth] ${msg}`);
24
+ for (const line of msg.split(/\r?\n/)) {
25
+ const url = parseOAuthReadyUrl(line);
26
+ if (!url) continue;
27
+ const port = parseLocalhostPortFromUrl(url);
28
+ if (port && port !== oauthPort) {
29
+ console.log(`[oauth] requested port ${oauthPort}, actual port ${port}`);
30
+ }
31
+ options.onReady?.({ url, port: port || oauthPort, requestedPort: oauthPort });
32
+ }
33
+ });
18
34
 
19
- child.stderr.on("data", (d) => {
20
- const msg = d.toString().trim();
21
- if (msg && !msg.includes("npm warn")) console.error(`[oauth] ${msg}`);
22
- });
35
+ child.stderr.on("data", (d) => {
36
+ const msg = d.toString().trim();
37
+ if (msg && !msg.includes("npm warn")) console.error(`[oauth] ${msg}`);
38
+ });
23
39
 
24
- child.on("exit", (code) => {
25
- console.log(`[oauth] exited with code ${code}, restarting in ${Math.round(restartDelayMs / 1000)}s...`);
26
- setTimeout(() => startOAuthProxy(options), restartDelayMs);
27
- });
40
+ child.on("exit", (code) => {
41
+ if (currentChild === child) currentChild = null;
42
+ if (stopping) return;
43
+ options.onExit?.({ code });
44
+ console.log(`[oauth] exited with code ${code}, restarting in ${Math.round(restartDelayMs / 1000)}s...`);
45
+ restartTimer = setTimeout(spawnProxy, restartDelayMs);
46
+ });
47
+ };
28
48
 
29
- return child;
30
- }
49
+ spawnProxy();
31
50
 
51
+ return {
52
+ get child() {
53
+ return currentChild;
54
+ },
55
+ kill(signal = "SIGTERM") {
56
+ this.stop(signal);
57
+ },
58
+ stop(signal = "SIGTERM") {
59
+ stopping = true;
60
+ if (restartTimer) clearTimeout(restartTimer);
61
+ try { currentChild?.kill(signal); } catch {}
62
+ },
63
+ };
64
+ }
package/lib/oauthProxy.js CHANGED
@@ -34,10 +34,33 @@ export function buildEditTextPrompt(userPrompt, mode) {
34
34
  return `Edit this image: ${userPrompt}${AUTO_PROMPT_FIDELITY_SUFFIX}`;
35
35
  }
36
36
 
37
+ export function buildEditResearchTextPrompt(userPrompt, mode) {
38
+ if (mode === "direct") return buildEditTextPrompt(userPrompt, mode);
39
+ return `Edit this image: ${userPrompt}${RESEARCH_SUFFIX}${AUTO_PROMPT_FIDELITY_SUFFIX}`;
40
+ }
41
+
37
42
  function getOAuthUrl(ctx = {}) {
38
43
  return ctx.oauthUrl || `http://127.0.0.1:${config.oauth.proxyPort}`;
39
44
  }
40
45
 
46
+ export async function waitForOAuthReady(ctx = {}) {
47
+ if (!ctx || !Object.prototype.hasOwnProperty.call(ctx, "oauthReadyState")) return;
48
+ if (ctx.oauthReadyState === "ready" || ctx.oauthReadyState === "disabled") return;
49
+ if (ctx.oauthReadyState === "failed") {
50
+ throw makeOAuthError("OAuth proxy is unavailable", { code: "OAUTH_UNAVAILABLE", status: 503 });
51
+ }
52
+ const timeoutMs = ctx.config?.oauth?.statusTimeoutMs ?? config.oauth.statusTimeoutMs;
53
+ if (ctx.oauthReadyPromise) {
54
+ await Promise.race([
55
+ ctx.oauthReadyPromise,
56
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
57
+ ]);
58
+ }
59
+ if (ctx.oauthReadyState !== "ready" && ctx.oauthReadyState !== "disabled") {
60
+ throw makeOAuthError("OAuth proxy is not ready yet", { code: "OAUTH_UNAVAILABLE", status: 503 });
61
+ }
62
+ }
63
+
41
64
  function extractSseData(block) {
42
65
  let eventData = "";
43
66
  for (const line of block.split("\n")) {
@@ -64,15 +87,30 @@ function extractPartialImage(data) {
64
87
  return { b64, index, eventType: data.type };
65
88
  }
66
89
 
67
- function makeOAuthError(message, { status, code = "OAUTH_UPSTREAM_ERROR", upstreamBodyChars, eventType } = {}) {
90
+ function makeOAuthError(message, { status, code = "OAUTH_UPSTREAM_ERROR", upstreamBodyChars, eventType, eventCount, cause } = {}) {
68
91
  const err = new Error(message);
69
92
  err.code = code;
70
93
  if (status) err.status = status;
71
94
  if (typeof upstreamBodyChars === "number") err.upstreamBodyChars = upstreamBodyChars;
72
95
  if (eventType) err.eventType = eventType;
96
+ if (typeof eventCount === "number") err.eventCount = eventCount;
97
+ if (cause) err.cause = cause;
73
98
  return err;
74
99
  }
75
100
 
101
+ async function fetchOAuth(url, init, { requestId, scope } = {}) {
102
+ try {
103
+ return await fetch(url, init);
104
+ } catch (err) {
105
+ logEvent(scope || "oauth", "proxy_unavailable", { requestId, message: err?.message });
106
+ throw makeOAuthError("OAuth proxy is unavailable", {
107
+ code: "OAUTH_UNAVAILABLE",
108
+ status: 503,
109
+ cause: err,
110
+ });
111
+ }
112
+ }
113
+
76
114
  async function readImageStream(res, { requestId = null, scope = "oauth", onPartialImage = null } = {}) {
77
115
  const reader = res.body.getReader();
78
116
  const decoder = new TextDecoder();
@@ -129,9 +167,12 @@ async function readImageStream(res, { requestId = null, scope = "oauth", onParti
129
167
  if (typeof wsNum === "number" && wsNum > webSearchCalls) webSearchCalls = wsNum;
130
168
  }
131
169
  if (data.type === "error") {
170
+ const code = data.error?.code || "OAUTH_STREAM_ERROR";
171
+ logEvent(scope, "stream_error", { requestId, code, eventType: data.type, eventCount });
132
172
  throw makeOAuthError("OAuth stream returned an error", {
133
- code: data.error?.code || "OAUTH_STREAM_ERROR",
173
+ code,
134
174
  eventType: data.type,
175
+ eventCount,
135
176
  });
136
177
  }
137
178
  } catch (e) {
@@ -154,6 +195,7 @@ export async function generateViaOAuth(
154
195
  ctx = {},
155
196
  options = {},
156
197
  ) {
198
+ await waitForOAuthReady(ctx);
157
199
  const oauthUrl = getOAuthUrl(ctx);
158
200
  const model = options.model || ctx.config?.imageModels?.default || "gpt-5.4-mini";
159
201
  const tools = [
@@ -178,7 +220,7 @@ export async function generateViaOAuth(
178
220
  ]
179
221
  : textPrompt;
180
222
 
181
- const res = await fetch(`${oauthUrl}/v1/responses`, {
223
+ const res = await fetchOAuth(`${oauthUrl}/v1/responses`, {
182
224
  method: "POST",
183
225
  headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
184
226
  body: JSON.stringify({
@@ -191,7 +233,7 @@ export async function generateViaOAuth(
191
233
  tool_choice: "auto",
192
234
  stream: true,
193
235
  }),
194
- });
236
+ }, { requestId, scope: "oauth" });
195
237
 
196
238
  logEvent("oauth", "response", {
197
239
  requestId,
@@ -199,7 +241,6 @@ export async function generateViaOAuth(
199
241
  status: res.status,
200
242
  contentType: res.headers.get("content-type"),
201
243
  });
202
- if (requestId) setJobPhase(requestId, "streaming");
203
244
 
204
245
  if (!res.ok) {
205
246
  const text = await res.text();
@@ -210,6 +251,8 @@ export async function generateViaOAuth(
210
251
  });
211
252
  }
212
253
 
254
+ if (requestId) setJobPhase(requestId, "streaming");
255
+
213
256
  const contentType = res.headers.get("content-type") || "";
214
257
  if (!contentType.includes("text/event-stream")) {
215
258
  logEvent("oauth", "json_response", { requestId });
@@ -234,7 +277,7 @@ export async function generateViaOAuth(
234
277
 
235
278
  if (!imageB64) {
236
279
  logEvent("oauth", "retry_json", { requestId });
237
- const retryRes = await fetch(`${oauthUrl}/v1/responses`, {
280
+ const retryRes = await fetchOAuth(`${oauthUrl}/v1/responses`, {
238
281
  method: "POST",
239
282
  headers: { "Content-Type": "application/json" },
240
283
  body: JSON.stringify({
@@ -243,7 +286,7 @@ export async function generateViaOAuth(
243
286
  tools: [{ type: "image_generation", quality, size, moderation }],
244
287
  stream: false,
245
288
  }),
246
- });
289
+ }, { requestId, scope: "oauth" });
247
290
 
248
291
  if (retryRes.ok) {
249
292
  const json = await retryRes.json();
@@ -263,11 +306,33 @@ export async function generateViaOAuth(
263
306
  }
264
307
 
265
308
  export async function editViaOAuth(prompt, imageB64, quality, size, moderation = "low", mode = "auto", ctx = {}, requestId = null, options = {}) {
309
+ await waitForOAuthReady(ctx);
266
310
  const oauthUrl = getOAuthUrl(ctx);
267
311
  const model = options.model || ctx.config?.imageModels?.default || "gpt-5.4-mini";
268
- const textPrompt = buildEditTextPrompt(prompt, mode);
312
+ const searchMode = options.searchMode === "on" ? "on" : "off";
313
+ const textPrompt = searchMode === "on"
314
+ ? buildEditResearchTextPrompt(prompt, mode)
315
+ : buildEditTextPrompt(prompt, mode);
316
+ const references = Array.isArray(options.references) ? options.references : [];
317
+ const referenceContent = references.map((b64) => ({
318
+ type: "input_image",
319
+ image_url: `data:image/png;base64,${b64}`,
320
+ }));
321
+ const tools = [
322
+ ...(searchMode === "on" ? [{ type: "web_search" }] : []),
323
+ { type: "image_generation", quality, size, moderation },
324
+ ];
269
325
 
270
- const res = await fetch(`${oauthUrl}/v1/responses`, {
326
+ logEvent("oauth-edit", "request", {
327
+ requestId,
328
+ model,
329
+ refsCount: references.length,
330
+ inputImageCount: 1 + references.length,
331
+ parentImagePresent: true,
332
+ webSearchEnabled: searchMode === "on",
333
+ });
334
+
335
+ const res = await fetchOAuth(`${oauthUrl}/v1/responses`, {
271
336
  method: "POST",
272
337
  headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
273
338
  body: JSON.stringify({
@@ -278,15 +343,16 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
278
343
  role: "user",
279
344
  content: [
280
345
  { type: "input_image", image_url: `data:image/png;base64,${imageB64}` },
346
+ ...referenceContent,
281
347
  { type: "input_text", text: textPrompt },
282
348
  ],
283
349
  },
284
350
  ],
285
- tools: [{ type: "image_generation", quality, size, moderation }],
351
+ tools,
286
352
  tool_choice: "required",
287
353
  stream: true,
288
354
  }),
289
- });
355
+ }, { requestId, scope: "oauth-edit" });
290
356
 
291
357
  logEvent("oauth-edit", "response", {
292
358
  requestId,
@@ -294,7 +360,6 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
294
360
  status: res.status,
295
361
  contentType: res.headers.get("content-type"),
296
362
  });
297
- if (requestId) setJobPhase(requestId, "streaming");
298
363
 
299
364
  if (!res.ok) {
300
365
  const text = await res.text();
@@ -305,11 +370,13 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
305
370
  });
306
371
  }
307
372
 
308
- const { imageB64: resultB64, usage, revisedPrompt } = await readImageStream(res, {
373
+ if (requestId) setJobPhase(requestId, "streaming");
374
+
375
+ const { imageB64: resultB64, usage, revisedPrompt, webSearchCalls } = await readImageStream(res, {
309
376
  scope: "oauth-edit",
310
377
  requestId,
311
378
  });
312
379
  logEvent("oauth-edit", "stream_end", { requestId, hasImage: !!resultB64 });
313
- if (resultB64) return { b64: resultB64, usage, revisedPrompt };
380
+ if (resultB64) return { b64: resultB64, usage, revisedPrompt, webSearchCalls };
314
381
  throw new Error("No image data received from OAuth edit");
315
382
  }