@dickpy/dsh-imagegen 1.0.20 → 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.
@@ -14,15 +14,11 @@ import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
14
14
  import type { ImageGenApi } from './api.ts'
15
15
  import { errorMessage, tt } from './helpers.ts'
16
16
  import { TemplateLibrary } from './TemplateLibrary.tsx'
17
- import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryImageRef, UpdateInfo } from '../protocol.ts'
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. */
@@ -49,7 +45,6 @@ const QUALITIES = ['auto', '1k', '2k', '4k'] as const
49
45
  /** Detail options ('' = omit the passthrough). */
50
46
  const DETAILS = ['', 'standard', 'high'] as const
51
47
 
52
- const PROMPT_MAX = 2000
53
48
  const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
54
49
  const PREVIEW_SCALE_MIN = 0.5
55
50
  const PREVIEW_SCALE_MAX = 3
@@ -96,11 +91,11 @@ function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
96
91
  return value
97
92
  }
98
93
 
99
- /** Track the redacted api-key presence bit exposed by the settings bridge. */
100
- function useKeySet(scope: ImageGenScope): boolean {
101
- const [keySet, setKeySet] = useState(scope.getKeySetSnapshot())
102
- useEffect(() => scope.subscribeKeySet(() => { setKeySet(scope.getKeySetSnapshot()) }), [scope])
103
- return keySet
94
+ /** Track one redacted secret field without exposing its value to the panel. */
95
+ function useSecretSet(scope: ImageGenScope, field: string): boolean {
96
+ const [isSet, setIsSet] = useState(scope.getSecretSetSnapshot(field))
97
+ useEffect(() => scope.subscribeSecretSets(() => { setIsSet(scope.getSecretSetSnapshot(field)) }), [field, scope])
98
+ return isSet
104
99
  }
105
100
 
106
101
  /** Tick a seconds counter while `running`. */
@@ -154,7 +149,8 @@ function formatTime(timestamp: number): string {
154
149
  /** Studio tabs: the two generation modes plus the gallery view. */
155
150
  type PanelTab = GenerateMode | 'gallery'
156
151
 
157
- type GalleryFilter = 'all' | 'text' | 'edit' | 'gpt-image-2' | 'grok-imagine-image'
152
+ type GalleryFilter = string
153
+ type ComparisonSession = { taskIds: string[]; prompt: string }
158
154
 
159
155
  /** Render the studio. */
160
156
  export function ImageGenPanel(props: {
@@ -166,8 +162,10 @@ export function ImageGenPanel(props: {
166
162
  const enabled = config?.enabled ?? true
167
163
  const apiUrl = config?.apiUrl ?? ''
168
164
  const configured = apiUrl.trim() !== ''
169
- const keySet = useKeySet(scope)
170
- const connected = enabled && configured && keySet
165
+ const apiKeySet = useSecretSet(scope, 'apiKey')
166
+ const promptKeySet = useSecretSet(scope, 'promptApiKey')
167
+ const connected = enabled && configured && apiKeySet
168
+ const imageModels = normalizeImageModels(config?.imageModels)
171
169
 
172
170
  const [tab, setTab] = useState<PanelTab>('text')
173
171
  const [prompt, setPrompt] = useState('')
@@ -175,12 +173,16 @@ export function ImageGenPanel(props: {
175
173
  const [quality, setQuality] = useState<string>('auto')
176
174
  const [count, setCount] = useState(1)
177
175
  const [detail, setDetail] = useState('')
178
- const [model, setModel] = useState<string>(MODELS[0])
176
+ const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODELS[0])
177
+ const [compareEnabled, setCompareEnabled] = useState(false)
178
+ const [compareModels, setCompareModels] = useState<string[]>([...DEFAULT_IMAGE_MODELS])
179
179
  const [modelOpen, setModelOpen] = useState(false)
180
180
  const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
181
181
  const [images, setImages] = useState<GeneratedImage[]>([])
182
182
  const [error, setError] = useState<string | null>(null)
183
183
  const [generating, setGenerating] = useState(false)
184
+ const [enhancing, setEnhancing] = useState(false)
185
+ const [configGuide, setConfigGuide] = useState<'generation' | 'enhancement' | 'disabled' | null>(null)
184
186
  const [startedAt, setStartedAt] = useState<number | null>(null)
185
187
  const [history, setHistory] = useState<HistoryEntry[]>([])
186
188
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
@@ -190,8 +192,18 @@ export function ImageGenPanel(props: {
190
192
  const [galleryMessage, setGalleryMessage] = useState<string | null>(null)
191
193
  const [galleryFilter, setGalleryFilter] = useState<GalleryFilter>('all')
192
194
  const [galleryRatio, setGalleryRatio] = useState('all')
195
+ const [galleryTagFilter, setGalleryTagFilter] = useState<string | null>(null)
193
196
  const [galleryView, setGalleryView] = useState<'masonry' | 'grid'>('masonry')
194
197
  const [gallerySort, setGallerySort] = useState<'newest' | 'oldest'>('newest')
198
+ const [galleryQuery, setGalleryQuery] = useState('')
199
+ const [galleryTagInput, setGalleryTagInput] = useState('')
200
+ const [editingGalleryTagsId, setEditingGalleryTagsId] = useState<string | null>(null)
201
+ const [galleryTagEditInput, setGalleryTagEditInput] = useState('')
202
+ const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set())
203
+ const [gallerySelecting, setGallerySelecting] = useState(false)
204
+ const [historyQuery, setHistoryQuery] = useState('')
205
+ const [historyModelFilter, setHistoryModelFilter] = useState('all')
206
+ const [historyRatioFilter, setHistoryRatioFilter] = useState('all')
195
207
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
196
208
  const [previewScale, setPreviewScale] = useState(1)
197
209
  const [promptCopied, setPromptCopied] = useState(false)
@@ -200,10 +212,24 @@ export function ImageGenPanel(props: {
200
212
  const [updateMessage, setUpdateMessage] = useState<string | null>(null)
201
213
  const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
202
214
  const [libraryOpen, setLibraryOpen] = useState(false)
215
+ const [tasks, setTasks] = useState<GenerationTask[]>([])
216
+ const [comparison, setComparison] = useState<ComparisonSession | null>(null)
217
+ const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
203
218
  const fileInput = useRef<HTMLInputElement>(null)
204
219
  const previewStage = useRef<HTMLDivElement>(null)
205
220
  const elapsed = useElapsed(generating, startedAt)
206
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
+
207
233
  const filteredGallery = gallery
208
234
  .filter(entry => {
209
235
  if (galleryFilter === 'all') return true
@@ -211,9 +237,21 @@ export function ImageGenPanel(props: {
211
237
  return entry.model === galleryFilter
212
238
  })
213
239
  .filter(entry => galleryRatio === 'all' || normalizeSize(entry.size) === galleryRatio)
240
+ .filter(entry => galleryTagFilter === null || (entry.tags ?? []).includes(galleryTagFilter))
241
+ .filter(entry => galleryQuery.trim() === '' || `${entry.prompt} ${entry.model} ${(entry.tags ?? []).join(' ')}`.toLocaleLowerCase().includes(galleryQuery.trim().toLocaleLowerCase()))
214
242
  .slice()
215
243
  .sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
216
244
 
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)])]
247
+
248
+ const filteredHistory = history.filter(entry => {
249
+ const query = historyQuery.trim().toLocaleLowerCase()
250
+ return (query === '' || `${entry.prompt} ${entry.model}`.toLocaleLowerCase().includes(query))
251
+ && (historyModelFilter === 'all' || entry.model === historyModelFilter)
252
+ && (historyRatioFilter === 'all' || normalizeSize(entry.size) === historyRatioFilter)
253
+ })
254
+
217
255
  // Load the host-persisted history and gallery once on mount (they live in
218
256
  // ~/.dsh on the DSH host, so every browser/device sees the same lists).
219
257
  useEffect(() => {
@@ -227,6 +265,29 @@ export function ImageGenPanel(props: {
227
265
  return () => { disposed = true }
228
266
  }, [api])
229
267
 
268
+ useEffect(() => {
269
+ let disposed = false
270
+ const refresh = (): void => {
271
+ void api.taskList().then(next => {
272
+ if (disposed) return
273
+ setTasks(previous => {
274
+ const completed = next.find(task => task.status === 'completed'
275
+ && !previous.some(old => old.id === task.id && old.status === 'completed')
276
+ && !comparison?.taskIds.includes(task.id))
277
+ if (completed?.result !== undefined) {
278
+ setImages(completed.result.images)
279
+ if (completed.result.history !== undefined) setHistory(completed.result.history)
280
+ setError(completed.result.historyError ?? null)
281
+ }
282
+ return next
283
+ })
284
+ }).catch(() => {})
285
+ }
286
+ refresh()
287
+ const timer = window.setInterval(refresh, 1500)
288
+ return () => { disposed = true; window.clearInterval(timer) }
289
+ }, [api, comparison])
290
+
230
291
  // Close the model dropdown when clicking anywhere outside it.
231
292
  const modelMenuRef = useRef<HTMLDivElement>(null)
232
293
  useEffect(() => {
@@ -273,6 +334,39 @@ export function ImageGenPanel(props: {
273
334
  }
274
335
  }
275
336
 
337
+ const openSettingsGuide = (kind: 'generation' | 'enhancement' | 'disabled'): void => {
338
+ setConfigGuide(kind)
339
+ const openPluginSettings = (): void => {
340
+ const pluginButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(插件|Plugins)$/.test(button.textContent?.trim() ?? ''))
341
+ pluginButton?.click()
342
+ window.setTimeout(() => {
343
+ const imageGenButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /dsh-imagegen/i.test(button.textContent ?? ''))
344
+ if (imageGenButton?.getAttribute('aria-expanded') !== 'true') imageGenButton?.click()
345
+ }, 0)
346
+ }
347
+ const settingsButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(设置|Settings)$/.test(button.textContent?.trim() ?? ''))
348
+ if (settingsButton?.getAttribute('aria-expanded') !== 'true') settingsButton?.click()
349
+ window.setTimeout(openPluginSettings, 0)
350
+ }
351
+
352
+ const enhanceCurrentPrompt = async (): Promise<void> => {
353
+ if (prompt.trim() === '' || enhancing) return
354
+ const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || configured
355
+ if ((config?.promptModel ?? '').trim() === '' || !promptEndpointConfigured || (!promptKeySet && !apiKeySet)) {
356
+ openSettingsGuide('enhancement')
357
+ return
358
+ }
359
+ setEnhancing(true)
360
+ setError(null)
361
+ try {
362
+ setPrompt(await api.enhancePrompt(prompt))
363
+ } catch (caught) {
364
+ setError(errorMessage(caught))
365
+ } finally {
366
+ setEnhancing(false)
367
+ }
368
+ }
369
+
276
370
  /** Read an uploaded reference image into a data URL. */
277
371
  const acceptFile = (file: File | undefined): void => {
278
372
  if (file === undefined) return
@@ -295,6 +389,14 @@ export function ImageGenPanel(props: {
295
389
  /** Run one generation. */
296
390
  const handleGenerate = async (): Promise<void> => {
297
391
  if (generating) return
392
+ if (!enabled) {
393
+ openSettingsGuide('disabled')
394
+ return
395
+ }
396
+ if (!configured || !apiKeySet) {
397
+ openSettingsGuide('generation')
398
+ return
399
+ }
298
400
  const promptText = prompt.trim()
299
401
  if (promptText === '') {
300
402
  setError(tt('prompt.required'))
@@ -306,7 +408,7 @@ export function ImageGenPanel(props: {
306
408
  }
307
409
  const request: GenerateRequest = {
308
410
  mode: tab === 'gallery' ? 'text' : tab,
309
- model,
411
+ model: imageModels.includes(model) ? model : imageModels[0],
310
412
  prompt: promptText,
311
413
  size,
312
414
  quality,
@@ -315,22 +417,18 @@ export function ImageGenPanel(props: {
315
417
  ...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
316
418
  ...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
317
419
  }
318
- setGenerating(true)
319
420
  setError(null)
320
- setImages([])
321
- setStartedAt(Date.now())
322
421
  try {
323
- const result = await api.generate(request)
324
- setImages(result.images)
325
- setViewingHistoryId(null)
326
- setGalleryViewingId(null)
327
- if (result.history !== undefined) setHistory(result.history)
328
- if (result.historyError !== undefined) setError(result.historyError)
422
+ const targetModels = (compareEnabled ? compareModels : [request.model]).filter(candidate => imageModels.includes(candidate))
423
+ if (targetModels.length === 0) {
424
+ setError(tt('compare.selectRequired'))
425
+ return
426
+ }
427
+ const submitted = await Promise.all(targetModels.map(targetModel => api.taskSubmit({ ...request, model: targetModel })))
428
+ setTasks(previous => [...submitted, ...previous.filter(item => !submitted.some(task => task.id === item.id))])
429
+ setComparison(targetModels.length > 1 ? { taskIds: submitted.map(task => task.id), prompt: promptText } : null)
329
430
  } catch (caught) {
330
431
  setError(errorMessage(caught))
331
- } finally {
332
- setGenerating(false)
333
- setStartedAt(null)
334
432
  }
335
433
  }
336
434
 
@@ -408,7 +506,7 @@ export function ImageGenPanel(props: {
408
506
  setQuality(normalizeQuality(entry.quality))
409
507
  setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
410
508
  setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
411
- setModel((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
509
+ setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
412
510
  setRefImage(null)
413
511
  setImages(restored)
414
512
  setError(null)
@@ -518,7 +616,7 @@ export function ImageGenPanel(props: {
518
616
  setQuality(normalizeQuality(entry.quality))
519
617
  setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
520
618
  setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
521
- setModel((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
619
+ setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
522
620
  setRefImage(null)
523
621
  setImages(restored)
524
622
  setError(null)
@@ -551,10 +649,76 @@ export function ImageGenPanel(props: {
551
649
  }
552
650
  }
553
651
 
554
- const generateDisabled = generating || !enabled || !configured
652
+ const applyGalleryTags = async (): Promise<void> => {
653
+ const tags = galleryTagInput.split(',').map(tag => tag.trim()).filter(Boolean)
654
+ if (tags.length === 0 || selectedGalleryIds.size === 0) return
655
+ try {
656
+ let next = gallery
657
+ for (const id of selectedGalleryIds) {
658
+ const existing = next.find(entry => entry.id === id)?.tags ?? []
659
+ next = await api.gallerySetTags(id, [...existing, ...tags])
660
+ }
661
+ setGallery(next)
662
+ setGalleryTagInput('')
663
+ } catch (caught) { setError(errorMessage(caught)) }
664
+ }
665
+
666
+ const startEditingGalleryTags = (entry: HistoryEntry): void => {
667
+ setEditingGalleryTagsId(entry.id)
668
+ setGalleryTagEditInput((entry.tags ?? []).join(', '))
669
+ }
670
+
671
+ const saveGalleryTags = async (id: string): Promise<void> => {
672
+ const tags = galleryTagEditInput.split(',').map(tag => tag.trim()).filter(Boolean)
673
+ try {
674
+ setGallery(await api.gallerySetTags(id, tags))
675
+ setEditingGalleryTagsId(null)
676
+ setGalleryTagEditInput('')
677
+ } catch (caught) { setError(errorMessage(caught)) }
678
+ }
679
+
680
+ const toggleGallerySelection = (id: string): void => {
681
+ setSelectedGalleryIds(previous => {
682
+ const next = new Set(previous)
683
+ if (next.has(id)) next.delete(id)
684
+ else next.add(id)
685
+ return next
686
+ })
687
+ }
688
+
689
+ const clearGallerySelection = (): void => {
690
+ setSelectedGalleryIds(new Set())
691
+ setGallerySelecting(false)
692
+ }
693
+
694
+ const exportGalleryJson = (): void => {
695
+ const entries = gallery.filter(entry => selectedGalleryIds.has(entry.id))
696
+ const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
697
+ const url = URL.createObjectURL(blob)
698
+ const anchor = document.createElement('a')
699
+ anchor.href = url
700
+ anchor.download = `dsh-imagegen-gallery-${new Date().toISOString().slice(0, 10)}.json`
701
+ anchor.click()
702
+ URL.revokeObjectURL(url)
703
+ }
704
+
705
+ const downloadGalleryImages = (): void => {
706
+ gallery.filter(entry => selectedGalleryIds.has(entry.id)).forEach((entry, index) => {
707
+ const image = entry.images[0]
708
+ if (image === undefined) return
709
+ const anchor = document.createElement('a')
710
+ anchor.href = image.url
711
+ anchor.download = `dsh-gallery-${index + 1}.${extensionOf(image.mime)}`
712
+ anchor.click()
713
+ })
714
+ }
715
+
716
+ const generateDisabled = generating
555
717
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
556
718
  const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
557
719
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
720
+ const comparisonTasks = comparison === null ? [] : comparison.taskIds.map(id => tasks.find(task => task.id === id)).filter((task): task is GenerationTask => task !== undefined)
721
+ const comparisonResults = comparisonTasks.filter(task => task.status === 'completed' && task.result !== undefined)
558
722
  const previewFrameScale = Math.max(1, previewScale)
559
723
  const previewImageScale = previewScale / previewFrameScale
560
724
 
@@ -639,13 +803,12 @@ export function ImageGenPanel(props: {
639
803
  {tab === 'gallery' ? (
640
804
  <div className={css.galleryFilters}>
641
805
  <div className={css.galleryFilterHeading}>{tt('gallery.categories')}</div>
642
- {([
643
- ['all', 'gallery.all'],
644
- ['text', 'mode.text'],
645
- ['edit', 'mode.edit'],
646
- ['gpt-image-2', 'gallery.gpt'],
647
- ['grok-imagine-image', 'gallery.grok'],
648
- ] 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]) => (
649
812
  <button
650
813
  key={value}
651
814
  type="button"
@@ -653,7 +816,7 @@ export function ImageGenPanel(props: {
653
816
  data-active={galleryFilter === value ? '' : undefined}
654
817
  onClick={() => { setGalleryFilter(value) }}
655
818
  >
656
- <span>{tt(label as never)}</span>
819
+ <span>{label}</span>
657
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>
658
821
  </button>
659
822
  ))}
@@ -666,6 +829,20 @@ export function ImageGenPanel(props: {
666
829
  </button>
667
830
  ))}
668
831
  </div>
832
+ {galleryTagOptions.length > 0 ? (
833
+ <>
834
+ <div className={css.galleryFilterDivider} />
835
+ <div className={css.galleryFilterHeading}>{tt('gallery.tags')}</div>
836
+ <div className={css.galleryTagFilterList}>
837
+ {galleryTagOptions.map(tag => (
838
+ <button key={tag} type="button" className={css.galleryTagFilter} data-active={galleryTagFilter === tag ? '' : undefined} onClick={() => { setGalleryTagFilter(previous => previous === tag ? null : tag) }}>
839
+ <span>{tag}</span>
840
+ <span>{gallery.filter(entry => (entry.tags ?? []).includes(tag)).length}</span>
841
+ </button>
842
+ ))}
843
+ </div>
844
+ </>
845
+ ) : null}
669
846
  <div className={css.galleryFilterNote}>{tt('gallery.filterHint')}</div>
670
847
  </div>
671
848
  ) : null}
@@ -732,7 +909,6 @@ export function ImageGenPanel(props: {
732
909
  <textarea
733
910
  className={css.prompt}
734
911
  value={prompt}
735
- maxLength={PROMPT_MAX}
736
912
  placeholder={tt('prompt.placeholder')}
737
913
  onChange={(event) => { setPrompt(event.target.value) }}
738
914
  />
@@ -746,6 +922,15 @@ export function ImageGenPanel(props: {
746
922
  <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 3.5h11M2.5 8h11M2.5 12.5h7"/></svg>
747
923
  {tt('templates.open')}
748
924
  </button>
925
+ <button
926
+ type="button"
927
+ className={css.enhanceButton}
928
+ disabled={prompt.trim() === '' || enhancing}
929
+ title={tt('prompt.enhanceHint')}
930
+ onClick={() => { void enhanceCurrentPrompt() }}
931
+ >
932
+ {enhancing ? tt('prompt.enhancing') : tt('prompt.enhance')}
933
+ </button>
749
934
  <span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
750
935
  </div>
751
936
  </section>
@@ -835,7 +1020,7 @@ export function ImageGenPanel(props: {
835
1020
  </button>
836
1021
  {modelOpen ? (
837
1022
  <div className={css.modelMenuList} role="listbox" aria-label={tt('model.label')}>
838
- {MODELS.map(option => (
1023
+ {imageModels.map(option => (
839
1024
  <button
840
1025
  key={option}
841
1026
  type="button"
@@ -852,6 +1037,22 @@ export function ImageGenPanel(props: {
852
1037
  ) : null}
853
1038
  </span>
854
1039
  </label>
1040
+ <div className={css.compareControl}>
1041
+ <label className={css.compareToggle}>
1042
+ <input type="checkbox" checked={compareEnabled} onChange={event => { setCompareEnabled(event.target.checked) }} />
1043
+ <span>{tt('compare.enable')}</span>
1044
+ </label>
1045
+ {compareEnabled ? (
1046
+ <div className={css.compareModelChoices} role="group" aria-label={tt('compare.models')}>
1047
+ {imageModels.map(option => (
1048
+ <label key={option}>
1049
+ <input type="checkbox" checked={compareModels.includes(option)} onChange={() => { setCompareModels(previous => previous.includes(option) ? previous.filter(value => value !== option) : [...previous, option]) }} />
1050
+ <span>{option}</span>
1051
+ </label>
1052
+ ))}
1053
+ </div>
1054
+ ) : null}
1055
+ </div>
855
1056
  <Button
856
1057
  variant="primary"
857
1058
  size="md"
@@ -879,6 +1080,10 @@ export function ImageGenPanel(props: {
879
1080
  <span className={css.galleryCount}>{tt('gallery.count', { count: filteredGallery.length })}</span>
880
1081
  </div>
881
1082
  <div className={css.galleryToolbarActions}>
1083
+ <input className={css.gallerySearch} value={galleryQuery} onChange={event => { setGalleryQuery(event.target.value) }} placeholder={tt('gallery.search')} aria-label={tt('gallery.search')} />
1084
+ <button type="button" className={css.gallerySelectMode} data-active={gallerySelecting ? '' : undefined} aria-pressed={gallerySelecting} onClick={() => { setGallerySelecting(previous => !previous) }}>
1085
+ {gallerySelecting ? tt('gallery.selectionDone') : tt('gallery.select')}
1086
+ </button>
882
1087
  <div className={css.galleryViewToggle} role="group" aria-label={tt('gallery.viewMode')}>
883
1088
  <button type="button" data-active={galleryView === 'masonry' ? '' : undefined} onClick={() => { setGalleryView('masonry') }} title={tt('gallery.masonry')}>
884
1089
  <span aria-hidden="true">▦</span> {tt('gallery.masonry')}
@@ -894,6 +1099,16 @@ export function ImageGenPanel(props: {
894
1099
  {gallery.length > 0 ? <button type="button" className={css.galleryClear} onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
895
1100
  </div>
896
1101
  </header>
1102
+ {selectedGalleryIds.size > 0 ? (
1103
+ <section className={css.gallerySelectionBar} aria-label={tt('gallery.selected', { count: selectedGalleryIds.size })}>
1104
+ <strong>{tt('gallery.selected', { count: selectedGalleryIds.size })}</strong>
1105
+ <input className={css.galleryTagInput} value={galleryTagInput} onChange={event => { setGalleryTagInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} />
1106
+ <button type="button" className={css.galleryBulkButton} disabled={galleryTagInput.trim() === ''} onClick={() => { void applyGalleryTags() }}>{tt('gallery.tagsApply')}</button>
1107
+ <button type="button" className={css.galleryBulkButton} onClick={downloadGalleryImages}>{tt('gallery.downloadSelected')}</button>
1108
+ <button type="button" className={css.galleryBulkButton} onClick={exportGalleryJson}>{tt('gallery.exportJson')}</button>
1109
+ <button type="button" className={css.gallerySelectionClear} onClick={clearGallerySelection}>{tt('gallery.selectionClear')}</button>
1110
+ </section>
1111
+ ) : null}
897
1112
  {filteredGallery.length === 0 ? (
898
1113
  <div className={css.historyEmpty}>{tt('gallery.empty')}</div>
899
1114
  ) : (
@@ -902,8 +1117,11 @@ export function ImageGenPanel(props: {
902
1117
  const image = entry.images[0]
903
1118
  if (image === undefined) return null
904
1119
  return (
905
- <article key={entry.id} className={css.galleryCard}>
906
- <button type="button" className={css.galleryImageButton} onClick={() => { void viewGalleryEntry(entry) }} title={tt('preview.open')}>
1120
+ <article key={entry.id} className={css.galleryCard} data-selected={selectedGalleryIds.has(entry.id) ? '' : undefined}>
1121
+ <label className={css.gallerySelect} title={tt('gallery.select')}>
1122
+ <input type="checkbox" checked={selectedGalleryIds.has(entry.id)} onChange={() => { setGallerySelecting(true); toggleGallerySelection(entry.id) }} />
1123
+ </label>
1124
+ <button type="button" className={css.galleryImageButton} data-selecting={gallerySelecting ? '' : undefined} onClick={() => { if (gallerySelecting) toggleGallerySelection(entry.id); else void viewGalleryEntry(entry) }} title={gallerySelecting ? tt('gallery.select') : tt('preview.open')}>
907
1125
  <img className={css.galleryImage} src={image.url} alt={entry.prompt} />
908
1126
  <span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
909
1127
  </button>
@@ -912,9 +1130,20 @@ export function ImageGenPanel(props: {
912
1130
  <span className={css.galleryCardInfo}>
913
1131
  <strong>{entry.prompt || tt('gallery.untitled')}</strong>
914
1132
  <small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
1133
+ <span className={css.galleryTags}>
1134
+ {(entry.tags ?? []).map(tag => <button key={tag} type="button" onClick={() => { setGalleryTagFilter(tag) }}>{tag}</button>)}
1135
+ <button type="button" className={css.galleryTagEdit} onClick={() => { startEditingGalleryTags(entry) }} title={tt('gallery.editTags')}>{tt('gallery.tagsEditShort')}</button>
1136
+ </span>
915
1137
  </span>
916
1138
  <button type="button" className={css.galleryRemove} onClick={() => { void deleteGalleryEntry(entry.id) }} title={tt('gallery.delete')}>×</button>
917
1139
  </div>
1140
+ {editingGalleryTagsId === entry.id ? (
1141
+ <form className={css.galleryTagEditor} onSubmit={event => { event.preventDefault(); void saveGalleryTags(entry.id) }}>
1142
+ <input value={galleryTagEditInput} onChange={event => { setGalleryTagEditInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} autoFocus />
1143
+ <button type="submit">{tt('gallery.tagsSave')}</button>
1144
+ <button type="button" onClick={() => { setEditingGalleryTagsId(null); setGalleryTagEditInput('') }}>{tt('gallery.tagsCancel')}</button>
1145
+ </form>
1146
+ ) : null}
918
1147
  </article>
919
1148
  )
920
1149
  })}
@@ -922,6 +1151,32 @@ export function ImageGenPanel(props: {
922
1151
  )}
923
1152
  </div>
924
1153
  ) : null}
1154
+ {tab !== 'gallery' && tasks.length > 0 ? (
1155
+ <section className={css.taskTray} aria-label={tt('tasks.title')}>
1156
+ <header className={css.taskTrayHeader}>{tt('tasks.title')} <span>{tasks.filter(task => task.status === 'queued' || task.status === 'running').length}</span></header>
1157
+ {tasks.slice(0, 5).map(task => (
1158
+ <div key={task.id} className={css.taskRow} data-status={task.status}>
1159
+ <span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
1160
+ <span className={css.taskPrompt}>{task.request.prompt}</span>
1161
+ {(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
1162
+ {task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
1163
+ </div>
1164
+ ))}
1165
+ </section>
1166
+ ) : null}
1167
+ {tab !== 'gallery' && comparison !== null ? (
1168
+ <section className={css.comparisonBoard} aria-label={tt('compare.title')}>
1169
+ <header><div><strong>{tt('compare.title')}</strong><span>{comparisonResults.length} / {comparisonTasks.length}</span></div><button type="button" disabled={comparisonResults.length === 0} onClick={() => { setComparisonFullscreen(true) }}>{tt('compare.fullscreen')}</button></header>
1170
+ <div className={css.comparisonGrid}>
1171
+ {comparisonTasks.map(task => (
1172
+ <article key={task.id}>
1173
+ <strong>{task.request.model}</strong>
1174
+ {task.result?.images[0] !== undefined ? <img src={srcOf(task.result.images[0])} alt={task.request.model} /> : <span>{tt(`tasks.${task.status}` as never)}</span>}
1175
+ </article>
1176
+ ))}
1177
+ </div>
1178
+ </section>
1179
+ ) : null}
925
1180
  {generating ? (
926
1181
  <div className={css.canvasState} role="status">
927
1182
  <span className={css.bigSpinner} />
@@ -1075,11 +1330,23 @@ export function ImageGenPanel(props: {
1075
1330
  ) : null}
1076
1331
  </header>
1077
1332
 
1078
- {history.length === 0 ? (
1333
+ <div className={css.historyFilters}>
1334
+ <input className={css.historySearch} value={historyQuery} onChange={event => { setHistoryQuery(event.target.value) }} placeholder={tt('history.search')} aria-label={tt('history.search')} />
1335
+ <select value={historyModelFilter} onChange={event => { setHistoryModelFilter(event.target.value) }} aria-label={tt('history.model')}>
1336
+ <option value="all">{tt('history.allModels')}</option>
1337
+ {[...new Set(history.map(entry => entry.model))].map(option => <option key={option} value={option}>{option}</option>)}
1338
+ </select>
1339
+ <select value={historyRatioFilter} onChange={event => { setHistoryRatioFilter(event.target.value) }} aria-label={tt('history.ratio')}>
1340
+ <option value="all">{tt('history.allRatios')}</option>
1341
+ {[...new Set(history.map(entry => normalizeSize(entry.size)))].map(option => <option key={option} value={option}>{option}</option>)}
1342
+ </select>
1343
+ </div>
1344
+
1345
+ {filteredHistory.length === 0 ? (
1079
1346
  <div className={css.historyEmpty}>{tt('history.empty')}</div>
1080
1347
  ) : (
1081
1348
  <div className={css.historyList}>
1082
- {history.map(entry => (
1349
+ {filteredHistory.map(entry => (
1083
1350
  <div
1084
1351
  key={entry.id}
1085
1352
  className={css.historyItem}
@@ -1145,6 +1412,26 @@ export function ImageGenPanel(props: {
1145
1412
  />
1146
1413
  ) : null}
1147
1414
 
1415
+ {configGuide !== null ? (
1416
+ <div className={css.configGuide} role="dialog" aria-modal="true" aria-label={tt(`config.${configGuide}Title` as never)}>
1417
+ <div className={css.configGuideBody}>
1418
+ <strong>{tt(`config.${configGuide}Title` as never)}</strong>
1419
+ <span>{tt(`config.${configGuide}Hint` as never)}</span>
1420
+ <button type="button" onClick={() => { setConfigGuide(null) }}>{tt('preview.close')}</button>
1421
+ </div>
1422
+ </div>
1423
+ ) : null}
1424
+
1425
+ {comparisonFullscreen && comparison !== null ? createPortal(
1426
+ <div className={css.comparisonFullscreen} role="dialog" aria-modal="true" aria-label={tt('compare.title')} onClick={() => { setComparisonFullscreen(false) }}>
1427
+ <button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setComparisonFullscreen(false) }}>×</button>
1428
+ <div className={css.comparisonFullscreenGrid} onClick={event => { event.stopPropagation() }}>
1429
+ {comparisonResults.map(task => (
1430
+ <figure key={task.id}><figcaption>{task.request.model}</figcaption>{task.result!.images.map((image, index) => <img key={index} src={srcOf(image)} alt={task.request.model} />)}</figure>
1431
+ ))}
1432
+ </div>
1433
+ </div>, document.body) : null}
1434
+
1148
1435
  {/* -------------------------------------------------- preview overlay */}
1149
1436
  {preview !== null && previewImage !== null
1150
1437
  ? createPortal(