@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
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.2.2",
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": {
@@ -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 { normalizeImageModels } from './image-models.ts'
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
- apiUrl: string
17
- apiKey: string
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 } | { type: 'image'; attachment: ImageAttachmentRef }> {
101
- const text = JSON.stringify(value)
102
- return [
103
- { type: 'text', text },
104
- ...value.images.map(image => ({ type: 'image' as const, attachment: restoreRef(image) })),
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
- 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')
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
- const selectedModel = (requested: unknown): string => {
118
- const models = normalizeImageModels(resolve().imageModels)
119
- const model = typeof requested === 'string' && requested.trim() !== '' ? requested.trim() : models[0]
120
- if (!models.includes(model)) {
121
- throw new ImageGenError(`Image model "${model}" is not configured. Choose one of: ${models.join(', ')}.`, 'image-model-not-configured')
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
- return model
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 attached below and can be reused as source_image in edit_image.'
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, and completed images are returned directly as tool-result attachments 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.',
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: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
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: selectedModel(args.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, and completed images are returned directly as tool-result attachments 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.',
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: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
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: selectedModel(args.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 image attachments for edit_image. Generation tools normally wait for completion, so use this for explicit recovery or status checks.',
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: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
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: { schema: taskResultSchema, render: (_args, value) => renderTaskResult(value) },
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 mediaType = acceptedMediaType(image.mime) ? image.mime : 'image/png'
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: Buffer.from(image.b64, 'base64'),
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 { DEFAULT_IMAGE_MODELS, normalizeImageModels } from '../image-models.ts'
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) return
106
- const timer = window.setInterval(() => {
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
- }, 1000)
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
- const apiUrl = config?.apiUrl ?? ''
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 apiKeySet = useSecretSet(scope, 'apiKey')
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>(DEFAULT_IMAGE_MODELS[0])
193
+ const [model, setModel] = useState<string>('')
177
194
  const [compareEnabled, setCompareEnabled] = useState(false)
178
- const [compareModels, setCompareModels] = useState<string[]>([...DEFAULT_IMAGE_MODELS])
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
- const [generating, setGenerating] = useState(false)
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 elapsed = useElapsed(generating, startedAt)
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 (generating) return
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 = generating
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={generating}
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}>{tasks.filter(task => task.status === 'queued' || task.status === 'running').length}</span>
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}>{tt('canvas.generating')}</span>
1194
- <span className={css.canvasStateHint}>{tt('canvas.elapsed', { seconds: elapsed })}</span>
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