@dickpy/dsh-imagegen 1.1.0 → 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/README.md +32 -10
- package/docs/images/agent-chat-edit.png +0 -0
- package/docs/images/agent-chat-generate.png +0 -0
- package/lib/client.js +687 -355
- 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 +30 -22
- package/src/client/SettingsCard.tsx +261 -70
- package/src/client/locales.ts +40 -0
- 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.
|
|
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
|
+
}
|
|
@@ -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[]>([])
|
|
@@ -222,6 +219,17 @@ export function ImageGenPanel(props: {
|
|
|
222
219
|
const previewStage = useRef<HTMLDivElement>(null)
|
|
223
220
|
const elapsed = useElapsed(generating, startedAt)
|
|
224
221
|
|
|
222
|
+
// A saved settings change is authoritative. Keep the active selection and
|
|
223
|
+
// comparison choices in that allow-list without disturbing valid choices.
|
|
224
|
+
const imageModelKey = imageModels.join('\u0000')
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
setModel(previous => imageModels.includes(previous) ? previous : imageModels[0])
|
|
227
|
+
setCompareModels(previous => {
|
|
228
|
+
const retained = previous.filter(candidate => imageModels.includes(candidate))
|
|
229
|
+
return retained.length > 0 ? retained : [imageModels[0]]
|
|
230
|
+
})
|
|
231
|
+
}, [imageModelKey])
|
|
232
|
+
|
|
225
233
|
const filteredGallery = gallery
|
|
226
234
|
.filter(entry => {
|
|
227
235
|
if (galleryFilter === 'all') return true
|
|
@@ -235,6 +243,7 @@ export function ImageGenPanel(props: {
|
|
|
235
243
|
.sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
|
|
236
244
|
|
|
237
245
|
const galleryTagOptions = [...new Set(gallery.flatMap(entry => entry.tags ?? []))].sort((a, b) => a.localeCompare(b))
|
|
246
|
+
const galleryModels = [...new Set([...imageModels, ...gallery.map(entry => entry.model)])]
|
|
238
247
|
|
|
239
248
|
const filteredHistory = history.filter(entry => {
|
|
240
249
|
const query = historyQuery.trim().toLocaleLowerCase()
|
|
@@ -399,7 +408,7 @@ export function ImageGenPanel(props: {
|
|
|
399
408
|
}
|
|
400
409
|
const request: GenerateRequest = {
|
|
401
410
|
mode: tab === 'gallery' ? 'text' : tab,
|
|
402
|
-
model,
|
|
411
|
+
model: imageModels.includes(model) ? model : imageModels[0],
|
|
403
412
|
prompt: promptText,
|
|
404
413
|
size,
|
|
405
414
|
quality,
|
|
@@ -410,7 +419,7 @@ export function ImageGenPanel(props: {
|
|
|
410
419
|
}
|
|
411
420
|
setError(null)
|
|
412
421
|
try {
|
|
413
|
-
const targetModels = compareEnabled ? compareModels : [model]
|
|
422
|
+
const targetModels = (compareEnabled ? compareModels : [request.model]).filter(candidate => imageModels.includes(candidate))
|
|
414
423
|
if (targetModels.length === 0) {
|
|
415
424
|
setError(tt('compare.selectRequired'))
|
|
416
425
|
return
|
|
@@ -497,7 +506,7 @@ export function ImageGenPanel(props: {
|
|
|
497
506
|
setQuality(normalizeQuality(entry.quality))
|
|
498
507
|
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
499
508
|
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
500
|
-
setModel(
|
|
509
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
501
510
|
setRefImage(null)
|
|
502
511
|
setImages(restored)
|
|
503
512
|
setError(null)
|
|
@@ -607,7 +616,7 @@ export function ImageGenPanel(props: {
|
|
|
607
616
|
setQuality(normalizeQuality(entry.quality))
|
|
608
617
|
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
609
618
|
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
610
|
-
setModel(
|
|
619
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
611
620
|
setRefImage(null)
|
|
612
621
|
setImages(restored)
|
|
613
622
|
setError(null)
|
|
@@ -794,13 +803,12 @@ export function ImageGenPanel(props: {
|
|
|
794
803
|
{tab === 'gallery' ? (
|
|
795
804
|
<div className={css.galleryFilters}>
|
|
796
805
|
<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]) => (
|
|
806
|
+
{[
|
|
807
|
+
['all', tt('gallery.all')],
|
|
808
|
+
['text', tt('mode.text')],
|
|
809
|
+
['edit', tt('mode.edit')],
|
|
810
|
+
...galleryModels.map(value => [value, value]),
|
|
811
|
+
].map(([value, label]) => (
|
|
804
812
|
<button
|
|
805
813
|
key={value}
|
|
806
814
|
type="button"
|
|
@@ -808,7 +816,7 @@ export function ImageGenPanel(props: {
|
|
|
808
816
|
data-active={galleryFilter === value ? '' : undefined}
|
|
809
817
|
onClick={() => { setGalleryFilter(value) }}
|
|
810
818
|
>
|
|
811
|
-
<span>{
|
|
819
|
+
<span>{label}</span>
|
|
812
820
|
<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
821
|
</button>
|
|
814
822
|
))}
|
|
@@ -1012,7 +1020,7 @@ export function ImageGenPanel(props: {
|
|
|
1012
1020
|
</button>
|
|
1013
1021
|
{modelOpen ? (
|
|
1014
1022
|
<div className={css.modelMenuList} role="listbox" aria-label={tt('model.label')}>
|
|
1015
|
-
{
|
|
1023
|
+
{imageModels.map(option => (
|
|
1016
1024
|
<button
|
|
1017
1025
|
key={option}
|
|
1018
1026
|
type="button"
|
|
@@ -1036,7 +1044,7 @@ export function ImageGenPanel(props: {
|
|
|
1036
1044
|
</label>
|
|
1037
1045
|
{compareEnabled ? (
|
|
1038
1046
|
<div className={css.compareModelChoices} role="group" aria-label={tt('compare.models')}>
|
|
1039
|
-
{
|
|
1047
|
+
{imageModels.map(option => (
|
|
1040
1048
|
<label key={option}>
|
|
1041
1049
|
<input type="checkbox" checked={compareModels.includes(option)} onChange={() => { setCompareModels(previous => previous.includes(option) ? previous.filter(value => value !== option) : [...previous, option]) }} />
|
|
1042
1050
|
<span>{option}</span>
|