@dickpy/dsh-imagegen 1.2.2 → 1.3.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 +24 -11
- package/docs/images/community-qq.png +0 -0
- package/lib/client.js +2040 -906
- package/lib/client.js.map +1 -1
- package/lib/index.js +710 -109
- package/package.json +2 -2
- package/src/agent-image-tools.ts +132 -30
- package/src/client/ImageGenPanel.tsx +56 -20
- package/src/client/SettingsCard.tsx +505 -196
- package/src/client/channels-form.ts +263 -0
- package/src/client/image-toolview.tsx +19 -12
- package/src/client/locales.ts +112 -2
- package/src/client/settings-card.module.css +487 -0
- package/src/client/settings-scope.ts +56 -8
- package/src/engine.ts +107 -8
- package/src/gallery-store.ts +6 -0
- package/src/generation-runtime.ts +32 -5
- package/src/history-store.ts +6 -0
- package/src/image-format.ts +11 -0
- package/src/image-models.ts +1 -1
- package/src/index.ts +153 -47
- package/src/model-catalog.ts +98 -0
- package/src/presets.ts +63 -0
- package/src/protocol.ts +81 -6
- package/src/routes.ts +192 -24
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dickpy/dsh-imagegen",
|
|
3
|
-
"description": "AI
|
|
4
|
-
"version": "1.
|
|
3
|
+
"description": "AI image generation plugin for the dsh web GUI: text-to-image and image-to-image through configurable provider channels (gpt-image-2 / grok-imagine-image / nanobanana series / seedream-5.0-pro / dall-e-3, with native xAI Grok Imagine, Google Nano Banana and ByteDance Seedream request shaping), with per-channel model catalogs and a sidebar entry opening a split-pane generation studio.",
|
|
4
|
+
"version": "1.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/src/agent-image-tools.ts
CHANGED
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Context } from '@deepseek-ai/cordis'
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import type { ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
|
5
6
|
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
|
6
7
|
import type {} from '@deepseek-ai/dsh-attachment'
|
|
7
8
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
8
9
|
import { ImageGenError } from './engine.ts'
|
|
9
|
-
import { ImageGenerationRuntime } from './generation-runtime.ts'
|
|
10
|
-
import {
|
|
10
|
+
import { ImageGenerationRuntime, type RuntimeChannel } from './generation-runtime.ts'
|
|
11
|
+
import { detectImageMime } from './image-format.ts'
|
|
11
12
|
import type { GenerationTask, GeneratedImage } from './protocol.ts'
|
|
12
13
|
|
|
13
14
|
export interface AgentImageToolConfig {
|
|
14
15
|
enabled: boolean
|
|
15
16
|
allowAgentImageGeneration: boolean
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
imageModels: string[]
|
|
17
|
+
channels: RuntimeChannel[]
|
|
18
|
+
defaultChannelId: string
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface AgentImageRef {
|
|
@@ -97,12 +97,62 @@ function imageDataUrl(image: { data: Uint8Array; ref: ImageAttachmentRef }): str
|
|
|
97
97
|
return `data:${image.ref.mediaType};base64,${Buffer.from(image.data).toString('base64')}`
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function renderTaskResult(value: AgentTaskResult): Array<{ type: 'text'; text: string }
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
]
|
|
100
|
+
function renderTaskResult(value: AgentTaskResult): Array<{ type: 'text'; text: string }> {
|
|
101
|
+
// Generated images are presentation output, not model input. Keeping the
|
|
102
|
+
// model-facing result textual lets image generation work with text-only
|
|
103
|
+
// conversation models while preserving the attachment references needed by
|
|
104
|
+
// edit_image.
|
|
105
|
+
return [{ type: 'text', text: JSON.stringify(value) }]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The UI-only projection that keeps generated images beside the tool call. */
|
|
109
|
+
function imagePresentationMeta(value: AgentTaskResult): { images: Array<Record<string, string | number>> } {
|
|
110
|
+
return {
|
|
111
|
+
images: value.images.map(image => {
|
|
112
|
+
const ref: Record<string, string | number> = {
|
|
113
|
+
attachment_id: image.attachment_id,
|
|
114
|
+
media_type: image.media_type,
|
|
115
|
+
bytes: image.bytes,
|
|
116
|
+
width: image.width,
|
|
117
|
+
height: image.height,
|
|
118
|
+
}
|
|
119
|
+
if (image.name !== undefined) ref.name = image.name
|
|
120
|
+
return ref
|
|
121
|
+
}),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function imageBlocksFromMeta(meta: unknown): Array<{ type: 'image'; attachment: ImageAttachmentRef }> {
|
|
126
|
+
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return []
|
|
127
|
+
const images = (meta as { images?: unknown }).images
|
|
128
|
+
if (!Array.isArray(images)) return []
|
|
129
|
+
return images.flatMap((value): Array<{ type: 'image'; attachment: ImageAttachmentRef }> => {
|
|
130
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return []
|
|
131
|
+
const raw = value as Record<string, unknown>
|
|
132
|
+
if (typeof raw.attachment_id !== 'string'
|
|
133
|
+
|| typeof raw.media_type !== 'string'
|
|
134
|
+
|| typeof raw.bytes !== 'number'
|
|
135
|
+
|| typeof raw.width !== 'number'
|
|
136
|
+
|| typeof raw.height !== 'number') return []
|
|
137
|
+
try {
|
|
138
|
+
return [{ type: 'image', attachment: restoreRef({
|
|
139
|
+
attachment_id: raw.attachment_id,
|
|
140
|
+
media_type: raw.media_type,
|
|
141
|
+
bytes: raw.bytes,
|
|
142
|
+
width: raw.width,
|
|
143
|
+
height: raw.height,
|
|
144
|
+
...typeof raw.name === 'string' ? { name: raw.name } : {},
|
|
145
|
+
}) }]
|
|
146
|
+
} catch {
|
|
147
|
+
return []
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Rehydrate image attachments for the host-computed tool result view only. */
|
|
153
|
+
function presentImageResult(_args: unknown, result: ToolResult): ToolResultView | undefined {
|
|
154
|
+
const content = result.isError ? [] : imageBlocksFromMeta(result.meta)
|
|
155
|
+
return content.length === 0 ? undefined : { card: 'generic', content }
|
|
106
156
|
}
|
|
107
157
|
|
|
108
158
|
/** Register the global Agent tools and unregister them with the plugin lifecycle. */
|
|
@@ -112,15 +162,37 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
112
162
|
const config = resolve()
|
|
113
163
|
if (!config.enabled) throw new ImageGenError('AI image generation is disabled. Open Settings > Plugins > AI Image and enable it.', 'plugin-disabled')
|
|
114
164
|
if (!config.allowAgentImageGeneration) throw new ImageGenError('Agent image generation is disabled in Settings > Plugins > AI Image.', 'agent-generation-disabled')
|
|
115
|
-
|
|
165
|
+
const usable = config.channels.some(channel => channel.apiUrl.trim() !== '' && channel.apiKey.trim() !== '')
|
|
166
|
+
if (!usable) throw new ImageGenError('Image API credentials are not configured. Open Settings > Plugins > AI Image, add a channel and fill in its API URL and API key.', 'image-api-not-configured')
|
|
116
167
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the requested model alias onto a channel. Rules:
|
|
171
|
+
* - a named alias must exist in some channel's catalog (several channels
|
|
172
|
+
* may host it; the default channel wins);
|
|
173
|
+
* - with no alias, a single configured model is used directly, while
|
|
174
|
+
* multiple models require the Agent to ask the user first.
|
|
175
|
+
* @returns the channel plus the alias and its upstream id.
|
|
176
|
+
*/
|
|
177
|
+
const resolveModel = (requested: unknown): { channel: RuntimeChannel; alias: string; upstream: string } => {
|
|
178
|
+
const config = resolve()
|
|
179
|
+
const entries = config.channels.flatMap(channel => channel.models.map(model => ({ channel, alias: model.alias, upstream: model.id })))
|
|
180
|
+
if (entries.length === 0) {
|
|
181
|
+
throw new ImageGenError('No image models are configured. Open Settings > Plugins > AI Image and add a channel with at least one model.', 'no-models-configured')
|
|
182
|
+
}
|
|
183
|
+
const wanted = typeof requested === 'string' && requested.trim() !== '' ? requested.trim() : ''
|
|
184
|
+
if (wanted === '') {
|
|
185
|
+
if (entries.length === 1) return entries[0]!
|
|
186
|
+
const options = config.channels.flatMap(channel => channel.models.map(model => `"${channel.name} · ${model.alias}"`)).join(', ')
|
|
187
|
+
throw new ImageGenError(`Multiple image models are available — ask the user which channel and model to use, then call this tool again with that exact model name. Options: ${options}.`, 'model-choice-required')
|
|
122
188
|
}
|
|
123
|
-
|
|
189
|
+
const hosting = entries.filter(entry => entry.alias === wanted)
|
|
190
|
+
if (hosting.length === 0) {
|
|
191
|
+
const available = [...new Set(entries.map(entry => entry.alias))].join(', ')
|
|
192
|
+
throw new ImageGenError(`Image model "${wanted}" is not configured in any channel. Choose one of: ${available}.`, 'image-model-not-configured')
|
|
193
|
+
}
|
|
194
|
+
const preferred = hosting.find(entry => entry.channel.id === config.defaultChannelId)
|
|
195
|
+
return preferred ?? hosting[0]!
|
|
124
196
|
}
|
|
125
197
|
const materializeTaskImages = (task: GenerationTask): Promise<AgentImageRef[]> => {
|
|
126
198
|
if (task.status !== 'completed') return Promise.resolve([])
|
|
@@ -140,7 +212,7 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
140
212
|
task_id: task.id,
|
|
141
213
|
status: task.status,
|
|
142
214
|
message: task.status === 'completed'
|
|
143
|
-
? 'Generation completed. The images are
|
|
215
|
+
? 'Generation completed. The images are shown beside this tool call and can be reused as source_image in edit_image.'
|
|
144
216
|
: task.status === 'failed'
|
|
145
217
|
? 'Generation failed.'
|
|
146
218
|
: task.status === 'cancelled'
|
|
@@ -218,7 +290,7 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
218
290
|
const disposers = [
|
|
219
291
|
ctx.tools.register(defineTool({
|
|
220
292
|
name: 'generate_image',
|
|
221
|
-
description: 'Generate an image. By default this tool call stays pending until the task reaches a final state
|
|
293
|
+
description: 'Generate an image. By default this tool call stays pending until the task reaches a final state; completed images are shown beside this tool call, while the model receives their attachment references, without creating a user message. Set wait_for_completion to false for background mode, then use get_image_generation_task explicitly. Only use models configured for this plugin; omit model to use the first configured image model.',
|
|
222
294
|
parameters: {
|
|
223
295
|
prompt: { type: 'string', required: true, description: 'Detailed image-generation prompt.' },
|
|
224
296
|
model: { type: 'string', description: 'One of the configured image models. Defaults to the first configured model.' },
|
|
@@ -228,12 +300,21 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
228
300
|
detail: { type: 'string', description: 'Optional provider detail value, for example standard or high.' },
|
|
229
301
|
wait_for_completion: { type: 'boolean', description: 'Wait for images and return them in this tool result. Defaults to true; set false for background mode.' },
|
|
230
302
|
},
|
|
231
|
-
output: {
|
|
303
|
+
output: {
|
|
304
|
+
schema: taskResultSchema,
|
|
305
|
+
render: (_args, value) => renderTaskResult(value),
|
|
306
|
+
presentationMeta: (_args, value) => imagePresentationMeta(value),
|
|
307
|
+
},
|
|
308
|
+
presentResult: presentImageResult,
|
|
232
309
|
async execute(args, exec) {
|
|
233
310
|
ensureConfigured()
|
|
311
|
+
const picked = resolveModel(args.model)
|
|
234
312
|
const task = runtime.queue.submit({
|
|
235
313
|
mode: 'text',
|
|
236
|
-
model:
|
|
314
|
+
model: picked.alias,
|
|
315
|
+
upstream: picked.upstream,
|
|
316
|
+
channelId: picked.channel.id,
|
|
317
|
+
channel: picked.channel.name,
|
|
237
318
|
prompt: args.prompt.trim(),
|
|
238
319
|
size: args.size ?? 'auto',
|
|
239
320
|
quality: args.quality ?? 'auto',
|
|
@@ -245,7 +326,7 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
245
326
|
})),
|
|
246
327
|
ctx.tools.register(defineTool({
|
|
247
328
|
name: 'edit_image',
|
|
248
|
-
description: 'Edit an image. By default this tool call stays pending until the task reaches a final state
|
|
329
|
+
description: 'Edit an image. By default this tool call stays pending until the task reaches a final state; completed images are shown beside this tool call, while the model receives their attachment references, without creating a user message. Set wait_for_completion to false for background mode, then use get_image_generation_task explicitly. source_image must be an image reference returned by a completed generation or get_image_generation_task; pass that entire object unchanged. Only configured image models are allowed; omit model to use the first configured model.',
|
|
249
330
|
parameters: {
|
|
250
331
|
prompt: { type: 'string', required: true, description: 'How to transform the source image.' },
|
|
251
332
|
source_image: { ...imageRefSchema, required: true, description: 'Image reference returned by get_image_generation_task.' },
|
|
@@ -256,13 +337,22 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
256
337
|
detail: { type: 'string', description: 'Optional provider detail value.' },
|
|
257
338
|
wait_for_completion: { type: 'boolean', description: 'Wait for images and return them in this tool result. Defaults to true; set false for background mode.' },
|
|
258
339
|
},
|
|
259
|
-
output: {
|
|
340
|
+
output: {
|
|
341
|
+
schema: taskResultSchema,
|
|
342
|
+
render: (_args, value) => renderTaskResult(value),
|
|
343
|
+
presentationMeta: (_args, value) => imagePresentationMeta(value),
|
|
344
|
+
},
|
|
345
|
+
presentResult: presentImageResult,
|
|
260
346
|
async execute(args, exec) {
|
|
261
347
|
ensureConfigured()
|
|
262
348
|
const reference = await ctx.attachments.readImage(restoreRef(args.source_image), exec.signal)
|
|
349
|
+
const picked = resolveModel(args.model)
|
|
263
350
|
const task = runtime.queue.submit({
|
|
264
351
|
mode: 'edit',
|
|
265
|
-
model:
|
|
352
|
+
model: picked.alias,
|
|
353
|
+
upstream: picked.upstream,
|
|
354
|
+
channelId: picked.channel.id,
|
|
355
|
+
channel: picked.channel.name,
|
|
266
356
|
prompt: args.prompt.trim(),
|
|
267
357
|
size: args.size ?? 'auto',
|
|
268
358
|
quality: args.quality ?? 'auto',
|
|
@@ -276,9 +366,14 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
276
366
|
})),
|
|
277
367
|
ctx.tools.register(defineTool({
|
|
278
368
|
name: 'get_image_generation_task',
|
|
279
|
-
description: 'Check an image-generation task status. Completed tasks return image references and
|
|
369
|
+
description: 'Check an image-generation task status. Completed tasks return image references; their images are shown beside this tool call and the references can be passed to edit_image. Generation tools normally wait for completion, so use this for explicit recovery or status checks.',
|
|
280
370
|
parameters: { task_id: { type: 'string', required: true, description: 'Task id returned by generate_image or edit_image.' } },
|
|
281
|
-
output: {
|
|
371
|
+
output: {
|
|
372
|
+
schema: taskResultSchema,
|
|
373
|
+
render: (_args, value) => renderTaskResult(value),
|
|
374
|
+
presentationMeta: (_args, value) => imagePresentationMeta(value),
|
|
375
|
+
},
|
|
376
|
+
presentResult: presentImageResult,
|
|
282
377
|
async execute(args) {
|
|
283
378
|
ensureConfigured()
|
|
284
379
|
return taskResult(findTask(args.task_id))
|
|
@@ -288,7 +383,12 @@ export function registerAgentImageTools(ctx: Context, runtime: ImageGenerationRu
|
|
|
288
383
|
name: 'cancel_image_generation_task',
|
|
289
384
|
description: 'Cancel a queued or running image generation task.',
|
|
290
385
|
parameters: { task_id: { type: 'string', required: true, description: 'Task id returned by generate_image or edit_image.' } },
|
|
291
|
-
output: {
|
|
386
|
+
output: {
|
|
387
|
+
schema: taskResultSchema,
|
|
388
|
+
render: (_args, value) => renderTaskResult(value),
|
|
389
|
+
presentationMeta: (_args, value) => imagePresentationMeta(value),
|
|
390
|
+
},
|
|
391
|
+
presentResult: presentImageResult,
|
|
292
392
|
async execute(args) {
|
|
293
393
|
ensureConfigured()
|
|
294
394
|
const task = runtime.queue.cancel(args.task_id)
|
|
@@ -307,9 +407,11 @@ function isFinalTask(task: GenerationTask): boolean {
|
|
|
307
407
|
}
|
|
308
408
|
|
|
309
409
|
function toSaveImage(image: GeneratedImage, taskId: string, index: number): { data: Uint8Array; mediaType: ImageMediaType; name: string } {
|
|
310
|
-
const
|
|
410
|
+
const data = Buffer.from(image.b64, 'base64')
|
|
411
|
+
const declaredMediaType = acceptedMediaType(image.mime) ? image.mime : 'image/png'
|
|
412
|
+
const mediaType = detectImageMime(data) ?? declaredMediaType
|
|
311
413
|
return {
|
|
312
|
-
data
|
|
414
|
+
data,
|
|
313
415
|
mediaType,
|
|
314
416
|
name: `imagegen-${taskId}-${index + 1}.${mediaType === 'image/jpeg' ? 'jpg' : mediaType.slice('image/'.length)}`,
|
|
315
417
|
}
|
|
@@ -16,7 +16,8 @@ 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 {
|
|
19
|
+
import { imageModelOptions } from './settings-scope.ts'
|
|
20
|
+
import { normalizeImageModels } from '../image-models.ts'
|
|
20
21
|
import css from './panel.module.css'
|
|
21
22
|
|
|
22
23
|
/** Size options, presented as aspect ratios (auto = let the model decide).
|
|
@@ -102,10 +103,15 @@ function useSecretSet(scope: ImageGenScope, field: string): boolean {
|
|
|
102
103
|
function useElapsed(running: boolean, startedAt: number | null): number {
|
|
103
104
|
const [elapsed, setElapsed] = useState(0)
|
|
104
105
|
useEffect(() => {
|
|
105
|
-
if (!running || startedAt === null)
|
|
106
|
-
|
|
106
|
+
if (!running || startedAt === null) {
|
|
107
|
+
setElapsed(0)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const update = (): void => {
|
|
107
111
|
setElapsed(Math.max(1, Math.round((Date.now() - startedAt) / 1000)))
|
|
108
|
-
}
|
|
112
|
+
}
|
|
113
|
+
update()
|
|
114
|
+
const timer = window.setInterval(update, 1000)
|
|
109
115
|
return () => window.clearInterval(timer)
|
|
110
116
|
}, [running, startedAt])
|
|
111
117
|
return elapsed
|
|
@@ -160,12 +166,23 @@ export function ImageGenPanel(props: {
|
|
|
160
166
|
const { api, scope } = props
|
|
161
167
|
const config = useConfig(scope)
|
|
162
168
|
const enabled = config?.enabled ?? true
|
|
163
|
-
|
|
169
|
+
// Channel-aware model options: the panel lists every configured alias
|
|
170
|
+
// (default channel first); legacy flat fields remain the upgrade fallback.
|
|
171
|
+
const modelOptions = imageModelOptions(config)
|
|
172
|
+
const hasChannels = (config?.channels ?? []).length > 0
|
|
173
|
+
// With channels configured, the model list is exactly the configured aliases
|
|
174
|
+
// (possibly empty — never fall back to the hardcoded legacy defaults).
|
|
175
|
+
const imageModels = hasChannels ? modelOptions.models : normalizeImageModels(config?.imageModels)
|
|
176
|
+
const defaultChannelId = modelOptions.defaultChannelId
|
|
177
|
+
const apiUrl = defaultChannelId !== undefined && (config?.channels ?? []).length > 0
|
|
178
|
+
? (config!.channels!.find(channel => channel.id === defaultChannelId)?.apiUrl ?? '')
|
|
179
|
+
: (config?.apiUrl ?? '')
|
|
164
180
|
const configured = apiUrl.trim() !== ''
|
|
165
|
-
const
|
|
181
|
+
const legacyKeySet = useSecretSet(scope, 'apiKey')
|
|
166
182
|
const promptKeySet = useSecretSet(scope, 'promptApiKey')
|
|
183
|
+
const channelKeySet = (config?.channels ?? []).some(channel => scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`))
|
|
184
|
+
const apiKeySet = (config?.channels ?? []).length > 0 ? channelKeySet : legacyKeySet
|
|
167
185
|
const connected = enabled && configured && apiKeySet
|
|
168
|
-
const imageModels = normalizeImageModels(config?.imageModels)
|
|
169
186
|
|
|
170
187
|
const [tab, setTab] = useState<PanelTab>('text')
|
|
171
188
|
const [prompt, setPrompt] = useState('')
|
|
@@ -173,17 +190,18 @@ export function ImageGenPanel(props: {
|
|
|
173
190
|
const [quality, setQuality] = useState<string>('auto')
|
|
174
191
|
const [count, setCount] = useState(1)
|
|
175
192
|
const [detail, setDetail] = useState('')
|
|
176
|
-
const [model, setModel] = useState<string>(
|
|
193
|
+
const [model, setModel] = useState<string>('')
|
|
177
194
|
const [compareEnabled, setCompareEnabled] = useState(false)
|
|
178
|
-
const [compareModels, setCompareModels] = useState<string[]>([
|
|
195
|
+
const [compareModels, setCompareModels] = useState<string[]>([])
|
|
179
196
|
const [modelOpen, setModelOpen] = useState(false)
|
|
180
197
|
const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
|
|
181
198
|
const [images, setImages] = useState<GeneratedImage[]>([])
|
|
182
199
|
const [error, setError] = useState<string | null>(null)
|
|
183
|
-
|
|
200
|
+
// Submission is brief; actual generation stays visible until the host
|
|
201
|
+
// queue reports that every queued/running task has finished.
|
|
202
|
+
const [submitting, setSubmitting] = useState(false)
|
|
184
203
|
const [enhancing, setEnhancing] = useState(false)
|
|
185
204
|
const [configGuide, setConfigGuide] = useState<'generation' | 'enhancement' | 'disabled' | null>(null)
|
|
186
|
-
const [startedAt, setStartedAt] = useState<number | null>(null)
|
|
187
205
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
|
188
206
|
const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
|
|
189
207
|
const [gallery, setGallery] = useState<HistoryEntry[]>([])
|
|
@@ -218,7 +236,11 @@ export function ImageGenPanel(props: {
|
|
|
218
236
|
const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
|
|
219
237
|
const fileInput = useRef<HTMLInputElement>(null)
|
|
220
238
|
const previewStage = useRef<HTMLDivElement>(null)
|
|
221
|
-
const
|
|
239
|
+
const activeTasks = tasks.filter(task => task.status === 'queued' || task.status === 'running')
|
|
240
|
+
const activeTask = activeTasks.find(task => task.status === 'running') ?? activeTasks[0]
|
|
241
|
+
const generating = submitting || activeTasks.length > 0
|
|
242
|
+
const generationStartedAt = activeTask?.startedAt ?? activeTask?.createdAt ?? null
|
|
243
|
+
const elapsed = useElapsed(generating, generationStartedAt)
|
|
222
244
|
|
|
223
245
|
// A saved settings change is authoritative. Keep the active selection and
|
|
224
246
|
// comparison choices in that allow-list without disturbing valid choices.
|
|
@@ -389,7 +411,7 @@ export function ImageGenPanel(props: {
|
|
|
389
411
|
|
|
390
412
|
/** Run one generation. */
|
|
391
413
|
const handleGenerate = async (): Promise<void> => {
|
|
392
|
-
if (
|
|
414
|
+
if (submitting) return
|
|
393
415
|
if (!enabled) {
|
|
394
416
|
openSettingsGuide('disabled')
|
|
395
417
|
return
|
|
@@ -415,10 +437,12 @@ export function ImageGenPanel(props: {
|
|
|
415
437
|
quality,
|
|
416
438
|
n: count,
|
|
417
439
|
detail,
|
|
440
|
+
...defaultChannelId !== undefined ? { channelId: defaultChannelId } : {},
|
|
418
441
|
...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
|
|
419
442
|
...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
|
|
420
443
|
}
|
|
421
444
|
setError(null)
|
|
445
|
+
setSubmitting(true)
|
|
422
446
|
try {
|
|
423
447
|
const targetModels = (compareEnabled ? compareModels : [request.model]).filter(candidate => imageModels.includes(candidate))
|
|
424
448
|
if (targetModels.length === 0) {
|
|
@@ -430,6 +454,8 @@ export function ImageGenPanel(props: {
|
|
|
430
454
|
setComparison(targetModels.length > 1 ? { taskIds: submitted.map(task => task.id), prompt: promptText } : null)
|
|
431
455
|
} catch (caught) {
|
|
432
456
|
setError(errorMessage(caught))
|
|
457
|
+
} finally {
|
|
458
|
+
setSubmitting(false)
|
|
433
459
|
}
|
|
434
460
|
}
|
|
435
461
|
|
|
@@ -714,7 +740,7 @@ export function ImageGenPanel(props: {
|
|
|
714
740
|
})
|
|
715
741
|
}
|
|
716
742
|
|
|
717
|
-
const generateDisabled =
|
|
743
|
+
const generateDisabled = submitting
|
|
718
744
|
const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
|
|
719
745
|
const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
|
|
720
746
|
const previewImage = preview === null ? null : preview.images[preview.index] ?? null
|
|
@@ -1011,7 +1037,7 @@ export function ImageGenPanel(props: {
|
|
|
1011
1037
|
<button
|
|
1012
1038
|
type="button"
|
|
1013
1039
|
className={css.modelSelect}
|
|
1014
|
-
disabled={
|
|
1040
|
+
disabled={submitting}
|
|
1015
1041
|
aria-haspopup="listbox"
|
|
1016
1042
|
aria-expanded={modelOpen}
|
|
1017
1043
|
onClick={() => { setModelOpen(open => !open) }}
|
|
@@ -1127,7 +1153,7 @@ export function ImageGenPanel(props: {
|
|
|
1127
1153
|
<span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
|
|
1128
1154
|
</button>
|
|
1129
1155
|
<div className={css.galleryCardFooter}>
|
|
1130
|
-
<span className={css.galleryAvatar}>{entry.model.startsWith('grok') ? 'G' : 'D'}</span>
|
|
1156
|
+
<span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
|
|
1131
1157
|
<span className={css.galleryCardInfo}>
|
|
1132
1158
|
<strong>{entry.prompt || tt('gallery.untitled')}</strong>
|
|
1133
1159
|
<small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
|
|
@@ -1157,7 +1183,7 @@ export function ImageGenPanel(props: {
|
|
|
1157
1183
|
<header className={css.taskTrayHeader}>
|
|
1158
1184
|
<button type="button" className={css.taskTrayToggle} aria-expanded={taskTrayOpen} onClick={() => { setTaskTrayOpen(open => !open) }}>
|
|
1159
1185
|
<span>{tt('tasks.title')}</span>
|
|
1160
|
-
<span className={css.taskTrayCount}>{
|
|
1186
|
+
<span className={css.taskTrayCount}>{activeTasks.length}</span>
|
|
1161
1187
|
<span className={css.taskTrayChevron} aria-hidden="true">{taskTrayOpen ? '⌃' : '⌄'}</span>
|
|
1162
1188
|
</button>
|
|
1163
1189
|
{taskTrayOpen ? <button type="button" className={css.taskTrayClose} aria-label={tt('preview.close')} onClick={() => { setTaskTrayOpen(false) }}>×</button> : null}
|
|
@@ -1188,10 +1214,20 @@ export function ImageGenPanel(props: {
|
|
|
1188
1214
|
</section>
|
|
1189
1215
|
) : null}
|
|
1190
1216
|
{generating ? (
|
|
1191
|
-
<div className={css.canvasState} role="status">
|
|
1217
|
+
<div className={css.canvasState} data-generation-state={activeTask?.status ?? 'submitting'} role="status">
|
|
1192
1218
|
<span className={css.bigSpinner} />
|
|
1193
|
-
<span className={css.canvasStateTitle}>
|
|
1194
|
-
|
|
1219
|
+
<span className={css.canvasStateTitle}>
|
|
1220
|
+
{submitting && activeTask === undefined
|
|
1221
|
+
? tt('canvas.submitting')
|
|
1222
|
+
: activeTask?.status === 'queued'
|
|
1223
|
+
? tt('canvas.queued')
|
|
1224
|
+
: tt('canvas.generating')}
|
|
1225
|
+
</span>
|
|
1226
|
+
<span className={css.canvasStateHint}>
|
|
1227
|
+
{activeTask?.status === 'queued'
|
|
1228
|
+
? tt('canvas.queueHint', { count: activeTasks.length })
|
|
1229
|
+
: tt('canvas.elapsed', { seconds: elapsed })}
|
|
1230
|
+
</span>
|
|
1195
1231
|
</div>
|
|
1196
1232
|
) : null}
|
|
1197
1233
|
|