@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/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.0",
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 = 'all' | 'text' | 'edit' | 'gpt-image-2' | 'grok-imagine-image'
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>(MODELS[0])
176
+ const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODELS[0])
180
177
  const [compareEnabled, setCompareEnabled] = useState(false)
181
- const [compareModels, setCompareModels] = useState<string[]>([...MODELS])
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((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
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((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
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
- ['gpt-image-2', 'gallery.gpt'],
802
- ['grok-imagine-image', 'gallery.grok'],
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>{tt(label as never)}</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
- {MODELS.map(option => (
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
- {MODELS.map(option => (
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}>{tt('tasks.title')} <span>{tasks.filter(task => task.status === 'queued' || task.status === 'running').length}</span></header>
1149
- {tasks.slice(0, 5).map(task => (
1150
- <div key={task.id} className={css.taskRow} data-status={task.status}>
1151
- <span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
1152
- <span className={css.taskPrompt}>{task.request.prompt}</span>
1153
- {(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
1154
- {task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
1155
- </div>
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 ? (