@dickpy/dsh-imagegen 1.2.0 → 1.2.2

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.0";
19
+ const PLUGIN_VERSION = "1.2.2";
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",
@@ -2297,6 +2296,8 @@ const taskResultSchema = {
2297
2296
  }
2298
2297
  }
2299
2298
  };
2299
+ /** Agent calls stay pending until the provider and history write settle. */
2300
+ const AGENT_GENERATION_TIMEOUT_MS = 3e5;
2300
2301
  function acceptedMediaType(value) {
2301
2302
  return value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif";
2302
2303
  }
@@ -2337,7 +2338,6 @@ function renderTaskResult(value) {
2337
2338
  /** Register the global Agent tools and unregister them with the plugin lifecycle. */
2338
2339
  function registerAgentImageTools(ctx, runtime, resolve) {
2339
2340
  const attachmentRefs = /* @__PURE__ */ new Map();
2340
- const taskSubscriptions = /* @__PURE__ */ new Set();
2341
2341
  const ensureConfigured = () => {
2342
2342
  const config = resolve();
2343
2343
  if (!config.enabled) throw new ImageGenError("AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.", "plugin-disabled");
@@ -2366,7 +2366,7 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2366
2366
  return {
2367
2367
  task_id: task.id,
2368
2368
  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.",
2369
+ message: task.status === "completed" ? "Generation completed. The images are attached below and can be reused as source_image in edit_image." : task.status === "failed" ? "Generation failed." : task.status === "cancelled" ? "Generation was cancelled." : "Generation is still running. Query the task again when you need its current status.",
2370
2370
  ...task.error === void 0 ? {} : { error: task.error },
2371
2371
  images
2372
2372
  };
@@ -2376,39 +2376,66 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2376
2376
  if (task === void 0) throw new ImageGenError(`Image generation task ${id} was not found.`, "task-not-found");
2377
2377
  return task;
2378
2378
  };
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;
2379
+ const waitForTask = (id, signal) => new Promise((resolveTask, rejectTask) => {
2380
+ let settled = false;
2381
+ let dispose = () => {};
2382
+ let timer;
2383
+ let abort = () => {};
2384
+ const cleanup = () => {
2385
+ dispose();
2386
+ if (timer !== void 0) clearTimeout(timer);
2387
+ signal?.removeEventListener("abort", abort);
2388
+ };
2389
+ const resolve = (task) => {
2390
+ if (settled) return;
2391
+ settled = true;
2392
+ cleanup();
2393
+ resolveTask(task);
2394
+ };
2395
+ const reject = (error) => {
2396
+ if (settled) return;
2397
+ settled = true;
2398
+ cleanup();
2399
+ rejectTask(error);
2400
+ };
2401
+ abort = () => {
2402
+ if (settled) return;
2403
+ const reason = signal?.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Image generation was cancelled.");
2404
+ settled = true;
2405
+ cleanup();
2406
+ runtime.queue.cancel(id);
2407
+ rejectTask(reason);
2408
+ };
2397
2409
  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(() => {});
2410
+ if (updated.id === id && isFinalTask(updated)) resolve(updated);
2402
2411
  };
2412
+ if (signal?.aborted === true) {
2413
+ abort();
2414
+ return;
2415
+ }
2403
2416
  dispose = runtime.queue.subscribe(onChange);
2404
- taskSubscriptions.add(dispose);
2405
- const current = findTask(task.id);
2406
- if (isFinalTask(current)) onChange(current);
2407
- };
2417
+ signal?.addEventListener("abort", abort, { once: true });
2418
+ timer = setTimeout(() => {
2419
+ if (settled) return;
2420
+ const timeout = new ImageGenError(`Image generation task ${id} timed out after ${AGENT_GENERATION_TIMEOUT_MS / 1e3} seconds.`, "generation-timeout");
2421
+ settled = true;
2422
+ cleanup();
2423
+ runtime.queue.cancel(id);
2424
+ rejectTask(timeout);
2425
+ }, AGENT_GENERATION_TIMEOUT_MS);
2426
+ let current;
2427
+ try {
2428
+ current = findTask(id);
2429
+ } catch (error) {
2430
+ reject(error);
2431
+ return;
2432
+ }
2433
+ if (isFinalTask(current)) resolve(current);
2434
+ });
2408
2435
  const disposers = [
2409
2436
  ctx.tools.register(defineTool({
2410
2437
  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.",
2438
+ description: "Generate an image. By default this tool call stays pending until the task reaches a final state, and completed images are returned directly as tool-result attachments without creating a user message. Set wait_for_completion to false for background mode, then use get_image_generation_task explicitly. Only use models configured for this plugin; omit model to use the first configured image model.",
2412
2439
  parameters: {
2413
2440
  prompt: {
2414
2441
  type: "string",
@@ -2434,6 +2461,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2434
2461
  detail: {
2435
2462
  type: "string",
2436
2463
  description: "Optional provider detail value, for example standard or high."
2464
+ },
2465
+ wait_for_completion: {
2466
+ type: "boolean",
2467
+ description: "Wait for images and return them in this tool result. Defaults to true; set false for background mode."
2437
2468
  }
2438
2469
  },
2439
2470
  output: {
@@ -2451,13 +2482,12 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2451
2482
  n: Math.min(4, Math.max(1, args.count ?? 1)),
2452
2483
  detail: args.detail ?? ""
2453
2484
  });
2454
- watchTask(task, exec.agent);
2455
- return taskResult(task);
2485
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal));
2456
2486
  }
2457
2487
  })),
2458
2488
  ctx.tools.register(defineTool({
2459
2489
  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.",
2490
+ description: "Edit an image. By default this tool call stays pending until the task reaches a final state, and completed images are returned directly as tool-result attachments without creating a user message. Set wait_for_completion to false for background mode, then use get_image_generation_task explicitly. source_image must be an image reference returned by a completed generation or get_image_generation_task; pass that entire object unchanged. Only configured image models are allowed; omit model to use the first configured model.",
2461
2491
  parameters: {
2462
2492
  prompt: {
2463
2493
  type: "string",
@@ -2488,6 +2518,10 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2488
2518
  detail: {
2489
2519
  type: "string",
2490
2520
  description: "Optional provider detail value."
2521
+ },
2522
+ wait_for_completion: {
2523
+ type: "boolean",
2524
+ description: "Wait for images and return them in this tool result. Defaults to true; set false for background mode."
2491
2525
  }
2492
2526
  },
2493
2527
  output: {
@@ -2508,13 +2542,12 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2508
2542
  image: imageDataUrl(reference),
2509
2543
  ...reference.ref.name === void 0 ? {} : { refName: reference.ref.name }
2510
2544
  });
2511
- watchTask(task, exec.agent);
2512
- return taskResult(task);
2545
+ return taskResult(args.wait_for_completion === false ? task : await waitForTask(task.id, exec.signal));
2513
2546
  }
2514
2547
  })),
2515
2548
  ctx.tools.register(defineTool({
2516
2549
  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.",
2550
+ description: "Check an image-generation task status. Completed tasks return image references and image attachments for edit_image. Generation tools normally wait for completion, so use this for explicit recovery or status checks.",
2518
2551
  parameters: { task_id: {
2519
2552
  type: "string",
2520
2553
  required: true,
@@ -2550,8 +2583,6 @@ function registerAgentImageTools(ctx, runtime, resolve) {
2550
2583
  }))
2551
2584
  ];
2552
2585
  return () => {
2553
- for (const dispose of taskSubscriptions) dispose();
2554
- taskSubscriptions.clear();
2555
2586
  for (const dispose of disposers) dispose();
2556
2587
  };
2557
2588
  }
@@ -2592,7 +2623,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true;
2592
2623
  /** Order of the announcement section within the tool-guidance band. */
2593
2624
  const SECTION_ORDER = 150;
2594
2625
  /** 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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
2626
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API,模型由用户在「设置 → 插件 → AI 生图」中检测或手动配置的生图模型列表决定;支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送)。API 地址与密钥在 GUI 设置中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用已配置的生图模型;模型出现在 /models 中不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片直接作为工具结果附件返回,不会额外伪造用户消息。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。";
2596
2627
  /** Add the live allow-list so an Agent can honor a user's model choice. */
2597
2628
  function guidanceFor(imageModels) {
2598
2629
  return `${IMAGEGEN_GUIDANCE} 当前允许调用的生图模型:${imageModels.join("、")}。用户指定其中某个模型时,工具参数 model 必须使用该精确名称;未指定时使用列表中的第一个。`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
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.0",
4
+ "version": "1.2.2",
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
  }
@@ -213,6 +213,7 @@ export function ImageGenPanel(props: {
213
213
  const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
214
214
  const [libraryOpen, setLibraryOpen] = useState(false)
215
215
  const [tasks, setTasks] = useState<GenerationTask[]>([])
216
+ const [taskTrayOpen, setTaskTrayOpen] = useState(false)
216
217
  const [comparison, setComparison] = useState<ComparisonSession | null>(null)
217
218
  const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
218
219
  const fileInput = useRef<HTMLInputElement>(null)
@@ -1152,16 +1153,25 @@ export function ImageGenPanel(props: {
1152
1153
  </div>
1153
1154
  ) : null}
1154
1155
  {tab !== 'gallery' && tasks.length > 0 ? (
1155
- <section className={css.taskTray} aria-label={tt('tasks.title')}>
1156
- <header className={css.taskTrayHeader}>{tt('tasks.title')} <span>{tasks.filter(task => task.status === 'queued' || task.status === 'running').length}</span></header>
1157
- {tasks.slice(0, 5).map(task => (
1158
- <div key={task.id} className={css.taskRow} data-status={task.status}>
1159
- <span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
1160
- <span className={css.taskPrompt}>{task.request.prompt}</span>
1161
- {(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
1162
- {task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
1163
- </div>
1164
- ))}
1156
+ <section className={css.taskTray} data-open={taskTrayOpen ? 'true' : 'false'} aria-label={tt('tasks.title')}>
1157
+ <header className={css.taskTrayHeader}>
1158
+ <button type="button" className={css.taskTrayToggle} aria-expanded={taskTrayOpen} onClick={() => { setTaskTrayOpen(open => !open) }}>
1159
+ <span>{tt('tasks.title')}</span>
1160
+ <span className={css.taskTrayCount}>{tasks.filter(task => task.status === 'queued' || task.status === 'running').length}</span>
1161
+ <span className={css.taskTrayChevron} aria-hidden="true">{taskTrayOpen ? '⌃' : '⌄'}</span>
1162
+ </button>
1163
+ {taskTrayOpen ? <button type="button" className={css.taskTrayClose} aria-label={tt('preview.close')} onClick={() => { setTaskTrayOpen(false) }}>×</button> : null}
1164
+ </header>
1165
+ <div className={css.taskRows}>
1166
+ {tasks.slice(0, 5).map(task => (
1167
+ <div key={task.id} className={css.taskRow} data-status={task.status}>
1168
+ <span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
1169
+ <span className={css.taskPrompt}>{task.request.prompt}</span>
1170
+ {(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
1171
+ {task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
1172
+ </div>
1173
+ ))}
1174
+ </div>
1165
1175
  </section>
1166
1176
  ) : null}
1167
1177
  {tab !== 'gallery' && comparison !== null ? (
@@ -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
+ }