@dickpy/dsh-imagegen 1.2.1 → 1.2.3

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
@@ -6,7 +6,6 @@ import { homedir } from "node:os";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawn } from "node:child_process";
9
- import { createUserMessage } from "@deepseek-ai/dsh-llm/message";
10
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
10
  //#region src/protocol.ts
12
11
  /**
@@ -17,7 +16,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
17
16
  /** Settings namespace this plugin owns (host settings seam + bridge). */
18
17
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
19
18
  /** Published package version shared by the host updater and the client UI. */
20
- const PLUGIN_VERSION = "1.2.1";
19
+ const PLUGIN_VERSION = "1.2.3";
21
20
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
22
21
  const SETTINGS_API = {
23
22
  describe: "/api/dsh-imagegen/settings/describe",
@@ -141,7 +140,14 @@ async function enhancePrompt(config, prompt) {
141
140
  * `/models` exposes candidates only: the configured list is the explicit
142
141
  * allow-list because OpenAI-compatible gateways rarely advertise modalities.
143
142
  */
144
- const DEFAULT_IMAGE_MODELS = ["gpt-image-2", "grok-imagine-image"];
143
+ const DEFAULT_IMAGE_MODELS = [
144
+ "gpt-image-2",
145
+ "grok-imagine-image",
146
+ "nanobanana2",
147
+ "nanobanana2-lite",
148
+ "nanobanana-pro",
149
+ "seedream-5.0-pro"
150
+ ];
145
151
  /** Normalize user-entered model identifiers and retain a usable legacy default. */
146
152
  function normalizeImageModels(value) {
147
153
  const candidates = Array.isArray(value) ? value : [];
@@ -184,6 +190,24 @@ const DALLE3_SIZES = /* @__PURE__ */ new Set([
184
190
  function isGrokImagine(model) {
185
191
  return /^grok-imagine(?:-|$)/.test(model);
186
192
  }
193
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
194
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
195
+ * gateways expose). OpenAI-compatible gateways serve these with their own
196
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
197
+ * passthrough. */
198
+ 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";
201
+ }
202
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
203
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
204
+ * 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). */
208
+ function isSeedream(model) {
209
+ return /^(?:doubao-)?seedream/i.test(model);
210
+ }
187
211
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
188
212
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
189
213
  const OPENAI_SIZE_BY_RATIO = {
@@ -274,6 +298,18 @@ function effectiveParams(request) {
274
298
  ...request.quality !== "" && request.quality !== "auto" ? { resolution: request.quality === "4k" ? "2k" : request.quality } : {},
275
299
  response_format: "b64_json"
276
300
  };
301
+ if (isNanoBanana(model)) return {
302
+ model,
303
+ ...request.size !== "" && request.size !== "auto" ? { aspect_ratio: request.size } : {},
304
+ ...request.quality !== "" && request.quality !== "auto" ? { image_size: request.quality.toUpperCase() } : {},
305
+ response_format: "b64_json"
306
+ };
307
+ if (isSeedream(model)) return {
308
+ 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"
312
+ };
277
313
  return {
278
314
  model,
279
315
  ...request.size !== "" && request.size !== "auto" && OPENAI_SIZE_BY_RATIO[request.size] !== void 0 ? { size: OPENAI_SIZE_BY_RATIO[request.size] } : {},
@@ -359,6 +395,24 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
359
395
  ...params.aspect_ratio !== void 0 ? { aspect_ratio: params.aspect_ratio } : {},
360
396
  response_format: "b64_json"
361
397
  });
398
+ } else if (isNanoBanana(params.model)) {
399
+ const form = new FormData();
400
+ form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
401
+ form.append("prompt", request.prompt);
402
+ form.append("model", params.model);
403
+ if (params.aspect_ratio !== void 0) form.append("aspect_ratio", params.aspect_ratio);
404
+ if (params.image_size !== void 0) form.append("image_size", params.image_size);
405
+ body = form;
406
+ } else if (isSeedream(params.model)) {
407
+ headers["content-type"] = "application/json";
408
+ body = JSON.stringify({
409
+ model: params.model,
410
+ prompt: request.prompt,
411
+ image: [request.image],
412
+ ...params.size !== void 0 ? { size: params.size } : {},
413
+ ...params.resolution !== void 0 ? { resolution: params.resolution } : {},
414
+ response_format: "b64_json"
415
+ });
362
416
  } else {
363
417
  const form = new FormData();
364
418
  form.append("image", new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf$2(parsed.mime)}`);
@@ -379,7 +433,8 @@ async function requestOneImage(baseUrl, upstream, request, params, signal) {
379
433
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS);
380
434
  let response;
381
435
  try {
382
- response = await fetch(`${baseUrl}/images/${request.mode === "edit" ? "edits" : "generations"}`, {
436
+ const endpoint = request.mode === "edit" && !isSeedream(params.model) ? "/images/edits" : "/images/generations";
437
+ response = await fetch(`${baseUrl}${endpoint}`, {
383
438
  method: "POST",
384
439
  headers,
385
440
  body,
@@ -2297,6 +2352,8 @@ const taskResultSchema = {
2297
2352
  }
2298
2353
  }
2299
2354
  };
2355
+ /** Agent calls stay pending until the provider and history write settle. */
2356
+ const AGENT_GENERATION_TIMEOUT_MS = 3e5;
2300
2357
  function acceptedMediaType(value) {
2301
2358
  return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
2302
2359
  }
@@ -2337,7 +2394,6 @@ function renderTaskResult(value) {
2337
2394
  /** Register the global Agent tools and unregister them with the plugin lifecycle. */
2338
2395
  function registerAgentImageTools(ctx, runtime, resolve) {
2339
2396
  const attachmentRefs = /* @__PURE__ */ new Map();
2340
- const taskSubscriptions = /* @__PURE__ */ new Set();
2341
2397
  const ensureConfigured = () => {
2342
2398
  const config = resolve();
2343
2399
  if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.", "plugin-disabled");
@@ -2366,7 +2422,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2366
2422
  return {
2367
2423
  task_id: task.id,
2368
2424
  status: task.status,
2369
- message: task.status === "completed" ? "Generation completed. The images are attached below and can be reused as source_image in edit_image." : task.status === "failed" ? "Generation failed." : task.status === "cancelled" ? "Generation was cancelled." : "Generation is still running. Completion will be delivered to the conversation automatically with image attachments.",
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.",
2370
2426
  ...task.error === void 0 ? {} : { error: task.error },
2371
2427
  images
2372
2428
  };
@@ -2376,39 +2432,66 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2376
2432
  if (task === void 0) throw new ImageGenError(`Image generation task ${id} was not found.`, "task-not-found");
2377
2433
  return task;
2378
2434
  };
2379
- const notifyCompletion = async (agent, task) => {
2380
- const result = await taskResult(task);
2381
- const completed = task.status === "completed";
2382
- const text = completed ? `图像生成任务已完成(${task.id})。图片已附在这条消息中,可以直接查看、下载或作为后续图生图的参考。` : task.status === "cancelled" ? `图像生成任务已取消(${task.id})。` : `图像生成任务失败(${task.id}):${task.error ?? "未知错误"}`;
2383
- agent.send(createUserMessage({
2384
- content: [{
2385
- type: "text",
2386
- text
2387
- }, ...completed ? result.images.map((image) => ({
2388
- type: "image",
2389
- attachment: restoreRef(image)
2390
- })) : []],
2391
- source: { kind: "user" }
2392
- }), "next-turn", true);
2393
- };
2394
- const watchTask = (task, agent) => {
2395
- if (agent === void 0) return;
2396
- let dispose;
2435
+ const waitForTask = (id, signal) => new Promise((resolveTask, rejectTask) => {
2436
+ let settled = false;
2437
+ let dispose = () => {};
2438
+ let timer;
2439
+ let abort = () => {};
2440
+ const cleanup = () => {
2441
+ dispose();
2442
+ if (timer !== void 0) clearTimeout(timer);
2443
+ signal?.removeEventListener("abort", abort);
2444
+ };
2445
+ const resolve = (task) => {
2446
+ if (settled) return;
2447
+ settled = true;
2448
+ cleanup();
2449
+ resolveTask(task);
2450
+ };
2451
+ const reject = (error) => {
2452
+ if (settled) return;
2453
+ settled = true;
2454
+ cleanup();
2455
+ rejectTask(error);
2456
+ };
2457
+ abort = () => {
2458
+ if (settled) return;
2459
+ const reason = signal?.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Image generation was cancelled.");
2460
+ settled = true;
2461
+ cleanup();
2462
+ runtime.queue.cancel(id);
2463
+ rejectTask(reason);
2464
+ };
2397
2465
  const onChange = (updated) => {
2398
- if (updated.id !== task.id || !isFinalTask(updated)) return;
2399
- dispose?.();
2400
- if (dispose !== void 0) taskSubscriptions.delete(dispose);
2401
- notifyCompletion(agent, updated).catch(() => {});
2466
+ if (updated.id === id && isFinalTask(updated)) resolve(updated);
2402
2467
  };
2468
+ if (signal?.aborted === true) {
2469
+ abort();
2470
+ return;
2471
+ }
2403
2472
  dispose = runtime.queue.subscribe(onChange);
2404
- taskSubscriptions.add(dispose);
2405
- const current = findTask(task.id);
2406
- if (isFinalTask(current)) onChange(current);
2407
- };
2473
+ signal?.addEventListener("abort", abort, { once: true });
2474
+ timer = setTimeout(() => {
2475
+ if (settled) return;
2476
+ const timeout = new ImageGenError(`Image generation task ${id} timed out after ${AGENT_GENERATION_TIMEOUT_MS / 1e3} seconds.`, "generation-timeout");
2477
+ settled = true;
2478
+ cleanup();
2479
+ runtime.queue.cancel(id);
2480
+ rejectTask(timeout);
2481
+ }, AGENT_GENERATION_TIMEOUT_MS);
2482
+ let current;
2483
+ try {
2484
+ current = findTask(id);
2485
+ } catch (error) {
2486
+ reject(error);
2487
+ return;
2488
+ }
2489
+ if (isFinalTask(current)) resolve(current);
2490
+ });
2408
2491
  const disposers = [
2409
2492
  ctx.tools.register(defineTool({
2410
2493
  name: "generate_image",
2411
- description: "Queue a text-to-image generation request. This returns immediately with a task id. When it finishes, the conversation automatically receives a visible image-attachment notification; do not repeatedly poll. Only use models configured for this plugin; omit model to use the first configured image model. Use get_image_generation_task only for an explicit status check or recovery.",
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.",
2412
2495
  parameters: {
2413
2496
  prompt: {
2414
2497
  type: "string",
@@ -2434,6 +2517,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2434
2517
  detail: {
2435
2518
  type: "string",
2436
2519
  description: "Optional provider detail value, for example standard or high."
2520
+ },
2521
+ wait_for_completion: {
2522
+ type: "boolean",
2523
+ description: "Wait for images and return them in this tool result. Defaults to true; set false for background mode."
2437
2524
  }
2438
2525
  },
2439
2526
  output: {
@@ -2451,13 +2538,12 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2451
2538
  n: Math.min(4, Math.max(1, args.count ?? 1)),
2452
2539
  detail: args.detail ?? ""
2453
2540
  });
2454
- watchTask(task, exec.agent);
2455
- return taskResult(task);
2541
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal));
2456
2542
  }
2457
2543
  })),
2458
2544
  ctx.tools.register(defineTool({
2459
2545
  name: "edit_image",
2460
- description: "Queue an image-to-image edit. source_image must be an image reference returned by a completed generation notification or get_image_generation_task; pass that entire object unchanged. Only configured image models are allowed; omit model to use the first configured model. Completion is automatically delivered to the conversation with visible image attachments; do not repeatedly poll.",
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.",
2461
2547
  parameters: {
2462
2548
  prompt: {
2463
2549
  type: "string",
@@ -2488,6 +2574,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2488
2574
  detail: {
2489
2575
  type: "string",
2490
2576
  description: "Optional provider detail value."
2577
+ },
2578
+ wait_for_completion: {
2579
+ type: "boolean",
2580
+ description: "Wait for images and return them in this tool result. Defaults to true; set false for background mode."
2491
2581
  }
2492
2582
  },
2493
2583
  output: {
@@ -2508,13 +2598,12 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2508
2598
  image: imageDataUrl(reference),
2509
2599
  ...reference.ref.name === void 0 ? {} : { refName: reference.ref.name }
2510
2600
  });
2511
- watchTask(task, exec.agent);
2512
- return taskResult(task);
2601
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal));
2513
2602
  }
2514
2603
  })),
2515
2604
  ctx.tools.register(defineTool({
2516
2605
  name: "get_image_generation_task",
2517
- description: "Optionally check an image-generation task status. Completed tasks return image references for edit_image, but the conversation already receives a visible completion notification automatically; do not poll repeatedly.",
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.",
2518
2607
  parameters: { task_id: {
2519
2608
  type: "string",
2520
2609
  required: true,
@@ -2550,8 +2639,6 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2550
2639
  }))
2551
2640
  ];
2552
2641
  return () => {
2553
- for (const dispose of taskSubscriptions) dispose();
2554
- taskSubscriptions.clear();
2555
2642
  for (const dispose of disposers) dispose();
2556
2643
  };
2557
2644
  }
@@ -2592,7 +2679,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
2592
2679
  /** Order of the announcement section within the tool-guidance band. */
2593
2680
  const SECTION_ORDER = 150;
2594
2681
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
2595
- const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API,模型由用户在「设置 → 插件 → AI 生图」中检测或手动配置的生图模型列表决定;支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送)。API 地址与密钥在 GUI 设置中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用已配置的生图模型;模型出现在 /models 中不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;任务后台异步执行,完成后插件会自动唤醒原对话,并以可直接查看和复用的图片附件回贴结果,因此不要反复轮询。仅在用户明确要求进度或需要恢复任务时,才使用 `get_image_generation_task` 查询状态。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
2596
2683
  /** Add the live allow-list so an Agent can honor a user's model choice. */
2597
2684
  function guidanceFor(imageModels) {
2598
2685
  return `${IMAGEGEN_GUIDANCE} 当前允许调用的生图模型:${imageModels.join("、")}。用户指定其中某个模型时,工具参数 model 必须使用该精确名称;未指定时使用列表中的第一个。`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
- "description": "AI 鐢熷浘 (image generation) plugin for the dsh web GUI: text-to-image and image-to-image through a configurable OpenAI-compatible endpoint (gpt-image-2 / grok-imagine-image / dall-e-3, with native xAI Grok Imagine request shaping), with a settings card for api_url / api_key and a sidebar entry opening a split-pane generation studio.",
4
- "version": "1.2.1",
3
+ "description": "AI 鐢熷浘 (image generation) plugin for the dsh web GUI: text-to-image and image-to-image through a configurable OpenAI-compatible endpoint (gpt-image-2 / grok-imagine-image / nanobanana series / seedream-5.0-pro / dall-e-3, with native xAI Grok Imagine, Google Nano Banana and ByteDance Seedream request shaping), with a settings card for api_url / api_key and a sidebar entry opening a split-pane generation studio.",
4
+ "version": "1.2.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -17,7 +17,8 @@
17
17
  "inject": [
18
18
  "@deepseek-ai/dsh-client-runtime",
19
19
  "@deepseek-ai/dsh-client-connection",
20
- "@deepseek-ai/dsh-client-ui-settings"
20
+ "@deepseek-ai/dsh-client-ui-settings",
21
+ "@deepseek-ai/dsh-client-ui-tool"
21
22
  ],
22
23
  "platform": "web"
23
24
  }
@@ -1,7 +1,6 @@
1
1
  /** Agent-facing image-generation tools backed by the shared host queue. */
2
2
 
3
3
  import type { Context } from '@deepseek-ai/cordis'
4
- import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
5
  import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
7
6
  import type {} from '@deepseek-ai/dsh-attachment'
@@ -61,6 +60,9 @@ const taskResultSchema = {
61
60
  },
62
61
  } as const
63
62
 
63
+ /** Agent calls stay pending until the provider and history write settle. */
64
+ const AGENT_GENERATION_TIMEOUT_MS = 300_000
65
+
64
66
  function acceptedMediaType(value: string): value is ImageMediaType {
65
67
  return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif'
66
68
  }
@@ -106,7 +108,6 @@ function renderTaskResult(value: AgentTaskResult): Array<{ type: 'text'; text: s
106
108
  /** Register the global Agent tools and unregister them with the plugin lifecycle. */
107
109
  export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRuntime, resolve: () => AgentImageToolConfig): () => void {
108
110
  const attachmentRefs = new Map<string, Promise<AgentImageRef[]>>()
109
- const taskSubscriptions = new Set<() => void>()
110
111
  const ensureConfigured = (): void => {
111
112
  const config = resolve()
112
113
  if (!config.enabled) throw new ImageGenError('AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.', 'plugin-disabled')
@@ -144,7 +145,7 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
144
145
  ? 'Generation failed.'
145
146
  : task.status === 'cancelled'
146
147
  ? 'Generation was cancelled.'
147
- : 'Generation is still running. Completion will be delivered to the conversation automatically with image attachments.',
148
+ : 'Generation is still running. Query the task again when you need its current status.',
148
149
  ...task.error === undefined ? {} : { error: task.error },
149
150
  images,
150
151
  }
@@ -154,42 +155,70 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
154
155
  if (task === undefined) throw new ImageGenError(`Image generation task ${id} was not found.`, 'task-not-found')
155
156
  return task
156
157
  }
157
- const notifyCompletion = async (agent: { send: (message: ReturnType<typeof createUserMessage>, target: 'next-turn', wakeup: true) => void }, task: GenerationTask): Promise<void> => {
158
- const result = await taskResult(task)
159
- const completed = task.status === 'completed'
160
- const text = completed
161
- ? `图像生成任务已完成(${task.id})。图片已附在这条消息中,可以直接查看、下载或作为后续图生图的参考。`
162
- : task.status === 'cancelled'
163
- ? `图像生成任务已取消(${task.id})。`
164
- : `图像生成任务失败(${task.id}):${task.error ?? '未知错误'}`
165
- agent.send(createUserMessage({
166
- content: [
167
- { type: 'text', text },
168
- ...completed ? result.images.map(image => ({ type: 'image' as const, attachment: restoreRef(image) })) : [],
169
- ],
170
- // The stock conversation's plugin-context row renders text only; a
171
- // user-role message is the supported path that renders image attachments.
172
- source: { kind: 'user' },
173
- }), 'next-turn', true)
174
- }
175
- const watchTask = (task: GenerationTask, agent: { send: (message: ReturnType<typeof createUserMessage>, target: 'next-turn', wakeup: true) => void } | undefined): void => {
176
- if (agent === undefined) return
177
- let dispose: (() => void) | undefined
158
+ const waitForTask = (id: string, signal: AbortSignal | undefined): Promise<GenerationTask> => new Promise((resolveTask, rejectTask) => {
159
+ let settled = false
160
+ let dispose = (): void => {}
161
+ let timer: ReturnType<typeof setTimeout> | undefined
162
+ let abort = (): void => {}
163
+
164
+ const cleanup = (): void => {
165
+ dispose()
166
+ if (timer !== undefined) clearTimeout(timer)
167
+ signal?.removeEventListener('abort', abort)
168
+ }
169
+ const resolve = (task: GenerationTask): void => {
170
+ if (settled) return
171
+ settled = true
172
+ cleanup()
173
+ resolveTask(task)
174
+ }
175
+ const reject = (error: unknown): void => {
176
+ if (settled) return
177
+ settled = true
178
+ cleanup()
179
+ rejectTask(error)
180
+ }
181
+ abort = (): void => {
182
+ if (settled) return
183
+ const reason = signal?.reason instanceof Error ? signal.reason : new Error('Image generation was cancelled.')
184
+ // Remove the listener before publishing cancellation so the queue event
185
+ // cannot turn an execution abort into a successful cancelled result.
186
+ settled = true
187
+ cleanup()
188
+ runtime.queue.cancel(id)
189
+ rejectTask(reason)
190
+ }
178
191
  const onChange = (updated: GenerationTask): void => {
179
- if (updated.id !== task.id || !isFinalTask(updated)) return
180
- dispose?.()
181
- if (dispose !== undefined) taskSubscriptions.delete(dispose)
182
- void notifyCompletion(agent, updated).catch(() => {})
192
+ if (updated.id === id && isFinalTask(updated)) resolve(updated)
193
+ }
194
+
195
+ if (signal?.aborted === true) {
196
+ abort()
197
+ return
183
198
  }
184
199
  dispose = runtime.queue.subscribe(onChange)
185
- taskSubscriptions.add(dispose)
186
- const current = findTask(task.id)
187
- if (isFinalTask(current)) onChange(current)
188
- }
200
+ signal?.addEventListener('abort', abort, { once: true })
201
+ timer = setTimeout(() => {
202
+ if (settled) return
203
+ const timeout = new ImageGenError(`Image generation task ${id} timed out after ${AGENT_GENERATION_TIMEOUT_MS / 1000} seconds.`, 'generation-timeout')
204
+ settled = true
205
+ cleanup()
206
+ runtime.queue.cancel(id)
207
+ rejectTask(timeout)
208
+ }, AGENT_GENERATION_TIMEOUT_MS)
209
+ let current: GenerationTask
210
+ try {
211
+ current = findTask(id)
212
+ } catch (error) {
213
+ reject(error)
214
+ return
215
+ }
216
+ if (isFinalTask(current)) resolve(current)
217
+ })
189
218
  const disposers = [
190
219
  ctx.tools.register(defineTool({
191
220
  name: 'generate_image',
192
- description: 'Queue a text-to-image generation request. This returns immediately with a task id. When it finishes, the conversation automatically receives a visible image-attachment notification; do not repeatedly poll. Only use models configured for this plugin; omit model to use the first configured image model. Use get_image_generation_task only for an explicit status check or recovery.',
221
+ 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.',
193
222
  parameters: {
194
223
  prompt: { type: 'string', required: true, description: 'Detailed image-generation prompt.' },
195
224
  model: { type: 'string', description: 'One of the configured image models. Defaults to the first configured model.' },
@@ -197,6 +226,7 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
197
226
  quality: { type: 'string', description: 'auto, 1k, 2k, or 4k.' },
198
227
  count: { type: 'integer', description: 'Number of images, 1 to 4. Defaults to 1.' },
199
228
  detail: { type: 'string', description: 'Optional provider detail value, for example standard or high.' },
229
+ wait_for_completion: { type: 'boolean', description: 'Wait for images and return them in this tool result. Defaults to true; set false for background mode.' },
200
230
  },
201
231
  output: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
202
232
  async execute(args, exec) {
@@ -210,13 +240,12 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
210
240
  n: Math.min(4, Math.max(1, args.count ?? 1)),
211
241
  detail: args.detail ?? '',
212
242
  })
213
- watchTask(task, exec.agent)
214
- return taskResult(task)
243
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal))
215
244
  },
216
245
  })),
217
246
  ctx.tools.register(defineTool({
218
247
  name: 'edit_image',
219
- description: 'Queue an image-to-image edit. source_image must be an image reference returned by a completed generation notification or get_image_generation_task; pass that entire object unchanged. Only configured image models are allowed; omit model to use the first configured model. Completion is automatically delivered to the conversation with visible image attachments; do not repeatedly poll.',
248
+ 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.',
220
249
  parameters: {
221
250
  prompt: { type: 'string', required: true, description: 'How to transform the source image.' },
222
251
  source_image: { ...imageRefSchema, required: true, description: 'Image reference returned by get_image_generation_task.' },
@@ -225,6 +254,7 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
225
254
  quality: { type: 'string', description: 'auto, 1k, 2k, or 4k.' },
226
255
  count: { type: 'integer', description: 'Number of images, 1 to 4. Defaults to 1.' },
227
256
  detail: { type: 'string', description: 'Optional provider detail value.' },
257
+ wait_for_completion: { type: 'boolean', description: 'Wait for images and return them in this tool result. Defaults to true; set false for background mode.' },
228
258
  },
229
259
  output: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
230
260
  async execute(args, exec) {
@@ -241,13 +271,12 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
241
271
  image: imageDataUrl(reference),
242
272
  ...reference.ref.name === undefined ? {} : { refName: reference.ref.name },
243
273
  })
244
- watchTask(task, exec.agent)
245
- return taskResult(task)
274
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal))
246
275
  },
247
276
  })),
248
277
  ctx.tools.register(defineTool({
249
278
  name: 'get_image_generation_task',
250
- description: 'Optionally check an image-generation task status. Completed tasks return image references for edit_image, but the conversation already receives a visible completion notification automatically; do not poll repeatedly.',
279
+ 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.',
251
280
  parameters: { task_id: { type: 'string', required: true, description: 'Task id returned by generate_image or edit_image.' } },
252
281
  output: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
253
282
  async execute(args) {
@@ -269,8 +298,6 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
269
298
  })),
270
299
  ]
271
300
  return () => {
272
- for (const dispose of taskSubscriptions) dispose()
273
- taskSubscriptions.clear()
274
301
  for (const dispose of disposers) dispose()
275
302
  }
276
303
  }
@@ -1127,7 +1127,7 @@ export function ImageGenPanel(props: {
1127
1127
  <span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
1128
1128
  </button>
1129
1129
  <div className={css.galleryCardFooter}>
1130
- <span className={css.galleryAvatar}>{entry.model.startsWith('grok') ? 'G' : 'D'}</span>
1130
+ <span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
1131
1131
  <span className={css.galleryCardInfo}>
1132
1132
  <strong>{entry.prompt || tt('gallery.untitled')}</strong>
1133
1133
  <small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
@@ -0,0 +1,73 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 7px;
5
+ margin: 4px 0;
6
+ padding: 8px 10px 10px;
7
+ border: 1px solid var(--dsw-alias-border-l1);
8
+ border-radius: 8px;
9
+ background: var(--dsw-alias-bg-layer-1);
10
+ }
11
+
12
+ .header {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 7px;
16
+ min-height: 20px;
17
+ color: var(--dsw-alias-label-secondary);
18
+ font-size: 12px;
19
+ }
20
+
21
+ .icon {
22
+ color: var(--dsw-alias-brand-primary);
23
+ font-size: 15px;
24
+ line-height: 1;
25
+ }
26
+
27
+ .status {
28
+ margin-left: auto;
29
+ color: var(--dsw-alias-label-tertiary);
30
+ font-size: 11px;
31
+ }
32
+
33
+ .message,
34
+ .loading,
35
+ .error {
36
+ margin: 0;
37
+ color: var(--dsw-alias-label-tertiary);
38
+ font-size: 12px;
39
+ line-height: 1.5;
40
+ overflow-wrap: anywhere;
41
+ }
42
+
43
+ .images {
44
+ display: flex;
45
+ flex-wrap: wrap;
46
+ gap: 8px;
47
+ }
48
+
49
+ .imageLink {
50
+ display: block;
51
+ max-width: min(280px, 100%);
52
+ overflow: hidden;
53
+ border: 1px solid var(--dsw-alias-border-l1);
54
+ border-radius: 6px;
55
+ background: var(--dsw-alias-bg-base);
56
+ }
57
+
58
+ .imageLink:hover {
59
+ border-color: var(--dsw-alias-brand-primary);
60
+ }
61
+
62
+ .image {
63
+ display: block;
64
+ width: auto;
65
+ max-width: 280px;
66
+ height: auto;
67
+ max-height: 220px;
68
+ object-fit: contain;
69
+ }
70
+
71
+ .error {
72
+ color: var(--dsw-alias-state-error-primary);
73
+ }