@dickpy/dsh-imagegen 1.0.20 → 1.2.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
3
  "description": "AI 鐢熷浘 (image generation) plugin for the dsh web GUI: text-to-image and image-to-image through a configurable OpenAI-compatible endpoint (gpt-image-2 / grok-imagine-image / dall-e-3, with native xAI Grok Imagine request shaping), with a settings card for api_url / api_key and a sidebar entry opening a split-pane generation studio.",
4
- "version": "1.0.20",
4
+ "version": "1.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "devDependencies": {
29
29
  "@deepseek-ai/cordis": "^4.0.1",
30
+ "@deepseek-ai/dsh-attachment": "0.1.0-rc.7",
30
31
  "@deepseek-ai/dsh-client-connection": "0.1.0-rc.7",
31
32
  "@deepseek-ai/dsh-client-locale": "0.1.0-rc.7",
32
33
  "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.7",
@@ -34,8 +35,10 @@
34
35
  "@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.7",
35
36
  "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.7",
36
37
  "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.7",
38
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.7",
37
39
  "@deepseek-ai/dsh-settings": "0.1.0-rc.7",
38
40
  "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.7",
41
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.7",
39
42
  "@types/node": "^22.20.0",
40
43
  "@types/react": "~18.3.1",
41
44
  "@types/react-dom": "^18.3.5",
@@ -0,0 +1,289 @@
1
+ /** Agent-facing image-generation tools backed by the shared host queue. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
7
+ import type {} from '@deepseek-ai/dsh-attachment'
8
+ import type {} from '@deepseek-ai/dsh-tools'
9
+ import { ImageGenError } from './engine.ts'
10
+ import { ImageGenerationRuntime } from './generation-runtime.ts'
11
+ import { normalizeImageModels } from './image-models.ts'
12
+ import type { GenerationTask, GeneratedImage } from './protocol.ts'
13
+
14
+ export interface AgentImageToolConfig {
15
+ enabled: boolean
16
+ allowAgentImageGeneration: boolean
17
+ apiUrl: string
18
+ apiKey: string
19
+ imageModels: string[]
20
+ }
21
+
22
+ interface AgentImageRef {
23
+ attachment_id: string
24
+ media_type: string
25
+ bytes: number
26
+ width: number
27
+ height: number
28
+ name?: string
29
+ }
30
+
31
+ interface AgentTaskResult {
32
+ task_id: string
33
+ status: string
34
+ message: string
35
+ error?: string
36
+ images: AgentImageRef[]
37
+ }
38
+
39
+ const imageRefSchema = {
40
+ type: 'object',
41
+ additionalProperties: false,
42
+ properties: {
43
+ attachment_id: { type: 'string', required: true },
44
+ media_type: { type: 'string', required: true },
45
+ bytes: { type: 'integer', required: true },
46
+ width: { type: 'integer', required: true },
47
+ height: { type: 'integer', required: true },
48
+ name: { type: 'string' },
49
+ },
50
+ } as const
51
+
52
+ const taskResultSchema = {
53
+ type: 'object',
54
+ additionalProperties: false,
55
+ properties: {
56
+ task_id: { type: 'string', required: true },
57
+ status: { type: 'string', required: true },
58
+ message: { type: 'string', required: true },
59
+ error: { type: 'string' },
60
+ images: { type: 'array', required: true, items: imageRefSchema },
61
+ },
62
+ } as const
63
+
64
+ function acceptedMediaType(value: string): value is ImageMediaType {
65
+ return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif'
66
+ }
67
+
68
+ function projectRef(ref: ImageAttachmentRef): AgentImageRef {
69
+ return {
70
+ attachment_id: String(ref.attachmentId),
71
+ media_type: ref.mediaType,
72
+ bytes: ref.bytes,
73
+ width: ref.width,
74
+ height: ref.height,
75
+ ...ref.name === undefined ? {} : { name: ref.name },
76
+ }
77
+ }
78
+
79
+ function restoreRef(value: AgentImageRef): ImageAttachmentRef {
80
+ if (!acceptedMediaType(value.media_type)) throw new ImageGenError('source_image.media_type is not a supported image type', 'bad-reference-image')
81
+ if (!Number.isInteger(value.bytes) || value.bytes < 1 || !Number.isInteger(value.width) || value.width < 1 || !Number.isInteger(value.height) || value.height < 1) {
82
+ throw new ImageGenError('source_image metadata is invalid', 'bad-reference-image')
83
+ }
84
+ return {
85
+ attachmentId: value.attachment_id as ImageAttachmentRef['attachmentId'],
86
+ mediaType: value.media_type,
87
+ bytes: value.bytes,
88
+ width: value.width,
89
+ height: value.height,
90
+ ...value.name === undefined ? {} : { name: value.name },
91
+ }
92
+ }
93
+
94
+ function imageDataUrl(image: { data: Uint8Array; ref: ImageAttachmentRef }): string {
95
+ return `data:${image.ref.mediaType};base64,${Buffer.from(image.data).toString('base64')}`
96
+ }
97
+
98
+ function renderTaskResult(value: AgentTaskResult): Array<{ type: 'text'; text: string } | { type: 'image'; attachment: ImageAttachmentRef }> {
99
+ const text = JSON.stringify(value)
100
+ return [
101
+ { type: 'text', text },
102
+ ...value.images.map(image => ({ type: 'image' as const, attachment: restoreRef(image) })),
103
+ ]
104
+ }
105
+
106
+ /** Register the global Agent tools and unregister them with the plugin lifecycle. */
107
+ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRuntime, resolve: () => AgentImageToolConfig): () => void {
108
+ const attachmentRefs = new Map<string, Promise<AgentImageRef[]>>()
109
+ const taskSubscriptions = new Set<() => void>()
110
+ const ensureConfigured = (): void => {
111
+ const config = resolve()
112
+ if (!config.enabled) throw new ImageGenError('AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.', 'plugin-disabled')
113
+ if (!config.allowAgentImageGeneration) throw new ImageGenError('Agent image generation is disabled in Settings > Plugins > AI Image.', 'agent-generation-disabled')
114
+ if (config.apiUrl.trim() === '' || config.apiKey.trim() === '') throw new ImageGenError('Image API credentials are not configured. Open Settings > Plugins > AI Image and fill in API URL and API key.', 'image-api-not-configured')
115
+ }
116
+ const selectedModel = (requested: unknown): string => {
117
+ const models = normalizeImageModels(resolve().imageModels)
118
+ const model = typeof requested === 'string' && requested.trim() !== '' ? requested.trim() : models[0]
119
+ if (!models.includes(model)) {
120
+ throw new ImageGenError(`Image model "${model}" is not configured. Choose one of: ${models.join(', ')}.`, 'image-model-not-configured')
121
+ }
122
+ return model
123
+ }
124
+ const materializeTaskImages = (task: GenerationTask): Promise<AgentImageRef[]> => {
125
+ if (task.status !== 'completed') return Promise.resolve([])
126
+ const existing = attachmentRefs.get(task.id)
127
+ if (existing !== undefined) return existing
128
+ const pending = ctx.attachments.saveImages((task.result?.images ?? []).map((image, index) => toSaveImage(image, task.id, index)))
129
+ .then(refs => refs.map(projectRef))
130
+ attachmentRefs.set(task.id, pending)
131
+ void pending.catch(() => {
132
+ if (attachmentRefs.get(task.id) === pending) attachmentRefs.delete(task.id)
133
+ })
134
+ return pending
135
+ }
136
+ const taskResult = async (task: GenerationTask): Promise<AgentTaskResult> => {
137
+ const images = await materializeTaskImages(task)
138
+ return {
139
+ task_id: task.id,
140
+ status: task.status,
141
+ message: task.status === 'completed'
142
+ ? 'Generation completed. The images are attached below and can be reused as source_image in edit_image.'
143
+ : task.status === 'failed'
144
+ ? 'Generation failed.'
145
+ : task.status === 'cancelled'
146
+ ? 'Generation was cancelled.'
147
+ : 'Generation is still running. Completion will be delivered to the conversation automatically with image attachments.',
148
+ ...task.error === undefined ? {} : { error: task.error },
149
+ images,
150
+ }
151
+ }
152
+ const findTask = (id: string): GenerationTask => {
153
+ const task = runtime.queue.list().find(candidate => candidate.id === id)
154
+ if (task === undefined) throw new ImageGenError(`Image generation task ${id} was not found.`, 'task-not-found')
155
+ return task
156
+ }
157
+ const notifyCompletion = async (agent: { send: (message: ReturnType<typeof createUserMessage>, target: 'next-turn', wakeup: true) => void }, task: GenerationTask): Promise<void> => {
158
+ const result = await taskResult(task)
159
+ const completed = task.status === 'completed'
160
+ const text = completed
161
+ ? `图像生成任务已完成(${task.id})。图片已附在这条消息中,可以直接查看、下载或作为后续图生图的参考。`
162
+ : task.status === 'cancelled'
163
+ ? `图像生成任务已取消(${task.id})。`
164
+ : `图像生成任务失败(${task.id}):${task.error ?? '未知错误'}`
165
+ agent.send(createUserMessage({
166
+ content: [
167
+ { type: 'text', text },
168
+ ...completed ? result.images.map(image => ({ type: 'image' as const, attachment: restoreRef(image) })) : [],
169
+ ],
170
+ // The stock conversation's plugin-context row renders text only; a
171
+ // user-role message is the supported path that renders image attachments.
172
+ source: { kind: 'user' },
173
+ }), 'next-turn', true)
174
+ }
175
+ const watchTask = (task: GenerationTask, agent: { send: (message: ReturnType<typeof createUserMessage>, target: 'next-turn', wakeup: true) => void } | undefined): void => {
176
+ if (agent === undefined) return
177
+ let dispose: (() => void) | undefined
178
+ const onChange = (updated: GenerationTask): void => {
179
+ if (updated.id !== task.id || !isFinalTask(updated)) return
180
+ dispose?.()
181
+ if (dispose !== undefined) taskSubscriptions.delete(dispose)
182
+ void notifyCompletion(agent, updated).catch(() => {})
183
+ }
184
+ dispose = runtime.queue.subscribe(onChange)
185
+ taskSubscriptions.add(dispose)
186
+ const current = findTask(task.id)
187
+ if (isFinalTask(current)) onChange(current)
188
+ }
189
+ const disposers = [
190
+ ctx.tools.register(defineTool({
191
+ name: 'generate_image',
192
+ description: 'Queue a text-to-image generation request. This returns immediately with a task id. When it finishes, the conversation automatically receives a visible image-attachment notification; do not repeatedly poll. Only use models configured for this plugin; omit model to use the first configured image model. Use get_image_generation_task only for an explicit status check or recovery.',
193
+ parameters: {
194
+ prompt: { type: 'string', required: true, description: 'Detailed image-generation prompt.' },
195
+ model: { type: 'string', description: 'One of the configured image models. Defaults to the first configured model.' },
196
+ size: { type: 'string', description: 'Aspect ratio such as 1:1, 16:9, 9:16, or auto.' },
197
+ quality: { type: 'string', description: 'auto, 1k, 2k, or 4k.' },
198
+ count: { type: 'integer', description: 'Number of images, 1 to 4. Defaults to 1.' },
199
+ detail: { type: 'string', description: 'Optional provider detail value, for example standard or high.' },
200
+ },
201
+ output: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
202
+ async execute(args, exec) {
203
+ ensureConfigured()
204
+ const task = runtime.queue.submit({
205
+ mode: 'text',
206
+ model: selectedModel(args.model),
207
+ prompt: args.prompt.trim(),
208
+ size: args.size ?? 'auto',
209
+ quality: args.quality ?? 'auto',
210
+ n: Math.min(4, Math.max(1, args.count ?? 1)),
211
+ detail: args.detail ?? '',
212
+ })
213
+ watchTask(task, exec.agent)
214
+ return taskResult(task)
215
+ },
216
+ })),
217
+ ctx.tools.register(defineTool({
218
+ name: 'edit_image',
219
+ description: 'Queue an image-to-image edit. source_image must be an image reference returned by a completed generation notification or get_image_generation_task; pass that entire object unchanged. Only configured image models are allowed; omit model to use the first configured model. Completion is automatically delivered to the conversation with visible image attachments; do not repeatedly poll.',
220
+ parameters: {
221
+ prompt: { type: 'string', required: true, description: 'How to transform the source image.' },
222
+ source_image: { ...imageRefSchema, required: true, description: 'Image reference returned by get_image_generation_task.' },
223
+ model: { type: 'string', description: 'One of the configured image models. Defaults to the first configured model.' },
224
+ size: { type: 'string', description: 'Aspect ratio such as 1:1, 16:9, 9:16, or auto.' },
225
+ quality: { type: 'string', description: 'auto, 1k, 2k, or 4k.' },
226
+ count: { type: 'integer', description: 'Number of images, 1 to 4. Defaults to 1.' },
227
+ detail: { type: 'string', description: 'Optional provider detail value.' },
228
+ },
229
+ output: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
230
+ async execute(args, exec) {
231
+ ensureConfigured()
232
+ const reference = await ctx.attachments.readImage(restoreRef(args.source_image), exec.signal)
233
+ const task = runtime.queue.submit({
234
+ mode: 'edit',
235
+ model: selectedModel(args.model),
236
+ prompt: args.prompt.trim(),
237
+ size: args.size ?? 'auto',
238
+ quality: args.quality ?? 'auto',
239
+ n: Math.min(4, Math.max(1, args.count ?? 1)),
240
+ detail: args.detail ?? '',
241
+ image: imageDataUrl(reference),
242
+ ...reference.ref.name === undefined ? {} : { refName: reference.ref.name },
243
+ })
244
+ watchTask(task, exec.agent)
245
+ return taskResult(task)
246
+ },
247
+ })),
248
+ ctx.tools.register(defineTool({
249
+ name: 'get_image_generation_task',
250
+ description: 'Optionally check an image-generation task status. Completed tasks return image references for edit_image, but the conversation already receives a visible completion notification automatically; do not poll repeatedly.',
251
+ parameters: { task_id: { type: 'string', required: true, description: 'Task id returned by generate_image or edit_image.' } },
252
+ output: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
253
+ async execute(args) {
254
+ ensureConfigured()
255
+ return taskResult(findTask(args.task_id))
256
+ },
257
+ })),
258
+ ctx.tools.register(defineTool({
259
+ name: 'cancel_image_generation_task',
260
+ description: 'Cancel a queued or running image generation task.',
261
+ parameters: { task_id: { type: 'string', required: true, description: 'Task id returned by generate_image or edit_image.' } },
262
+ output: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
263
+ async execute(args) {
264
+ ensureConfigured()
265
+ const task = runtime.queue.cancel(args.task_id)
266
+ if (task === undefined) throw new ImageGenError(`Image generation task ${args.task_id} was not found.`, 'task-not-found')
267
+ return taskResult(task)
268
+ },
269
+ })),
270
+ ]
271
+ return () => {
272
+ for (const dispose of taskSubscriptions) dispose()
273
+ taskSubscriptions.clear()
274
+ for (const dispose of disposers) dispose()
275
+ }
276
+ }
277
+
278
+ function isFinalTask(task: GenerationTask): boolean {
279
+ return task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
280
+ }
281
+
282
+ function toSaveImage(image: GeneratedImage, taskId: string, index: number): { data: Uint8Array; mediaType: ImageMediaType; name: string } {
283
+ const mediaType = acceptedMediaType(image.mime) ? image.mime : 'image/png'
284
+ return {
285
+ data: Buffer.from(image.b64, 'base64'),
286
+ mediaType,
287
+ name: `imagegen-${taskId}-${index + 1}.${mediaType === 'image/jpeg' ? 'jpg' : mediaType.slice('image/'.length)}`,
288
+ }
289
+ }