ima2-gen 1.1.5 → 1.1.7

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 (43) hide show
  1. package/.env.example +5 -0
  2. package/README.md +3 -0
  3. package/config.js +58 -0
  4. package/docs/FAQ.ko.md +20 -0
  5. package/docs/FAQ.md +20 -0
  6. package/docs/README.ko.md +3 -0
  7. package/docs/README.zh-CN.md +3 -0
  8. package/integrations/comfyui/ima2_gen_bridge/README.md +88 -0
  9. package/integrations/comfyui/ima2_gen_bridge/__init__.py +3 -0
  10. package/integrations/comfyui/ima2_gen_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  11. package/integrations/comfyui/ima2_gen_bridge/__pycache__/nodes.cpython-313.pyc +0 -0
  12. package/integrations/comfyui/ima2_gen_bridge/nodes.py +238 -0
  13. package/lib/assetLifecycle.js +21 -0
  14. package/lib/canvasVersionStore.js +181 -0
  15. package/lib/cardNewsPlannerClient.js +4 -2
  16. package/lib/comfyBridge.js +214 -0
  17. package/lib/db.js +14 -0
  18. package/lib/historyList.js +9 -0
  19. package/lib/imageMetadata.js +4 -0
  20. package/lib/imageModels.js +20 -0
  21. package/lib/oauthProxy.js +341 -32
  22. package/lib/pngInfo.js +26 -0
  23. package/lib/promptImport/errors.js +16 -0
  24. package/lib/promptImport/githubSource.js +205 -0
  25. package/lib/promptImport/parsePromptCandidates.js +140 -0
  26. package/package.json +3 -2
  27. package/routes/annotations.js +95 -0
  28. package/routes/canvasVersions.js +64 -0
  29. package/routes/comfy.js +39 -0
  30. package/routes/edit.js +74 -26
  31. package/routes/generate.js +18 -25
  32. package/routes/history.js +11 -1
  33. package/routes/index.js +10 -0
  34. package/routes/multimode.js +281 -0
  35. package/routes/nodes.js +28 -26
  36. package/routes/promptImport.js +175 -0
  37. package/ui/dist/assets/index-DARPdT4Q.css +1 -0
  38. package/ui/dist/assets/index-ht80GMq4.js +31 -0
  39. package/ui/dist/assets/index-ht80GMq4.js.map +1 -0
  40. package/ui/dist/index.html +2 -2
  41. package/ui/dist/assets/index-0SyTGr-u.js +0 -25
  42. package/ui/dist/assets/index-0SyTGr-u.js.map +0 -1
  43. package/ui/dist/assets/index-DfiV508Q.css +0 -1
package/routes/edit.js CHANGED
@@ -4,11 +4,10 @@ import { randomBytes } from "crypto";
4
4
  import { editViaOAuth } from "../lib/oauthProxy.js";
5
5
  import { classifyUpstreamError } from "../lib/errorClassify.js";
6
6
  import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
7
- import { normalizeImageModel } from "../lib/imageModels.js";
8
- import { getStyleSheet } from "../lib/sessionStore.js";
9
- import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
7
+ import { normalizeImageModel, normalizeReasoningEffort } from "../lib/imageModels.js";
10
8
  import { startJob, finishJob } from "../lib/inflight.js";
11
9
  import { logEvent, logError } from "../lib/logger.js";
10
+ import { hasPngAlphaChannel, parsePngInfo } from "../lib/pngInfo.js";
12
11
 
13
12
  function validateModeration(ctx, moderation) {
14
13
  if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
@@ -17,6 +16,49 @@ function validateModeration(ctx, moderation) {
17
16
  return { moderation };
18
17
  }
19
18
 
19
+ const MAX_EDIT_MASK_BYTES = 16 * 1024 * 1024;
20
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
21
+
22
+ function stripPngDataUrl(value) {
23
+ if (typeof value !== "string") return "";
24
+ return value.replace(/^data:image\/png;base64,/, "");
25
+ }
26
+
27
+ function decodePngDataUrl(value, invalidCode, pngCode) {
28
+ const b64 = stripPngDataUrl(value).replace(/\s+/g, "");
29
+ if (!b64 || b64.length % 4 !== 0 || !BASE64_RE.test(b64)) {
30
+ return { error: "image must be valid base64", code: invalidCode };
31
+ }
32
+ const buffer = Buffer.from(b64, "base64");
33
+ if (buffer.length === 0 || buffer.toString("base64").replace(/=+$/, "") !== b64.replace(/=+$/, "")) {
34
+ return { error: "image must be valid base64", code: invalidCode };
35
+ }
36
+ const info = parsePngInfo(buffer);
37
+ if (info.error) return { error: "image must be a PNG image", code: pngCode };
38
+ return { b64, buffer, info };
39
+ }
40
+
41
+ function validateEditMask(imageB64, mask) {
42
+ if (mask == null) return { mask: null, maskBytes: 0 };
43
+ if (typeof mask !== "string" || mask.length === 0) {
44
+ return { error: "mask must be a PNG data URL or base64 string", code: "INVALID_EDIT_MASK" };
45
+ }
46
+ const maskCheck = decodePngDataUrl(mask, "INVALID_EDIT_MASK_BASE64", "INVALID_EDIT_MASK_PNG");
47
+ if (maskCheck.error) return maskCheck;
48
+ if (maskCheck.buffer.length > MAX_EDIT_MASK_BYTES) {
49
+ return { error: "mask is too large", code: "EDIT_MASK_TOO_LARGE" };
50
+ }
51
+ if (!hasPngAlphaChannel(maskCheck.info)) {
52
+ return { error: "mask PNG must include an alpha channel", code: "EDIT_MASK_NO_ALPHA" };
53
+ }
54
+ const imageCheck = decodePngDataUrl(imageB64, "INVALID_EDIT_IMAGE_BASE64", "INVALID_EDIT_IMAGE_PNG");
55
+ if (imageCheck.error) return imageCheck;
56
+ if (imageCheck.info.width !== maskCheck.info.width || imageCheck.info.height !== maskCheck.info.height) {
57
+ return { error: "mask dimensions must match image dimensions", code: "EDIT_MASK_DIMENSION_MISMATCH" };
58
+ }
59
+ return { mask: maskCheck.b64, maskBytes: maskCheck.buffer.length };
60
+ }
61
+
20
62
  export function registerEditRoutes(app, ctx) {
21
63
  app.post("/api/edit", async (req, res) => {
22
64
  const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
@@ -28,12 +70,15 @@ export function registerEditRoutes(app, ctx) {
28
70
  const {
29
71
  prompt,
30
72
  image: imageB64,
73
+ mask: rawMask,
31
74
  quality: rawQuality = "medium",
32
75
  size = "1024x1024",
33
76
  moderation = "low",
34
77
  provider = "oauth",
35
78
  mode: promptMode = "auto",
36
79
  model: rawModel,
80
+ reasoningEffort: rawReasoningEffort,
81
+ webSearchEnabled: rawWebSearchEnabled = true,
37
82
  } = req.body;
38
83
  const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
39
84
  const modelCheck = normalizeImageModel(ctx, rawModel);
@@ -44,6 +89,15 @@ export function registerEditRoutes(app, ctx) {
44
89
  return res.status(modelCheck.status).json({ error: modelCheck.error, code: modelCheck.code });
45
90
  }
46
91
  const imageModel = modelCheck.model;
92
+ const reasoningCheck = normalizeReasoningEffort(ctx, rawReasoningEffort);
93
+ if (reasoningCheck.error) {
94
+ finishStatus = "error";
95
+ finishHttpStatus = reasoningCheck.status;
96
+ finishErrorCode = reasoningCheck.code;
97
+ return res.status(reasoningCheck.status).json({ error: reasoningCheck.error, code: reasoningCheck.code });
98
+ }
99
+ const reasoningEffort = reasoningCheck.effort;
100
+ const webSearchEnabled = rawWebSearchEnabled !== false;
47
101
  const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : null;
48
102
  const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
49
103
 
@@ -57,7 +111,6 @@ export function registerEditRoutes(app, ctx) {
57
111
  quality,
58
112
  model: imageModel,
59
113
  size,
60
- styleSheetApplied: false,
61
114
  },
62
115
  });
63
116
 
@@ -67,6 +120,13 @@ export function registerEditRoutes(app, ctx) {
67
120
  finishErrorCode = "INVALID_EDIT_INPUT";
68
121
  return res.status(400).json({ error: "Prompt and image are required" });
69
122
  }
123
+ const maskCheck = validateEditMask(imageB64, rawMask);
124
+ if (maskCheck.error) {
125
+ finishStatus = "error";
126
+ finishHttpStatus = 400;
127
+ finishErrorCode = maskCheck.code;
128
+ return res.status(400).json({ error: maskCheck.error, code: maskCheck.code });
129
+ }
70
130
  const moderationCheck = validateModeration(ctx, moderation);
71
131
  if (moderationCheck.error) {
72
132
  finishStatus = "error";
@@ -81,21 +141,6 @@ export function registerEditRoutes(app, ctx) {
81
141
  return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
82
142
  }
83
143
 
84
- let effectivePrompt = prompt;
85
- let styleSheetApplied = null;
86
- if (sessionId) {
87
- try {
88
- const data = getStyleSheet(sessionId);
89
- if (data && data.enabled && data.styleSheet) {
90
- const prefix = renderStyleSheetPrefix(data.styleSheet);
91
- if (prefix) {
92
- effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
93
- styleSheetApplied = data.styleSheet;
94
- }
95
- }
96
- } catch {}
97
- }
98
-
99
144
  logEvent("edit", "request", {
100
145
  requestId,
101
146
  client: req.get("x-ima2-client") || "ui",
@@ -107,12 +152,14 @@ export function registerEditRoutes(app, ctx) {
107
152
  sessionId,
108
153
  promptChars: typeof prompt === "string" ? prompt.length : 0,
109
154
  promptMode: normalizedPromptMode,
110
- styleSheetApplied: !!styleSheetApplied,
155
+ webSearchEnabled,
111
156
  inputImageChars: typeof imageB64 === "string" ? imageB64.length : 0,
157
+ maskPresent: Boolean(maskCheck.mask),
158
+ maskBytes: maskCheck.maskBytes ?? 0,
112
159
  });
113
160
  const startTime = Date.now();
114
- const { b64: resultB64, usage, revisedPrompt } = await editViaOAuth(
115
- effectivePrompt,
161
+ const { b64: resultB64, usage, revisedPrompt, webSearchCalls = 0 } = await editViaOAuth(
162
+ prompt,
116
163
  imageB64,
117
164
  quality,
118
165
  size,
@@ -120,7 +167,7 @@ export function registerEditRoutes(app, ctx) {
120
167
  normalizedPromptMode,
121
168
  ctx,
122
169
  requestId,
123
- { model: imageModel },
170
+ { model: imageModel, reasoningEffort, webSearchEnabled, mask: maskCheck.mask },
124
171
  );
125
172
 
126
173
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -132,8 +179,6 @@ export function registerEditRoutes(app, ctx) {
132
179
  userPrompt: prompt,
133
180
  revisedPrompt: revisedPrompt || null,
134
181
  promptMode: normalizedPromptMode,
135
- effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
136
- styleSheetApplied: styleSheetApplied || undefined,
137
182
  quality,
138
183
  size,
139
184
  moderation,
@@ -143,7 +188,8 @@ export function registerEditRoutes(app, ctx) {
143
188
  kind: "edit",
144
189
  createdAt: Date.now(),
145
190
  usage: usage || null,
146
- webSearchCalls: 0,
191
+ webSearchCalls,
192
+ webSearchEnabled,
147
193
  };
148
194
  await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
149
195
  finishHttpStatus = 200;
@@ -166,6 +212,8 @@ export function registerEditRoutes(app, ctx) {
166
212
  warnings: qualityWarnings,
167
213
  revisedPrompt: revisedPrompt || null,
168
214
  promptMode: normalizedPromptMode,
215
+ webSearchCalls,
216
+ webSearchEnabled,
169
217
  });
170
218
  } catch (err) {
171
219
  const fallbackCode = err.code || classifyUpstreamError(err.message);
@@ -4,12 +4,10 @@ import { randomBytes } from "crypto";
4
4
  import { validateAndNormalizeRefs } from "../lib/refs.js";
5
5
  import { classifyUpstreamError } from "../lib/errorClassify.js";
6
6
  import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
7
- import { normalizeImageModel } from "../lib/imageModels.js";
7
+ import { normalizeImageModel, normalizeReasoningEffort } from "../lib/imageModels.js";
8
8
  import { generateViaOAuth } from "../lib/oauthProxy.js";
9
9
  import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
10
10
  import { startJob, finishJob } from "../lib/inflight.js";
11
- import { getStyleSheet } from "../lib/sessionStore.js";
12
- import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
13
11
  import { logEvent, logError } from "../lib/logger.js";
14
12
  import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
15
13
 
@@ -41,6 +39,8 @@ export function registerGenerateRoutes(app, ctx) {
41
39
  references = [],
42
40
  mode: promptMode = "auto",
43
41
  model: rawModel,
42
+ reasoningEffort: rawReasoningEffort,
43
+ webSearchEnabled: rawWebSearchEnabled = true,
44
44
  } = req.body;
45
45
  const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
46
46
  const modelCheck = normalizeImageModel(ctx, rawModel);
@@ -51,6 +51,15 @@ export function registerGenerateRoutes(app, ctx) {
51
51
  return res.status(modelCheck.status).json({ error: modelCheck.error, code: modelCheck.code });
52
52
  }
53
53
  const imageModel = modelCheck.model;
54
+ const reasoningCheck = normalizeReasoningEffort(ctx, rawReasoningEffort);
55
+ if (reasoningCheck.error) {
56
+ finishStatus = "error";
57
+ finishHttpStatus = reasoningCheck.status;
58
+ finishErrorCode = reasoningCheck.code;
59
+ return res.status(reasoningCheck.status).json({ error: reasoningCheck.error, code: reasoningCheck.code });
60
+ }
61
+ const reasoningEffort = reasoningCheck.effort;
62
+ const webSearchEnabled = rawWebSearchEnabled !== false;
54
63
  const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
55
64
 
56
65
  if (!prompt) return res.status(400).json({ error: "Prompt is required" });
@@ -58,25 +67,10 @@ export function registerGenerateRoutes(app, ctx) {
58
67
  if (moderationCheck.error) return res.status(400).json({ error: moderationCheck.error });
59
68
  const count = Math.min(Math.max(parseInt(n) || 1, 1), 8);
60
69
 
61
- let effectivePrompt = prompt;
62
- let styleSheetApplied = null;
63
- if (sessionId) {
64
- try {
65
- const data = getStyleSheet(sessionId);
66
- if (data && data.enabled && data.styleSheet) {
67
- const prefix = renderStyleSheetPrefix(data.styleSheet);
68
- if (prefix) {
69
- effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
70
- styleSheetApplied = data.styleSheet;
71
- }
72
- }
73
- } catch {}
74
- }
75
-
76
70
  startJob({
77
71
  requestId,
78
72
  kind: "classic",
79
- prompt: effectivePrompt,
73
+ prompt,
80
74
  meta: {
81
75
  kind: "classic",
82
76
  sessionId,
@@ -86,7 +80,6 @@ export function registerGenerateRoutes(app, ctx) {
86
80
  model: imageModel,
87
81
  size,
88
82
  n: count,
89
- styleSheetApplied: !!styleSheetApplied,
90
83
  },
91
84
  });
92
85
 
@@ -124,7 +117,7 @@ export function registerGenerateRoutes(app, ctx) {
124
117
  clientNodeId,
125
118
  promptChars: typeof prompt === "string" ? prompt.length : 0,
126
119
  promptMode: normalizedPromptMode,
127
- styleSheetApplied: !!styleSheetApplied,
120
+ webSearchEnabled,
128
121
  });
129
122
  const startTime = Date.now();
130
123
 
@@ -138,7 +131,7 @@ export function registerGenerateRoutes(app, ctx) {
138
131
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
139
132
  try {
140
133
  const r = await generateViaOAuth(
141
- effectivePrompt,
134
+ prompt,
142
135
  quality,
143
136
  size,
144
137
  moderation,
@@ -146,7 +139,7 @@ export function registerGenerateRoutes(app, ctx) {
146
139
  requestId,
147
140
  normalizedPromptMode,
148
141
  ctx,
149
- { model: imageModel },
142
+ { model: imageModel, reasoningEffort, webSearchEnabled },
150
143
  );
151
144
  if (r.b64) return r;
152
145
  lastErr = new Error("Empty response (safety refusal)");
@@ -180,8 +173,6 @@ export function registerGenerateRoutes(app, ctx) {
180
173
  userPrompt: prompt,
181
174
  revisedPrompt: r.value.revisedPrompt || null,
182
175
  promptMode: normalizedPromptMode,
183
- effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
184
- styleSheetApplied: styleSheetApplied || undefined,
185
176
  quality,
186
177
  size,
187
178
  format,
@@ -191,6 +182,7 @@ export function registerGenerateRoutes(app, ctx) {
191
182
  createdAt: Date.now(),
192
183
  usage: r.value.usage || null,
193
184
  webSearchCalls: r.value.webSearchCalls || 0,
185
+ webSearchEnabled,
194
186
  refsCount: refCheck.refs.length,
195
187
  };
196
188
  const rawBuffer = Buffer.from(r.value.b64, "base64");
@@ -263,6 +255,7 @@ export function registerGenerateRoutes(app, ctx) {
263
255
  warnings: qualityWarnings,
264
256
  revisedPrompt: firstRevised,
265
257
  promptMode: normalizedPromptMode,
258
+ webSearchEnabled,
266
259
  };
267
260
 
268
261
  if (count === 1) {
package/routes/history.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { listHistoryRows } from "../lib/historyList.js";
2
- import { trashAsset, restoreAsset } from "../lib/assetLifecycle.js";
2
+ import { trashAsset, restoreAsset, deleteAssetPermanent } from "../lib/assetLifecycle.js";
3
3
  import { getSessionTitleMap } from "../lib/sessionStore.js";
4
4
  import { logError, logEvent } from "../lib/logger.js";
5
5
  import { getDb } from "../lib/db.js";
@@ -88,6 +88,16 @@ export function registerHistoryRoutes(app, ctx) {
88
88
  }
89
89
  });
90
90
 
91
+ app.delete("/api/history/:filename/permanent", async (req, res) => {
92
+ try {
93
+ const filename = decodeURIComponent(req.params.filename);
94
+ const result = await deleteAssetPermanent(ctx.rootDir, filename);
95
+ res.json(result);
96
+ } catch (err) {
97
+ res.status(err.status || 500).json({ error: err.message, code: err.code });
98
+ }
99
+ });
100
+
91
101
  app.delete("/api/history/:filename", async (req, res) => {
92
102
  try {
93
103
  const filename = decodeURIComponent(req.params.filename);
package/routes/index.js CHANGED
@@ -4,20 +4,30 @@ import { registerSessionRoutes } from "./sessions.js";
4
4
  import { registerEditRoutes } from "./edit.js";
5
5
  import { registerNodeRoutes } from "./nodes.js";
6
6
  import { registerGenerateRoutes } from "./generate.js";
7
+ import { registerMultimodeRoutes } from "./multimode.js";
7
8
  import { registerStorageRoutes } from "./storage.js";
8
9
  import { registerCardNewsRoutes } from "./cardNews.js";
9
10
  import { registerMetadataRoutes } from "./metadata.js";
10
11
  import { registerPromptRoutes } from "./prompts.js";
12
+ import { registerPromptImportRoutes } from "./promptImport.js";
13
+ import { registerAnnotationRoutes } from "./annotations.js";
14
+ import { registerCanvasVersionRoutes } from "./canvasVersions.js";
15
+ import { registerComfyRoutes } from "./comfy.js";
11
16
 
12
17
  export function configureRoutes(app, ctx) {
13
18
  registerHealthRoutes(app, ctx);
14
19
  registerStorageRoutes(app, ctx);
15
20
  registerMetadataRoutes(app, ctx);
16
21
  registerHistoryRoutes(app, ctx);
22
+ registerAnnotationRoutes(app, ctx);
23
+ registerCanvasVersionRoutes(app, ctx);
24
+ registerComfyRoutes(app, ctx);
17
25
  registerSessionRoutes(app, ctx);
18
26
  registerEditRoutes(app, ctx);
19
27
  registerNodeRoutes(app, ctx);
20
28
  if (ctx.config.features.cardNews) registerCardNewsRoutes(app, ctx);
29
+ registerMultimodeRoutes(app, ctx);
21
30
  registerGenerateRoutes(app, ctx);
22
31
  registerPromptRoutes(app, ctx);
32
+ registerPromptImportRoutes(app, ctx);
23
33
  }
@@ -0,0 +1,281 @@
1
+ import { mkdir, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { validateAndNormalizeRefs } from "../lib/refs.js";
5
+ import { classifyUpstreamError } from "../lib/errorClassify.js";
6
+ import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
7
+ import { normalizeImageModel, normalizeReasoningEffort } from "../lib/imageModels.js";
8
+ import { generateMultimodeViaOAuth } from "../lib/oauthProxy.js";
9
+ import { startJob, finishJob } from "../lib/inflight.js";
10
+ import { logEvent, logError } from "../lib/logger.js";
11
+ import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
12
+
13
+ function sendSse(res, event, data) {
14
+ res.write(`event: ${event}\n`);
15
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
16
+ }
17
+
18
+ function validateModeration(ctx, moderation) {
19
+ if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
20
+ return { error: "moderation must be one of: auto, low" };
21
+ }
22
+ return { moderation };
23
+ }
24
+
25
+ function normalizeMaxImages(value) {
26
+ return Math.min(8, Math.max(1, Math.trunc(Number(value) || 1)));
27
+ }
28
+
29
+ function sequenceStatus(returned, requested) {
30
+ if (returned <= 0) return "empty";
31
+ if (returned < requested) return "partial";
32
+ return "complete";
33
+ }
34
+
35
+ export function registerMultimodeRoutes(app, ctx) {
36
+ app.post("/api/generate/multimode", async (req, res) => {
37
+ const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
38
+ let finishStatus = "completed";
39
+ let finishHttpStatus = 200;
40
+ let finishErrorCode;
41
+ let finishMeta = {};
42
+
43
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
44
+ res.setHeader("Cache-Control", "no-cache, no-transform");
45
+ res.setHeader("Connection", "keep-alive");
46
+ res.flushHeaders?.();
47
+
48
+ try {
49
+ const {
50
+ prompt,
51
+ quality: rawQuality = "medium",
52
+ size = "1024x1024",
53
+ format = "png",
54
+ moderation = "low",
55
+ provider = "auto",
56
+ references = [],
57
+ mode: promptMode = "auto",
58
+ model: rawModel,
59
+ reasoningEffort: rawReasoningEffort,
60
+ webSearchEnabled: rawWebSearchEnabled = true,
61
+ } = req.body;
62
+ const maxImages = normalizeMaxImages(req.body?.maxImages);
63
+ const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
64
+ const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
65
+ const modelCheck = normalizeImageModel(ctx, rawModel);
66
+ if (modelCheck.error) {
67
+ finishStatus = "error";
68
+ finishHttpStatus = modelCheck.status;
69
+ finishErrorCode = modelCheck.code;
70
+ sendSse(res, "error", { error: modelCheck.error, code: modelCheck.code, status: modelCheck.status, requestId });
71
+ return;
72
+ }
73
+ const imageModel = modelCheck.model;
74
+ const reasoningCheck = normalizeReasoningEffort(ctx, rawReasoningEffort);
75
+ if (reasoningCheck.error) {
76
+ finishStatus = "error";
77
+ finishHttpStatus = reasoningCheck.status;
78
+ finishErrorCode = reasoningCheck.code;
79
+ sendSse(res, "error", { error: reasoningCheck.error, code: reasoningCheck.code, status: reasoningCheck.status, requestId });
80
+ return;
81
+ }
82
+ const reasoningEffort = reasoningCheck.effort;
83
+ const webSearchEnabled = rawWebSearchEnabled !== false;
84
+ if (!prompt) {
85
+ finishStatus = "error";
86
+ finishHttpStatus = 400;
87
+ finishErrorCode = "PROMPT_REQUIRED";
88
+ sendSse(res, "error", { error: "Prompt is required", code: finishErrorCode, status: 400, requestId });
89
+ return;
90
+ }
91
+ const moderationCheck = validateModeration(ctx, moderation);
92
+ if (moderationCheck.error) {
93
+ finishStatus = "error";
94
+ finishHttpStatus = 400;
95
+ finishErrorCode = "INVALID_MODERATION";
96
+ sendSse(res, "error", { error: moderationCheck.error, code: finishErrorCode, status: 400, requestId });
97
+ return;
98
+ }
99
+ if (provider === "api") {
100
+ finishStatus = "error";
101
+ finishHttpStatus = 403;
102
+ finishErrorCode = "APIKEY_DISABLED";
103
+ sendSse(res, "error", {
104
+ error: "API key provider is disabled. Use OAuth (Codex login).",
105
+ code: finishErrorCode,
106
+ status: 403,
107
+ requestId,
108
+ });
109
+ return;
110
+ }
111
+
112
+ const refCheck = validateAndNormalizeRefs(references);
113
+ if (refCheck.error) {
114
+ finishStatus = "error";
115
+ finishHttpStatus = 400;
116
+ finishErrorCode = refCheck.code;
117
+ sendSse(res, "error", { error: refCheck.error, code: refCheck.code, status: 400, requestId });
118
+ return;
119
+ }
120
+
121
+ startJob({
122
+ requestId,
123
+ kind: "multimode",
124
+ prompt,
125
+ meta: { kind: "multimode", quality, model: imageModel, size, maxImages },
126
+ });
127
+
128
+ logEvent("multimode", "request", {
129
+ requestId,
130
+ quality,
131
+ model: imageModel,
132
+ size,
133
+ moderation,
134
+ maxImages,
135
+ refs: refCheck.refs.length,
136
+ promptChars: typeof prompt === "string" ? prompt.length : 0,
137
+ webSearchEnabled,
138
+ });
139
+
140
+ const startTime = Date.now();
141
+ const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
142
+ const mime = mimeMap[format] || "image/png";
143
+ const sequenceId = `seq_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
144
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
145
+
146
+ sendSse(res, "phase", { phase: "streaming", requestId, sequenceId, maxImages });
147
+ const generated = await generateMultimodeViaOAuth(
148
+ prompt,
149
+ quality,
150
+ size,
151
+ moderation,
152
+ refCheck.refDetails || refCheck.refs,
153
+ requestId,
154
+ normalizedPromptMode,
155
+ ctx,
156
+ {
157
+ model: imageModel,
158
+ maxImages,
159
+ reasoningEffort,
160
+ webSearchEnabled,
161
+ onPartialImage: (partial) =>
162
+ sendSse(res, "partial", {
163
+ image: `data:${mime};base64,${partial.b64}`,
164
+ requestId,
165
+ sequenceId,
166
+ index: partial.index,
167
+ }),
168
+ },
169
+ );
170
+
171
+ const returned = generated.images.length;
172
+ const status = sequenceStatus(returned, maxImages);
173
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
174
+ const images = [];
175
+
176
+ for (const [index, image] of generated.images.entries()) {
177
+ const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
178
+ const filename = `${Date.now()}_${rand}_multimode_${index}.${format}`;
179
+ const meta = {
180
+ kind: "multimode-image",
181
+ generationStrategy: "one-call-text-sequence",
182
+ sequenceId,
183
+ sequenceIndex: index + 1,
184
+ sequenceTotalRequested: maxImages,
185
+ sequenceTotalReturned: returned,
186
+ sequenceStatus: status,
187
+ stageLabel: String.fromCharCode(65 + index),
188
+ requestId,
189
+ prompt,
190
+ userPrompt: prompt,
191
+ revisedPrompt: image.revisedPrompt || null,
192
+ promptMode: normalizedPromptMode,
193
+ quality,
194
+ size,
195
+ format,
196
+ moderation,
197
+ model: imageModel,
198
+ provider: "oauth",
199
+ createdAt: Date.now(),
200
+ usage: generated.usage || null,
201
+ webSearchCalls: generated.webSearchCalls || 0,
202
+ webSearchEnabled,
203
+ refsCount: refCheck.refs.length,
204
+ };
205
+ const rawBuffer = Buffer.from(image.b64, "base64");
206
+ const embedded = await embedImageMetadataBestEffort(rawBuffer, format, meta, {
207
+ version: ctx.packageVersion,
208
+ });
209
+ await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
210
+ await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
211
+ const item = {
212
+ image: `data:${mime};base64,${image.b64}`,
213
+ filename,
214
+ revisedPrompt: image.revisedPrompt || null,
215
+ sequenceId,
216
+ sequenceIndex: index + 1,
217
+ sequenceTotalRequested: maxImages,
218
+ sequenceTotalReturned: returned,
219
+ sequenceStatus: status,
220
+ };
221
+ images.push(item);
222
+ sendSse(res, "image", item);
223
+ }
224
+
225
+ finishMeta = { sequenceId, imageCount: returned, maxImages, status };
226
+ finishHttpStatus = 200;
227
+ sendSse(res, "done", {
228
+ ok: true,
229
+ requestId,
230
+ sequenceId,
231
+ requested: maxImages,
232
+ returned,
233
+ status,
234
+ elapsed,
235
+ images,
236
+ provider: "oauth",
237
+ quality,
238
+ size,
239
+ moderation,
240
+ model: imageModel,
241
+ usage: generated.usage || null,
242
+ webSearchCalls: generated.webSearchCalls || 0,
243
+ webSearchEnabled,
244
+ warnings: qualityWarnings,
245
+ extraIgnored: generated.extraIgnored || 0,
246
+ promptMode: normalizedPromptMode,
247
+ });
248
+ logEvent("multimode", "saved", {
249
+ requestId,
250
+ sequenceId,
251
+ imageCount: returned,
252
+ maxImages,
253
+ status,
254
+ elapsedMs: Date.now() - startTime,
255
+ });
256
+ } catch (err) {
257
+ const fallbackCode = err.code || classifyUpstreamError(err.message);
258
+ finishStatus = "error";
259
+ finishHttpStatus = err.status || 500;
260
+ finishErrorCode = fallbackCode || "MULTIMODE_GENERATE_FAILED";
261
+ logError("multimode", "error", err, { requestId, code: finishErrorCode });
262
+ sendSse(res, "error", {
263
+ error: err.message,
264
+ code: finishErrorCode,
265
+ status: finishHttpStatus,
266
+ requestId,
267
+ upstreamCode: err.upstreamCode || null,
268
+ upstreamType: err.upstreamType || null,
269
+ upstreamParam: err.upstreamParam || null,
270
+ });
271
+ } finally {
272
+ finishJob(requestId, {
273
+ status: finishStatus,
274
+ httpStatus: finishHttpStatus,
275
+ errorCode: finishErrorCode,
276
+ meta: finishMeta,
277
+ });
278
+ res.end();
279
+ }
280
+ });
281
+ }