@dickpy/dsh-imagegen 1.0.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/LICENSE +201 -0
- package/README.md +126 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2413 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +896 -0
- package/package.json +66 -0
- package/src/client/ImageGenPanel.tsx +687 -0
- package/src/client/SettingsCard.tsx +373 -0
- package/src/client/api.ts +89 -0
- package/src/client/controller.ts +46 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/helpers.ts +33 -0
- package/src/client/index.ts +127 -0
- package/src/client/locales.ts +204 -0
- package/src/client/mount.tsx +119 -0
- package/src/client/panel.module.css +970 -0
- package/src/client/settings-card.module.css +288 -0
- package/src/client/settings-form.ts +324 -0
- package/src/client/settings-scope.ts +227 -0
- package/src/client/sidebar-entry.ts +144 -0
- package/src/engine.ts +284 -0
- package/src/history-store.ts +217 -0
- package/src/index.ts +139 -0
- package/src/protocol.ts +118 -0
- package/src/routes.ts +373 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI 生图 studio: a three-column layout — left, a card-grouped
|
|
3
|
+
* configuration sidebar (mode tabs, prompt with counter, rounded parameter
|
|
4
|
+
* selectors, model dropdown + generate button); center, the result canvas;
|
|
5
|
+
* right, a persistent generation history column.
|
|
6
|
+
*
|
|
7
|
+
* Controls ride the system UI primitives (@deepseek-ai/dsh-client-ui-primitives,
|
|
8
|
+
* a platform module) so the studio matches the dsh shell look by construction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState } from 'react'
|
|
12
|
+
import { createPortal } from 'react-dom'
|
|
13
|
+
import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
14
|
+
import type { ImageGenApi } from './api.ts'
|
|
15
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
16
|
+
import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryEntryInput, HistoryImageRef } from '../protocol.ts'
|
|
17
|
+
import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
|
|
18
|
+
import css from './panel.module.css'
|
|
19
|
+
|
|
20
|
+
/** The model dropdown offers exactly the plugin's namesake model. */
|
|
21
|
+
const MODELS = ['gpt-image-2'] as const
|
|
22
|
+
|
|
23
|
+
/** All size options for gpt-image-2. */
|
|
24
|
+
const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536', '512x512', '1792x1024', '1024x1792'] as const
|
|
25
|
+
|
|
26
|
+
/** Size option keys in the locale dictionary. */
|
|
27
|
+
const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.landscape' | 'size.portrait' | 'size.small' | 'size.wide' | 'size.tall'> = {
|
|
28
|
+
auto: 'size.auto',
|
|
29
|
+
'1024x1024': 'size.square',
|
|
30
|
+
'1536x1024': 'size.landscape',
|
|
31
|
+
'1024x1536': 'size.portrait',
|
|
32
|
+
'512x512': 'size.small',
|
|
33
|
+
'1792x1024': 'size.wide',
|
|
34
|
+
'1024x1792': 'size.tall',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Quality options. */
|
|
38
|
+
const QUALITIES = ['auto', 'low', 'medium', 'high'] as const
|
|
39
|
+
|
|
40
|
+
/** Detail options ('' = omit the passthrough). */
|
|
41
|
+
const DETAILS = ['', 'standard', 'high'] as const
|
|
42
|
+
|
|
43
|
+
const PROMPT_MAX = 2000
|
|
44
|
+
const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
/** Read the current config from the settings scope snapshot. */
|
|
47
|
+
function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
|
|
48
|
+
const [value, setValue] = useState(scope.getSnapshot().value)
|
|
49
|
+
useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tick a seconds counter while `running`. */
|
|
54
|
+
function useElapsed(running: boolean, startedAt: number | null): number {
|
|
55
|
+
const [elapsed, setElapsed] = useState(0)
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!running || startedAt === null) return
|
|
58
|
+
const timer = window.setInterval(() => {
|
|
59
|
+
setElapsed(Math.max(1, Math.round((Date.now() - startedAt) / 1000)))
|
|
60
|
+
}, 1000)
|
|
61
|
+
return () => window.clearInterval(timer)
|
|
62
|
+
}, [running, startedAt])
|
|
63
|
+
return elapsed
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Data URL for a generated image. */
|
|
67
|
+
function srcOf(image: GeneratedImage): string {
|
|
68
|
+
return `data:${image.mime};base64,${image.b64}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fetch persisted history image refs and decode them back to in-memory
|
|
72
|
+
* GeneratedImage[] (base64), so the canvas/preview can reuse the same
|
|
73
|
+
* rendering path as a fresh generation. */
|
|
74
|
+
async function historyImagesToGenerated(refs: HistoryImageRef[]): Promise<GeneratedImage[]> {
|
|
75
|
+
return Promise.all(refs.map(async ref => {
|
|
76
|
+
const response = await fetch(ref.url)
|
|
77
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
78
|
+
const blob = await response.blob()
|
|
79
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
80
|
+
const reader = new FileReader()
|
|
81
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
|
82
|
+
reader.onerror = () => reject(new Error('image read failed'))
|
|
83
|
+
reader.readAsDataURL(blob)
|
|
84
|
+
})
|
|
85
|
+
const comma = dataUrl.indexOf(',')
|
|
86
|
+
return {
|
|
87
|
+
b64: comma >= 0 ? dataUrl.slice(comma + 1) : '',
|
|
88
|
+
mime: ref.mime,
|
|
89
|
+
...ref.revisedPrompt === undefined ? {} : { revisedPrompt: ref.revisedPrompt },
|
|
90
|
+
}
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Compact, locale-independent timestamp for history entries. */
|
|
95
|
+
function formatTime(timestamp: number): string {
|
|
96
|
+
const d = new Date(timestamp)
|
|
97
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
98
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Render the studio. */
|
|
102
|
+
export function ImageGenPanel(props: {
|
|
103
|
+
api: ImageGenApi
|
|
104
|
+
scope: ImageGenScope
|
|
105
|
+
}) {
|
|
106
|
+
const { api, scope } = props
|
|
107
|
+
const config = useConfig(scope)
|
|
108
|
+
const enabled = config?.enabled ?? true
|
|
109
|
+
const apiUrl = config?.apiUrl ?? ''
|
|
110
|
+
const configured = apiUrl.trim() !== ''
|
|
111
|
+
|
|
112
|
+
const [mode, setMode] = useState<GenerateMode>('text')
|
|
113
|
+
const [prompt, setPrompt] = useState('')
|
|
114
|
+
const [size, setSize] = useState<string>('auto')
|
|
115
|
+
const [quality, setQuality] = useState<string>('auto')
|
|
116
|
+
const [count, setCount] = useState(1)
|
|
117
|
+
const [detail, setDetail] = useState('')
|
|
118
|
+
const [model, setModel] = useState<string>(MODELS[0])
|
|
119
|
+
const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
|
|
120
|
+
const [images, setImages] = useState<GeneratedImage[]>([])
|
|
121
|
+
const [error, setError] = useState<string | null>(null)
|
|
122
|
+
const [generating, setGenerating] = useState(false)
|
|
123
|
+
const [startedAt, setStartedAt] = useState<number | null>(null)
|
|
124
|
+
const [history, setHistory] = useState<HistoryEntry[]>([])
|
|
125
|
+
const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
|
|
126
|
+
const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
|
|
127
|
+
const fileInput = useRef<HTMLInputElement>(null)
|
|
128
|
+
const elapsed = useElapsed(generating, startedAt)
|
|
129
|
+
|
|
130
|
+
// Load the host-persisted history once on mount (it lives in ~/.dsh on the
|
|
131
|
+
// DSH host, so every browser/device sees the same list).
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
let disposed = false
|
|
134
|
+
api.historyList()
|
|
135
|
+
.then(entries => { if (!disposed) setHistory(entries) })
|
|
136
|
+
.catch(() => { /* history unavailable — leave the list empty */ })
|
|
137
|
+
return () => { disposed = true }
|
|
138
|
+
}, [api])
|
|
139
|
+
|
|
140
|
+
/** Read an uploaded reference image into a data URL. */
|
|
141
|
+
const acceptFile = (file: File | undefined): void => {
|
|
142
|
+
if (file === undefined) return
|
|
143
|
+
if (!file.type.startsWith('image/')) {
|
|
144
|
+
setError(tt('edit.uploadHint'))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (file.size > REF_IMAGE_MAX_BYTES) {
|
|
148
|
+
setError(tt('edit.uploadHint'))
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const reader = new FileReader()
|
|
152
|
+
reader.onload = () => {
|
|
153
|
+
if (typeof reader.result === 'string') setRefImage({ dataUrl: reader.result, name: file.name })
|
|
154
|
+
}
|
|
155
|
+
reader.onerror = () => { setError(tt('edit.uploadHint')) }
|
|
156
|
+
reader.readAsDataURL(file)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Run one generation. */
|
|
160
|
+
const handleGenerate = async (): Promise<void> => {
|
|
161
|
+
if (generating) return
|
|
162
|
+
const promptText = prompt.trim()
|
|
163
|
+
if (promptText === '') {
|
|
164
|
+
setError(tt('prompt.required'))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (mode === 'edit' && refImage === null) {
|
|
168
|
+
setError(tt('edit.required'))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const request: GenerateRequest = {
|
|
172
|
+
mode,
|
|
173
|
+
model,
|
|
174
|
+
prompt: promptText,
|
|
175
|
+
size,
|
|
176
|
+
quality,
|
|
177
|
+
n: count,
|
|
178
|
+
detail,
|
|
179
|
+
...mode === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
|
|
180
|
+
}
|
|
181
|
+
setGenerating(true)
|
|
182
|
+
setError(null)
|
|
183
|
+
setImages([])
|
|
184
|
+
setStartedAt(Date.now())
|
|
185
|
+
try {
|
|
186
|
+
const result = await api.generate(request)
|
|
187
|
+
setImages(result.images)
|
|
188
|
+
setViewingHistoryId(null)
|
|
189
|
+
const entry: HistoryEntryInput = {
|
|
190
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
191
|
+
createdAt: Date.now(),
|
|
192
|
+
mode,
|
|
193
|
+
model,
|
|
194
|
+
prompt: promptText,
|
|
195
|
+
size,
|
|
196
|
+
quality,
|
|
197
|
+
detail,
|
|
198
|
+
n: count,
|
|
199
|
+
images: result.images,
|
|
200
|
+
...mode === 'edit' && refImage !== null ? { refName: refImage.name } : {},
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
setHistory(await api.historyAppend(entry))
|
|
204
|
+
} catch {
|
|
205
|
+
// Persisting history is best-effort; the images stay on the canvas.
|
|
206
|
+
}
|
|
207
|
+
} catch (caught) {
|
|
208
|
+
setError(errorMessage(caught))
|
|
209
|
+
} finally {
|
|
210
|
+
setGenerating(false)
|
|
211
|
+
setStartedAt(null)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Open the full-screen image preview at a given index. */
|
|
216
|
+
const openPreview = (previewImages: GeneratedImage[], index: number): void => {
|
|
217
|
+
setPreview({ images: previewImages, index })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Step the preview by ±1, wrapping around. */
|
|
221
|
+
const stepPreview = (delta: number): void => {
|
|
222
|
+
setPreview(current => {
|
|
223
|
+
if (current === null) return null
|
|
224
|
+
const total = current.images.length
|
|
225
|
+
return { images: current.images, index: (current.index + delta + total) % total }
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Keyboard navigation for the preview overlay.
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (preview === null) return
|
|
232
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
233
|
+
if (event.key === 'Escape') setPreview(null)
|
|
234
|
+
else if (event.key === 'ArrowLeft') stepPreview(-1)
|
|
235
|
+
else if (event.key === 'ArrowRight') stepPreview(1)
|
|
236
|
+
}
|
|
237
|
+
window.addEventListener('keydown', onKey)
|
|
238
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
239
|
+
}, [preview])
|
|
240
|
+
|
|
241
|
+
/** Load a past generation's images into the canvas. */
|
|
242
|
+
const viewHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
243
|
+
try {
|
|
244
|
+
setImages(await historyImagesToGenerated(entry.images))
|
|
245
|
+
setError(null)
|
|
246
|
+
setViewingHistoryId(entry.id)
|
|
247
|
+
} catch (caught) {
|
|
248
|
+
setError(errorMessage(caught))
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Restore a past generation's parameters (and its images) into the form. */
|
|
253
|
+
const restoreHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
254
|
+
try {
|
|
255
|
+
const restored = await historyImagesToGenerated(entry.images)
|
|
256
|
+
setMode(entry.mode)
|
|
257
|
+
setPrompt(entry.prompt)
|
|
258
|
+
setSize((SIZES as readonly string[]).includes(entry.size) ? entry.size : 'auto')
|
|
259
|
+
setQuality((QUALITIES as readonly string[]).includes(entry.quality) ? entry.quality : 'auto')
|
|
260
|
+
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
261
|
+
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
262
|
+
setModel((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
|
|
263
|
+
setRefImage(null)
|
|
264
|
+
setImages(restored)
|
|
265
|
+
setError(null)
|
|
266
|
+
setViewingHistoryId(entry.id)
|
|
267
|
+
} catch (caught) {
|
|
268
|
+
setError(errorMessage(caught))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Remove one history entry. */
|
|
273
|
+
const deleteHistoryEntry = async (id: string): Promise<void> => {
|
|
274
|
+
setHistory(history.filter(entry => entry.id !== id))
|
|
275
|
+
if (viewingHistoryId === id) setViewingHistoryId(null)
|
|
276
|
+
try {
|
|
277
|
+
setHistory(await api.historyRemove(id))
|
|
278
|
+
} catch {
|
|
279
|
+
// Keep the optimistic local removal.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Remove all history entries. */
|
|
284
|
+
const clearHistory = async (): Promise<void> => {
|
|
285
|
+
setHistory([])
|
|
286
|
+
setViewingHistoryId(null)
|
|
287
|
+
try {
|
|
288
|
+
setHistory(await api.historyClear())
|
|
289
|
+
} catch {
|
|
290
|
+
// Keep the cleared local state.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const generateDisabled = generating || !enabled || !configured
|
|
295
|
+
const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
|
|
296
|
+
const previewImage = preview === null ? null : preview.images[preview.index] ?? null
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<div className={css.panel}>
|
|
300
|
+
<header className={css.panelHeader}>
|
|
301
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
302
|
+
<span className={css.panelSubtitle}>{tt('panel.subtitle')}</span>
|
|
303
|
+
</header>
|
|
304
|
+
|
|
305
|
+
{!enabled
|
|
306
|
+
? <div className={css.banner} data-kind="warn">{tt('config.disabled')}</div>
|
|
307
|
+
: !configured
|
|
308
|
+
? <div className={css.banner} data-kind="warn">{tt('config.missing')}</div>
|
|
309
|
+
: <div className={css.banner} data-kind="ok">{tt('config.configured', { url: apiUrl })}</div>}
|
|
310
|
+
|
|
311
|
+
<div className={css.studio}>
|
|
312
|
+
{/* ---------------------------------------------------- config sidebar */}
|
|
313
|
+
<aside className={css.config}>
|
|
314
|
+
<div className={css.configScroll}>
|
|
315
|
+
{/* mode tabs */}
|
|
316
|
+
<section className={css.card}>
|
|
317
|
+
<div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
|
|
318
|
+
<Pill
|
|
319
|
+
active={mode === 'text'}
|
|
320
|
+
onClick={() => { setMode('text') }}
|
|
321
|
+
className={css.modePill}
|
|
322
|
+
>
|
|
323
|
+
{tt('mode.text')}
|
|
324
|
+
</Pill>
|
|
325
|
+
<Pill
|
|
326
|
+
active={mode === 'edit'}
|
|
327
|
+
onClick={() => { setMode('edit') }}
|
|
328
|
+
className={css.modePill}
|
|
329
|
+
>
|
|
330
|
+
{tt('mode.edit')}
|
|
331
|
+
</Pill>
|
|
332
|
+
</div>
|
|
333
|
+
</section>
|
|
334
|
+
|
|
335
|
+
{/* reference image (edit mode) */}
|
|
336
|
+
{mode === 'edit' ? (
|
|
337
|
+
<section className={css.card}>
|
|
338
|
+
{refImage === null
|
|
339
|
+
? (
|
|
340
|
+
<button
|
|
341
|
+
type="button"
|
|
342
|
+
className={css.uploadBox}
|
|
343
|
+
onClick={() => { fileInput.current?.click() }}
|
|
344
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
345
|
+
onDrop={(event) => {
|
|
346
|
+
event.preventDefault()
|
|
347
|
+
acceptFile(event.dataTransfer.files?.[0])
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
<span className={css.uploadIcon}>
|
|
351
|
+
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10.5V3"/><path d="M5 5.5l3-3 3 3"/><path d="M2.5 9v3.5h11V9"/></svg>
|
|
352
|
+
</span>
|
|
353
|
+
<span>{tt('edit.upload')}</span>
|
|
354
|
+
<span className={css.uploadHint}>{tt('edit.uploadHint')}</span>
|
|
355
|
+
</button>
|
|
356
|
+
)
|
|
357
|
+
: (
|
|
358
|
+
<div className={css.reference}>
|
|
359
|
+
<img className={css.referenceImage} src={refImage.dataUrl} alt={refImage.name} />
|
|
360
|
+
<div className={css.referenceActions}>
|
|
361
|
+
<Button variant="outline" size="sm" onClick={() => { fileInput.current?.click() }}>
|
|
362
|
+
{tt('edit.change')}
|
|
363
|
+
</Button>
|
|
364
|
+
<Button variant="outline" size="sm" onClick={() => { setRefImage(null) }}>
|
|
365
|
+
{tt('edit.remove')}
|
|
366
|
+
</Button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
<input
|
|
371
|
+
ref={fileInput}
|
|
372
|
+
type="file"
|
|
373
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
374
|
+
className={css.hiddenFile}
|
|
375
|
+
onChange={(event) => {
|
|
376
|
+
acceptFile(event.target.files?.[0])
|
|
377
|
+
event.target.value = ''
|
|
378
|
+
}}
|
|
379
|
+
/>
|
|
380
|
+
</section>
|
|
381
|
+
) : null}
|
|
382
|
+
|
|
383
|
+
{/* prompt */}
|
|
384
|
+
<section className={css.card}>
|
|
385
|
+
<textarea
|
|
386
|
+
className={css.prompt}
|
|
387
|
+
value={prompt}
|
|
388
|
+
maxLength={PROMPT_MAX}
|
|
389
|
+
placeholder={tt('prompt.placeholder')}
|
|
390
|
+
onChange={(event) => { setPrompt(event.target.value) }}
|
|
391
|
+
/>
|
|
392
|
+
<div className={css.promptFooter}>
|
|
393
|
+
<span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
|
|
394
|
+
</div>
|
|
395
|
+
</section>
|
|
396
|
+
|
|
397
|
+
{/* parameters */}
|
|
398
|
+
<section className={css.card}>
|
|
399
|
+
<div className={css.paramGroup}>
|
|
400
|
+
<span className={css.paramLabel}>{tt('params.size')}</span>
|
|
401
|
+
<div className={css.optionGrid}>
|
|
402
|
+
{SIZES.map(option => (
|
|
403
|
+
<Pill
|
|
404
|
+
key={option}
|
|
405
|
+
active={size === option}
|
|
406
|
+
onClick={() => { setSize(option) }}
|
|
407
|
+
className={css.optionPill}
|
|
408
|
+
>
|
|
409
|
+
{tt(SIZE_KEYS[option] ?? 'size.auto')}
|
|
410
|
+
</Pill>
|
|
411
|
+
))}
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
<div className={css.paramGroup}>
|
|
415
|
+
<span className={css.paramLabel}>{tt('params.quality')}</span>
|
|
416
|
+
<div className={css.optionRow}>
|
|
417
|
+
{QUALITIES.map(option => (
|
|
418
|
+
<Pill
|
|
419
|
+
key={option}
|
|
420
|
+
active={quality === option}
|
|
421
|
+
onClick={() => { setQuality(option) }}
|
|
422
|
+
className={css.optionPill}
|
|
423
|
+
>
|
|
424
|
+
{tt(`quality.${option}` as const)}
|
|
425
|
+
</Pill>
|
|
426
|
+
))}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
<div className={css.paramGroup}>
|
|
430
|
+
<span className={css.paramLabel}>{tt('params.count')}</span>
|
|
431
|
+
<div className={css.optionRow}>
|
|
432
|
+
{[1, 2, 3, 4].map(option => (
|
|
433
|
+
<Pill
|
|
434
|
+
key={option}
|
|
435
|
+
active={count === option}
|
|
436
|
+
onClick={() => { setCount(option) }}
|
|
437
|
+
className={css.optionPill}
|
|
438
|
+
>
|
|
439
|
+
{tt(`count.${option === 1 ? 'one' : option === 2 ? 'two' : option === 3 ? 'three' : 'four'}` as const)}
|
|
440
|
+
</Pill>
|
|
441
|
+
))}
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
<div className={css.paramGroup}>
|
|
445
|
+
<span className={css.paramLabel}>{tt('params.detail')}</span>
|
|
446
|
+
<div className={css.optionRow}>
|
|
447
|
+
{DETAILS.map(option => (
|
|
448
|
+
<Pill
|
|
449
|
+
key={option === '' ? 'auto' : option}
|
|
450
|
+
active={detail === option}
|
|
451
|
+
onClick={() => { setDetail(option) }}
|
|
452
|
+
className={css.optionPill}
|
|
453
|
+
>
|
|
454
|
+
{tt(option === '' ? 'detail.auto' : option === 'standard' ? 'detail.standard' : 'detail.high')}
|
|
455
|
+
</Pill>
|
|
456
|
+
))}
|
|
457
|
+
</div>
|
|
458
|
+
<span className={css.paramHint}>{tt('detail.hint')}</span>
|
|
459
|
+
</div>
|
|
460
|
+
</section>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
{/* footer: model + generate — a fixed sibling of the scroll area, so
|
|
464
|
+
it never overlaps the cards scrolling above it. */}
|
|
465
|
+
<section className={css.footer}>
|
|
466
|
+
<label className={css.modelWrap}>
|
|
467
|
+
<span className={css.modelLabel}>{tt('model.label')}</span>
|
|
468
|
+
<select
|
|
469
|
+
className={css.modelSelect}
|
|
470
|
+
value={model}
|
|
471
|
+
disabled={generating}
|
|
472
|
+
onChange={(event) => { setModel(event.target.value) }}
|
|
473
|
+
>
|
|
474
|
+
{MODELS.map(option => <option key={option} value={option}>{option}</option>)}
|
|
475
|
+
</select>
|
|
476
|
+
</label>
|
|
477
|
+
<Button
|
|
478
|
+
variant="primary"
|
|
479
|
+
size="md"
|
|
480
|
+
className={css.generateButton}
|
|
481
|
+
disabled={generateDisabled}
|
|
482
|
+
onClick={() => { void handleGenerate() }}
|
|
483
|
+
>
|
|
484
|
+
{generating ? (
|
|
485
|
+
<span className={css.generateInner}>
|
|
486
|
+
<span className={css.spinner} />
|
|
487
|
+
{tt('generating')}
|
|
488
|
+
</span>
|
|
489
|
+
) : tt('generate')}
|
|
490
|
+
</Button>
|
|
491
|
+
</section>
|
|
492
|
+
</aside>
|
|
493
|
+
|
|
494
|
+
{/* --------------------------------------------------------- canvas */}
|
|
495
|
+
<section className={css.canvas}>
|
|
496
|
+
{generating ? (
|
|
497
|
+
<div className={css.canvasState} role="status">
|
|
498
|
+
<span className={css.bigSpinner} />
|
|
499
|
+
<span className={css.canvasStateTitle}>{tt('canvas.generating')}</span>
|
|
500
|
+
<span className={css.canvasStateHint}>{tt('canvas.elapsed', { seconds: elapsed })}</span>
|
|
501
|
+
</div>
|
|
502
|
+
) : null}
|
|
503
|
+
|
|
504
|
+
{!generating && error !== null ? (
|
|
505
|
+
<div className={css.canvasError} role="alert">{tt('canvas.error', { error })}</div>
|
|
506
|
+
) : null}
|
|
507
|
+
|
|
508
|
+
{!generating && !error && images.length === 0 ? (
|
|
509
|
+
<div className={css.canvasState}>
|
|
510
|
+
<span className={css.canvasEmptyIcon}>
|
|
511
|
+
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
|
512
|
+
</span>
|
|
513
|
+
<span className={css.canvasStateTitle}>{tt('canvas.emptyTitle')}</span>
|
|
514
|
+
<span className={css.canvasStateHint}>{tt('canvas.emptyHint')}</span>
|
|
515
|
+
</div>
|
|
516
|
+
) : null}
|
|
517
|
+
|
|
518
|
+
{!generating && images.length > 0 ? (
|
|
519
|
+
<div className={css.canvasBody}>
|
|
520
|
+
<div className={css.canvasMeta}>
|
|
521
|
+
<span>{tt('canvas.images', { count: images.length })}</span>
|
|
522
|
+
{viewingEntry !== null ? (
|
|
523
|
+
<span className={css.canvasHistoryTag}>{tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })}</span>
|
|
524
|
+
) : null}
|
|
525
|
+
</div>
|
|
526
|
+
<div className={css.grid}>
|
|
527
|
+
{images.map((image, index) => (
|
|
528
|
+
<figure
|
|
529
|
+
key={index}
|
|
530
|
+
className={css.imageCard}
|
|
531
|
+
role="button"
|
|
532
|
+
tabIndex={0}
|
|
533
|
+
title={tt('preview.open')}
|
|
534
|
+
onClick={() => { openPreview(images, index) }}
|
|
535
|
+
onKeyDown={(event) => {
|
|
536
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
537
|
+
event.preventDefault()
|
|
538
|
+
openPreview(images, index)
|
|
539
|
+
}
|
|
540
|
+
}}
|
|
541
|
+
>
|
|
542
|
+
<img
|
|
543
|
+
className={css.image}
|
|
544
|
+
src={srcOf(image)}
|
|
545
|
+
alt={image.revisedPrompt ?? `${tt('panel.title')} ${index + 1}`}
|
|
546
|
+
/>
|
|
547
|
+
{image.revisedPrompt !== undefined ? (
|
|
548
|
+
<figcaption className={css.imageCaption} title={image.revisedPrompt}>
|
|
549
|
+
{tt('revisedPrompt', { prompt: image.revisedPrompt })}
|
|
550
|
+
</figcaption>
|
|
551
|
+
) : null}
|
|
552
|
+
<span className={css.zoomHint}>
|
|
553
|
+
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="7" cy="7" r="4"/><path d="M13 13l-3.2-3.2"/><path d="M7 5.4v3.2M5.4 7h3.2"/></svg>
|
|
554
|
+
{tt('preview.open')}
|
|
555
|
+
</span>
|
|
556
|
+
<a
|
|
557
|
+
className={css.download}
|
|
558
|
+
href={srcOf(image)}
|
|
559
|
+
download={`dsh-image-${index + 1}.${extensionOf(image.mime)}`}
|
|
560
|
+
onClick={(event) => { event.stopPropagation() }}
|
|
561
|
+
>
|
|
562
|
+
{tt('download')}
|
|
563
|
+
</a>
|
|
564
|
+
</figure>
|
|
565
|
+
))}
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
) : null}
|
|
569
|
+
</section>
|
|
570
|
+
|
|
571
|
+
{/* -------------------------------------------------------- history */}
|
|
572
|
+
<aside className={css.history}>
|
|
573
|
+
<header className={css.historyHeader}>
|
|
574
|
+
<span className={css.historyTitle}>{tt('history.title')}</span>
|
|
575
|
+
{history.length > 0 ? (
|
|
576
|
+
<button type="button" className={css.historyClear} onClick={() => { void clearHistory() }}>
|
|
577
|
+
{tt('history.clear')}
|
|
578
|
+
</button>
|
|
579
|
+
) : null}
|
|
580
|
+
</header>
|
|
581
|
+
|
|
582
|
+
{history.length === 0 ? (
|
|
583
|
+
<div className={css.historyEmpty}>{tt('history.empty')}</div>
|
|
584
|
+
) : (
|
|
585
|
+
<div className={css.historyList}>
|
|
586
|
+
{history.map(entry => (
|
|
587
|
+
<div
|
|
588
|
+
key={entry.id}
|
|
589
|
+
className={css.historyItem}
|
|
590
|
+
data-active={entry.id === viewingHistoryId ? '' : undefined}
|
|
591
|
+
>
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
className={css.historyMain}
|
|
595
|
+
onClick={() => { void viewHistoryEntry(entry) }}
|
|
596
|
+
>
|
|
597
|
+
{entry.images.length > 0 ? (
|
|
598
|
+
<img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
|
|
599
|
+
) : (
|
|
600
|
+
<span className={css.historyThumbPlaceholder} />
|
|
601
|
+
)}
|
|
602
|
+
<span className={css.historyInfo}>
|
|
603
|
+
<span className={css.historyPrompt}>{entry.prompt}</span>
|
|
604
|
+
<span className={css.historyMeta}>
|
|
605
|
+
{tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
|
|
606
|
+
{' · '}{formatTime(entry.createdAt)}
|
|
607
|
+
{' · '}{entry.images.length} {tt('history.images')}
|
|
608
|
+
</span>
|
|
609
|
+
</span>
|
|
610
|
+
</button>
|
|
611
|
+
<span className={css.historyActions}>
|
|
612
|
+
<button type="button" className={css.historyAction} onClick={() => { void restoreHistoryEntry(entry) }}>
|
|
613
|
+
{tt('history.restore')}
|
|
614
|
+
</button>
|
|
615
|
+
<button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryEntry(entry.id) }}>
|
|
616
|
+
{tt('history.delete')}
|
|
617
|
+
</button>
|
|
618
|
+
</span>
|
|
619
|
+
</div>
|
|
620
|
+
))}
|
|
621
|
+
</div>
|
|
622
|
+
)}
|
|
623
|
+
</aside>
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
{/* -------------------------------------------------- preview overlay */}
|
|
627
|
+
{preview !== null && previewImage !== null
|
|
628
|
+
? createPortal(
|
|
629
|
+
<div
|
|
630
|
+
className={css.lightbox}
|
|
631
|
+
role="dialog"
|
|
632
|
+
aria-modal="true"
|
|
633
|
+
aria-label={tt('preview.title')}
|
|
634
|
+
onClick={() => { setPreview(null) }}
|
|
635
|
+
>
|
|
636
|
+
<button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setPreview(null) }}>
|
|
637
|
+
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
|
638
|
+
</button>
|
|
639
|
+
{preview.images.length > 1 ? (
|
|
640
|
+
<>
|
|
641
|
+
<button type="button" className={css.lightboxNav} data-dir="prev" aria-label={tt('preview.prev')} onClick={(event) => { event.stopPropagation(); stepPreview(-1) }}>
|
|
642
|
+
<svg viewBox="0 0 16 16" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M10 3l-5 5 5 5"/></svg>
|
|
643
|
+
</button>
|
|
644
|
+
<button type="button" className={css.lightboxNav} data-dir="next" aria-label={tt('preview.next')} onClick={(event) => { event.stopPropagation(); stepPreview(1) }}>
|
|
645
|
+
<svg viewBox="0 0 16 16" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
|
|
646
|
+
</button>
|
|
647
|
+
</>
|
|
648
|
+
) : null}
|
|
649
|
+
<figure className={css.lightboxFigure} onClick={(event) => { event.stopPropagation() }}>
|
|
650
|
+
<img
|
|
651
|
+
className={css.lightboxImage}
|
|
652
|
+
src={srcOf(previewImage)}
|
|
653
|
+
alt={previewImage.revisedPrompt ?? tt('preview.title')}
|
|
654
|
+
/>
|
|
655
|
+
{previewImage.revisedPrompt !== undefined ? (
|
|
656
|
+
<figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
|
|
657
|
+
{tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
|
|
658
|
+
</figcaption>
|
|
659
|
+
) : null}
|
|
660
|
+
<div className={css.lightboxMeta}>
|
|
661
|
+
<span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
|
|
662
|
+
<a
|
|
663
|
+
className={css.lightboxDownload}
|
|
664
|
+
href={srcOf(previewImage)}
|
|
665
|
+
download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
|
|
666
|
+
>
|
|
667
|
+
{tt('download')}
|
|
668
|
+
</a>
|
|
669
|
+
</div>
|
|
670
|
+
</figure>
|
|
671
|
+
</div>,
|
|
672
|
+
document.body,
|
|
673
|
+
)
|
|
674
|
+
: null}
|
|
675
|
+
</div>
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** File extension for a MIME type (download filenames). */
|
|
680
|
+
function extensionOf(mime: string): string {
|
|
681
|
+
switch (mime.split(';')[0]!.trim()) {
|
|
682
|
+
case 'image/jpeg': return 'jpg'
|
|
683
|
+
case 'image/webp': return 'webp'
|
|
684
|
+
case 'image/gif': return 'gif'
|
|
685
|
+
default: return 'png'
|
|
686
|
+
}
|
|
687
|
+
}
|