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.
- package/.env.example +5 -0
- package/README.md +3 -0
- package/config.js +58 -0
- package/docs/FAQ.ko.md +20 -0
- package/docs/FAQ.md +20 -0
- package/docs/README.ko.md +3 -0
- package/docs/README.zh-CN.md +3 -0
- package/integrations/comfyui/ima2_gen_bridge/README.md +88 -0
- package/integrations/comfyui/ima2_gen_bridge/__init__.py +3 -0
- package/integrations/comfyui/ima2_gen_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/integrations/comfyui/ima2_gen_bridge/__pycache__/nodes.cpython-313.pyc +0 -0
- package/integrations/comfyui/ima2_gen_bridge/nodes.py +238 -0
- package/lib/assetLifecycle.js +21 -0
- package/lib/canvasVersionStore.js +181 -0
- package/lib/cardNewsPlannerClient.js +4 -2
- package/lib/comfyBridge.js +214 -0
- package/lib/db.js +14 -0
- package/lib/historyList.js +9 -0
- package/lib/imageMetadata.js +4 -0
- package/lib/imageModels.js +20 -0
- package/lib/oauthProxy.js +341 -32
- package/lib/pngInfo.js +26 -0
- package/lib/promptImport/errors.js +16 -0
- package/lib/promptImport/githubSource.js +205 -0
- package/lib/promptImport/parsePromptCandidates.js +140 -0
- package/package.json +3 -2
- package/routes/annotations.js +95 -0
- package/routes/canvasVersions.js +64 -0
- package/routes/comfy.js +39 -0
- package/routes/edit.js +74 -26
- package/routes/generate.js +18 -25
- package/routes/history.js +11 -1
- package/routes/index.js +10 -0
- package/routes/multimode.js +281 -0
- package/routes/nodes.js +28 -26
- package/routes/promptImport.js +175 -0
- package/ui/dist/assets/index-DARPdT4Q.css +1 -0
- package/ui/dist/assets/index-ht80GMq4.js +31 -0
- package/ui/dist/assets/index-ht80GMq4.js.map +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-0SyTGr-u.js +0 -25
- package/ui/dist/assets/index-0SyTGr-u.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/routes/generate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|