@dickpy/dsh-imagegen 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +363 -196
  2. package/docs/images/ecommerce-mode.png +0 -0
  3. package/docs/images/image-generation-studio-three-column.png +0 -0
  4. package/docs/images/imagegen-overview.png +0 -0
  5. package/docs/images/multi-model-comparison.png +0 -0
  6. package/docs/videos/agent-chat-edit.gif +0 -0
  7. package/docs/videos/agent-chat-edit.mp4 +0 -0
  8. package/lib/client.js +2589 -884
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +585 -240
  11. package/package.json +5 -2
  12. package/src/agent-image-tools.ts +131 -102
  13. package/src/client/ImageGenPanel.tsx +2679 -1594
  14. package/src/client/SettingsCard.tsx +6 -27
  15. package/src/client/api.ts +11 -1
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/image-toolview.tsx +176 -165
  18. package/src/client/index.ts +25 -15
  19. package/src/client/locales.ts +746 -602
  20. package/src/client/mount.tsx +213 -124
  21. package/src/client/panel.module.css +2619 -1563
  22. package/src/client/sidebar-entry.ts +190 -144
  23. package/src/edit-image-command.ts +110 -0
  24. package/src/engine.ts +47 -5
  25. package/src/gallery-store.ts +20 -0
  26. package/src/generation-runtime.ts +11 -2
  27. package/src/history-store.ts +26 -0
  28. package/src/image-models.ts +1 -1
  29. package/src/index.ts +31 -12
  30. package/src/model-catalog.ts +19 -2
  31. package/src/presets.ts +11 -3
  32. package/src/prompt-enhancer.ts +63 -5
  33. package/src/protocol.ts +59 -5
  34. package/src/routes.ts +62 -4
  35. package/src/task-queue.ts +42 -32
  36. package/docs/images/agent-chat-edit.png +0 -0
  37. package/docs/images/agent-chat-generate.png +0 -0
  38. package/docs/images/agent-chat-poster-workflow.png +0 -0
@@ -49,6 +49,13 @@ interface StoredEntry {
49
49
  refName?: string
50
50
  channelId?: string
51
51
  channel?: string
52
+ comparisonId?: string
53
+ comparisonModels?: string[]
54
+ workflow?: 'ecommerce'
55
+ projectId?: string
56
+ projectName?: string
57
+ slotKey?: string
58
+ slotLabel?: string
52
59
  }
53
60
 
54
61
  /** The index.json shape. */
@@ -119,6 +126,11 @@ function isStoredEntry(value: unknown): value is StoredEntry {
119
126
  return typeof entry.id === 'string'
120
127
  && typeof entry.createdAt === 'number'
121
128
  && (entry.mode === 'text' || entry.mode === 'edit')
129
+ && (entry.workflow === undefined || entry.workflow === 'ecommerce')
130
+ && (entry.projectId === undefined || typeof entry.projectId === 'string')
131
+ && (entry.projectName === undefined || typeof entry.projectName === 'string')
132
+ && (entry.slotKey === undefined || typeof entry.slotKey === 'string')
133
+ && (entry.slotLabel === undefined || typeof entry.slotLabel === 'string')
122
134
  && typeof entry.prompt === 'string'
123
135
  && Array.isArray(entry.images)
124
136
  && entry.images.every(image => {
@@ -155,6 +167,13 @@ function toWire(entry: StoredEntry): HistoryEntry {
155
167
  ...entry.refName === undefined ? {} : { refName: entry.refName },
156
168
  ...entry.channel === undefined ? {} : { channel: entry.channel },
157
169
  ...entry.channelId === undefined ? {} : { channelId: entry.channelId },
170
+ ...entry.comparisonId === undefined ? {} : { comparisonId: entry.comparisonId },
171
+ ...entry.comparisonModels === undefined ? {} : { comparisonModels: entry.comparisonModels },
172
+ ...entry.workflow === undefined ? {} : { workflow: entry.workflow },
173
+ ...entry.projectId === undefined ? {} : { projectId: entry.projectId },
174
+ ...entry.projectName === undefined ? {} : { projectName: entry.projectName },
175
+ ...entry.slotKey === undefined ? {} : { slotKey: entry.slotKey },
176
+ ...entry.slotLabel === undefined ? {} : { slotLabel: entry.slotLabel },
158
177
  }
159
178
  }
160
179
 
@@ -199,6 +218,13 @@ export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEn
199
218
  ...input.refName === undefined ? {} : { refName: input.refName },
200
219
  ...input.channelId === undefined ? {} : { channelId: input.channelId },
201
220
  ...input.channel === undefined ? {} : { channel: input.channel },
221
+ ...input.comparisonId === undefined ? {} : { comparisonId: input.comparisonId },
222
+ ...input.comparisonModels === undefined ? {} : { comparisonModels: input.comparisonModels },
223
+ ...input.workflow === undefined ? {} : { workflow: input.workflow },
224
+ ...input.projectId === undefined ? {} : { projectId: input.projectId },
225
+ ...input.projectName === undefined ? {} : { projectName: input.projectName },
226
+ ...input.slotKey === undefined ? {} : { slotKey: input.slotKey },
227
+ ...input.slotLabel === undefined ? {} : { slotLabel: input.slotLabel },
202
228
  }
203
229
  const merged = [entry, ...await readIndex()]
204
230
  const kept = merged.slice(0, HISTORY_MAX)
@@ -4,7 +4,7 @@
4
4
  * allow-list because OpenAI-compatible gateways rarely advertise modalities.
5
5
  */
6
6
 
7
- export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image', 'nanobanana2', 'nanobanana2-lite', 'nanobanana-pro', 'seedream-5.0-pro'] as const
7
+ export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image', 'nanobanana2', 'nanobanana2-lite', 'nanobanana-pro', 'seedream-5.0-pro', 'glm-image'] as const
8
8
 
9
9
  /** Normalize user-entered model identifiers and retain a usable legacy default. */
10
10
  export function normalizeImageModels(value: unknown): string[] {
package/src/index.ts CHANGED
@@ -14,19 +14,22 @@ import z from 'schemastery'
14
14
  import type {} from '@deepseek-ai/dsh-host-webserver'
15
15
  // Type-only: pulls the systemPrompt Context merge (announcement section).
16
16
  import type {} from '@deepseek-ai/dsh-system-prompt'
17
+ // Type-only: pulls the human slash-command registry Context merge.
18
+ import type {} from '@deepseek-ai/dsh-commands'
17
19
  import type {} from '@deepseek-ai/dsh-tools'
18
- import type {} from '@deepseek-ai/dsh-attachment'
20
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
19
21
  import { IMAGEGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
20
22
  import { makeRoutes, type SettingsSeam } from './routes.ts'
21
23
  import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
22
24
  import { registerAgentImageTools } from './agent-image-tools.ts'
25
+ import { registerEditImageCommand } from './edit-image-command.ts'
23
26
  import { presetById } from './presets.ts'
24
27
 
25
28
  /** Stable cordis plugin name. */
26
29
  export const name = 'imagegen'
27
30
 
28
31
  /** Services required before the surfaces can mount. */
29
- export const inject = ['webServer', 'systemPrompt']
32
+ export const inject = ['webServer', 'systemPrompt', 'commands']
30
33
 
31
34
  // Internals re-exported for smoke tests and host-side debugging; the plugin
32
35
  // contract only requires name / inject / Config / apply.
@@ -34,6 +37,7 @@ export { makeRoutes } from './routes.ts'
34
37
  export { generateImage, ImageGenError } from './engine.ts'
35
38
  export { ImageGenerationRuntime } from './generation-runtime.ts'
36
39
  export { registerAgentImageTools } from './agent-image-tools.ts'
40
+ export { latestSessionImage, registerEditImageCommand } from './edit-image-command.ts'
37
41
  export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
38
42
  export { listTemplates, readTemplateImage, refreshTemplates, clearTemplateMemo } from './templates-store.ts'
39
43
  export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
@@ -111,7 +115,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
111
115
  const SECTION_ORDER = 150
112
116
 
113
117
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
114
- 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 数组发送)。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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
118
+ 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`,当前仅支持文生图)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
115
119
 
116
120
  /** Append the live channel × model table so an Agent can honor user choices. */
117
121
  function guidanceFor(channels: RuntimeChannel[], defaultChannelId: string): string {
@@ -235,6 +239,7 @@ export function apply(ctx: Context, config?: Config): void {
235
239
  // entry points; Agent tools wait for their task result by default and render
236
240
  // images in the tool result instead of injecting a synthetic user message.
237
241
  const runtime = new ImageGenerationRuntime(channelsView)
242
+ const pendingConversationImages = new Map<string, ImageAttachmentRef>()
238
243
 
239
244
  // The route family mounts once, gated on the settings seam (the bridge
240
245
  // serves it; without the seam there is nothing to expose). Route handlers
@@ -267,6 +272,7 @@ export function apply(ctx: Context, config?: Config): void {
267
272
  return [...new Set(value.channels.flatMap(channel => channel.models.map(model => model.alias)))]
268
273
  },
269
274
  attachments: sctx.attachments,
275
+ pendingConversationImages,
270
276
  runtime,
271
277
  })
272
278
  const disposers = routes.map(route => ctx.webServer.register(route))
@@ -276,16 +282,29 @@ export function apply(ctx: Context, config?: Config): void {
276
282
  )
277
283
  })
278
284
 
279
- ctx.inject(['tools', 'attachments'], (tctx) => {
280
- tctx.effect(() => registerAgentImageTools(tctx, runtime, () => {
281
- const value = resolve()
282
- return {
283
- enabled: value.enabled,
284
- allowAgentImageGeneration: value.allowAgentImageGeneration,
285
- channels: value.channels,
286
- defaultChannelId: value.defaultChannelId,
285
+ ctx.inject(['tools', 'attachments', 'commands'], (tctx) => {
286
+ tctx.effect(() => {
287
+ const resolveAgentConfig = () => {
288
+ const value = resolve()
289
+ return {
290
+ enabled: value.enabled,
291
+ allowAgentImageGeneration: value.allowAgentImageGeneration,
292
+ channels: value.channels,
293
+ defaultChannelId: value.defaultChannelId,
294
+ }
287
295
  }
288
- }), 'dsh-imagegen: agent image tools')
296
+ const disposeTools = registerAgentImageTools(tctx, runtime, resolveAgentConfig)
297
+ const disposeCommand = registerEditImageCommand(tctx, runtime, resolveAgentConfig, {
298
+ get: sessionId => pendingConversationImages.get(sessionId),
299
+ consume: (sessionId, ref) => {
300
+ if (pendingConversationImages.get(sessionId)?.attachmentId === ref.attachmentId) pendingConversationImages.delete(sessionId)
301
+ },
302
+ })
303
+ return () => {
304
+ disposeCommand()
305
+ disposeTools()
306
+ }
307
+ }, 'dsh-imagegen: agent image tools and commands')
289
308
  })
290
309
 
291
310
  // System-prompt announcement (toggled by settings changes).
@@ -8,7 +8,7 @@
8
8
  * Framework-free (pure data + regex), safe for the client bundle to inline.
9
9
  */
10
10
 
11
- export type ModelFamily = 'gpt-image' | 'dall-e' | 'grok' | 'nanobanana' | 'seedream' | 'unknown'
11
+ export type ModelFamily = 'gpt-image' | 'dall-e' | 'grok' | 'nanobanana' | 'seedream' | 'zhipu' | 'unknown'
12
12
 
13
13
  /** Capability/identity annotation for one model id. */
14
14
  export interface ModelCatalogEntry {
@@ -69,6 +69,14 @@ const ENTRIES: Record<Exclude<ModelFamily, 'unknown'>, Omit<ModelCatalogEntry, '
69
69
  supportsAspectRatio: true,
70
70
  qualityTiers: ['1K', '2K'],
71
71
  },
72
+ zhipu: {
73
+ label: 'GLM-Image',
74
+ labelZh: '智谱图像',
75
+ known: true,
76
+ supportsEdit: false,
77
+ supportsAspectRatio: false,
78
+ qualityTiers: ['HD'],
79
+ },
72
80
  }
73
81
 
74
82
  /** Official Gemini image ids served by Nano Banana gateways. */
@@ -89,10 +97,19 @@ export function describeModel(model: string): ModelCatalogEntry {
89
97
  if (/^grok-imagine(?:-|$)/.test(id)) return { family: 'grok', ...ENTRIES.grok }
90
98
  if (/^nanobanana/i.test(id) || NANOBANANA_GEMINI_IDS.has(id)) return { family: 'nanobanana', ...ENTRIES.nanobanana }
91
99
  if (/^(?:doubao-)?seedream/i.test(id)) return { family: 'seedream', ...ENTRIES.seedream }
100
+ if (/^(?:glm-image|cogview(?:-|$))/i.test(id)) return { family: 'zhipu', ...ENTRIES.zhipu }
92
101
  return { family: 'unknown', label: 'unknown', labelZh: '未知协议', known: false, supportsEdit: true, supportsAspectRatio: false, qualityTiers: [] }
93
102
  }
94
103
 
104
+ /** Conservative fallback for providers whose /models response only has ids.
105
+ * Metadata-aware filtering lives in prompt-enhancer.ts; this catches common
106
+ * image model naming conventions without treating every unknown model as an
107
+ * image model. */
108
+ export function isLikelyImageModelId(model: string): boolean {
109
+ return /(?:^|[-_.])(?:image|img|diffusion|flux|cogview|imagen|seedream|nanobanana|grok-imagine|dall-e|stable-diffusion|sdxl|pixart|kolors|ideogram|midjourney|recraft|hunyuan|jimeng|wanx|hidream|playground)(?:$|[-_.])/i.test(model.trim())
110
+ }
111
+
95
112
  /** The family a model id routes its request through. */
96
113
  export function modelFamily(model: string): ModelFamily {
97
114
  return describeModel(model).family
98
- }
115
+ }
package/src/presets.ts CHANGED
@@ -40,10 +40,18 @@ export const IMAGE_PRESETS: PresetProvider[] = [
40
40
  id: 'openai-official',
41
41
  name: 'OpenAI 官方',
42
42
  apiUrl: 'https://api.openai.com/v1',
43
- hint: 'OpenAI 官方接口:gpt-image-2 / dall-e-3',
43
+ hint: 'OpenAI 官方图像生成接口',
44
44
  models: [
45
45
  { alias: 'gpt-image-2', id: 'gpt-image-2' },
46
- { alias: 'dall-e-3', id: 'dall-e-3' },
46
+ ],
47
+ },
48
+ {
49
+ id: 'zhipu-official',
50
+ name: '智谱 AI 官方',
51
+ apiUrl: 'https://open.bigmodel.cn/api/paas/v4',
52
+ hint: '智谱官方 GLM-Image 图像生成接口',
53
+ models: [
54
+ { alias: 'glm-image', id: 'glm-image' },
47
55
  ],
48
56
  },
49
57
  {
@@ -60,4 +68,4 @@ export const IMAGE_PRESETS: PresetProvider[] = [
60
68
  /** Look up one built-in provider by id. */
61
69
  export function presetById(id: string): PresetProvider | undefined {
62
70
  return IMAGE_PRESETS.find(preset => preset.id === id)
63
- }
71
+ }
@@ -1,5 +1,7 @@
1
1
  /** OpenAI-compatible chat helpers used by the optional prompt-enhancement UI. */
2
2
 
3
+ import { isLikelyImageModelId } from './model-catalog.ts'
4
+
3
5
  export interface PromptModelConfig {
4
6
  apiUrl: string
5
7
  apiKey: string
@@ -34,15 +36,71 @@ async function responseJson(response: Response): Promise<Record<string, unknown>
34
36
  return body as Record<string, unknown>
35
37
  }
36
38
 
37
- /** List candidates exposed by an OpenAI-compatible endpoint. */
38
- export async function listOpenAIModels(config: ModelListConfig): Promise<string[]> {
39
+ type ModelRecord = Record<string, unknown> & { id: string }
40
+
41
+ async function listModelRecords(config: ModelListConfig): Promise<ModelRecord[]> {
39
42
  if (config.apiUrl.trim() === '') throw new Error('API URL is required')
40
43
  const response = await fetch(endpoint(config.apiUrl, '/models'), { headers: headers(config.apiKey) })
41
44
  const body = await responseJson(response)
42
45
  const data = Array.isArray(body.data) ? body.data : []
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))]
46
+ return data.flatMap(item => {
47
+ if (item === null || typeof item !== 'object' || typeof (item as { id?: unknown }).id !== 'string') return []
48
+ const id = (item as { id: string }).id.trim()
49
+ return id === '' ? [] : [{ ...(item as Record<string, unknown>), id }]
50
+ })
51
+ }
52
+
53
+ function textOf(value: unknown): string[] {
54
+ if (typeof value === 'string') return [value]
55
+ if (!Array.isArray(value)) return []
56
+ return value.filter((item): item is string => typeof item === 'string')
57
+ }
58
+
59
+ function hasImageGenerationCapability(record: ModelRecord): boolean | undefined {
60
+ const capability = record.capabilities
61
+ if (capability !== null && typeof capability === 'object') {
62
+ const values = capability as Record<string, unknown>
63
+ for (const key of ['image_generation', 'imageGeneration', 'text_to_image', 'textToImage', 'image_gen']) {
64
+ if (typeof values[key] === 'boolean') return values[key]
65
+ }
66
+ const serialized = JSON.stringify(values).toLowerCase()
67
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image/.test(serialized)) return true
68
+ }
69
+
70
+ const taskText = [
71
+ ...textOf(record.task),
72
+ ...textOf(record.task_type),
73
+ ...textOf(record.taskType),
74
+ ...textOf(record.type),
75
+ ...textOf(record.model_type),
76
+ ...textOf(record.modelType),
77
+ ...textOf(record.tasks),
78
+ ...textOf(record.description),
79
+ ].join(' ').toLowerCase()
80
+ if (/image[ _-]?generation|text[ _-]?to[ _-]?image|image[ _-]?gen/.test(taskText)) return true
81
+ if (/^image(?:[ _-]?generation)?$/.test(taskText.trim())) return true
82
+ if (/embedding|rerank|moderation|transcri|speech|audio|video|chat[ _-]?completion/.test(taskText)) return false
83
+
84
+ for (const key of ['output_modalities', 'outputModalities', 'supported_output_modalities']) {
85
+ const modalities = textOf(record[key]).map(value => value.toLowerCase())
86
+ if (modalities.length > 0) return modalities.includes('image')
87
+ }
88
+ return undefined
89
+ }
90
+
91
+ function isImageModelRecord(record: ModelRecord): boolean {
92
+ return hasImageGenerationCapability(record) ?? isLikelyImageModelId(record.id)
93
+ }
94
+
95
+ /** List candidates exposed by an OpenAI-compatible endpoint. */
96
+ export async function listOpenAIModels(config: ModelListConfig): Promise<string[]> {
97
+ return [...new Set((await listModelRecords(config)).map(record => record.id))]
98
+ .sort((a, b) => a.localeCompare(b))
99
+ }
100
+
101
+ /** List only models that advertise or conventionally represent image generation. */
102
+ export async function listImageModels(config: ModelListConfig): Promise<string[]> {
103
+ return [...new Set((await listModelRecords(config)).filter(isImageModelRecord).map(record => record.id))]
46
104
  .sort((a, b) => a.localeCompare(b))
47
105
  }
48
106
 
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.3.0'
11
+ export const PLUGIN_VERSION = '1.5.0'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -36,6 +36,9 @@ export const PRESETS_API = '/api/dsh-imagegen/presets' as const
36
36
  /** Loopback-only image reader for Agent tool-result previews. */
37
37
  export const AGENT_IMAGE_API = '/api/dsh-imagegen/agent-image' as const
38
38
 
39
+ /** Store the current composer image for the direct edit_image command. */
40
+ export const CONVERSATION_IMAGE_API = '/api/dsh-imagegen/conversation-image' as const
41
+
39
42
  /**
40
43
  * Host-computed per-channel usage counters (generation-count badges in the
41
44
  * settings card): entries are tallied from the persisted history and gallery
@@ -149,8 +152,47 @@ export interface TemplateRefreshResult {
149
152
  /** Generation modes. */
150
153
  export type GenerateMode = 'text' | 'edit'
151
154
 
155
+ /** Metadata shared by the ecommerce product-set workflow. */
156
+ export interface EcommerceTaskMeta {
157
+ workflow?: 'ecommerce'
158
+ projectId?: string
159
+ projectName?: string
160
+ slotKey?: string
161
+ slotLabel?: string
162
+ }
163
+
164
+ /** Role an uploaded product asset plays in the ecommerce workflow. 'none' is
165
+ * only used as a slot selection meaning "generate without a reference". */
166
+ export type EcommerceRefRole = 'none' | 'product' | 'packaging' | 'detail' | 'style'
167
+
168
+ /** One planned image slot in a product set. */
169
+ export interface ProductSetSlot {
170
+ key: string
171
+ label: string
172
+ description: string
173
+ count: number
174
+ enabled: boolean
175
+ /** Which uploaded asset role this slot uses as its edit reference. */
176
+ refRole?: EcommerceRefRole
177
+ }
178
+
179
+ /** A browser-local ecommerce product-set draft. */
180
+ export interface ProductSetDraft {
181
+ projectId: string
182
+ projectName: string
183
+ category: string
184
+ platform: string
185
+ language: string
186
+ size: string
187
+ productName: string
188
+ sellingPoints: string
189
+ protectedFeatures: string
190
+ styleHint: string
191
+ slots: ProductSetSlot[]
192
+ }
193
+
152
194
  /** A client → host generate request (what the panel collects). */
153
- export interface GenerateRequest {
195
+ export interface GenerateRequest extends EcommerceTaskMeta {
154
196
  /** text-to-image (images/generations) or image-to-image (images/edits). */
155
197
  mode: GenerateMode
156
198
  /**
@@ -191,6 +233,10 @@ export interface GenerateRequest {
191
233
  /** Upstream model id actually sent to the gateway (host-filled from the
192
234
  * alias mapping; defaults to `model` when absent). */
193
235
  upstream?: string
236
+ /** Stable client-created id shared by the tasks in one comparison run. */
237
+ comparisonId?: string
238
+ /** All model aliases selected for one comparison run. */
239
+ comparisonModels?: string[]
194
240
  }
195
241
 
196
242
  /** One generated image, normalized host-side to base64 so the browser never
@@ -254,7 +300,7 @@ export interface PresetProviderView {
254
300
 
255
301
  export type GenerationTaskStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
256
302
 
257
- export interface GenerationTask {
303
+ export interface GenerationTask extends EcommerceTaskMeta {
258
304
  id: string
259
305
  request: GenerateRequest
260
306
  status: GenerationTaskStatus
@@ -285,7 +331,7 @@ export interface HistoryImageRef {
285
331
  }
286
332
 
287
333
  /** A saved generation as the browser consumes it (metadata + served images). */
288
- export interface HistoryEntry {
334
+ export interface HistoryEntry extends EcommerceTaskMeta {
289
335
  id: string
290
336
  createdAt: number
291
337
  mode: GenerateMode
@@ -304,10 +350,14 @@ export interface HistoryEntry {
304
350
  channelId?: string
305
351
  /** Channel display name snapshot (survives channel deletion). */
306
352
  channel?: string
353
+ /** Stable id shared by the history entries in one comparison run. */
354
+ comparisonId?: string
355
+ /** Model aliases included in the comparison run. */
356
+ comparisonModels?: string[]
307
357
  }
308
358
 
309
359
  /** A history entry the client submits for persistence (images still carry base64). */
310
- export interface HistoryEntryInput {
360
+ export interface HistoryEntryInput extends EcommerceTaskMeta {
311
361
  id: string
312
362
  createdAt: number
313
363
  mode: GenerateMode
@@ -323,4 +373,8 @@ export interface HistoryEntryInput {
323
373
  channelId?: string
324
374
  /** Channel display name snapshot (survives channel deletion). */
325
375
  channel?: string
376
+ /** Stable id shared by the history entries in one comparison run. */
377
+ comparisonId?: string
378
+ /** Model aliases included in the comparison run. */
379
+ comparisonModels?: string[]
326
380
  }
package/src/routes.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
- import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
11
+ import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
12
12
  import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
13
13
  import type { UpstreamConfig } from './engine.ts'
14
- import { enhancePrompt, listOpenAIModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
14
+ import { enhancePrompt, listImageModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
15
15
  import { normalizeImageModels } from './image-models.ts'
16
16
  import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
17
17
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
@@ -19,7 +19,7 @@ import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGalle
19
19
  import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
20
20
  import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
21
21
  import { IMAGE_PRESETS } from './presets.ts'
22
- import { AGENT_IMAGE_API, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, USAGE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
22
+ import { AGENT_IMAGE_API, CONVERSATION_IMAGE_API, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, USAGE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
23
23
 
24
24
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
25
25
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -49,6 +49,11 @@ export interface ImageGenRoutesDeps {
49
49
  /** Host attachment storage used by Agent tool-result previews. */
50
50
  attachments?: {
51
51
  readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>
52
+ saveImage?: (input: SaveImageAttachment) => Promise<ImageAttachmentRef>
53
+ }
54
+ /** Latest composer image staged for the direct edit_image command. */
55
+ pendingConversationImages?: {
56
+ set: (sessionId: string, ref: ImageAttachmentRef) => void
52
57
  }
53
58
  /** Overrideable history backend, primarily for host integration tests. */
54
59
  history?: {
@@ -133,6 +138,9 @@ function messageOf(error: unknown): string {
133
138
  function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
134
139
  const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
135
140
  if (prompt === '') return undefined
141
+ const comparisonModels = Array.isArray(body.comparisonModels)
142
+ ? [...new Set(body.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
143
+ : []
136
144
  return {
137
145
  mode: body.mode === 'edit' ? 'edit' : 'text',
138
146
  model: typeof body.model === 'string' ? body.model : '',
@@ -144,6 +152,13 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
144
152
  ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
145
153
  ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
146
154
  ...typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {},
155
+ ...typeof body.comparisonId === 'string' && body.comparisonId !== '' ? { comparisonId: body.comparisonId } : {},
156
+ ...comparisonModels.length > 1 ? { comparisonModels } : {},
157
+ ...body.workflow === 'ecommerce' ? { workflow: 'ecommerce' as const } : {},
158
+ ...typeof body.projectId === 'string' && body.projectId !== '' ? { projectId: body.projectId } : {},
159
+ ...typeof body.projectName === 'string' && body.projectName !== '' ? { projectName: body.projectName } : {},
160
+ ...typeof body.slotKey === 'string' && body.slotKey !== '' ? { slotKey: body.slotKey } : {},
161
+ ...typeof body.slotLabel === 'string' && body.slotLabel !== '' ? { slotLabel: body.slotLabel } : {},
147
162
  }
148
163
  }
149
164
 
@@ -169,6 +184,9 @@ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInpu
169
184
  ...typeof image.revisedPrompt === 'string' ? { revisedPrompt: image.revisedPrompt } : {},
170
185
  })
171
186
  }
187
+ const comparisonModels = Array.isArray(entry.comparisonModels)
188
+ ? [...new Set(entry.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
189
+ : []
172
190
  return {
173
191
  id: entry.id,
174
192
  createdAt: entry.createdAt,
@@ -183,6 +201,13 @@ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInpu
183
201
  ...typeof entry.refName === 'string' ? { refName: entry.refName } : {},
184
202
  ...typeof entry.channelId === 'string' ? { channelId: entry.channelId } : {},
185
203
  ...typeof entry.channel === 'string' ? { channel: entry.channel } : {},
204
+ ...typeof entry.comparisonId === 'string' ? { comparisonId: entry.comparisonId } : {},
205
+ ...comparisonModels.length > 1 ? { comparisonModels } : {},
206
+ ...entry.workflow === 'ecommerce' ? { workflow: 'ecommerce' as const } : {},
207
+ ...typeof entry.projectId === 'string' ? { projectId: entry.projectId } : {},
208
+ ...typeof entry.projectName === 'string' ? { projectName: entry.projectName } : {},
209
+ ...typeof entry.slotKey === 'string' ? { slotKey: entry.slotKey } : {},
210
+ ...typeof entry.slotLabel === 'string' ? { slotLabel: entry.slotLabel } : {},
186
211
  }
187
212
  }
188
213
 
@@ -231,6 +256,13 @@ function isImageMediaType(value: string): value is ImageMediaType {
231
256
  return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif'
232
257
  }
233
258
 
259
+ function imageDataUrl(value: string): { mediaType: ImageMediaType; data: Uint8Array } | undefined {
260
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/su.exec(value.trim())
261
+ if (match === null || match[1] === undefined || match[2] === undefined) return undefined
262
+ const data = Buffer.from(match[2], 'base64')
263
+ return data.byteLength === 0 ? undefined : { mediaType: match[1] as ImageMediaType, data }
264
+ }
265
+
234
266
  /** Project one settings descriptor onto the bridge wire view. */
235
267
  function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
236
268
  return {
@@ -341,6 +373,32 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
341
373
  }
342
374
 
343
375
  return [
376
+ // ---------------------------- composer image for /edit_image (exact)
377
+ ...(deps.attachments?.saveImage === undefined || deps.pendingConversationImages === undefined ? [] : [{
378
+ kind: 'exact' as const,
379
+ path: CONVERSATION_IMAGE_API,
380
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
381
+ if (!guard(req, res, 'POST')) return
382
+ const body = await readJsonBody(req, MAX_JSON_BODY_BYTES)
383
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : ''
384
+ const dataUrl = typeof body?.dataUrl === 'string' ? imageDataUrl(body.dataUrl) : undefined
385
+ if (sessionId === '' || dataUrl === undefined) {
386
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'sessionId and image data are required' })
387
+ return
388
+ }
389
+ try {
390
+ const ref = await deps.attachments!.saveImage!({
391
+ data: dataUrl.data,
392
+ mediaType: dataUrl.mediaType,
393
+ ...typeof body?.name === 'string' && body.name.trim() !== '' ? { name: body.name.trim() } : {},
394
+ })
395
+ deps.pendingConversationImages!.set(sessionId, ref)
396
+ writeJson(res, 200, { ok: true })
397
+ } catch (error) {
398
+ writeJson(res, 200, { ok: false, code: 'image-save-failed', message: messageOf(error) })
399
+ }
400
+ },
401
+ } satisfies WebRoute]),
344
402
  // ------------------------------------ Agent tool-result image (prefix)
345
403
  ...(deps.attachments === undefined ? [] : [{
346
404
  kind: 'prefix' as const,
@@ -393,7 +451,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
393
451
  apiKey: typeof body?.apiKey === 'string' && body.apiKey.trim() !== '' ? body.apiKey.trim() : (stored?.apiKey ?? ''),
394
452
  }
395
453
  try {
396
- writeJson(res, 200, { ok: true, models: await listOpenAIModels(upstream) })
454
+ writeJson(res, 200, { ok: true, models: await listImageModels(upstream) })
397
455
  } catch (error) {
398
456
  writeJson(res, 200, { ok: false, code: 'image-models-failed', message: messageOf(error) })
399
457
  }