@dickpy/dsh-imagegen 1.2.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -181
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +2711 -1318
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +830 -155
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -316
  10. package/src/client/ImageGenPanel.tsx +1703 -1476
  11. package/src/client/SettingsCard.tsx +936 -648
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -0
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +170 -152
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -484
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -536
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -250
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -464
  31. package/src/gallery-store.ts +286 -280
  32. package/src/generation-runtime.ts +79 -48
  33. package/src/history-store.ts +250 -238
  34. package/src/image-format.ts +11 -0
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -212
  37. package/src/model-catalog.ts +115 -0
  38. package/src/presets.ts +71 -0
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -253
  41. package/src/routes.ts +916 -738
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. package/src/updater.ts +117 -117
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
16
16
  /** Settings namespace this plugin owns (host settings seam + bridge). */
17
17
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
18
18
  /** Published package version shared by the host updater and the client UI. */
19
- const PLUGIN_VERSION = "1.2.3";
19
+ const PLUGIN_VERSION = "1.3.1";
20
20
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
21
21
  const SETTINGS_API = {
22
22
  describe: "/api/dsh-imagegen/settings/describe",
@@ -31,6 +31,16 @@ const PROMPT_ENHANCE_API = {
31
31
  };
32
32
  /** Host-mediated candidate discovery for the configured image API. */
33
33
  const IMAGE_MODEL_API = { models: "/api/dsh-imagegen/image-models" };
34
+ /** Host-served built-in provider catalog (channels the user can instantiate). */
35
+ const PRESETS_API = "/api/dsh-imagegen/presets";
36
+ /** Loopback-only image reader for Agent tool-result previews. */
37
+ const AGENT_IMAGE_API = "/api/dsh-imagegen/agent-image";
38
+ /**
39
+ * Host-computed per-channel usage counters (generation-count badges in the
40
+ * settings card): entries are tallied from the persisted history and gallery
41
+ * by channel + model alias.
42
+ */
43
+ const USAGE_API = "/api/dsh-imagegen/usage";
34
44
  /** Host-resident generation queue endpoints. */
35
45
  const TASK_API = {
36
46
  submit: "/api/dsh-imagegen/tasks/submit",
@@ -81,7 +91,125 @@ const TEMPLATES_API = {
81
91
  image: "/api/dsh-imagegen/templates/image"
82
92
  };
83
93
  //#endregion
94
+ //#region src/model-catalog.ts
95
+ const ENTRIES = {
96
+ "gpt-image": {
97
+ label: "gpt-image",
98
+ labelZh: "GPT 图像",
99
+ known: true,
100
+ supportsEdit: true,
101
+ supportsAspectRatio: false,
102
+ qualityTiers: [
103
+ "1K",
104
+ "2K",
105
+ "4K"
106
+ ]
107
+ },
108
+ "dall-e": {
109
+ label: "DALL·E",
110
+ labelZh: "DALL·E",
111
+ known: true,
112
+ supportsEdit: true,
113
+ supportsAspectRatio: false,
114
+ qualityTiers: ["auto"]
115
+ },
116
+ grok: {
117
+ label: "grok",
118
+ labelZh: "Grok",
119
+ known: true,
120
+ supportsEdit: true,
121
+ supportsAspectRatio: true,
122
+ qualityTiers: ["1K", "2K"]
123
+ },
124
+ nanobanana: {
125
+ label: "nanobanana",
126
+ labelZh: "Nano Banana",
127
+ known: true,
128
+ supportsEdit: true,
129
+ supportsAspectRatio: true,
130
+ qualityTiers: [
131
+ "1K",
132
+ "2K",
133
+ "4K"
134
+ ]
135
+ },
136
+ seedream: {
137
+ label: "seedream",
138
+ labelZh: "Seedream",
139
+ known: true,
140
+ supportsEdit: true,
141
+ supportsAspectRatio: true,
142
+ qualityTiers: ["1K", "2K"]
143
+ },
144
+ zhipu: {
145
+ label: "GLM-Image",
146
+ labelZh: "智谱图像",
147
+ known: true,
148
+ supportsEdit: false,
149
+ supportsAspectRatio: false,
150
+ qualityTiers: ["HD"]
151
+ }
152
+ };
153
+ /** Official Gemini image ids served by Nano Banana gateways. */
154
+ const NANOBANANA_GEMINI_IDS = /* @__PURE__ */ new Set([
155
+ "gemini-3-pro-image",
156
+ "gemini-3-pro-image-preview",
157
+ "gemini-3.1-flash-image",
158
+ "gemini-3.1-flash-image-preview",
159
+ "gemini-3.1-flash-lite-image",
160
+ "gemini-2.5-flash-image"
161
+ ]);
162
+ /** Classify one upstream model id into its request-shaping family. */
163
+ function describeModel(model) {
164
+ const id = model.trim();
165
+ if (/^gpt-image/i.test(id)) return {
166
+ family: "gpt-image",
167
+ ...ENTRIES["gpt-image"]
168
+ };
169
+ if (/^dall-e/i.test(id)) return {
170
+ family: "dall-e",
171
+ ...ENTRIES["dall-e"]
172
+ };
173
+ if (/^grok-imagine(?:-|$)/.test(id)) return {
174
+ family: "grok",
175
+ ...ENTRIES.grok
176
+ };
177
+ if (/^nanobanana/i.test(id) || NANOBANANA_GEMINI_IDS.has(id)) return {
178
+ family: "nanobanana",
179
+ ...ENTRIES.nanobanana
180
+ };
181
+ if (/^(?:doubao-)?seedream/i.test(id)) return {
182
+ family: "seedream",
183
+ ...ENTRIES.seedream
184
+ };
185
+ if (/^(?:glm-image|cogview(?:-|$))/i.test(id)) return {
186
+ family: "zhipu",
187
+ ...ENTRIES.zhipu
188
+ };
189
+ return {
190
+ family: "unknown",
191
+ label: "unknown",
192
+ labelZh: "未知协议",
193
+ known: false,
194
+ supportsEdit: true,
195
+ supportsAspectRatio: false,
196
+ qualityTiers: []
197
+ };
198
+ }
199
+ /** Conservative fallback for providers whose /models response only has ids.
200
+ * Metadata-aware filtering lives in prompt-enhancer.ts; this catches common
201
+ * image model naming conventions without treating every unknown model as an
202
+ * image model. */
203
+ function isLikelyImageModelId(model) {
204
+ 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());
205
+ }
206
+ /** The family a model id routes its request through. */
207
+ function modelFamily(model) {
208
+ return describeModel(model).family;
209
+ }
210
+ //#endregion
84
211
  //#region src/prompt-enhancer.ts
212
+ /** OpenAI-compatible chat helpers used by the optional prompt-enhancement UI. */
85
213
  function endpoint(base, suffix) {
86
214
  return `${base.replace(/\/+$/, "")}${suffix}`;
87
215
  }
@@ -99,12 +227,69 @@ async function responseJson(response) {
99
227
  }
100
228
  return body;
101
229
  }
102
- /** List candidates exposed by an OpenAI-compatible endpoint. */
103
- async function listOpenAIModels(config) {
230
+ async function listModelRecords(config) {
104
231
  if (config.apiUrl.trim() === "") throw new Error("API URL is required");
105
232
  const body = await responseJson(await fetch(endpoint(config.apiUrl, "/models"), { headers: headers(config.apiKey) }));
106
- const data = Array.isArray(body.data) ? body.data : [];
107
- return [...new Set(data.flatMap((item) => item !== null && typeof item === "object" && typeof item.id === "string" ? [item.id.trim()] : []).filter(Boolean))].sort((a, b) => a.localeCompare(b));
233
+ return (Array.isArray(body.data) ? body.data : []).flatMap((item) => {
234
+ if (item === null || typeof item !== "object" || typeof item.id !== "string") return [];
235
+ const id = item.id.trim();
236
+ return id === "" ? [] : [{
237
+ ...item,
238
+ id
239
+ }];
240
+ });
241
+ }
242
+ function textOf(value) {
243
+ if (typeof value === "string") return [value];
244
+ if (!Array.isArray(value)) return [];
245
+ return value.filter((item) => typeof item === "string");
246
+ }
247
+ function hasImageGenerationCapability(record) {
248
+ const capability = record.capabilities;
249
+ if (capability !== null && typeof capability === "object") {
250
+ const values = capability;
251
+ for (const key of [
252
+ "image_generation",
253
+ "imageGeneration",
254
+ "text_to_image",
255
+ "textToImage",
256
+ "image_gen"
257
+ ]) if (typeof values[key] === "boolean") return values[key];
258
+ const serialized = JSON.stringify(values).toLowerCase();
259
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image/.test(serialized)) return true;
260
+ }
261
+ const taskText = [
262
+ ...textOf(record.task),
263
+ ...textOf(record.task_type),
264
+ ...textOf(record.taskType),
265
+ ...textOf(record.type),
266
+ ...textOf(record.model_type),
267
+ ...textOf(record.modelType),
268
+ ...textOf(record.tasks),
269
+ ...textOf(record.description)
270
+ ].join(" ").toLowerCase();
271
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image|image[ _-]?gen/.test(taskText)) return true;
272
+ if (/^image(?:[ _-]?generation)?$/.test(taskText.trim())) return true;
273
+ if (/embedding|rerank|moderation|transcri|speech|audio|video|chat[ _-]?completion/.test(taskText)) return false;
274
+ for (const key of [
275
+ "output_modalities",
276
+ "outputModalities",
277
+ "supported_output_modalities"
278
+ ]) {
279
+ const modalities = textOf(record[key]).map((value) => value.toLowerCase());
280
+ if (modalities.length > 0) return modalities.includes("image");
281
+ }
282
+ }
283
+ function isImageModelRecord(record) {
284
+ return hasImageGenerationCapability(record) ?? isLikelyImageModelId(record.id);
285
+ }
286
+ /** List candidates exposed by an OpenAI-compatible endpoint. */
287
+ async function listOpenAIModels(config) {
288
+ return [...new Set((await listModelRecords(config)).map((record) => record.id))].sort((a, b) => a.localeCompare(b));
289
+ }
290
+ /** List only models that advertise or conventionally represent image generation. */
291
+ async function listImageModels(config) {
292
+ return [...new Set((await listModelRecords(config)).filter(isImageModelRecord).map((record) => record.id))].sort((a, b) => a.localeCompare(b));
108
293
  }
109
294
  /** List chat models exposed by an OpenAI-compatible endpoint. */
110
295
  async function listPromptModels(config) {
@@ -146,7 +331,8 @@ const DEFAULT_IMAGE_MODELS = [
146
331
  "nanobanana2",
147
332
  "nanobanana2-lite",
148
333
  "nanobanana-pro",
149
- "seedream-5.0-pro"
334
+ "seedream-5.0-pro",
335
+ "glm-image"
150
336
  ];
151
337
  /** Normalize user-entered model identifiers and retain a usable legacy default. */
152
338
  function normalizeImageModels(value) {
@@ -160,6 +346,15 @@ function normalizeImageModels(value) {
160
346
  return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS];
161
347
  }
162
348
  //#endregion
349
+ //#region src/image-format.ts
350
+ function detectImageMime(data) {
351
+ const startsWith = (...bytes) => bytes.every((value, index) => data[index] === value);
352
+ if (startsWith(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
353
+ if (startsWith(255, 216, 255)) return "image/jpeg";
354
+ if (startsWith(71, 73, 70, 56, 55, 97) || startsWith(71, 73, 70, 56, 57, 97)) return "image/gif";
355
+ if (startsWith(82, 73, 70, 70) && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "image/webp";
356
+ }
357
+ //#endregion
163
358
  //#region src/engine.ts
164
359
  /** A generation failure with a user-presentable message. */
165
360
  var ImageGenError = class extends Error {
@@ -183,12 +378,20 @@ const DALLE3_SIZES = /* @__PURE__ */ new Set([
183
378
  "1792x1024",
184
379
  "1024x1792"
185
380
  ]);
381
+ /** The wire model id for a request: `upstream` (host-filled alias mapping)
382
+ * wins, then the alias, then the family default. */
383
+ function wireModel(request) {
384
+ const upstream = request.upstream?.trim();
385
+ if (upstream !== void 0 && upstream !== "") return upstream;
386
+ const alias = request.model.trim();
387
+ return alias === "" ? "gpt-image-2" : alias;
388
+ }
186
389
  /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
187
390
  * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
188
391
  * and exposes its own aspect-ratio / response-format knobs instead of the
189
392
  * OpenAI size/quality/detail passthrough. */
190
393
  function isGrokImagine(model) {
191
- return /^grok-imagine(?:-|$)/.test(model);
394
+ return modelFamily(model) === "grok";
192
395
  }
193
396
  /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
194
397
  * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
@@ -196,17 +399,31 @@ function isGrokImagine(model) {
196
399
  * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
197
400
  * passthrough. */
198
401
  function isNanoBanana(model) {
199
- if (/^nanobanana/i.test(model)) return true;
200
- return model === "gemini-3-pro-image" || model === "gemini-3-pro-image-preview" || model === "gemini-3.1-flash-image" || model === "gemini-3.1-flash-image-preview" || model === "gemini-3.1-flash-lite-image" || model === "gemini-2.5-flash-image";
402
+ return modelFamily(model) === "nanobanana";
201
403
  }
202
404
  /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
203
405
  * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
204
406
  * serve Seedream through a unified generate-and-edit architecture:
205
- * generation AND editing both go to /images/generations, reference images are
206
- * a JSON URL / data-URL array, and the clarity tier is `resolution` while
207
- * `size` carries the aspect ratio (or exact pixels). */
407
+ * generation AND editing both go to /images/generations and reference images
408
+ * are a JSON URL / data-URL array. */
208
409
  function isSeedream(model) {
209
- return /^(?:doubao-)?seedream/i.test(model);
410
+ return modelFamily(model) === "seedream";
411
+ }
412
+ /** Whether the model uses the official Zhipu image-generation contract. */
413
+ function isZhipuImage(model) {
414
+ return modelFamily(model) === "zhipu";
415
+ }
416
+ function isGlmImage(model) {
417
+ return /^glm-image(?:-|$)/i.test(model.trim());
418
+ }
419
+ /** Whether this is the official Volcengine Ark model naming convention. */
420
+ function isVolcSeedream(model) {
421
+ return /^doubao-seedream(?:-|$)/i.test(model.trim());
422
+ }
423
+ /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
424
+ function seedreamSize(quality) {
425
+ if (quality === "1k") return "1K";
426
+ return "2K";
210
427
  }
211
428
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
212
429
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
@@ -275,6 +492,19 @@ function bareBase64(value) {
275
492
  const parsed = parseDataUrl(value);
276
493
  return parsed !== void 0 && parsed.base64 !== void 0 ? parsed.base64 : value;
277
494
  }
495
+ /** Whether a result URL carries cloud-storage signing credentials. */
496
+ function isPresignedUrl(value) {
497
+ let url;
498
+ try {
499
+ url = new URL(value);
500
+ } catch {
501
+ return false;
502
+ }
503
+ const params = new Set(Array.from(url.searchParams.keys(), (key) => key.toLowerCase()));
504
+ if (params.has("x-goog-signature") || params.has("x-goog-credential")) return true;
505
+ if (params.has("x-amz-signature") || params.has("x-amz-credential")) return true;
506
+ return params.has("signature") && (params.has("expires") || params.has("googleaccessid") || params.has("awsaccesskeyid"));
507
+ }
278
508
  /** Clamp the requested image count into the API-accepted range. */
279
509
  function clampCount(n) {
280
510
  if (!Number.isFinite(n)) return 1;
@@ -284,7 +514,7 @@ function clampCount(n) {
284
514
  * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
285
515
  * so the count is satisfied by parallel single-image requests instead. */
286
516
  function effectiveParams(request) {
287
- const model = request.model.trim() === "" ? "gpt-image-2" : request.model.trim();
517
+ const model = wireModel(request);
288
518
  if (model === "dall-e-3") {
289
519
  const pixel = OPENAI_SIZE_BY_RATIO[request.size];
290
520
  return {
@@ -306,9 +536,13 @@ function effectiveParams(request) {
306
536
  };
307
537
  if (isSeedream(model)) return {
308
538
  model,
309
- ...request.size !== "" && request.size !== "auto" ? { size: request.size } : {},
310
- ...request.quality !== "" && request.quality !== "auto" ? { resolution: request.quality === "4k" ? "2K" : request.quality.toUpperCase() } : {},
311
- response_format: "b64_json"
539
+ size: seedreamSize(request.quality),
540
+ response_format: isVolcSeedream(model) ? "url" : "b64_json"
541
+ };
542
+ if (isZhipuImage(model)) return {
543
+ model,
544
+ ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
545
+ quality: isGlmImage(model) ? "hd" : "standard"
312
546
  };
313
547
  return {
314
548
  model,
@@ -321,17 +555,20 @@ function effectiveParams(request) {
321
555
  }
322
556
  /** How many single-image requests to issue for the requested image count. */
323
557
  function effectiveCount(request) {
324
- if ((request.model.trim() === "" ? "gpt-image-2" : request.model.trim()) === "dall-e-3") return 1;
558
+ if (wireModel(request) === "dall-e-3") return 1;
325
559
  return clampCount(request.n);
326
560
  }
327
561
  /** Normalize one upstream data item into a base64 image. */
328
562
  async function normalizeItem(item, upstream) {
329
563
  const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : void 0;
330
- if (typeof item.b64_json === "string") return {
331
- b64: bareBase64(item.b64_json),
332
- mime: "image/png",
333
- revisedPrompt
334
- };
564
+ if (typeof item.b64_json === "string" && item.b64_json.trim() !== "") {
565
+ const b64 = bareBase64(item.b64_json);
566
+ if (b64.trim() !== "") return {
567
+ b64,
568
+ mime: detectImageMime(Buffer.from(b64, "base64")) ?? "image/png",
569
+ revisedPrompt
570
+ };
571
+ }
335
572
  if (typeof item.url !== "string" || item.url === "") throw new ImageGenError("upstream image item has neither b64_json nor url");
336
573
  const url = item.url;
337
574
  if (url.startsWith("data:")) {
@@ -339,7 +576,7 @@ async function normalizeItem(item, upstream) {
339
576
  if (parsed === void 0) throw new ImageGenError("upstream returned a malformed data: url");
340
577
  return {
341
578
  b64: parsed.base64,
342
- mime: parsed.mime,
579
+ mime: detectImageMime(Buffer.from(parsed.base64, "base64")) ?? parsed.mime,
343
580
  revisedPrompt
344
581
  };
345
582
  }
@@ -347,7 +584,7 @@ async function normalizeItem(item, upstream) {
347
584
  let response;
348
585
  try {
349
586
  response = await fetch(url, {
350
- headers: { ...upstream.apiKey === "" ? {} : { authorization: `Bearer ${upstream.apiKey}` } },
587
+ ...isPresignedUrl(url) || upstream.apiKey === "" ? {} : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
351
588
  signal: budget.signal
352
589
  });
353
590
  } catch (error) {
@@ -358,7 +595,7 @@ async function normalizeItem(item, upstream) {
358
595
  if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
359
596
  const buffer = Buffer.from(await response.arrayBuffer());
360
597
  const contentType = response.headers.get("content-type");
361
- const mime = contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png";
598
+ const mime = detectImageMime(buffer) ?? (contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png");
362
599
  return {
363
600
  b64: buffer.toString("base64"),
364
601
  mime,
@@ -411,7 +648,7 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
411
648
  image: [request.image],
412
649
  ...params.size !== void 0 ? { size: params.size } : {},
413
650
  ...params.resolution !== void 0 ? { resolution: params.resolution } : {},
414
- response_format: "b64_json"
651
+ response_format: isVolcSeedream(params.model) ? "url" : "b64_json"
415
652
  });
416
653
  } else {
417
654
  const form = new FormData();
@@ -473,6 +710,7 @@ async function generateImage(upstream, request, options = {}) {
473
710
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, "");
474
711
  if (baseUrl === "") throw new ImageGenError("api_url 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
475
712
  if (upstream.apiKey.trim() === "") throw new ImageGenError("api_key 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
713
+ if (request.mode === "edit" && isZhipuImage(wireModel(request))) throw new ImageGenError("智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型", "edit-unsupported");
476
714
  const params = effectiveParams(request);
477
715
  const count = effectiveCount(request);
478
716
  return { images: (await Promise.all(Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)))).flat() };
@@ -602,7 +840,11 @@ function toWire$1(entry) {
602
840
  mime: image.mime,
603
841
  ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
604
842
  })),
605
- ...entry.refName === void 0 ? {} : { refName: entry.refName }
843
+ ...entry.refName === void 0 ? {} : { refName: entry.refName },
844
+ ...entry.channel === void 0 ? {} : { channel: entry.channel },
845
+ ...entry.channelId === void 0 ? {} : { channelId: entry.channelId },
846
+ ...entry.comparisonId === void 0 ? {} : { comparisonId: entry.comparisonId },
847
+ ...entry.comparisonModels === void 0 ? {} : { comparisonModels: entry.comparisonModels }
606
848
  };
607
849
  }
608
850
  /** List the persisted history, newest first, as wire entries. */
@@ -641,7 +883,11 @@ async function appendHistory(input) {
641
883
  detail: input.detail,
642
884
  n: input.n,
643
885
  images: storedImages,
644
- ...input.refName === void 0 ? {} : { refName: input.refName }
886
+ ...input.refName === void 0 ? {} : { refName: input.refName },
887
+ ...input.channelId === void 0 ? {} : { channelId: input.channelId },
888
+ ...input.channel === void 0 ? {} : { channel: input.channel },
889
+ ...input.comparisonId === void 0 ? {} : { comparisonId: input.comparisonId },
890
+ ...input.comparisonModels === void 0 ? {} : { comparisonModels: input.comparisonModels }
645
891
  }, ...await readIndex$1()];
646
892
  const kept = merged.slice(0, 50);
647
893
  for (const dropped of merged.slice(50)) await removeEntryFiles$1(dropped);
@@ -686,12 +932,15 @@ async function readHistoryImage(file) {
686
932
  /** In-memory, host-resident image generation queue. */
687
933
  var GenerationTaskQueue = class {
688
934
  run;
935
+ concurrency;
689
936
  tasks = [];
690
937
  controllers = /* @__PURE__ */ new Map();
691
938
  listeners = /* @__PURE__ */ new Set();
692
- running = false;
693
- constructor(run) {
939
+ running = 0;
940
+ serialRunning = false;
941
+ constructor(run, concurrency = 1) {
694
942
  this.run = run;
943
+ this.concurrency = concurrency;
695
944
  }
696
945
  list() {
697
946
  return this.tasks.map((task) => this.snapshot(task));
@@ -722,45 +971,49 @@ var GenerationTaskQueue = class {
722
971
  task.finishedAt = Date.now();
723
972
  this.controllers.get(id)?.abort();
724
973
  this.publish(task);
974
+ this.drain();
725
975
  return this.snapshot(task);
726
976
  }
727
977
  retry(id) {
728
978
  const previous = this.tasks.find((item) => item.id === id);
729
979
  return previous === void 0 ? void 0 : this.submit(previous.request);
730
980
  }
731
- async drain() {
732
- if (this.running) return;
733
- this.running = true;
981
+ drain() {
982
+ while (this.running < Math.max(1, this.concurrency)) {
983
+ const task = this.tasks.find((item) => item.status === "queued" && (this.running === 0 || item.request.comparisonId !== void 0 && !this.serialRunning));
984
+ if (task === void 0) return;
985
+ this.running += 1;
986
+ if (task.request.comparisonId === void 0) this.serialRunning = true;
987
+ this.runTask(task).finally(() => {
988
+ this.running -= 1;
989
+ if (task.request.comparisonId === void 0) this.serialRunning = false;
990
+ this.drain();
991
+ });
992
+ }
993
+ }
994
+ async runTask(task) {
995
+ task.status = "running";
996
+ task.startedAt = Date.now();
997
+ this.publish(task);
998
+ const controller = new AbortController();
999
+ this.controllers.set(task.id, controller);
734
1000
  try {
735
- for (;;) {
736
- const task = this.tasks.find((item) => item.status === "queued");
737
- if (task === void 0) return;
738
- task.status = "running";
739
- task.startedAt = Date.now();
1001
+ const result = await this.run(task.request, controller.signal);
1002
+ if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
1003
+ task.status = "completed";
1004
+ task.result = result;
1005
+ task.finishedAt = Date.now();
1006
+ this.publish(task);
1007
+ }
1008
+ } catch (error) {
1009
+ if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
1010
+ task.status = "failed";
1011
+ task.error = error instanceof Error ? error.message : String(error);
1012
+ task.finishedAt = Date.now();
740
1013
  this.publish(task);
741
- const controller = new AbortController();
742
- this.controllers.set(task.id, controller);
743
- try {
744
- const result = await this.run(task.request, controller.signal);
745
- if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
746
- task.status = "completed";
747
- task.result = result;
748
- task.finishedAt = Date.now();
749
- this.publish(task);
750
- }
751
- } catch (error) {
752
- if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
753
- task.status = "failed";
754
- task.error = error instanceof Error ? error.message : String(error);
755
- task.finishedAt = Date.now();
756
- this.publish(task);
757
- }
758
- } finally {
759
- this.controllers.delete(task.id);
760
- }
761
1014
  }
762
1015
  } finally {
763
- this.running = false;
1016
+ this.controllers.delete(task.id);
764
1017
  }
765
1018
  }
766
1019
  publish(task) {
@@ -783,6 +1036,11 @@ var GenerationTaskQueue = class {
783
1036
  * Shared host-side generation runtime. Both the browser routes and Agent tools
784
1037
  * submit to this one queue so persisted history and cancellation semantics stay
785
1038
  * identical regardless of where a request originated.
1039
+ *
1040
+ * Requests carry a channel id (host-filled by the route/tool resolution); the
1041
+ * runtime picks that channel's upstream credentials, otherwise the default
1042
+ * channel, and records a channel snapshot on the history entry so usage
1043
+ * counters and filters survive channel deletion.
786
1044
  */
787
1045
  var ImageGenerationRuntime = class {
788
1046
  resolve;
@@ -791,10 +1049,16 @@ var ImageGenerationRuntime = class {
791
1049
  constructor(resolve, history = { append: appendHistory }) {
792
1050
  this.resolve = resolve;
793
1051
  this.history = history;
794
- this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal));
1052
+ this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal), 4);
795
1053
  }
796
1054
  async run(request, signal) {
797
- const result = await generateImage(this.resolve(), request, { signal });
1055
+ const view = this.resolve();
1056
+ const channel = view.channels.find((candidate) => candidate.id === request.channelId) ?? view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
1057
+ if (channel === void 0) throw new ImageGenError("尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥", "no-channels");
1058
+ const result = await generateImage({
1059
+ apiUrl: channel.apiUrl,
1060
+ apiKey: channel.apiKey
1061
+ }, request, { signal });
798
1062
  try {
799
1063
  const history = await this.history.append({
800
1064
  id: randomUUID(),
@@ -807,7 +1071,11 @@ var ImageGenerationRuntime = class {
807
1071
  detail: request.detail,
808
1072
  n: request.n,
809
1073
  images: result.images,
810
- ...request.refName === void 0 ? {} : { refName: request.refName }
1074
+ ...request.refName === void 0 ? {} : { refName: request.refName },
1075
+ ...request.channelId === void 0 ? {} : { channelId: request.channelId },
1076
+ ...request.channel === void 0 ? {} : { channel: request.channel },
1077
+ ...request.comparisonId === void 0 ? {} : { comparisonId: request.comparisonId },
1078
+ ...request.comparisonModels === void 0 ? {} : { comparisonModels: request.comparisonModels }
811
1079
  });
812
1080
  return {
813
1081
  ...result,
@@ -931,7 +1199,9 @@ function toWire(entry) {
931
1199
  ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
932
1200
  })),
933
1201
  ...entry.refName === void 0 ? {} : { refName: entry.refName },
934
- ...entry.tags === void 0 ? {} : { tags: entry.tags }
1202
+ ...entry.tags === void 0 ? {} : { tags: entry.tags },
1203
+ ...entry.channel === void 0 ? {} : { channel: entry.channel },
1204
+ ...entry.channelId === void 0 ? {} : { channelId: entry.channelId }
935
1205
  };
936
1206
  }
937
1207
  /** List the persisted gallery, newest first, as wire entries. */
@@ -981,7 +1251,9 @@ async function appendGallery(input) {
981
1251
  n: input.n,
982
1252
  images: storedImages,
983
1253
  ...hash === void 0 ? {} : { hash },
984
- ...input.refName === void 0 ? {} : { refName: input.refName }
1254
+ ...input.refName === void 0 ? {} : { refName: input.refName },
1255
+ ...input.channelId === void 0 ? {} : { channelId: input.channelId },
1256
+ ...input.channel === void 0 ? {} : { channel: input.channel }
985
1257
  }, ...await readIndex()];
986
1258
  await writeIndex(merged);
987
1259
  return {
@@ -1403,6 +1675,64 @@ function clearUpdateCache() {
1403
1675
  cached = void 0;
1404
1676
  }
1405
1677
  //#endregion
1678
+ //#region src/presets.ts
1679
+ const IMAGE_PRESETS = [
1680
+ {
1681
+ id: "volc-ark-seedream",
1682
+ name: "字节 · 火山方舟(Seedream)",
1683
+ apiUrl: "https://ark.cn-beijing.volces.com/api/v3",
1684
+ hint: "字节跳动官方 Seedream 文生图/图生图入口",
1685
+ models: [
1686
+ {
1687
+ alias: "seedream-5.0-pro",
1688
+ id: "seedream-5.0-pro"
1689
+ },
1690
+ {
1691
+ alias: "seedream-5.0",
1692
+ id: "seedream-5.0"
1693
+ },
1694
+ {
1695
+ alias: "seedream-4.0",
1696
+ id: "seedream-4.0"
1697
+ }
1698
+ ]
1699
+ },
1700
+ {
1701
+ id: "openai-official",
1702
+ name: "OpenAI 官方",
1703
+ apiUrl: "https://api.openai.com/v1",
1704
+ hint: "OpenAI 官方图像生成接口",
1705
+ models: [{
1706
+ alias: "gpt-image-2",
1707
+ id: "gpt-image-2"
1708
+ }]
1709
+ },
1710
+ {
1711
+ id: "zhipu-official",
1712
+ name: "智谱 AI 官方",
1713
+ apiUrl: "https://open.bigmodel.cn/api/paas/v4",
1714
+ hint: "智谱官方 GLM-Image 图像生成接口",
1715
+ models: [{
1716
+ alias: "glm-image",
1717
+ id: "glm-image"
1718
+ }]
1719
+ },
1720
+ {
1721
+ id: "xai-grok",
1722
+ name: "xAI(Grok)",
1723
+ apiUrl: "https://api.x.ai/v1",
1724
+ hint: "xAI 官方接口:Grok Imagine 系列",
1725
+ models: [{
1726
+ alias: "grok-imagine-image",
1727
+ id: "grok-imagine-image"
1728
+ }]
1729
+ }
1730
+ ];
1731
+ /** Look up one built-in provider by id. */
1732
+ function presetById(id) {
1733
+ return IMAGE_PRESETS.find((preset) => preset.id === id);
1734
+ }
1735
+ //#endregion
1406
1736
  //#region src/routes.ts
1407
1737
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
1408
1738
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
@@ -1463,6 +1793,7 @@ function messageOf(error) {
1463
1793
  function parseGenerateRequest(body) {
1464
1794
  const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1465
1795
  if (prompt === "") return void 0;
1796
+ const comparisonModels = Array.isArray(body.comparisonModels) ? [...new Set(body.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
1466
1797
  return {
1467
1798
  mode: body.mode === "edit" ? "edit" : "text",
1468
1799
  model: typeof body.model === "string" ? body.model : "",
@@ -1472,7 +1803,10 @@ function parseGenerateRequest(body) {
1472
1803
  n: typeof body.n === "number" ? body.n : 1,
1473
1804
  detail: typeof body.detail === "string" ? body.detail : "",
1474
1805
  ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {},
1475
- ...typeof body.refName === "string" && body.refName !== "" ? { refName: body.refName } : {}
1806
+ ...typeof body.refName === "string" && body.refName !== "" ? { refName: body.refName } : {},
1807
+ ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {},
1808
+ ...typeof body.comparisonId === "string" && body.comparisonId !== "" ? { comparisonId: body.comparisonId } : {},
1809
+ ...comparisonModels.length > 1 ? { comparisonModels } : {}
1476
1810
  };
1477
1811
  }
1478
1812
  /** Validate a submitted history entry (images carry base64). */
@@ -1497,6 +1831,7 @@ function parseHistoryEntryInput(body) {
1497
1831
  ...typeof image.revisedPrompt === "string" ? { revisedPrompt: image.revisedPrompt } : {}
1498
1832
  });
1499
1833
  }
1834
+ const comparisonModels = Array.isArray(entry.comparisonModels) ? [...new Set(entry.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
1500
1835
  return {
1501
1836
  id: entry.id,
1502
1837
  createdAt: entry.createdAt,
@@ -1508,7 +1843,11 @@ function parseHistoryEntryInput(body) {
1508
1843
  detail: entry.detail,
1509
1844
  n: entry.n,
1510
1845
  images,
1511
- ...typeof entry.refName === "string" ? { refName: entry.refName } : {}
1846
+ ...typeof entry.refName === "string" ? { refName: entry.refName } : {},
1847
+ ...typeof entry.channelId === "string" ? { channelId: entry.channelId } : {},
1848
+ ...typeof entry.channel === "string" ? { channel: entry.channel } : {},
1849
+ ...typeof entry.comparisonId === "string" ? { comparisonId: entry.comparisonId } : {},
1850
+ ...comparisonModels.length > 1 ? { comparisonModels } : {}
1512
1851
  };
1513
1852
  }
1514
1853
  /** Extract the image file name from a history-image request URL. */
@@ -1523,6 +1862,33 @@ function imageFileFrom(rawUrl, basePath) {
1523
1862
  if (!pathname.startsWith(`${basePath}/`)) return void 0;
1524
1863
  return decodeURIComponent(pathname.slice(basePath.length + 1));
1525
1864
  }
1865
+ /** Parse the durable image reference carried by an Agent tool-result view. */
1866
+ function agentImageRefFrom(rawUrl) {
1867
+ if (rawUrl === void 0) return void 0;
1868
+ let url;
1869
+ try {
1870
+ url = new URL(rawUrl, "http://localhost");
1871
+ } catch {
1872
+ return;
1873
+ }
1874
+ if (url.pathname !== "/api/dsh-imagegen/agent-image") return void 0;
1875
+ const attachmentId = url.searchParams.get("attachment_id") ?? "";
1876
+ const mediaType = url.searchParams.get("media_type") ?? "";
1877
+ const bytes = Number(url.searchParams.get("bytes"));
1878
+ const width = Number(url.searchParams.get("width"));
1879
+ const height = Number(url.searchParams.get("height"));
1880
+ if (attachmentId === "" || !isImageMediaType(mediaType) || !Number.isSafeInteger(bytes) || bytes < 1 || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) return void 0;
1881
+ return {
1882
+ attachmentId,
1883
+ mediaType,
1884
+ bytes,
1885
+ width,
1886
+ height
1887
+ };
1888
+ }
1889
+ function isImageMediaType(value) {
1890
+ return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
1891
+ }
1526
1892
  /** Project one settings descriptor onto the bridge wire view. */
1527
1893
  function toView(descriptor) {
1528
1894
  return {
@@ -1583,18 +1949,85 @@ function makeRoutes(deps) {
1583
1949
  model: ""
1584
1950
  }));
1585
1951
  const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(void 0));
1586
- const parseConfiguredRequest = (body) => {
1587
- const request = parseGenerateRequest(body);
1588
- if (request === void 0) return void 0;
1589
- const models = normalizeImageModels(resolveImageModels());
1590
- const model = request.model.trim() === "" ? models[0] : request.model.trim();
1591
- if (!models.includes(model)) throw new Error(`image model "${model}" is not configured; choose one of: ${models.join(", ")}`);
1952
+ /** The current channel view: the channel-aware resolver, or a synthesized
1953
+ * single default channel from the legacy flat upstream config (tests and
1954
+ * older hosts). */
1955
+ const channelViewOf = () => {
1956
+ if (deps.resolveChannels !== void 0) return deps.resolveChannels();
1957
+ const upstream = deps.resolve();
1958
+ const models = normalizeImageModels(resolveImageModels()).map((id) => ({
1959
+ alias: id,
1960
+ id
1961
+ }));
1962
+ if (upstream.apiUrl.trim() === "" && models.length === 0) return {
1963
+ channels: [],
1964
+ defaultChannelId: ""
1965
+ };
1966
+ return {
1967
+ channels: [{
1968
+ id: "default",
1969
+ preset: "",
1970
+ name: "默认渠道",
1971
+ apiUrl: upstream.apiUrl,
1972
+ apiKey: upstream.apiKey,
1973
+ models
1974
+ }],
1975
+ defaultChannelId: "default"
1976
+ };
1977
+ };
1978
+ const runtime = deps.runtime ?? new ImageGenerationRuntime(channelViewOf, history);
1979
+ /** Resolve an alias (or the channel fallback) into a concrete generation
1980
+ * request: picks the channel (explicit then default), maps alias → upstream
1981
+ * id, and fills the channel snapshot kept on history entries. */
1982
+ const resolveChannelRequest = (request) => {
1983
+ const view = channelViewOf();
1984
+ if (view.channels.length === 0) return {
1985
+ ok: false,
1986
+ code: "no-channels",
1987
+ message: "尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥"
1988
+ };
1989
+ const explicit = view.channels.find((candidate) => candidate.id === request.channelId);
1990
+ const defaults = view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
1991
+ const target = explicit ?? defaults;
1992
+ const asked = request.model.trim();
1993
+ if (asked === "") {
1994
+ const alias = target?.models[0]?.alias ?? "";
1995
+ if (alias === "") return {
1996
+ ok: false,
1997
+ code: "no-models",
1998
+ message: `渠道「${target?.name ?? ""}」尚未配置模型,请先在设置中添加`
1999
+ };
2000
+ const mapping = target.models.find((model) => model.alias === alias);
2001
+ return {
2002
+ ok: true,
2003
+ request: {
2004
+ ...request,
2005
+ model: alias,
2006
+ upstream: mapping.id,
2007
+ channelId: target.id,
2008
+ channel: target.name
2009
+ }
2010
+ };
2011
+ }
2012
+ const hosting = view.channels.filter((channel) => channel.models.some((model) => model.alias === asked));
2013
+ if (hosting.length === 0) return {
2014
+ ok: false,
2015
+ code: "image-model-not-configured",
2016
+ message: `模型「${asked}」未在任一渠道配置;可用模型:${[...new Set(view.channels.flatMap((channel) => channel.models.map((model) => model.alias)))].join("、") || "(无)"}`
2017
+ };
2018
+ const picked = target !== void 0 && target.models.some((model) => model.alias === asked) ? target : hosting[0];
2019
+ const mapping = picked.models.find((model) => model.alias === asked);
1592
2020
  return {
1593
- ...request,
1594
- model
2021
+ ok: true,
2022
+ request: {
2023
+ ...request,
2024
+ model: asked,
2025
+ upstream: mapping.id,
2026
+ channelId: picked.id,
2027
+ channel: picked.name
2028
+ }
1595
2029
  };
1596
2030
  };
1597
- const runtime = deps.runtime ?? new ImageGenerationRuntime(deps.resolve, history);
1598
2031
  const guard = (req, res, method) => {
1599
2032
  if (!isLoopbackRequest(req)) {
1600
2033
  writeJson(res, 403, { error: "forbidden: loopback-only" });
@@ -1607,15 +2040,52 @@ function makeRoutes(deps) {
1607
2040
  return true;
1608
2041
  };
1609
2042
  return [
2043
+ ...deps.attachments === void 0 ? [] : [{
2044
+ kind: "prefix",
2045
+ path: AGENT_IMAGE_API,
2046
+ handler: async (req, res) => {
2047
+ if (!isLoopbackRequest(req)) {
2048
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2049
+ return;
2050
+ }
2051
+ if (req.method !== "GET") {
2052
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
2053
+ return;
2054
+ }
2055
+ const ref = agentImageRefFrom(req.url);
2056
+ if (ref === void 0) {
2057
+ writeJson(res, 400, { error: "invalid image reference" });
2058
+ return;
2059
+ }
2060
+ try {
2061
+ const stored = await deps.attachments.readImage(ref);
2062
+ res.writeHead(200, {
2063
+ "content-type": stored.ref.mediaType,
2064
+ "content-length": stored.data.byteLength,
2065
+ "cache-control": "private, max-age=3600"
2066
+ });
2067
+ res.end(Buffer.from(stored.data));
2068
+ } catch {
2069
+ writeJson(res, 404, { error: "image attachment not found" });
2070
+ }
2071
+ }
2072
+ }],
1610
2073
  {
1611
2074
  kind: "exact",
1612
2075
  path: IMAGE_MODEL_API.models,
1613
2076
  handler: async (req, res) => {
1614
2077
  if (!guard(req, res, "POST")) return;
2078
+ const body = await readJsonBody(req);
2079
+ const view = channelViewOf();
2080
+ 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];
2081
+ const upstream = {
2082
+ apiUrl: typeof body?.apiUrl === "string" && body.apiUrl.trim() !== "" ? body.apiUrl.trim() : stored?.apiUrl ?? "",
2083
+ apiKey: typeof body?.apiKey === "string" && body.apiKey.trim() !== "" ? body.apiKey.trim() : stored?.apiKey ?? ""
2084
+ };
1615
2085
  try {
1616
2086
  writeJson(res, 200, {
1617
2087
  ok: true,
1618
- models: await listOpenAIModels(deps.resolve())
2088
+ models: await listImageModels(upstream)
1619
2089
  });
1620
2090
  } catch (error) {
1621
2091
  writeJson(res, 200, {
@@ -1626,6 +2096,55 @@ function makeRoutes(deps) {
1626
2096
  }
1627
2097
  }
1628
2098
  },
2099
+ {
2100
+ kind: "exact",
2101
+ path: PRESETS_API,
2102
+ handler: async (req, res) => {
2103
+ if (!guard(req, res, "POST")) return;
2104
+ writeJson(res, 200, {
2105
+ ok: true,
2106
+ presets: IMAGE_PRESETS.map((preset) => ({
2107
+ id: preset.id,
2108
+ name: preset.name,
2109
+ apiUrl: preset.apiUrl,
2110
+ hint: preset.hint,
2111
+ models: preset.models
2112
+ }))
2113
+ });
2114
+ }
2115
+ },
2116
+ {
2117
+ kind: "exact",
2118
+ path: USAGE_API,
2119
+ handler: async (req, res) => {
2120
+ if (!guard(req, res, "POST")) return;
2121
+ try {
2122
+ const entries = [...await history.list(), ...await gallery.list()];
2123
+ const byChannel = {};
2124
+ const totals = {};
2125
+ for (const entry of entries) {
2126
+ const channelKey = entry.channelId !== void 0 ? entry.channelId : entry.channel !== void 0 ? `name:${entry.channel}` : "";
2127
+ const alias = entry.model;
2128
+ const bucket = byChannel[channelKey] ?? (byChannel[channelKey] = {});
2129
+ bucket[alias] = (bucket[alias] ?? 0) + 1;
2130
+ totals[alias] = (totals[alias] ?? 0) + 1;
2131
+ }
2132
+ writeJson(res, 200, {
2133
+ ok: true,
2134
+ usage: {
2135
+ byChannel,
2136
+ totals
2137
+ }
2138
+ });
2139
+ } catch (error) {
2140
+ writeJson(res, 200, {
2141
+ ok: false,
2142
+ code: "usage-failed",
2143
+ message: messageOf(error)
2144
+ });
2145
+ }
2146
+ }
2147
+ },
1629
2148
  {
1630
2149
  kind: "exact",
1631
2150
  path: PROMPT_ENHANCE_API.models,
@@ -1740,37 +2259,28 @@ function makeRoutes(deps) {
1740
2259
  handler: async (req, res) => {
1741
2260
  if (!guard(req, res, "POST")) return;
1742
2261
  const body = await readJsonBody(req);
1743
- if (body === void 0) {
2262
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2263
+ if (parsed === void 0) {
1744
2264
  writeJson(res, 200, {
1745
2265
  ok: false,
1746
2266
  code: "bad-request",
1747
- message: "unreadable JSON body"
2267
+ message: "prompt is required"
1748
2268
  });
1749
2269
  return;
1750
2270
  }
1751
- let request;
1752
- try {
1753
- request = parseConfiguredRequest(body);
1754
- } catch (error) {
2271
+ const resolved = resolveChannelRequest(parsed);
2272
+ if (!resolved.ok) {
1755
2273
  writeJson(res, 200, {
1756
2274
  ok: false,
1757
- code: "image-model-not-configured",
1758
- message: messageOf(error)
1759
- });
1760
- return;
1761
- }
1762
- if (request === void 0) {
1763
- writeJson(res, 200, {
1764
- ok: false,
1765
- code: "bad-request",
1766
- message: "prompt is required"
2275
+ code: resolved.code,
2276
+ message: resolved.message
1767
2277
  });
1768
2278
  return;
1769
2279
  }
1770
2280
  try {
1771
2281
  writeJson(res, 200, {
1772
2282
  ok: true,
1773
- ...await runtime.run(request)
2283
+ ...await runtime.run(resolved.request)
1774
2284
  });
1775
2285
  } catch (error) {
1776
2286
  const message = error instanceof Error ? error.message : String(error);
@@ -1788,28 +2298,27 @@ function makeRoutes(deps) {
1788
2298
  handler: async (req, res) => {
1789
2299
  if (!guard(req, res, "POST")) return;
1790
2300
  const body = await readJsonBody(req);
1791
- let request;
1792
- try {
1793
- request = body === void 0 ? void 0 : parseConfiguredRequest(body);
1794
- } catch (error) {
2301
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2302
+ if (parsed === void 0) {
1795
2303
  writeJson(res, 200, {
1796
2304
  ok: false,
1797
- code: "image-model-not-configured",
1798
- message: messageOf(error)
2305
+ code: "bad-request",
2306
+ message: "prompt is required"
1799
2307
  });
1800
2308
  return;
1801
2309
  }
1802
- if (request === void 0) {
2310
+ const resolved = resolveChannelRequest(parsed);
2311
+ if (!resolved.ok) {
1803
2312
  writeJson(res, 200, {
1804
2313
  ok: false,
1805
- code: "bad-request",
1806
- message: "prompt is required"
2314
+ code: resolved.code,
2315
+ message: resolved.message
1807
2316
  });
1808
2317
  return;
1809
2318
  }
1810
2319
  writeJson(res, 200, {
1811
2320
  ok: true,
1812
- task: runtime.queue.submit(request)
2321
+ task: runtime.queue.submit(resolved.request)
1813
2322
  });
1814
2323
  }
1815
2324
  },
@@ -2386,10 +2895,54 @@ function renderTaskResult(value) {
2386
2895
  return [{
2387
2896
  type: "text",
2388
2897
  text: JSON.stringify(value)
2389
- }, ...value.images.map((image) => ({
2390
- type: "image",
2391
- attachment: restoreRef(image)
2392
- }))];
2898
+ }];
2899
+ }
2900
+ /** The UI-only projection that keeps generated images beside the tool call. */
2901
+ function imagePresentationMeta(value) {
2902
+ return { images: value.images.map((image) => {
2903
+ const ref = {
2904
+ attachment_id: image.attachment_id,
2905
+ media_type: image.media_type,
2906
+ bytes: image.bytes,
2907
+ width: image.width,
2908
+ height: image.height
2909
+ };
2910
+ if (image.name !== void 0) ref.name = image.name;
2911
+ return ref;
2912
+ }) };
2913
+ }
2914
+ function imageBlocksFromMeta(meta) {
2915
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return [];
2916
+ const images = meta.images;
2917
+ if (!Array.isArray(images)) return [];
2918
+ return images.flatMap((value) => {
2919
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
2920
+ const raw = value;
2921
+ if (typeof raw.attachment_id !== "string" || typeof raw.media_type !== "string" || typeof raw.bytes !== "number" || typeof raw.width !== "number" || typeof raw.height !== "number") return [];
2922
+ try {
2923
+ return [{
2924
+ type: "image",
2925
+ attachment: restoreRef({
2926
+ attachment_id: raw.attachment_id,
2927
+ media_type: raw.media_type,
2928
+ bytes: raw.bytes,
2929
+ width: raw.width,
2930
+ height: raw.height,
2931
+ ...typeof raw.name === "string" ? { name: raw.name } : {}
2932
+ })
2933
+ }];
2934
+ } catch {
2935
+ return [];
2936
+ }
2937
+ });
2938
+ }
2939
+ /** Rehydrate image attachments for the host-computed tool result view only. */
2940
+ function presentImageResult(_args, result) {
2941
+ const content = result.isError ? [] : imageBlocksFromMeta(result.meta);
2942
+ return content.length === 0 ? void 0 : {
2943
+ card: "generic",
2944
+ content
2945
+ };
2393
2946
  }
2394
2947
  /** Register the global Agent tools and unregister them with the plugin lifecycle. */
2395
2948
  function registerAgentImageTools(ctx, runtime, resolve) {
@@ -2398,13 +2951,32 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2398
2951
  const config = resolve();
2399
2952
  if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.", "plugin-disabled");
2400
2953
  if (!config.allowAgentImageGeneration) throw new ImageGenError("Agent image generation is disabled in Settings > Plugins > AI Image.", "agent-generation-disabled");
2401
- if (config.apiUrl.trim() === "" || config.apiKey.trim() === "") throw new ImageGenError("Image API credentials are not configured. Open Settings > Plugins > AI Image and fill in API URL and API key.", "image-api-not-configured");
2954
+ if (!config.channels.some((channel) => channel.apiUrl.trim() !== "" && channel.apiKey.trim() !== "")) throw new ImageGenError("Image API credentials are not configured. Open Settings > Plugins > AI Image, add a channel and fill in its API URL and API key.", "image-api-not-configured");
2402
2955
  };
2403
- const selectedModel = (requested) => {
2404
- const models = normalizeImageModels(resolve().imageModels);
2405
- const model = typeof requested === "string" && requested.trim() !== "" ? requested.trim() : models[0];
2406
- if (!models.includes(model)) throw new ImageGenError(`Image model "${model}" is not configured. Choose one of: ${models.join(", ")}.`, "image-model-not-configured");
2407
- return model;
2956
+ /**
2957
+ * Resolve the requested model alias onto a channel. Rules:
2958
+ * - a named alias must exist in some channel's catalog (several channels
2959
+ * may host it; the default channel wins);
2960
+ * - with no alias, a single configured model is used directly, while
2961
+ * multiple models require the Agent to ask the user first.
2962
+ * @returns the channel plus the alias and its upstream id.
2963
+ */
2964
+ const resolveModel = (requested) => {
2965
+ const config = resolve();
2966
+ const entries = config.channels.flatMap((channel) => channel.models.map((model) => ({
2967
+ channel,
2968
+ alias: model.alias,
2969
+ upstream: model.id
2970
+ })));
2971
+ if (entries.length === 0) throw new ImageGenError("No image models are configured. Open Settings > Plugins > AI Image and add a channel with at least one model.", "no-models-configured");
2972
+ const wanted = typeof requested === "string" && requested.trim() !== "" ? requested.trim() : "";
2973
+ if (wanted === "") {
2974
+ if (entries.length === 1) return entries[0];
2975
+ 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");
2976
+ }
2977
+ const hosting = entries.filter((entry) => entry.alias === wanted);
2978
+ 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");
2979
+ return hosting.find((entry) => entry.channel.id === config.defaultChannelId) ?? hosting[0];
2408
2980
  };
2409
2981
  const materializeTaskImages = (task) => {
2410
2982
  if (task.status !== "completed") return Promise.resolve([]);
@@ -2422,7 +2994,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2422
2994
  return {
2423
2995
  task_id: task.id,
2424
2996
  status: task.status,
2425
- message: task.status === "completed" ? "Generation completed. The images are attached below 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.",
2997
+ 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.",
2426
2998
  ...task.error === void 0 ? {} : { error: task.error },
2427
2999
  images
2428
3000
  };
@@ -2491,7 +3063,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2491
3063
  const disposers = [
2492
3064
  ctx.tools.register(defineTool({
2493
3065
  name: "generate_image",
2494
- description: "Generate an image. By default this tool call stays pending until the task reaches a final state, and completed images are returned directly as tool-result attachments 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 first configured image model.",
3066
+ 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 first configured image model.",
2495
3067
  parameters: {
2496
3068
  prompt: {
2497
3069
  type: "string",
@@ -2525,13 +3097,19 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2525
3097
  },
2526
3098
  output: {
2527
3099
  schema: taskResultSchema,
2528
- render: (_args, value) => renderTaskResult(value)
3100
+ render: (_args, value) => renderTaskResult(value),
3101
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2529
3102
  },
3103
+ presentResult: presentImageResult,
2530
3104
  async execute(args, exec) {
2531
3105
  ensureConfigured();
3106
+ const picked = resolveModel(args.model);
2532
3107
  const task = runtime.queue.submit({
2533
3108
  mode: "text",
2534
- model: selectedModel(args.model),
3109
+ model: picked.alias,
3110
+ upstream: picked.upstream,
3111
+ channelId: picked.channel.id,
3112
+ channel: picked.channel.name,
2535
3113
  prompt: args.prompt.trim(),
2536
3114
  size: args.size ?? "auto",
2537
3115
  quality: args.quality ?? "auto",
@@ -2543,7 +3121,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2543
3121
  })),
2544
3122
  ctx.tools.register(defineTool({
2545
3123
  name: "edit_image",
2546
- description: "Edit an image. By default this tool call stays pending until the task reaches a final state, and completed images are returned directly as tool-result attachments 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 first configured model.",
3124
+ 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 first configured model.",
2547
3125
  parameters: {
2548
3126
  prompt: {
2549
3127
  type: "string",
@@ -2582,14 +3160,20 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2582
3160
  },
2583
3161
  output: {
2584
3162
  schema: taskResultSchema,
2585
- render: (_args, value) => renderTaskResult(value)
3163
+ render: (_args, value) => renderTaskResult(value),
3164
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2586
3165
  },
3166
+ presentResult: presentImageResult,
2587
3167
  async execute(args, exec) {
2588
3168
  ensureConfigured();
2589
3169
  const reference = await ctx.attachments.readImage(restoreRef(args.source_image), exec.signal);
3170
+ const picked = resolveModel(args.model);
2590
3171
  const task = runtime.queue.submit({
2591
3172
  mode: "edit",
2592
- model: selectedModel(args.model),
3173
+ model: picked.alias,
3174
+ upstream: picked.upstream,
3175
+ channelId: picked.channel.id,
3176
+ channel: picked.channel.name,
2593
3177
  prompt: args.prompt.trim(),
2594
3178
  size: args.size ?? "auto",
2595
3179
  quality: args.quality ?? "auto",
@@ -2603,7 +3187,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2603
3187
  })),
2604
3188
  ctx.tools.register(defineTool({
2605
3189
  name: "get_image_generation_task",
2606
- description: "Check an image-generation task status. Completed tasks return image references and image attachments for edit_image. Generation tools normally wait for completion, so use this for explicit recovery or status checks.",
3190
+ 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.",
2607
3191
  parameters: { task_id: {
2608
3192
  type: "string",
2609
3193
  required: true,
@@ -2611,8 +3195,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2611
3195
  } },
2612
3196
  output: {
2613
3197
  schema: taskResultSchema,
2614
- render: (_args, value) => renderTaskResult(value)
3198
+ render: (_args, value) => renderTaskResult(value),
3199
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2615
3200
  },
3201
+ presentResult: presentImageResult,
2616
3202
  async execute(args) {
2617
3203
  ensureConfigured();
2618
3204
  return taskResult(findTask(args.task_id));
@@ -2628,8 +3214,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2628
3214
  } },
2629
3215
  output: {
2630
3216
  schema: taskResultSchema,
2631
- render: (_args, value) => renderTaskResult(value)
3217
+ render: (_args, value) => renderTaskResult(value),
3218
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2632
3219
  },
3220
+ presentResult: presentImageResult,
2633
3221
  async execute(args) {
2634
3222
  ensureConfigured();
2635
3223
  const task = runtime.queue.cancel(args.task_id);
@@ -2646,9 +3234,11 @@ function isFinalTask(task) {
2646
3234
  return task.status === "completed" || task.status === "failed" || task.status === "cancelled";
2647
3235
  }
2648
3236
  function toSaveImage(image, taskId, index) {
2649
- const mediaType = acceptedMediaType(image.mime) ? image.mime : "image/png";
3237
+ const data = Buffer.from(image.b64, "base64");
3238
+ const declaredMediaType = acceptedMediaType(image.mime) ? image.mime : "image/png";
3239
+ const mediaType = detectImageMime(data) ?? declaredMediaType;
2650
3240
  return {
2651
- data: Buffer.from(image.b64, "base64"),
3241
+ data,
2652
3242
  mediaType,
2653
3243
  name: `imagegen-${taskId}-${index + 1}.${mediaType === "image/jpeg" ? "jpg" : mediaType.slice(6)}`
2654
3244
  };
@@ -2665,12 +3255,24 @@ const Config = z.object({
2665
3255
  enabled: z.boolean().default(true),
2666
3256
  announceToAgent: z.boolean().default(true),
2667
3257
  allowAgentImageGeneration: z.boolean().default(true),
2668
- apiUrl: z.string().default(""),
2669
- apiKey: z.string().role("secret").default(""),
2670
- imageModels: z.array(z.string()).default([...DEFAULT_IMAGE_MODELS]),
3258
+ channels: z.array(z.object({
3259
+ id: z.string(),
3260
+ preset: z.string().default(""),
3261
+ name: z.string().default(""),
3262
+ apiUrl: z.string().default(""),
3263
+ models: z.array(z.object({
3264
+ alias: z.string(),
3265
+ id: z.string()
3266
+ })).default([])
3267
+ })).default([]),
3268
+ channelSecrets: z.dict(z.string().role("secret")).default({}),
3269
+ defaultChannelId: z.string().default(""),
2671
3270
  promptApiUrl: z.string().default(""),
2672
3271
  promptApiKey: z.string().role("secret").default(""),
2673
- promptModel: z.string().default("")
3272
+ promptModel: z.string().default(""),
3273
+ apiUrl: z.string().default(""),
3274
+ apiKey: z.string().role("secret").default(""),
3275
+ imageModels: z.array(z.string()).default([])
2674
3276
  });
2675
3277
  /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
2676
3278
  const DEFAULT_ENABLED = true;
@@ -2679,10 +3281,49 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
2679
3281
  /** Order of the announcement section within the tool-guidance band. */
2680
3282
  const SECTION_ORDER = 150;
2681
3283
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
2682
- const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API,模型由用户在「设置 插件 AI 生图」中检测或手动配置的生图模型列表决定;支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送)。API 地址与密钥在 GUI 设置中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用已配置的生图模型;模型出现在 /models 中不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片直接作为工具结果附件返回,不会额外伪造用户消息。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
2683
- /** Add the live allow-list so an Agent can honor a user's model choice. */
2684
- function guidanceFor(imageModels) {
2685
- return `${IMAGEGEN_GUIDANCE} 当前允许调用的生图模型:${imageModels.join("、")}。用户指定其中某个模型时,工具参数 model 必须使用该精确名称;未指定时使用列表中的第一个。`;
3284
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「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`,当前仅支持文生图)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
3285
+ /** Append the live channel × model table so an Agent can honor user choices. */
3286
+ function guidanceFor(channels, defaultChannelId) {
3287
+ if (channels.length === 0) return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`;
3288
+ const table = channels.map((channel) => {
3289
+ const aliases = channel.models.map((model) => model.alias).join("、");
3290
+ const mark = channel.id === defaultChannelId ? "(默认渠道)" : "";
3291
+ const key = channel.apiKey === "" ? "(未填密钥)" : "";
3292
+ const models = channel.models.length === 0 ? "未配置模型" : `可用模型:${aliases}`;
3293
+ return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`;
3294
+ }).join(";");
3295
+ return `${IMAGEGEN_GUIDANCE} 当前渠道与模型:${table}。用户指定模型名时取该模型所属渠道(多渠道同名用默认渠道);未指定模型时若仅一个可用模型可直接生成,若有多个应先询问用户选择「渠道 + 模型」。`;
3296
+ }
3297
+ /** Normalize raw channel entries into the wire shape (schema-adjacent guard). */
3298
+ function normalizeChannels(value) {
3299
+ if (!Array.isArray(value)) return [];
3300
+ const out = [];
3301
+ for (const item of value) {
3302
+ if (item === null || typeof item !== "object") continue;
3303
+ const raw = item;
3304
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
3305
+ if (id === "") continue;
3306
+ const models = [];
3307
+ if (Array.isArray(raw.models)) for (const entry of raw.models) {
3308
+ if (entry === null || typeof entry !== "object") continue;
3309
+ const record = entry;
3310
+ const alias = typeof record.alias === "string" ? record.alias.trim() : "";
3311
+ const upstream = typeof record.id === "string" ? record.id.trim() : "";
3312
+ if (alias === "") continue;
3313
+ models.push({
3314
+ alias,
3315
+ id: upstream === "" ? alias : upstream
3316
+ });
3317
+ }
3318
+ out.push({
3319
+ id,
3320
+ preset: typeof raw.preset === "string" ? raw.preset : "",
3321
+ name: typeof raw.name === "string" ? raw.name.trim() : "",
3322
+ apiUrl: typeof raw.apiUrl === "string" ? raw.apiUrl.trim() : "",
3323
+ models
3324
+ });
3325
+ }
3326
+ return out;
2686
3327
  }
2687
3328
  /**
2688
3329
  * Mount the settings section, routes, and announcement.
@@ -2692,47 +3333,82 @@ function guidanceFor(imageModels) {
2692
3333
  function apply(ctx, config) {
2693
3334
  let current = () => config ?? {};
2694
3335
  const resolve = () => {
2695
- const value = current();
3336
+ const value = current() ?? {};
3337
+ let channels = normalizeChannels(value.channels);
3338
+ const secrets = { ...value.channelSecrets ?? {} };
3339
+ if (channels.length === 0) {
3340
+ const legacyUrl = typeof value.apiUrl === "string" ? value.apiUrl.trim() : "";
3341
+ const legacyModels = Array.isArray(value.imageModels) ? value.imageModels.filter((model) => typeof model === "string" && model.trim() !== "").map((model) => ({
3342
+ alias: model.trim(),
3343
+ id: model.trim()
3344
+ })) : [];
3345
+ if (legacyUrl !== "" || legacyModels.length > 0) {
3346
+ channels = [{
3347
+ id: "default",
3348
+ preset: "",
3349
+ name: "默认渠道",
3350
+ apiUrl: legacyUrl,
3351
+ models: legacyModels
3352
+ }];
3353
+ const legacyKey = typeof value.apiKey === "string" ? value.apiKey.trim() : "";
3354
+ if (legacyKey !== "") secrets["default"] = legacyKey;
3355
+ }
3356
+ }
3357
+ const named = channels.map((channel) => ({
3358
+ ...channel,
3359
+ name: channel.name === "" ? presetById(channel.preset)?.name ?? "未命名渠道" : channel.name
3360
+ }));
3361
+ const defaultChannelId = typeof value.defaultChannelId === "string" && named.some((channel) => channel.id === value.defaultChannelId) ? value.defaultChannelId : named[0]?.id ?? "";
2696
3362
  return {
2697
3363
  enabled: value.enabled ?? DEFAULT_ENABLED,
2698
3364
  announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
2699
3365
  allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
2700
- apiUrl: value.apiUrl ?? "",
2701
- apiKey: value.apiKey ?? "",
2702
- imageModels: normalizeImageModels(value.imageModels),
2703
- promptApiUrl: value.promptApiUrl ?? "",
2704
- promptApiKey: value.promptApiKey ?? "",
2705
- promptModel: value.promptModel ?? ""
3366
+ channels: named.map((channel) => ({
3367
+ ...channel,
3368
+ apiKey: typeof secrets[channel.id] === "string" ? secrets[channel.id] : ""
3369
+ })),
3370
+ defaultChannelId,
3371
+ promptApiUrl: typeof value.promptApiUrl === "string" ? value.promptApiUrl.trim() : "",
3372
+ promptApiKey: typeof value.promptApiKey === "string" ? value.promptApiKey.trim() : "",
3373
+ promptModel: typeof value.promptModel === "string" ? value.promptModel.trim() : ""
2706
3374
  };
2707
3375
  };
2708
- const runtime = new ImageGenerationRuntime(() => {
3376
+ const channelsView = () => {
2709
3377
  const value = resolve();
2710
3378
  return {
2711
- apiUrl: value.apiUrl,
2712
- apiKey: value.apiKey
3379
+ channels: value.channels,
3380
+ defaultChannelId: value.defaultChannelId
2713
3381
  };
2714
- });
2715
- ctx.inject(["settings"], (sctx) => {
3382
+ };
3383
+ const runtime = new ImageGenerationRuntime(channelsView);
3384
+ ctx.inject(["settings", "attachments"], (sctx) => {
2716
3385
  const seam = sctx.get("settings");
2717
3386
  sctx.effect(() => {
2718
3387
  const disposers = makeRoutes({
2719
3388
  settings: seam,
2720
3389
  resolve: () => {
2721
3390
  const value = resolve();
3391
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
2722
3392
  return {
2723
- apiUrl: value.apiUrl,
2724
- apiKey: value.apiKey
3393
+ apiUrl: channel?.apiUrl ?? "",
3394
+ apiKey: channel?.apiKey ?? ""
2725
3395
  };
2726
3396
  },
3397
+ resolveChannels: channelsView,
2727
3398
  resolvePrompt: () => {
2728
3399
  const value = resolve();
3400
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
2729
3401
  return {
2730
- apiUrl: value.promptApiUrl.trim() || value.apiUrl,
2731
- apiKey: value.promptApiKey.trim() || value.apiKey,
3402
+ apiUrl: value.promptApiUrl !== "" ? value.promptApiUrl : channel?.apiUrl ?? "",
3403
+ apiKey: value.promptApiKey !== "" ? value.promptApiKey : channel?.apiKey ?? "",
2732
3404
  model: value.promptModel
2733
3405
  };
2734
3406
  },
2735
- resolveImageModels: () => resolve().imageModels,
3407
+ resolveImageModels: () => {
3408
+ const value = resolve();
3409
+ return [...new Set(value.channels.flatMap((channel) => channel.models.map((model) => model.alias)))];
3410
+ },
3411
+ attachments: sctx.attachments,
2736
3412
  runtime
2737
3413
  }).map((route) => ctx.webServer.register(route));
2738
3414
  return () => {
@@ -2746,9 +3422,8 @@ function apply(ctx, config) {
2746
3422
  return {
2747
3423
  enabled: value.enabled,
2748
3424
  allowAgentImageGeneration: value.allowAgentImageGeneration,
2749
- apiUrl: value.apiUrl,
2750
- apiKey: value.apiKey,
2751
- imageModels: value.imageModels
3425
+ channels: value.channels,
3426
+ defaultChannelId: value.defaultChannelId
2752
3427
  };
2753
3428
  }), "dsh-imagegen: agent image tools");
2754
3429
  });
@@ -2763,7 +3438,7 @@ function apply(ctx, config) {
2763
3438
  disposeSection = ctx.systemPrompt.section({
2764
3439
  name: "plugin:dsh-imagegen",
2765
3440
  order: SECTION_ORDER,
2766
- text: guidanceFor(value.imageModels)
3441
+ text: guidanceFor(value.channels, value.defaultChannelId)
2767
3442
  });
2768
3443
  };
2769
3444
  installSettingsSection(ctx, ImageGenSettingsNamespace, Config, config ?? {}, {