@logictan/dsh-imagegen 2.0.0 → 2.0.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.
package/lib/index.js ADDED
@@ -0,0 +1,3326 @@
1
+ import * as settingsModule from "@deepseek-ai/dsh-settings";
2
+ import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import "@deepseek-ai/cordis";
5
+ import { randomUUID } from "node:crypto";
6
+ import { defineTool } from "@deepseek-ai/dsh-tools";
7
+ //#region src/settings-compat.ts
8
+ const compatModule = settingsModule;
9
+ /** Strip volatile markers for the pre-0.1.7 settings providers. */
10
+ function plainSettingsSchema(schema) {
11
+ const clone = schema.toJSON();
12
+ const seen = /* @__PURE__ */ new WeakSet();
13
+ const walk = (node) => {
14
+ if (typeof node !== "object" || node === null || seen.has(node)) return;
15
+ seen.add(node);
16
+ const record = node;
17
+ const meta = record.meta;
18
+ if (typeof meta === "object" && meta !== null) delete meta.volatile;
19
+ if (typeof record.dict === "object" && record.dict !== null) for (const child of Object.values(record.dict)) walk(child);
20
+ walk(record.inner);
21
+ walk(record.sKey);
22
+ if (Array.isArray(record.list)) for (const child of record.list) walk(child);
23
+ };
24
+ walk(clone);
25
+ return clone;
26
+ }
27
+ /** The loader entry id of the plugin instance owning `ctx`, when available. */
28
+ function entryNamespaceOf(ctx) {
29
+ const compat = ctx;
30
+ const entry = compat.fiber?.entry;
31
+ const candidates = [
32
+ entry?.options?.id,
33
+ entry?.id,
34
+ compat.loader?.locate?.(compat.fiber)
35
+ ];
36
+ for (const candidate of candidates) if (typeof candidate === "string" && candidate !== "") return candidate;
37
+ }
38
+ /** Brand namespaces where the installed settings package still exposes it. */
39
+ function settingsNamespaceCompat(value) {
40
+ return compatModule.settingsNamespace?.(value) ?? value;
41
+ }
42
+ /**
43
+ * Register an optional settings section across the rc.7, alpha.2 and managed
44
+ * forms APIs. The first two expose an installer; 0.1.7 derives forms directly
45
+ * from the plugin's volatile Config and only needs the custom-page policy.
46
+ */
47
+ function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
48
+ const legacyInstaller = compatModule.installSettingsSection;
49
+ if (legacyInstaller !== void 0) {
50
+ legacyInstaller(ctx, ns, plainSettingsSchema(schema), entry, hooks);
51
+ return;
52
+ }
53
+ ctx.inject(["settings"], (sctx) => {
54
+ const provider = sctx.get("settings");
55
+ if (provider.installSection !== void 0) {
56
+ provider.installSection(ctx, ns, plainSettingsSchema(schema), entry, hooks);
57
+ return;
58
+ }
59
+ if (provider.configure === void 0) throw new TypeError("dsh-settings does not expose installSection or configure");
60
+ sctx.effect(() => {
61
+ const dispose = provider.configure?.({ auto: false }, ctx.fiber);
62
+ return typeof dispose === "function" ? dispose : () => {};
63
+ });
64
+ const namespace = entryNamespaceOf(ctx);
65
+ if (namespace !== void 0) {
66
+ const settingsEvents = sctx;
67
+ sctx.effect(() => settingsEvents.on("settings/document-updated", (updated) => {
68
+ if (String(updated) === namespace) hooks.onChange();
69
+ }));
70
+ }
71
+ });
72
+ }
73
+ //#endregion
74
+ //#region src/protocol.ts
75
+ /**
76
+ * Wire contract shared by the host and client halves of dsh-imagegen: the
77
+ * settings namespace, the route paths, and the generate payload/result shapes.
78
+ * Pure types + constants 鈥?safe for the client bundle to inline.
79
+ */
80
+ /** Settings namespace this plugin owns (host settings seam + bridge). */
81
+ const IMAGEGEN_SETTINGS_NAMESPACE = "imagegen";
82
+ /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
83
+ const SETTINGS_API = {
84
+ describe: "/api/dsh-imagegen/settings/describe",
85
+ mutate: "/api/dsh-imagegen/settings/mutate"
86
+ };
87
+ /** Subscription account login and status routes. */
88
+ const SUBSCRIPTION_API = {
89
+ status: "/api/dsh-imagegen/subscription/status",
90
+ login: "/api/dsh-imagegen/subscription/login"
91
+ };
92
+ /** Official/subscription-backed image providers. */
93
+ const SUBSCRIPTION_PROVIDERS = [
94
+ "chatgpt-sub",
95
+ "grok-sub",
96
+ "google-sub",
97
+ "openrouter-sub"
98
+ ];
99
+ /** Whether an endpoint URL explicitly names an OpenAI-compatible chat route. */
100
+ function isChatCompletionsUrl(value) {
101
+ const trimmed = value.trim();
102
+ if (trimmed === "") return false;
103
+ try {
104
+ return /\/chat\/completions\/?$/.test(new URL(trimmed).pathname);
105
+ } catch {
106
+ return /\/chat\/completions\/?$/.test(trimmed.split(/[?#]/, 1)[0] ?? "");
107
+ }
108
+ }
109
+ /** Whether a stored value is one of the accepted protocol preferences. */
110
+ function isChannelProtocolPreference(value) {
111
+ return value === "auto" || value === "images" || value === "chat-completions";
112
+ }
113
+ /** Resolve a channel preference without changing legacy behavior by default. */
114
+ function resolveChannelProtocol(apiUrl, preference = "auto") {
115
+ if (preference === "images" || preference === "chat-completions") return preference;
116
+ return isChatCompletionsUrl(apiUrl) ? "chat-completions" : "images";
117
+ }
118
+ /** Subscription channels with a fixed image model. */
119
+ const DEFAULT_SUBSCRIPTION_MODELS = {
120
+ "chatgpt-sub": "gpt-image-2.5-flare",
121
+ "grok-sub": "grok-imagine-image-2.0",
122
+ "google-sub": "gemini-3-pro-image",
123
+ "openrouter-sub": "google/gemini-3-pro-image"
124
+ };
125
+ /** The image-generation proxy route. */
126
+ const GENERATE_API = "/api/dsh-imagegen/generate";
127
+ /** Host-mediated OpenAI-compatible prompt enhancement endpoints. */
128
+ const PROMPT_ENHANCE_API = {
129
+ models: "/api/dsh-imagegen/prompt-enhance/models",
130
+ enhance: "/api/dsh-imagegen/prompt-enhance"
131
+ };
132
+ /** Host-mediated candidate discovery for the configured image API. */
133
+ const IMAGE_MODEL_API = { models: "/api/dsh-imagegen/image-models" };
134
+ /** Host-served built-in provider catalog (channels the user can instantiate). */
135
+ const PRESETS_API = "/api/dsh-imagegen/presets";
136
+ /** Loopback-only image reader for Agent tool-result previews. */
137
+ const AGENT_IMAGE_API = "/api/dsh-imagegen/agent-image";
138
+ /** Store the current composer image for the direct edit_image command. */
139
+ const CONVERSATION_IMAGE_API = "/api/dsh-imagegen/conversation-image";
140
+ /** Host-resident generation queue endpoints. */
141
+ const TASK_API = {
142
+ submit: "/api/dsh-imagegen/tasks/submit",
143
+ list: "/api/dsh-imagegen/tasks/list",
144
+ get: "/api/dsh-imagegen/tasks/get",
145
+ cancel: "/api/dsh-imagegen/tasks/cancel",
146
+ retry: "/api/dsh-imagegen/tasks/retry"
147
+ };
148
+ //#endregion
149
+ //#region src/model-catalog.ts
150
+ const ENTRIES = {
151
+ "gpt-image": {
152
+ label: "gpt-image",
153
+ labelZh: "GPT 图像",
154
+ known: true,
155
+ supportsEdit: true,
156
+ supportsAspectRatio: false,
157
+ qualityTiers: [
158
+ "1K",
159
+ "2K",
160
+ "4K"
161
+ ]
162
+ },
163
+ "dall-e": {
164
+ label: "DALL·E",
165
+ labelZh: "DALL·E",
166
+ known: true,
167
+ supportsEdit: true,
168
+ supportsAspectRatio: false,
169
+ qualityTiers: ["auto"]
170
+ },
171
+ grok: {
172
+ label: "grok",
173
+ labelZh: "Grok",
174
+ known: true,
175
+ supportsEdit: true,
176
+ supportsAspectRatio: true,
177
+ qualityTiers: ["1K", "2K"]
178
+ },
179
+ nanobanana: {
180
+ label: "nanobanana",
181
+ labelZh: "Nano Banana",
182
+ known: true,
183
+ supportsEdit: true,
184
+ supportsAspectRatio: true,
185
+ qualityTiers: [
186
+ "1K",
187
+ "2K",
188
+ "4K"
189
+ ]
190
+ },
191
+ seedream: {
192
+ label: "seedream",
193
+ labelZh: "Seedream",
194
+ known: true,
195
+ supportsEdit: true,
196
+ supportsAspectRatio: true,
197
+ qualityTiers: ["1K", "2K"]
198
+ },
199
+ zhipu: {
200
+ label: "GLM-Image",
201
+ labelZh: "智谱图像",
202
+ known: true,
203
+ supportsEdit: false,
204
+ supportsAspectRatio: false,
205
+ qualityTiers: ["HD"]
206
+ },
207
+ qwen: {
208
+ label: "qwen-image",
209
+ labelZh: "千问图像",
210
+ known: true,
211
+ supportsEdit: true,
212
+ supportsAspectRatio: true,
213
+ qualityTiers: ["auto"]
214
+ },
215
+ minimax: {
216
+ label: "MiniMax",
217
+ labelZh: "MiniMax 图像",
218
+ known: true,
219
+ supportsEdit: true,
220
+ supportsAspectRatio: true,
221
+ qualityTiers: ["auto"]
222
+ }
223
+ };
224
+ /** Documented upstream prompt hard limits (UTF-16 code units). The engine
225
+ * fast-fails on these before any network call and the panel counter shows
226
+ * them live; families absent here have no documented limit. */
227
+ const PROMPT_CHAR_LIMITS = { minimax: 1500 };
228
+ /** The documented prompt character limit for a model id, or null when the
229
+ * family has no known limit. Single source of truth for engine enforcement
230
+ * and the panel's live counter. */
231
+ function promptCharLimit(model) {
232
+ const { family } = describeModel(model);
233
+ return (family !== "unknown" ? PROMPT_CHAR_LIMITS[family] : void 0) ?? null;
234
+ }
235
+ /** Official Gemini image ids served by Nano Banana gateways. */
236
+ const NANOBANANA_GEMINI_IDS = /* @__PURE__ */ new Set([
237
+ "gemini-3-pro-image",
238
+ "gemini-3-pro-image-preview",
239
+ "gemini-3.1-flash-image",
240
+ "gemini-3.1-flash-image-preview",
241
+ "gemini-3.1-flash-lite-image",
242
+ "gemini-2.5-flash-image"
243
+ ]);
244
+ /** Classify one upstream model id into its request-shaping family. */
245
+ function describeModel(model) {
246
+ const id = model.trim();
247
+ if (/^gpt-image/i.test(id)) return {
248
+ family: "gpt-image",
249
+ ...ENTRIES["gpt-image"]
250
+ };
251
+ if (/^dall-e/i.test(id)) return {
252
+ family: "dall-e",
253
+ ...ENTRIES["dall-e"]
254
+ };
255
+ if (/^grok-imagine(?:-|$)/.test(id)) return {
256
+ family: "grok",
257
+ ...ENTRIES.grok
258
+ };
259
+ if (/^nanobanana/i.test(id) || NANOBANANA_GEMINI_IDS.has(id)) return {
260
+ family: "nanobanana",
261
+ ...ENTRIES.nanobanana
262
+ };
263
+ if (/^(?:doubao-)?seedream/i.test(id)) return {
264
+ family: "seedream",
265
+ ...ENTRIES.seedream
266
+ };
267
+ if (/^(?:glm-image|cogview(?:-|$))/i.test(id)) return {
268
+ family: "zhipu",
269
+ ...ENTRIES.zhipu
270
+ };
271
+ if (/^qwen-image(?:[-_.]|$)/i.test(id)) return {
272
+ family: "qwen",
273
+ ...ENTRIES.qwen
274
+ };
275
+ if (/^(?:minimax[-_/])?image-\d+/i.test(id)) return {
276
+ family: "minimax",
277
+ ...ENTRIES.minimax
278
+ };
279
+ return {
280
+ family: "unknown",
281
+ label: "unknown",
282
+ labelZh: "未知协议",
283
+ known: false,
284
+ supportsEdit: true,
285
+ supportsAspectRatio: false,
286
+ qualityTiers: []
287
+ };
288
+ }
289
+ /** Conservative fallback for providers whose /models response only has ids.
290
+ * Metadata-aware filtering lives in prompt-enhancer.ts; this catches common
291
+ * image model naming conventions without treating every unknown model as an
292
+ * image model. */
293
+ function isLikelyImageModelId(model) {
294
+ return /(?:^|[-_.])(?:image|img|diffusion|flux|cogview|imagen|seedream|nanobanana|grok-imagine|dall-e|stable-diffusion|sdxl|pixart|kolors|ideogram|midjourney|recraft|hunyuan|jimeng|wanx|hidream|playground)(?:$|[-_.])/i.test(model.trim());
295
+ }
296
+ /** The family a model id routes its request through. */
297
+ function modelFamily(model) {
298
+ return describeModel(model).family;
299
+ }
300
+ //#endregion
301
+ //#region src/prompt-enhancer.ts
302
+ /** OpenAI-compatible chat helpers used by the optional prompt-enhancement UI. */
303
+ function endpoint(base, suffix) {
304
+ return `${base.replace(/\/+$/, "")}${suffix}`;
305
+ }
306
+ function headers(apiKey) {
307
+ return {
308
+ "content-type": "application/json",
309
+ ...apiKey.trim() === "" ? {} : { authorization: `Bearer ${apiKey.trim()}` }
310
+ };
311
+ }
312
+ async function responseJson(response) {
313
+ const body = await response.json().catch(() => void 0);
314
+ if (!response.ok || body === void 0 || body === null || typeof body !== "object") {
315
+ const message = body !== null && typeof body === "object" && typeof body.error?.message === "string" ? body.error.message : `HTTP ${response.status}`;
316
+ throw new Error(message);
317
+ }
318
+ return body;
319
+ }
320
+ async function listModelRecords(config) {
321
+ if (config.apiUrl.trim() === "") throw new Error("API URL is required");
322
+ const base = config.apiUrl.trim().replace(/\/+$/, "");
323
+ let modelsUrl = endpoint(base, "/models");
324
+ if (resolveChannelProtocol(base, config.protocol) === "chat-completions" && isChatCompletionsUrl(base)) {
325
+ const url = new URL(base);
326
+ url.pathname = `${url.pathname.replace(/\/chat\/completions\/?$/, "")}/models`;
327
+ url.search = "";
328
+ url.hash = "";
329
+ modelsUrl = url.toString();
330
+ }
331
+ const body = await responseJson(await fetch(modelsUrl, { headers: headers(config.apiKey) }));
332
+ return (Array.isArray(body.data) ? body.data : []).flatMap((item) => {
333
+ if (item === null || typeof item !== "object" || typeof item.id !== "string") return [];
334
+ const id = item.id.trim();
335
+ return id === "" ? [] : [{
336
+ ...item,
337
+ id
338
+ }];
339
+ });
340
+ }
341
+ function textOf(value) {
342
+ if (typeof value === "string") return [value];
343
+ if (!Array.isArray(value)) return [];
344
+ return value.filter((item) => typeof item === "string");
345
+ }
346
+ function hasImageGenerationCapability(record) {
347
+ const capability = record.capabilities;
348
+ if (capability !== null && typeof capability === "object") {
349
+ const values = capability;
350
+ for (const key of [
351
+ "image_generation",
352
+ "imageGeneration",
353
+ "text_to_image",
354
+ "textToImage",
355
+ "image_gen"
356
+ ]) if (typeof values[key] === "boolean") return values[key];
357
+ const serialized = JSON.stringify(values).toLowerCase();
358
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image/.test(serialized)) return true;
359
+ }
360
+ const taskText = [
361
+ ...textOf(record.task),
362
+ ...textOf(record.task_type),
363
+ ...textOf(record.taskType),
364
+ ...textOf(record.type),
365
+ ...textOf(record.model_type),
366
+ ...textOf(record.modelType),
367
+ ...textOf(record.tasks),
368
+ ...textOf(record.description)
369
+ ].join(" ").toLowerCase();
370
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image|image[ _-]?gen/.test(taskText)) return true;
371
+ if (/^image(?:[ _-]?generation)?$/.test(taskText.trim())) return true;
372
+ if (/embedding|rerank|moderation|transcri|speech|audio|video|chat[ _-]?completion/.test(taskText)) return false;
373
+ for (const key of [
374
+ "output_modalities",
375
+ "outputModalities",
376
+ "supported_output_modalities"
377
+ ]) {
378
+ const modalities = textOf(record[key]).map((value) => value.toLowerCase());
379
+ if (modalities.length > 0) return modalities.includes("image");
380
+ }
381
+ }
382
+ function isImageModelRecord(record) {
383
+ return hasImageGenerationCapability(record) ?? isLikelyImageModelId(record.id);
384
+ }
385
+ /** List candidates exposed by an OpenAI-compatible endpoint. */
386
+ async function listOpenAIModels(config) {
387
+ return [...new Set((await listModelRecords(config)).map((record) => record.id))].sort((a, b) => a.localeCompare(b));
388
+ }
389
+ /** List only models that advertise or conventionally represent image generation. */
390
+ async function listImageModels(config) {
391
+ return [...new Set((await listModelRecords(config)).filter(isImageModelRecord).map((record) => record.id))].sort((a, b) => a.localeCompare(b));
392
+ }
393
+ /** List chat models exposed by an OpenAI-compatible endpoint. */
394
+ async function listPromptModels(config) {
395
+ return listOpenAIModels(config);
396
+ }
397
+ /** Remove reasoning-model artifacts from a chat model's visible content:
398
+ * complete `<think>…</think>` blocks first, then anything from a dangling
399
+ * unclosed `<think>` to the end of the text. Reasoning models served through
400
+ * OpenAI-compatible endpoints (MiniMax M3, DeepSeek R1, Qwen QVQ, …) inline
401
+ * these blocks in `message.content`; leaking them into the prompt box both
402
+ * pollutes the prompt and can push it past image models' length limits. */
403
+ function stripReasoning(text) {
404
+ const withoutClosed = text.replace(/<think>[\s\S]*?<\/think>/gi, "");
405
+ const dangling = /<think>/i.exec(withoutClosed);
406
+ return (dangling === null ? withoutClosed : withoutClosed.slice(0, dangling.index)).trim();
407
+ }
408
+ /**
409
+ * Run one chat completion and return the cleaned visible answer.
410
+ *
411
+ * Shared by prompt enhancement, the canvas light-tier skill runner and the
412
+ * layer analyzer: it owns the endpoint, auth, error shaping (an upstream
413
+ * `error.message` is surfaced verbatim) and the reasoning-strip step, so every
414
+ * caller sees the same behaviour.
415
+ * @param config - endpoint, secret and model.
416
+ * @param options - prompt parts plus optional sampling controls.
417
+ * @returns the model's visible answer, with reasoning artifacts removed.
418
+ */
419
+ async function chatComplete(config, options) {
420
+ if (config.apiUrl.trim() === "" || config.model.trim() === "") throw new Error("chat model is not configured (left sidebar > Image settings > Prompt enhancement)");
421
+ const body = await responseJson(await fetch(endpoint(config.apiUrl, "/chat/completions"), {
422
+ method: "POST",
423
+ headers: headers(config.apiKey),
424
+ body: JSON.stringify({
425
+ model: config.model.trim(),
426
+ temperature: options.temperature ?? .4,
427
+ ...options.maxTokens === void 0 ? {} : { max_tokens: options.maxTokens },
428
+ messages: [{
429
+ role: "system",
430
+ content: options.system
431
+ }, {
432
+ role: "user",
433
+ content: typeof options.content === "string" ? options.content : options.content
434
+ }]
435
+ }),
436
+ ...options.signal === void 0 ? {} : { signal: options.signal }
437
+ }));
438
+ const choices = Array.isArray(body.choices) ? body.choices : [];
439
+ const content = choices[0] !== null && typeof choices[0] === "object" ? choices[0].message?.content : void 0;
440
+ if (typeof content !== "string" || content.trim() === "") throw new Error("chat model returned an empty answer");
441
+ const answer = stripReasoning(content);
442
+ if (answer === "") throw new Error("chat model returned only reasoning content (empty <think> payload)");
443
+ return answer;
444
+ }
445
+ /** Expand a concise image request into a production-ready image prompt. */
446
+ async function enhancePrompt(config, prompt) {
447
+ return chatComplete(config, {
448
+ temperature: .7,
449
+ system: "You are an expert image-prompt editor. Expand the user request into one vivid, specific image-generation prompt. Preserve intent and language. Add only useful visual detail: subject, composition, lighting, materials, color, camera/style and quality. Return only the finished prompt, with no preface or markdown.",
450
+ content: prompt
451
+ });
452
+ }
453
+ //#endregion
454
+ //#region src/image-models.ts
455
+ /**
456
+ * Image-model configuration shared by the host, panel, and Agent tools.
457
+ * `/models` exposes candidates only: the configured list is the explicit
458
+ * allow-list because OpenAI-compatible gateways rarely advertise modalities.
459
+ */
460
+ const DEFAULT_IMAGE_MODELS = [
461
+ "gpt-image-2.5",
462
+ "gpt-image-2",
463
+ "grok-imagine-image",
464
+ "nanobanana2",
465
+ "nanobanana2-lite",
466
+ "nanobanana-pro",
467
+ "seedream-5.0-pro",
468
+ "glm-image"
469
+ ];
470
+ /** Normalize user-entered model identifiers and retain a usable legacy default. */
471
+ function normalizeImageModels(value) {
472
+ const candidates = Array.isArray(value) ? value : [];
473
+ const unique = /* @__PURE__ */ new Set();
474
+ for (const candidate of candidates) {
475
+ if (typeof candidate !== "string") continue;
476
+ const model = candidate.trim();
477
+ if (model !== "") unique.add(model);
478
+ }
479
+ return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS];
480
+ }
481
+ //#endregion
482
+ //#region src/image-format.ts
483
+ function detectImageMime(data) {
484
+ const startsWith = (...bytes) => bytes.every((value, index) => data[index] === value);
485
+ if (startsWith(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
486
+ if (startsWith(255, 216, 255)) return "image/jpeg";
487
+ if (startsWith(71, 73, 70, 56, 55, 97) || startsWith(71, 73, 70, 56, 57, 97)) return "image/gif";
488
+ if (startsWith(82, 73, 70, 70) && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "image/webp";
489
+ }
490
+ //#endregion
491
+ //#region src/engine.ts
492
+ /**
493
+ * Upstream proxy engine: forwards a generate request to the configured
494
+ * OpenAI-compatible image endpoint (/images/generations for text-to-image,
495
+ * /images/edits for image-to-image) and normalizes the response to base64
496
+ * images so the browser never fetches the upstream itself.
497
+ *
498
+ * Framework-free (no cordis imports) so the route layer and tests can drive
499
+ * it directly.
500
+ */
501
+ /** A generation failure with a user-presentable message. */
502
+ var ImageGenError = class extends Error {
503
+ /** Stable wire code. */
504
+ code;
505
+ constructor(message, code = "generate-failed") {
506
+ super(message);
507
+ this.name = "ImageGenError";
508
+ this.code = code;
509
+ }
510
+ };
511
+ /** Total budget for the upstream generation call (image models are slow). */
512
+ const UPSTREAM_TIMEOUT_MS = 24e4;
513
+ /** Budget for downloading one result image URL. */
514
+ const IMAGE_FETCH_TIMEOUT_MS = 6e4;
515
+ /** Cap on the reference image payload (edit mode), in bytes. */
516
+ const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024;
517
+ /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
518
+ const DALLE3_SIZES = /* @__PURE__ */ new Set([
519
+ "1024x1024",
520
+ "1792x1024",
521
+ "1024x1792"
522
+ ]);
523
+ /** The wire model id for a request: `upstream` (host-filled alias mapping)
524
+ * wins, then the alias, then the family default. */
525
+ function wireModel(request) {
526
+ const upstream = request.upstream?.trim();
527
+ if (upstream !== void 0 && upstream !== "") return upstream;
528
+ const alias = request.model.trim();
529
+ return alias === "" ? "gpt-image-2" : alias;
530
+ }
531
+ /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
532
+ * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
533
+ * and exposes its own aspect-ratio / response-format knobs instead of the
534
+ * OpenAI size/quality/detail passthrough. */
535
+ function isGrokImagine(model) {
536
+ return modelFamily(model) === "grok";
537
+ }
538
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
539
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
540
+ * gateways expose). OpenAI-compatible gateways serve these with their own
541
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
542
+ * passthrough. */
543
+ function isNanoBanana(model) {
544
+ return modelFamily(model) === "nanobanana";
545
+ }
546
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
547
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
548
+ * serve Seedream through a unified generate-and-edit architecture:
549
+ * generation AND editing both go to /images/generations and reference images
550
+ * are a JSON URL / data-URL array. */
551
+ function isSeedream(model) {
552
+ return modelFamily(model) === "seedream";
553
+ }
554
+ /** Whether the model uses the official Zhipu image-generation contract. */
555
+ function isZhipuImage(model) {
556
+ return modelFamily(model) === "zhipu";
557
+ }
558
+ /** Whether the model is Alibaba Qwen-Image, which speaks the DashScope native
559
+ * multimodal-generation contract (NOT OpenAI-compatible): a chat-style
560
+ * messages body, `宽*高` pixel sizes, and image URLs in the reply content. */
561
+ function isQwenImage(model) {
562
+ return modelFamily(model) === "qwen";
563
+ }
564
+ /** Whether the model is MiniMax image-01, which speaks MiniMax's native
565
+ * `/image_generation` contract (NOT OpenAI-compatible): `aspect_ratio`,
566
+ * `subject_reference` for image-to-image, `data.image_base64[]` results, and
567
+ * errors reported as HTTP 200 + non-zero `base_resp.status_code`. */
568
+ function isMiniMaxImage(model) {
569
+ return modelFamily(model) === "minimax";
570
+ }
571
+ /** Aspect ratios MiniMax image-01 documents (the panel vocabulary is a superset). */
572
+ const MINIMAX_RATIOS = /* @__PURE__ */ new Set([
573
+ "1:1",
574
+ "16:9",
575
+ "4:3",
576
+ "3:2",
577
+ "2:3",
578
+ "3:4",
579
+ "9:16",
580
+ "21:9"
581
+ ]);
582
+ /** MiniMax caps one request at 9 images. */
583
+ const MINIMAX_MAX_N = 9;
584
+ /** MiniMax image-01 rejects prompts of 1500+ characters; the exact number
585
+ * lives in model-catalog.ts so the panel counter and this guard agree. */
586
+ function isGlmImage(model) {
587
+ return /^glm-image(?:-|$)/i.test(model.trim());
588
+ }
589
+ /** Whether this is the official Volcengine Ark model naming convention. */
590
+ function isVolcSeedream(model) {
591
+ return /^doubao-seedream(?:-|$)/i.test(model.trim());
592
+ }
593
+ /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
594
+ function seedreamSize(quality) {
595
+ if (quality === "1k") return "1K";
596
+ return "2K";
597
+ }
598
+ /** The panel's aspect ratios mapped to Qwen-Image's `宽*高` pixel sizes.
599
+ * The classic series (qwen-image / -plus / -max) documents this fixed list;
600
+ * 2.0 / 3.0-series models accept any size within their pixel budget and
601
+ * recommend the larger set. */
602
+ const QWEN_SIZE_CLASSIC = {
603
+ "16:9": "1664*928",
604
+ "21:9": "1664*928",
605
+ "4:3": "1472*1104",
606
+ "3:2": "1472*1104",
607
+ "1:1": "1328*1328",
608
+ "3:4": "1104*1472",
609
+ "2:3": "1104*1472",
610
+ "9:16": "928*1664"
611
+ };
612
+ const QWEN_SIZE_HD = {
613
+ "16:9": "2688*1536",
614
+ "21:9": "2688*1536",
615
+ "4:3": "2368*1728",
616
+ "3:2": "2368*1728",
617
+ "1:1": "2048*2048",
618
+ "3:4": "1728*2368",
619
+ "2:3": "1728*2368",
620
+ "9:16": "1536*2688"
621
+ };
622
+ /** Versioned ids (qwen-image-2.0 / -3.0-pro / …) take the large size set. */
623
+ function isVersionedQwenImage(model) {
624
+ return /^qwen-image-\d+\.\d/i.test(model.trim());
625
+ }
626
+ function qwenSize(model, ratio) {
627
+ if (ratio === "" || ratio === "auto") return void 0;
628
+ return (isVersionedQwenImage(model) ? QWEN_SIZE_HD : QWEN_SIZE_CLASSIC)[ratio];
629
+ }
630
+ /** The panel's aspect ratios mapped to the closest OpenAI pixel size
631
+ * (gpt-image-2 / generic OpenAI-compatible endpoints). */
632
+ const OPENAI_SIZE_BY_RATIO = {
633
+ "1:1": "1024x1024",
634
+ "3:4": "1024x1536",
635
+ "4:3": "1536x1024",
636
+ "9:16": "1024x1792",
637
+ "2:3": "1024x1536",
638
+ "3:2": "1536x1024",
639
+ "16:9": "1792x1024",
640
+ "21:9": "1792x1024"
641
+ };
642
+ /** Panel ratios that need renaming for a model's vocabulary. Grok documents
643
+ * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
644
+ const GROK_ASPECT_ALIASES = { "21:9": "20:9" };
645
+ /**
646
+ * One request-scoped timeout that is cleared as soon as its fetch settles.
647
+ * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
648
+ * task queue leaves an otherwise idle Node process holding every timeout.
649
+ */
650
+ function requestSignal(source, timeoutMs) {
651
+ const controller = new AbortController();
652
+ const abortFromSource = () => {
653
+ controller.abort(source?.reason);
654
+ };
655
+ if (source?.aborted === true) abortFromSource();
656
+ else source?.addEventListener("abort", abortFromSource, { once: true });
657
+ const timeout = setTimeout(() => {
658
+ controller.abort(new DOMException("The operation timed out.", "TimeoutError"));
659
+ }, timeoutMs);
660
+ timeout.unref();
661
+ return {
662
+ signal: controller.signal,
663
+ dispose: () => {
664
+ clearTimeout(timeout);
665
+ source?.removeEventListener("abort", abortFromSource);
666
+ }
667
+ };
668
+ }
669
+ /** Whether an error was produced by a requestSignal budget timeout. These can
670
+ * surface from the fetch call itself or from reading the response body, so the
671
+ * budget must stay armed until the body has been consumed. */
672
+ function isBudgetTimeout(error) {
673
+ return (error instanceof DOMException || error instanceof Error) && error.name === "TimeoutError";
674
+ }
675
+ /** Content-type extension hints for URL-fetched images. */ function mimeOfExtension(path) {
676
+ const match = /\.([a-z0-9]+)$/i.exec(path);
677
+ if (match === null) return void 0;
678
+ switch (match[1].toLowerCase()) {
679
+ case "png": return "image/png";
680
+ case "jpg":
681
+ case "jpeg": return "image/jpeg";
682
+ case "webp": return "image/webp";
683
+ case "gif": return "image/gif";
684
+ default: return;
685
+ }
686
+ }
687
+ /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
688
+ function parseDataUrl(dataUrl) {
689
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim());
690
+ if (match === null || match[3] === void 0) return void 0;
691
+ if (match[2] === void 0) return;
692
+ return {
693
+ mime: match[1] ?? "application/octet-stream",
694
+ base64: match[3]
695
+ };
696
+ }
697
+ /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
698
+ function bareBase64(value) {
699
+ const parsed = parseDataUrl(value);
700
+ return parsed !== void 0 && parsed.base64 !== void 0 ? parsed.base64 : value;
701
+ }
702
+ /** Whether a result URL carries cloud-storage signing credentials. */
703
+ function isPresignedUrl(value) {
704
+ let url;
705
+ try {
706
+ url = new URL(value);
707
+ } catch {
708
+ return false;
709
+ }
710
+ const params = new Set(Array.from(url.searchParams.keys(), (key) => key.toLowerCase()));
711
+ if (params.has("x-goog-signature") || params.has("x-goog-credential")) return true;
712
+ if (params.has("x-amz-signature") || params.has("x-amz-credential")) return true;
713
+ return params.has("signature") && (params.has("expires") || params.has("googleaccessid") || params.has("awsaccesskeyid"));
714
+ }
715
+ /** Resolve the request URL for a channel: base URL + path, or the exact URL. */
716
+ function endpointUrl(upstream, baseUrl, path) {
717
+ return upstream.apiUrlFull === true ? baseUrl : `${baseUrl}${path}`;
718
+ }
719
+ /**
720
+ * Whether a result URL lives on the same origin as the configured API base.
721
+ * The upstream Bearer key is only ever forwarded to this origin: a provider
722
+ * (or a compromised relay) that hands back an image URL on a foreign host
723
+ * must not be able to harvest the key through that download.
724
+ */
725
+ function isSameOriginAsApi(value, apiUrl) {
726
+ try {
727
+ return new URL(value).origin === new URL(apiUrl).origin;
728
+ } catch {
729
+ return false;
730
+ }
731
+ }
732
+ /** Clamp the requested image count into the API-accepted range. */
733
+ function clampCount(n) {
734
+ if (!Number.isFinite(n)) return 1;
735
+ return Math.min(4, Math.max(1, Math.round(n)));
736
+ }
737
+ /** Pick the effective per-model request parameters. Never includes `n`: the
738
+ * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
739
+ * so the count is satisfied by parallel single-image requests instead. */
740
+ function effectiveParams(request) {
741
+ const model = wireModel(request);
742
+ if (model === "dall-e-3") {
743
+ const pixel = OPENAI_SIZE_BY_RATIO[request.size];
744
+ return {
745
+ model,
746
+ size: pixel !== void 0 && DALLE3_SIZES.has(pixel) ? pixel : "1024x1024"
747
+ };
748
+ }
749
+ if (isGrokImagine(model)) return {
750
+ model,
751
+ ...request.size !== "" && request.size !== "auto" ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size } : {},
752
+ ...request.quality !== "" && request.quality !== "auto" ? { resolution: request.quality === "4k" ? "2k" : request.quality } : {},
753
+ response_format: "b64_json"
754
+ };
755
+ if (isNanoBanana(model)) return {
756
+ model,
757
+ ...request.size !== "" && request.size !== "auto" ? { aspect_ratio: request.size } : {},
758
+ ...request.quality !== "" && request.quality !== "auto" ? { image_size: request.quality.toUpperCase() } : {},
759
+ response_format: "b64_json"
760
+ };
761
+ if (isSeedream(model)) return {
762
+ model,
763
+ size: seedreamSize(request.quality),
764
+ response_format: isVolcSeedream(model) ? "url" : "b64_json"
765
+ };
766
+ if (isZhipuImage(model)) return {
767
+ model,
768
+ ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
769
+ quality: isGlmImage(model) ? "hd" : "standard"
770
+ };
771
+ return {
772
+ model,
773
+ ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
774
+ ...request.quality === "1k" ? { quality: "low" } : {},
775
+ ...request.quality === "2k" ? { quality: "medium" } : {},
776
+ ...request.quality === "4k" ? { quality: "high" } : {},
777
+ ...request.detail !== "" ? { detail: request.detail } : {}
778
+ };
779
+ }
780
+ /** How many single-image requests to issue for the requested image count. */
781
+ function effectiveCount(request) {
782
+ if (wireModel(request) === "dall-e-3") return 1;
783
+ return clampCount(request.n);
784
+ }
785
+ /** Normalize one upstream data item into a base64 image. */
786
+ async function normalizeItem(item, upstream, signal) {
787
+ const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : void 0;
788
+ if (typeof item.b64_json === "string" && item.b64_json.trim() !== "") {
789
+ const b64 = bareBase64(item.b64_json);
790
+ if (b64.trim() !== "") return {
791
+ b64,
792
+ mime: detectImageMime(Buffer.from(b64, "base64")) ?? "image/png",
793
+ revisedPrompt
794
+ };
795
+ }
796
+ if (typeof item.url !== "string" || item.url === "") throw new ImageGenError("upstream image item has neither b64_json nor url");
797
+ const url = item.url;
798
+ if (url.startsWith("data:")) {
799
+ const parsed = parseDataUrl(url);
800
+ if (parsed === void 0) throw new ImageGenError("upstream returned a malformed data: url");
801
+ return {
802
+ b64: parsed.base64,
803
+ mime: detectImageMime(Buffer.from(parsed.base64, "base64")) ?? parsed.mime,
804
+ revisedPrompt
805
+ };
806
+ }
807
+ const budget = requestSignal(signal, IMAGE_FETCH_TIMEOUT_MS);
808
+ try {
809
+ let response;
810
+ try {
811
+ const forwardKey = upstream.apiKey !== "" && !isPresignedUrl(url) && isSameOriginAsApi(url, upstream.apiUrl);
812
+ response = await fetch(url, {
813
+ ...forwardKey ? { headers: { authorization: `Bearer ${upstream.apiKey}` } } : {},
814
+ signal: budget.signal
815
+ });
816
+ } catch (error) {
817
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`);
818
+ }
819
+ if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
820
+ const buffer = Buffer.from(await response.arrayBuffer());
821
+ const contentType = response.headers.get("content-type");
822
+ const mime = detectImageMime(buffer) ?? (contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png");
823
+ return {
824
+ b64: buffer.toString("base64"),
825
+ mime,
826
+ revisedPrompt
827
+ };
828
+ } finally {
829
+ budget.dispose();
830
+ }
831
+ }
832
+ /** Expand a provider image item whose URL may be a string or an array. */
833
+ function imageItemsOf(value) {
834
+ if (value === null || typeof value !== "object") return [];
835
+ const item = value;
836
+ if (Array.isArray(item.url)) return item.url.filter((url) => typeof url === "string" && url !== "").map((url) => ({
837
+ ...item,
838
+ url
839
+ }));
840
+ return [item];
841
+ }
842
+ /** Return the data records from the response shapes shared by sync gateways. */
843
+ function dataRecordsOf(payload) {
844
+ const data = Array.isArray(payload.data) ? payload.data : payload.data !== null && typeof payload.data === "object" ? [payload.data] : Array.isArray(payload.images) ? payload.images : Array.isArray(payload.output) ? payload.output : void 0;
845
+ if (data === void 0) return void 0;
846
+ return data.filter((entry) => entry !== null && typeof entry === "object");
847
+ }
848
+ /** Remove reasoning-model wrappers before treating chat content as a user-visible error. */
849
+ function stripChatReasoning(value) {
850
+ const withoutClosed = value.replace(/<think>[\s\S]*?<\/think>/gi, "");
851
+ const dangling = /<think>/i.exec(withoutClosed);
852
+ return (dangling === null ? withoutClosed : withoutClosed.slice(0, dangling.index)).trim();
853
+ }
854
+ /** Whether a URL returned in chat content is plausibly an image resource. */
855
+ function isLikelyImageUrl(value) {
856
+ if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return true;
857
+ try {
858
+ const url = new URL(value);
859
+ if (mimeOfExtension(url.pathname) !== void 0) return true;
860
+ const signal = `${url.hostname}${url.pathname}${url.search}`.toLowerCase();
861
+ return /(?:image|img|photo|picture|cdn|blob|cloudinary|googleusercontent|oaidalle|replicate|fal\.media|files\.oai)/.test(signal);
862
+ } catch {
863
+ return false;
864
+ }
865
+ }
866
+ /** Parse a complete or fenced JSON value embedded in chat text. */
867
+ function parseEmbeddedJson(value) {
868
+ const cleaned = stripChatReasoning(value).replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
869
+ if (cleaned === "") return void 0;
870
+ const attempts = [cleaned];
871
+ const objectStart = cleaned.indexOf("{");
872
+ const objectEnd = cleaned.lastIndexOf("}");
873
+ const arrayStart = cleaned.indexOf("[");
874
+ const arrayEnd = cleaned.lastIndexOf("]");
875
+ if (objectStart >= 0 && objectEnd > objectStart) attempts.push(cleaned.slice(objectStart, objectEnd + 1));
876
+ if (arrayStart >= 0 && arrayEnd > arrayStart) attempts.push(cleaned.slice(arrayStart, arrayEnd + 1));
877
+ for (const attempt of attempts) try {
878
+ const parsed = JSON.parse(attempt);
879
+ if (parsed !== null && typeof parsed === "object") return parsed;
880
+ } catch {}
881
+ }
882
+ /** Collect image references from structured Chat Completions content. */
883
+ function collectChatImageItems(value, out, seen, depth = 0) {
884
+ if (depth > 6 || value === null || value === void 0) return;
885
+ if (typeof value === "string") {
886
+ const parsed = parseEmbeddedJson(value);
887
+ if (parsed !== void 0 && parsed !== value) collectChatImageItems(parsed, out, seen, depth + 1);
888
+ for (const match of value.matchAll(/!\[[^\]]*\]\((?:<)?(https?:\/\/[^)\s>]+|data:image\/[^)\s>]+)(?:>)?\)/gi)) if (match[1] !== void 0) out.push({ url: match[1] });
889
+ for (const match of value.matchAll(/<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi)) if (match[1] !== void 0) out.push({ url: match[1] });
890
+ const urls = value.match(/(?:https?:\/\/|data:image\/)[^\s"'<>)\]}]+/gi) ?? [];
891
+ for (const raw of urls) {
892
+ const url = raw.replace(/[.,;:!?]+$/, "");
893
+ if (isLikelyImageUrl(url)) out.push({ url });
894
+ }
895
+ return;
896
+ }
897
+ if (Array.isArray(value)) {
898
+ for (const item of value) collectChatImageItems(item, out, seen, depth + 1);
899
+ return;
900
+ }
901
+ if (typeof value !== "object") return;
902
+ const record = value;
903
+ if (seen.has(record)) return;
904
+ seen.add(record);
905
+ if (typeof record.b64_json === "string" && record.b64_json.trim() !== "") out.push({ b64_json: record.b64_json });
906
+ for (const key of [
907
+ "url",
908
+ "image_url",
909
+ "image",
910
+ "output_url"
911
+ ]) {
912
+ const item = record[key];
913
+ if (typeof item === "string" && item !== "") {
914
+ if (key !== "url" || isLikelyImageUrl(item)) out.push({ url: item });
915
+ } else if (item !== null && typeof item === "object") {
916
+ const nested = item;
917
+ if (key !== "url" && typeof nested.url === "string" && nested.url !== "") out.push({ url: nested.url });
918
+ else collectChatImageItems(item, out, seen, depth + 1);
919
+ }
920
+ }
921
+ for (const key of [
922
+ "images",
923
+ "content",
924
+ "message",
925
+ "data",
926
+ "output",
927
+ "result"
928
+ ]) if (key in record) collectChatImageItems(record[key], out, seen, depth + 1);
929
+ }
930
+ /** Extract every image candidate from one Chat Completions response. */
931
+ function chatImageItemsOf(payload) {
932
+ const out = [];
933
+ const seenValues = /* @__PURE__ */ new Set();
934
+ const data = dataRecordsOf(payload);
935
+ if (data !== void 0) for (const record of data) collectChatImageItems(record, out, seenValues);
936
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
937
+ for (const choice of choices) {
938
+ if (choice === null || typeof choice !== "object") continue;
939
+ const message = choice.message;
940
+ collectChatImageItems(message, out, seenValues);
941
+ const delta = choice.delta;
942
+ collectChatImageItems(delta, out, seenValues);
943
+ }
944
+ if (out.length === 0) collectChatImageItems(payload.images, out, seenValues);
945
+ const unique = /* @__PURE__ */ new Map();
946
+ for (const item of out) {
947
+ const url = typeof item.url === "string" ? item.url.trim() : "";
948
+ const b64 = typeof item.b64_json === "string" ? item.b64_json.trim() : "";
949
+ const key = url !== "" ? `url:${url}` : b64 !== "" ? `b64:${b64.slice(0, 128)}` : "";
950
+ if (key !== "" && !unique.has(key)) unique.set(key, item);
951
+ }
952
+ return [...unique.values()];
953
+ }
954
+ /** User-visible text from a Chat response, used only when no image was found. */
955
+ function chatErrorTextOf(payload) {
956
+ const first = (Array.isArray(payload.choices) ? payload.choices : [])[0];
957
+ const content = first !== null && typeof first === "object" ? first.message?.content : void 0;
958
+ const pieces = [];
959
+ if (typeof content === "string") pieces.push(stripChatReasoning(content));
960
+ else if (Array.isArray(content)) {
961
+ for (const part of content) if (typeof part === "string") pieces.push(stripChatReasoning(part));
962
+ else if (part !== null && typeof part === "object" && typeof part.text === "string") pieces.push(stripChatReasoning(part.text));
963
+ }
964
+ if (pieces.join("\n").trim() !== "") return pieces.join("\n").trim();
965
+ if (typeof payload.message === "string" && payload.message.trim() !== "") return payload.message.trim();
966
+ const error = payload.error;
967
+ if (error !== null && typeof error === "object" && typeof error.message === "string") return error.message.trim();
968
+ }
969
+ const ASYNC_PENDING_STATUSES = /* @__PURE__ */ new Set([
970
+ "submitted",
971
+ "pending",
972
+ "processing",
973
+ "running",
974
+ "in_progress",
975
+ "queued"
976
+ ]);
977
+ const ASYNC_COMPLETED_STATUSES = /* @__PURE__ */ new Set([
978
+ "completed",
979
+ "succeeded",
980
+ "success",
981
+ "done"
982
+ ]);
983
+ const ASYNC_FAILED_STATUSES = /* @__PURE__ */ new Set([
984
+ "failed",
985
+ "failure",
986
+ "cancelled",
987
+ "canceled",
988
+ "error"
989
+ ]);
990
+ const ASYNC_POLL_MAX_MS = 24e4;
991
+ const ASYNC_POLL_REQUEST_TIMEOUT_MS = 3e4;
992
+ /** Read a provider error message from the common nested locations. */
993
+ function asyncErrorMessage(payload, fallback) {
994
+ if (payload !== null && typeof payload === "object") {
995
+ const record = payload;
996
+ const candidates = [record.message, record.error];
997
+ const data = record.data;
998
+ const entries = Array.isArray(data) ? data : [data];
999
+ for (const entry of entries) {
1000
+ if (entry === null || typeof entry !== "object") continue;
1001
+ const item = entry;
1002
+ candidates.push(item.message, item.error);
1003
+ const nested = item.error;
1004
+ if (nested !== null && typeof nested === "object") candidates.push(nested.message);
1005
+ }
1006
+ for (const candidate of candidates) {
1007
+ if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
1008
+ if (candidate !== null && typeof candidate === "object") {
1009
+ const message = candidate.message;
1010
+ if (typeof message === "string" && message.trim() !== "") return message;
1011
+ }
1012
+ }
1013
+ }
1014
+ return fallback;
1015
+ }
1016
+ /** Wait between async-provider polls, but wake immediately when cancelled. */
1017
+ function waitForPoll(ms, signal) {
1018
+ return new Promise((resolve, reject) => {
1019
+ if (signal?.aborted === true) {
1020
+ reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
1021
+ return;
1022
+ }
1023
+ const onAbort = () => {
1024
+ clearTimeout(timer);
1025
+ signal?.removeEventListener("abort", onAbort);
1026
+ reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
1027
+ };
1028
+ const done = () => {
1029
+ signal?.removeEventListener("abort", onAbort);
1030
+ resolve();
1031
+ };
1032
+ const timer = setTimeout(done, ms);
1033
+ timer.unref();
1034
+ signal?.addEventListener("abort", onAbort, { once: true });
1035
+ });
1036
+ }
1037
+ /**
1038
+ * Poll one apib/apimart-style provider task until it yields image records.
1039
+ * The total deadline is shared by every poll and the final image downloads;
1040
+ * local task cancellation propagates through every request and sleep.
1041
+ */
1042
+ async function pollAsyncTask(baseUrl, upstream, taskId, signal) {
1043
+ const deadline = Date.now() + ASYNC_POLL_MAX_MS;
1044
+ let delay = 1e3;
1045
+ while (Date.now() < deadline) {
1046
+ const remaining = deadline - Date.now();
1047
+ const budget = requestSignal(signal, Math.min(ASYNC_POLL_REQUEST_TIMEOUT_MS, remaining));
1048
+ try {
1049
+ let response;
1050
+ try {
1051
+ response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
1052
+ method: "GET",
1053
+ headers: { authorization: `Bearer ${upstream.apiKey.trim()}` },
1054
+ signal: budget.signal
1055
+ });
1056
+ } catch (error) {
1057
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1058
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游异步任务轮询超时", "upstream-timeout");
1059
+ throw new ImageGenError(`无法轮询上游异步任务:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
1060
+ }
1061
+ let payload;
1062
+ try {
1063
+ payload = await response.json();
1064
+ } catch (error) {
1065
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游异步任务轮询超时", "upstream-timeout");
1066
+ throw new ImageGenError(`上游任务接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
1067
+ }
1068
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(asyncErrorMessage(payload, `上游任务轮询失败(HTTP ${response.status})`), "upstream-rejected");
1069
+ const record = payload;
1070
+ const data = record.data;
1071
+ const statusRecord = Array.isArray(data) ? data[0] : data !== null && typeof data === "object" ? data : record;
1072
+ const statusValue = statusRecord !== null && typeof statusRecord === "object" ? statusRecord.status : void 0;
1073
+ const status = typeof statusValue === "string" ? statusValue.toLowerCase() : "";
1074
+ if (ASYNC_FAILED_STATUSES.has(status)) throw new ImageGenError(asyncErrorMessage(payload, `上游异步任务失败(${status || "unknown"})`), "upstream-rejected");
1075
+ const nested = statusRecord !== null && typeof statusRecord === "object" ? statusRecord : record;
1076
+ const result = nested.result ?? (nested.output !== null && typeof nested.output === "object" ? nested.output.result : void 0) ?? record.result;
1077
+ const images = (result !== null && typeof result === "object" ? result : void 0)?.images ?? nested.images ?? record.images;
1078
+ if (ASYNC_COMPLETED_STATUSES.has(status) || images !== void 0) {
1079
+ const items = Array.isArray(images) ? images.flatMap(imageItemsOf) : imageItemsOf(images);
1080
+ if (items.length > 0) return items;
1081
+ if (ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
1082
+ }
1083
+ if (status !== "" && !ASYNC_PENDING_STATUSES.has(status) && !ASYNC_COMPLETED_STATUSES.has(status)) throw new ImageGenError(`上游返回了未知异步任务状态:${status}`, "upstream-invalid");
1084
+ } finally {
1085
+ budget.dispose();
1086
+ }
1087
+ await waitForPoll(Math.min(delay, Math.max(1, deadline - Date.now())), signal);
1088
+ delay = Math.min(5e3, delay * 2);
1089
+ }
1090
+ throw new ImageGenError("上游异步任务轮询超时(240 秒)", "upstream-timeout");
1091
+ }
1092
+ /**
1093
+ * Issue one single-image request (never sends `n`). The response is kept as a
1094
+ * list so a gateway that happens to return several images per call still works.
1095
+ */
1096
+ async function requestOneImage(baseUrl, upstream, request, params, signal) {
1097
+ const headers = { authorization: `Bearer ${upstream.apiKey.trim()}` };
1098
+ let body;
1099
+ if (request.mode === "edit") {
1100
+ if (typeof request.image !== "string" || request.image === "") throw new ImageGenError("图生图需要上传参考图片", "edit-image-missing");
1101
+ const decodeReference = (dataUrl) => {
1102
+ const parsed = parseDataUrl(dataUrl);
1103
+ if (parsed === void 0) throw new ImageGenError("参考图片格式无效", "edit-image-invalid");
1104
+ let bytes;
1105
+ try {
1106
+ bytes = Buffer.from(parsed.base64, "base64");
1107
+ } catch {
1108
+ throw new ImageGenError("参考图片数据无法解码", "edit-image-invalid");
1109
+ }
1110
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) throw new ImageGenError("参考图片超过 10MB 上限", "edit-image-too-large");
1111
+ return {
1112
+ bytes,
1113
+ mime: parsed.mime,
1114
+ filename: `reference.${extensionOf(parsed.mime)}`
1115
+ };
1116
+ };
1117
+ const primary = decodeReference(request.image);
1118
+ const extras = (request.images ?? []).filter((img) => typeof img === "string" && img !== "").slice(0, 4).map(decodeReference);
1119
+ if (isGrokImagine(params.model)) {
1120
+ headers["content-type"] = "application/json";
1121
+ body = JSON.stringify({
1122
+ model: params.model,
1123
+ prompt: request.prompt,
1124
+ image: {
1125
+ url: request.image,
1126
+ type: "image_url"
1127
+ },
1128
+ ...params.aspect_ratio !== void 0 ? { aspect_ratio: params.aspect_ratio } : {},
1129
+ response_format: "b64_json"
1130
+ });
1131
+ } else if (isNanoBanana(params.model)) {
1132
+ const form = new FormData();
1133
+ form.append("image", new Blob([primary.bytes], { type: primary.mime }), primary.filename);
1134
+ form.append("prompt", request.prompt);
1135
+ form.append("model", params.model);
1136
+ if (params.aspect_ratio !== void 0) form.append("aspect_ratio", params.aspect_ratio);
1137
+ if (params.image_size !== void 0) form.append("image_size", params.image_size);
1138
+ body = form;
1139
+ } else if (isSeedream(params.model)) {
1140
+ headers["content-type"] = "application/json";
1141
+ body = JSON.stringify({
1142
+ model: params.model,
1143
+ prompt: request.prompt,
1144
+ image: [request.image, ...(request.images ?? []).filter((img) => typeof img === "string" && img !== "").slice(0, 4)],
1145
+ ...params.size !== void 0 ? { size: params.size } : {},
1146
+ ...params.resolution !== void 0 ? { resolution: params.resolution } : {},
1147
+ response_format: isVolcSeedream(params.model) ? "url" : "b64_json"
1148
+ });
1149
+ } else {
1150
+ const form = new FormData();
1151
+ if (extras.length > 0) for (const [index, reference] of [primary, ...extras].entries()) form.append("image[]", new Blob([reference.bytes], { type: reference.mime }), `reference-${index}.${extensionOf(reference.mime)}`);
1152
+ else form.append("image", new Blob([primary.bytes], { type: primary.mime }), primary.filename);
1153
+ form.append("prompt", request.prompt);
1154
+ form.append("model", params.model);
1155
+ if (params.size !== void 0) form.append("size", params.size);
1156
+ if (params.quality !== void 0) form.append("quality", params.quality);
1157
+ if (params.detail !== void 0) form.append("detail", params.detail);
1158
+ body = form;
1159
+ }
1160
+ } else {
1161
+ headers["content-type"] = "application/json";
1162
+ body = JSON.stringify({
1163
+ prompt: request.prompt,
1164
+ ...params
1165
+ });
1166
+ }
1167
+ const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS);
1168
+ try {
1169
+ let response;
1170
+ try {
1171
+ const endpoint = request.mode === "edit" && !isSeedream(params.model) ? "/images/edits" : "/images/generations";
1172
+ response = await fetch(endpointUrl(upstream, baseUrl, endpoint), {
1173
+ method: "POST",
1174
+ headers,
1175
+ body,
1176
+ signal: budget.signal
1177
+ });
1178
+ } catch (error) {
1179
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1180
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1181
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
1182
+ }
1183
+ let payload;
1184
+ try {
1185
+ payload = await response.json();
1186
+ } catch (error) {
1187
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1188
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1189
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
1190
+ }
1191
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
1192
+ const data = dataRecordsOf(payload);
1193
+ if (data === void 0) throw new ImageGenError("上游响应缺少 data 数组", "upstream-invalid");
1194
+ if (data.length === 0) throw new ImageGenError("上游返回了 0 张图片", "upstream-empty");
1195
+ const asyncEntries = data.filter((entry) => typeof entry.task_id === "string" && entry.task_id.trim() !== "");
1196
+ if (asyncEntries.length > 0) {
1197
+ const asyncRecords = (await Promise.all(asyncEntries.map((entry) => pollAsyncTask(baseUrl, upstream, entry.task_id, signal)))).flat();
1198
+ if (asyncRecords.length === 0) throw new ImageGenError("上游异步任务完成但没有图片结果", "upstream-empty");
1199
+ return Promise.all(asyncRecords.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
1200
+ }
1201
+ return Promise.all(data.flatMap(imageItemsOf).map((item) => normalizeItem(item, upstream, signal)));
1202
+ } finally {
1203
+ budget.dispose();
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Qwen-Image (DashScope native multimodal-generation): one chat-style request
1208
+ * carries the prompt (plus the reference image for edit mode) and answers
1209
+ * synchronously with image URLs in the reply content. The versioned series
1210
+ * batches natively (n ≤ 6; the panel caps at 4), the classic series is
1211
+ * single-image per call.
1212
+ */
1213
+ async function generateQwenImage(baseUrl, upstream, request, options) {
1214
+ const model = wireModel(request);
1215
+ const content = [];
1216
+ if (request.mode === "edit") {
1217
+ if (typeof request.image !== "string" || request.image === "") throw new ImageGenError("图生图需要上传参考图片", "edit-image-missing");
1218
+ const parsed = parseDataUrl(request.image);
1219
+ if (parsed === void 0) throw new ImageGenError("参考图片格式无效", "edit-image-invalid");
1220
+ if (Buffer.from(parsed.base64, "base64").byteLength > MAX_EDIT_IMAGE_BYTES) throw new ImageGenError("参考图片超过 10MB 上限", "edit-image-too-large");
1221
+ content.push({ image: request.image });
1222
+ }
1223
+ content.push({ text: request.prompt });
1224
+ const count = isVersionedQwenImage(model) ? clampCount(request.n) : 1;
1225
+ const size = qwenSize(model, request.size);
1226
+ const body = {
1227
+ model,
1228
+ input: { messages: [{
1229
+ role: "user",
1230
+ content
1231
+ }] },
1232
+ parameters: {
1233
+ ...size !== void 0 ? { size } : {},
1234
+ ...count > 1 ? { n: count } : {}
1235
+ }
1236
+ };
1237
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS);
1238
+ try {
1239
+ let response;
1240
+ try {
1241
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
1242
+ method: "POST",
1243
+ headers: {
1244
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
1245
+ "content-type": "application/json"
1246
+ },
1247
+ body: JSON.stringify(body),
1248
+ signal: budget.signal
1249
+ });
1250
+ } catch (error) {
1251
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1252
+ if (options.signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1253
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
1254
+ }
1255
+ let payload;
1256
+ try {
1257
+ payload = await response.json();
1258
+ } catch (error) {
1259
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1260
+ if (options.signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1261
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
1262
+ }
1263
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
1264
+ const output = payload.output;
1265
+ const choices = output !== void 0 && Array.isArray(output.choices) ? output.choices : [];
1266
+ const urls = [];
1267
+ for (const choice of choices) {
1268
+ const message = choice !== null && typeof choice === "object" ? choice.message : void 0;
1269
+ const items = message !== null && typeof message === "object" && Array.isArray(message.content) ? message.content : [];
1270
+ for (const item of items) if (item !== null && typeof item === "object") {
1271
+ const image = item.image;
1272
+ if (typeof image === "string" && image !== "") urls.push(image);
1273
+ }
1274
+ }
1275
+ if (urls.length === 0) throw new ImageGenError("上游响应缺少图片内容", "upstream-empty");
1276
+ return { images: await Promise.all(urls.map(async (url) => {
1277
+ const normalized = await normalizeItem({ url }, upstream);
1278
+ return {
1279
+ b64: normalized.b64,
1280
+ mime: normalized.mime
1281
+ };
1282
+ })) };
1283
+ } finally {
1284
+ budget.dispose();
1285
+ }
1286
+ }
1287
+ /**
1288
+ * MiniMax image-01 (native `/image_generation`): one JSON request that batches
1289
+ * up to 9 images and returns them inline as base64. Image-to-image rides the
1290
+ * `subject_reference` array (a character reference, data URL accepted). The
1291
+ * endpoint answers HTTP 200 even on failure, so `base_resp.status_code` is the
1292
+ * real verdict.
1293
+ */
1294
+ async function generateMiniMaxImage(baseUrl, upstream, request, options) {
1295
+ const model = wireModel(request);
1296
+ const promptLimit = promptCharLimit(model);
1297
+ if (promptLimit !== null && request.prompt.length >= promptLimit) throw new ImageGenError(`MiniMax image-01 要求提示词少于 ${promptLimit} 字符(当前 ${request.prompt.length}),请精简后重试`, "prompt-too-long");
1298
+ const body = {
1299
+ model,
1300
+ prompt: request.prompt,
1301
+ response_format: "base64"
1302
+ };
1303
+ const ratio = request.size.trim();
1304
+ if (ratio !== "" && ratio !== "auto") {
1305
+ if (!MINIMAX_RATIOS.has(ratio)) throw new ImageGenError(`MiniMax image-01 不支持 ${ratio} 宽高比,可选:${Array.from(MINIMAX_RATIOS).join(" / ")}`, "size-unsupported");
1306
+ body.aspect_ratio = ratio;
1307
+ }
1308
+ const count = Math.min(MINIMAX_MAX_N, clampCount(request.n));
1309
+ if (count > 1) body.n = count;
1310
+ if (request.mode === "edit") {
1311
+ if (typeof request.image !== "string" || request.image === "") throw new ImageGenError("图生图需要上传参考图片", "edit-image-missing");
1312
+ const parsed = parseDataUrl(request.image);
1313
+ if (parsed === void 0) throw new ImageGenError("参考图片格式无效", "edit-image-invalid");
1314
+ if (Buffer.from(parsed.base64, "base64").byteLength > MAX_EDIT_IMAGE_BYTES) throw new ImageGenError("参考图片超过 10MB 上限", "edit-image-too-large");
1315
+ body.subject_reference = [{
1316
+ type: "character",
1317
+ image_file: request.image
1318
+ }];
1319
+ }
1320
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS);
1321
+ try {
1322
+ let response;
1323
+ try {
1324
+ response = await fetch(`${baseUrl}/image_generation`, {
1325
+ method: "POST",
1326
+ headers: {
1327
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
1328
+ "content-type": "application/json"
1329
+ },
1330
+ body: JSON.stringify(body),
1331
+ signal: budget.signal
1332
+ });
1333
+ } catch (error) {
1334
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1335
+ if (options.signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1336
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
1337
+ }
1338
+ let payload;
1339
+ try {
1340
+ payload = await response.json();
1341
+ } catch (error) {
1342
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1343
+ if (options.signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1344
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
1345
+ }
1346
+ if (payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
1347
+ const record = payload;
1348
+ const baseResp = record.base_resp;
1349
+ if (baseResp !== null && typeof baseResp === "object") {
1350
+ const status = baseResp.status_code;
1351
+ if (typeof status === "number" && status !== 0) {
1352
+ const msg = baseResp.status_msg;
1353
+ throw new ImageGenError(`MiniMax 拒绝请求(${status}):${typeof msg === "string" && msg !== "" ? msg : "unknown error"}`, status === 1004 || status === 2049 ? "upstream-unauthorized" : "upstream-rejected");
1354
+ }
1355
+ }
1356
+ if (!response.ok) throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
1357
+ const data = record.data;
1358
+ const b64s = data !== null && typeof data === "object" && Array.isArray(data.image_base64) ? data.image_base64.filter((item) => typeof item === "string" && item.trim() !== "") : [];
1359
+ const urls = data !== null && typeof data === "object" && Array.isArray(data.image_urls) ? data.image_urls.filter((item) => typeof item === "string" && item !== "") : [];
1360
+ if (b64s.length === 0 && urls.length === 0) throw new ImageGenError("上游响应缺少图片内容", "upstream-empty");
1361
+ const images = b64s.map((raw) => {
1362
+ const b64 = bareBase64(raw);
1363
+ return {
1364
+ b64,
1365
+ mime: detectImageMime(Buffer.from(b64, "base64")) ?? "image/jpeg"
1366
+ };
1367
+ });
1368
+ for (const url of urls) {
1369
+ const normalized = await normalizeItem({ url }, upstream, options.signal);
1370
+ images.push({
1371
+ b64: normalized.b64,
1372
+ mime: normalized.mime
1373
+ });
1374
+ }
1375
+ return { images };
1376
+ } finally {
1377
+ budget.dispose();
1378
+ }
1379
+ }
1380
+ /** Resolve the exact Chat Completions endpoint for a channel. */
1381
+ function chatEndpointUrl(upstream, baseUrl) {
1382
+ if (isChatCompletionsUrl(baseUrl)) return baseUrl;
1383
+ if (upstream.apiUrlFull === true) return baseUrl;
1384
+ return `${baseUrl}/chat/completions`;
1385
+ }
1386
+ /** One chat-protocol image request; image count is satisfied by parallel calls. */
1387
+ async function requestOneChatImage(upstream, baseUrl, request, signal) {
1388
+ const content = request.mode === "edit" ? [{
1389
+ type: "text",
1390
+ text: request.prompt
1391
+ }, ...[request.image, ...request.images ?? []].filter((value) => typeof value === "string" && value !== "").slice(0, 5).map((url) => ({
1392
+ type: "image_url",
1393
+ image_url: { url }
1394
+ }))] : request.prompt;
1395
+ const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS);
1396
+ try {
1397
+ let response;
1398
+ try {
1399
+ response = await fetch(chatEndpointUrl(upstream, baseUrl), {
1400
+ method: "POST",
1401
+ headers: {
1402
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
1403
+ "content-type": "application/json",
1404
+ accept: "application/json"
1405
+ },
1406
+ body: JSON.stringify({
1407
+ model: wireModel(request),
1408
+ messages: [{
1409
+ role: "user",
1410
+ content
1411
+ }],
1412
+ stream: false
1413
+ }),
1414
+ signal: budget.signal
1415
+ });
1416
+ } catch (error) {
1417
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1418
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1419
+ throw new ImageGenError(`无法连接上游接口:${error instanceof Error ? error.message : String(error)}`, "upstream-unreachable");
1420
+ }
1421
+ let payload;
1422
+ try {
1423
+ payload = await response.json();
1424
+ } catch (error) {
1425
+ if (isBudgetTimeout(error)) throw new ImageGenError("上游接口响应超时(240 秒)", "upstream-timeout");
1426
+ if (signal?.aborted === true) throw new ImageGenError("任务已取消", "cancelled");
1427
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, "upstream-invalid");
1428
+ }
1429
+ if (!response.ok || payload === null || typeof payload !== "object") throw new ImageGenError(upstreamMessage(payload, response.status), "upstream-rejected");
1430
+ const items = chatImageItemsOf(payload);
1431
+ if (items.length === 0) throw new ImageGenError(chatErrorTextOf(payload)?.slice(0, 1200) || "Chat Completions 响应中没有可用的图片结果", "upstream-empty");
1432
+ return await Promise.all(items.map((item) => normalizeItem(item, upstream, signal)));
1433
+ } finally {
1434
+ budget.dispose();
1435
+ }
1436
+ }
1437
+ /** Run N parallel Chat Completions image calls and flatten the result. */
1438
+ async function generateChatCompletionImages(upstream, baseUrl, request, signal) {
1439
+ const count = effectiveCount(request);
1440
+ return (await Promise.all(Array.from({ length: count }, () => requestOneChatImage(upstream, baseUrl, request, signal)))).flat();
1441
+ }
1442
+ /**
1443
+ * Forward one generate request to the configured endpoint. The requested image
1444
+ * count is satisfied with N parallel single-image requests (the `n` batch
1445
+ * parameter is never sent, because Responses-API-based gateways reject it as
1446
+ * `tools[0].n`), then the results are flattened in order.
1447
+ */
1448
+ async function generateImage(upstream, request, options = {}) {
1449
+ const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, "");
1450
+ if (baseUrl === "") throw new ImageGenError("api_url 未配置:请先在「设置 → 生图配置」中填写", "config-missing");
1451
+ if (upstream.apiKey.trim() === "") throw new ImageGenError("api_key 未配置:请先在「设置 → 生图配置」中填写", "config-missing");
1452
+ if (resolveChannelProtocol(baseUrl, upstream.protocol) === "chat-completions") return { images: await generateChatCompletionImages(upstream, baseUrl, request, options.signal) };
1453
+ if (upstream.apiUrlFull !== true) {
1454
+ if (isQwenImage(wireModel(request))) return generateQwenImage(baseUrl, upstream, request, options);
1455
+ if (isMiniMaxImage(wireModel(request))) return generateMiniMaxImage(baseUrl, upstream, request, options);
1456
+ if (request.mode === "edit" && isZhipuImage(wireModel(request))) throw new ImageGenError("智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型", "edit-unsupported");
1457
+ }
1458
+ const params = effectiveParams(request);
1459
+ const count = effectiveCount(request);
1460
+ return { images: (await Promise.all(Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)))).flat() };
1461
+ }
1462
+ /** Human-readable failure message from an upstream error payload. */
1463
+ function upstreamMessage(payload, status) {
1464
+ if (payload !== null && typeof payload === "object") {
1465
+ const record = payload;
1466
+ const error = record.error;
1467
+ if (error !== null && typeof error === "object") {
1468
+ const message = error.message;
1469
+ if (typeof message === "string" && message !== "") return message;
1470
+ }
1471
+ if (typeof record.message === "string" && record.message !== "") return record.message;
1472
+ if (typeof record.error === "string" && record.error !== "") return record.error;
1473
+ }
1474
+ return `上游接口拒绝请求(HTTP ${status})`;
1475
+ }
1476
+ /** File extension for a MIME type (multipart reference image). */
1477
+ function extensionOf(mime) {
1478
+ switch (mime.split(";")[0].trim()) {
1479
+ case "image/jpeg": return "jpg";
1480
+ case "image/webp": return "webp";
1481
+ case "image/gif": return "gif";
1482
+ default: return "png";
1483
+ }
1484
+ }
1485
+ [
1486
+ "https://www.googleapis.com/auth/cloud-platform",
1487
+ "https://www.googleapis.com/auth/userinfo.email",
1488
+ "https://www.googleapis.com/auth/userinfo.profile",
1489
+ "https://www.googleapis.com/auth/cclog",
1490
+ "https://www.googleapis.com/auth/experimentsandconfigs"
1491
+ ].join(" ");
1492
+ //#endregion
1493
+ //#region src/subscription/manager.ts
1494
+ /** The vendor each dsh-image-gen subscription provider maps onto. */
1495
+ function vendorOf(provider) {
1496
+ if (provider === "chatgpt-sub") return "codex";
1497
+ if (provider === "google-sub") return "antigravity";
1498
+ if (provider === "openrouter-sub") return "openrouter";
1499
+ return "grok";
1500
+ }
1501
+ //#endregion
1502
+ //#region src/task-queue.ts
1503
+ /** In-memory, host-resident image generation queue. */
1504
+ var GenerationTaskQueue = class {
1505
+ run;
1506
+ concurrency;
1507
+ tasks = [];
1508
+ controllers = /* @__PURE__ */ new Map();
1509
+ listeners = /* @__PURE__ */ new Set();
1510
+ running = 0;
1511
+ constructor(run, concurrency = 1) {
1512
+ this.run = run;
1513
+ this.concurrency = concurrency;
1514
+ }
1515
+ list() {
1516
+ return this.tasks.map((task) => this.snapshot(task));
1517
+ }
1518
+ /**
1519
+ * Bounded poll payload: active tasks plus the newest terminal summaries.
1520
+ * Image data and completed results are intentionally omitted.
1521
+ */
1522
+ summaries(terminalLimit = 100) {
1523
+ const limit = Math.max(0, Math.floor(terminalLimit));
1524
+ const active = this.tasks.filter((task) => task.status === "queued" || task.status === "running");
1525
+ const terminal = this.tasks.filter((task) => task.status !== "queued" && task.status !== "running").slice(0, limit);
1526
+ const selected = new Set([...active, ...terminal].map((task) => task.id));
1527
+ return this.tasks.filter((task) => selected.has(task.id)).map((task) => this.summary(task));
1528
+ }
1529
+ get(id) {
1530
+ const task = this.tasks.find((item) => item.id === id);
1531
+ return task === void 0 ? void 0 : this.snapshot(task);
1532
+ }
1533
+ /** Observe queue state changes. Listener failures never disrupt generation. */
1534
+ subscribe(listener) {
1535
+ this.listeners.add(listener);
1536
+ return () => {
1537
+ this.listeners.delete(listener);
1538
+ };
1539
+ }
1540
+ submit(request) {
1541
+ const task = {
1542
+ id: randomUUID(),
1543
+ request: { ...request },
1544
+ status: "queued",
1545
+ createdAt: Date.now()
1546
+ };
1547
+ this.tasks.unshift(task);
1548
+ this.publish(task);
1549
+ this.drain();
1550
+ return this.snapshot(task);
1551
+ }
1552
+ cancel(id) {
1553
+ const task = this.tasks.find((item) => item.id === id);
1554
+ if (task === void 0 || task.status === "completed" || task.status === "failed" || task.status === "cancelled") return task;
1555
+ task.status = "cancelled";
1556
+ task.finishedAt = Date.now();
1557
+ this.controllers.get(id)?.abort();
1558
+ this.publish(task);
1559
+ this.drain();
1560
+ return this.snapshot(task);
1561
+ }
1562
+ retry(id) {
1563
+ const previous = this.tasks.find((item) => item.id === id);
1564
+ return previous === void 0 ? void 0 : this.submit(previous.request);
1565
+ }
1566
+ /** Start queued tasks while capacity remains. Plain submissions and
1567
+ * comparison batches alike run in parallel up to the host-wide limit, so one
1568
+ * slow upstream can no longer hold back unrelated generations. */
1569
+ drain() {
1570
+ while (this.running < Math.max(1, this.concurrency)) {
1571
+ const task = this.tasks.find((item) => item.status === "queued");
1572
+ if (task === void 0) return;
1573
+ this.running += 1;
1574
+ this.runTask(task).finally(() => {
1575
+ this.running -= 1;
1576
+ this.drain();
1577
+ });
1578
+ }
1579
+ }
1580
+ async runTask(task) {
1581
+ task.status = "running";
1582
+ task.startedAt = Date.now();
1583
+ this.publish(task);
1584
+ const controller = new AbortController();
1585
+ this.controllers.set(task.id, controller);
1586
+ try {
1587
+ const result = await this.run(task.request, controller.signal);
1588
+ if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
1589
+ task.status = "completed";
1590
+ task.result = result;
1591
+ task.finishedAt = Date.now();
1592
+ this.publish(task);
1593
+ }
1594
+ } catch (error) {
1595
+ if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
1596
+ task.status = "failed";
1597
+ task.error = error instanceof Error ? error.message : String(error);
1598
+ task.finishedAt = Date.now();
1599
+ this.publish(task);
1600
+ }
1601
+ } finally {
1602
+ this.controllers.delete(task.id);
1603
+ }
1604
+ }
1605
+ publish(task) {
1606
+ const snapshot = this.snapshot(task);
1607
+ for (const listener of this.listeners) try {
1608
+ listener(snapshot);
1609
+ } catch {}
1610
+ }
1611
+ summary(task) {
1612
+ const request = { ...task.request };
1613
+ delete request.image;
1614
+ delete request.images;
1615
+ return {
1616
+ id: task.id,
1617
+ request,
1618
+ status: task.status,
1619
+ createdAt: task.createdAt,
1620
+ ...task.startedAt === void 0 ? {} : { startedAt: task.startedAt },
1621
+ ...task.finishedAt === void 0 ? {} : { finishedAt: task.finishedAt },
1622
+ ...task.error === void 0 ? {} : { error: task.error },
1623
+ resultAvailable: task.result !== void 0
1624
+ };
1625
+ }
1626
+ snapshot(task) {
1627
+ return {
1628
+ ...task,
1629
+ request: { ...task.request },
1630
+ ...task.result === void 0 ? {} : { result: task.result }
1631
+ };
1632
+ }
1633
+ };
1634
+ //#endregion
1635
+ //#region src/generation-runtime.ts
1636
+ /**
1637
+ * Shared host-side generation runtime. Both the browser routes and Agent tools
1638
+ * submit to this one queue so cancellation semantics stay identical regardless
1639
+ * of where a request originated.
1640
+ *
1641
+ * Requests carry a channel id (host-filled by the route/tool resolution); the
1642
+ * runtime picks that channel's upstream credentials, otherwise the default
1643
+ * channel.
1644
+ */
1645
+ var ImageGenerationRuntime = class {
1646
+ resolve;
1647
+ subscriptions;
1648
+ queue;
1649
+ constructor(resolve, subscriptions) {
1650
+ this.resolve = resolve;
1651
+ this.subscriptions = subscriptions;
1652
+ this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal), 4);
1653
+ }
1654
+ async run(request, signal) {
1655
+ const view = this.resolve();
1656
+ const channel = view.channels.find((candidate) => candidate.id === request.channelId) ?? view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
1657
+ if (channel === void 0) throw new ImageGenError("尚未配置任何渠道:请先打开「设置 → 生图配置」添加渠道并填写 API 地址与密钥", "no-channels");
1658
+ return channel.auth === "subscription" && channel.subscription !== void 0 ? { images: await this.generateSubscription(channel.subscription, request, signal) } : await generateImage({
1659
+ apiUrl: channel.apiUrl,
1660
+ apiKey: channel.apiKey,
1661
+ apiUrlFull: channel.apiUrlFull,
1662
+ protocol: channel.protocol
1663
+ }, request, { signal });
1664
+ }
1665
+ /** Generate images through one logged-in subscription account. */
1666
+ async generateSubscription(provider, request, signal) {
1667
+ if (this.subscriptions === void 0) throw new ImageGenError("当前部署未挂载 DSH Credentials,订阅账号不可用", "subscription-unavailable");
1668
+ const references = subscriptionReferences(request);
1669
+ const count = Math.max(1, Math.min(4, Math.trunc(request.n) || 1));
1670
+ return (await Promise.all(Array.from({ length: count }, async () => await this.subscriptions.generate({
1671
+ vendor: vendorOf(provider),
1672
+ prompt: request.prompt,
1673
+ size: request.size,
1674
+ quality: request.quality,
1675
+ ...references.length > 0 ? { referenceImages: references } : {},
1676
+ ...signal === void 0 ? {} : { signal }
1677
+ })))).flatMap((batch) => batch.flatMap((row) => {
1678
+ const mime = detectImageMime(Buffer.from(row.b64_json, "base64"));
1679
+ if (mime === void 0) throw new ImageGenError("订阅图像返回了无法识别的图片格式", "subscription-invalid-image");
1680
+ return [{
1681
+ b64: row.b64_json,
1682
+ mime,
1683
+ ...row.revisedPrompt === void 0 ? {} : { revisedPrompt: row.revisedPrompt }
1684
+ }];
1685
+ }));
1686
+ }
1687
+ };
1688
+ /** Decode the request's data-URL references into subscription wire images. */
1689
+ function subscriptionReferences(request) {
1690
+ return [request.image, ...request.images ?? []].filter((value) => typeof value === "string" && value !== "").slice(0, 5).map((value) => {
1691
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/su.exec(value);
1692
+ if (match === null || match[1] === void 0 || match[2] === void 0) throw new ImageGenError("订阅图生图收到无法解码的参考图片", "subscription-invalid-reference");
1693
+ return {
1694
+ data: Buffer.from(match[2], "base64"),
1695
+ mediaType: match[1]
1696
+ };
1697
+ });
1698
+ }
1699
+ //#endregion
1700
+ //#region src/presets.ts
1701
+ /**
1702
+ * Built-in provider catalog (presets). A preset is an official or well-known
1703
+ * OpenAI-compatible endpoint with its known model list, so the user only fills
1704
+ * in the API key. The list ships with the package and is served to the
1705
+ * settings card through a host route, so it can later be refreshed online like
1706
+ * the template library.
1707
+ *
1708
+ * Framework-free (pure data), safe for the host routes to serve directly.
1709
+ */
1710
+ const IMAGE_PRESETS = [
1711
+ {
1712
+ id: "volc-ark-seedream",
1713
+ name: "字节 · 火山方舟(Seedream)",
1714
+ apiUrl: "https://ark.cn-beijing.volces.com/api/v3",
1715
+ hint: "字节跳动官方 Seedream 文生图/图生图入口",
1716
+ models: [
1717
+ {
1718
+ alias: "seedream-5.0-pro",
1719
+ id: "seedream-5.0-pro"
1720
+ },
1721
+ {
1722
+ alias: "seedream-5.0",
1723
+ id: "seedream-5.0"
1724
+ },
1725
+ {
1726
+ alias: "seedream-4.0",
1727
+ id: "seedream-4.0"
1728
+ }
1729
+ ]
1730
+ },
1731
+ {
1732
+ id: "openai-official",
1733
+ name: "OpenAI 官方",
1734
+ apiUrl: "https://api.openai.com/v1",
1735
+ hint: "OpenAI 官方图像生成接口(gpt-image-2.5 支持手绘草图生图)",
1736
+ models: [{
1737
+ alias: "gpt-image-2.5",
1738
+ id: "gpt-image-2.5"
1739
+ }, {
1740
+ alias: "gpt-image-2",
1741
+ id: "gpt-image-2"
1742
+ }]
1743
+ },
1744
+ {
1745
+ id: "zhipu-official",
1746
+ name: "智谱 AI 官方",
1747
+ apiUrl: "https://open.bigmodel.cn/api/paas/v4",
1748
+ hint: "智谱官方 GLM-Image 图像生成接口",
1749
+ models: [{
1750
+ alias: "glm-image",
1751
+ id: "glm-image"
1752
+ }]
1753
+ },
1754
+ {
1755
+ id: "aliyun-dashscope-qwen",
1756
+ name: "阿里云百炼(Qwen-Image)",
1757
+ apiUrl: "https://dashscope.aliyuncs.com/api/v1",
1758
+ hint: "阿里云百炼 DashScope 原生接口:通义千问 Qwen-Image 系列(该渠道不可复用于提示词增强)",
1759
+ models: [
1760
+ {
1761
+ alias: "qwen-image-3.0-pro",
1762
+ id: "qwen-image-3.0-pro"
1763
+ },
1764
+ {
1765
+ alias: "qwen-image-3.0",
1766
+ id: "qwen-image-3.0"
1767
+ },
1768
+ {
1769
+ alias: "qwen-image-2.0-pro",
1770
+ id: "qwen-image-2.0-pro"
1771
+ },
1772
+ {
1773
+ alias: "qwen-image-2.0",
1774
+ id: "qwen-image-2.0"
1775
+ },
1776
+ {
1777
+ alias: "qwen-image-max",
1778
+ id: "qwen-image-max"
1779
+ },
1780
+ {
1781
+ alias: "qwen-image-plus",
1782
+ id: "qwen-image-plus"
1783
+ },
1784
+ {
1785
+ alias: "qwen-image",
1786
+ id: "qwen-image"
1787
+ }
1788
+ ]
1789
+ },
1790
+ {
1791
+ id: "minimax-official",
1792
+ name: "MiniMax 官方(国际站)",
1793
+ apiUrl: "https://api.minimax.io/v1",
1794
+ hint: "MiniMax 原生 /image_generation 接口:image-01(国内站请把地址改为 https://api.minimaxi.com/v1;/models 不列出图片模型,请直接使用预填目录)",
1795
+ models: [{
1796
+ alias: "image-01",
1797
+ id: "image-01"
1798
+ }]
1799
+ },
1800
+ {
1801
+ id: "xai-grok",
1802
+ name: "xAI(Grok)",
1803
+ apiUrl: "https://api.x.ai/v1",
1804
+ hint: "xAI 官方接口:Grok Imagine 系列",
1805
+ models: [{
1806
+ alias: "grok-imagine-image",
1807
+ id: "grok-imagine-image"
1808
+ }]
1809
+ },
1810
+ {
1811
+ id: "chatgpt-subscription",
1812
+ name: "ChatGPT 订阅(实验)",
1813
+ apiUrl: "",
1814
+ hint: "浏览器授权登录后使用 gpt-image-2.5-flare;非官方 Codex 图像接口",
1815
+ models: [{
1816
+ alias: DEFAULT_SUBSCRIPTION_MODELS["chatgpt-sub"],
1817
+ id: DEFAULT_SUBSCRIPTION_MODELS["chatgpt-sub"]
1818
+ }],
1819
+ subscription: "chatgpt-sub",
1820
+ experimental: true
1821
+ },
1822
+ {
1823
+ id: "grok-subscription",
1824
+ name: "Grok 订阅",
1825
+ apiUrl: "",
1826
+ hint: "SuperGrok / X Premium 登录后使用 grok-imagine-image-2.0",
1827
+ models: [{
1828
+ alias: DEFAULT_SUBSCRIPTION_MODELS["grok-sub"],
1829
+ id: DEFAULT_SUBSCRIPTION_MODELS["grok-sub"]
1830
+ }],
1831
+ subscription: "grok-sub"
1832
+ },
1833
+ {
1834
+ id: "google-subscription",
1835
+ name: "Google 订阅(实验)",
1836
+ apiUrl: "",
1837
+ hint: "Google 授权登录后使用 gemini-3-pro-image;非官方 Antigravity 接口",
1838
+ models: [{
1839
+ alias: DEFAULT_SUBSCRIPTION_MODELS["google-sub"],
1840
+ id: DEFAULT_SUBSCRIPTION_MODELS["google-sub"]
1841
+ }],
1842
+ subscription: "google-sub",
1843
+ experimental: true
1844
+ },
1845
+ {
1846
+ id: "openrouter-subscription",
1847
+ name: "OpenRouter 账号",
1848
+ apiUrl: "",
1849
+ hint: "OpenRouter 官方 OAuth 登录,使用 google/gemini-3-pro-image",
1850
+ models: [{
1851
+ alias: DEFAULT_SUBSCRIPTION_MODELS["openrouter-sub"],
1852
+ id: DEFAULT_SUBSCRIPTION_MODELS["openrouter-sub"]
1853
+ }],
1854
+ subscription: "openrouter-sub"
1855
+ }
1856
+ ];
1857
+ /** Look up one built-in provider by id. */
1858
+ function presetById(id) {
1859
+ return IMAGE_PRESETS.find((preset) => preset.id === id);
1860
+ }
1861
+ //#endregion
1862
+ //#region src/routes.ts
1863
+ /** Cap on JSON request bodies (settings ops and generate payloads are small). */
1864
+ const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
1865
+ /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
1866
+ function isLoopbackRequest(request) {
1867
+ const address = request.socket.remoteAddress;
1868
+ if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
1869
+ const host = request.headers.host;
1870
+ if (typeof host !== "string") return false;
1871
+ let hostUrl;
1872
+ try {
1873
+ hostUrl = new URL(`http://${host}`);
1874
+ } catch {
1875
+ return false;
1876
+ }
1877
+ if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
1878
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
1879
+ const origin = request.headers.origin;
1880
+ if (origin === void 0) return true;
1881
+ try {
1882
+ return new URL(origin).host === hostUrl.host;
1883
+ } catch {
1884
+ return false;
1885
+ }
1886
+ }
1887
+ /** One JSON response. */
1888
+ function writeJson(res, status, body) {
1889
+ const payload = JSON.stringify(body);
1890
+ res.writeHead(status, {
1891
+ "content-type": "application/json; charset=utf-8",
1892
+ "referrer-policy": "no-referrer"
1893
+ });
1894
+ res.end(payload);
1895
+ }
1896
+ /** Read a JSON request body (undefined when too large or unparseable). */
1897
+ async function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
1898
+ const chunks = [];
1899
+ let size = 0;
1900
+ for await (const chunk of req) {
1901
+ const buffer = chunk;
1902
+ size += buffer.length;
1903
+ if (size > maxBytes) return void 0;
1904
+ chunks.push(buffer);
1905
+ }
1906
+ try {
1907
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1908
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
1909
+ } catch {
1910
+ return;
1911
+ }
1912
+ }
1913
+ /** Human-readable text from an unknown thrown value. */
1914
+ function messageOf(error) {
1915
+ return error instanceof Error ? error.message : String(error);
1916
+ }
1917
+ /** Parse the durable image reference carried by an Agent tool-result view. */
1918
+ function agentImageRefFrom(rawUrl) {
1919
+ if (rawUrl === void 0) return void 0;
1920
+ let url;
1921
+ try {
1922
+ url = new URL(rawUrl, "http://localhost");
1923
+ } catch {
1924
+ return;
1925
+ }
1926
+ if (url.pathname !== "/api/dsh-imagegen/agent-image") return void 0;
1927
+ const attachmentId = url.searchParams.get("attachment_id") ?? "";
1928
+ const mediaType = url.searchParams.get("media_type") ?? "";
1929
+ const bytes = Number(url.searchParams.get("bytes"));
1930
+ const width = Number(url.searchParams.get("width"));
1931
+ const height = Number(url.searchParams.get("height"));
1932
+ if (attachmentId === "" || !isImageMediaType(mediaType) || !Number.isSafeInteger(bytes) || bytes < 1 || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) return void 0;
1933
+ return {
1934
+ attachmentId,
1935
+ mediaType,
1936
+ bytes,
1937
+ width,
1938
+ height
1939
+ };
1940
+ }
1941
+ function isImageMediaType(value) {
1942
+ return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
1943
+ }
1944
+ function imageDataUrl$1(value) {
1945
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/su.exec(value.trim());
1946
+ if (match === null || match[1] === void 0 || match[2] === void 0) return void 0;
1947
+ const data = Buffer.from(match[2], "base64");
1948
+ return data.byteLength === 0 ? void 0 : {
1949
+ mediaType: match[1],
1950
+ data
1951
+ };
1952
+ }
1953
+ /** Project one settings descriptor onto the bridge wire view. */
1954
+ function toView(descriptor, namespace = String(descriptor.ns)) {
1955
+ return {
1956
+ ns: namespace,
1957
+ schema: descriptor.schema,
1958
+ value: descriptor.value,
1959
+ ...descriptor.base === void 0 ? {} : { base: descriptor.base },
1960
+ ...descriptor.user === void 0 ? {} : { user: descriptor.user },
1961
+ ...descriptor.secrets === void 0 ? {} : { secrets: descriptor.secrets.map((secret) => ({
1962
+ path: [...secret.path],
1963
+ set: secret.set
1964
+ })) },
1965
+ revision: descriptor.revision
1966
+ };
1967
+ }
1968
+ /** Map a subscription provider to its manager vendor id. */
1969
+ function vendorOfSubscription(provider) {
1970
+ if (provider === "chatgpt-sub") return "codex";
1971
+ if (provider === "google-sub") return "antigravity";
1972
+ if (provider === "openrouter-sub") return "openrouter";
1973
+ return "grok";
1974
+ }
1975
+ /** Map a seam failure onto the bridge refusal envelope. */
1976
+ function failureOf(error) {
1977
+ if (error instanceof SettingsConflictError) return {
1978
+ ok: false,
1979
+ code: "settings-conflict",
1980
+ message: error.message
1981
+ };
1982
+ return {
1983
+ ok: false,
1984
+ code: "settings-rejected",
1985
+ message: error instanceof Error ? error.message : String(error)
1986
+ };
1987
+ }
1988
+ /** Validate a submitted generation request (the browser's generate payload). */
1989
+ function parseGenerateRequest(body) {
1990
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1991
+ if (prompt === "") return void 0;
1992
+ return {
1993
+ mode: body.mode === "edit" ? "edit" : "text",
1994
+ model: typeof body.model === "string" ? body.model : "",
1995
+ prompt,
1996
+ size: typeof body.size === "string" ? body.size : "auto",
1997
+ quality: typeof body.quality === "string" ? body.quality : "auto",
1998
+ n: typeof body.n === "number" ? body.n : 1,
1999
+ detail: typeof body.detail === "string" ? body.detail : "",
2000
+ ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {},
2001
+ ...Array.isArray(body.images) ? { images: body.images.filter((item) => typeof item === "string" && item !== "").slice(0, 4) } : {},
2002
+ ...typeof body.refName === "string" && body.refName !== "" ? { refName: body.refName } : {},
2003
+ ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {}
2004
+ };
2005
+ }
2006
+ /**
2007
+ * Build every /api/dsh-imagegen route.
2008
+ * @param deps - settings seam + config resolver.
2009
+ * @returns the route registrations.
2010
+ */
2011
+ function makeRoutes(deps) {
2012
+ const resolvePrompt = deps.resolvePrompt ?? (() => ({
2013
+ apiUrl: "",
2014
+ apiKey: "",
2015
+ model: ""
2016
+ }));
2017
+ const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(void 0));
2018
+ const settingsNamespace = IMAGEGEN_SETTINGS_NAMESPACE;
2019
+ /** The current channel view: the channel-aware resolver, or a synthesized
2020
+ * single default channel from the legacy flat upstream config (tests and
2021
+ * older hosts). */
2022
+ const channelViewOf = () => {
2023
+ if (deps.resolveChannels !== void 0) return deps.resolveChannels();
2024
+ const upstream = deps.resolve();
2025
+ const models = normalizeImageModels(resolveImageModels()).map((id) => ({
2026
+ alias: id,
2027
+ id
2028
+ }));
2029
+ if (upstream.apiUrl.trim() === "" && models.length === 0) return {
2030
+ channels: [],
2031
+ defaultChannelId: ""
2032
+ };
2033
+ return {
2034
+ channels: [{
2035
+ id: "default",
2036
+ preset: "",
2037
+ name: "默认渠道",
2038
+ apiUrl: upstream.apiUrl,
2039
+ apiUrlFull: false,
2040
+ apiKey: upstream.apiKey,
2041
+ models
2042
+ }],
2043
+ defaultChannelId: "default"
2044
+ };
2045
+ };
2046
+ const runtime = deps.runtime ?? new ImageGenerationRuntime(channelViewOf);
2047
+ /** Resolve an alias (or the channel fallback) into a concrete generation
2048
+ * request: picks the channel (explicit then default), maps alias → upstream
2049
+ * id, and fills the channel snapshot kept on history entries. */
2050
+ const resolveChannelRequest = (request) => {
2051
+ const view = channelViewOf();
2052
+ if (view.channels.length === 0) return {
2053
+ ok: false,
2054
+ code: "no-channels",
2055
+ message: "尚未配置任何渠道:请先打开「设置 → 生图配置」添加渠道并填写 API 地址与密钥"
2056
+ };
2057
+ const explicit = view.channels.find((candidate) => candidate.id === request.channelId);
2058
+ const defaults = view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
2059
+ const target = explicit ?? defaults;
2060
+ const asked = request.model.trim();
2061
+ if (asked === "") {
2062
+ const alias = target?.models[0]?.alias ?? "";
2063
+ if (alias === "") return {
2064
+ ok: false,
2065
+ code: "no-models",
2066
+ message: `渠道「${target?.name ?? ""}」尚未配置模型,请先在设置中添加`
2067
+ };
2068
+ const mapping = target.models.find((model) => model.alias === alias);
2069
+ return {
2070
+ ok: true,
2071
+ request: {
2072
+ ...request,
2073
+ model: alias,
2074
+ upstream: mapping.id,
2075
+ channelId: target.id,
2076
+ channel: target.name
2077
+ }
2078
+ };
2079
+ }
2080
+ const hosting = view.channels.filter((channel) => channel.models.some((model) => model.alias === asked));
2081
+ if (hosting.length === 0) return {
2082
+ ok: false,
2083
+ code: "image-model-not-configured",
2084
+ message: `模型「${asked}」未在任一渠道配置;可用模型:${[...new Set(view.channels.flatMap((channel) => channel.models.map((model) => model.alias)))].join("、") || "(无)"}`
2085
+ };
2086
+ const picked = target !== void 0 && target.models.some((model) => model.alias === asked) ? target : hosting[0];
2087
+ const mapping = picked.models.find((model) => model.alias === asked);
2088
+ return {
2089
+ ok: true,
2090
+ request: {
2091
+ ...request,
2092
+ model: asked,
2093
+ upstream: mapping.id,
2094
+ channelId: picked.id,
2095
+ channel: picked.name
2096
+ }
2097
+ };
2098
+ };
2099
+ const guard = (req, res, method) => {
2100
+ if (!isLoopbackRequest(req)) {
2101
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2102
+ return false;
2103
+ }
2104
+ if (req.method !== method) {
2105
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
2106
+ return false;
2107
+ }
2108
+ return true;
2109
+ };
2110
+ return [
2111
+ ...deps.attachments?.saveImage === void 0 || deps.pendingConversationImages === void 0 ? [] : [{
2112
+ kind: "exact",
2113
+ path: CONVERSATION_IMAGE_API,
2114
+ handler: async (req, res) => {
2115
+ if (!guard(req, res, "POST")) return;
2116
+ const body = await readJsonBody(req, MAX_JSON_BODY_BYTES);
2117
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId.trim() : "";
2118
+ const dataUrl = typeof body?.dataUrl === "string" ? imageDataUrl$1(body.dataUrl) : void 0;
2119
+ if (sessionId === "" || dataUrl === void 0) {
2120
+ writeJson(res, 200, {
2121
+ ok: false,
2122
+ code: "bad-request",
2123
+ message: "sessionId and image data are required"
2124
+ });
2125
+ return;
2126
+ }
2127
+ try {
2128
+ const ref = await deps.attachments.saveImage({
2129
+ data: dataUrl.data,
2130
+ mediaType: dataUrl.mediaType,
2131
+ ...typeof body?.name === "string" && body.name.trim() !== "" ? { name: body.name.trim() } : {}
2132
+ });
2133
+ deps.pendingConversationImages.set(sessionId, ref);
2134
+ writeJson(res, 200, { ok: true });
2135
+ } catch (error) {
2136
+ writeJson(res, 200, {
2137
+ ok: false,
2138
+ code: "image-save-failed",
2139
+ message: messageOf(error)
2140
+ });
2141
+ }
2142
+ }
2143
+ }],
2144
+ ...deps.attachments === void 0 ? [] : [{
2145
+ kind: "prefix",
2146
+ path: AGENT_IMAGE_API,
2147
+ handler: async (req, res) => {
2148
+ if (!isLoopbackRequest(req)) {
2149
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2150
+ return;
2151
+ }
2152
+ if (req.method !== "GET") {
2153
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
2154
+ return;
2155
+ }
2156
+ const ref = agentImageRefFrom(req.url);
2157
+ if (ref === void 0) {
2158
+ writeJson(res, 400, { error: "invalid image reference" });
2159
+ return;
2160
+ }
2161
+ try {
2162
+ const stored = await deps.attachments.readImage(ref);
2163
+ res.writeHead(200, {
2164
+ "content-type": stored.ref.mediaType,
2165
+ "content-length": stored.data.byteLength,
2166
+ "cache-control": "private, max-age=3600"
2167
+ });
2168
+ res.end(Buffer.from(stored.data));
2169
+ } catch {
2170
+ writeJson(res, 404, { error: "image attachment not found" });
2171
+ }
2172
+ }
2173
+ }],
2174
+ {
2175
+ kind: "exact",
2176
+ path: IMAGE_MODEL_API.models,
2177
+ handler: async (req, res) => {
2178
+ if (!guard(req, res, "POST")) return;
2179
+ const body = await readJsonBody(req);
2180
+ const view = channelViewOf();
2181
+ const stored = view.channels.find((candidate) => candidate.id === (typeof body?.channelId === "string" ? body.channelId : void 0)) ?? view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
2182
+ const protocol = isChannelProtocolPreference(body?.protocol) ? body.protocol : stored?.protocol;
2183
+ const upstream = {
2184
+ apiUrl: typeof body?.apiUrl === "string" && body.apiUrl.trim() !== "" ? body.apiUrl.trim() : stored?.apiUrl ?? "",
2185
+ apiKey: typeof body?.apiKey === "string" && body.apiKey.trim() !== "" ? body.apiKey.trim() : stored?.apiKey ?? "",
2186
+ apiUrlFull: typeof body?.apiUrlFull === "boolean" ? body.apiUrlFull : stored?.apiUrlFull === true,
2187
+ ...protocol === void 0 ? {} : { protocol }
2188
+ };
2189
+ try {
2190
+ writeJson(res, 200, {
2191
+ ok: true,
2192
+ models: await listImageModels(upstream)
2193
+ });
2194
+ } catch (error) {
2195
+ writeJson(res, 200, {
2196
+ ok: false,
2197
+ code: "image-models-failed",
2198
+ message: messageOf(error)
2199
+ });
2200
+ }
2201
+ }
2202
+ },
2203
+ {
2204
+ kind: "exact",
2205
+ path: PRESETS_API,
2206
+ handler: async (req, res) => {
2207
+ if (!guard(req, res, "POST")) return;
2208
+ writeJson(res, 200, {
2209
+ ok: true,
2210
+ presets: IMAGE_PRESETS.map((preset) => ({
2211
+ id: preset.id,
2212
+ name: preset.name,
2213
+ apiUrl: preset.apiUrl,
2214
+ hint: preset.hint,
2215
+ models: preset.models,
2216
+ ...preset.subscription === void 0 ? {} : { subscription: preset.subscription },
2217
+ ...preset.experimental === void 0 ? {} : { experimental: preset.experimental }
2218
+ }))
2219
+ });
2220
+ }
2221
+ },
2222
+ {
2223
+ kind: "exact",
2224
+ path: PROMPT_ENHANCE_API.models,
2225
+ handler: async (req, res) => {
2226
+ if (!guard(req, res, "POST")) return;
2227
+ try {
2228
+ writeJson(res, 200, {
2229
+ ok: true,
2230
+ models: await listPromptModels(resolvePrompt())
2231
+ });
2232
+ } catch (error) {
2233
+ writeJson(res, 200, {
2234
+ ok: false,
2235
+ code: "prompt-models-failed",
2236
+ message: messageOf(error)
2237
+ });
2238
+ }
2239
+ }
2240
+ },
2241
+ {
2242
+ kind: "exact",
2243
+ path: PROMPT_ENHANCE_API.enhance,
2244
+ handler: async (req, res) => {
2245
+ if (!guard(req, res, "POST")) return;
2246
+ const body = await readJsonBody(req);
2247
+ const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
2248
+ if (prompt === "") {
2249
+ writeJson(res, 200, {
2250
+ ok: false,
2251
+ code: "bad-request",
2252
+ message: "prompt is required"
2253
+ });
2254
+ return;
2255
+ }
2256
+ try {
2257
+ writeJson(res, 200, {
2258
+ ok: true,
2259
+ prompt: await enhancePrompt(resolvePrompt(), prompt)
2260
+ });
2261
+ } catch (error) {
2262
+ writeJson(res, 200, {
2263
+ ok: false,
2264
+ code: "prompt-enhance-failed",
2265
+ message: messageOf(error)
2266
+ });
2267
+ }
2268
+ }
2269
+ },
2270
+ {
2271
+ kind: "exact",
2272
+ path: SETTINGS_API.describe,
2273
+ handler: async (req, res) => {
2274
+ if (!guard(req, res, "POST")) return;
2275
+ const descriptor = deps.settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === settingsNamespace);
2276
+ writeJson(res, 200, {
2277
+ ok: true,
2278
+ value: {
2279
+ namespaces: descriptor === void 0 ? [] : [toView(descriptor, IMAGEGEN_SETTINGS_NAMESPACE)],
2280
+ writable: deps.settings.writable !== false
2281
+ }
2282
+ });
2283
+ }
2284
+ },
2285
+ {
2286
+ kind: "exact",
2287
+ path: SETTINGS_API.mutate,
2288
+ handler: async (req, res) => {
2289
+ if (!guard(req, res, "POST")) return;
2290
+ const body = await readJsonBody(req);
2291
+ if (body === void 0) {
2292
+ writeJson(res, 200, {
2293
+ ok: false,
2294
+ code: "settings-rejected",
2295
+ message: "unreadable JSON body"
2296
+ });
2297
+ return;
2298
+ }
2299
+ if ((typeof body.ns === "string" ? body.ns : "") !== "imagegen" || !Array.isArray(body.ops)) {
2300
+ writeJson(res, 200, {
2301
+ ok: false,
2302
+ code: "settings-rejected",
2303
+ message: "malformed bridge settings request"
2304
+ });
2305
+ return;
2306
+ }
2307
+ const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : void 0;
2308
+ try {
2309
+ await deps.settings.mutate(settingsNamespace, body.ops, expectedRevision);
2310
+ } catch (error) {
2311
+ writeJson(res, 200, failureOf(error));
2312
+ return;
2313
+ }
2314
+ const descriptor = deps.settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === settingsNamespace);
2315
+ if (descriptor === void 0) {
2316
+ writeJson(res, 200, {
2317
+ ok: false,
2318
+ code: "internal",
2319
+ message: `settings namespace "${settingsNamespace}" was disposed after the mutate`
2320
+ });
2321
+ return;
2322
+ }
2323
+ writeJson(res, 200, {
2324
+ ok: true,
2325
+ value: toView(descriptor, IMAGEGEN_SETTINGS_NAMESPACE)
2326
+ });
2327
+ }
2328
+ },
2329
+ {
2330
+ kind: "exact",
2331
+ path: GENERATE_API,
2332
+ handler: async (req, res) => {
2333
+ if (!guard(req, res, "POST")) return;
2334
+ const body = await readJsonBody(req);
2335
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2336
+ if (parsed === void 0) {
2337
+ writeJson(res, 200, {
2338
+ ok: false,
2339
+ code: "bad-request",
2340
+ message: "prompt is required"
2341
+ });
2342
+ return;
2343
+ }
2344
+ const resolved = resolveChannelRequest(parsed);
2345
+ if (!resolved.ok) {
2346
+ writeJson(res, 200, {
2347
+ ok: false,
2348
+ code: resolved.code,
2349
+ message: resolved.message
2350
+ });
2351
+ return;
2352
+ }
2353
+ try {
2354
+ writeJson(res, 200, {
2355
+ ok: true,
2356
+ ...await runtime.run(resolved.request)
2357
+ });
2358
+ } catch (error) {
2359
+ const message = error instanceof Error ? error.message : String(error);
2360
+ writeJson(res, 200, {
2361
+ ok: false,
2362
+ code: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "generate-failed",
2363
+ message
2364
+ });
2365
+ }
2366
+ }
2367
+ },
2368
+ {
2369
+ kind: "exact",
2370
+ path: TASK_API.submit,
2371
+ handler: async (req, res) => {
2372
+ if (!guard(req, res, "POST")) return;
2373
+ const body = await readJsonBody(req);
2374
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2375
+ if (parsed === void 0) {
2376
+ writeJson(res, 200, {
2377
+ ok: false,
2378
+ code: "bad-request",
2379
+ message: "prompt is required"
2380
+ });
2381
+ return;
2382
+ }
2383
+ const resolved = resolveChannelRequest(parsed);
2384
+ if (!resolved.ok) {
2385
+ writeJson(res, 200, {
2386
+ ok: false,
2387
+ code: resolved.code,
2388
+ message: resolved.message
2389
+ });
2390
+ return;
2391
+ }
2392
+ writeJson(res, 200, {
2393
+ ok: true,
2394
+ task: runtime.queue.submit(resolved.request)
2395
+ });
2396
+ }
2397
+ },
2398
+ {
2399
+ kind: "exact",
2400
+ path: TASK_API.list,
2401
+ handler: async (req, res) => {
2402
+ if (!guard(req, res, "POST")) return;
2403
+ writeJson(res, 200, {
2404
+ ok: true,
2405
+ tasks: runtime.queue.summaries()
2406
+ });
2407
+ }
2408
+ },
2409
+ {
2410
+ kind: "exact",
2411
+ path: TASK_API.get,
2412
+ handler: async (req, res) => {
2413
+ if (!guard(req, res, "POST")) return;
2414
+ const body = await readJsonBody(req);
2415
+ const task = typeof body?.id === "string" ? runtime.queue.get(body.id) : void 0;
2416
+ if (task === void 0) {
2417
+ writeJson(res, 200, {
2418
+ ok: false,
2419
+ code: "not-found",
2420
+ message: "task not found"
2421
+ });
2422
+ return;
2423
+ }
2424
+ writeJson(res, 200, {
2425
+ ok: true,
2426
+ task
2427
+ });
2428
+ }
2429
+ },
2430
+ {
2431
+ kind: "exact",
2432
+ path: TASK_API.cancel,
2433
+ handler: async (req, res) => {
2434
+ if (!guard(req, res, "POST")) return;
2435
+ const body = await readJsonBody(req);
2436
+ const task = typeof body?.id === "string" ? runtime.queue.cancel(body.id) : void 0;
2437
+ if (task === void 0) {
2438
+ writeJson(res, 200, {
2439
+ ok: false,
2440
+ code: "not-found",
2441
+ message: "task not found"
2442
+ });
2443
+ return;
2444
+ }
2445
+ writeJson(res, 200, {
2446
+ ok: true,
2447
+ task
2448
+ });
2449
+ }
2450
+ },
2451
+ {
2452
+ kind: "exact",
2453
+ path: TASK_API.retry,
2454
+ handler: async (req, res) => {
2455
+ if (!guard(req, res, "POST")) return;
2456
+ const body = await readJsonBody(req);
2457
+ const task = typeof body?.id === "string" ? runtime.queue.retry(body.id) : void 0;
2458
+ if (task === void 0) {
2459
+ writeJson(res, 200, {
2460
+ ok: false,
2461
+ code: "not-found",
2462
+ message: "task not found"
2463
+ });
2464
+ return;
2465
+ }
2466
+ writeJson(res, 200, {
2467
+ ok: true,
2468
+ task
2469
+ });
2470
+ }
2471
+ },
2472
+ ...deps.subscriptions === void 0 ? [] : [{
2473
+ kind: "exact",
2474
+ path: SUBSCRIPTION_API.status,
2475
+ handler: async (req, res) => {
2476
+ if (!guard(req, res, "POST")) return;
2477
+ const statuses = {};
2478
+ for (const provider of SUBSCRIPTION_PROVIDERS) {
2479
+ const vendor = vendorOfSubscription(provider);
2480
+ const status = await deps.subscriptions.loginStatus(vendor);
2481
+ const error = deps.subscriptions.lastLoginError(vendor);
2482
+ statuses[provider] = status.state === "logged-in" ? {
2483
+ state: status.state,
2484
+ email: status.email
2485
+ } : {
2486
+ state: status.state,
2487
+ ...error === void 0 ? {} : { error }
2488
+ };
2489
+ }
2490
+ writeJson(res, 200, {
2491
+ ok: true,
2492
+ statuses
2493
+ });
2494
+ }
2495
+ }, {
2496
+ kind: "exact",
2497
+ path: SUBSCRIPTION_API.login,
2498
+ handler: async (req, res) => {
2499
+ if (!guard(req, res, "POST")) return;
2500
+ const body = await readJsonBody(req);
2501
+ const provider = typeof body?.provider === "string" ? body.provider : "";
2502
+ if (!SUBSCRIPTION_PROVIDERS.includes(provider)) {
2503
+ writeJson(res, 200, {
2504
+ ok: false,
2505
+ code: "invalid-provider",
2506
+ message: "unknown subscription provider"
2507
+ });
2508
+ return;
2509
+ }
2510
+ const vendor = vendorOfSubscription(provider);
2511
+ try {
2512
+ if (body?.action === "logout") {
2513
+ await deps.subscriptions.logout(vendor);
2514
+ writeJson(res, 200, { ok: true });
2515
+ return;
2516
+ }
2517
+ if (body?.action === "complete") {
2518
+ const input = typeof body.input === "string" ? body.input : "";
2519
+ await deps.subscriptions.completeLogin(vendor, input);
2520
+ writeJson(res, 200, { ok: true });
2521
+ return;
2522
+ }
2523
+ writeJson(res, 200, {
2524
+ ok: true,
2525
+ ...await deps.subscriptions.beginLogin(vendor)
2526
+ });
2527
+ } catch (error) {
2528
+ writeJson(res, 200, {
2529
+ ok: false,
2530
+ code: "subscription-login-failed",
2531
+ message: messageOf(error)
2532
+ });
2533
+ }
2534
+ }
2535
+ }]
2536
+ ];
2537
+ }
2538
+ //#endregion
2539
+ //#region src/agent-image-tools.ts
2540
+ const imageRefSchema = {
2541
+ type: "object",
2542
+ additionalProperties: false,
2543
+ properties: {
2544
+ attachment_id: {
2545
+ type: "string",
2546
+ required: true
2547
+ },
2548
+ media_type: {
2549
+ type: "string",
2550
+ required: true
2551
+ },
2552
+ bytes: {
2553
+ type: "integer",
2554
+ required: true
2555
+ },
2556
+ width: {
2557
+ type: "integer",
2558
+ required: true
2559
+ },
2560
+ height: {
2561
+ type: "integer",
2562
+ required: true
2563
+ },
2564
+ name: { type: "string" }
2565
+ }
2566
+ };
2567
+ const taskResultSchema = {
2568
+ type: "object",
2569
+ additionalProperties: false,
2570
+ properties: {
2571
+ task_id: {
2572
+ type: "string",
2573
+ required: true
2574
+ },
2575
+ status: {
2576
+ type: "string",
2577
+ required: true
2578
+ },
2579
+ message: {
2580
+ type: "string",
2581
+ required: true
2582
+ },
2583
+ error: { type: "string" },
2584
+ images: {
2585
+ type: "array",
2586
+ required: true,
2587
+ items: imageRefSchema
2588
+ }
2589
+ }
2590
+ };
2591
+ /** Agent calls stay pending until the provider and history write settle. */
2592
+ const AGENT_GENERATION_TIMEOUT_MS = 3e5;
2593
+ function ensureAgentImageConfigured(config) {
2594
+ if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings → Image settings and enable it.", "plugin-disabled");
2595
+ if (!config.allowAgentImageGeneration) throw new ImageGenError("Agent image generation is disabled in Settings → Image settings.", "agent-generation-disabled");
2596
+ if (!config.channels.some((channel) => channel.apiUrl.trim() !== "" && channel.apiKey.trim() !== "")) throw new ImageGenError("Image API credentials are not configured. Open Settings → Image settings, add a channel and fill in its API URL and API key.", "image-api-not-configured");
2597
+ }
2598
+ /** Resolve a configured image alias and its owning channel. */
2599
+ function resolveAgentImageModel(config, requested) {
2600
+ const entries = config.channels.flatMap((channel) => channel.models.map((model) => ({
2601
+ channel,
2602
+ alias: model.alias,
2603
+ upstream: model.id
2604
+ })));
2605
+ if (entries.length === 0) throw new ImageGenError("No image models are configured. Open Settings → Image settings and add a channel with at least one model.", "no-models-configured");
2606
+ const wanted = typeof requested === "string" && requested.trim() !== "" ? requested.trim() : typeof config.defaultModel === "string" ? config.defaultModel.trim() : "";
2607
+ if (wanted === "") {
2608
+ if (entries.length === 1) return entries[0];
2609
+ throw new ImageGenError(`Multiple image models are available — ask the user which channel and model to use, then call this tool again with that exact model name. Options: ${config.channels.flatMap((channel) => channel.models.map((model) => `"${channel.name} · ${model.alias}"`)).join(", ")}.`, "model-choice-required");
2610
+ }
2611
+ const hosting = entries.filter((entry) => entry.alias === wanted);
2612
+ if (hosting.length === 0) throw new ImageGenError(`Image model "${wanted}" is not configured in any channel. Choose one of: ${[...new Set(entries.map((entry) => entry.alias))].join(", ")}.`, "image-model-not-configured");
2613
+ return hosting.find((entry) => entry.channel.id === config.defaultChannelId) ?? hosting[0];
2614
+ }
2615
+ function findAgentImageTask(runtime, id) {
2616
+ const task = runtime.queue.list().find((candidate) => candidate.id === id);
2617
+ if (task === void 0) throw new ImageGenError(`Image generation task ${id} was not found.`, "task-not-found");
2618
+ return task;
2619
+ }
2620
+ /** Wait for a queue task without sending anything through the chat model. */
2621
+ function waitForAgentImageTask(runtime, id, signal) {
2622
+ return new Promise((resolveTask, rejectTask) => {
2623
+ let settled = false;
2624
+ let dispose = () => {};
2625
+ let timer;
2626
+ let abort = () => {};
2627
+ const cleanup = () => {
2628
+ dispose();
2629
+ if (timer !== void 0) clearTimeout(timer);
2630
+ signal?.removeEventListener("abort", abort);
2631
+ };
2632
+ const resolve = (task) => {
2633
+ if (settled) return;
2634
+ settled = true;
2635
+ cleanup();
2636
+ resolveTask(task);
2637
+ };
2638
+ const reject = (error) => {
2639
+ if (settled) return;
2640
+ settled = true;
2641
+ cleanup();
2642
+ rejectTask(error);
2643
+ };
2644
+ abort = () => {
2645
+ if (settled) return;
2646
+ const reason = signal?.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Image generation was cancelled.");
2647
+ settled = true;
2648
+ cleanup();
2649
+ runtime.queue.cancel(id);
2650
+ rejectTask(reason);
2651
+ };
2652
+ const onChange = (updated) => {
2653
+ if (updated.id === id && isFinalTask(updated)) resolve(updated);
2654
+ };
2655
+ if (signal?.aborted === true) {
2656
+ abort();
2657
+ return;
2658
+ }
2659
+ dispose = runtime.queue.subscribe(onChange);
2660
+ signal?.addEventListener("abort", abort, { once: true });
2661
+ timer = setTimeout(() => {
2662
+ if (settled) return;
2663
+ const timeout = new ImageGenError(`Image generation task ${id} timed out after ${AGENT_GENERATION_TIMEOUT_MS / 1e3} seconds.`, "generation-timeout");
2664
+ settled = true;
2665
+ cleanup();
2666
+ runtime.queue.cancel(id);
2667
+ rejectTask(timeout);
2668
+ }, AGENT_GENERATION_TIMEOUT_MS);
2669
+ let current;
2670
+ try {
2671
+ current = findAgentImageTask(runtime, id);
2672
+ } catch (error) {
2673
+ reject(error);
2674
+ return;
2675
+ }
2676
+ if (isFinalTask(current)) resolve(current);
2677
+ });
2678
+ }
2679
+ /** Submit the same image-edit request used by the Agent tool. */
2680
+ async function submitAgentImageEdit(attachments, runtime, resolve, input) {
2681
+ const config = resolve();
2682
+ ensureAgentImageConfigured(config);
2683
+ const reference = await attachments.readImage(input.sourceImage, input.signal);
2684
+ const fallbackModel = (config.channels.find((channel) => channel.id === config.defaultChannelId) ?? config.channels[0])?.models[0]?.alias ?? config.channels.flatMap((channel) => channel.models)[0]?.alias ?? "";
2685
+ const picked = resolveAgentImageModel(config, config.defaultModel?.trim() || fallbackModel);
2686
+ return waitForAgentImageTask(runtime, runtime.queue.submit({
2687
+ mode: "edit",
2688
+ model: picked.alias,
2689
+ upstream: picked.upstream,
2690
+ channelId: picked.channel.id,
2691
+ channel: picked.channel.name,
2692
+ prompt: input.prompt.trim(),
2693
+ size: "auto",
2694
+ quality: "auto",
2695
+ n: 1,
2696
+ detail: "",
2697
+ image: imageDataUrl(reference),
2698
+ ...reference.ref.name === void 0 ? {} : { refName: reference.ref.name }
2699
+ }).id, input.signal);
2700
+ }
2701
+ function acceptedMediaType(value) {
2702
+ return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
2703
+ }
2704
+ function projectRef(ref) {
2705
+ return {
2706
+ attachment_id: String(ref.attachmentId),
2707
+ media_type: ref.mediaType,
2708
+ bytes: ref.bytes,
2709
+ width: ref.width,
2710
+ height: ref.height,
2711
+ ...ref.name === void 0 ? {} : { name: ref.name }
2712
+ };
2713
+ }
2714
+ function restoreRef(value) {
2715
+ if (!acceptedMediaType(value.media_type)) throw new ImageGenError("source_image.media_type is not a supported image type", "bad-reference-image");
2716
+ if (!Number.isInteger(value.bytes) || value.bytes < 1 || !Number.isInteger(value.width) || value.width < 1 || !Number.isInteger(value.height) || value.height < 1) throw new ImageGenError("source_image metadata is invalid", "bad-reference-image");
2717
+ return {
2718
+ attachmentId: value.attachment_id,
2719
+ mediaType: value.media_type,
2720
+ bytes: value.bytes,
2721
+ width: value.width,
2722
+ height: value.height,
2723
+ ...value.name === void 0 ? {} : { name: value.name }
2724
+ };
2725
+ }
2726
+ function imageDataUrl(image) {
2727
+ return `data:${image.ref.mediaType};base64,${Buffer.from(image.data).toString("base64")}`;
2728
+ }
2729
+ function renderTaskResult(value) {
2730
+ return [{
2731
+ type: "text",
2732
+ text: JSON.stringify(value)
2733
+ }];
2734
+ }
2735
+ /** The UI-only projection that keeps generated images beside the tool call. */
2736
+ function imagePresentationMeta(value) {
2737
+ return { images: value.images.map((image) => {
2738
+ const ref = {
2739
+ attachment_id: image.attachment_id,
2740
+ media_type: image.media_type,
2741
+ bytes: image.bytes,
2742
+ width: image.width,
2743
+ height: image.height
2744
+ };
2745
+ if (image.name !== void 0) ref.name = image.name;
2746
+ return ref;
2747
+ }) };
2748
+ }
2749
+ function imageBlocksFromMeta(meta) {
2750
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return [];
2751
+ const images = meta.images;
2752
+ if (!Array.isArray(images)) return [];
2753
+ return images.flatMap((value) => {
2754
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
2755
+ const raw = value;
2756
+ if (typeof raw.attachment_id !== "string" || typeof raw.media_type !== "string" || typeof raw.bytes !== "number" || typeof raw.width !== "number" || typeof raw.height !== "number") return [];
2757
+ try {
2758
+ return [{
2759
+ type: "image",
2760
+ attachment: restoreRef({
2761
+ attachment_id: raw.attachment_id,
2762
+ media_type: raw.media_type,
2763
+ bytes: raw.bytes,
2764
+ width: raw.width,
2765
+ height: raw.height,
2766
+ ...typeof raw.name === "string" ? { name: raw.name } : {}
2767
+ })
2768
+ }];
2769
+ } catch {
2770
+ return [];
2771
+ }
2772
+ });
2773
+ }
2774
+ /** Rehydrate image attachments for the host-computed tool result view only. */
2775
+ function presentImageResult(_args, result) {
2776
+ const content = result.isError ? [] : imageBlocksFromMeta(result.meta);
2777
+ return content.length === 0 ? void 0 : {
2778
+ card: "generic",
2779
+ content
2780
+ };
2781
+ }
2782
+ /** Register the global Agent tools and unregister them with the plugin lifecycle. */
2783
+ function registerAgentImageTools(ctx, runtime, resolve) {
2784
+ const attachmentRefs = /* @__PURE__ */ new Map();
2785
+ const ensureConfigured = () => {
2786
+ ensureAgentImageConfigured(resolve());
2787
+ };
2788
+ const resolveModel = (requested) => resolveAgentImageModel(resolve(), requested);
2789
+ const materializeTaskImages = (task) => {
2790
+ if (task.status !== "completed") return Promise.resolve([]);
2791
+ const existing = attachmentRefs.get(task.id);
2792
+ if (existing !== void 0) return existing;
2793
+ const pending = ctx.attachments.saveImages((task.result?.images ?? []).map((image, index) => toSaveImage(image, task.id, index))).then((refs) => refs.map(projectRef));
2794
+ attachmentRefs.set(task.id, pending);
2795
+ pending.catch(() => {
2796
+ if (attachmentRefs.get(task.id) === pending) attachmentRefs.delete(task.id);
2797
+ });
2798
+ return pending;
2799
+ };
2800
+ const taskResult = async (task) => {
2801
+ const images = await materializeTaskImages(task);
2802
+ return {
2803
+ task_id: task.id,
2804
+ status: task.status,
2805
+ message: task.status === "completed" ? "Generation completed. The images are shown beside this tool call and can be reused as source_image in edit_image." : task.status === "failed" ? "Generation failed." : task.status === "cancelled" ? "Generation was cancelled." : "Generation is still running. Query the task again when you need its current status.",
2806
+ ...task.error === void 0 ? {} : { error: task.error },
2807
+ images
2808
+ };
2809
+ };
2810
+ const findTask = (id) => findAgentImageTask(runtime, id);
2811
+ const waitForTask = (id, signal) => waitForAgentImageTask(runtime, id, signal);
2812
+ const disposers = [
2813
+ ctx.tools.register(defineTool({
2814
+ name: "generate_image",
2815
+ description: "Generate an image. By default this tool call stays pending until the task reaches a final state; completed images are shown beside this tool call, while the model receives their attachment references, without creating a user message. Set wait_for_completion to false for background mode, then use get_image_generation_task explicitly. Only use models configured for this plugin; omit model to use the configured default image model (or the first model when none is configured).",
2816
+ parameters: {
2817
+ prompt: {
2818
+ type: "string",
2819
+ required: true,
2820
+ description: "Detailed image-generation prompt."
2821
+ },
2822
+ model: {
2823
+ type: "string",
2824
+ description: "One of the configured image models. Defaults to the configured default image model, or the first model when none is configured."
2825
+ },
2826
+ size: {
2827
+ type: "string",
2828
+ description: "Aspect ratio such as 1:1, 16:9, 9:16, or auto."
2829
+ },
2830
+ quality: {
2831
+ type: "string",
2832
+ description: "auto, 1k, 2k, or 4k."
2833
+ },
2834
+ count: {
2835
+ type: "integer",
2836
+ description: "Number of images, 1 to 4. Defaults to 1."
2837
+ },
2838
+ detail: {
2839
+ type: "string",
2840
+ description: "Optional provider detail value, for example standard or high."
2841
+ },
2842
+ wait_for_completion: {
2843
+ type: "boolean",
2844
+ description: "Wait for images and return them in this tool result. Defaults to true; set false for background mode."
2845
+ }
2846
+ },
2847
+ output: {
2848
+ schema: taskResultSchema,
2849
+ render: (_args, value) => renderTaskResult(value),
2850
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2851
+ },
2852
+ presentResult: presentImageResult,
2853
+ async execute(args, exec) {
2854
+ ensureConfigured();
2855
+ const picked = resolveModel(args.model);
2856
+ const task = runtime.queue.submit({
2857
+ mode: "text",
2858
+ model: picked.alias,
2859
+ upstream: picked.upstream,
2860
+ channelId: picked.channel.id,
2861
+ channel: picked.channel.name,
2862
+ prompt: args.prompt.trim(),
2863
+ size: args.size ?? "auto",
2864
+ quality: args.quality ?? "auto",
2865
+ n: Math.min(4, Math.max(1, args.count ?? 1)),
2866
+ detail: args.detail ?? ""
2867
+ });
2868
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal));
2869
+ }
2870
+ })),
2871
+ ctx.tools.register(defineTool({
2872
+ name: "edit_image",
2873
+ description: "Edit an image. By default this tool call stays pending until the task reaches a final state; completed images are shown beside this tool call, while the model receives their attachment references, without creating a user message. Set wait_for_completion to false for background mode, then use get_image_generation_task explicitly. source_image must be an image reference returned by a completed generation or get_image_generation_task; pass that entire object unchanged. Only configured image models are allowed; omit model to use the configured default image model (or the first model when none is configured).",
2874
+ parameters: {
2875
+ prompt: {
2876
+ type: "string",
2877
+ required: true,
2878
+ description: "How to transform the source image."
2879
+ },
2880
+ source_image: {
2881
+ ...imageRefSchema,
2882
+ required: true,
2883
+ description: "Image reference returned by get_image_generation_task."
2884
+ },
2885
+ model: {
2886
+ type: "string",
2887
+ description: "One of the configured image models. Defaults to the configured default image model, or the first model when none is configured."
2888
+ },
2889
+ size: {
2890
+ type: "string",
2891
+ description: "Aspect ratio such as 1:1, 16:9, 9:16, or auto."
2892
+ },
2893
+ quality: {
2894
+ type: "string",
2895
+ description: "auto, 1k, 2k, or 4k."
2896
+ },
2897
+ count: {
2898
+ type: "integer",
2899
+ description: "Number of images, 1 to 4. Defaults to 1."
2900
+ },
2901
+ detail: {
2902
+ type: "string",
2903
+ description: "Optional provider detail value."
2904
+ },
2905
+ wait_for_completion: {
2906
+ type: "boolean",
2907
+ description: "Wait for images and return them in this tool result. Defaults to true; set false for background mode."
2908
+ }
2909
+ },
2910
+ output: {
2911
+ schema: taskResultSchema,
2912
+ render: (_args, value) => renderTaskResult(value),
2913
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2914
+ },
2915
+ presentResult: presentImageResult,
2916
+ async execute(args, exec) {
2917
+ ensureConfigured();
2918
+ const reference = await ctx.attachments.readImage(restoreRef(args.source_image), exec.signal);
2919
+ const picked = resolveModel(args.model);
2920
+ const task = runtime.queue.submit({
2921
+ mode: "edit",
2922
+ model: picked.alias,
2923
+ upstream: picked.upstream,
2924
+ channelId: picked.channel.id,
2925
+ channel: picked.channel.name,
2926
+ prompt: args.prompt.trim(),
2927
+ size: args.size ?? "auto",
2928
+ quality: args.quality ?? "auto",
2929
+ n: Math.min(4, Math.max(1, args.count ?? 1)),
2930
+ detail: args.detail ?? "",
2931
+ image: imageDataUrl(reference),
2932
+ ...reference.ref.name === void 0 ? {} : { refName: reference.ref.name }
2933
+ });
2934
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal));
2935
+ }
2936
+ })),
2937
+ ctx.tools.register(defineTool({
2938
+ name: "get_image_generation_task",
2939
+ description: "Check an image-generation task status. Completed tasks return image references; their images are shown beside this tool call and the references can be passed to edit_image. Generation tools normally wait for completion, so use this for explicit recovery or status checks.",
2940
+ parameters: { task_id: {
2941
+ type: "string",
2942
+ required: true,
2943
+ description: "Task id returned by generate_image or edit_image."
2944
+ } },
2945
+ output: {
2946
+ schema: taskResultSchema,
2947
+ render: (_args, value) => renderTaskResult(value),
2948
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2949
+ },
2950
+ presentResult: presentImageResult,
2951
+ async execute(args) {
2952
+ ensureConfigured();
2953
+ return taskResult(findTask(args.task_id));
2954
+ }
2955
+ })),
2956
+ ctx.tools.register(defineTool({
2957
+ name: "cancel_image_generation_task",
2958
+ description: "Cancel a queued or running image generation task.",
2959
+ parameters: { task_id: {
2960
+ type: "string",
2961
+ required: true,
2962
+ description: "Task id returned by generate_image or edit_image."
2963
+ } },
2964
+ output: {
2965
+ schema: taskResultSchema,
2966
+ render: (_args, value) => renderTaskResult(value),
2967
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2968
+ },
2969
+ presentResult: presentImageResult,
2970
+ async execute(args) {
2971
+ ensureConfigured();
2972
+ const task = runtime.queue.cancel(args.task_id);
2973
+ if (task === void 0) throw new ImageGenError(`Image generation task ${args.task_id} was not found.`, "task-not-found");
2974
+ return taskResult(task);
2975
+ }
2976
+ }))
2977
+ ];
2978
+ return () => {
2979
+ for (const dispose of disposers) dispose();
2980
+ };
2981
+ }
2982
+ function isFinalTask(task) {
2983
+ return task.status === "completed" || task.status === "failed" || task.status === "cancelled";
2984
+ }
2985
+ function toSaveImage(image, taskId, index) {
2986
+ const data = Buffer.from(image.b64, "base64");
2987
+ const declaredMediaType = acceptedMediaType(image.mime) ? image.mime : "image/png";
2988
+ const mediaType = detectImageMime(data) ?? declaredMediaType;
2989
+ return {
2990
+ data,
2991
+ mediaType,
2992
+ name: `imagegen-${taskId}-${index + 1}.${mediaType === "image/jpeg" ? "jpg" : mediaType.slice(6)}`
2993
+ };
2994
+ }
2995
+ //#endregion
2996
+ //#region src/edit-image-command.ts
2997
+ function isImageReference(value) {
2998
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
2999
+ const ref = value;
3000
+ return typeof ref.attachmentId === "string" && (ref.mediaType === "image/png" || ref.mediaType === "image/jpeg" || ref.mediaType === "image/webp" || ref.mediaType === "image/gif") && Number.isInteger(ref.bytes) && ref.bytes > 0 && Number.isInteger(ref.width) && ref.width > 0 && Number.isInteger(ref.height) && ref.height > 0;
3001
+ }
3002
+ function imageInContent(value) {
3003
+ if (!Array.isArray(value)) return void 0;
3004
+ for (let index = value.length - 1; index >= 0; index -= 1) {
3005
+ const block = value[index];
3006
+ if (typeof block !== "object" || block === null || Array.isArray(block)) continue;
3007
+ const raw = block;
3008
+ if (raw.type === "image" && isImageReference(raw.attachment)) return raw.attachment;
3009
+ if (raw.type === "tool-result") {
3010
+ const nested = imageInContent(raw.content);
3011
+ if (nested !== void 0) return nested;
3012
+ }
3013
+ }
3014
+ }
3015
+ /** Pick the newest image explicitly attached to this command invocation. */
3016
+ function imageInInvocation(value) {
3017
+ if (!Array.isArray(value)) return void 0;
3018
+ for (let index = value.length - 1; index >= 0; index -= 1) {
3019
+ const block = value[index];
3020
+ if (typeof block !== "object" || block === null || Array.isArray(block)) continue;
3021
+ const raw = block;
3022
+ if (raw.type === "image" && isImageReference(raw.attachment)) return raw.attachment;
3023
+ }
3024
+ }
3025
+ /** Find the newest durable image reference, including nested tool results. */
3026
+ function latestSessionImage(messages) {
3027
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
3028
+ const message = messages[index];
3029
+ if (typeof message !== "object" || message === null || Array.isArray(message)) continue;
3030
+ const image = imageInContent(message.content);
3031
+ if (image !== void 0) return image;
3032
+ }
3033
+ }
3034
+ function commandError(error) {
3035
+ const text = error instanceof Error ? error.message : String(error);
3036
+ return {
3037
+ kind: "error",
3038
+ text: text.trim() === "" ? "图片编辑失败。" : text
3039
+ };
3040
+ }
3041
+ /** Register the host-side command; it never sends the command line to a chat model. */
3042
+ function registerEditImageCommand(ctx, runtime, resolve, pendingImages) {
3043
+ return ctx.commands.register({
3044
+ name: "edit_image",
3045
+ description: "Edit the latest image in this conversation with the plugin image model",
3046
+ input: {
3047
+ hint: "Describe how to modify the latest image",
3048
+ images: true
3049
+ },
3050
+ async handler(invocation) {
3051
+ const prompt = invocation.rawInput.trim();
3052
+ if (prompt === "") return {
3053
+ kind: "error",
3054
+ text: "请提供图片修改描述,例如:/edit_image 把背景改成夜景"
3055
+ };
3056
+ const invocationImage = imageInInvocation(invocation.attachments);
3057
+ const pendingImage = pendingImages?.get(String(invocation.agent.id));
3058
+ const durableImage = latestSessionImage(invocation.agent.session.deriveMessages());
3059
+ const sourceImage = invocationImage ?? pendingImage ?? durableImage;
3060
+ if (sourceImage === void 0) return {
3061
+ kind: "error",
3062
+ text: "当前对话没有可用图片,请先上传图片或把画廊图片加入对话。"
3063
+ };
3064
+ try {
3065
+ const task = await submitAgentImageEdit(ctx.attachments, runtime, resolve, {
3066
+ prompt,
3067
+ sourceImage,
3068
+ signal: invocation.signal
3069
+ });
3070
+ if (task.status === "completed") {
3071
+ if (pendingImage !== void 0) pendingImages?.consume(String(invocation.agent.id), pendingImage);
3072
+ return {
3073
+ kind: "success",
3074
+ text: "图片编辑已完成,可在 AI 生图面板查看结果。"
3075
+ };
3076
+ }
3077
+ return {
3078
+ kind: "error",
3079
+ text: task.error ?? `图片编辑${task.status === "cancelled" ? "已取消" : "失败"}。`
3080
+ };
3081
+ } catch (error) {
3082
+ return commandError(error);
3083
+ }
3084
+ }
3085
+ });
3086
+ }
3087
+ //#endregion
3088
+ //#region src/index.ts
3089
+ /** Stable cordis plugin name. */
3090
+ const name = "imagegen";
3091
+ /** Services required before the surfaces can mount. */
3092
+ const inject = [
3093
+ "webServer",
3094
+ "systemPrompt",
3095
+ "commands"
3096
+ ];
3097
+ /** The branded settings namespace of this plugin (the card edits it). */
3098
+ const ImageGenSettingsNamespace = settingsNamespaceCompat(IMAGEGEN_SETTINGS_NAMESPACE);
3099
+ /**
3100
+ * The config schema. Every field is live: the root is marked volatile so the
3101
+ * host derives an editable form for this entry and commits settings edits into
3102
+ * the running fiber instead of remounting the plugin.
3103
+ */
3104
+ const Config = z.object({
3105
+ enabled: z.boolean().default(true),
3106
+ announceToAgent: z.boolean().default(true),
3107
+ allowAgentImageGeneration: z.boolean().default(true),
3108
+ channels: z.array(z.object({
3109
+ id: z.string(),
3110
+ preset: z.string().default(""),
3111
+ name: z.string().default(""),
3112
+ apiUrl: z.string().default(""),
3113
+ apiUrlFull: z.boolean().default(false),
3114
+ models: z.array(z.object({
3115
+ alias: z.string(),
3116
+ id: z.string()
3117
+ })).default([])
3118
+ })).default([]),
3119
+ channelSecrets: z.dict(z.string().role("secret")).default({}),
3120
+ defaultChannelId: z.string().default(""),
3121
+ promptApiUrl: z.string().default(""),
3122
+ promptApiKey: z.string().role("secret").default(""),
3123
+ promptModel: z.string().default(""),
3124
+ apiUrl: z.string().default(""),
3125
+ apiKey: z.string().role("secret").default(""),
3126
+ imageModels: z.array(z.string()).default([])
3127
+ }).volatile();
3128
+ /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
3129
+ const DEFAULT_ENABLED = true;
3130
+ const DEFAULT_ANNOUNCE = true;
3131
+ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
3132
+ /** Order of the announcement section within the tool-guidance band. */
3133
+ const SECTION_ORDER = 150;
3134
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
3135
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):通过「渠道」对接 OpenAI 兼容图像生成 API(每个渠道 = 一个 API 端点 + 各自的模型目录),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送;智谱 `glm-image` 使用官方 `/api/paas/v4/images/generations`,当前仅支持文生图;qwen-image 系列使用阿里云 DashScope 原生接口(api_url 填 https://dashscope.aliyuncs.com/api/v1,不支持 OpenAI 兼容模式,该渠道不可复用于提示词增强,尺寸自动映射为宽*高)。MiniMax `image-01` 使用 MiniMax 原生 `/image_generation` 接口(api_url 填 https://api.minimax.io/v1 或国内站 https://api.minimaxi.com/v1,支持 1:1/16:9/4:3/3:2/2:3/3:4/9:16/21:9 宽高比,一次最多 9 张;图生图为单张 subject_reference 主体参考(保持人物/主体一致,非像素级局部编辑);其 /models 只列聊天模型,图片模型需用预设目录)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、MiniMax、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图」时即指本插件,请据此协作。";
3136
+ /** Append the live channel × model table so an Agent can honor user choices. */
3137
+ function guidanceFor(channels, defaultChannelId) {
3138
+ if (channels.length === 0) return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`;
3139
+ const table = channels.map((channel) => {
3140
+ const aliases = channel.models.map((model) => model.alias).join("、");
3141
+ const mark = channel.id === defaultChannelId ? "(默认渠道)" : "";
3142
+ const key = channel.apiKey === "" ? "(未填密钥)" : "";
3143
+ const models = channel.models.length === 0 ? "未配置模型" : `可用模型:${aliases}`;
3144
+ return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`;
3145
+ }).join(";");
3146
+ return `${IMAGEGEN_GUIDANCE} 当前渠道与模型:${table}。用户指定模型名时取该模型所属渠道(多渠道同名用默认渠道);未指定模型时若仅一个可用模型可直接生成,若有多个应先询问用户选择「渠道 + 模型」。`;
3147
+ }
3148
+ /** Normalize raw channel entries into the wire shape (schema-adjacent guard). */
3149
+ function normalizeChannels(value) {
3150
+ if (!Array.isArray(value)) return [];
3151
+ const out = [];
3152
+ for (const item of value) {
3153
+ if (item === null || typeof item !== "object") continue;
3154
+ const raw = item;
3155
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
3156
+ if (id === "") continue;
3157
+ const models = [];
3158
+ if (Array.isArray(raw.models)) for (const entry of raw.models) {
3159
+ if (entry === null || typeof entry !== "object") continue;
3160
+ const record = entry;
3161
+ const alias = typeof record.alias === "string" ? record.alias.trim() : "";
3162
+ const upstream = typeof record.id === "string" ? record.id.trim() : "";
3163
+ if (alias === "") continue;
3164
+ models.push({
3165
+ alias,
3166
+ id: upstream === "" ? alias : upstream
3167
+ });
3168
+ }
3169
+ out.push({
3170
+ id,
3171
+ preset: typeof raw.preset === "string" ? raw.preset : "",
3172
+ name: typeof raw.name === "string" ? raw.name.trim() : "",
3173
+ apiUrl: typeof raw.apiUrl === "string" ? raw.apiUrl.trim() : "",
3174
+ apiUrlFull: false,
3175
+ models
3176
+ });
3177
+ }
3178
+ return out;
3179
+ }
3180
+ /**
3181
+ * Mount the settings section, routes, Agent tools, and announcement.
3182
+ * @param ctx - host plugin context carrying webServer/systemPrompt.
3183
+ * @param config - live config reference (schema defaults applied by the loader).
3184
+ */
3185
+ function apply(ctx, config) {
3186
+ let current = () => config?.get() ?? {};
3187
+ const resolve = () => {
3188
+ const value = current() ?? {};
3189
+ let channels = normalizeChannels(value.channels);
3190
+ const secrets = { ...value.channelSecrets ?? {} };
3191
+ if (channels.length === 0) {
3192
+ const legacyUrl = typeof value.apiUrl === "string" ? value.apiUrl.trim() : "";
3193
+ const legacyModels = Array.isArray(value.imageModels) ? value.imageModels.filter((model) => typeof model === "string" && model.trim() !== "").map((model) => ({
3194
+ alias: model.trim(),
3195
+ id: model.trim()
3196
+ })) : [];
3197
+ if (legacyUrl !== "" || legacyModels.length > 0) {
3198
+ channels = [{
3199
+ id: "default",
3200
+ preset: "",
3201
+ name: "默认渠道",
3202
+ apiUrl: legacyUrl,
3203
+ apiUrlFull: false,
3204
+ models: legacyModels
3205
+ }];
3206
+ const legacyKey = typeof value.apiKey === "string" ? value.apiKey.trim() : "";
3207
+ if (legacyKey !== "") secrets["default"] = legacyKey;
3208
+ }
3209
+ }
3210
+ const named = channels.map((channel) => ({
3211
+ ...channel,
3212
+ name: channel.name === "" ? presetById(channel.preset)?.name ?? "未命名渠道" : channel.name
3213
+ }));
3214
+ const defaultChannelId = typeof value.defaultChannelId === "string" && named.some((channel) => channel.id === value.defaultChannelId) ? value.defaultChannelId : named[0]?.id ?? "";
3215
+ return {
3216
+ enabled: value.enabled ?? DEFAULT_ENABLED,
3217
+ announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
3218
+ allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
3219
+ channels: named.map((channel) => ({
3220
+ ...channel,
3221
+ apiKey: typeof secrets[channel.id] === "string" ? secrets[channel.id] : ""
3222
+ })),
3223
+ defaultChannelId,
3224
+ promptApiUrl: typeof value.promptApiUrl === "string" ? value.promptApiUrl.trim() : "",
3225
+ promptApiKey: typeof value.promptApiKey === "string" ? value.promptApiKey.trim() : "",
3226
+ promptModel: typeof value.promptModel === "string" ? value.promptModel.trim() : ""
3227
+ };
3228
+ };
3229
+ const channelsView = () => {
3230
+ const value = resolve();
3231
+ return {
3232
+ channels: value.channels,
3233
+ defaultChannelId: value.defaultChannelId
3234
+ };
3235
+ };
3236
+ const runtime = new ImageGenerationRuntime(channelsView);
3237
+ const pendingConversationImages = /* @__PURE__ */ new Map();
3238
+ ctx.inject(["settings", "attachments"], (sctx) => {
3239
+ const seam = sctx.get("settings");
3240
+ sctx.effect(() => {
3241
+ const disposers = makeRoutes({
3242
+ settings: seam,
3243
+ resolve: () => {
3244
+ const value = resolve();
3245
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
3246
+ return {
3247
+ apiUrl: channel?.apiUrl ?? "",
3248
+ apiKey: channel?.apiKey ?? ""
3249
+ };
3250
+ },
3251
+ resolveChannels: channelsView,
3252
+ resolvePrompt: () => {
3253
+ const value = resolve();
3254
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
3255
+ return {
3256
+ apiUrl: value.promptApiUrl !== "" ? value.promptApiUrl : channel?.apiUrl ?? "",
3257
+ apiKey: value.promptApiKey !== "" ? value.promptApiKey : channel?.apiKey ?? "",
3258
+ model: value.promptModel
3259
+ };
3260
+ },
3261
+ resolveImageModels: () => {
3262
+ const value = resolve();
3263
+ return [...new Set(value.channels.flatMap((channel) => channel.models.map((model) => model.alias)))];
3264
+ },
3265
+ attachments: sctx.attachments,
3266
+ pendingConversationImages,
3267
+ runtime
3268
+ }).map((route) => ctx.webServer.register(route));
3269
+ return () => {
3270
+ for (const dispose of disposers) dispose();
3271
+ };
3272
+ }, "dsh-imagegen: routes");
3273
+ });
3274
+ ctx.inject([
3275
+ "tools",
3276
+ "attachments",
3277
+ "commands"
3278
+ ], (tctx) => {
3279
+ tctx.effect(() => {
3280
+ const resolveAgentConfig = () => {
3281
+ const value = resolve();
3282
+ return {
3283
+ enabled: value.enabled,
3284
+ allowAgentImageGeneration: value.allowAgentImageGeneration,
3285
+ channels: value.channels,
3286
+ defaultChannelId: value.defaultChannelId
3287
+ };
3288
+ };
3289
+ const disposeTools = registerAgentImageTools(tctx, runtime, resolveAgentConfig);
3290
+ const disposeCommand = registerEditImageCommand(tctx, runtime, resolveAgentConfig, {
3291
+ get: (sessionId) => pendingConversationImages.get(sessionId),
3292
+ consume: (sessionId, ref) => {
3293
+ if (pendingConversationImages.get(sessionId)?.attachmentId === ref.attachmentId) pendingConversationImages.delete(sessionId);
3294
+ }
3295
+ });
3296
+ return () => {
3297
+ disposeCommand();
3298
+ disposeTools();
3299
+ };
3300
+ }, "dsh-imagegen: agent image tools and commands");
3301
+ });
3302
+ let disposeSection;
3303
+ const sync = () => {
3304
+ if (disposeSection !== void 0) {
3305
+ disposeSection();
3306
+ disposeSection = void 0;
3307
+ }
3308
+ const value = resolve();
3309
+ if (!value.enabled || !value.announceToAgent) return;
3310
+ disposeSection = ctx.systemPrompt.section({
3311
+ name: "plugin:dsh-imagegen",
3312
+ order: SECTION_ORDER,
3313
+ text: guidanceFor(value.channels, value.defaultChannelId)
3314
+ });
3315
+ };
3316
+ installSettingsSectionCompat(ctx, ImageGenSettingsNamespace, Config, config?.get() ?? {}, {
3317
+ setSource: (source) => {
3318
+ current = source;
3319
+ sync();
3320
+ },
3321
+ onChange: sync
3322
+ });
3323
+ sync();
3324
+ }
3325
+ //#endregion
3326
+ export { Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, ImageGenerationRuntime, apply, generateImage, inject, latestSessionImage, makeRoutes, name, promptCharLimit, registerAgentImageTools, registerEditImageCommand };