@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.
- package/README.md +46 -13
- package/docs/images/agent-chat-edit.png +0 -0
- package/docs/images/agent-chat-generate.png +0 -0
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1677 -538
- package/lib/client.js.map +1 -1
- package/lib/index.js +877 -57
- package/package.json +4 -1
- package/src/agent-image-tools.ts +289 -0
- package/src/client/ImageGenPanel.tsx +333 -46
- package/src/client/SettingsCard.tsx +293 -23
- package/src/client/api.ts +37 -1
- package/src/client/locales.ts +150 -2
- package/src/client/panel.module.css +129 -1
- package/src/client/settings-card.module.css +222 -0
- package/src/client/settings-form.ts +12 -0
- package/src/client/settings-scope.ts +24 -1
- package/src/engine.ts +32 -4
- package/src/gallery-store.ts +15 -1
- package/src/generation-runtime.ts +48 -0
- package/src/image-models.ts +19 -0
- package/src/index.ts +72 -3
- package/src/prompt-enhancer.ts +79 -0
- package/src/protocol.ts +37 -2
- package/src/routes.ts +142 -40
- package/src/task-queue.ts +103 -0
|
@@ -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
|
|
100
|
-
function
|
|
101
|
-
const [
|
|
102
|
-
useEffect(() => scope.
|
|
103
|
-
return
|
|
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 =
|
|
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
|
|
170
|
-
const
|
|
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>(
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
[
|
|
647
|
-
|
|
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>{
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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(
|