@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.
- package/README.md +25 -7
- package/docs/images/gallery-workspace.png +0 -0
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1267 -460
- package/lib/client.js.map +1 -1
- package/lib/index.js +376 -56
- package/package.json +1 -1
- package/src/client/ImageGenPanel.tsx +306 -27
- package/src/client/SettingsCard.tsx +79 -0
- package/src/client/api.ts +37 -1
- package/src/client/locales.ts +110 -2
- package/src/client/panel.module.css +129 -1
- package/src/client/settings-card.module.css +42 -0
- package/src/client/settings-scope.ts +22 -1
- package/src/engine.ts +4 -3
- package/src/gallery-store.ts +15 -1
- package/src/index.ts +24 -1
- package/src/prompt-enhancer.ts +67 -0
- package/src/protocol.ts +32 -2
- package/src/routes.ts +133 -39
- package/src/task-queue.ts +70 -0
|
@@ -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
|
|
100
|
-
function
|
|
101
|
-
const [
|
|
102
|
-
useEffect(() => scope.
|
|
103
|
-
return
|
|
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
|
|
170
|
-
const
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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')}
|