@dickpy/dsh-imagegen 1.2.2 → 1.3.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.
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.2";
19
+ const PLUGIN_VERSION = "1.3.0";
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",
@@ -140,7 +150,14 @@ async function enhancePrompt(config, prompt) {
140
150
  * `/models` exposes candidates only: the configured list is the explicit
141
151
  * allow-list because OpenAI-compatible gateways rarely advertise modalities.
142
152
  */
143
- const DEFAULT_IMAGE_MODELS = ["gpt-image-2", "grok-imagine-image"];
153
+ const DEFAULT_IMAGE_MODELS = [
154
+ "gpt-image-2",
155
+ "grok-imagine-image",
156
+ "nanobanana2",
157
+ "nanobanana2-lite",
158
+ "nanobanana-pro",
159
+ "seedream-5.0-pro"
160
+ ];
144
161
  /** Normalize user-entered model identifiers and retain a usable legacy default. */
145
162
  function normalizeImageModels(value) {
146
163
  const candidates = Array.isArray(value) ? value : [];
@@ -153,6 +170,113 @@ function normalizeImageModels(value) {
153
170
  return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS];
154
171
  }
155
172
  //#endregion
173
+ //#region src/image-format.ts
174
+ function detectImageMime(data) {
175
+ const startsWith = (...bytes) => bytes.every((value, index) => data[index] === value);
176
+ if (startsWith(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
177
+ if (startsWith(255, 216, 255)) return "image/jpeg";
178
+ if (startsWith(71, 73, 70, 56, 55, 97) || startsWith(71, 73, 70, 56, 57, 97)) return "image/gif";
179
+ if (startsWith(82, 73, 70, 70) && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "image/webp";
180
+ }
181
+ //#endregion
182
+ //#region src/model-catalog.ts
183
+ const ENTRIES = {
184
+ "gpt-image": {
185
+ label: "gpt-image",
186
+ labelZh: "GPT 图像",
187
+ known: true,
188
+ supportsEdit: true,
189
+ supportsAspectRatio: false,
190
+ qualityTiers: [
191
+ "1K",
192
+ "2K",
193
+ "4K"
194
+ ]
195
+ },
196
+ "dall-e": {
197
+ label: "DALL·E",
198
+ labelZh: "DALL·E",
199
+ known: true,
200
+ supportsEdit: true,
201
+ supportsAspectRatio: false,
202
+ qualityTiers: ["auto"]
203
+ },
204
+ grok: {
205
+ label: "grok",
206
+ labelZh: "Grok",
207
+ known: true,
208
+ supportsEdit: true,
209
+ supportsAspectRatio: true,
210
+ qualityTiers: ["1K", "2K"]
211
+ },
212
+ nanobanana: {
213
+ label: "nanobanana",
214
+ labelZh: "Nano Banana",
215
+ known: true,
216
+ supportsEdit: true,
217
+ supportsAspectRatio: true,
218
+ qualityTiers: [
219
+ "1K",
220
+ "2K",
221
+ "4K"
222
+ ]
223
+ },
224
+ seedream: {
225
+ label: "seedream",
226
+ labelZh: "Seedream",
227
+ known: true,
228
+ supportsEdit: true,
229
+ supportsAspectRatio: true,
230
+ qualityTiers: ["1K", "2K"]
231
+ }
232
+ };
233
+ /** Official Gemini image ids served by Nano Banana gateways. */
234
+ const NANOBANANA_GEMINI_IDS = /* @__PURE__ */ new Set([
235
+ "gemini-3-pro-image",
236
+ "gemini-3-pro-image-preview",
237
+ "gemini-3.1-flash-image",
238
+ "gemini-3.1-flash-image-preview",
239
+ "gemini-3.1-flash-lite-image",
240
+ "gemini-2.5-flash-image"
241
+ ]);
242
+ /** Classify one upstream model id into its request-shaping family. */
243
+ function describeModel(model) {
244
+ const id = model.trim();
245
+ if (/^gpt-image/i.test(id)) return {
246
+ family: "gpt-image",
247
+ ...ENTRIES["gpt-image"]
248
+ };
249
+ if (/^dall-e/i.test(id)) return {
250
+ family: "dall-e",
251
+ ...ENTRIES["dall-e"]
252
+ };
253
+ if (/^grok-imagine(?:-|$)/.test(id)) return {
254
+ family: "grok",
255
+ ...ENTRIES.grok
256
+ };
257
+ if (/^nanobanana/i.test(id) || NANOBANANA_GEMINI_IDS.has(id)) return {
258
+ family: "nanobanana",
259
+ ...ENTRIES.nanobanana
260
+ };
261
+ if (/^(?:doubao-)?seedream/i.test(id)) return {
262
+ family: "seedream",
263
+ ...ENTRIES.seedream
264
+ };
265
+ return {
266
+ family: "unknown",
267
+ label: "unknown",
268
+ labelZh: "未知协议",
269
+ known: false,
270
+ supportsEdit: true,
271
+ supportsAspectRatio: false,
272
+ qualityTiers: []
273
+ };
274
+ }
275
+ /** The family a model id routes its request through. */
276
+ function modelFamily(model) {
277
+ return describeModel(model).family;
278
+ }
279
+ //#endregion
156
280
  //#region src/engine.ts
157
281
  /** A generation failure with a user-presentable message. */
158
282
  var ImageGenError = class extends Error {
@@ -176,12 +300,45 @@ const DALLE3_SIZES = /* @__PURE__ */ new Set([
176
300
  "1792x1024",
177
301
  "1024x1792"
178
302
  ]);
303
+ /** The wire model id for a request: `upstream` (host-filled alias mapping)
304
+ * wins, then the alias, then the family default. */
305
+ function wireModel(request) {
306
+ const upstream = request.upstream?.trim();
307
+ if (upstream !== void 0 && upstream !== "") return upstream;
308
+ const alias = request.model.trim();
309
+ return alias === "" ? "gpt-image-2" : alias;
310
+ }
179
311
  /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
180
312
  * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
181
313
  * and exposes its own aspect-ratio / response-format knobs instead of the
182
314
  * OpenAI size/quality/detail passthrough. */
183
315
  function isGrokImagine(model) {
184
- return /^grok-imagine(?:-|$)/.test(model);
316
+ return modelFamily(model) === "grok";
317
+ }
318
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
319
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
320
+ * gateways expose). OpenAI-compatible gateways serve these with their own
321
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
322
+ * passthrough. */
323
+ function isNanoBanana(model) {
324
+ return modelFamily(model) === "nanobanana";
325
+ }
326
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
327
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
328
+ * serve Seedream through a unified generate-and-edit architecture:
329
+ * generation AND editing both go to /images/generations and reference images
330
+ * are a JSON URL / data-URL array. */
331
+ function isSeedream(model) {
332
+ return modelFamily(model) === "seedream";
333
+ }
334
+ /** Whether this is the official Volcengine Ark model naming convention. */
335
+ function isVolcSeedream(model) {
336
+ return /^doubao-seedream(?:-|$)/i.test(model.trim());
337
+ }
338
+ /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
339
+ function seedreamSize(quality) {
340
+ if (quality === "1k") return "1K";
341
+ return "2K";
185
342
  }
186
343
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
187
344
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
@@ -259,7 +416,7 @@ function clampCount(n) {
259
416
  * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
260
417
  * so the count is satisfied by parallel single-image requests instead. */
261
418
  function effectiveParams(request) {
262
- const model = request.model.trim() === "" ? "gpt-image-2" : request.model.trim();
419
+ const model = wireModel(request);
263
420
  if (model === "dall-e-3") {
264
421
  const pixel = OPENAI_SIZE_BY_RATIO[request.size];
265
422
  return {
@@ -273,6 +430,17 @@ function effectiveParams(request) {
273
430
  ...request.quality !== "" && request.quality !== "auto" ? { resolution: request.quality === "4k" ? "2k" : request.quality } : {},
274
431
  response_format: "b64_json"
275
432
  };
433
+ if (isNanoBanana(model)) return {
434
+ model,
435
+ ...request.size !== "" && request.size !== "auto" ? { aspect_ratio: request.size } : {},
436
+ ...request.quality !== "" && request.quality !== "auto" ? { image_size: request.quality.toUpperCase() } : {},
437
+ response_format: "b64_json"
438
+ };
439
+ if (isSeedream(model)) return {
440
+ model,
441
+ size: seedreamSize(request.quality),
442
+ response_format: isVolcSeedream(model) ? "url" : "b64_json"
443
+ };
276
444
  return {
277
445
  model,
278
446
  ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
@@ -284,17 +452,20 @@ function effectiveParams(request) {
284
452
  }
285
453
  /** How many single-image requests to issue for the requested image count. */
286
454
  function effectiveCount(request) {
287
- if ((request.model.trim() === "" ? "gpt-image-2" : request.model.trim()) === "dall-e-3") return 1;
455
+ if (wireModel(request) === "dall-e-3") return 1;
288
456
  return clampCount(request.n);
289
457
  }
290
458
  /** Normalize one upstream data item into a base64 image. */
291
459
  async function normalizeItem(item, upstream) {
292
460
  const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : void 0;
293
- if (typeof item.b64_json === "string") return {
294
- b64: bareBase64(item.b64_json),
295
- mime: "image/png",
296
- revisedPrompt
297
- };
461
+ if (typeof item.b64_json === "string") {
462
+ const b64 = bareBase64(item.b64_json);
463
+ return {
464
+ b64,
465
+ mime: detectImageMime(Buffer.from(b64, "base64")) ?? "image/png",
466
+ revisedPrompt
467
+ };
468
+ }
298
469
  if (typeof item.url !== "string" || item.url === "") throw new ImageGenError("upstream image item has neither b64_json nor url");
299
470
  const url = item.url;
300
471
  if (url.startsWith("data:")) {
@@ -302,7 +473,7 @@ async function normalizeItem(item, upstream) {
302
473
  if (parsed === void 0) throw new ImageGenError("upstream returned a malformed data: url");
303
474
  return {
304
475
  b64: parsed.base64,
305
- mime: parsed.mime,
476
+ mime: detectImageMime(Buffer.from(parsed.base64, "base64")) ?? parsed.mime,
306
477
  revisedPrompt
307
478
  };
308
479
  }
@@ -321,7 +492,7 @@ async function normalizeItem(item, upstream) {
321
492
  if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
322
493
  const buffer = Buffer.from(await response.arrayBuffer());
323
494
  const contentType = response.headers.get("content-type");
324
- const mime = contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png";
495
+ const mime = detectImageMime(buffer) ?? (contentType !== null && contentType !== "" ? contentType.split(";")[0].trim() : mimeOfExtension(url) ?? "image/png");
325
496
  return {
326
497
  b64: buffer.toString("base64"),
327
498
  mime,
@@ -358,6 +529,24 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
358
529
  ...params.aspect_ratio !== void 0 ? { aspect_ratio: params.aspect_ratio } : {},
359
530
  response_format: "b64_json"
360
531
  });
532
+ } else if (isNanoBanana(params.model)) {
533
+ const form = new FormData();
534
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
535
+ form.append("prompt", request.prompt);
536
+ form.append("model", params.model);
537
+ if (params.aspect_ratio !== void 0) form.append("aspect_ratio", params.aspect_ratio);
538
+ if (params.image_size !== void 0) form.append("image_size", params.image_size);
539
+ body = form;
540
+ } else if (isSeedream(params.model)) {
541
+ headers["content-type"] = "application/json";
542
+ body = JSON.stringify({
543
+ model: params.model,
544
+ prompt: request.prompt,
545
+ image: [request.image],
546
+ ...params.size !== void 0 ? { size: params.size } : {},
547
+ ...params.resolution !== void 0 ? { resolution: params.resolution } : {},
548
+ response_format: isVolcSeedream(params.model) ? "url" : "b64_json"
549
+ });
361
550
  } else {
362
551
  const form = new FormData();
363
552
  form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
@@ -378,7 +567,8 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
378
567
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS);
379
568
  let response;
380
569
  try {
381
- response = await fetch(`${baseUrl}/images/${request.mode === "edit" ? "edits" : "generations"}`, {
570
+ const endpoint = request.mode === "edit" && !isSeedream(params.model) ? "/images/edits" : "/images/generations";
571
+ response = await fetch(`${baseUrl}${endpoint}`, {
382
572
  method: "POST",
383
573
  headers,
384
574
  body,
@@ -546,7 +736,9 @@ function toWire$1(entry) {
546
736
  mime: image.mime,
547
737
  ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
548
738
  })),
549
- ...entry.refName === void 0 ? {} : { refName: entry.refName }
739
+ ...entry.refName === void 0 ? {} : { refName: entry.refName },
740
+ ...entry.channel === void 0 ? {} : { channel: entry.channel },
741
+ ...entry.channelId === void 0 ? {} : { channelId: entry.channelId }
550
742
  };
551
743
  }
552
744
  /** List the persisted history, newest first, as wire entries. */
@@ -585,7 +777,9 @@ async function appendHistory(input) {
585
777
  detail: input.detail,
586
778
  n: input.n,
587
779
  images: storedImages,
588
- ...input.refName === void 0 ? {} : { refName: input.refName }
780
+ ...input.refName === void 0 ? {} : { refName: input.refName },
781
+ ...input.channelId === void 0 ? {} : { channelId: input.channelId },
782
+ ...input.channel === void 0 ? {} : { channel: input.channel }
589
783
  }, ...await readIndex$1()];
590
784
  const kept = merged.slice(0, 50);
591
785
  for (const dropped of merged.slice(50)) await removeEntryFiles$1(dropped);
@@ -727,6 +921,11 @@ var GenerationTaskQueue = class {
727
921
  * Shared host-side generation runtime. Both the browser routes and Agent tools
728
922
  * submit to this one queue so persisted history and cancellation semantics stay
729
923
  * identical regardless of where a request originated.
924
+ *
925
+ * Requests carry a channel id (host-filled by the route/tool resolution); the
926
+ * runtime picks that channel's upstream credentials, otherwise the default
927
+ * channel, and records a channel snapshot on the history entry so usage
928
+ * counters and filters survive channel deletion.
730
929
  */
731
930
  var ImageGenerationRuntime = class {
732
931
  resolve;
@@ -738,7 +937,13 @@ var ImageGenerationRuntime = class {
738
937
  this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal));
739
938
  }
740
939
  async run(request, signal) {
741
- const result = await generateImage(this.resolve(), request, { signal });
940
+ const view = this.resolve();
941
+ const channel = view.channels.find((candidate) => candidate.id === request.channelId) ?? view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
942
+ if (channel === void 0) throw new ImageGenError("尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥", "no-channels");
943
+ const result = await generateImage({
944
+ apiUrl: channel.apiUrl,
945
+ apiKey: channel.apiKey
946
+ }, request, { signal });
742
947
  try {
743
948
  const history = await this.history.append({
744
949
  id: randomUUID(),
@@ -751,7 +956,9 @@ var ImageGenerationRuntime = class {
751
956
  detail: request.detail,
752
957
  n: request.n,
753
958
  images: result.images,
754
- ...request.refName === void 0 ? {} : { refName: request.refName }
959
+ ...request.refName === void 0 ? {} : { refName: request.refName },
960
+ ...request.channelId === void 0 ? {} : { channelId: request.channelId },
961
+ ...request.channel === void 0 ? {} : { channel: request.channel }
755
962
  });
756
963
  return {
757
964
  ...result,
@@ -875,7 +1082,9 @@ function toWire(entry) {
875
1082
  ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
876
1083
  })),
877
1084
  ...entry.refName === void 0 ? {} : { refName: entry.refName },
878
- ...entry.tags === void 0 ? {} : { tags: entry.tags }
1085
+ ...entry.tags === void 0 ? {} : { tags: entry.tags },
1086
+ ...entry.channel === void 0 ? {} : { channel: entry.channel },
1087
+ ...entry.channelId === void 0 ? {} : { channelId: entry.channelId }
879
1088
  };
880
1089
  }
881
1090
  /** List the persisted gallery, newest first, as wire entries. */
@@ -925,7 +1134,9 @@ async function appendGallery(input) {
925
1134
  n: input.n,
926
1135
  images: storedImages,
927
1136
  ...hash === void 0 ? {} : { hash },
928
- ...input.refName === void 0 ? {} : { refName: input.refName }
1137
+ ...input.refName === void 0 ? {} : { refName: input.refName },
1138
+ ...input.channelId === void 0 ? {} : { channelId: input.channelId },
1139
+ ...input.channel === void 0 ? {} : { channel: input.channel }
929
1140
  }, ...await readIndex()];
930
1141
  await writeIndex(merged);
931
1142
  return {
@@ -1347,6 +1558,57 @@ function clearUpdateCache() {
1347
1558
  cached = void 0;
1348
1559
  }
1349
1560
  //#endregion
1561
+ //#region src/presets.ts
1562
+ const IMAGE_PRESETS = [
1563
+ {
1564
+ id: "volc-ark-seedream",
1565
+ name: "字节 · 火山方舟(Seedream)",
1566
+ apiUrl: "https://ark.cn-beijing.volces.com/api/v3",
1567
+ hint: "字节跳动官方 Seedream 文生图/图生图入口",
1568
+ models: [
1569
+ {
1570
+ alias: "seedream-5.0-pro",
1571
+ id: "seedream-5.0-pro"
1572
+ },
1573
+ {
1574
+ alias: "seedream-5.0",
1575
+ id: "seedream-5.0"
1576
+ },
1577
+ {
1578
+ alias: "seedream-4.0",
1579
+ id: "seedream-4.0"
1580
+ }
1581
+ ]
1582
+ },
1583
+ {
1584
+ id: "openai-official",
1585
+ name: "OpenAI 官方",
1586
+ apiUrl: "https://api.openai.com/v1",
1587
+ hint: "OpenAI 官方接口:gpt-image-2 / dall-e-3",
1588
+ models: [{
1589
+ alias: "gpt-image-2",
1590
+ id: "gpt-image-2"
1591
+ }, {
1592
+ alias: "dall-e-3",
1593
+ id: "dall-e-3"
1594
+ }]
1595
+ },
1596
+ {
1597
+ id: "xai-grok",
1598
+ name: "xAI(Grok)",
1599
+ apiUrl: "https://api.x.ai/v1",
1600
+ hint: "xAI 官方接口:Grok Imagine 系列",
1601
+ models: [{
1602
+ alias: "grok-imagine-image",
1603
+ id: "grok-imagine-image"
1604
+ }]
1605
+ }
1606
+ ];
1607
+ /** Look up one built-in provider by id. */
1608
+ function presetById(id) {
1609
+ return IMAGE_PRESETS.find((preset) => preset.id === id);
1610
+ }
1611
+ //#endregion
1350
1612
  //#region src/routes.ts
1351
1613
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
1352
1614
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
@@ -1416,7 +1678,8 @@ function parseGenerateRequest(body) {
1416
1678
  n: typeof body.n === "number" ? body.n : 1,
1417
1679
  detail: typeof body.detail === "string" ? body.detail : "",
1418
1680
  ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {},
1419
- ...typeof body.refName === "string" && body.refName !== "" ? { refName: body.refName } : {}
1681
+ ...typeof body.refName === "string" && body.refName !== "" ? { refName: body.refName } : {},
1682
+ ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {}
1420
1683
  };
1421
1684
  }
1422
1685
  /** Validate a submitted history entry (images carry base64). */
@@ -1452,7 +1715,9 @@ function parseHistoryEntryInput(body) {
1452
1715
  detail: entry.detail,
1453
1716
  n: entry.n,
1454
1717
  images,
1455
- ...typeof entry.refName === "string" ? { refName: entry.refName } : {}
1718
+ ...typeof entry.refName === "string" ? { refName: entry.refName } : {},
1719
+ ...typeof entry.channelId === "string" ? { channelId: entry.channelId } : {},
1720
+ ...typeof entry.channel === "string" ? { channel: entry.channel } : {}
1456
1721
  };
1457
1722
  }
1458
1723
  /** Extract the image file name from a history-image request URL. */
@@ -1467,6 +1732,33 @@ function imageFileFrom(rawUrl, basePath) {
1467
1732
  if (!pathname.startsWith(`${basePath}/`)) return void 0;
1468
1733
  return decodeURIComponent(pathname.slice(basePath.length + 1));
1469
1734
  }
1735
+ /** Parse the durable image reference carried by an Agent tool-result view. */
1736
+ function agentImageRefFrom(rawUrl) {
1737
+ if (rawUrl === void 0) return void 0;
1738
+ let url;
1739
+ try {
1740
+ url = new URL(rawUrl, "http://localhost");
1741
+ } catch {
1742
+ return;
1743
+ }
1744
+ if (url.pathname !== "/api/dsh-imagegen/agent-image") return void 0;
1745
+ const attachmentId = url.searchParams.get("attachment_id") ?? "";
1746
+ const mediaType = url.searchParams.get("media_type") ?? "";
1747
+ const bytes = Number(url.searchParams.get("bytes"));
1748
+ const width = Number(url.searchParams.get("width"));
1749
+ const height = Number(url.searchParams.get("height"));
1750
+ if (attachmentId === "" || !isImageMediaType(mediaType) || !Number.isSafeInteger(bytes) || bytes < 1 || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) return void 0;
1751
+ return {
1752
+ attachmentId,
1753
+ mediaType,
1754
+ bytes,
1755
+ width,
1756
+ height
1757
+ };
1758
+ }
1759
+ function isImageMediaType(value) {
1760
+ return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
1761
+ }
1470
1762
  /** Project one settings descriptor onto the bridge wire view. */
1471
1763
  function toView(descriptor) {
1472
1764
  return {
@@ -1527,18 +1819,85 @@ function makeRoutes(deps) {
1527
1819
  model: ""
1528
1820
  }));
1529
1821
  const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(void 0));
1530
- const parseConfiguredRequest = (body) => {
1531
- const request = parseGenerateRequest(body);
1532
- if (request === void 0) return void 0;
1533
- const models = normalizeImageModels(resolveImageModels());
1534
- const model = request.model.trim() === "" ? models[0] : request.model.trim();
1535
- if (!models.includes(model)) throw new Error(`image model "${model}" is not configured; choose one of: ${models.join(", ")}`);
1822
+ /** The current channel view: the channel-aware resolver, or a synthesized
1823
+ * single default channel from the legacy flat upstream config (tests and
1824
+ * older hosts). */
1825
+ const channelViewOf = () => {
1826
+ if (deps.resolveChannels !== void 0) return deps.resolveChannels();
1827
+ const upstream = deps.resolve();
1828
+ const models = normalizeImageModels(resolveImageModels()).map((id) => ({
1829
+ alias: id,
1830
+ id
1831
+ }));
1832
+ if (upstream.apiUrl.trim() === "" && models.length === 0) return {
1833
+ channels: [],
1834
+ defaultChannelId: ""
1835
+ };
1536
1836
  return {
1537
- ...request,
1538
- model
1837
+ channels: [{
1838
+ id: "default",
1839
+ preset: "",
1840
+ name: "默认渠道",
1841
+ apiUrl: upstream.apiUrl,
1842
+ apiKey: upstream.apiKey,
1843
+ models
1844
+ }],
1845
+ defaultChannelId: "default"
1846
+ };
1847
+ };
1848
+ const runtime = deps.runtime ?? new ImageGenerationRuntime(channelViewOf, history);
1849
+ /** Resolve an alias (or the channel fallback) into a concrete generation
1850
+ * request: picks the channel (explicit then default), maps alias → upstream
1851
+ * id, and fills the channel snapshot kept on history entries. */
1852
+ const resolveChannelRequest = (request) => {
1853
+ const view = channelViewOf();
1854
+ if (view.channels.length === 0) return {
1855
+ ok: false,
1856
+ code: "no-channels",
1857
+ message: "尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥"
1858
+ };
1859
+ const explicit = view.channels.find((candidate) => candidate.id === request.channelId);
1860
+ const defaults = view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
1861
+ const target = explicit ?? defaults;
1862
+ const asked = request.model.trim();
1863
+ if (asked === "") {
1864
+ const alias = target?.models[0]?.alias ?? "";
1865
+ if (alias === "") return {
1866
+ ok: false,
1867
+ code: "no-models",
1868
+ message: `渠道「${target?.name ?? ""}」尚未配置模型,请先在设置中添加`
1869
+ };
1870
+ const mapping = target.models.find((model) => model.alias === alias);
1871
+ return {
1872
+ ok: true,
1873
+ request: {
1874
+ ...request,
1875
+ model: alias,
1876
+ upstream: mapping.id,
1877
+ channelId: target.id,
1878
+ channel: target.name
1879
+ }
1880
+ };
1881
+ }
1882
+ const hosting = view.channels.filter((channel) => channel.models.some((model) => model.alias === asked));
1883
+ if (hosting.length === 0) return {
1884
+ ok: false,
1885
+ code: "image-model-not-configured",
1886
+ message: `模型「${asked}」未在任一渠道配置;可用模型:${[...new Set(view.channels.flatMap((channel) => channel.models.map((model) => model.alias)))].join("、") || "(无)"}`
1887
+ };
1888
+ const picked = target !== void 0 && target.models.some((model) => model.alias === asked) ? target : hosting[0];
1889
+ const mapping = picked.models.find((model) => model.alias === asked);
1890
+ return {
1891
+ ok: true,
1892
+ request: {
1893
+ ...request,
1894
+ model: asked,
1895
+ upstream: mapping.id,
1896
+ channelId: picked.id,
1897
+ channel: picked.name
1898
+ }
1539
1899
  };
1540
1900
  };
1541
- const runtime = deps.runtime ?? new ImageGenerationRuntime(deps.resolve, history);
1542
1901
  const guard = (req, res, method) => {
1543
1902
  if (!isLoopbackRequest(req)) {
1544
1903
  writeJson(res, 403, { error: "forbidden: loopback-only" });
@@ -1551,15 +1910,52 @@ function makeRoutes(deps) {
1551
1910
  return true;
1552
1911
  };
1553
1912
  return [
1913
+ ...deps.attachments === void 0 ? [] : [{
1914
+ kind: "prefix",
1915
+ path: AGENT_IMAGE_API,
1916
+ handler: async (req, res) => {
1917
+ if (!isLoopbackRequest(req)) {
1918
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1919
+ return;
1920
+ }
1921
+ if (req.method !== "GET") {
1922
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1923
+ return;
1924
+ }
1925
+ const ref = agentImageRefFrom(req.url);
1926
+ if (ref === void 0) {
1927
+ writeJson(res, 400, { error: "invalid image reference" });
1928
+ return;
1929
+ }
1930
+ try {
1931
+ const stored = await deps.attachments.readImage(ref);
1932
+ res.writeHead(200, {
1933
+ "content-type": stored.ref.mediaType,
1934
+ "content-length": stored.data.byteLength,
1935
+ "cache-control": "private, max-age=3600"
1936
+ });
1937
+ res.end(Buffer.from(stored.data));
1938
+ } catch {
1939
+ writeJson(res, 404, { error: "image attachment not found" });
1940
+ }
1941
+ }
1942
+ }],
1554
1943
  {
1555
1944
  kind: "exact",
1556
1945
  path: IMAGE_MODEL_API.models,
1557
1946
  handler: async (req, res) => {
1558
1947
  if (!guard(req, res, "POST")) return;
1948
+ const body = await readJsonBody(req);
1949
+ const view = channelViewOf();
1950
+ 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];
1951
+ const upstream = {
1952
+ apiUrl: typeof body?.apiUrl === "string" && body.apiUrl.trim() !== "" ? body.apiUrl.trim() : stored?.apiUrl ?? "",
1953
+ apiKey: typeof body?.apiKey === "string" && body.apiKey.trim() !== "" ? body.apiKey.trim() : stored?.apiKey ?? ""
1954
+ };
1559
1955
  try {
1560
1956
  writeJson(res, 200, {
1561
1957
  ok: true,
1562
- models: await listOpenAIModels(deps.resolve())
1958
+ models: await listOpenAIModels(upstream)
1563
1959
  });
1564
1960
  } catch (error) {
1565
1961
  writeJson(res, 200, {
@@ -1570,6 +1966,55 @@ function makeRoutes(deps) {
1570
1966
  }
1571
1967
  }
1572
1968
  },
1969
+ {
1970
+ kind: "exact",
1971
+ path: PRESETS_API,
1972
+ handler: async (req, res) => {
1973
+ if (!guard(req, res, "POST")) return;
1974
+ writeJson(res, 200, {
1975
+ ok: true,
1976
+ presets: IMAGE_PRESETS.map((preset) => ({
1977
+ id: preset.id,
1978
+ name: preset.name,
1979
+ apiUrl: preset.apiUrl,
1980
+ hint: preset.hint,
1981
+ models: preset.models
1982
+ }))
1983
+ });
1984
+ }
1985
+ },
1986
+ {
1987
+ kind: "exact",
1988
+ path: USAGE_API,
1989
+ handler: async (req, res) => {
1990
+ if (!guard(req, res, "POST")) return;
1991
+ try {
1992
+ const entries = [...await history.list(), ...await gallery.list()];
1993
+ const byChannel = {};
1994
+ const totals = {};
1995
+ for (const entry of entries) {
1996
+ const channelKey = entry.channelId !== void 0 ? entry.channelId : entry.channel !== void 0 ? `name:${entry.channel}` : "";
1997
+ const alias = entry.model;
1998
+ const bucket = byChannel[channelKey] ?? (byChannel[channelKey] = {});
1999
+ bucket[alias] = (bucket[alias] ?? 0) + 1;
2000
+ totals[alias] = (totals[alias] ?? 0) + 1;
2001
+ }
2002
+ writeJson(res, 200, {
2003
+ ok: true,
2004
+ usage: {
2005
+ byChannel,
2006
+ totals
2007
+ }
2008
+ });
2009
+ } catch (error) {
2010
+ writeJson(res, 200, {
2011
+ ok: false,
2012
+ code: "usage-failed",
2013
+ message: messageOf(error)
2014
+ });
2015
+ }
2016
+ }
2017
+ },
1573
2018
  {
1574
2019
  kind: "exact",
1575
2020
  path: PROMPT_ENHANCE_API.models,
@@ -1684,37 +2129,28 @@ function makeRoutes(deps) {
1684
2129
  handler: async (req, res) => {
1685
2130
  if (!guard(req, res, "POST")) return;
1686
2131
  const body = await readJsonBody(req);
1687
- if (body === void 0) {
2132
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2133
+ if (parsed === void 0) {
1688
2134
  writeJson(res, 200, {
1689
2135
  ok: false,
1690
2136
  code: "bad-request",
1691
- message: "unreadable JSON body"
1692
- });
1693
- return;
1694
- }
1695
- let request;
1696
- try {
1697
- request = parseConfiguredRequest(body);
1698
- } catch (error) {
1699
- writeJson(res, 200, {
1700
- ok: false,
1701
- code: "image-model-not-configured",
1702
- message: messageOf(error)
2137
+ message: "prompt is required"
1703
2138
  });
1704
2139
  return;
1705
2140
  }
1706
- if (request === void 0) {
2141
+ const resolved = resolveChannelRequest(parsed);
2142
+ if (!resolved.ok) {
1707
2143
  writeJson(res, 200, {
1708
2144
  ok: false,
1709
- code: "bad-request",
1710
- message: "prompt is required"
2145
+ code: resolved.code,
2146
+ message: resolved.message
1711
2147
  });
1712
2148
  return;
1713
2149
  }
1714
2150
  try {
1715
2151
  writeJson(res, 200, {
1716
2152
  ok: true,
1717
- ...await runtime.run(request)
2153
+ ...await runtime.run(resolved.request)
1718
2154
  });
1719
2155
  } catch (error) {
1720
2156
  const message = error instanceof Error ? error.message : String(error);
@@ -1732,28 +2168,27 @@ function makeRoutes(deps) {
1732
2168
  handler: async (req, res) => {
1733
2169
  if (!guard(req, res, "POST")) return;
1734
2170
  const body = await readJsonBody(req);
1735
- let request;
1736
- try {
1737
- request = body === void 0 ? void 0 : parseConfiguredRequest(body);
1738
- } catch (error) {
2171
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2172
+ if (parsed === void 0) {
1739
2173
  writeJson(res, 200, {
1740
2174
  ok: false,
1741
- code: "image-model-not-configured",
1742
- message: messageOf(error)
2175
+ code: "bad-request",
2176
+ message: "prompt is required"
1743
2177
  });
1744
2178
  return;
1745
2179
  }
1746
- if (request === void 0) {
2180
+ const resolved = resolveChannelRequest(parsed);
2181
+ if (!resolved.ok) {
1747
2182
  writeJson(res, 200, {
1748
2183
  ok: false,
1749
- code: "bad-request",
1750
- message: "prompt is required"
2184
+ code: resolved.code,
2185
+ message: resolved.message
1751
2186
  });
1752
2187
  return;
1753
2188
  }
1754
2189
  writeJson(res, 200, {
1755
2190
  ok: true,
1756
- task: runtime.queue.submit(request)
2191
+ task: runtime.queue.submit(resolved.request)
1757
2192
  });
1758
2193
  }
1759
2194
  },
@@ -2330,10 +2765,54 @@ function renderTaskResult(value) {
2330
2765
  return [{
2331
2766
  type: "text",
2332
2767
  text: JSON.stringify(value)
2333
- }, ...value.images.map((image) => ({
2334
- type: "image",
2335
- attachment: restoreRef(image)
2336
- }))];
2768
+ }];
2769
+ }
2770
+ /** The UI-only projection that keeps generated images beside the tool call. */
2771
+ function imagePresentationMeta(value) {
2772
+ return { images: value.images.map((image) => {
2773
+ const ref = {
2774
+ attachment_id: image.attachment_id,
2775
+ media_type: image.media_type,
2776
+ bytes: image.bytes,
2777
+ width: image.width,
2778
+ height: image.height
2779
+ };
2780
+ if (image.name !== void 0) ref.name = image.name;
2781
+ return ref;
2782
+ }) };
2783
+ }
2784
+ function imageBlocksFromMeta(meta) {
2785
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return [];
2786
+ const images = meta.images;
2787
+ if (!Array.isArray(images)) return [];
2788
+ return images.flatMap((value) => {
2789
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
2790
+ const raw = value;
2791
+ if (typeof raw.attachment_id !== "string" || typeof raw.media_type !== "string" || typeof raw.bytes !== "number" || typeof raw.width !== "number" || typeof raw.height !== "number") return [];
2792
+ try {
2793
+ return [{
2794
+ type: "image",
2795
+ attachment: restoreRef({
2796
+ attachment_id: raw.attachment_id,
2797
+ media_type: raw.media_type,
2798
+ bytes: raw.bytes,
2799
+ width: raw.width,
2800
+ height: raw.height,
2801
+ ...typeof raw.name === "string" ? { name: raw.name } : {}
2802
+ })
2803
+ }];
2804
+ } catch {
2805
+ return [];
2806
+ }
2807
+ });
2808
+ }
2809
+ /** Rehydrate image attachments for the host-computed tool result view only. */
2810
+ function presentImageResult(_args, result) {
2811
+ const content = result.isError ? [] : imageBlocksFromMeta(result.meta);
2812
+ return content.length === 0 ? void 0 : {
2813
+ card: "generic",
2814
+ content
2815
+ };
2337
2816
  }
2338
2817
  /** Register the global Agent tools and unregister them with the plugin lifecycle. */
2339
2818
  function registerAgentImageTools(ctx, runtime, resolve) {
@@ -2342,13 +2821,32 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2342
2821
  const config = resolve();
2343
2822
  if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.", "plugin-disabled");
2344
2823
  if (!config.allowAgentImageGeneration) throw new ImageGenError("Agent image generation is disabled in Settings > Plugins > AI Image.", "agent-generation-disabled");
2345
- 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");
2824
+ 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");
2346
2825
  };
2347
- const selectedModel = (requested) => {
2348
- const models = normalizeImageModels(resolve().imageModels);
2349
- const model = typeof requested === "string" && requested.trim() !== "" ? requested.trim() : models[0];
2350
- if (!models.includes(model)) throw new ImageGenError(`Image model "${model}" is not configured. Choose one of: ${models.join(", ")}.`, "image-model-not-configured");
2351
- return model;
2826
+ /**
2827
+ * Resolve the requested model alias onto a channel. Rules:
2828
+ * - a named alias must exist in some channel's catalog (several channels
2829
+ * may host it; the default channel wins);
2830
+ * - with no alias, a single configured model is used directly, while
2831
+ * multiple models require the Agent to ask the user first.
2832
+ * @returns the channel plus the alias and its upstream id.
2833
+ */
2834
+ const resolveModel = (requested) => {
2835
+ const config = resolve();
2836
+ const entries = config.channels.flatMap((channel) => channel.models.map((model) => ({
2837
+ channel,
2838
+ alias: model.alias,
2839
+ upstream: model.id
2840
+ })));
2841
+ 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");
2842
+ const wanted = typeof requested === "string" && requested.trim() !== "" ? requested.trim() : "";
2843
+ if (wanted === "") {
2844
+ if (entries.length === 1) return entries[0];
2845
+ 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");
2846
+ }
2847
+ const hosting = entries.filter((entry) => entry.alias === wanted);
2848
+ 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");
2849
+ return hosting.find((entry) => entry.channel.id === config.defaultChannelId) ?? hosting[0];
2352
2850
  };
2353
2851
  const materializeTaskImages = (task) => {
2354
2852
  if (task.status !== "completed") return Promise.resolve([]);
@@ -2366,7 +2864,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2366
2864
  return {
2367
2865
  task_id: task.id,
2368
2866
  status: task.status,
2369
- 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.",
2867
+ 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.",
2370
2868
  ...task.error === void 0 ? {} : { error: task.error },
2371
2869
  images
2372
2870
  };
@@ -2435,7 +2933,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2435
2933
  const disposers = [
2436
2934
  ctx.tools.register(defineTool({
2437
2935
  name: "generate_image",
2438
- 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.",
2936
+ 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.",
2439
2937
  parameters: {
2440
2938
  prompt: {
2441
2939
  type: "string",
@@ -2469,13 +2967,19 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2469
2967
  },
2470
2968
  output: {
2471
2969
  schema: taskResultSchema,
2472
- render: (_args, value) => renderTaskResult(value)
2970
+ render: (_args, value) => renderTaskResult(value),
2971
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2473
2972
  },
2973
+ presentResult: presentImageResult,
2474
2974
  async execute(args, exec) {
2475
2975
  ensureConfigured();
2976
+ const picked = resolveModel(args.model);
2476
2977
  const task = runtime.queue.submit({
2477
2978
  mode: "text",
2478
- model: selectedModel(args.model),
2979
+ model: picked.alias,
2980
+ upstream: picked.upstream,
2981
+ channelId: picked.channel.id,
2982
+ channel: picked.channel.name,
2479
2983
  prompt: args.prompt.trim(),
2480
2984
  size: args.size ?? "auto",
2481
2985
  quality: args.quality ?? "auto",
@@ -2487,7 +2991,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2487
2991
  })),
2488
2992
  ctx.tools.register(defineTool({
2489
2993
  name: "edit_image",
2490
- 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.",
2994
+ 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.",
2491
2995
  parameters: {
2492
2996
  prompt: {
2493
2997
  type: "string",
@@ -2526,14 +3030,20 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2526
3030
  },
2527
3031
  output: {
2528
3032
  schema: taskResultSchema,
2529
- render: (_args, value) => renderTaskResult(value)
3033
+ render: (_args, value) => renderTaskResult(value),
3034
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2530
3035
  },
3036
+ presentResult: presentImageResult,
2531
3037
  async execute(args, exec) {
2532
3038
  ensureConfigured();
2533
3039
  const reference = await ctx.attachments.readImage(restoreRef(args.source_image), exec.signal);
3040
+ const picked = resolveModel(args.model);
2534
3041
  const task = runtime.queue.submit({
2535
3042
  mode: "edit",
2536
- model: selectedModel(args.model),
3043
+ model: picked.alias,
3044
+ upstream: picked.upstream,
3045
+ channelId: picked.channel.id,
3046
+ channel: picked.channel.name,
2537
3047
  prompt: args.prompt.trim(),
2538
3048
  size: args.size ?? "auto",
2539
3049
  quality: args.quality ?? "auto",
@@ -2547,7 +3057,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2547
3057
  })),
2548
3058
  ctx.tools.register(defineTool({
2549
3059
  name: "get_image_generation_task",
2550
- 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.",
3060
+ 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.",
2551
3061
  parameters: { task_id: {
2552
3062
  type: "string",
2553
3063
  required: true,
@@ -2555,8 +3065,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2555
3065
  } },
2556
3066
  output: {
2557
3067
  schema: taskResultSchema,
2558
- render: (_args, value) => renderTaskResult(value)
3068
+ render: (_args, value) => renderTaskResult(value),
3069
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2559
3070
  },
3071
+ presentResult: presentImageResult,
2560
3072
  async execute(args) {
2561
3073
  ensureConfigured();
2562
3074
  return taskResult(findTask(args.task_id));
@@ -2572,8 +3084,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2572
3084
  } },
2573
3085
  output: {
2574
3086
  schema: taskResultSchema,
2575
- render: (_args, value) => renderTaskResult(value)
3087
+ render: (_args, value) => renderTaskResult(value),
3088
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2576
3089
  },
3090
+ presentResult: presentImageResult,
2577
3091
  async execute(args) {
2578
3092
  ensureConfigured();
2579
3093
  const task = runtime.queue.cancel(args.task_id);
@@ -2590,9 +3104,11 @@ function isFinalTask(task) {
2590
3104
  return task.status === "completed" || task.status === "failed" || task.status === "cancelled";
2591
3105
  }
2592
3106
  function toSaveImage(image, taskId, index) {
2593
- const mediaType = acceptedMediaType(image.mime) ? image.mime : "image/png";
3107
+ const data = Buffer.from(image.b64, "base64");
3108
+ const declaredMediaType = acceptedMediaType(image.mime) ? image.mime : "image/png";
3109
+ const mediaType = detectImageMime(data) ?? declaredMediaType;
2594
3110
  return {
2595
- data: Buffer.from(image.b64, "base64"),
3111
+ data,
2596
3112
  mediaType,
2597
3113
  name: `imagegen-${taskId}-${index + 1}.${mediaType === "image/jpeg" ? "jpg" : mediaType.slice(6)}`
2598
3114
  };
@@ -2609,12 +3125,24 @@ const Config = z.object({
2609
3125
  enabled: z.boolean().default(true),
2610
3126
  announceToAgent: z.boolean().default(true),
2611
3127
  allowAgentImageGeneration: z.boolean().default(true),
2612
- apiUrl: z.string().default(""),
2613
- apiKey: z.string().role("secret").default(""),
2614
- imageModels: z.array(z.string()).default([...DEFAULT_IMAGE_MODELS]),
3128
+ channels: z.array(z.object({
3129
+ id: z.string(),
3130
+ preset: z.string().default(""),
3131
+ name: z.string().default(""),
3132
+ apiUrl: z.string().default(""),
3133
+ models: z.array(z.object({
3134
+ alias: z.string(),
3135
+ id: z.string()
3136
+ })).default([])
3137
+ })).default([]),
3138
+ channelSecrets: z.dict(z.string().role("secret")).default({}),
3139
+ defaultChannelId: z.string().default(""),
2615
3140
  promptApiUrl: z.string().default(""),
2616
3141
  promptApiKey: z.string().role("secret").default(""),
2617
- promptModel: z.string().default("")
3142
+ promptModel: z.string().default(""),
3143
+ apiUrl: z.string().default(""),
3144
+ apiKey: z.string().role("secret").default(""),
3145
+ imageModels: z.array(z.string()).default([])
2618
3146
  });
2619
3147
  /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
2620
3148
  const DEFAULT_ENABLED = true;
@@ -2623,10 +3151,49 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
2623
3151
  /** Order of the announcement section within the tool-guidance band. */
2624
3152
  const SECTION_ORDER = 150;
2625
3153
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
2626
- const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API,模型由用户在「设置 插件 AI 生图」中检测或手动配置的生图模型列表决定;支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送)。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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
2627
- /** Add the live allow-list so an Agent can honor a user's model choice. */
2628
- function guidanceFor(imageModels) {
2629
- return `${IMAGEGEN_GUIDANCE} 当前允许调用的生图模型:${imageModels.join("、")}。用户指定其中某个模型时,工具参数 model 必须使用该精确名称;未指定时使用列表中的第一个。`;
3154
+ 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 数组发送)。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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
3155
+ /** Append the live channel × model table so an Agent can honor user choices. */
3156
+ function guidanceFor(channels, defaultChannelId) {
3157
+ if (channels.length === 0) return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`;
3158
+ const table = channels.map((channel) => {
3159
+ const aliases = channel.models.map((model) => model.alias).join("、");
3160
+ const mark = channel.id === defaultChannelId ? "(默认渠道)" : "";
3161
+ const key = channel.apiKey === "" ? "(未填密钥)" : "";
3162
+ const models = channel.models.length === 0 ? "未配置模型" : `可用模型:${aliases}`;
3163
+ return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`;
3164
+ }).join(";");
3165
+ return `${IMAGEGEN_GUIDANCE} 当前渠道与模型:${table}。用户指定模型名时取该模型所属渠道(多渠道同名用默认渠道);未指定模型时若仅一个可用模型可直接生成,若有多个应先询问用户选择「渠道 + 模型」。`;
3166
+ }
3167
+ /** Normalize raw channel entries into the wire shape (schema-adjacent guard). */
3168
+ function normalizeChannels(value) {
3169
+ if (!Array.isArray(value)) return [];
3170
+ const out = [];
3171
+ for (const item of value) {
3172
+ if (item === null || typeof item !== "object") continue;
3173
+ const raw = item;
3174
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
3175
+ if (id === "") continue;
3176
+ const models = [];
3177
+ if (Array.isArray(raw.models)) for (const entry of raw.models) {
3178
+ if (entry === null || typeof entry !== "object") continue;
3179
+ const record = entry;
3180
+ const alias = typeof record.alias === "string" ? record.alias.trim() : "";
3181
+ const upstream = typeof record.id === "string" ? record.id.trim() : "";
3182
+ if (alias === "") continue;
3183
+ models.push({
3184
+ alias,
3185
+ id: upstream === "" ? alias : upstream
3186
+ });
3187
+ }
3188
+ out.push({
3189
+ id,
3190
+ preset: typeof raw.preset === "string" ? raw.preset : "",
3191
+ name: typeof raw.name === "string" ? raw.name.trim() : "",
3192
+ apiUrl: typeof raw.apiUrl === "string" ? raw.apiUrl.trim() : "",
3193
+ models
3194
+ });
3195
+ }
3196
+ return out;
2630
3197
  }
2631
3198
  /**
2632
3199
  * Mount the settings section, routes, and announcement.
@@ -2636,47 +3203,82 @@ function guidanceFor(imageModels) {
2636
3203
  function apply(ctx, config) {
2637
3204
  let current = () => config ?? {};
2638
3205
  const resolve = () => {
2639
- const value = current();
3206
+ const value = current() ?? {};
3207
+ let channels = normalizeChannels(value.channels);
3208
+ const secrets = { ...value.channelSecrets ?? {} };
3209
+ if (channels.length === 0) {
3210
+ const legacyUrl = typeof value.apiUrl === "string" ? value.apiUrl.trim() : "";
3211
+ const legacyModels = Array.isArray(value.imageModels) ? value.imageModels.filter((model) => typeof model === "string" && model.trim() !== "").map((model) => ({
3212
+ alias: model.trim(),
3213
+ id: model.trim()
3214
+ })) : [];
3215
+ if (legacyUrl !== "" || legacyModels.length > 0) {
3216
+ channels = [{
3217
+ id: "default",
3218
+ preset: "",
3219
+ name: "默认渠道",
3220
+ apiUrl: legacyUrl,
3221
+ models: legacyModels
3222
+ }];
3223
+ const legacyKey = typeof value.apiKey === "string" ? value.apiKey.trim() : "";
3224
+ if (legacyKey !== "") secrets["default"] = legacyKey;
3225
+ }
3226
+ }
3227
+ const named = channels.map((channel) => ({
3228
+ ...channel,
3229
+ name: channel.name === "" ? presetById(channel.preset)?.name ?? "未命名渠道" : channel.name
3230
+ }));
3231
+ const defaultChannelId = typeof value.defaultChannelId === "string" && named.some((channel) => channel.id === value.defaultChannelId) ? value.defaultChannelId : named[0]?.id ?? "";
2640
3232
  return {
2641
3233
  enabled: value.enabled ?? DEFAULT_ENABLED,
2642
3234
  announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
2643
3235
  allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
2644
- apiUrl: value.apiUrl ?? "",
2645
- apiKey: value.apiKey ?? "",
2646
- imageModels: normalizeImageModels(value.imageModels),
2647
- promptApiUrl: value.promptApiUrl ?? "",
2648
- promptApiKey: value.promptApiKey ?? "",
2649
- promptModel: value.promptModel ?? ""
3236
+ channels: named.map((channel) => ({
3237
+ ...channel,
3238
+ apiKey: typeof secrets[channel.id] === "string" ? secrets[channel.id] : ""
3239
+ })),
3240
+ defaultChannelId,
3241
+ promptApiUrl: typeof value.promptApiUrl === "string" ? value.promptApiUrl.trim() : "",
3242
+ promptApiKey: typeof value.promptApiKey === "string" ? value.promptApiKey.trim() : "",
3243
+ promptModel: typeof value.promptModel === "string" ? value.promptModel.trim() : ""
2650
3244
  };
2651
3245
  };
2652
- const runtime = new ImageGenerationRuntime(() => {
3246
+ const channelsView = () => {
2653
3247
  const value = resolve();
2654
3248
  return {
2655
- apiUrl: value.apiUrl,
2656
- apiKey: value.apiKey
3249
+ channels: value.channels,
3250
+ defaultChannelId: value.defaultChannelId
2657
3251
  };
2658
- });
2659
- ctx.inject(["settings"], (sctx) => {
3252
+ };
3253
+ const runtime = new ImageGenerationRuntime(channelsView);
3254
+ ctx.inject(["settings", "attachments"], (sctx) => {
2660
3255
  const seam = sctx.get("settings");
2661
3256
  sctx.effect(() => {
2662
3257
  const disposers = makeRoutes({
2663
3258
  settings: seam,
2664
3259
  resolve: () => {
2665
3260
  const value = resolve();
3261
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
2666
3262
  return {
2667
- apiUrl: value.apiUrl,
2668
- apiKey: value.apiKey
3263
+ apiUrl: channel?.apiUrl ?? "",
3264
+ apiKey: channel?.apiKey ?? ""
2669
3265
  };
2670
3266
  },
3267
+ resolveChannels: channelsView,
2671
3268
  resolvePrompt: () => {
2672
3269
  const value = resolve();
3270
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
2673
3271
  return {
2674
- apiUrl: value.promptApiUrl.trim() || value.apiUrl,
2675
- apiKey: value.promptApiKey.trim() || value.apiKey,
3272
+ apiUrl: value.promptApiUrl !== "" ? value.promptApiUrl : channel?.apiUrl ?? "",
3273
+ apiKey: value.promptApiKey !== "" ? value.promptApiKey : channel?.apiKey ?? "",
2676
3274
  model: value.promptModel
2677
3275
  };
2678
3276
  },
2679
- resolveImageModels: () => resolve().imageModels,
3277
+ resolveImageModels: () => {
3278
+ const value = resolve();
3279
+ return [...new Set(value.channels.flatMap((channel) => channel.models.map((model) => model.alias)))];
3280
+ },
3281
+ attachments: sctx.attachments,
2680
3282
  runtime
2681
3283
  }).map((route) => ctx.webServer.register(route));
2682
3284
  return () => {
@@ -2690,9 +3292,8 @@ function apply(ctx, config) {
2690
3292
  return {
2691
3293
  enabled: value.enabled,
2692
3294
  allowAgentImageGeneration: value.allowAgentImageGeneration,
2693
- apiUrl: value.apiUrl,
2694
- apiKey: value.apiKey,
2695
- imageModels: value.imageModels
3295
+ channels: value.channels,
3296
+ defaultChannelId: value.defaultChannelId
2696
3297
  };
2697
3298
  }), "dsh-imagegen: agent image tools");
2698
3299
  });
@@ -2707,7 +3308,7 @@ function apply(ctx, config) {
2707
3308
  disposeSection = ctx.systemPrompt.section({
2708
3309
  name: "plugin:dsh-imagegen",
2709
3310
  order: SECTION_ORDER,
2710
- text: guidanceFor(value.imageModels)
3311
+ text: guidanceFor(value.channels, value.defaultChannelId)
2711
3312
  });
2712
3313
  };
2713
3314
  installSettingsSection(ctx, ImageGenSettingsNamespace, Config, config ?? {}, {