@dickpy/dsh-imagegen 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -118
- package/docs/images/agent-chat-edit.png +0 -0
- package/docs/images/agent-chat-generate.png +0 -0
- package/docs/images/agent-chat-poster-workflow.png +0 -0
- package/docs/images/imagegen-overview.png +0 -0
- package/docs/images/poster-features-16x9.png +0 -0
- package/lib/client.js +759 -394
- package/lib/client.js.map +1 -1
- package/lib/index.js +664 -164
- package/package.json +4 -1
- package/src/agent-image-tools.ts +289 -0
- package/src/client/ImageGenPanel.tsx +50 -32
- package/src/client/SettingsCard.tsx +261 -70
- package/src/client/locales.ts +40 -0
- package/src/client/panel.module.css +15 -1
- package/src/client/settings-card.module.css +180 -0
- package/src/client/settings-form.ts +12 -0
- package/src/client/settings-scope.ts +2 -0
- package/src/engine.ts +29 -2
- package/src/generation-runtime.ts +48 -0
- package/src/image-models.ts +19 -0
- package/src/index.ts +48 -2
- package/src/prompt-enhancer.ts +17 -5
- package/src/protocol.ts +6 -1
- package/src/routes.ts +41 -33
- package/src/task-queue.ts +36 -3
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.1
|
|
4
|
+
"version": "1.2.1",
|
|
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
|
+
}
|
|
@@ -16,13 +16,9 @@ import { errorMessage, tt } from './helpers.ts'
|
|
|
16
16
|
import { TemplateLibrary } from './TemplateLibrary.tsx'
|
|
17
17
|
import type { GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, HistoryEntry, HistoryImageRef, UpdateInfo } from '../protocol.ts'
|
|
18
18
|
import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
|
|
19
|
+
import { DEFAULT_IMAGE_MODELS, normalizeImageModels } from '../image-models.ts'
|
|
19
20
|
import css from './panel.module.css'
|
|
20
21
|
|
|
21
|
-
/** Models offered by the dropdown. Anything OpenAI-compatible that answers
|
|
22
|
-
* /images/generations (+ /images/edits) works; grok-imagine-image is handled
|
|
23
|
-
* specially host-side (JSON /images/edits, aspect_ratio, b64_json). */
|
|
24
|
-
const MODELS = ['gpt-image-2', 'grok-imagine-image'] as const
|
|
25
|
-
|
|
26
22
|
/** Size options, presented as aspect ratios (auto = let the model decide).
|
|
27
23
|
* The host maps each ratio onto the model's own vocabulary: aspect_ratio for
|
|
28
24
|
* Grok Imagine, the closest pixel size for OpenAI-compatible endpoints. */
|
|
@@ -153,7 +149,7 @@ function formatTime(timestamp: number): string {
|
|
|
153
149
|
/** Studio tabs: the two generation modes plus the gallery view. */
|
|
154
150
|
type PanelTab = GenerateMode | 'gallery'
|
|
155
151
|
|
|
156
|
-
type GalleryFilter =
|
|
152
|
+
type GalleryFilter = string
|
|
157
153
|
type ComparisonSession = { taskIds: string[]; prompt: string }
|
|
158
154
|
|
|
159
155
|
/** Render the studio. */
|
|
@@ -169,6 +165,7 @@ export function ImageGenPanel(props: {
|
|
|
169
165
|
const apiKeySet = useSecretSet(scope, 'apiKey')
|
|
170
166
|
const promptKeySet = useSecretSet(scope, 'promptApiKey')
|
|
171
167
|
const connected = enabled && configured && apiKeySet
|
|
168
|
+
const imageModels = normalizeImageModels(config?.imageModels)
|
|
172
169
|
|
|
173
170
|
const [tab, setTab] = useState<PanelTab>('text')
|
|
174
171
|
const [prompt, setPrompt] = useState('')
|
|
@@ -176,9 +173,9 @@ export function ImageGenPanel(props: {
|
|
|
176
173
|
const [quality, setQuality] = useState<string>('auto')
|
|
177
174
|
const [count, setCount] = useState(1)
|
|
178
175
|
const [detail, setDetail] = useState('')
|
|
179
|
-
const [model, setModel] = useState<string>(
|
|
176
|
+
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODELS[0])
|
|
180
177
|
const [compareEnabled, setCompareEnabled] = useState(false)
|
|
181
|
-
const [compareModels, setCompareModels] = useState<string[]>([...
|
|
178
|
+
const [compareModels, setCompareModels] = useState<string[]>([...DEFAULT_IMAGE_MODELS])
|
|
182
179
|
const [modelOpen, setModelOpen] = useState(false)
|
|
183
180
|
const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
|
|
184
181
|
const [images, setImages] = useState<GeneratedImage[]>([])
|
|
@@ -216,12 +213,24 @@ export function ImageGenPanel(props: {
|
|
|
216
213
|
const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
|
|
217
214
|
const [libraryOpen, setLibraryOpen] = useState(false)
|
|
218
215
|
const [tasks, setTasks] = useState<GenerationTask[]>([])
|
|
216
|
+
const [taskTrayOpen, setTaskTrayOpen] = useState(false)
|
|
219
217
|
const [comparison, setComparison] = useState<ComparisonSession | null>(null)
|
|
220
218
|
const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
|
|
221
219
|
const fileInput = useRef<HTMLInputElement>(null)
|
|
222
220
|
const previewStage = useRef<HTMLDivElement>(null)
|
|
223
221
|
const elapsed = useElapsed(generating, startedAt)
|
|
224
222
|
|
|
223
|
+
// A saved settings change is authoritative. Keep the active selection and
|
|
224
|
+
// comparison choices in that allow-list without disturbing valid choices.
|
|
225
|
+
const imageModelKey = imageModels.join('\u0000')
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
setModel(previous => imageModels.includes(previous) ? previous : imageModels[0])
|
|
228
|
+
setCompareModels(previous => {
|
|
229
|
+
const retained = previous.filter(candidate => imageModels.includes(candidate))
|
|
230
|
+
return retained.length > 0 ? retained : [imageModels[0]]
|
|
231
|
+
})
|
|
232
|
+
}, [imageModelKey])
|
|
233
|
+
|
|
225
234
|
const filteredGallery = gallery
|
|
226
235
|
.filter(entry => {
|
|
227
236
|
if (galleryFilter === 'all') return true
|
|
@@ -235,6 +244,7 @@ export function ImageGenPanel(props: {
|
|
|
235
244
|
.sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
|
|
236
245
|
|
|
237
246
|
const galleryTagOptions = [...new Set(gallery.flatMap(entry => entry.tags ?? []))].sort((a, b) => a.localeCompare(b))
|
|
247
|
+
const galleryModels = [...new Set([...imageModels, ...gallery.map(entry => entry.model)])]
|
|
238
248
|
|
|
239
249
|
const filteredHistory = history.filter(entry => {
|
|
240
250
|
const query = historyQuery.trim().toLocaleLowerCase()
|
|
@@ -399,7 +409,7 @@ export function ImageGenPanel(props: {
|
|
|
399
409
|
}
|
|
400
410
|
const request: GenerateRequest = {
|
|
401
411
|
mode: tab === 'gallery' ? 'text' : tab,
|
|
402
|
-
model,
|
|
412
|
+
model: imageModels.includes(model) ? model : imageModels[0],
|
|
403
413
|
prompt: promptText,
|
|
404
414
|
size,
|
|
405
415
|
quality,
|
|
@@ -410,7 +420,7 @@ export function ImageGenPanel(props: {
|
|
|
410
420
|
}
|
|
411
421
|
setError(null)
|
|
412
422
|
try {
|
|
413
|
-
const targetModels = compareEnabled ? compareModels : [model]
|
|
423
|
+
const targetModels = (compareEnabled ? compareModels : [request.model]).filter(candidate => imageModels.includes(candidate))
|
|
414
424
|
if (targetModels.length === 0) {
|
|
415
425
|
setError(tt('compare.selectRequired'))
|
|
416
426
|
return
|
|
@@ -497,7 +507,7 @@ export function ImageGenPanel(props: {
|
|
|
497
507
|
setQuality(normalizeQuality(entry.quality))
|
|
498
508
|
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
499
509
|
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
500
|
-
setModel(
|
|
510
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
501
511
|
setRefImage(null)
|
|
502
512
|
setImages(restored)
|
|
503
513
|
setError(null)
|
|
@@ -607,7 +617,7 @@ export function ImageGenPanel(props: {
|
|
|
607
617
|
setQuality(normalizeQuality(entry.quality))
|
|
608
618
|
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
609
619
|
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
610
|
-
setModel(
|
|
620
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
611
621
|
setRefImage(null)
|
|
612
622
|
setImages(restored)
|
|
613
623
|
setError(null)
|
|
@@ -794,13 +804,12 @@ export function ImageGenPanel(props: {
|
|
|
794
804
|
{tab === 'gallery' ? (
|
|
795
805
|
<div className={css.galleryFilters}>
|
|
796
806
|
<div className={css.galleryFilterHeading}>{tt('gallery.categories')}</div>
|
|
797
|
-
{
|
|
798
|
-
['all', 'gallery.all'],
|
|
799
|
-
['text', 'mode.text'],
|
|
800
|
-
['edit', 'mode.edit'],
|
|
801
|
-
[
|
|
802
|
-
|
|
803
|
-
] as const).map(([value, label]) => (
|
|
807
|
+
{[
|
|
808
|
+
['all', tt('gallery.all')],
|
|
809
|
+
['text', tt('mode.text')],
|
|
810
|
+
['edit', tt('mode.edit')],
|
|
811
|
+
...galleryModels.map(value => [value, value]),
|
|
812
|
+
].map(([value, label]) => (
|
|
804
813
|
<button
|
|
805
814
|
key={value}
|
|
806
815
|
type="button"
|
|
@@ -808,7 +817,7 @@ export function ImageGenPanel(props: {
|
|
|
808
817
|
data-active={galleryFilter === value ? '' : undefined}
|
|
809
818
|
onClick={() => { setGalleryFilter(value) }}
|
|
810
819
|
>
|
|
811
|
-
<span>{
|
|
820
|
+
<span>{label}</span>
|
|
812
821
|
<span className={css.galleryFilterCount}>{gallery.filter(entry => value === 'all' || value === 'text' || value === 'edit' ? (value === 'all' ? true : entry.mode === value) : entry.model === value).length}</span>
|
|
813
822
|
</button>
|
|
814
823
|
))}
|
|
@@ -1012,7 +1021,7 @@ export function ImageGenPanel(props: {
|
|
|
1012
1021
|
</button>
|
|
1013
1022
|
{modelOpen ? (
|
|
1014
1023
|
<div className={css.modelMenuList} role="listbox" aria-label={tt('model.label')}>
|
|
1015
|
-
{
|
|
1024
|
+
{imageModels.map(option => (
|
|
1016
1025
|
<button
|
|
1017
1026
|
key={option}
|
|
1018
1027
|
type="button"
|
|
@@ -1036,7 +1045,7 @@ export function ImageGenPanel(props: {
|
|
|
1036
1045
|
</label>
|
|
1037
1046
|
{compareEnabled ? (
|
|
1038
1047
|
<div className={css.compareModelChoices} role="group" aria-label={tt('compare.models')}>
|
|
1039
|
-
{
|
|
1048
|
+
{imageModels.map(option => (
|
|
1040
1049
|
<label key={option}>
|
|
1041
1050
|
<input type="checkbox" checked={compareModels.includes(option)} onChange={() => { setCompareModels(previous => previous.includes(option) ? previous.filter(value => value !== option) : [...previous, option]) }} />
|
|
1042
1051
|
<span>{option}</span>
|
|
@@ -1144,16 +1153,25 @@ export function ImageGenPanel(props: {
|
|
|
1144
1153
|
</div>
|
|
1145
1154
|
) : null}
|
|
1146
1155
|
{tab !== 'gallery' && tasks.length > 0 ? (
|
|
1147
|
-
<section className={css.taskTray} aria-label={tt('tasks.title')}>
|
|
1148
|
-
<header className={css.taskTrayHeader}>
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
<span className={css.
|
|
1152
|
-
<span className={css.
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1156
|
+
<section className={css.taskTray} data-open={taskTrayOpen ? 'true' : 'false'} aria-label={tt('tasks.title')}>
|
|
1157
|
+
<header className={css.taskTrayHeader}>
|
|
1158
|
+
<button type="button" className={css.taskTrayToggle} aria-expanded={taskTrayOpen} onClick={() => { setTaskTrayOpen(open => !open) }}>
|
|
1159
|
+
<span>{tt('tasks.title')}</span>
|
|
1160
|
+
<span className={css.taskTrayCount}>{tasks.filter(task => task.status === 'queued' || task.status === 'running').length}</span>
|
|
1161
|
+
<span className={css.taskTrayChevron} aria-hidden="true">{taskTrayOpen ? '⌃' : '⌄'}</span>
|
|
1162
|
+
</button>
|
|
1163
|
+
{taskTrayOpen ? <button type="button" className={css.taskTrayClose} aria-label={tt('preview.close')} onClick={() => { setTaskTrayOpen(false) }}>×</button> : null}
|
|
1164
|
+
</header>
|
|
1165
|
+
<div className={css.taskRows}>
|
|
1166
|
+
{tasks.slice(0, 5).map(task => (
|
|
1167
|
+
<div key={task.id} className={css.taskRow} data-status={task.status}>
|
|
1168
|
+
<span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
|
|
1169
|
+
<span className={css.taskPrompt}>{task.request.prompt}</span>
|
|
1170
|
+
{(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
|
|
1171
|
+
{task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
|
|
1172
|
+
</div>
|
|
1173
|
+
))}
|
|
1174
|
+
</div>
|
|
1157
1175
|
</section>
|
|
1158
1176
|
) : null}
|
|
1159
1177
|
{tab !== 'gallery' && comparison !== null ? (
|