@dickpy/dsh-imagegen 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +363 -196
  2. package/docs/images/ecommerce-mode.png +0 -0
  3. package/docs/images/image-generation-studio-three-column.png +0 -0
  4. package/docs/images/imagegen-overview.png +0 -0
  5. package/docs/images/multi-model-comparison.png +0 -0
  6. package/docs/videos/agent-chat-edit.gif +0 -0
  7. package/docs/videos/agent-chat-edit.mp4 +0 -0
  8. package/lib/client.js +2589 -884
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +585 -240
  11. package/package.json +5 -2
  12. package/src/agent-image-tools.ts +131 -102
  13. package/src/client/ImageGenPanel.tsx +2679 -1594
  14. package/src/client/SettingsCard.tsx +6 -27
  15. package/src/client/api.ts +11 -1
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/image-toolview.tsx +176 -165
  18. package/src/client/index.ts +25 -15
  19. package/src/client/locales.ts +746 -602
  20. package/src/client/mount.tsx +213 -124
  21. package/src/client/panel.module.css +2619 -1563
  22. package/src/client/sidebar-entry.ts +190 -144
  23. package/src/edit-image-command.ts +110 -0
  24. package/src/engine.ts +47 -5
  25. package/src/gallery-store.ts +20 -0
  26. package/src/generation-runtime.ts +11 -2
  27. package/src/history-store.ts +26 -0
  28. package/src/image-models.ts +1 -1
  29. package/src/index.ts +31 -12
  30. package/src/model-catalog.ts +19 -2
  31. package/src/presets.ts +11 -3
  32. package/src/prompt-enhancer.ts +63 -5
  33. package/src/protocol.ts +59 -5
  34. package/src/routes.ts +62 -4
  35. package/src/task-queue.ts +42 -32
  36. package/docs/images/agent-chat-edit.png +0 -0
  37. package/docs/images/agent-chat-generate.png +0 -0
  38. package/docs/images/agent-chat-poster-workflow.png +0 -0
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.3.0";
19
+ const PLUGIN_VERSION = "1.5.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",
@@ -35,6 +35,8 @@ const IMAGE_MODEL_API = { models: "/api/dsh-imagegen/image-models" };
35
35
  const PRESETS_API = "/api/dsh-imagegen/presets";
36
36
  /** Loopback-only image reader for Agent tool-result previews. */
37
37
  const AGENT_IMAGE_API = "/api/dsh-imagegen/agent-image";
38
+ /** Store the current composer image for the direct edit_image command. */
39
+ const CONVERSATION_IMAGE_API = "/api/dsh-imagegen/conversation-image";
38
40
  /**
39
41
  * Host-computed per-channel usage counters (generation-count badges in the
40
42
  * settings card): entries are tallied from the persisted history and gallery
@@ -91,94 +93,6 @@ const TEMPLATES_API = {
91
93
  image: "/api/dsh-imagegen/templates/image"
92
94
  };
93
95
  //#endregion
94
- //#region src/prompt-enhancer.ts
95
- function endpoint(base, suffix) {
96
- return `${base.replace(/\/+$/, "")}${suffix}`;
97
- }
98
- function headers(apiKey) {
99
- return {
100
- "content-type": "application/json",
101
- ...apiKey.trim() === "" ? {} : { authorization: `Bearer ${apiKey.trim()}` }
102
- };
103
- }
104
- async function responseJson(response) {
105
- const body = await response.json().catch(() => void 0);
106
- if (!response.ok || body === void 0 || body === null || typeof body !== "object") {
107
- const message = body !== null && typeof body === "object" && typeof body.error?.message === "string" ? body.error.message : `HTTP ${response.status}`;
108
- throw new Error(message);
109
- }
110
- return body;
111
- }
112
- /** List candidates exposed by an OpenAI-compatible endpoint. */
113
- async function listOpenAIModels(config) {
114
- if (config.apiUrl.trim() === "") throw new Error("API URL is required");
115
- const body = await responseJson(await fetch(endpoint(config.apiUrl, "/models"), { headers: headers(config.apiKey) }));
116
- const data = Array.isArray(body.data) ? body.data : [];
117
- return [...new Set(data.flatMap((item) => item !== null && typeof item === "object" && typeof item.id === "string" ? [item.id.trim()] : []).filter(Boolean))].sort((a, b) => a.localeCompare(b));
118
- }
119
- /** List chat models exposed by an OpenAI-compatible endpoint. */
120
- async function listPromptModels(config) {
121
- return listOpenAIModels(config);
122
- }
123
- /** Expand a concise image request into a production-ready image prompt. */
124
- async function enhancePrompt(config, prompt) {
125
- if (config.apiUrl.trim() === "" || config.model.trim() === "") throw new Error("prompt enhancement model is not configured");
126
- const body = await responseJson(await fetch(endpoint(config.apiUrl, "/chat/completions"), {
127
- method: "POST",
128
- headers: headers(config.apiKey),
129
- body: JSON.stringify({
130
- model: config.model.trim(),
131
- temperature: .7,
132
- messages: [{
133
- role: "system",
134
- content: "You are an expert image-prompt editor. Expand the user request into one vivid, specific image-generation prompt. Preserve intent and language. Add only useful visual detail: subject, composition, lighting, materials, color, camera/style and quality. Return only the finished prompt, with no preface or markdown."
135
- }, {
136
- role: "user",
137
- content: prompt
138
- }]
139
- })
140
- }));
141
- const choices = Array.isArray(body.choices) ? body.choices : [];
142
- const content = choices[0] !== null && typeof choices[0] === "object" ? choices[0].message?.content : void 0;
143
- if (typeof content !== "string" || content.trim() === "") throw new Error("chat model returned an empty prompt");
144
- return content.trim();
145
- }
146
- //#endregion
147
- //#region src/image-models.ts
148
- /**
149
- * Image-model configuration shared by the host, panel, and Agent tools.
150
- * `/models` exposes candidates only: the configured list is the explicit
151
- * allow-list because OpenAI-compatible gateways rarely advertise modalities.
152
- */
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
- ];
161
- /** Normalize user-entered model identifiers and retain a usable legacy default. */
162
- function normalizeImageModels(value) {
163
- const candidates = Array.isArray(value) ? value : [];
164
- const unique = /* @__PURE__ */ new Set();
165
- for (const candidate of candidates) {
166
- if (typeof candidate !== "string") continue;
167
- const model = candidate.trim();
168
- if (model !== "") unique.add(model);
169
- }
170
- return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS];
171
- }
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
96
  //#region src/model-catalog.ts
183
97
  const ENTRIES = {
184
98
  "gpt-image": {
@@ -228,6 +142,14 @@ const ENTRIES = {
228
142
  supportsEdit: true,
229
143
  supportsAspectRatio: true,
230
144
  qualityTiers: ["1K", "2K"]
145
+ },
146
+ zhipu: {
147
+ label: "GLM-Image",
148
+ labelZh: "智谱图像",
149
+ known: true,
150
+ supportsEdit: false,
151
+ supportsAspectRatio: false,
152
+ qualityTiers: ["HD"]
231
153
  }
232
154
  };
233
155
  /** Official Gemini image ids served by Nano Banana gateways. */
@@ -262,6 +184,10 @@ function describeModel(model) {
262
184
  family: "seedream",
263
185
  ...ENTRIES.seedream
264
186
  };
187
+ if (/^(?:glm-image|cogview(?:-|$))/i.test(id)) return {
188
+ family: "zhipu",
189
+ ...ENTRIES.zhipu
190
+ };
265
191
  return {
266
192
  family: "unknown",
267
193
  label: "unknown",
@@ -272,11 +198,165 @@ function describeModel(model) {
272
198
  qualityTiers: []
273
199
  };
274
200
  }
201
+ /** Conservative fallback for providers whose /models response only has ids.
202
+ * Metadata-aware filtering lives in prompt-enhancer.ts; this catches common
203
+ * image model naming conventions without treating every unknown model as an
204
+ * image model. */
205
+ function isLikelyImageModelId(model) {
206
+ return /(?:^|[-_.])(?:image|img|diffusion|flux|cogview|imagen|seedream|nanobanana|grok-imagine|dall-e|stable-diffusion|sdxl|pixart|kolors|ideogram|midjourney|recraft|hunyuan|jimeng|wanx|hidream|playground)(?:$|[-_.])/i.test(model.trim());
207
+ }
275
208
  /** The family a model id routes its request through. */
276
209
  function modelFamily(model) {
277
210
  return describeModel(model).family;
278
211
  }
279
212
  //#endregion
213
+ //#region src/prompt-enhancer.ts
214
+ /** OpenAI-compatible chat helpers used by the optional prompt-enhancement UI. */
215
+ function endpoint(base, suffix) {
216
+ return `${base.replace(/\/+$/, "")}${suffix}`;
217
+ }
218
+ function headers(apiKey) {
219
+ return {
220
+ "content-type": "application/json",
221
+ ...apiKey.trim() === "" ? {} : { authorization: `Bearer ${apiKey.trim()}` }
222
+ };
223
+ }
224
+ async function responseJson(response) {
225
+ const body = await response.json().catch(() => void 0);
226
+ if (!response.ok || body === void 0 || body === null || typeof body !== "object") {
227
+ const message = body !== null && typeof body === "object" && typeof body.error?.message === "string" ? body.error.message : `HTTP ${response.status}`;
228
+ throw new Error(message);
229
+ }
230
+ return body;
231
+ }
232
+ async function listModelRecords(config) {
233
+ if (config.apiUrl.trim() === "") throw new Error("API URL is required");
234
+ const body = await responseJson(await fetch(endpoint(config.apiUrl, "/models"), { headers: headers(config.apiKey) }));
235
+ return (Array.isArray(body.data) ? body.data : []).flatMap((item) => {
236
+ if (item === null || typeof item !== "object" || typeof item.id !== "string") return [];
237
+ const id = item.id.trim();
238
+ return id === "" ? [] : [{
239
+ ...item,
240
+ id
241
+ }];
242
+ });
243
+ }
244
+ function textOf(value) {
245
+ if (typeof value === "string") return [value];
246
+ if (!Array.isArray(value)) return [];
247
+ return value.filter((item) => typeof item === "string");
248
+ }
249
+ function hasImageGenerationCapability(record) {
250
+ const capability = record.capabilities;
251
+ if (capability !== null && typeof capability === "object") {
252
+ const values = capability;
253
+ for (const key of [
254
+ "image_generation",
255
+ "imageGeneration",
256
+ "text_to_image",
257
+ "textToImage",
258
+ "image_gen"
259
+ ]) if (typeof values[key] === "boolean") return values[key];
260
+ const serialized = JSON.stringify(values).toLowerCase();
261
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image/.test(serialized)) return true;
262
+ }
263
+ const taskText = [
264
+ ...textOf(record.task),
265
+ ...textOf(record.task_type),
266
+ ...textOf(record.taskType),
267
+ ...textOf(record.type),
268
+ ...textOf(record.model_type),
269
+ ...textOf(record.modelType),
270
+ ...textOf(record.tasks),
271
+ ...textOf(record.description)
272
+ ].join(" ").toLowerCase();
273
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image|image[ _-]?gen/.test(taskText)) return true;
274
+ if (/^image(?:[ _-]?generation)?$/.test(taskText.trim())) return true;
275
+ if (/embedding|rerank|moderation|transcri|speech|audio|video|chat[ _-]?completion/.test(taskText)) return false;
276
+ for (const key of [
277
+ "output_modalities",
278
+ "outputModalities",
279
+ "supported_output_modalities"
280
+ ]) {
281
+ const modalities = textOf(record[key]).map((value) => value.toLowerCase());
282
+ if (modalities.length > 0) return modalities.includes("image");
283
+ }
284
+ }
285
+ function isImageModelRecord(record) {
286
+ return hasImageGenerationCapability(record) ?? isLikelyImageModelId(record.id);
287
+ }
288
+ /** List candidates exposed by an OpenAI-compatible endpoint. */
289
+ async function listOpenAIModels(config) {
290
+ return [...new Set((await listModelRecords(config)).map((record) => record.id))].sort((a, b) => a.localeCompare(b));
291
+ }
292
+ /** List only models that advertise or conventionally represent image generation. */
293
+ async function listImageModels(config) {
294
+ return [...new Set((await listModelRecords(config)).filter(isImageModelRecord).map((record) => record.id))].sort((a, b) => a.localeCompare(b));
295
+ }
296
+ /** List chat models exposed by an OpenAI-compatible endpoint. */
297
+ async function listPromptModels(config) {
298
+ return listOpenAIModels(config);
299
+ }
300
+ /** Expand a concise image request into a production-ready image prompt. */
301
+ async function enhancePrompt(config, prompt) {
302
+ if (config.apiUrl.trim() === "" || config.model.trim() === "") throw new Error("prompt enhancement model is not configured");
303
+ const body = await responseJson(await fetch(endpoint(config.apiUrl, "/chat/completions"), {
304
+ method: "POST",
305
+ headers: headers(config.apiKey),
306
+ body: JSON.stringify({
307
+ model: config.model.trim(),
308
+ temperature: .7,
309
+ messages: [{
310
+ role: "system",
311
+ content: "You are an expert image-prompt editor. Expand the user request into one vivid, specific image-generation prompt. Preserve intent and language. Add only useful visual detail: subject, composition, lighting, materials, color, camera/style and quality. Return only the finished prompt, with no preface or markdown."
312
+ }, {
313
+ role: "user",
314
+ content: prompt
315
+ }]
316
+ })
317
+ }));
318
+ const choices = Array.isArray(body.choices) ? body.choices : [];
319
+ const content = choices[0] !== null && typeof choices[0] === "object" ? choices[0].message?.content : void 0;
320
+ if (typeof content !== "string" || content.trim() === "") throw new Error("chat model returned an empty prompt");
321
+ return content.trim();
322
+ }
323
+ //#endregion
324
+ //#region src/image-models.ts
325
+ /**
326
+ * Image-model configuration shared by the host, panel, and Agent tools.
327
+ * `/models` exposes candidates only: the configured list is the explicit
328
+ * allow-list because OpenAI-compatible gateways rarely advertise modalities.
329
+ */
330
+ const DEFAULT_IMAGE_MODELS = [
331
+ "gpt-image-2",
332
+ "grok-imagine-image",
333
+ "nanobanana2",
334
+ "nanobanana2-lite",
335
+ "nanobanana-pro",
336
+ "seedream-5.0-pro",
337
+ "glm-image"
338
+ ];
339
+ /** Normalize user-entered model identifiers and retain a usable legacy default. */
340
+ function normalizeImageModels(value) {
341
+ const candidates = Array.isArray(value) ? value : [];
342
+ const unique = /* @__PURE__ */ new Set();
343
+ for (const candidate of candidates) {
344
+ if (typeof candidate !== "string") continue;
345
+ const model = candidate.trim();
346
+ if (model !== "") unique.add(model);
347
+ }
348
+ return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS];
349
+ }
350
+ //#endregion
351
+ //#region src/image-format.ts
352
+ function detectImageMime(data) {
353
+ const startsWith = (...bytes) => bytes.every((value, index) => data[index] === value);
354
+ if (startsWith(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
355
+ if (startsWith(255, 216, 255)) return "image/jpeg";
356
+ if (startsWith(71, 73, 70, 56, 55, 97) || startsWith(71, 73, 70, 56, 57, 97)) return "image/gif";
357
+ if (startsWith(82, 73, 70, 70) && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "image/webp";
358
+ }
359
+ //#endregion
280
360
  //#region src/engine.ts
281
361
  /** A generation failure with a user-presentable message. */
282
362
  var ImageGenError = class extends Error {
@@ -331,6 +411,13 @@ function isNanoBanana(model) {
331
411
  function isSeedream(model) {
332
412
  return modelFamily(model) === "seedream";
333
413
  }
414
+ /** Whether the model uses the official Zhipu image-generation contract. */
415
+ function isZhipuImage(model) {
416
+ return modelFamily(model) === "zhipu";
417
+ }
418
+ function isGlmImage(model) {
419
+ return /^glm-image(?:-|$)/i.test(model.trim());
420
+ }
334
421
  /** Whether this is the official Volcengine Ark model naming convention. */
335
422
  function isVolcSeedream(model) {
336
423
  return /^doubao-seedream(?:-|$)/i.test(model.trim());
@@ -407,6 +494,19 @@ function bareBase64(value) {
407
494
  const parsed = parseDataUrl(value);
408
495
  return parsed !== void 0 && parsed.base64 !== void 0 ? parsed.base64 : value;
409
496
  }
497
+ /** Whether a result URL carries cloud-storage signing credentials. */
498
+ function isPresignedUrl(value) {
499
+ let url;
500
+ try {
501
+ url = new URL(value);
502
+ } catch {
503
+ return false;
504
+ }
505
+ const params = new Set(Array.from(url.searchParams.keys(), (key) => key.toLowerCase()));
506
+ if (params.has("x-goog-signature") || params.has("x-goog-credential")) return true;
507
+ if (params.has("x-amz-signature") || params.has("x-amz-credential")) return true;
508
+ return params.has("signature") && (params.has("expires") || params.has("googleaccessid") || params.has("awsaccesskeyid"));
509
+ }
410
510
  /** Clamp the requested image count into the API-accepted range. */
411
511
  function clampCount(n) {
412
512
  if (!Number.isFinite(n)) return 1;
@@ -441,6 +541,11 @@ function effectiveParams(request) {
441
541
  size: seedreamSize(request.quality),
442
542
  response_format: isVolcSeedream(model) ? "url" : "b64_json"
443
543
  };
544
+ if (isZhipuImage(model)) return {
545
+ model,
546
+ ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
547
+ quality: isGlmImage(model) ? "hd" : "standard"
548
+ };
444
549
  return {
445
550
  model,
446
551
  ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
@@ -458,9 +563,9 @@ function effectiveCount(request) {
458
563
  /** Normalize one upstream data item into a base64 image. */
459
564
  async function normalizeItem(item, upstream) {
460
565
  const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : void 0;
461
- if (typeof item.b64_json === "string") {
566
+ if (typeof item.b64_json === "string" && item.b64_json.trim() !== "") {
462
567
  const b64 = bareBase64(item.b64_json);
463
- return {
568
+ if (b64.trim() !== "") return {
464
569
  b64,
465
570
  mime: detectImageMime(Buffer.from(b64, "base64")) ?? "image/png",
466
571
  revisedPrompt
@@ -481,7 +586,7 @@ async function normalizeItem(item, upstream) {
481
586
  let response;
482
587
  try {
483
588
  response = await fetch(url, {
484
- headers: { ...upstream.apiKey === "" ? {} : { authorization: `Bearer ${upstream.apiKey}` } },
589
+ ...isPresignedUrl(url) || upstream.apiKey === "" ? {} : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
485
590
  signal: budget.signal
486
591
  });
487
592
  } catch (error) {
@@ -607,6 +712,7 @@ async function generateImage(upstream, request, options = {}) {
607
712
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, "");
608
713
  if (baseUrl === "") throw new ImageGenError("api_url 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
609
714
  if (upstream.apiKey.trim() === "") throw new ImageGenError("api_key 未配置:请先在「设置 → 插件 → 可配置」中填写", "config-missing");
715
+ if (request.mode === "edit" && isZhipuImage(wireModel(request))) throw new ImageGenError("智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型", "edit-unsupported");
610
716
  const params = effectiveParams(request);
611
717
  const count = effectiveCount(request);
612
718
  return { images: (await Promise.all(Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)))).flat() };
@@ -707,7 +813,7 @@ async function writeIndex$1(entries) {
707
813
  function isStoredEntry$1(value) {
708
814
  if (value === null || typeof value !== "object") return false;
709
815
  const entry = value;
710
- return typeof entry.id === "string" && typeof entry.createdAt === "number" && (entry.mode === "text" || entry.mode === "edit") && typeof entry.prompt === "string" && Array.isArray(entry.images) && entry.images.every((image) => {
816
+ return typeof entry.id === "string" && typeof entry.createdAt === "number" && (entry.mode === "text" || entry.mode === "edit") && (entry.workflow === void 0 || entry.workflow === "ecommerce") && (entry.projectId === void 0 || typeof entry.projectId === "string") && (entry.projectName === void 0 || typeof entry.projectName === "string") && (entry.slotKey === void 0 || typeof entry.slotKey === "string") && (entry.slotLabel === void 0 || typeof entry.slotLabel === "string") && typeof entry.prompt === "string" && Array.isArray(entry.images) && entry.images.every((image) => {
711
817
  if (image === null || typeof image !== "object") return false;
712
818
  const record = image;
713
819
  return typeof record.file === "string" && typeof record.mime === "string";
@@ -738,7 +844,14 @@ function toWire$1(entry) {
738
844
  })),
739
845
  ...entry.refName === void 0 ? {} : { refName: entry.refName },
740
846
  ...entry.channel === void 0 ? {} : { channel: entry.channel },
741
- ...entry.channelId === void 0 ? {} : { channelId: entry.channelId }
847
+ ...entry.channelId === void 0 ? {} : { channelId: entry.channelId },
848
+ ...entry.comparisonId === void 0 ? {} : { comparisonId: entry.comparisonId },
849
+ ...entry.comparisonModels === void 0 ? {} : { comparisonModels: entry.comparisonModels },
850
+ ...entry.workflow === void 0 ? {} : { workflow: entry.workflow },
851
+ ...entry.projectId === void 0 ? {} : { projectId: entry.projectId },
852
+ ...entry.projectName === void 0 ? {} : { projectName: entry.projectName },
853
+ ...entry.slotKey === void 0 ? {} : { slotKey: entry.slotKey },
854
+ ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel }
742
855
  };
743
856
  }
744
857
  /** List the persisted history, newest first, as wire entries. */
@@ -779,7 +892,14 @@ async function appendHistory(input) {
779
892
  images: storedImages,
780
893
  ...input.refName === void 0 ? {} : { refName: input.refName },
781
894
  ...input.channelId === void 0 ? {} : { channelId: input.channelId },
782
- ...input.channel === void 0 ? {} : { channel: input.channel }
895
+ ...input.channel === void 0 ? {} : { channel: input.channel },
896
+ ...input.comparisonId === void 0 ? {} : { comparisonId: input.comparisonId },
897
+ ...input.comparisonModels === void 0 ? {} : { comparisonModels: input.comparisonModels },
898
+ ...input.workflow === void 0 ? {} : { workflow: input.workflow },
899
+ ...input.projectId === void 0 ? {} : { projectId: input.projectId },
900
+ ...input.projectName === void 0 ? {} : { projectName: input.projectName },
901
+ ...input.slotKey === void 0 ? {} : { slotKey: input.slotKey },
902
+ ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel }
783
903
  }, ...await readIndex$1()];
784
904
  const kept = merged.slice(0, 50);
785
905
  for (const dropped of merged.slice(50)) await removeEntryFiles$1(dropped);
@@ -824,12 +944,15 @@ async function readHistoryImage(file) {
824
944
  /** In-memory, host-resident image generation queue. */
825
945
  var GenerationTaskQueue = class {
826
946
  run;
947
+ concurrency;
827
948
  tasks = [];
828
949
  controllers = /* @__PURE__ */ new Map();
829
950
  listeners = /* @__PURE__ */ new Set();
830
- running = false;
831
- constructor(run) {
951
+ running = 0;
952
+ serialRunning = false;
953
+ constructor(run, concurrency = 1) {
832
954
  this.run = run;
955
+ this.concurrency = concurrency;
833
956
  }
834
957
  list() {
835
958
  return this.tasks.map((task) => this.snapshot(task));
@@ -860,45 +983,49 @@ var GenerationTaskQueue = class {
860
983
  task.finishedAt = Date.now();
861
984
  this.controllers.get(id)?.abort();
862
985
  this.publish(task);
986
+ this.drain();
863
987
  return this.snapshot(task);
864
988
  }
865
989
  retry(id) {
866
990
  const previous = this.tasks.find((item) => item.id === id);
867
991
  return previous === void 0 ? void 0 : this.submit(previous.request);
868
992
  }
869
- async drain() {
870
- if (this.running) return;
871
- this.running = true;
993
+ drain() {
994
+ while (this.running < Math.max(1, this.concurrency)) {
995
+ const task = this.tasks.find((item) => item.status === "queued" && (this.running === 0 || item.request.comparisonId !== void 0 && !this.serialRunning));
996
+ if (task === void 0) return;
997
+ this.running += 1;
998
+ if (task.request.comparisonId === void 0) this.serialRunning = true;
999
+ this.runTask(task).finally(() => {
1000
+ this.running -= 1;
1001
+ if (task.request.comparisonId === void 0) this.serialRunning = false;
1002
+ this.drain();
1003
+ });
1004
+ }
1005
+ }
1006
+ async runTask(task) {
1007
+ task.status = "running";
1008
+ task.startedAt = Date.now();
1009
+ this.publish(task);
1010
+ const controller = new AbortController();
1011
+ this.controllers.set(task.id, controller);
872
1012
  try {
873
- for (;;) {
874
- const task = this.tasks.find((item) => item.status === "queued");
875
- if (task === void 0) return;
876
- task.status = "running";
877
- task.startedAt = Date.now();
1013
+ const result = await this.run(task.request, controller.signal);
1014
+ if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
1015
+ task.status = "completed";
1016
+ task.result = result;
1017
+ task.finishedAt = Date.now();
1018
+ this.publish(task);
1019
+ }
1020
+ } catch (error) {
1021
+ if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
1022
+ task.status = "failed";
1023
+ task.error = error instanceof Error ? error.message : String(error);
1024
+ task.finishedAt = Date.now();
878
1025
  this.publish(task);
879
- const controller = new AbortController();
880
- this.controllers.set(task.id, controller);
881
- try {
882
- const result = await this.run(task.request, controller.signal);
883
- if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
884
- task.status = "completed";
885
- task.result = result;
886
- task.finishedAt = Date.now();
887
- this.publish(task);
888
- }
889
- } catch (error) {
890
- if (this.tasks.find((item) => item.id === task.id)?.status !== "cancelled") {
891
- task.status = "failed";
892
- task.error = error instanceof Error ? error.message : String(error);
893
- task.finishedAt = Date.now();
894
- this.publish(task);
895
- }
896
- } finally {
897
- this.controllers.delete(task.id);
898
- }
899
1026
  }
900
1027
  } finally {
901
- this.running = false;
1028
+ this.controllers.delete(task.id);
902
1029
  }
903
1030
  }
904
1031
  publish(task) {
@@ -934,7 +1061,7 @@ var ImageGenerationRuntime = class {
934
1061
  constructor(resolve, history = { append: appendHistory }) {
935
1062
  this.resolve = resolve;
936
1063
  this.history = history;
937
- this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal));
1064
+ this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal), 4);
938
1065
  }
939
1066
  async run(request, signal) {
940
1067
  const view = this.resolve();
@@ -958,7 +1085,14 @@ var ImageGenerationRuntime = class {
958
1085
  images: result.images,
959
1086
  ...request.refName === void 0 ? {} : { refName: request.refName },
960
1087
  ...request.channelId === void 0 ? {} : { channelId: request.channelId },
961
- ...request.channel === void 0 ? {} : { channel: request.channel }
1088
+ ...request.channel === void 0 ? {} : { channel: request.channel },
1089
+ ...request.comparisonId === void 0 ? {} : { comparisonId: request.comparisonId },
1090
+ ...request.comparisonModels === void 0 ? {} : { comparisonModels: request.comparisonModels },
1091
+ ...request.workflow === void 0 ? {} : { workflow: request.workflow },
1092
+ ...request.projectId === void 0 ? {} : { projectId: request.projectId },
1093
+ ...request.projectName === void 0 ? {} : { projectName: request.projectName },
1094
+ ...request.slotKey === void 0 ? {} : { slotKey: request.slotKey },
1095
+ ...request.slotLabel === void 0 ? {} : { slotLabel: request.slotLabel }
962
1096
  });
963
1097
  return {
964
1098
  ...result,
@@ -1052,7 +1186,7 @@ async function writeIndex(entries) {
1052
1186
  function isStoredEntry(value) {
1053
1187
  if (value === null || typeof value !== "object") return false;
1054
1188
  const entry = value;
1055
- return typeof entry.id === "string" && typeof entry.createdAt === "number" && (entry.mode === "text" || entry.mode === "edit") && typeof entry.prompt === "string" && Array.isArray(entry.images) && entry.images.every((image) => {
1189
+ return typeof entry.id === "string" && typeof entry.createdAt === "number" && (entry.mode === "text" || entry.mode === "edit") && (entry.workflow === void 0 || entry.workflow === "ecommerce") && (entry.projectId === void 0 || typeof entry.projectId === "string") && (entry.projectName === void 0 || typeof entry.projectName === "string") && (entry.slotKey === void 0 || typeof entry.slotKey === "string") && (entry.slotLabel === void 0 || typeof entry.slotLabel === "string") && typeof entry.prompt === "string" && Array.isArray(entry.images) && entry.images.every((image) => {
1056
1190
  if (image === null || typeof image !== "object") return false;
1057
1191
  const record = image;
1058
1192
  return typeof record.file === "string" && typeof record.mime === "string";
@@ -1084,7 +1218,12 @@ function toWire(entry) {
1084
1218
  ...entry.refName === void 0 ? {} : { refName: entry.refName },
1085
1219
  ...entry.tags === void 0 ? {} : { tags: entry.tags },
1086
1220
  ...entry.channel === void 0 ? {} : { channel: entry.channel },
1087
- ...entry.channelId === void 0 ? {} : { channelId: entry.channelId }
1221
+ ...entry.channelId === void 0 ? {} : { channelId: entry.channelId },
1222
+ ...entry.workflow === void 0 ? {} : { workflow: entry.workflow },
1223
+ ...entry.projectId === void 0 ? {} : { projectId: entry.projectId },
1224
+ ...entry.projectName === void 0 ? {} : { projectName: entry.projectName },
1225
+ ...entry.slotKey === void 0 ? {} : { slotKey: entry.slotKey },
1226
+ ...entry.slotLabel === void 0 ? {} : { slotLabel: entry.slotLabel }
1088
1227
  };
1089
1228
  }
1090
1229
  /** List the persisted gallery, newest first, as wire entries. */
@@ -1136,7 +1275,12 @@ async function appendGallery(input) {
1136
1275
  ...hash === void 0 ? {} : { hash },
1137
1276
  ...input.refName === void 0 ? {} : { refName: input.refName },
1138
1277
  ...input.channelId === void 0 ? {} : { channelId: input.channelId },
1139
- ...input.channel === void 0 ? {} : { channel: input.channel }
1278
+ ...input.channel === void 0 ? {} : { channel: input.channel },
1279
+ ...input.workflow === void 0 ? {} : { workflow: input.workflow },
1280
+ ...input.projectId === void 0 ? {} : { projectId: input.projectId },
1281
+ ...input.projectName === void 0 ? {} : { projectName: input.projectName },
1282
+ ...input.slotKey === void 0 ? {} : { slotKey: input.slotKey },
1283
+ ...input.slotLabel === void 0 ? {} : { slotLabel: input.slotLabel }
1140
1284
  }, ...await readIndex()];
1141
1285
  await writeIndex(merged);
1142
1286
  return {
@@ -1584,13 +1728,20 @@ const IMAGE_PRESETS = [
1584
1728
  id: "openai-official",
1585
1729
  name: "OpenAI 官方",
1586
1730
  apiUrl: "https://api.openai.com/v1",
1587
- hint: "OpenAI 官方接口:gpt-image-2 / dall-e-3",
1731
+ hint: "OpenAI 官方图像生成接口",
1588
1732
  models: [{
1589
1733
  alias: "gpt-image-2",
1590
1734
  id: "gpt-image-2"
1591
- }, {
1592
- alias: "dall-e-3",
1593
- id: "dall-e-3"
1735
+ }]
1736
+ },
1737
+ {
1738
+ id: "zhipu-official",
1739
+ name: "智谱 AI 官方",
1740
+ apiUrl: "https://open.bigmodel.cn/api/paas/v4",
1741
+ hint: "智谱官方 GLM-Image 图像生成接口",
1742
+ models: [{
1743
+ alias: "glm-image",
1744
+ id: "glm-image"
1594
1745
  }]
1595
1746
  },
1596
1747
  {
@@ -1669,6 +1820,7 @@ function messageOf(error) {
1669
1820
  function parseGenerateRequest(body) {
1670
1821
  const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1671
1822
  if (prompt === "") return void 0;
1823
+ const comparisonModels = Array.isArray(body.comparisonModels) ? [...new Set(body.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
1672
1824
  return {
1673
1825
  mode: body.mode === "edit" ? "edit" : "text",
1674
1826
  model: typeof body.model === "string" ? body.model : "",
@@ -1679,7 +1831,14 @@ function parseGenerateRequest(body) {
1679
1831
  detail: typeof body.detail === "string" ? body.detail : "",
1680
1832
  ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {},
1681
1833
  ...typeof body.refName === "string" && body.refName !== "" ? { refName: body.refName } : {},
1682
- ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {}
1834
+ ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {},
1835
+ ...typeof body.comparisonId === "string" && body.comparisonId !== "" ? { comparisonId: body.comparisonId } : {},
1836
+ ...comparisonModels.length > 1 ? { comparisonModels } : {},
1837
+ ...body.workflow === "ecommerce" ? { workflow: "ecommerce" } : {},
1838
+ ...typeof body.projectId === "string" && body.projectId !== "" ? { projectId: body.projectId } : {},
1839
+ ...typeof body.projectName === "string" && body.projectName !== "" ? { projectName: body.projectName } : {},
1840
+ ...typeof body.slotKey === "string" && body.slotKey !== "" ? { slotKey: body.slotKey } : {},
1841
+ ...typeof body.slotLabel === "string" && body.slotLabel !== "" ? { slotLabel: body.slotLabel } : {}
1683
1842
  };
1684
1843
  }
1685
1844
  /** Validate a submitted history entry (images carry base64). */
@@ -1704,6 +1863,7 @@ function parseHistoryEntryInput(body) {
1704
1863
  ...typeof image.revisedPrompt === "string" ? { revisedPrompt: image.revisedPrompt } : {}
1705
1864
  });
1706
1865
  }
1866
+ const comparisonModels = Array.isArray(entry.comparisonModels) ? [...new Set(entry.comparisonModels.filter((model) => typeof model === "string").map((model) => model.trim()).filter(Boolean))] : [];
1707
1867
  return {
1708
1868
  id: entry.id,
1709
1869
  createdAt: entry.createdAt,
@@ -1717,7 +1877,14 @@ function parseHistoryEntryInput(body) {
1717
1877
  images,
1718
1878
  ...typeof entry.refName === "string" ? { refName: entry.refName } : {},
1719
1879
  ...typeof entry.channelId === "string" ? { channelId: entry.channelId } : {},
1720
- ...typeof entry.channel === "string" ? { channel: entry.channel } : {}
1880
+ ...typeof entry.channel === "string" ? { channel: entry.channel } : {},
1881
+ ...typeof entry.comparisonId === "string" ? { comparisonId: entry.comparisonId } : {},
1882
+ ...comparisonModels.length > 1 ? { comparisonModels } : {},
1883
+ ...entry.workflow === "ecommerce" ? { workflow: "ecommerce" } : {},
1884
+ ...typeof entry.projectId === "string" ? { projectId: entry.projectId } : {},
1885
+ ...typeof entry.projectName === "string" ? { projectName: entry.projectName } : {},
1886
+ ...typeof entry.slotKey === "string" ? { slotKey: entry.slotKey } : {},
1887
+ ...typeof entry.slotLabel === "string" ? { slotLabel: entry.slotLabel } : {}
1721
1888
  };
1722
1889
  }
1723
1890
  /** Extract the image file name from a history-image request URL. */
@@ -1759,6 +1926,15 @@ function agentImageRefFrom(rawUrl) {
1759
1926
  function isImageMediaType(value) {
1760
1927
  return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
1761
1928
  }
1929
+ function imageDataUrl$1(value) {
1930
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/su.exec(value.trim());
1931
+ if (match === null || match[1] === void 0 || match[2] === void 0) return void 0;
1932
+ const data = Buffer.from(match[2], "base64");
1933
+ return data.byteLength === 0 ? void 0 : {
1934
+ mediaType: match[1],
1935
+ data
1936
+ };
1937
+ }
1762
1938
  /** Project one settings descriptor onto the bridge wire view. */
1763
1939
  function toView(descriptor) {
1764
1940
  return {
@@ -1910,6 +2086,39 @@ function makeRoutes(deps) {
1910
2086
  return true;
1911
2087
  };
1912
2088
  return [
2089
+ ...deps.attachments?.saveImage === void 0 || deps.pendingConversationImages === void 0 ? [] : [{
2090
+ kind: "exact",
2091
+ path: CONVERSATION_IMAGE_API,
2092
+ handler: async (req, res) => {
2093
+ if (!guard(req, res, "POST")) return;
2094
+ const body = await readJsonBody(req, MAX_JSON_BODY_BYTES);
2095
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId.trim() : "";
2096
+ const dataUrl = typeof body?.dataUrl === "string" ? imageDataUrl$1(body.dataUrl) : void 0;
2097
+ if (sessionId === "" || dataUrl === void 0) {
2098
+ writeJson(res, 200, {
2099
+ ok: false,
2100
+ code: "bad-request",
2101
+ message: "sessionId and image data are required"
2102
+ });
2103
+ return;
2104
+ }
2105
+ try {
2106
+ const ref = await deps.attachments.saveImage({
2107
+ data: dataUrl.data,
2108
+ mediaType: dataUrl.mediaType,
2109
+ ...typeof body?.name === "string" && body.name.trim() !== "" ? { name: body.name.trim() } : {}
2110
+ });
2111
+ deps.pendingConversationImages.set(sessionId, ref);
2112
+ writeJson(res, 200, { ok: true });
2113
+ } catch (error) {
2114
+ writeJson(res, 200, {
2115
+ ok: false,
2116
+ code: "image-save-failed",
2117
+ message: messageOf(error)
2118
+ });
2119
+ }
2120
+ }
2121
+ }],
1913
2122
  ...deps.attachments === void 0 ? [] : [{
1914
2123
  kind: "prefix",
1915
2124
  path: AGENT_IMAGE_API,
@@ -1955,7 +2164,7 @@ function makeRoutes(deps) {
1955
2164
  try {
1956
2165
  writeJson(res, 200, {
1957
2166
  ok: true,
1958
- models: await listOpenAIModels(upstream)
2167
+ models: await listImageModels(upstream)
1959
2168
  });
1960
2169
  } catch (error) {
1961
2170
  writeJson(res, 200, {
@@ -2733,6 +2942,113 @@ const taskResultSchema = {
2733
2942
  };
2734
2943
  /** Agent calls stay pending until the provider and history write settle. */
2735
2944
  const AGENT_GENERATION_TIMEOUT_MS = 3e5;
2945
+ function ensureAgentImageConfigured(config) {
2946
+ if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.", "plugin-disabled");
2947
+ if (!config.allowAgentImageGeneration) throw new ImageGenError("Agent image generation is disabled in Settings > Plugins > AI Image.", "agent-generation-disabled");
2948
+ 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");
2949
+ }
2950
+ /** Resolve a configured image alias and its owning channel. */
2951
+ function resolveAgentImageModel(config, requested) {
2952
+ const entries = config.channels.flatMap((channel) => channel.models.map((model) => ({
2953
+ channel,
2954
+ alias: model.alias,
2955
+ upstream: model.id
2956
+ })));
2957
+ 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");
2958
+ const wanted = typeof requested === "string" && requested.trim() !== "" ? requested.trim() : "";
2959
+ if (wanted === "") {
2960
+ if (entries.length === 1) return entries[0];
2961
+ 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");
2962
+ }
2963
+ const hosting = entries.filter((entry) => entry.alias === wanted);
2964
+ 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");
2965
+ return hosting.find((entry) => entry.channel.id === config.defaultChannelId) ?? hosting[0];
2966
+ }
2967
+ function findAgentImageTask(runtime, id) {
2968
+ const task = runtime.queue.list().find((candidate) => candidate.id === id);
2969
+ if (task === void 0) throw new ImageGenError(`Image generation task ${id} was not found.`, "task-not-found");
2970
+ return task;
2971
+ }
2972
+ /** Wait for a queue task without sending anything through the chat model. */
2973
+ function waitForAgentImageTask(runtime, id, signal) {
2974
+ return new Promise((resolveTask, rejectTask) => {
2975
+ let settled = false;
2976
+ let dispose = () => {};
2977
+ let timer;
2978
+ let abort = () => {};
2979
+ const cleanup = () => {
2980
+ dispose();
2981
+ if (timer !== void 0) clearTimeout(timer);
2982
+ signal?.removeEventListener("abort", abort);
2983
+ };
2984
+ const resolve = (task) => {
2985
+ if (settled) return;
2986
+ settled = true;
2987
+ cleanup();
2988
+ resolveTask(task);
2989
+ };
2990
+ const reject = (error) => {
2991
+ if (settled) return;
2992
+ settled = true;
2993
+ cleanup();
2994
+ rejectTask(error);
2995
+ };
2996
+ abort = () => {
2997
+ if (settled) return;
2998
+ const reason = signal?.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Image generation was cancelled.");
2999
+ settled = true;
3000
+ cleanup();
3001
+ runtime.queue.cancel(id);
3002
+ rejectTask(reason);
3003
+ };
3004
+ const onChange = (updated) => {
3005
+ if (updated.id === id && isFinalTask(updated)) resolve(updated);
3006
+ };
3007
+ if (signal?.aborted === true) {
3008
+ abort();
3009
+ return;
3010
+ }
3011
+ dispose = runtime.queue.subscribe(onChange);
3012
+ signal?.addEventListener("abort", abort, { once: true });
3013
+ timer = setTimeout(() => {
3014
+ if (settled) return;
3015
+ const timeout = new ImageGenError(`Image generation task ${id} timed out after ${AGENT_GENERATION_TIMEOUT_MS / 1e3} seconds.`, "generation-timeout");
3016
+ settled = true;
3017
+ cleanup();
3018
+ runtime.queue.cancel(id);
3019
+ rejectTask(timeout);
3020
+ }, AGENT_GENERATION_TIMEOUT_MS);
3021
+ let current;
3022
+ try {
3023
+ current = findAgentImageTask(runtime, id);
3024
+ } catch (error) {
3025
+ reject(error);
3026
+ return;
3027
+ }
3028
+ if (isFinalTask(current)) resolve(current);
3029
+ });
3030
+ }
3031
+ /** Submit the same image-edit request used by the Agent tool. */
3032
+ async function submitAgentImageEdit(attachments, runtime, resolve, input) {
3033
+ const config = resolve();
3034
+ ensureAgentImageConfigured(config);
3035
+ const reference = await attachments.readImage(input.sourceImage, input.signal);
3036
+ const picked = resolveAgentImageModel(config, (config.channels.find((channel) => channel.id === config.defaultChannelId) ?? config.channels[0])?.models[0]?.alias ?? config.channels.flatMap((channel) => channel.models)[0]?.alias);
3037
+ return waitForAgentImageTask(runtime, runtime.queue.submit({
3038
+ mode: "edit",
3039
+ model: picked.alias,
3040
+ upstream: picked.upstream,
3041
+ channelId: picked.channel.id,
3042
+ channel: picked.channel.name,
3043
+ prompt: input.prompt.trim(),
3044
+ size: "auto",
3045
+ quality: "auto",
3046
+ n: 1,
3047
+ detail: "",
3048
+ image: imageDataUrl(reference),
3049
+ ...reference.ref.name === void 0 ? {} : { refName: reference.ref.name }
3050
+ }).id, input.signal);
3051
+ }
2736
3052
  function acceptedMediaType(value) {
2737
3053
  return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
2738
3054
  }
@@ -2818,36 +3134,9 @@ function presentImageResult(_args, result) {
2818
3134
  function registerAgentImageTools(ctx, runtime, resolve) {
2819
3135
  const attachmentRefs = /* @__PURE__ */ new Map();
2820
3136
  const ensureConfigured = () => {
2821
- const config = resolve();
2822
- if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.", "plugin-disabled");
2823
- if (!config.allowAgentImageGeneration) throw new ImageGenError("Agent image generation is disabled in Settings > Plugins > AI Image.", "agent-generation-disabled");
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");
2825
- };
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];
3137
+ ensureAgentImageConfigured(resolve());
2850
3138
  };
3139
+ const resolveModel = (requested) => resolveAgentImageModel(resolve(), requested);
2851
3140
  const materializeTaskImages = (task) => {
2852
3141
  if (task.status !== "completed") return Promise.resolve([]);
2853
3142
  const existing = attachmentRefs.get(task.id);
@@ -2869,67 +3158,8 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2869
3158
  images
2870
3159
  };
2871
3160
  };
2872
- const findTask = (id) => {
2873
- const task = runtime.queue.list().find((candidate) => candidate.id === id);
2874
- if (task === void 0) throw new ImageGenError(`Image generation task ${id} was not found.`, "task-not-found");
2875
- return task;
2876
- };
2877
- const waitForTask = (id, signal) => new Promise((resolveTask, rejectTask) => {
2878
- let settled = false;
2879
- let dispose = () => {};
2880
- let timer;
2881
- let abort = () => {};
2882
- const cleanup = () => {
2883
- dispose();
2884
- if (timer !== void 0) clearTimeout(timer);
2885
- signal?.removeEventListener("abort", abort);
2886
- };
2887
- const resolve = (task) => {
2888
- if (settled) return;
2889
- settled = true;
2890
- cleanup();
2891
- resolveTask(task);
2892
- };
2893
- const reject = (error) => {
2894
- if (settled) return;
2895
- settled = true;
2896
- cleanup();
2897
- rejectTask(error);
2898
- };
2899
- abort = () => {
2900
- if (settled) return;
2901
- const reason = signal?.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Image generation was cancelled.");
2902
- settled = true;
2903
- cleanup();
2904
- runtime.queue.cancel(id);
2905
- rejectTask(reason);
2906
- };
2907
- const onChange = (updated) => {
2908
- if (updated.id === id && isFinalTask(updated)) resolve(updated);
2909
- };
2910
- if (signal?.aborted === true) {
2911
- abort();
2912
- return;
2913
- }
2914
- dispose = runtime.queue.subscribe(onChange);
2915
- signal?.addEventListener("abort", abort, { once: true });
2916
- timer = setTimeout(() => {
2917
- if (settled) return;
2918
- const timeout = new ImageGenError(`Image generation task ${id} timed out after ${AGENT_GENERATION_TIMEOUT_MS / 1e3} seconds.`, "generation-timeout");
2919
- settled = true;
2920
- cleanup();
2921
- runtime.queue.cancel(id);
2922
- rejectTask(timeout);
2923
- }, AGENT_GENERATION_TIMEOUT_MS);
2924
- let current;
2925
- try {
2926
- current = findTask(id);
2927
- } catch (error) {
2928
- reject(error);
2929
- return;
2930
- }
2931
- if (isFinalTask(current)) resolve(current);
2932
- });
3161
+ const findTask = (id) => findAgentImageTask(runtime, id);
3162
+ const waitForTask = (id, signal) => waitForAgentImageTask(runtime, id, signal);
2933
3163
  const disposers = [
2934
3164
  ctx.tools.register(defineTool({
2935
3165
  name: "generate_image",
@@ -3114,11 +3344,107 @@ function toSaveImage(image, taskId, index) {
3114
3344
  };
3115
3345
  }
3116
3346
  //#endregion
3347
+ //#region src/edit-image-command.ts
3348
+ function isImageReference(value) {
3349
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
3350
+ const ref = value;
3351
+ return typeof ref.attachmentId === "string" && (ref.mediaType === "image/png" || ref.mediaType === "image/jpeg" || ref.mediaType === "image/webp" || ref.mediaType === "image/gif") && Number.isInteger(ref.bytes) && ref.bytes > 0 && Number.isInteger(ref.width) && ref.width > 0 && Number.isInteger(ref.height) && ref.height > 0;
3352
+ }
3353
+ function imageInContent(value) {
3354
+ if (!Array.isArray(value)) return void 0;
3355
+ for (let index = value.length - 1; index >= 0; index -= 1) {
3356
+ const block = value[index];
3357
+ if (typeof block !== "object" || block === null || Array.isArray(block)) continue;
3358
+ const raw = block;
3359
+ if (raw.type === "image" && isImageReference(raw.attachment)) return raw.attachment;
3360
+ if (raw.type === "tool-result") {
3361
+ const nested = imageInContent(raw.content);
3362
+ if (nested !== void 0) return nested;
3363
+ }
3364
+ }
3365
+ }
3366
+ /** Pick the newest image explicitly attached to this command invocation. */
3367
+ function imageInInvocation(value) {
3368
+ if (!Array.isArray(value)) return void 0;
3369
+ for (let index = value.length - 1; index >= 0; index -= 1) {
3370
+ const block = value[index];
3371
+ if (typeof block !== "object" || block === null || Array.isArray(block)) continue;
3372
+ const raw = block;
3373
+ if (raw.type === "image" && isImageReference(raw.attachment)) return raw.attachment;
3374
+ }
3375
+ }
3376
+ /** Find the newest durable image reference, including nested tool results. */
3377
+ function latestSessionImage(messages) {
3378
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
3379
+ const message = messages[index];
3380
+ if (typeof message !== "object" || message === null || Array.isArray(message)) continue;
3381
+ const image = imageInContent(message.content);
3382
+ if (image !== void 0) return image;
3383
+ }
3384
+ }
3385
+ function commandError(error) {
3386
+ const text = error instanceof Error ? error.message : String(error);
3387
+ return {
3388
+ kind: "error",
3389
+ text: text.trim() === "" ? "图片编辑失败。" : text
3390
+ };
3391
+ }
3392
+ /** Register the host-side command; it never sends the command line to a chat model. */
3393
+ function registerEditImageCommand(ctx, runtime, resolve, pendingImages) {
3394
+ return ctx.commands.register({
3395
+ name: "edit_image",
3396
+ description: "Edit the latest image in this conversation with the plugin image model",
3397
+ input: {
3398
+ hint: "Describe how to modify the latest image",
3399
+ images: true
3400
+ },
3401
+ async handler(invocation) {
3402
+ const prompt = invocation.rawInput.trim();
3403
+ if (prompt === "") return {
3404
+ kind: "error",
3405
+ text: "请提供图片修改描述,例如:/edit_image 把背景改成夜景"
3406
+ };
3407
+ const invocationImage = imageInInvocation(invocation.attachments);
3408
+ const pendingImage = pendingImages?.get(String(invocation.agent.id));
3409
+ const durableImage = latestSessionImage(invocation.agent.session.deriveMessages());
3410
+ const sourceImage = invocationImage ?? pendingImage ?? durableImage;
3411
+ if (sourceImage === void 0) return {
3412
+ kind: "error",
3413
+ text: "当前对话没有可用图片,请先上传图片或把画廊图片加入对话。"
3414
+ };
3415
+ try {
3416
+ const task = await submitAgentImageEdit(ctx.attachments, runtime, resolve, {
3417
+ prompt,
3418
+ sourceImage,
3419
+ signal: invocation.signal
3420
+ });
3421
+ if (task.status === "completed") {
3422
+ if (pendingImage !== void 0) pendingImages?.consume(String(invocation.agent.id), pendingImage);
3423
+ return {
3424
+ kind: "success",
3425
+ text: "图片编辑已完成,可在 AI 生图面板查看结果。"
3426
+ };
3427
+ }
3428
+ return {
3429
+ kind: "error",
3430
+ text: task.error ?? `图片编辑${task.status === "cancelled" ? "已取消" : "失败"}。`
3431
+ };
3432
+ } catch (error) {
3433
+ return commandError(error);
3434
+ }
3435
+ }
3436
+ });
3437
+ }
3438
+ //#endregion
3117
3439
  //#region src/index.ts
3118
3440
  /** Stable cordis plugin name. */
3119
3441
  const name = "imagegen";
3120
3442
  /** Services required before the surfaces can mount. */
3121
- const inject = ["webServer", "systemPrompt"];
3443
+ const inject = [
3444
+ "webServer",
3445
+ "systemPrompt",
3446
+ "commands"
3447
+ ];
3122
3448
  /** The branded settings namespace of this plugin (the card edits it). */
3123
3449
  const ImageGenSettingsNamespace = settingsNamespace(IMAGEGEN_SETTINGS_NAMESPACE);
3124
3450
  const Config = z.object({
@@ -3151,7 +3477,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
3151
3477
  /** Order of the announcement section within the tool-guidance band. */
3152
3478
  const SECTION_ORDER = 150;
3153
3479
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
3480
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:通过「渠道」对接 OpenAI 兼容图像生成 API(每个渠道 = 一个 API 端点 + 各自的模型目录),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送;智谱 `glm-image` 使用官方 `/api/paas/v4/images/generations`,当前仅支持文生图)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
3155
3481
  /** Append the live channel × model table so an Agent can honor user choices. */
3156
3482
  function guidanceFor(channels, defaultChannelId) {
3157
3483
  if (channels.length === 0) return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`;
@@ -3251,6 +3577,7 @@ function apply(ctx, config) {
3251
3577
  };
3252
3578
  };
3253
3579
  const runtime = new ImageGenerationRuntime(channelsView);
3580
+ const pendingConversationImages = /* @__PURE__ */ new Map();
3254
3581
  ctx.inject(["settings", "attachments"], (sctx) => {
3255
3582
  const seam = sctx.get("settings");
3256
3583
  sctx.effect(() => {
@@ -3279,6 +3606,7 @@ function apply(ctx, config) {
3279
3606
  return [...new Set(value.channels.flatMap((channel) => channel.models.map((model) => model.alias)))];
3280
3607
  },
3281
3608
  attachments: sctx.attachments,
3609
+ pendingConversationImages,
3282
3610
  runtime
3283
3611
  }).map((route) => ctx.webServer.register(route));
3284
3612
  return () => {
@@ -3286,16 +3614,33 @@ function apply(ctx, config) {
3286
3614
  };
3287
3615
  }, "dsh-imagegen: routes");
3288
3616
  });
3289
- ctx.inject(["tools", "attachments"], (tctx) => {
3290
- tctx.effect(() => registerAgentImageTools(tctx, runtime, () => {
3291
- const value = resolve();
3292
- return {
3293
- enabled: value.enabled,
3294
- allowAgentImageGeneration: value.allowAgentImageGeneration,
3295
- channels: value.channels,
3296
- defaultChannelId: value.defaultChannelId
3617
+ ctx.inject([
3618
+ "tools",
3619
+ "attachments",
3620
+ "commands"
3621
+ ], (tctx) => {
3622
+ tctx.effect(() => {
3623
+ const resolveAgentConfig = () => {
3624
+ const value = resolve();
3625
+ return {
3626
+ enabled: value.enabled,
3627
+ allowAgentImageGeneration: value.allowAgentImageGeneration,
3628
+ channels: value.channels,
3629
+ defaultChannelId: value.defaultChannelId
3630
+ };
3631
+ };
3632
+ const disposeTools = registerAgentImageTools(tctx, runtime, resolveAgentConfig);
3633
+ const disposeCommand = registerEditImageCommand(tctx, runtime, resolveAgentConfig, {
3634
+ get: (sessionId) => pendingConversationImages.get(sessionId),
3635
+ consume: (sessionId, ref) => {
3636
+ if (pendingConversationImages.get(sessionId)?.attachmentId === ref.attachmentId) pendingConversationImages.delete(sessionId);
3637
+ }
3638
+ });
3639
+ return () => {
3640
+ disposeCommand();
3641
+ disposeTools();
3297
3642
  };
3298
- }), "dsh-imagegen: agent image tools");
3643
+ }, "dsh-imagegen: agent image tools and commands");
3299
3644
  });
3300
3645
  let disposeSection;
3301
3646
  const sync = () => {
@@ -3321,4 +3666,4 @@ function apply(ctx, config) {
3321
3666
  sync();
3322
3667
  }
3323
3668
  //#endregion
3324
- export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, ImageGenerationRuntime, appendGallery, apply, checkForUpdate, clearGallery, clearTemplateMemo, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, listGallery, listTemplates, makeRoutes, name, profileFromProcess, readGalleryImage, readTemplateImage, refreshTemplates, registerAgentImageTools, removeGallery, updateGalleryTags };
3669
+ export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, ImageGenerationRuntime, appendGallery, apply, checkForUpdate, clearGallery, clearTemplateMemo, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, latestSessionImage, listGallery, listTemplates, makeRoutes, name, profileFromProcess, readGalleryImage, readTemplateImage, refreshTemplates, registerAgentImageTools, registerEditImageCommand, removeGallery, updateGalleryTags };