@dickpy/dsh-imagegen 1.0.19 → 1.1.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,7 +14,7 @@ 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
19
  import css from './panel.module.css'
20
20
 
@@ -49,7 +49,6 @@ const QUALITIES = ['auto', '1k', '2k', '4k'] as const
49
49
  /** Detail options ('' = omit the passthrough). */
50
50
  const DETAILS = ['', 'standard', 'high'] as const
51
51
 
52
- const PROMPT_MAX = 2000
53
52
  const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
54
53
  const PREVIEW_SCALE_MIN = 0.5
55
54
  const PREVIEW_SCALE_MAX = 3
@@ -96,11 +95,11 @@ function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
96
95
  return value
97
96
  }
98
97
 
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
98
+ /** Track one redacted secret field without exposing its value to the panel. */
99
+ function useSecretSet(scope: ImageGenScope, field: string): boolean {
100
+ const [isSet, setIsSet] = useState(scope.getSecretSetSnapshot(field))
101
+ useEffect(() => scope.subscribeSecretSets(() => { setIsSet(scope.getSecretSetSnapshot(field)) }), [field, scope])
102
+ return isSet
104
103
  }
105
104
 
106
105
  /** Tick a seconds counter while `running`. */
@@ -155,6 +154,7 @@ function formatTime(timestamp: number): string {
155
154
  type PanelTab = GenerateMode | 'gallery'
156
155
 
157
156
  type GalleryFilter = 'all' | 'text' | 'edit' | 'gpt-image-2' | 'grok-imagine-image'
157
+ type ComparisonSession = { taskIds: string[]; prompt: string }
158
158
 
159
159
  /** Render the studio. */
160
160
  export function ImageGenPanel(props: {
@@ -166,8 +166,9 @@ export function ImageGenPanel(props: {
166
166
  const enabled = config?.enabled ?? true
167
167
  const apiUrl = config?.apiUrl ?? ''
168
168
  const configured = apiUrl.trim() !== ''
169
- const keySet = useKeySet(scope)
170
- const connected = enabled && configured && keySet
169
+ const apiKeySet = useSecretSet(scope, 'apiKey')
170
+ const promptKeySet = useSecretSet(scope, 'promptApiKey')
171
+ const connected = enabled && configured && apiKeySet
171
172
 
172
173
  const [tab, setTab] = useState<PanelTab>('text')
173
174
  const [prompt, setPrompt] = useState('')
@@ -176,11 +177,15 @@ export function ImageGenPanel(props: {
176
177
  const [count, setCount] = useState(1)
177
178
  const [detail, setDetail] = useState('')
178
179
  const [model, setModel] = useState<string>(MODELS[0])
180
+ const [compareEnabled, setCompareEnabled] = useState(false)
181
+ const [compareModels, setCompareModels] = useState<string[]>([...MODELS])
179
182
  const [modelOpen, setModelOpen] = useState(false)
180
183
  const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
181
184
  const [images, setImages] = useState<GeneratedImage[]>([])
182
185
  const [error, setError] = useState<string | null>(null)
183
186
  const [generating, setGenerating] = useState(false)
187
+ const [enhancing, setEnhancing] = useState(false)
188
+ const [configGuide, setConfigGuide] = useState<'generation' | 'enhancement' | 'disabled' | null>(null)
184
189
  const [startedAt, setStartedAt] = useState<number | null>(null)
185
190
  const [history, setHistory] = useState<HistoryEntry[]>([])
186
191
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
@@ -190,8 +195,18 @@ export function ImageGenPanel(props: {
190
195
  const [galleryMessage, setGalleryMessage] = useState<string | null>(null)
191
196
  const [galleryFilter, setGalleryFilter] = useState<GalleryFilter>('all')
192
197
  const [galleryRatio, setGalleryRatio] = useState('all')
198
+ const [galleryTagFilter, setGalleryTagFilter] = useState<string | null>(null)
193
199
  const [galleryView, setGalleryView] = useState<'masonry' | 'grid'>('masonry')
194
200
  const [gallerySort, setGallerySort] = useState<'newest' | 'oldest'>('newest')
201
+ const [galleryQuery, setGalleryQuery] = useState('')
202
+ const [galleryTagInput, setGalleryTagInput] = useState('')
203
+ const [editingGalleryTagsId, setEditingGalleryTagsId] = useState<string | null>(null)
204
+ const [galleryTagEditInput, setGalleryTagEditInput] = useState('')
205
+ const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set())
206
+ const [gallerySelecting, setGallerySelecting] = useState(false)
207
+ const [historyQuery, setHistoryQuery] = useState('')
208
+ const [historyModelFilter, setHistoryModelFilter] = useState('all')
209
+ const [historyRatioFilter, setHistoryRatioFilter] = useState('all')
195
210
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
196
211
  const [previewScale, setPreviewScale] = useState(1)
197
212
  const [promptCopied, setPromptCopied] = useState(false)
@@ -200,6 +215,9 @@ export function ImageGenPanel(props: {
200
215
  const [updateMessage, setUpdateMessage] = useState<string | null>(null)
201
216
  const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
202
217
  const [libraryOpen, setLibraryOpen] = useState(false)
218
+ const [tasks, setTasks] = useState<GenerationTask[]>([])
219
+ const [comparison, setComparison] = useState<ComparisonSession | null>(null)
220
+ const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
203
221
  const fileInput = useRef<HTMLInputElement>(null)
204
222
  const previewStage = useRef<HTMLDivElement>(null)
205
223
  const elapsed = useElapsed(generating, startedAt)
@@ -211,9 +229,20 @@ export function ImageGenPanel(props: {
211
229
  return entry.model === galleryFilter
212
230
  })
213
231
  .filter(entry => galleryRatio === 'all' || normalizeSize(entry.size) === galleryRatio)
232
+ .filter(entry => galleryTagFilter === null || (entry.tags ?? []).includes(galleryTagFilter))
233
+ .filter(entry => galleryQuery.trim() === '' || `${entry.prompt} ${entry.model} ${(entry.tags ?? []).join(' ')}`.toLocaleLowerCase().includes(galleryQuery.trim().toLocaleLowerCase()))
214
234
  .slice()
215
235
  .sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
216
236
 
237
+ const galleryTagOptions = [...new Set(gallery.flatMap(entry => entry.tags ?? []))].sort((a, b) => a.localeCompare(b))
238
+
239
+ const filteredHistory = history.filter(entry => {
240
+ const query = historyQuery.trim().toLocaleLowerCase()
241
+ return (query === '' || `${entry.prompt} ${entry.model}`.toLocaleLowerCase().includes(query))
242
+ && (historyModelFilter === 'all' || entry.model === historyModelFilter)
243
+ && (historyRatioFilter === 'all' || normalizeSize(entry.size) === historyRatioFilter)
244
+ })
245
+
217
246
  // Load the host-persisted history and gallery once on mount (they live in
218
247
  // ~/.dsh on the DSH host, so every browser/device sees the same lists).
219
248
  useEffect(() => {
@@ -227,6 +256,29 @@ export function ImageGenPanel(props: {
227
256
  return () => { disposed = true }
228
257
  }, [api])
229
258
 
259
+ useEffect(() => {
260
+ let disposed = false
261
+ const refresh = (): void => {
262
+ void api.taskList().then(next => {
263
+ if (disposed) return
264
+ setTasks(previous => {
265
+ const completed = next.find(task => task.status === 'completed'
266
+ && !previous.some(old => old.id === task.id && old.status === 'completed')
267
+ && !comparison?.taskIds.includes(task.id))
268
+ if (completed?.result !== undefined) {
269
+ setImages(completed.result.images)
270
+ if (completed.result.history !== undefined) setHistory(completed.result.history)
271
+ setError(completed.result.historyError ?? null)
272
+ }
273
+ return next
274
+ })
275
+ }).catch(() => {})
276
+ }
277
+ refresh()
278
+ const timer = window.setInterval(refresh, 1500)
279
+ return () => { disposed = true; window.clearInterval(timer) }
280
+ }, [api, comparison])
281
+
230
282
  // Close the model dropdown when clicking anywhere outside it.
231
283
  const modelMenuRef = useRef<HTMLDivElement>(null)
232
284
  useEffect(() => {
@@ -273,6 +325,39 @@ export function ImageGenPanel(props: {
273
325
  }
274
326
  }
275
327
 
328
+ const openSettingsGuide = (kind: 'generation' | 'enhancement' | 'disabled'): void => {
329
+ setConfigGuide(kind)
330
+ const openPluginSettings = (): void => {
331
+ const pluginButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(插件|Plugins)$/.test(button.textContent?.trim() ?? ''))
332
+ pluginButton?.click()
333
+ window.setTimeout(() => {
334
+ const imageGenButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /dsh-imagegen/i.test(button.textContent ?? ''))
335
+ if (imageGenButton?.getAttribute('aria-expanded') !== 'true') imageGenButton?.click()
336
+ }, 0)
337
+ }
338
+ const settingsButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(设置|Settings)$/.test(button.textContent?.trim() ?? ''))
339
+ if (settingsButton?.getAttribute('aria-expanded') !== 'true') settingsButton?.click()
340
+ window.setTimeout(openPluginSettings, 0)
341
+ }
342
+
343
+ const enhanceCurrentPrompt = async (): Promise<void> => {
344
+ if (prompt.trim() === '' || enhancing) return
345
+ const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || configured
346
+ if ((config?.promptModel ?? '').trim() === '' || !promptEndpointConfigured || (!promptKeySet && !apiKeySet)) {
347
+ openSettingsGuide('enhancement')
348
+ return
349
+ }
350
+ setEnhancing(true)
351
+ setError(null)
352
+ try {
353
+ setPrompt(await api.enhancePrompt(prompt))
354
+ } catch (caught) {
355
+ setError(errorMessage(caught))
356
+ } finally {
357
+ setEnhancing(false)
358
+ }
359
+ }
360
+
276
361
  /** Read an uploaded reference image into a data URL. */
277
362
  const acceptFile = (file: File | undefined): void => {
278
363
  if (file === undefined) return
@@ -295,6 +380,14 @@ export function ImageGenPanel(props: {
295
380
  /** Run one generation. */
296
381
  const handleGenerate = async (): Promise<void> => {
297
382
  if (generating) return
383
+ if (!enabled) {
384
+ openSettingsGuide('disabled')
385
+ return
386
+ }
387
+ if (!configured || !apiKeySet) {
388
+ openSettingsGuide('generation')
389
+ return
390
+ }
298
391
  const promptText = prompt.trim()
299
392
  if (promptText === '') {
300
393
  setError(tt('prompt.required'))
@@ -315,22 +408,18 @@ export function ImageGenPanel(props: {
315
408
  ...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
316
409
  ...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
317
410
  }
318
- setGenerating(true)
319
411
  setError(null)
320
- setImages([])
321
- setStartedAt(Date.now())
322
412
  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)
413
+ const targetModels = compareEnabled ? compareModels : [model]
414
+ if (targetModels.length === 0) {
415
+ setError(tt('compare.selectRequired'))
416
+ return
417
+ }
418
+ const submitted = await Promise.all(targetModels.map(targetModel => api.taskSubmit({ ...request, model: targetModel })))
419
+ setTasks(previous => [...submitted, ...previous.filter(item => !submitted.some(task => task.id === item.id))])
420
+ setComparison(targetModels.length > 1 ? { taskIds: submitted.map(task => task.id), prompt: promptText } : null)
329
421
  } catch (caught) {
330
422
  setError(errorMessage(caught))
331
- } finally {
332
- setGenerating(false)
333
- setStartedAt(null)
334
423
  }
335
424
  }
336
425
 
@@ -551,10 +640,76 @@ export function ImageGenPanel(props: {
551
640
  }
552
641
  }
553
642
 
554
- const generateDisabled = generating || !enabled || !configured
643
+ const applyGalleryTags = async (): Promise<void> => {
644
+ const tags = galleryTagInput.split(',').map(tag => tag.trim()).filter(Boolean)
645
+ if (tags.length === 0 || selectedGalleryIds.size === 0) return
646
+ try {
647
+ let next = gallery
648
+ for (const id of selectedGalleryIds) {
649
+ const existing = next.find(entry => entry.id === id)?.tags ?? []
650
+ next = await api.gallerySetTags(id, [...existing, ...tags])
651
+ }
652
+ setGallery(next)
653
+ setGalleryTagInput('')
654
+ } catch (caught) { setError(errorMessage(caught)) }
655
+ }
656
+
657
+ const startEditingGalleryTags = (entry: HistoryEntry): void => {
658
+ setEditingGalleryTagsId(entry.id)
659
+ setGalleryTagEditInput((entry.tags ?? []).join(', '))
660
+ }
661
+
662
+ const saveGalleryTags = async (id: string): Promise<void> => {
663
+ const tags = galleryTagEditInput.split(',').map(tag => tag.trim()).filter(Boolean)
664
+ try {
665
+ setGallery(await api.gallerySetTags(id, tags))
666
+ setEditingGalleryTagsId(null)
667
+ setGalleryTagEditInput('')
668
+ } catch (caught) { setError(errorMessage(caught)) }
669
+ }
670
+
671
+ const toggleGallerySelection = (id: string): void => {
672
+ setSelectedGalleryIds(previous => {
673
+ const next = new Set(previous)
674
+ if (next.has(id)) next.delete(id)
675
+ else next.add(id)
676
+ return next
677
+ })
678
+ }
679
+
680
+ const clearGallerySelection = (): void => {
681
+ setSelectedGalleryIds(new Set())
682
+ setGallerySelecting(false)
683
+ }
684
+
685
+ const exportGalleryJson = (): void => {
686
+ const entries = gallery.filter(entry => selectedGalleryIds.has(entry.id))
687
+ const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
688
+ const url = URL.createObjectURL(blob)
689
+ const anchor = document.createElement('a')
690
+ anchor.href = url
691
+ anchor.download = `dsh-imagegen-gallery-${new Date().toISOString().slice(0, 10)}.json`
692
+ anchor.click()
693
+ URL.revokeObjectURL(url)
694
+ }
695
+
696
+ const downloadGalleryImages = (): void => {
697
+ gallery.filter(entry => selectedGalleryIds.has(entry.id)).forEach((entry, index) => {
698
+ const image = entry.images[0]
699
+ if (image === undefined) return
700
+ const anchor = document.createElement('a')
701
+ anchor.href = image.url
702
+ anchor.download = `dsh-gallery-${index + 1}.${extensionOf(image.mime)}`
703
+ anchor.click()
704
+ })
705
+ }
706
+
707
+ const generateDisabled = generating
555
708
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
556
709
  const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
557
710
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
711
+ const comparisonTasks = comparison === null ? [] : comparison.taskIds.map(id => tasks.find(task => task.id === id)).filter((task): task is GenerationTask => task !== undefined)
712
+ const comparisonResults = comparisonTasks.filter(task => task.status === 'completed' && task.result !== undefined)
558
713
  const previewFrameScale = Math.max(1, previewScale)
559
714
  const previewImageScale = previewScale / previewFrameScale
560
715
 
@@ -666,6 +821,20 @@ export function ImageGenPanel(props: {
666
821
  </button>
667
822
  ))}
668
823
  </div>
824
+ {galleryTagOptions.length > 0 ? (
825
+ <>
826
+ <div className={css.galleryFilterDivider} />
827
+ <div className={css.galleryFilterHeading}>{tt('gallery.tags')}</div>
828
+ <div className={css.galleryTagFilterList}>
829
+ {galleryTagOptions.map(tag => (
830
+ <button key={tag} type="button" className={css.galleryTagFilter} data-active={galleryTagFilter === tag ? '' : undefined} onClick={() => { setGalleryTagFilter(previous => previous === tag ? null : tag) }}>
831
+ <span>{tag}</span>
832
+ <span>{gallery.filter(entry => (entry.tags ?? []).includes(tag)).length}</span>
833
+ </button>
834
+ ))}
835
+ </div>
836
+ </>
837
+ ) : null}
669
838
  <div className={css.galleryFilterNote}>{tt('gallery.filterHint')}</div>
670
839
  </div>
671
840
  ) : null}
@@ -732,7 +901,6 @@ export function ImageGenPanel(props: {
732
901
  <textarea
733
902
  className={css.prompt}
734
903
  value={prompt}
735
- maxLength={PROMPT_MAX}
736
904
  placeholder={tt('prompt.placeholder')}
737
905
  onChange={(event) => { setPrompt(event.target.value) }}
738
906
  />
@@ -746,6 +914,15 @@ export function ImageGenPanel(props: {
746
914
  <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
915
  {tt('templates.open')}
748
916
  </button>
917
+ <button
918
+ type="button"
919
+ className={css.enhanceButton}
920
+ disabled={prompt.trim() === '' || enhancing}
921
+ title={tt('prompt.enhanceHint')}
922
+ onClick={() => { void enhanceCurrentPrompt() }}
923
+ >
924
+ {enhancing ? tt('prompt.enhancing') : tt('prompt.enhance')}
925
+ </button>
749
926
  <span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
750
927
  </div>
751
928
  </section>
@@ -852,6 +1029,22 @@ export function ImageGenPanel(props: {
852
1029
  ) : null}
853
1030
  </span>
854
1031
  </label>
1032
+ <div className={css.compareControl}>
1033
+ <label className={css.compareToggle}>
1034
+ <input type="checkbox" checked={compareEnabled} onChange={event => { setCompareEnabled(event.target.checked) }} />
1035
+ <span>{tt('compare.enable')}</span>
1036
+ </label>
1037
+ {compareEnabled ? (
1038
+ <div className={css.compareModelChoices} role="group" aria-label={tt('compare.models')}>
1039
+ {MODELS.map(option => (
1040
+ <label key={option}>
1041
+ <input type="checkbox" checked={compareModels.includes(option)} onChange={() => { setCompareModels(previous => previous.includes(option) ? previous.filter(value => value !== option) : [...previous, option]) }} />
1042
+ <span>{option}</span>
1043
+ </label>
1044
+ ))}
1045
+ </div>
1046
+ ) : null}
1047
+ </div>
855
1048
  <Button
856
1049
  variant="primary"
857
1050
  size="md"
@@ -879,6 +1072,10 @@ export function ImageGenPanel(props: {
879
1072
  <span className={css.galleryCount}>{tt('gallery.count', { count: filteredGallery.length })}</span>
880
1073
  </div>
881
1074
  <div className={css.galleryToolbarActions}>
1075
+ <input className={css.gallerySearch} value={galleryQuery} onChange={event => { setGalleryQuery(event.target.value) }} placeholder={tt('gallery.search')} aria-label={tt('gallery.search')} />
1076
+ <button type="button" className={css.gallerySelectMode} data-active={gallerySelecting ? '' : undefined} aria-pressed={gallerySelecting} onClick={() => { setGallerySelecting(previous => !previous) }}>
1077
+ {gallerySelecting ? tt('gallery.selectionDone') : tt('gallery.select')}
1078
+ </button>
882
1079
  <div className={css.galleryViewToggle} role="group" aria-label={tt('gallery.viewMode')}>
883
1080
  <button type="button" data-active={galleryView === 'masonry' ? '' : undefined} onClick={() => { setGalleryView('masonry') }} title={tt('gallery.masonry')}>
884
1081
  <span aria-hidden="true">▦</span> {tt('gallery.masonry')}
@@ -894,6 +1091,16 @@ export function ImageGenPanel(props: {
894
1091
  {gallery.length > 0 ? <button type="button" className={css.galleryClear} onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
895
1092
  </div>
896
1093
  </header>
1094
+ {selectedGalleryIds.size > 0 ? (
1095
+ <section className={css.gallerySelectionBar} aria-label={tt('gallery.selected', { count: selectedGalleryIds.size })}>
1096
+ <strong>{tt('gallery.selected', { count: selectedGalleryIds.size })}</strong>
1097
+ <input className={css.galleryTagInput} value={galleryTagInput} onChange={event => { setGalleryTagInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} />
1098
+ <button type="button" className={css.galleryBulkButton} disabled={galleryTagInput.trim() === ''} onClick={() => { void applyGalleryTags() }}>{tt('gallery.tagsApply')}</button>
1099
+ <button type="button" className={css.galleryBulkButton} onClick={downloadGalleryImages}>{tt('gallery.downloadSelected')}</button>
1100
+ <button type="button" className={css.galleryBulkButton} onClick={exportGalleryJson}>{tt('gallery.exportJson')}</button>
1101
+ <button type="button" className={css.gallerySelectionClear} onClick={clearGallerySelection}>{tt('gallery.selectionClear')}</button>
1102
+ </section>
1103
+ ) : null}
897
1104
  {filteredGallery.length === 0 ? (
898
1105
  <div className={css.historyEmpty}>{tt('gallery.empty')}</div>
899
1106
  ) : (
@@ -902,8 +1109,11 @@ export function ImageGenPanel(props: {
902
1109
  const image = entry.images[0]
903
1110
  if (image === undefined) return null
904
1111
  return (
905
- <article key={entry.id} className={css.galleryCard}>
906
- <button type="button" className={css.galleryImageButton} onClick={() => { void viewGalleryEntry(entry) }} title={tt('preview.open')}>
1112
+ <article key={entry.id} className={css.galleryCard} data-selected={selectedGalleryIds.has(entry.id) ? '' : undefined}>
1113
+ <label className={css.gallerySelect} title={tt('gallery.select')}>
1114
+ <input type="checkbox" checked={selectedGalleryIds.has(entry.id)} onChange={() => { setGallerySelecting(true); toggleGallerySelection(entry.id) }} />
1115
+ </label>
1116
+ <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
1117
  <img className={css.galleryImage} src={image.url} alt={entry.prompt} />
908
1118
  <span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
909
1119
  </button>
@@ -912,9 +1122,20 @@ export function ImageGenPanel(props: {
912
1122
  <span className={css.galleryCardInfo}>
913
1123
  <strong>{entry.prompt || tt('gallery.untitled')}</strong>
914
1124
  <small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
1125
+ <span className={css.galleryTags}>
1126
+ {(entry.tags ?? []).map(tag => <button key={tag} type="button" onClick={() => { setGalleryTagFilter(tag) }}>{tag}</button>)}
1127
+ <button type="button" className={css.galleryTagEdit} onClick={() => { startEditingGalleryTags(entry) }} title={tt('gallery.editTags')}>{tt('gallery.tagsEditShort')}</button>
1128
+ </span>
915
1129
  </span>
916
1130
  <button type="button" className={css.galleryRemove} onClick={() => { void deleteGalleryEntry(entry.id) }} title={tt('gallery.delete')}>×</button>
917
1131
  </div>
1132
+ {editingGalleryTagsId === entry.id ? (
1133
+ <form className={css.galleryTagEditor} onSubmit={event => { event.preventDefault(); void saveGalleryTags(entry.id) }}>
1134
+ <input value={galleryTagEditInput} onChange={event => { setGalleryTagEditInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} autoFocus />
1135
+ <button type="submit">{tt('gallery.tagsSave')}</button>
1136
+ <button type="button" onClick={() => { setEditingGalleryTagsId(null); setGalleryTagEditInput('') }}>{tt('gallery.tagsCancel')}</button>
1137
+ </form>
1138
+ ) : null}
918
1139
  </article>
919
1140
  )
920
1141
  })}
@@ -922,6 +1143,32 @@ export function ImageGenPanel(props: {
922
1143
  )}
923
1144
  </div>
924
1145
  ) : null}
1146
+ {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
+ ))}
1157
+ </section>
1158
+ ) : null}
1159
+ {tab !== 'gallery' && comparison !== null ? (
1160
+ <section className={css.comparisonBoard} aria-label={tt('compare.title')}>
1161
+ <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>
1162
+ <div className={css.comparisonGrid}>
1163
+ {comparisonTasks.map(task => (
1164
+ <article key={task.id}>
1165
+ <strong>{task.request.model}</strong>
1166
+ {task.result?.images[0] !== undefined ? <img src={srcOf(task.result.images[0])} alt={task.request.model} /> : <span>{tt(`tasks.${task.status}` as never)}</span>}
1167
+ </article>
1168
+ ))}
1169
+ </div>
1170
+ </section>
1171
+ ) : null}
925
1172
  {generating ? (
926
1173
  <div className={css.canvasState} role="status">
927
1174
  <span className={css.bigSpinner} />
@@ -1075,11 +1322,23 @@ export function ImageGenPanel(props: {
1075
1322
  ) : null}
1076
1323
  </header>
1077
1324
 
1078
- {history.length === 0 ? (
1325
+ <div className={css.historyFilters}>
1326
+ <input className={css.historySearch} value={historyQuery} onChange={event => { setHistoryQuery(event.target.value) }} placeholder={tt('history.search')} aria-label={tt('history.search')} />
1327
+ <select value={historyModelFilter} onChange={event => { setHistoryModelFilter(event.target.value) }} aria-label={tt('history.model')}>
1328
+ <option value="all">{tt('history.allModels')}</option>
1329
+ {[...new Set(history.map(entry => entry.model))].map(option => <option key={option} value={option}>{option}</option>)}
1330
+ </select>
1331
+ <select value={historyRatioFilter} onChange={event => { setHistoryRatioFilter(event.target.value) }} aria-label={tt('history.ratio')}>
1332
+ <option value="all">{tt('history.allRatios')}</option>
1333
+ {[...new Set(history.map(entry => normalizeSize(entry.size)))].map(option => <option key={option} value={option}>{option}</option>)}
1334
+ </select>
1335
+ </div>
1336
+
1337
+ {filteredHistory.length === 0 ? (
1079
1338
  <div className={css.historyEmpty}>{tt('history.empty')}</div>
1080
1339
  ) : (
1081
1340
  <div className={css.historyList}>
1082
- {history.map(entry => (
1341
+ {filteredHistory.map(entry => (
1083
1342
  <div
1084
1343
  key={entry.id}
1085
1344
  className={css.historyItem}
@@ -1145,6 +1404,26 @@ export function ImageGenPanel(props: {
1145
1404
  />
1146
1405
  ) : null}
1147
1406
 
1407
+ {configGuide !== null ? (
1408
+ <div className={css.configGuide} role="dialog" aria-modal="true" aria-label={tt(`config.${configGuide}Title` as never)}>
1409
+ <div className={css.configGuideBody}>
1410
+ <strong>{tt(`config.${configGuide}Title` as never)}</strong>
1411
+ <span>{tt(`config.${configGuide}Hint` as never)}</span>
1412
+ <button type="button" onClick={() => { setConfigGuide(null) }}>{tt('preview.close')}</button>
1413
+ </div>
1414
+ </div>
1415
+ ) : null}
1416
+
1417
+ {comparisonFullscreen && comparison !== null ? createPortal(
1418
+ <div className={css.comparisonFullscreen} role="dialog" aria-modal="true" aria-label={tt('compare.title')} onClick={() => { setComparisonFullscreen(false) }}>
1419
+ <button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setComparisonFullscreen(false) }}>×</button>
1420
+ <div className={css.comparisonFullscreenGrid} onClick={event => { event.stopPropagation() }}>
1421
+ {comparisonResults.map(task => (
1422
+ <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>
1423
+ ))}
1424
+ </div>
1425
+ </div>, document.body) : null}
1426
+
1148
1427
  {/* -------------------------------------------------- preview overlay */}
1149
1428
  {preview !== null && previewImage !== null
1150
1429
  ? createPortal(
@@ -12,6 +12,7 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
12
12
  import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
13
  import type { ImageGenScope } from './settings-scope.ts'
14
14
  import { PLUGIN_VERSION } from '../protocol.ts'
15
+ import { PROMPT_ENHANCE_API } from '../protocol.ts'
15
16
  import css from './settings-card.module.css'
16
17
 
17
18
  /** The fields this card edits (the namespace's full schema). */
@@ -20,6 +21,9 @@ export interface ImageGenSettings {
20
21
  announceToAgent?: boolean
21
22
  apiUrl?: string
22
23
  apiKey?: string
24
+ promptApiUrl?: string
25
+ promptApiKey?: string
26
+ promptModel?: string
23
27
  }
24
28
 
25
29
  /** What the card renders. */
@@ -32,6 +36,9 @@ export interface ImageGenSettingsCardState extends CardShell {
32
36
  apiUrl: CardFieldState
33
37
  /** API key draft (the stored value is never rendered). */
34
38
  apiKey: CardFieldState
39
+ promptApiUrl: CardFieldState
40
+ promptApiKey: CardFieldState
41
+ promptModel: CardFieldState
35
42
  }
36
43
 
37
44
  /** The registration-side face the card's slot entry injects. */
@@ -55,6 +62,9 @@ export class ImageGenSettingsCardController {
55
62
  booleanField('announceToAgent'),
56
63
  textField('apiUrl'),
57
64
  secretField('apiKey'),
65
+ textField('promptApiUrl'),
66
+ secretField('promptApiKey'),
67
+ textField('promptModel'),
58
68
  ], {
59
69
  // The redacted wire view never returns the key; a save's outcome is
60
70
  // judged by the namespace's secrets sidecar instead.
@@ -69,6 +79,9 @@ export class ImageGenSettingsCardController {
69
79
  announceToAgent: this.form.field('announceToAgent'),
70
80
  apiUrl: this.form.field('apiUrl'),
71
81
  apiKey: this.form.field('apiKey'),
82
+ promptApiUrl: this.form.field('promptApiUrl'),
83
+ promptApiKey: this.form.field('promptApiKey'),
84
+ promptModel: this.form.field('promptModel'),
72
85
  }
73
86
  }
74
87
 
@@ -106,6 +119,9 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
106
119
  const state = props.useImageGenSettingsCard(snapshot => snapshot)
107
120
  const keySet = props.useImageGenKeySet(snapshot => snapshot)
108
121
  const [open, setOpen] = useState(false)
122
+ const [models, setModels] = useState<string[]>([])
123
+ const [loadingModels, setLoadingModels] = useState(false)
124
+ const [modelsError, setModelsError] = useState<string | null>(null)
109
125
  if (!state.available) return null
110
126
  const title = t('settings.title')
111
127
  const blocked = !state.dirty || state.invalid || state.saving
@@ -191,6 +207,69 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
191
207
  onEdit={(text) => { props.edit('apiUrl', text) }}
192
208
  onReset={() => { props.resetField('apiUrl') }}
193
209
  />
210
+ <div className={css.sectionDivider} />
211
+ <h3 className={css.sectionTitle}>{t('settings.promptEnhanceTitle')}</h3>
212
+ <p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
213
+ <ValueField
214
+ id="dsh-imagegen-settings-prompt-apiurl"
215
+ label={t('settings.promptApiUrl')}
216
+ hint={t('settings.promptApiUrlHint')}
217
+ placeholder="https://api.openai.com/v1"
218
+ {...fieldProps}
219
+ {...state.promptApiUrl}
220
+ onEdit={(text) => { props.edit('promptApiUrl', text) }}
221
+ onReset={() => { props.resetField('promptApiUrl') }}
222
+ />
223
+ <ValueField
224
+ id="dsh-imagegen-settings-prompt-apikey"
225
+ label={t('settings.promptApiKey')}
226
+ hint={t('settings.promptApiKeyHint')}
227
+ placeholder="sk-…"
228
+ secret
229
+ {...fieldProps}
230
+ {...state.promptApiKey}
231
+ overridden={false}
232
+ onEdit={(text) => { props.edit('promptApiKey', text) }}
233
+ onReset={() => { props.resetField('promptApiKey') }}
234
+ />
235
+ <ValueField
236
+ id="dsh-imagegen-settings-prompt-model"
237
+ label={t('settings.promptModel')}
238
+ hint={t('settings.promptModelHint')}
239
+ placeholder="gpt-4.1-mini"
240
+ {...fieldProps}
241
+ {...state.promptModel}
242
+ onEdit={(text) => { props.edit('promptModel', text) }}
243
+ onReset={() => { props.resetField('promptModel') }}
244
+ />
245
+ <div className={css.modelFetchRow}>
246
+ <button
247
+ type="button"
248
+ className={css.modelFetch}
249
+ disabled={disabled || loadingModels}
250
+ onClick={() => {
251
+ setLoadingModels(true)
252
+ setModelsError(null)
253
+ void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
254
+ .then(async response => {
255
+ const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
256
+ if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
257
+ setModels(body.models ?? [])
258
+ })
259
+ .catch(error => { setModelsError(error instanceof Error ? error.message : String(error)) })
260
+ .finally(() => { setLoadingModels(false) })
261
+ }}
262
+ >
263
+ {loadingModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
264
+ </button>
265
+ {models.length > 0 ? (
266
+ <select className={css.modelChoices} value="" onChange={event => { if (event.target.value !== '') props.edit('promptModel', event.target.value) }}>
267
+ <option value="">{t('settings.promptModelsSelect')}</option>
268
+ {models.map(model => <option key={model} value={model}>{model}</option>)}
269
+ </select>
270
+ ) : null}
271
+ </div>
272
+ {modelsError !== null ? <p className={css.failed} role="status">{modelsError}</p> : null}
194
273
  <BooleanField
195
274
  id="dsh-imagegen-settings-enabled"
196
275
  label={t('settings.enabled')}