@dickpy/dsh-imagegen 1.1.0 → 1.2.1

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.
@@ -224,6 +224,25 @@
224
224
  outline: none;
225
225
  }
226
226
 
227
+ .textarea,
228
+ .textareaInvalid {
229
+ min-height: 70px;
230
+ resize: vertical;
231
+ padding: 8px 12px;
232
+ border-radius: 8px;
233
+ background: var(--dsw-alias-bg-layer-3);
234
+ font: inherit;
235
+ font-size: 13px;
236
+ line-height: 1.5;
237
+ color: var(--dsw-alias-label-primary);
238
+ outline: none;
239
+ }
240
+
241
+ .textarea { border: 1px solid var(--dsw-alias-border-l2); }
242
+ .textareaInvalid { border: 1px solid var(--dsw-alias-label-error); }
243
+ .textarea:focus-visible { border-color: var(--dsw-alias-brand-primary); }
244
+ .textarea:disabled { color: var(--dsw-alias-label-tertiary); cursor: default; }
245
+
227
246
  .hint,
228
247
  .invalid {
229
248
  margin: 0;
@@ -267,6 +286,134 @@
267
286
  line-height: 1.5;
268
287
  }
269
288
 
289
+ .modelSection {
290
+ padding: 2px 0 14px;
291
+ }
292
+
293
+ .sectionHeader {
294
+ display: flex;
295
+ align-items: flex-start;
296
+ justify-content: space-between;
297
+ gap: 12px;
298
+ }
299
+
300
+ .sectionHeader .sectionHint {
301
+ max-width: 430px;
302
+ }
303
+
304
+ .modelSummary {
305
+ display: flex;
306
+ flex-wrap: wrap;
307
+ align-items: center;
308
+ gap: 6px;
309
+ margin-top: 12px;
310
+ }
311
+
312
+ .modelChip {
313
+ display: inline-flex;
314
+ align-items: center;
315
+ max-width: 100%;
316
+ gap: 5px;
317
+ padding: 3px 5px 3px 8px;
318
+ border: 1px solid var(--dsw-alias-border-l2);
319
+ border-radius: 6px;
320
+ background: var(--dsw-alias-bg-layer-3);
321
+ color: var(--dsw-alias-label-primary);
322
+ font-family: var(--dsw-font-family-mono, monospace);
323
+ font-size: 12px;
324
+ line-height: 1.5;
325
+ }
326
+
327
+ .modelChip > span {
328
+ overflow: hidden;
329
+ text-overflow: ellipsis;
330
+ white-space: nowrap;
331
+ }
332
+
333
+ .modelChip button {
334
+ appearance: none;
335
+ border: 0;
336
+ border-radius: 4px;
337
+ width: 18px;
338
+ height: 18px;
339
+ padding: 0;
340
+ background: transparent;
341
+ color: var(--dsw-alias-label-tertiary);
342
+ font: inherit;
343
+ line-height: 18px;
344
+ cursor: pointer;
345
+ }
346
+
347
+ .modelChip button:hover:not(:disabled) { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-module-platform); }
348
+ .modelChip button:disabled { cursor: default; opacity: 0.45; }
349
+
350
+ .addModel {
351
+ appearance: none;
352
+ min-height: 28px;
353
+ border: 1px dashed var(--dsw-alias-border-l2);
354
+ border-radius: 6px;
355
+ padding: 0 9px;
356
+ background: transparent;
357
+ color: var(--dsw-alias-label-secondary);
358
+ font: inherit;
359
+ font-size: 12px;
360
+ cursor: pointer;
361
+ }
362
+
363
+ .addModel:hover:not(:disabled) { color: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
364
+ .addModel:disabled { opacity: 0.45; cursor: default; }
365
+
366
+ .manualModelRow {
367
+ display: flex;
368
+ gap: 8px;
369
+ margin-top: 10px;
370
+ }
371
+
372
+ .manualModelRow .input { min-width: 0; flex: 1; }
373
+
374
+ .disclosure {
375
+ width: 100%;
376
+ appearance: none;
377
+ display: flex;
378
+ align-items: center;
379
+ gap: 8px;
380
+ border: 0;
381
+ border-top: 1px solid var(--dsw-alias-border-l2);
382
+ padding: 13px 0;
383
+ background: none;
384
+ color: var(--dsw-alias-label-primary);
385
+ font: inherit;
386
+ font-size: 13px;
387
+ font-weight: 500;
388
+ text-align: left;
389
+ cursor: pointer;
390
+ }
391
+
392
+ .disclosure > span:nth-child(2) { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; }
393
+ .disclosure > span:last-child { color: var(--dsw-alias-label-tertiary); }
394
+ .disclosure:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
395
+
396
+ .optionalContent {
397
+ padding: 0 0 8px;
398
+ }
399
+
400
+ .inlineDisclosure {
401
+ appearance: none;
402
+ display: inline-flex;
403
+ align-items: center;
404
+ gap: 6px;
405
+ margin-top: 12px;
406
+ padding: 0;
407
+ border: 0;
408
+ background: none;
409
+ color: var(--dsw-alias-label-secondary);
410
+ font: inherit;
411
+ font-size: 12px;
412
+ cursor: pointer;
413
+ }
414
+
415
+ .inlineDisclosure:hover { color: var(--dsw-alias-label-primary); }
416
+
270
417
  .modelFetchRow {
271
418
  display: flex;
272
419
  gap: 8px;
@@ -289,6 +436,39 @@
289
436
  .modelFetch:disabled { opacity: 0.5; cursor: default; }
290
437
  .modelChoices { min-width: 0; flex: 1; padding: 0 8px; }
291
438
 
439
+ .modelCandidateList {
440
+ display: flex;
441
+ flex-wrap: wrap;
442
+ gap: 6px 10px;
443
+ margin-top: 10px;
444
+ }
445
+
446
+ .modelCandidateLabel {
447
+ width: 100%;
448
+ color: var(--dsw-alias-label-tertiary);
449
+ font-size: 12px;
450
+ }
451
+
452
+ .modelCandidate {
453
+ appearance: none;
454
+ display: inline-flex;
455
+ align-items: center;
456
+ gap: 5px;
457
+ min-width: 0;
458
+ color: var(--dsw-alias-label-secondary);
459
+ font-size: 12px;
460
+ line-height: 1.5;
461
+ cursor: pointer;
462
+ padding: 3px 6px;
463
+ border: 1px solid transparent;
464
+ border-radius: 5px;
465
+ background: var(--dsw-alias-bg-layer-3);
466
+ font: inherit;
467
+ }
468
+
469
+ .modelCandidate input { margin: 0; }
470
+ .modelCandidate[data-selected] { border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
471
+
292
472
  /* --- footer (mirror of the official card footer) ------------------------------ */
293
473
 
294
474
  .footer {
@@ -102,6 +102,18 @@ export function textField(field: string): FieldSpec {
102
102
  }
103
103
  }
104
104
 
105
+ /** A newline/comma-separated model list, persisted as a normalized string array. */
106
+ export function stringListField(field: string): FieldSpec {
107
+ return {
108
+ field,
109
+ format: value => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').join('\n') : '',
110
+ parse: (text) => {
111
+ const values = [...new Set(text.split(/[\n,]/).map(item => item.trim()).filter(Boolean))]
112
+ return values.length === 0 ? { kind: 'clear' } : { kind: 'set', value: values }
113
+ },
114
+ }
115
+ }
116
+
105
117
  /** A boolean field, edited through true/false draft text. */
106
118
  export function booleanField(field: string): FieldSpec {
107
119
  return {
@@ -20,8 +20,10 @@ import { SETTINGS_API } from '../protocol.ts'
20
20
  export interface ImageGenConfig {
21
21
  enabled?: boolean
22
22
  announceToAgent?: boolean
23
+ allowAgentImageGeneration?: boolean
23
24
  apiUrl?: string
24
25
  apiKey?: string
26
+ imageModels?: string[]
25
27
  promptApiUrl?: string
26
28
  promptApiKey?: string
27
29
  promptModel?: string
package/src/engine.ts CHANGED
@@ -69,6 +69,27 @@ const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
69
69
  '21:9': '20:9',
70
70
  }
71
71
 
72
+ /**
73
+ * One request-scoped timeout that is cleared as soon as its fetch settles.
74
+ * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
75
+ * task queue leaves an otherwise idle Node process holding every timeout.
76
+ */
77
+ function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
78
+ const controller = new AbortController()
79
+ const abortFromSource = () => { controller.abort(source?.reason) }
80
+ if (source?.aborted === true) abortFromSource()
81
+ else source?.addEventListener('abort', abortFromSource, { once: true })
82
+ const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
83
+ timeout.unref()
84
+ return {
85
+ signal: controller.signal,
86
+ dispose: () => {
87
+ clearTimeout(timeout)
88
+ source?.removeEventListener('abort', abortFromSource)
89
+ },
90
+ }
91
+ }
92
+
72
93
  /** Content-type extension hints for URL-fetched images. */
73
94
  function mimeOfExtension(path: string): string | undefined {
74
95
  const match = /\.([a-z0-9]+)$/i.exec(path)
@@ -181,16 +202,19 @@ async function normalizeItem(
181
202
  if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
182
203
  return { b64: parsed.base64, mime: parsed.mime, revisedPrompt }
183
204
  }
205
+ const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
184
206
  let response: Response
185
207
  try {
186
208
  response = await fetch(url, {
187
209
  headers: {
188
210
  ...upstream.apiKey === '' ? {} : { authorization: `Bearer ${upstream.apiKey}` },
189
211
  },
190
- signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
212
+ signal: budget.signal,
191
213
  })
192
214
  } catch (error) {
193
215
  throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
216
+ } finally {
217
+ budget.dispose()
194
218
  }
195
219
  if (!response.ok) {
196
220
  throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
@@ -259,13 +283,14 @@ async function requestOneImage(
259
283
  body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
260
284
  }
261
285
 
286
+ const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
262
287
  let response: Response
263
288
  try {
264
289
  response = await fetch(`${baseUrl}/images/${request.mode === 'edit' ? 'edits' : 'generations'}`, {
265
290
  method: 'POST',
266
291
  headers,
267
292
  body,
268
- signal: signal === undefined ? AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) : AbortSignal.any([signal, AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)]),
293
+ signal: budget.signal,
269
294
  })
270
295
  } catch (error) {
271
296
  const message = error instanceof Error ? error.message : String(error)
@@ -273,6 +298,8 @@ async function requestOneImage(
273
298
  throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
274
299
  }
275
300
  throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
301
+ } finally {
302
+ budget.dispose()
276
303
  }
277
304
 
278
305
  let payload: unknown
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared host-side generation runtime. Both the browser routes and Agent tools
3
+ * submit to this one queue so persisted history and cancellation semantics stay
4
+ * identical regardless of where a request originated.
5
+ */
6
+
7
+ import { randomUUID } from 'node:crypto'
8
+ import { generateImage, type UpstreamConfig } from './engine.ts'
9
+ import { appendHistory } from './history-store.ts'
10
+ import type { GenerateRequest, GenerateResult, HistoryEntry, HistoryEntryInput } from './protocol.ts'
11
+ import { GenerationTaskQueue } from './task-queue.ts'
12
+
13
+ export interface HistorySink {
14
+ append(entry: HistoryEntryInput): Promise<HistoryEntry[]>
15
+ }
16
+
17
+ export class ImageGenerationRuntime {
18
+ readonly queue: GenerationTaskQueue
19
+
20
+ constructor(
21
+ private readonly resolve: () => UpstreamConfig,
22
+ private readonly history: HistorySink = { append: appendHistory },
23
+ ) {
24
+ this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal))
25
+ }
26
+
27
+ async run(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResult> {
28
+ const result = await generateImage(this.resolve(), request, { signal })
29
+ try {
30
+ const history = await this.history.append({
31
+ id: randomUUID(),
32
+ createdAt: Date.now(),
33
+ mode: request.mode,
34
+ model: request.model,
35
+ prompt: request.prompt,
36
+ size: request.size,
37
+ quality: request.quality,
38
+ detail: request.detail,
39
+ n: request.n,
40
+ images: result.images,
41
+ ...request.refName === undefined ? {} : { refName: request.refName },
42
+ })
43
+ return { ...result, history }
44
+ } catch (error) {
45
+ return { ...result, historyError: error instanceof Error ? error.message : String(error) }
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Image-model configuration shared by the host, panel, and Agent tools.
3
+ * `/models` exposes candidates only: the configured list is the explicit
4
+ * allow-list because OpenAI-compatible gateways rarely advertise modalities.
5
+ */
6
+
7
+ export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image'] as const
8
+
9
+ /** Normalize user-entered model identifiers and retain a usable legacy default. */
10
+ export function normalizeImageModels(value: unknown): string[] {
11
+ const candidates = Array.isArray(value) ? value : []
12
+ const unique = new Set<string>()
13
+ for (const candidate of candidates) {
14
+ if (typeof candidate !== 'string') continue
15
+ const model = candidate.trim()
16
+ if (model !== '') unique.add(model)
17
+ }
18
+ return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS]
19
+ }
package/src/index.ts CHANGED
@@ -13,8 +13,13 @@ import z from 'schemastery'
13
13
  import type {} from '@deepseek-ai/dsh-host-webserver'
14
14
  // Type-only: pulls the systemPrompt Context merge (announcement section).
15
15
  import type {} from '@deepseek-ai/dsh-system-prompt'
16
+ import type {} from '@deepseek-ai/dsh-tools'
17
+ import type {} from '@deepseek-ai/dsh-attachment'
16
18
  import { IMAGEGEN_SETTINGS_NAMESPACE } from './protocol.ts'
17
19
  import { makeRoutes, type SettingsSeam } from './routes.ts'
20
+ import { ImageGenerationRuntime } from './generation-runtime.ts'
21
+ import { registerAgentImageTools } from './agent-image-tools.ts'
22
+ import { DEFAULT_IMAGE_MODELS, normalizeImageModels } from './image-models.ts'
18
23
 
19
24
  /** Stable cordis plugin name. */
20
25
  export const name = 'imagegen'
@@ -26,6 +31,8 @@ export const inject = ['webServer', 'systemPrompt']
26
31
  // contract only requires name / inject / Config / apply.
27
32
  export { makeRoutes } from './routes.ts'
28
33
  export { generateImage, ImageGenError } from './engine.ts'
34
+ export { ImageGenerationRuntime } from './generation-runtime.ts'
35
+ export { registerAgentImageTools } from './agent-image-tools.ts'
29
36
  export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
30
37
  export { listTemplates, readTemplateImage, refreshTemplates, clearTemplateMemo } from './templates-store.ts'
31
38
  export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
@@ -39,10 +46,14 @@ export interface Config {
39
46
  enabled?: boolean
40
47
  /** Announce the plugin in every agent's system prompt. */
41
48
  announceToAgent?: boolean
49
+ /** Allow Agents to submit and retrieve image-generation tasks. */
50
+ allowAgentImageGeneration?: boolean
42
51
  /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
43
52
  apiUrl?: string
44
53
  /** Bearer API key (stored as a secret field on the settings seam). */
45
54
  apiKey?: string
55
+ /** Explicit allow-list of image models selected for this API endpoint. */
56
+ imageModels?: string[]
46
57
  /** Optional OpenAI-compatible chat endpoint for prompt enhancement. */
47
58
  promptApiUrl?: string
48
59
  /** Optional secret for the prompt enhancement endpoint. */
@@ -54,8 +65,10 @@ export interface Config {
54
65
  export const Config: z<Config> = z.object({
55
66
  enabled: z.boolean().default(true),
56
67
  announceToAgent: z.boolean().default(true),
68
+ allowAgentImageGeneration: z.boolean().default(true),
57
69
  apiUrl: z.string().default(''),
58
70
  apiKey: z.string().role('secret').default(''),
71
+ imageModels: z.array(z.string()).default([...DEFAULT_IMAGE_MODELS]),
59
72
  promptApiUrl: z.string().default(''),
60
73
  promptApiKey: z.string().role('secret').default(''),
61
74
  promptModel: z.string().default(''),
@@ -64,19 +77,27 @@ export const Config: z<Config> = z.object({
64
77
  /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
65
78
  const DEFAULT_ENABLED = true
66
79
  const DEFAULT_ANNOUNCE = true
80
+ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
67
81
 
68
82
  /** Order of the announcement section within the tool-guidance band. */
69
83
  const SECTION_ORDER = 150
70
84
 
71
85
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
72
- export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2 / grok-imagine-image),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok 模型按官方 JSON image_url 协议发送);API 地址与密钥在 GUI「设置 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载;可一键把满意的图片加入「画廊」(工作台顶部 文生图 / 图生图 / 画廊 三个标签页,画廊在右侧展示,收藏持久化在本地 ~/.dsh/dsh-imagegen/gallery/)。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条 gpt-image-2 提示词案例(含中文标题、分类、参考图,可搜索/按分类筛选),参考图经宿主代理按需缓存到本地;用户可一键把模板提示词填入提示词框再生成。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / grok-imagine-image / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
86
+ export 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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
87
+
88
+ /** Add the live allow-list so an Agent can honor a user's model choice. */
89
+ function guidanceFor(imageModels: string[]): string {
90
+ return `${IMAGEGEN_GUIDANCE} 当前允许调用的生图模型:${imageModels.join('、')}。用户指定其中某个模型时,工具参数 model 必须使用该精确名称;未指定时使用列表中的第一个。`
91
+ }
73
92
 
74
93
  /** Effective config (schema defaults applied). */
75
94
  interface EffectiveConfig {
76
95
  enabled: boolean
77
96
  announceToAgent: boolean
97
+ allowAgentImageGeneration: boolean
78
98
  apiUrl: string
79
99
  apiKey: string
100
+ imageModels: string[]
80
101
  promptApiUrl: string
81
102
  promptApiKey: string
82
103
  promptModel: string
@@ -96,14 +117,24 @@ export function apply(ctx: Context, config?: Config): void {
96
117
  return {
97
118
  enabled: value.enabled ?? DEFAULT_ENABLED,
98
119
  announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
120
+ allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
99
121
  apiUrl: value.apiUrl ?? '',
100
122
  apiKey: value.apiKey ?? '',
123
+ imageModels: normalizeImageModels(value.imageModels),
101
124
  promptApiUrl: value.promptApiUrl ?? '',
102
125
  promptApiKey: value.promptApiKey ?? '',
103
126
  promptModel: value.promptModel ?? '',
104
127
  }
105
128
  }
106
129
 
130
+ // Browser endpoints and Agent tools share the exact same serial queue. This
131
+ // keeps image persistence, cancellation, and retries coherent across both
132
+ // entry points while a tool call itself returns immediately with a task id.
133
+ const runtime = new ImageGenerationRuntime(() => {
134
+ const value = resolve()
135
+ return { apiUrl: value.apiUrl, apiKey: value.apiKey }
136
+ })
137
+
107
138
  // The route family mounts once, gated on the settings seam (the bridge
108
139
  // serves it; without the seam there is nothing to expose). Route handlers
109
140
  // read resolve() per request, so config edits apply live. The settings
@@ -127,6 +158,8 @@ export function apply(ctx: Context, config?: Config): void {
127
158
  model: value.promptModel,
128
159
  }
129
160
  },
161
+ resolveImageModels: () => resolve().imageModels,
162
+ runtime,
130
163
  })
131
164
  const disposers = routes.map(route => ctx.webServer.register(route))
132
165
  return () => { for (const dispose of disposers) dispose() }
@@ -135,6 +168,19 @@ export function apply(ctx: Context, config?: Config): void {
135
168
  )
136
169
  })
137
170
 
171
+ ctx.inject(['tools', 'attachments'], (tctx) => {
172
+ tctx.effect(() => registerAgentImageTools(tctx, runtime, () => {
173
+ const value = resolve()
174
+ return {
175
+ enabled: value.enabled,
176
+ allowAgentImageGeneration: value.allowAgentImageGeneration,
177
+ apiUrl: value.apiUrl,
178
+ apiKey: value.apiKey,
179
+ imageModels: value.imageModels,
180
+ }
181
+ }), 'dsh-imagegen: agent image tools')
182
+ })
183
+
138
184
  // System-prompt announcement (toggled by settings changes).
139
185
  let disposeSection: (() => void) | undefined
140
186
  const sync = (): void => {
@@ -147,7 +193,7 @@ export function apply(ctx: Context, config?: Config): void {
147
193
  disposeSection = ctx.systemPrompt.section({
148
194
  name: 'plugin:dsh-imagegen',
149
195
  order: SECTION_ORDER,
150
- text: IMAGEGEN_GUIDANCE,
196
+ text: guidanceFor(value.imageModels),
151
197
  })
152
198
  }
153
199
 
@@ -6,6 +6,12 @@ export interface PromptModelConfig {
6
6
  model: string
7
7
  }
8
8
 
9
+ /** Credentials shared by OpenAI-compatible `/models` discovery. */
10
+ export interface ModelListConfig {
11
+ apiUrl: string
12
+ apiKey: string
13
+ }
14
+
9
15
  function endpoint(base: string, suffix: string): string {
10
16
  return `${base.replace(/\/+$/, '')}${suffix}`
11
17
  }
@@ -28,17 +34,23 @@ async function responseJson(response: Response): Promise<Record<string, unknown>
28
34
  return body as Record<string, unknown>
29
35
  }
30
36
 
31
- /** List chat models exposed by an OpenAI-compatible endpoint. */
32
- export async function listPromptModels(config: PromptModelConfig): Promise<string[]> {
33
- if (config.apiUrl.trim() === '') throw new Error('prompt enhancement API URL is required')
37
+ /** List candidates exposed by an OpenAI-compatible endpoint. */
38
+ export async function listOpenAIModels(config: ModelListConfig): Promise<string[]> {
39
+ if (config.apiUrl.trim() === '') throw new Error('API URL is required')
34
40
  const response = await fetch(endpoint(config.apiUrl, '/models'), { headers: headers(config.apiKey) })
35
41
  const body = await responseJson(response)
36
42
  const data = Array.isArray(body.data) ? body.data : []
37
- return data
38
- .flatMap(item => item !== null && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' ? [(item as { id: string }).id] : [])
43
+ return [...new Set(data
44
+ .flatMap(item => item !== null && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' ? [(item as { id: string }).id.trim()] : [])
45
+ .filter(Boolean))]
39
46
  .sort((a, b) => a.localeCompare(b))
40
47
  }
41
48
 
49
+ /** List chat models exposed by an OpenAI-compatible endpoint. */
50
+ export async function listPromptModels(config: PromptModelConfig): Promise<string[]> {
51
+ return listOpenAIModels(config)
52
+ }
53
+
42
54
  /** Expand a concise image request into a production-ready image prompt. */
43
55
  export async function enhancePrompt(config: PromptModelConfig, prompt: string): Promise<string> {
44
56
  if (config.apiUrl.trim() === '' || config.model.trim() === '') throw new Error('prompt enhancement model is not configured')
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.1.0'
11
+ export const PLUGIN_VERSION = '1.2.1'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -25,6 +25,11 @@ export const PROMPT_ENHANCE_API = {
25
25
  enhance: '/api/dsh-imagegen/prompt-enhance',
26
26
  } as const
27
27
 
28
+ /** Host-mediated candidate discovery for the configured image API. */
29
+ export const IMAGE_MODEL_API = {
30
+ models: '/api/dsh-imagegen/image-models',
31
+ } as const
32
+
28
33
  /** Host-resident generation queue endpoints. */
29
34
  export const TASK_API = {
30
35
  submit: '/api/dsh-imagegen/tasks/submit',