@dickpy/dsh-imagegen 1.2.3 → 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.3";
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",
@@ -160,6 +170,113 @@ function normalizeImageModels(value) {
160
170
  return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS];
161
171
  }
162
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
163
280
  //#region src/engine.ts
164
281
  /** A generation failure with a user-presentable message. */
165
282
  var ImageGenError = class extends Error {
@@ -183,12 +300,20 @@ const DALLE3_SIZES = /* @__PURE__ */ new Set([
183
300
  "1792x1024",
184
301
  "1024x1792"
185
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
+ }
186
311
  /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
187
312
  * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
188
313
  * and exposes its own aspect-ratio / response-format knobs instead of the
189
314
  * OpenAI size/quality/detail passthrough. */
190
315
  function isGrokImagine(model) {
191
- return /^grok-imagine(?:-|$)/.test(model);
316
+ return modelFamily(model) === "grok";
192
317
  }
193
318
  /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
194
319
  * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
@@ -196,17 +321,24 @@ function isGrokImagine(model) {
196
321
  * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
197
322
  * passthrough. */
198
323
  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";
324
+ return modelFamily(model) === "nanobanana";
201
325
  }
202
326
  /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
203
327
  * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
204
328
  * 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). */
329
+ * generation AND editing both go to /images/generations and reference images
330
+ * are a JSON URL / data-URL array. */
208
331
  function isSeedream(model) {
209
- return /^(?:doubao-)?seedream/i.test(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";
210
342
  }
211
343
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
212
344
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
@@ -284,7 +416,7 @@ function clampCount(n) {
284
416
  * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
285
417
  * so the count is satisfied by parallel single-image requests instead. */
286
418
  function effectiveParams(request) {
287
- const model = request.model.trim() === "" ? "gpt-image-2" : request.model.trim();
419
+ const model = wireModel(request);
288
420
  if (model === "dall-e-3") {
289
421
  const pixel = OPENAI_SIZE_BY_RATIO[request.size];
290
422
  return {
@@ -306,9 +438,8 @@ function effectiveParams(request) {
306
438
  };
307
439
  if (isSeedream(model)) return {
308
440
  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"
441
+ size: seedreamSize(request.quality),
442
+ response_format: isVolcSeedream(model) ? "url" : "b64_json"
312
443
  };
313
444
  return {
314
445
  model,
@@ -321,17 +452,20 @@ function effectiveParams(request) {
321
452
  }
322
453
  /** How many single-image requests to issue for the requested image count. */
323
454
  function effectiveCount(request) {
324
- if ((request.model.trim() === "" ? "gpt-image-2" : request.model.trim()) === "dall-e-3") return 1;
455
+ if (wireModel(request) === "dall-e-3") return 1;
325
456
  return clampCount(request.n);
326
457
  }
327
458
  /** Normalize one upstream data item into a base64 image. */
328
459
  async function normalizeItem(item, upstream) {
329
460
  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
- };
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
+ }
335
469
  if (typeof item.url !== "string" || item.url === "") throw new ImageGenError("upstream image item has neither b64_json nor url");
336
470
  const url = item.url;
337
471
  if (url.startsWith("data:")) {
@@ -339,7 +473,7 @@ async function normalizeItem(item, upstream) {
339
473
  if (parsed === void 0) throw new ImageGenError("upstream returned a malformed data: url");
340
474
  return {
341
475
  b64: parsed.base64,
342
- mime: parsed.mime,
476
+ mime: detectImageMime(Buffer.from(parsed.base64, "base64")) ?? parsed.mime,
343
477
  revisedPrompt
344
478
  };
345
479
  }
@@ -358,7 +492,7 @@ async function normalizeItem(item, upstream) {
358
492
  if (!response.ok) throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`);
359
493
  const buffer = Buffer.from(await response.arrayBuffer());
360
494
  const contentType = response.headers.get("content-type");
361
- 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");
362
496
  return {
363
497
  b64: buffer.toString("base64"),
364
498
  mime,
@@ -411,7 +545,7 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
411
545
  image: [request.image],
412
546
  ...params.size !== void 0 ? { size: params.size } : {},
413
547
  ...params.resolution !== void 0 ? { resolution: params.resolution } : {},
414
- response_format: "b64_json"
548
+ response_format: isVolcSeedream(params.model) ? "url" : "b64_json"
415
549
  });
416
550
  } else {
417
551
  const form = new FormData();
@@ -602,7 +736,9 @@ function toWire$1(entry) {
602
736
  mime: image.mime,
603
737
  ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
604
738
  })),
605
- ...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 }
606
742
  };
607
743
  }
608
744
  /** List the persisted history, newest first, as wire entries. */
@@ -641,7 +777,9 @@ async function appendHistory(input) {
641
777
  detail: input.detail,
642
778
  n: input.n,
643
779
  images: storedImages,
644
- ...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 }
645
783
  }, ...await readIndex$1()];
646
784
  const kept = merged.slice(0, 50);
647
785
  for (const dropped of merged.slice(50)) await removeEntryFiles$1(dropped);
@@ -783,6 +921,11 @@ var GenerationTaskQueue = class {
783
921
  * Shared host-side generation runtime. Both the browser routes and Agent tools
784
922
  * submit to this one queue so persisted history and cancellation semantics stay
785
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.
786
929
  */
787
930
  var ImageGenerationRuntime = class {
788
931
  resolve;
@@ -794,7 +937,13 @@ var ImageGenerationRuntime = class {
794
937
  this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal));
795
938
  }
796
939
  async run(request, signal) {
797
- 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 });
798
947
  try {
799
948
  const history = await this.history.append({
800
949
  id: randomUUID(),
@@ -807,7 +956,9 @@ var ImageGenerationRuntime = class {
807
956
  detail: request.detail,
808
957
  n: request.n,
809
958
  images: result.images,
810
- ...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 }
811
962
  });
812
963
  return {
813
964
  ...result,
@@ -931,7 +1082,9 @@ function toWire(entry) {
931
1082
  ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
932
1083
  })),
933
1084
  ...entry.refName === void 0 ? {} : { refName: entry.refName },
934
- ...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 }
935
1088
  };
936
1089
  }
937
1090
  /** List the persisted gallery, newest first, as wire entries. */
@@ -981,7 +1134,9 @@ async function appendGallery(input) {
981
1134
  n: input.n,
982
1135
  images: storedImages,
983
1136
  ...hash === void 0 ? {} : { hash },
984
- ...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 }
985
1140
  }, ...await readIndex()];
986
1141
  await writeIndex(merged);
987
1142
  return {
@@ -1403,6 +1558,57 @@ function clearUpdateCache() {
1403
1558
  cached = void 0;
1404
1559
  }
1405
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
1406
1612
  //#region src/routes.ts
1407
1613
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
1408
1614
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
@@ -1472,7 +1678,8 @@ function parseGenerateRequest(body) {
1472
1678
  n: typeof body.n === "number" ? body.n : 1,
1473
1679
  detail: typeof body.detail === "string" ? body.detail : "",
1474
1680
  ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {},
1475
- ...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 } : {}
1476
1683
  };
1477
1684
  }
1478
1685
  /** Validate a submitted history entry (images carry base64). */
@@ -1508,7 +1715,9 @@ function parseHistoryEntryInput(body) {
1508
1715
  detail: entry.detail,
1509
1716
  n: entry.n,
1510
1717
  images,
1511
- ...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 } : {}
1512
1721
  };
1513
1722
  }
1514
1723
  /** Extract the image file name from a history-image request URL. */
@@ -1523,6 +1732,33 @@ function imageFileFrom(rawUrl, basePath) {
1523
1732
  if (!pathname.startsWith(`${basePath}/`)) return void 0;
1524
1733
  return decodeURIComponent(pathname.slice(basePath.length + 1));
1525
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
+ }
1526
1762
  /** Project one settings descriptor onto the bridge wire view. */
1527
1763
  function toView(descriptor) {
1528
1764
  return {
@@ -1583,18 +1819,85 @@ function makeRoutes(deps) {
1583
1819
  model: ""
1584
1820
  }));
1585
1821
  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(", ")}`);
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
+ };
1592
1836
  return {
1593
- ...request,
1594
- 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
+ }
1595
1899
  };
1596
1900
  };
1597
- const runtime = deps.runtime ?? new ImageGenerationRuntime(deps.resolve, history);
1598
1901
  const guard = (req, res, method) => {
1599
1902
  if (!isLoopbackRequest(req)) {
1600
1903
  writeJson(res, 403, { error: "forbidden: loopback-only" });
@@ -1607,15 +1910,52 @@ function makeRoutes(deps) {
1607
1910
  return true;
1608
1911
  };
1609
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
+ }],
1610
1943
  {
1611
1944
  kind: "exact",
1612
1945
  path: IMAGE_MODEL_API.models,
1613
1946
  handler: async (req, res) => {
1614
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
+ };
1615
1955
  try {
1616
1956
  writeJson(res, 200, {
1617
1957
  ok: true,
1618
- models: await listOpenAIModels(deps.resolve())
1958
+ models: await listOpenAIModels(upstream)
1619
1959
  });
1620
1960
  } catch (error) {
1621
1961
  writeJson(res, 200, {
@@ -1626,6 +1966,55 @@ function makeRoutes(deps) {
1626
1966
  }
1627
1967
  }
1628
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
+ },
1629
2018
  {
1630
2019
  kind: "exact",
1631
2020
  path: PROMPT_ENHANCE_API.models,
@@ -1740,37 +2129,28 @@ function makeRoutes(deps) {
1740
2129
  handler: async (req, res) => {
1741
2130
  if (!guard(req, res, "POST")) return;
1742
2131
  const body = await readJsonBody(req);
1743
- if (body === void 0) {
2132
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2133
+ if (parsed === void 0) {
1744
2134
  writeJson(res, 200, {
1745
2135
  ok: false,
1746
2136
  code: "bad-request",
1747
- message: "unreadable JSON body"
1748
- });
1749
- return;
1750
- }
1751
- let request;
1752
- try {
1753
- request = parseConfiguredRequest(body);
1754
- } catch (error) {
1755
- writeJson(res, 200, {
1756
- ok: false,
1757
- code: "image-model-not-configured",
1758
- message: messageOf(error)
2137
+ message: "prompt is required"
1759
2138
  });
1760
2139
  return;
1761
2140
  }
1762
- if (request === void 0) {
2141
+ const resolved = resolveChannelRequest(parsed);
2142
+ if (!resolved.ok) {
1763
2143
  writeJson(res, 200, {
1764
2144
  ok: false,
1765
- code: "bad-request",
1766
- message: "prompt is required"
2145
+ code: resolved.code,
2146
+ message: resolved.message
1767
2147
  });
1768
2148
  return;
1769
2149
  }
1770
2150
  try {
1771
2151
  writeJson(res, 200, {
1772
2152
  ok: true,
1773
- ...await runtime.run(request)
2153
+ ...await runtime.run(resolved.request)
1774
2154
  });
1775
2155
  } catch (error) {
1776
2156
  const message = error instanceof Error ? error.message : String(error);
@@ -1788,28 +2168,27 @@ function makeRoutes(deps) {
1788
2168
  handler: async (req, res) => {
1789
2169
  if (!guard(req, res, "POST")) return;
1790
2170
  const body = await readJsonBody(req);
1791
- let request;
1792
- try {
1793
- request = body === void 0 ? void 0 : parseConfiguredRequest(body);
1794
- } catch (error) {
2171
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
2172
+ if (parsed === void 0) {
1795
2173
  writeJson(res, 200, {
1796
2174
  ok: false,
1797
- code: "image-model-not-configured",
1798
- message: messageOf(error)
2175
+ code: "bad-request",
2176
+ message: "prompt is required"
1799
2177
  });
1800
2178
  return;
1801
2179
  }
1802
- if (request === void 0) {
2180
+ const resolved = resolveChannelRequest(parsed);
2181
+ if (!resolved.ok) {
1803
2182
  writeJson(res, 200, {
1804
2183
  ok: false,
1805
- code: "bad-request",
1806
- message: "prompt is required"
2184
+ code: resolved.code,
2185
+ message: resolved.message
1807
2186
  });
1808
2187
  return;
1809
2188
  }
1810
2189
  writeJson(res, 200, {
1811
2190
  ok: true,
1812
- task: runtime.queue.submit(request)
2191
+ task: runtime.queue.submit(resolved.request)
1813
2192
  });
1814
2193
  }
1815
2194
  },
@@ -2386,10 +2765,54 @@ function renderTaskResult(value) {
2386
2765
  return [{
2387
2766
  type: "text",
2388
2767
  text: JSON.stringify(value)
2389
- }, ...value.images.map((image) => ({
2390
- type: "image",
2391
- attachment: restoreRef(image)
2392
- }))];
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
+ };
2393
2816
  }
2394
2817
  /** Register the global Agent tools and unregister them with the plugin lifecycle. */
2395
2818
  function registerAgentImageTools(ctx, runtime, resolve) {
@@ -2398,13 +2821,32 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2398
2821
  const config = resolve();
2399
2822
  if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.", "plugin-disabled");
2400
2823
  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");
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");
2402
2825
  };
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;
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];
2408
2850
  };
2409
2851
  const materializeTaskImages = (task) => {
2410
2852
  if (task.status !== "completed") return Promise.resolve([]);
@@ -2422,7 +2864,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2422
2864
  return {
2423
2865
  task_id: task.id,
2424
2866
  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.",
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.",
2426
2868
  ...task.error === void 0 ? {} : { error: task.error },
2427
2869
  images
2428
2870
  };
@@ -2491,7 +2933,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2491
2933
  const disposers = [
2492
2934
  ctx.tools.register(defineTool({
2493
2935
  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.",
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.",
2495
2937
  parameters: {
2496
2938
  prompt: {
2497
2939
  type: "string",
@@ -2525,13 +2967,19 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2525
2967
  },
2526
2968
  output: {
2527
2969
  schema: taskResultSchema,
2528
- render: (_args, value) => renderTaskResult(value)
2970
+ render: (_args, value) => renderTaskResult(value),
2971
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2529
2972
  },
2973
+ presentResult: presentImageResult,
2530
2974
  async execute(args, exec) {
2531
2975
  ensureConfigured();
2976
+ const picked = resolveModel(args.model);
2532
2977
  const task = runtime.queue.submit({
2533
2978
  mode: "text",
2534
- model: selectedModel(args.model),
2979
+ model: picked.alias,
2980
+ upstream: picked.upstream,
2981
+ channelId: picked.channel.id,
2982
+ channel: picked.channel.name,
2535
2983
  prompt: args.prompt.trim(),
2536
2984
  size: args.size ?? "auto",
2537
2985
  quality: args.quality ?? "auto",
@@ -2543,7 +2991,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2543
2991
  })),
2544
2992
  ctx.tools.register(defineTool({
2545
2993
  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.",
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.",
2547
2995
  parameters: {
2548
2996
  prompt: {
2549
2997
  type: "string",
@@ -2582,14 +3030,20 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2582
3030
  },
2583
3031
  output: {
2584
3032
  schema: taskResultSchema,
2585
- render: (_args, value) => renderTaskResult(value)
3033
+ render: (_args, value) => renderTaskResult(value),
3034
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2586
3035
  },
3036
+ presentResult: presentImageResult,
2587
3037
  async execute(args, exec) {
2588
3038
  ensureConfigured();
2589
3039
  const reference = await ctx.attachments.readImage(restoreRef(args.source_image), exec.signal);
3040
+ const picked = resolveModel(args.model);
2590
3041
  const task = runtime.queue.submit({
2591
3042
  mode: "edit",
2592
- model: selectedModel(args.model),
3043
+ model: picked.alias,
3044
+ upstream: picked.upstream,
3045
+ channelId: picked.channel.id,
3046
+ channel: picked.channel.name,
2593
3047
  prompt: args.prompt.trim(),
2594
3048
  size: args.size ?? "auto",
2595
3049
  quality: args.quality ?? "auto",
@@ -2603,7 +3057,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2603
3057
  })),
2604
3058
  ctx.tools.register(defineTool({
2605
3059
  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.",
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.",
2607
3061
  parameters: { task_id: {
2608
3062
  type: "string",
2609
3063
  required: true,
@@ -2611,8 +3065,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2611
3065
  } },
2612
3066
  output: {
2613
3067
  schema: taskResultSchema,
2614
- render: (_args, value) => renderTaskResult(value)
3068
+ render: (_args, value) => renderTaskResult(value),
3069
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2615
3070
  },
3071
+ presentResult: presentImageResult,
2616
3072
  async execute(args) {
2617
3073
  ensureConfigured();
2618
3074
  return taskResult(findTask(args.task_id));
@@ -2628,8 +3084,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2628
3084
  } },
2629
3085
  output: {
2630
3086
  schema: taskResultSchema,
2631
- render: (_args, value) => renderTaskResult(value)
3087
+ render: (_args, value) => renderTaskResult(value),
3088
+ presentationMeta: (_args, value) => imagePresentationMeta(value)
2632
3089
  },
3090
+ presentResult: presentImageResult,
2633
3091
  async execute(args) {
2634
3092
  ensureConfigured();
2635
3093
  const task = runtime.queue.cancel(args.task_id);
@@ -2646,9 +3104,11 @@ function isFinalTask(task) {
2646
3104
  return task.status === "completed" || task.status === "failed" || task.status === "cancelled";
2647
3105
  }
2648
3106
  function toSaveImage(image, taskId, index) {
2649
- 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;
2650
3110
  return {
2651
- data: Buffer.from(image.b64, "base64"),
3111
+ data,
2652
3112
  mediaType,
2653
3113
  name: `imagegen-${taskId}-${index + 1}.${mediaType === "image/jpeg" ? "jpg" : mediaType.slice(6)}`
2654
3114
  };
@@ -2665,12 +3125,24 @@ const Config = z.object({
2665
3125
  enabled: z.boolean().default(true),
2666
3126
  announceToAgent: z.boolean().default(true),
2667
3127
  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]),
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(""),
2671
3140
  promptApiUrl: z.string().default(""),
2672
3141
  promptApiKey: z.string().role("secret").default(""),
2673
- 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([])
2674
3146
  });
2675
3147
  /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
2676
3148
  const DEFAULT_ENABLED = true;
@@ -2679,10 +3151,49 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
2679
3151
  /** Order of the announcement section within the tool-guidance band. */
2680
3152
  const SECTION_ORDER = 150;
2681
3153
  /** 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 必须使用该精确名称;未指定时使用列表中的第一个。`;
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;
2686
3197
  }
2687
3198
  /**
2688
3199
  * Mount the settings section, routes, and announcement.
@@ -2692,47 +3203,82 @@ function guidanceFor(imageModels) {
2692
3203
  function apply(ctx, config) {
2693
3204
  let current = () => config ?? {};
2694
3205
  const resolve = () => {
2695
- 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 ?? "";
2696
3232
  return {
2697
3233
  enabled: value.enabled ?? DEFAULT_ENABLED,
2698
3234
  announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
2699
3235
  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 ?? ""
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() : ""
2706
3244
  };
2707
3245
  };
2708
- const runtime = new ImageGenerationRuntime(() => {
3246
+ const channelsView = () => {
2709
3247
  const value = resolve();
2710
3248
  return {
2711
- apiUrl: value.apiUrl,
2712
- apiKey: value.apiKey
3249
+ channels: value.channels,
3250
+ defaultChannelId: value.defaultChannelId
2713
3251
  };
2714
- });
2715
- ctx.inject(["settings"], (sctx) => {
3252
+ };
3253
+ const runtime = new ImageGenerationRuntime(channelsView);
3254
+ ctx.inject(["settings", "attachments"], (sctx) => {
2716
3255
  const seam = sctx.get("settings");
2717
3256
  sctx.effect(() => {
2718
3257
  const disposers = makeRoutes({
2719
3258
  settings: seam,
2720
3259
  resolve: () => {
2721
3260
  const value = resolve();
3261
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
2722
3262
  return {
2723
- apiUrl: value.apiUrl,
2724
- apiKey: value.apiKey
3263
+ apiUrl: channel?.apiUrl ?? "",
3264
+ apiKey: channel?.apiKey ?? ""
2725
3265
  };
2726
3266
  },
3267
+ resolveChannels: channelsView,
2727
3268
  resolvePrompt: () => {
2728
3269
  const value = resolve();
3270
+ const channel = value.channels.find((candidate) => candidate.id === value.defaultChannelId) ?? value.channels[0];
2729
3271
  return {
2730
- apiUrl: value.promptApiUrl.trim() || value.apiUrl,
2731
- apiKey: value.promptApiKey.trim() || value.apiKey,
3272
+ apiUrl: value.promptApiUrl !== "" ? value.promptApiUrl : channel?.apiUrl ?? "",
3273
+ apiKey: value.promptApiKey !== "" ? value.promptApiKey : channel?.apiKey ?? "",
2732
3274
  model: value.promptModel
2733
3275
  };
2734
3276
  },
2735
- 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,
2736
3282
  runtime
2737
3283
  }).map((route) => ctx.webServer.register(route));
2738
3284
  return () => {
@@ -2746,9 +3292,8 @@ function apply(ctx, config) {
2746
3292
  return {
2747
3293
  enabled: value.enabled,
2748
3294
  allowAgentImageGeneration: value.allowAgentImageGeneration,
2749
- apiUrl: value.apiUrl,
2750
- apiKey: value.apiKey,
2751
- imageModels: value.imageModels
3295
+ channels: value.channels,
3296
+ defaultChannelId: value.defaultChannelId
2752
3297
  };
2753
3298
  }), "dsh-imagegen: agent image tools");
2754
3299
  });
@@ -2763,7 +3308,7 @@ function apply(ctx, config) {
2763
3308
  disposeSection = ctx.systemPrompt.section({
2764
3309
  name: "plugin:dsh-imagegen",
2765
3310
  order: SECTION_ORDER,
2766
- text: guidanceFor(value.imageModels)
3311
+ text: guidanceFor(value.channels, value.defaultChannelId)
2767
3312
  });
2768
3313
  };
2769
3314
  installSettingsSection(ctx, ImageGenSettingsNamespace, Config, config ?? {}, {