@dickpy/dsh-imagegen 1.5.6 → 1.5.7

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/src/index.ts CHANGED
@@ -1,429 +1,430 @@
1
- /**
2
- * dsh-imagegen — host half. Mounts the plugin's settings section (channels
3
- * with per-channel model catalogs on the host settings seam), the
4
- * /api/dsh-imagegen route family (loopback-only settings bridge + presets /
5
- * usage / image-generation proxy that keeps every API key host-side), and a
6
- * system-prompt announcement. The browser half (./client) renders the sidebar
7
- * entry and the split-pane generation studio.
8
- */
9
-
10
- import type { Context } from '@deepseek-ai/cordis'
11
- import { readFileSync } from 'node:fs'
12
- import path from 'node:path'
13
- import { installSettingsSectionCompat, settingsNamespaceCompat } from './settings-compat.ts'
14
- import z from 'schemastery'// Type-only: pulls the webServer Context merge (route registration).
15
- import type {} from '@deepseek-ai/dsh-host-webserver'
16
- // Type-only: pulls the systemPrompt Context merge (announcement section).
17
- import type {} from '@deepseek-ai/dsh-system-prompt'
18
- // Type-only: pulls the human slash-command registry Context merge.
19
- import type {} from '@deepseek-ai/dsh-commands'
20
- import type {} from '@deepseek-ai/dsh-tools'
21
- import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
22
- import { IMAGEGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
23
- import { makeRoutes, type SettingsSeam } from './routes.ts'
24
- import { syncAllTemplates } from './templates-store.ts'
25
- import { setStorageSyncHandler, putObject, type StorageSyncConfig } from './storage-sync.ts'
26
-
27
- /** Content type for a saved image file name (object uploads). */
28
- function mimeOfPath(filePath: string): string {
29
- switch (path.extname(filePath).toLowerCase()) {
30
- case '.jpg':
31
- case '.jpeg': return 'image/jpeg'
32
- case '.webp': return 'image/webp'
33
- case '.gif': return 'image/gif'
34
- default: return 'image/png'
35
- }
36
- }
37
- import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
38
- import { registerAgentImageTools } from './agent-image-tools.ts'
39
- import { registerEditImageCommand } from './edit-image-command.ts'
40
- import { setImageDataRoot } from './image-storage-path.ts'
41
- import { presetById } from './presets.ts'
42
-
43
- /** Stable cordis plugin name. */
44
- export const name = 'imagegen'
45
-
46
- /** Services required before the surfaces can mount. */
47
- export const inject = ['webServer', 'systemPrompt', 'commands']
48
-
49
- // Internals re-exported for smoke tests and host-side debugging; the plugin
50
- // contract only requires name / inject / Config / apply.
51
- export { makeRoutes } from './routes.ts'
52
- export { generateImage, ImageGenError } from './engine.ts'
53
- export { ImageGenerationRuntime } from './generation-runtime.ts'
54
- export { registerAgentImageTools } from './agent-image-tools.ts'
55
- export { latestSessionImage, registerEditImageCommand } from './edit-image-command.ts'
56
- export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
57
- export { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates, syncAllTemplates, clearTemplateMemo } from './templates-store.ts'
58
- export { addTemplateFavorite, clearTemplateFavoritesMemo, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
59
- export { putObject, setStorageSyncHandler, testStorage, type StorageSyncConfig } from './storage-sync.ts'
60
- export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
61
-
62
- /** The branded settings namespace of this plugin (the card edits it). */
63
- export const ImageGenSettingsNamespace = settingsNamespaceCompat(IMAGEGEN_SETTINGS_NAMESPACE)
64
-
65
- /**
66
- * Plugin config, validated by the same-named schemastery schema.
67
- *
68
- * Channels own the endpoint + model catalog. The API key of each channel lives
69
- * in `channelSecrets` (a secret dict keyed by channel id) instead of inside the
70
- * channel objects dsh-settings redaction supports dict/array containers, but
71
- * path ops cannot reach inside arrays, so a whole-array write must never carry
72
- * secrets it would clobber.
73
- */
74
- export interface Config {
75
- /** Master switch for the plugin (routes, prompt section). */
76
- enabled?: boolean
77
- /** Announce the plugin in every agent's system prompt. */
78
- announceToAgent?: boolean
79
- /** Allow Agents to submit and retrieve image-generation tasks. */
80
- allowAgentImageGeneration?: boolean
81
- /** Configured channels (each: name, endpoint, model catalog). */
82
- channels?: ChannelConfig[]
83
- /** Per-channel API keys, keyed by channel id. */
84
- channelSecrets?: Record<string, string>
85
- /** Channel used when a request does not name one. */
86
- defaultChannelId?: string
87
- /** Optional OpenAI-compatible chat endpoint for prompt enhancement. */
88
- promptApiUrl?: string
89
- /** Optional secret for the prompt enhancement endpoint. */
90
- promptApiKey?: string
91
- /** Chat model used to expand short image prompts. */
92
- promptModel?: string
93
- /** Local root for generated/history/gallery/canvas images. Empty keeps the default under DSH_HOME. */
94
- localStoragePath?: string
95
- /** Sync saved images to an S3-compatible object store (COS / OSS / Qiniu S3 …). */
96
- storageEnabled?: boolean
97
- /** S3-compatible endpoint URL including the bucket (virtual-hosted or path style). */
98
- storageEndpoint?: string
99
- /** Provider region for SigV4 scope, e.g. ap-guangzhou / oss-cn-hangzhou. */
100
- storageRegion?: string
101
- /** Object key prefix, default 'dsh-imagegen'. */
102
- storagePrefix?: string
103
- /** S3 access key id. */
104
- storageAccessKey?: string
105
- /** S3 secret access key (stored redacted). */
106
- storageSecretKey?: string
107
- /** Upload gallery additions (default on when storage is enabled). */
108
- storageSyncGallery?: boolean
109
- /** Also upload history images. */
110
- storageSyncHistory?: boolean
111
- /* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
112
- /** Legacy base URL; synthesized into the default channel on upgrade. */
113
- apiUrl?: string
114
- /** Legacy secret; migrated into channelSecrets on upgrade. */
115
- apiKey?: string
116
- /** Legacy allow-list; migrated into the default channel's catalog. */
117
- imageModels?: string[]
118
- }
119
-
120
- export const Config: z<Config> = z.object({
121
- enabled: z.boolean().default(true),
122
- announceToAgent: z.boolean().default(true),
123
- allowAgentImageGeneration: z.boolean().default(true),
124
- channels: z.array(z.object({
125
- id: z.string(),
126
- preset: z.string().default(''),
127
- name: z.string().default(''),
128
- apiUrl: z.string().default(''),
129
- models: z.array(z.object({
130
- alias: z.string(),
131
- id: z.string(),
132
- })).default([]),
133
- })).default([]),
134
- channelSecrets: z.dict(z.string().role('secret')).default({}),
135
- defaultChannelId: z.string().default(''),
136
- promptApiUrl: z.string().default(''),
137
- promptApiKey: z.string().role('secret').default(''),
138
- promptModel: z.string().default(''),
139
- localStoragePath: z.string().default(''),
140
- storageEnabled: z.boolean().default(false),
141
- storageEndpoint: z.string().default(''),
142
- storageRegion: z.string().default(''),
143
- storagePrefix: z.string().default('dsh-imagegen'),
144
- storageAccessKey: z.string().default(''),
145
- storageSecretKey: z.string().role('secret').default(''),
146
- storageSyncGallery: z.boolean().default(true),
147
- storageSyncHistory: z.boolean().default(false),
148
- apiUrl: z.string().default(''),
149
- apiKey: z.string().role('secret').default(''),
150
- imageModels: z.array(z.string()).default([]),
151
- })
152
-
153
- /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
154
- const DEFAULT_ENABLED = true
155
- const DEFAULT_ANNOUNCE = true
156
- const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
157
-
158
- /** Order of the announcement section within the tool-guidance band. */
159
- const SECTION_ORDER = 150
160
-
161
- /** Model-facing announcement: plugin presence, capabilities, and limits. */
162
- export 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`,当前仅支持文生图;qwen-image 系列使用阿里云 DashScope 原生接口(api_url 填 https://dashscope.aliyuncs.com/api/v1,不支持 OpenAI 兼容模式,该渠道不可复用于提示词增强,尺寸自动映射为宽*高)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):多来源标签页(精选案例库 / 沧河案例库,后续可扩展),打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选、收藏(星标,宿主持久化)与复用;各来源列表独立刷新,宿主每 12 小时后台自动同步一次。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问对应来源站点(vibeui.top / gpt-image2.canghe.ai)。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
163
-
164
- /** Append the live channel × model table so an Agent can honor user choices. */
165
- function guidanceFor(channels: RuntimeChannel[], defaultChannelId: string): string {
166
- if (channels.length === 0) {
167
- return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`
168
- }
169
- const table = channels.map(channel => {
170
- const aliases = channel.models.map(model => model.alias).join('、')
171
- const mark = channel.id === defaultChannelId ? '(默认渠道)' : ''
172
- const key = channel.apiKey === '' ? '(未填密钥)' : ''
173
- const models = channel.models.length === 0 ? '未配置模型' : `可用模型:${aliases}`
174
- return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`
175
- }).join(';')
176
- return `${IMAGEGEN_GUIDANCE} 当前渠道与模型:${table}。用户指定模型名时取该模型所属渠道(多渠道同名用默认渠道);未指定模型时若仅一个可用模型可直接生成,若有多个应先询问用户选择「渠道 + 模型」。`
177
- }
178
-
179
- /** Normalize raw channel entries into the wire shape (schema-adjacent guard). */
180
- function normalizeChannels(value: unknown): ChannelConfig[] {
181
- if (!Array.isArray(value)) return []
182
- const out: ChannelConfig[] = []
183
- for (const item of value) {
184
- if (item === null || typeof item !== 'object') continue
185
- const raw = item as Record<string, unknown>
186
- const id = typeof raw.id === 'string' ? raw.id.trim() : ''
187
- if (id === '') continue
188
- const models: ModelMapping[] = []
189
- if (Array.isArray(raw.models)) {
190
- for (const entry of raw.models) {
191
- if (entry === null || typeof entry !== 'object') continue
192
- const record = entry as Record<string, unknown>
193
- const alias = typeof record.alias === 'string' ? record.alias.trim() : ''
194
- const upstream = typeof record.id === 'string' ? record.id.trim() : ''
195
- if (alias === '') continue
196
- models.push({ alias, id: upstream === '' ? alias : upstream })
197
- }
198
- }
199
- out.push({
200
- id,
201
- preset: typeof raw.preset === 'string' ? raw.preset : '',
202
- name: typeof raw.name === 'string' ? raw.name.trim() : '',
203
- apiUrl: typeof raw.apiUrl === 'string' ? raw.apiUrl.trim() : '',
204
- models,
205
- })
206
- }
207
- return out
208
- }
209
-
210
- /** Effective config (schema defaults applied + legacy migration). */
211
- export interface EffectiveConfig {
212
- enabled: boolean
213
- announceToAgent: boolean
214
- allowAgentImageGeneration: boolean
215
- channels: RuntimeChannel[]
216
- defaultChannelId: string
217
- promptApiUrl: string
218
- promptApiKey: string
219
- promptModel: string
220
- storage: StorageSyncConfig & { enabled: boolean; syncGallery: boolean; syncHistory: boolean }
221
- }
222
-
223
- /**
224
- * Mount the settings section, routes, and announcement.
225
- * @param ctx - host plugin context carrying webServer/systemPrompt.
226
- * @param config - resolved plugin config (schema defaults applied by the loader).
227
- */
228
- export function apply(ctx: Context, config?: Config): (() => void) | void {
229
- // The live source the surfaces read: the settings section once the settings
230
- // service is attached, the composition entry otherwise.
231
- let current: () => Config = () => config ?? {}
232
- const resolve = (): EffectiveConfig => {
233
- const value = current() ?? {}
234
- setImageDataRoot(value.localStoragePath)
235
- let channels = normalizeChannels(value.channels)
236
- // Settings scopes are deep-frozen by the host. Legacy migration adds the
237
- // synthesized default-channel secret, so always work on a detached copy.
238
- const secrets: Record<string, string> = { ...(value.channelSecrets ?? {}) }
239
- // Legacy single-endpoint migration: no channels yet synthesize the
240
- // default channel from the old flat fields so upgrades never break.
241
- if (channels.length === 0) {
242
- const legacyUrl = typeof value.apiUrl === 'string' ? value.apiUrl.trim() : ''
243
- const legacyModels: ModelMapping[] = Array.isArray(value.imageModels)
244
- ? value.imageModels
245
- .filter((model): model is string => typeof model === 'string' && model.trim() !== '')
246
- .map(model => ({ alias: model.trim(), id: model.trim() }))
247
- : []
248
- if (legacyUrl !== '' || legacyModels.length > 0) {
249
- channels = [{ id: 'default', preset: '', name: '默认渠道', apiUrl: legacyUrl, models: legacyModels }]
250
- const legacyKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : ''
251
- if (legacyKey !== '') secrets['default'] = legacyKey
252
- }
253
- }
254
- const named = channels.map(channel => ({
255
- ...channel,
256
- name: channel.name === '' ? (presetById(channel.preset)?.name ?? '未命名渠道') : channel.name,
257
- }))
258
- const defaultChannelId = typeof value.defaultChannelId === 'string' && named.some(channel => channel.id === value.defaultChannelId)
259
- ? value.defaultChannelId
260
- : named[0]?.id ?? ''
261
- return {
262
- enabled: value.enabled ?? DEFAULT_ENABLED,
263
- announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
264
- allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
265
- channels: named.map(channel => ({
266
- ...channel,
267
- apiKey: typeof secrets[channel.id] === 'string' ? secrets[channel.id] : '',
268
- })),
269
- defaultChannelId,
270
- promptApiUrl: typeof value.promptApiUrl === 'string' ? value.promptApiUrl.trim() : '',
271
- promptApiKey: typeof value.promptApiKey === 'string' ? value.promptApiKey.trim() : '',
272
- promptModel: typeof value.promptModel === 'string' ? value.promptModel.trim() : '',
273
- storage: {
274
- enabled: value.storageEnabled ?? false,
275
- endpoint: typeof value.storageEndpoint === 'string' ? value.storageEndpoint.trim() : '',
276
- region: typeof value.storageRegion === 'string' ? value.storageRegion.trim() : '',
277
- accessKey: typeof value.storageAccessKey === 'string' ? value.storageAccessKey.trim() : '',
278
- secretKey: typeof value.storageSecretKey === 'string' ? value.storageSecretKey.trim() : '',
279
- prefix: typeof value.storagePrefix === 'string' && value.storagePrefix.trim() !== '' ? value.storagePrefix.trim() : 'dsh-imagegen',
280
- syncGallery: value.storageSyncGallery ?? true,
281
- syncHistory: value.storageSyncHistory ?? false,
282
- },
283
- }
284
- }
285
-
286
- // Transient helper used by several mount points below: resolve the shared
287
- // channel view once per access; the runtime then picks per-request creds.
288
- const channelsView = (): ChannelsView => {
289
- const value = resolve()
290
- return { channels: value.channels, defaultChannelId: value.defaultChannelId }
291
- }
292
-
293
- // Object-storage sync: the image stores announce every file they write; the
294
- // handler resolves the live settings and uploads when enabled. Fire and
295
- // forget a sync failure never blocks the save path.
296
- setStorageSyncHandler((kind, filePath) => {
297
- const storage = resolve().storage
298
- if (!storage.enabled || !storage.endpoint.trim() || storage.secretKey.trim() === '') return
299
- if (kind === 'gallery' && !storage.syncGallery) return
300
- if (kind === 'history' && !storage.syncHistory) return
301
- const key = `${storage.prefix}/${kind === 'gallery' ? 'gallery' : 'images'}/${path.basename(filePath)}`
302
- const data = readFileSync(filePath)
303
- void putObject(storage, key, data, mimeOfPath(filePath)).catch(() => {
304
- // Best-effort sync: surfaced through the settings test, never fatal here.
305
- })
306
- })
307
-
308
- // Browser endpoints and Agent tools share the exact same serial queue. This
309
- // keeps image persistence, cancellation, and retries coherent across both
310
- // entry points; Agent tools wait for their task result by default and render
311
- // images in the tool result instead of injecting a synthetic user message.
312
- const runtime = new ImageGenerationRuntime(channelsView)
313
- const pendingConversationImages = new Map<string, ImageAttachmentRef>()
314
-
315
- // The route family mounts once, gated on the settings seam (the bridge
316
- // serves it; without the seam there is nothing to expose). Route handlers
317
- // read resolve() per request, so config edits apply live. The settings
318
- // bridge deliberately keeps serving while the plugin is disabled it is
319
- // how the user re-enables the plugin from the settings card.
320
- ctx.inject(['settings', 'attachments'], (sctx) => {
321
- const seam = sctx.get('settings') as unknown as SettingsSeam
322
- sctx.effect(
323
- () => {
324
- const routes = makeRoutes({
325
- settings: seam,
326
- resolve: () => {
327
- const value = resolve()
328
- const channel = value.channels.find(candidate => candidate.id === value.defaultChannelId) ?? value.channels[0]
329
- return { apiUrl: channel?.apiUrl ?? '', apiKey: channel?.apiKey ?? '' }
330
- },
331
- resolveChannels: channelsView,
332
- resolvePrompt: () => {
333
- const value = resolve()
334
- const channel = value.channels.find(candidate => candidate.id === value.defaultChannelId) ?? value.channels[0]
335
- return {
336
- apiUrl: value.promptApiUrl !== '' ? value.promptApiUrl : (channel?.apiUrl ?? ''),
337
- apiKey: value.promptApiKey !== '' ? value.promptApiKey : (channel?.apiKey ?? ''),
338
- model: value.promptModel,
339
- }
340
- },
341
- resolveImageModels: () => {
342
- const value = resolve()
343
- return [...new Set(value.channels.flatMap(channel => channel.models.map(model => model.alias)))]
344
- },
345
- attachments: sctx.attachments,
346
- pendingConversationImages,
347
- runtime,
348
- resolveStorage: () => resolve().storage,
349
- })
350
- const disposers = routes.map(route => ctx.webServer.register(route))
351
- // Background template sync: the upstream sources update on their own
352
- // schedule, so pull every one of them shortly after start and then
353
- // twice a day while the plugin stays enabled. Best-effort: failures
354
- // keep the last good snapshot (bundled or previously refreshed).
355
- const TEMPLATE_SYNC_INITIAL_DELAY_MS = 30_000
356
- const TEMPLATE_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000
357
- let syncTimer: NodeJS.Timeout | undefined
358
- const runSync = (): void => {
359
- if (!resolve().enabled) return
360
- void syncAllTemplates().catch(() => { /* keep the last good snapshot */ })
361
- }
362
- const startTimer = setTimeout(runSync, TEMPLATE_SYNC_INITIAL_DELAY_MS)
363
- syncTimer = setInterval(runSync, TEMPLATE_SYNC_INTERVAL_MS)
364
- syncTimer.unref?.()
365
- return () => {
366
- clearTimeout(startTimer)
367
- clearInterval(syncTimer)
368
- for (const dispose of disposers) dispose()
369
- }
370
- },
371
- 'dsh-imagegen: routes',
372
- )
373
- })
374
-
375
- ctx.inject(['tools', 'attachments', 'commands'], (tctx) => {
376
- tctx.effect(() => {
377
- const resolveAgentConfig = () => {
378
- const value = resolve()
379
- return {
380
- enabled: value.enabled,
381
- allowAgentImageGeneration: value.allowAgentImageGeneration,
382
- channels: value.channels,
383
- defaultChannelId: value.defaultChannelId,
384
- }
385
- }
386
- const disposeTools = registerAgentImageTools(tctx, runtime, resolveAgentConfig)
387
- const disposeCommand = registerEditImageCommand(tctx, runtime, resolveAgentConfig, {
388
- get: sessionId => pendingConversationImages.get(sessionId),
389
- consume: (sessionId, ref) => {
390
- if (pendingConversationImages.get(sessionId)?.attachmentId === ref.attachmentId) pendingConversationImages.delete(sessionId)
391
- },
392
- })
393
- return () => {
394
- disposeCommand()
395
- disposeTools()
396
- }
397
- }, 'dsh-imagegen: agent image tools and commands')
398
- })
399
-
400
- // System-prompt announcement (toggled by settings changes).
401
- let disposeSection: (() => void) | undefined
402
- const sync = (): void => {
403
- if (disposeSection !== undefined) {
404
- disposeSection()
405
- disposeSection = undefined
406
- }
407
- const value = resolve()
408
- if (!value.enabled || !value.announceToAgent) return
409
- disposeSection = ctx.systemPrompt.section({
410
- name: 'plugin:dsh-imagegen',
411
- order: SECTION_ORDER,
412
- text: guidanceFor(value.channels, value.defaultChannelId),
413
- })
414
- }
415
-
416
- installSettingsSectionCompat(ctx, ImageGenSettingsNamespace, Config, config ?? {}, {
417
- setSource: (source) => {
418
- current = source
419
- sync()
420
- },
421
- onChange: sync,
422
- })
423
-
424
- // Initial registration from the composition entry (covers deployments with
425
- // no settings service, whose installSettingsSection never fires its hooks).
426
- sync()
427
-
428
- return () => { setStorageSyncHandler(undefined) }
429
- }
1
+ /**
2
+ * dsh-imagegen — host half. Mounts the plugin's settings section (channels
3
+ * with per-channel model catalogs on the host settings seam), the
4
+ * /api/dsh-imagegen route family (loopback-only settings bridge + presets /
5
+ * usage / image-generation proxy that keeps every API key host-side), and a
6
+ * system-prompt announcement. The browser half (./client) renders the sidebar
7
+ * entry and the split-pane generation studio.
8
+ */
9
+
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import { readFileSync } from 'node:fs'
12
+ import path from 'node:path'
13
+ import { installSettingsSectionCompat, settingsNamespaceCompat } from './settings-compat.ts'
14
+ import z from 'schemastery'// Type-only: pulls the webServer Context merge (route registration).
15
+ import type {} from '@deepseek-ai/dsh-host-webserver'
16
+ // Type-only: pulls the systemPrompt Context merge (announcement section).
17
+ import type {} from '@deepseek-ai/dsh-system-prompt'
18
+ // Type-only: pulls the human slash-command registry Context merge.
19
+ import type {} from '@deepseek-ai/dsh-commands'
20
+ import type {} from '@deepseek-ai/dsh-tools'
21
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
22
+ import { IMAGEGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
23
+ import { makeRoutes, type SettingsSeam } from './routes.ts'
24
+ import { syncAllTemplates } from './templates-store.ts'
25
+ import { setStorageSyncHandler, putObject, type StorageSyncConfig } from './storage-sync.ts'
26
+
27
+ /** Content type for a saved image file name (object uploads). */
28
+ function mimeOfPath(filePath: string): string {
29
+ switch (path.extname(filePath).toLowerCase()) {
30
+ case '.jpg':
31
+ case '.jpeg': return 'image/jpeg'
32
+ case '.webp': return 'image/webp'
33
+ case '.gif': return 'image/gif'
34
+ default: return 'image/png'
35
+ }
36
+ }
37
+ import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
38
+ import { registerAgentImageTools } from './agent-image-tools.ts'
39
+ import { registerEditImageCommand } from './edit-image-command.ts'
40
+ import { setImageDataRoot } from './image-storage-path.ts'
41
+ import { presetById } from './presets.ts'
42
+
43
+ /** Stable cordis plugin name. */
44
+ export const name = 'imagegen'
45
+
46
+ /** Services required before the surfaces can mount. */
47
+ export const inject = ['webServer', 'systemPrompt', 'commands']
48
+
49
+ // Internals re-exported for smoke tests and host-side debugging; the plugin
50
+ // contract only requires name / inject / Config / apply.
51
+ export { makeRoutes } from './routes.ts'
52
+ export { generateImage, ImageGenError } from './engine.ts'
53
+ export { promptCharLimit } from './model-catalog.ts'
54
+ export { ImageGenerationRuntime } from './generation-runtime.ts'
55
+ export { registerAgentImageTools } from './agent-image-tools.ts'
56
+ export { latestSessionImage, registerEditImageCommand } from './edit-image-command.ts'
57
+ export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
58
+ export { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates, syncAllTemplates, clearTemplateMemo } from './templates-store.ts'
59
+ export { addTemplateFavorite, clearTemplateFavoritesMemo, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
60
+ export { putObject, setStorageSyncHandler, testStorage, type StorageSyncConfig } from './storage-sync.ts'
61
+ export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
62
+
63
+ /** The branded settings namespace of this plugin (the card edits it). */
64
+ export const ImageGenSettingsNamespace = settingsNamespaceCompat(IMAGEGEN_SETTINGS_NAMESPACE)
65
+
66
+ /**
67
+ * Plugin config, validated by the same-named schemastery schema.
68
+ *
69
+ * Channels own the endpoint + model catalog. The API key of each channel lives
70
+ * in `channelSecrets` (a secret dict keyed by channel id) instead of inside the
71
+ * channel objects dsh-settings redaction supports dict/array containers, but
72
+ * path ops cannot reach inside arrays, so a whole-array write must never carry
73
+ * secrets it would clobber.
74
+ */
75
+ export interface Config {
76
+ /** Master switch for the plugin (routes, prompt section). */
77
+ enabled?: boolean
78
+ /** Announce the plugin in every agent's system prompt. */
79
+ announceToAgent?: boolean
80
+ /** Allow Agents to submit and retrieve image-generation tasks. */
81
+ allowAgentImageGeneration?: boolean
82
+ /** Configured channels (each: name, endpoint, model catalog). */
83
+ channels?: ChannelConfig[]
84
+ /** Per-channel API keys, keyed by channel id. */
85
+ channelSecrets?: Record<string, string>
86
+ /** Channel used when a request does not name one. */
87
+ defaultChannelId?: string
88
+ /** Optional OpenAI-compatible chat endpoint for prompt enhancement. */
89
+ promptApiUrl?: string
90
+ /** Optional secret for the prompt enhancement endpoint. */
91
+ promptApiKey?: string
92
+ /** Chat model used to expand short image prompts. */
93
+ promptModel?: string
94
+ /** Local root for generated/history/gallery/canvas images. Empty keeps the default under DSH_HOME. */
95
+ localStoragePath?: string
96
+ /** Sync saved images to an S3-compatible object store (COS / OSS / Qiniu S3 …). */
97
+ storageEnabled?: boolean
98
+ /** S3-compatible endpoint URL including the bucket (virtual-hosted or path style). */
99
+ storageEndpoint?: string
100
+ /** Provider region for SigV4 scope, e.g. ap-guangzhou / oss-cn-hangzhou. */
101
+ storageRegion?: string
102
+ /** Object key prefix, default 'dsh-imagegen'. */
103
+ storagePrefix?: string
104
+ /** S3 access key id. */
105
+ storageAccessKey?: string
106
+ /** S3 secret access key (stored redacted). */
107
+ storageSecretKey?: string
108
+ /** Upload gallery additions (default on when storage is enabled). */
109
+ storageSyncGallery?: boolean
110
+ /** Also upload history images. */
111
+ storageSyncHistory?: boolean
112
+ /* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
113
+ /** Legacy base URL; synthesized into the default channel on upgrade. */
114
+ apiUrl?: string
115
+ /** Legacy secret; migrated into channelSecrets on upgrade. */
116
+ apiKey?: string
117
+ /** Legacy allow-list; migrated into the default channel's catalog. */
118
+ imageModels?: string[]
119
+ }
120
+
121
+ export const Config: z<Config> = z.object({
122
+ enabled: z.boolean().default(true),
123
+ announceToAgent: z.boolean().default(true),
124
+ allowAgentImageGeneration: z.boolean().default(true),
125
+ channels: z.array(z.object({
126
+ id: z.string(),
127
+ preset: z.string().default(''),
128
+ name: z.string().default(''),
129
+ apiUrl: z.string().default(''),
130
+ models: z.array(z.object({
131
+ alias: z.string(),
132
+ id: z.string(),
133
+ })).default([]),
134
+ })).default([]),
135
+ channelSecrets: z.dict(z.string().role('secret')).default({}),
136
+ defaultChannelId: z.string().default(''),
137
+ promptApiUrl: z.string().default(''),
138
+ promptApiKey: z.string().role('secret').default(''),
139
+ promptModel: z.string().default(''),
140
+ localStoragePath: z.string().default(''),
141
+ storageEnabled: z.boolean().default(false),
142
+ storageEndpoint: z.string().default(''),
143
+ storageRegion: z.string().default(''),
144
+ storagePrefix: z.string().default('dsh-imagegen'),
145
+ storageAccessKey: z.string().default(''),
146
+ storageSecretKey: z.string().role('secret').default(''),
147
+ storageSyncGallery: z.boolean().default(true),
148
+ storageSyncHistory: z.boolean().default(false),
149
+ apiUrl: z.string().default(''),
150
+ apiKey: z.string().role('secret').default(''),
151
+ imageModels: z.array(z.string()).default([]),
152
+ })
153
+
154
+ /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
155
+ const DEFAULT_ENABLED = true
156
+ const DEFAULT_ANNOUNCE = true
157
+ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
158
+
159
+ /** Order of the announcement section within the tool-guidance band. */
160
+ const SECTION_ORDER = 150
161
+
162
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
163
+ export 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`,当前仅支持文生图;qwen-image 系列使用阿里云 DashScope 原生接口(api_url 填 https://dashscope.aliyuncs.com/api/v1,不支持 OpenAI 兼容模式,该渠道不可复用于提示词增强,尺寸自动映射为宽*高)。MiniMax `image-01` 使用 MiniMax 原生 `/image_generation` 接口(api_url 填 https://api.minimax.io/v1 或国内站 https://api.minimaxi.com/v1,支持 1:1/16:9/4:3/3:2/2:3/3:4/9:16/21:9 宽高比,一次最多 9 张;图生图为单张 subject_reference 主体参考(保持人物/主体一致,非像素级局部编辑);其 /models 只列聊天模型,图片模型需用预设目录)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、MiniMax、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):多来源标签页(精选案例库 / 沧河案例库,后续可扩展),打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选、收藏(星标,宿主持久化)与复用;各来源列表独立刷新,宿主每 12 小时后台自动同步一次。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问对应来源站点(vibeui.top / gpt-image2.canghe.ai)。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
164
+
165
+ /** Append the live channel × model table so an Agent can honor user choices. */
166
+ function guidanceFor(channels: RuntimeChannel[], defaultChannelId: string): string {
167
+ if (channels.length === 0) {
168
+ return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`
169
+ }
170
+ const table = channels.map(channel => {
171
+ const aliases = channel.models.map(model => model.alias).join('')
172
+ const mark = channel.id === defaultChannelId ? '(默认渠道)' : ''
173
+ const key = channel.apiKey === '' ? '(未填密钥)' : ''
174
+ const models = channel.models.length === 0 ? '未配置模型' : `可用模型:${aliases}`
175
+ return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`
176
+ }).join(';')
177
+ return `${IMAGEGEN_GUIDANCE} 当前渠道与模型:${table}。用户指定模型名时取该模型所属渠道(多渠道同名用默认渠道);未指定模型时若仅一个可用模型可直接生成,若有多个应先询问用户选择「渠道 + 模型」。`
178
+ }
179
+
180
+ /** Normalize raw channel entries into the wire shape (schema-adjacent guard). */
181
+ function normalizeChannels(value: unknown): ChannelConfig[] {
182
+ if (!Array.isArray(value)) return []
183
+ const out: ChannelConfig[] = []
184
+ for (const item of value) {
185
+ if (item === null || typeof item !== 'object') continue
186
+ const raw = item as Record<string, unknown>
187
+ const id = typeof raw.id === 'string' ? raw.id.trim() : ''
188
+ if (id === '') continue
189
+ const models: ModelMapping[] = []
190
+ if (Array.isArray(raw.models)) {
191
+ for (const entry of raw.models) {
192
+ if (entry === null || typeof entry !== 'object') continue
193
+ const record = entry as Record<string, unknown>
194
+ const alias = typeof record.alias === 'string' ? record.alias.trim() : ''
195
+ const upstream = typeof record.id === 'string' ? record.id.trim() : ''
196
+ if (alias === '') continue
197
+ models.push({ alias, id: upstream === '' ? alias : upstream })
198
+ }
199
+ }
200
+ out.push({
201
+ id,
202
+ preset: typeof raw.preset === 'string' ? raw.preset : '',
203
+ name: typeof raw.name === 'string' ? raw.name.trim() : '',
204
+ apiUrl: typeof raw.apiUrl === 'string' ? raw.apiUrl.trim() : '',
205
+ models,
206
+ })
207
+ }
208
+ return out
209
+ }
210
+
211
+ /** Effective config (schema defaults applied + legacy migration). */
212
+ export interface EffectiveConfig {
213
+ enabled: boolean
214
+ announceToAgent: boolean
215
+ allowAgentImageGeneration: boolean
216
+ channels: RuntimeChannel[]
217
+ defaultChannelId: string
218
+ promptApiUrl: string
219
+ promptApiKey: string
220
+ promptModel: string
221
+ storage: StorageSyncConfig & { enabled: boolean; syncGallery: boolean; syncHistory: boolean }
222
+ }
223
+
224
+ /**
225
+ * Mount the settings section, routes, and announcement.
226
+ * @param ctx - host plugin context carrying webServer/systemPrompt.
227
+ * @param config - resolved plugin config (schema defaults applied by the loader).
228
+ */
229
+ export function apply(ctx: Context, config?: Config): (() => void) | void {
230
+ // The live source the surfaces read: the settings section once the settings
231
+ // service is attached, the composition entry otherwise.
232
+ let current: () => Config = () => config ?? {}
233
+ const resolve = (): EffectiveConfig => {
234
+ const value = current() ?? {}
235
+ setImageDataRoot(value.localStoragePath)
236
+ let channels = normalizeChannels(value.channels)
237
+ // Settings scopes are deep-frozen by the host. Legacy migration adds the
238
+ // synthesized default-channel secret, so always work on a detached copy.
239
+ const secrets: Record<string, string> = { ...(value.channelSecrets ?? {}) }
240
+ // Legacy single-endpoint migration: no channels yet synthesize the
241
+ // default channel from the old flat fields so upgrades never break.
242
+ if (channels.length === 0) {
243
+ const legacyUrl = typeof value.apiUrl === 'string' ? value.apiUrl.trim() : ''
244
+ const legacyModels: ModelMapping[] = Array.isArray(value.imageModels)
245
+ ? value.imageModels
246
+ .filter((model): model is string => typeof model === 'string' && model.trim() !== '')
247
+ .map(model => ({ alias: model.trim(), id: model.trim() }))
248
+ : []
249
+ if (legacyUrl !== '' || legacyModels.length > 0) {
250
+ channels = [{ id: 'default', preset: '', name: '默认渠道', apiUrl: legacyUrl, models: legacyModels }]
251
+ const legacyKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : ''
252
+ if (legacyKey !== '') secrets['default'] = legacyKey
253
+ }
254
+ }
255
+ const named = channels.map(channel => ({
256
+ ...channel,
257
+ name: channel.name === '' ? (presetById(channel.preset)?.name ?? '未命名渠道') : channel.name,
258
+ }))
259
+ const defaultChannelId = typeof value.defaultChannelId === 'string' && named.some(channel => channel.id === value.defaultChannelId)
260
+ ? value.defaultChannelId
261
+ : named[0]?.id ?? ''
262
+ return {
263
+ enabled: value.enabled ?? DEFAULT_ENABLED,
264
+ announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
265
+ allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
266
+ channels: named.map(channel => ({
267
+ ...channel,
268
+ apiKey: typeof secrets[channel.id] === 'string' ? secrets[channel.id] : '',
269
+ })),
270
+ defaultChannelId,
271
+ promptApiUrl: typeof value.promptApiUrl === 'string' ? value.promptApiUrl.trim() : '',
272
+ promptApiKey: typeof value.promptApiKey === 'string' ? value.promptApiKey.trim() : '',
273
+ promptModel: typeof value.promptModel === 'string' ? value.promptModel.trim() : '',
274
+ storage: {
275
+ enabled: value.storageEnabled ?? false,
276
+ endpoint: typeof value.storageEndpoint === 'string' ? value.storageEndpoint.trim() : '',
277
+ region: typeof value.storageRegion === 'string' ? value.storageRegion.trim() : '',
278
+ accessKey: typeof value.storageAccessKey === 'string' ? value.storageAccessKey.trim() : '',
279
+ secretKey: typeof value.storageSecretKey === 'string' ? value.storageSecretKey.trim() : '',
280
+ prefix: typeof value.storagePrefix === 'string' && value.storagePrefix.trim() !== '' ? value.storagePrefix.trim() : 'dsh-imagegen',
281
+ syncGallery: value.storageSyncGallery ?? true,
282
+ syncHistory: value.storageSyncHistory ?? false,
283
+ },
284
+ }
285
+ }
286
+
287
+ // Transient helper used by several mount points below: resolve the shared
288
+ // channel view once per access; the runtime then picks per-request creds.
289
+ const channelsView = (): ChannelsView => {
290
+ const value = resolve()
291
+ return { channels: value.channels, defaultChannelId: value.defaultChannelId }
292
+ }
293
+
294
+ // Object-storage sync: the image stores announce every file they write; the
295
+ // handler resolves the live settings and uploads when enabled. Fire and
296
+ // forget a sync failure never blocks the save path.
297
+ setStorageSyncHandler((kind, filePath) => {
298
+ const storage = resolve().storage
299
+ if (!storage.enabled || !storage.endpoint.trim() || storage.secretKey.trim() === '') return
300
+ if (kind === 'gallery' && !storage.syncGallery) return
301
+ if (kind === 'history' && !storage.syncHistory) return
302
+ const key = `${storage.prefix}/${kind === 'gallery' ? 'gallery' : 'images'}/${path.basename(filePath)}`
303
+ const data = readFileSync(filePath)
304
+ void putObject(storage, key, data, mimeOfPath(filePath)).catch(() => {
305
+ // Best-effort sync: surfaced through the settings test, never fatal here.
306
+ })
307
+ })
308
+
309
+ // Browser endpoints and Agent tools share the exact same serial queue. This
310
+ // keeps image persistence, cancellation, and retries coherent across both
311
+ // entry points; Agent tools wait for their task result by default and render
312
+ // images in the tool result instead of injecting a synthetic user message.
313
+ const runtime = new ImageGenerationRuntime(channelsView)
314
+ const pendingConversationImages = new Map<string, ImageAttachmentRef>()
315
+
316
+ // The route family mounts once, gated on the settings seam (the bridge
317
+ // serves it; without the seam there is nothing to expose). Route handlers
318
+ // read resolve() per request, so config edits apply live. The settings
319
+ // bridge deliberately keeps serving while the plugin is disabled it is
320
+ // how the user re-enables the plugin from the settings card.
321
+ ctx.inject(['settings', 'attachments'], (sctx) => {
322
+ const seam = sctx.get('settings') as unknown as SettingsSeam
323
+ sctx.effect(
324
+ () => {
325
+ const routes = makeRoutes({
326
+ settings: seam,
327
+ resolve: () => {
328
+ const value = resolve()
329
+ const channel = value.channels.find(candidate => candidate.id === value.defaultChannelId) ?? value.channels[0]
330
+ return { apiUrl: channel?.apiUrl ?? '', apiKey: channel?.apiKey ?? '' }
331
+ },
332
+ resolveChannels: channelsView,
333
+ resolvePrompt: () => {
334
+ const value = resolve()
335
+ const channel = value.channels.find(candidate => candidate.id === value.defaultChannelId) ?? value.channels[0]
336
+ return {
337
+ apiUrl: value.promptApiUrl !== '' ? value.promptApiUrl : (channel?.apiUrl ?? ''),
338
+ apiKey: value.promptApiKey !== '' ? value.promptApiKey : (channel?.apiKey ?? ''),
339
+ model: value.promptModel,
340
+ }
341
+ },
342
+ resolveImageModels: () => {
343
+ const value = resolve()
344
+ return [...new Set(value.channels.flatMap(channel => channel.models.map(model => model.alias)))]
345
+ },
346
+ attachments: sctx.attachments,
347
+ pendingConversationImages,
348
+ runtime,
349
+ resolveStorage: () => resolve().storage,
350
+ })
351
+ const disposers = routes.map(route => ctx.webServer.register(route))
352
+ // Background template sync: the upstream sources update on their own
353
+ // schedule, so pull every one of them shortly after start and then
354
+ // twice a day while the plugin stays enabled. Best-effort: failures
355
+ // keep the last good snapshot (bundled or previously refreshed).
356
+ const TEMPLATE_SYNC_INITIAL_DELAY_MS = 30_000
357
+ const TEMPLATE_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000
358
+ let syncTimer: NodeJS.Timeout | undefined
359
+ const runSync = (): void => {
360
+ if (!resolve().enabled) return
361
+ void syncAllTemplates().catch(() => { /* keep the last good snapshot */ })
362
+ }
363
+ const startTimer = setTimeout(runSync, TEMPLATE_SYNC_INITIAL_DELAY_MS)
364
+ syncTimer = setInterval(runSync, TEMPLATE_SYNC_INTERVAL_MS)
365
+ syncTimer.unref?.()
366
+ return () => {
367
+ clearTimeout(startTimer)
368
+ clearInterval(syncTimer)
369
+ for (const dispose of disposers) dispose()
370
+ }
371
+ },
372
+ 'dsh-imagegen: routes',
373
+ )
374
+ })
375
+
376
+ ctx.inject(['tools', 'attachments', 'commands'], (tctx) => {
377
+ tctx.effect(() => {
378
+ const resolveAgentConfig = () => {
379
+ const value = resolve()
380
+ return {
381
+ enabled: value.enabled,
382
+ allowAgentImageGeneration: value.allowAgentImageGeneration,
383
+ channels: value.channels,
384
+ defaultChannelId: value.defaultChannelId,
385
+ }
386
+ }
387
+ const disposeTools = registerAgentImageTools(tctx, runtime, resolveAgentConfig)
388
+ const disposeCommand = registerEditImageCommand(tctx, runtime, resolveAgentConfig, {
389
+ get: sessionId => pendingConversationImages.get(sessionId),
390
+ consume: (sessionId, ref) => {
391
+ if (pendingConversationImages.get(sessionId)?.attachmentId === ref.attachmentId) pendingConversationImages.delete(sessionId)
392
+ },
393
+ })
394
+ return () => {
395
+ disposeCommand()
396
+ disposeTools()
397
+ }
398
+ }, 'dsh-imagegen: agent image tools and commands')
399
+ })
400
+
401
+ // System-prompt announcement (toggled by settings changes).
402
+ let disposeSection: (() => void) | undefined
403
+ const sync = (): void => {
404
+ if (disposeSection !== undefined) {
405
+ disposeSection()
406
+ disposeSection = undefined
407
+ }
408
+ const value = resolve()
409
+ if (!value.enabled || !value.announceToAgent) return
410
+ disposeSection = ctx.systemPrompt.section({
411
+ name: 'plugin:dsh-imagegen',
412
+ order: SECTION_ORDER,
413
+ text: guidanceFor(value.channels, value.defaultChannelId),
414
+ })
415
+ }
416
+
417
+ installSettingsSectionCompat(ctx, ImageGenSettingsNamespace, Config, config ?? {}, {
418
+ setSource: (source) => {
419
+ current = source
420
+ sync()
421
+ },
422
+ onChange: sync,
423
+ })
424
+
425
+ // Initial registration from the composition entry (covers deployments with
426
+ // no settings service, whose installSettingsSection never fires its hooks).
427
+ sync()
428
+
429
+ return () => { setStorageSyncHandler(undefined) }
430
+ }