@dickpy/dsh-imagegen 1.3.0 → 1.4.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 -201
- package/README.md +203 -182
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1103 -837
- package/lib/client.js.map +1 -1
- package/lib/index.js +265 -135
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -418
- package/src/client/ImageGenPanel.tsx +1699 -1508
- package/src/client/SettingsCard.tsx +936 -957
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -263
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +169 -158
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -594
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -1023
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -298
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -478
- package/src/gallery-store.ts +286 -286
- package/src/generation-runtime.ts +79 -75
- package/src/history-store.ts +250 -244
- package/src/image-format.ts +11 -11
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -318
- package/src/model-catalog.ts +115 -98
- package/src/presets.ts +71 -63
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -326
- package/src/routes.ts +916 -906
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- package/src/updater.ts +117 -117
|
@@ -1,1594 +1,1785 @@
|
|
|
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'
|
|
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
13
|
import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
14
|
+
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
15
|
+
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
14
16
|
import type { ImageGenApi } from './api.ts'
|
|
15
17
|
import { errorMessage, tt } from './helpers.ts'
|
|
16
18
|
import { TemplateLibrary } from './TemplateLibrary.tsx'
|
|
17
19
|
import type { GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, HistoryEntry, HistoryImageRef, UpdateInfo } from '../protocol.ts'
|
|
20
|
+
import { AGENT_IMAGE_API } from '../protocol.ts'
|
|
18
21
|
import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
|
|
19
22
|
import { imageModelOptions } from './settings-scope.ts'
|
|
20
23
|
import { normalizeImageModels } from '../image-models.ts'
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
24
|
+
import { describeModel } from '../model-catalog.ts'
|
|
25
|
+
import { CHAT_IMAGE_EVENT, type ChatImageEventDetail, type ConversationService } from './conversation-sync.ts'
|
|
26
|
+
import css from './panel.module.css'
|
|
27
|
+
|
|
28
|
+
/** Size options, presented as aspect ratios (auto = let the model decide).
|
|
29
|
+
* The host maps each ratio onto the model's own vocabulary: aspect_ratio for
|
|
30
|
+
* Grok Imagine, the closest pixel size for OpenAI-compatible endpoints. */
|
|
31
|
+
const SIZES = ['auto', '1:1', '3:4', '4:3', '9:16', '2:3', '3:2', '16:9', '21:9'] as const
|
|
32
|
+
|
|
33
|
+
/** Size option keys in the locale dictionary. */
|
|
34
|
+
const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.portrait34' | 'size.landscape43' | 'size.portrait916' | 'size.portrait23' | 'size.landscape32' | 'size.wide169' | 'size.ultrawide21'> = {
|
|
35
|
+
auto: 'size.auto',
|
|
36
|
+
'1:1': 'size.square',
|
|
37
|
+
'3:4': 'size.portrait34',
|
|
38
|
+
'4:3': 'size.landscape43',
|
|
39
|
+
'9:16': 'size.portrait916',
|
|
40
|
+
'2:3': 'size.portrait23',
|
|
41
|
+
'3:2': 'size.landscape32',
|
|
42
|
+
'16:9': 'size.wide169',
|
|
43
|
+
'21:9': 'size.ultrawide21',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Quality options, shown as output-resolution tiers (auto = let the model
|
|
47
|
+
* decide). The host maps them: resolution for Grok, quality level for
|
|
48
|
+
* OpenAI-compatible endpoints (1k→low, 2k→medium, 4k→high). */
|
|
49
|
+
const QUALITIES = ['auto', '1k', '2k', '4k'] as const
|
|
50
|
+
|
|
51
|
+
/** Detail options ('' = omit the passthrough). */
|
|
52
|
+
const DETAILS = ['', 'standard', 'high'] as const
|
|
53
|
+
|
|
54
|
+
const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
55
|
+
const PREVIEW_SCALE_MIN = 0.5
|
|
56
|
+
const PREVIEW_SCALE_MAX = 3
|
|
57
|
+
const PREVIEW_SCALE_STEP = 0.25
|
|
58
|
+
|
|
59
|
+
/** Legacy pixel sizes saved by older versions, mapped onto the current
|
|
60
|
+
* aspect-ratio vocabulary so restoring old history entries still works. */
|
|
61
|
+
const LEGACY_SIZE_TO_RATIO: Record<string, string> = {
|
|
62
|
+
'512x512': '1:1',
|
|
63
|
+
'1024x1024': '1:1',
|
|
64
|
+
'1536x1024': '3:2',
|
|
65
|
+
'1024x1536': '2:3',
|
|
66
|
+
'1792x1024': '16:9',
|
|
67
|
+
'1024x1792': '9:16',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Legacy quality levels saved by older versions, mapped onto resolution. */
|
|
71
|
+
const LEGACY_QUALITY_TO_RES: Record<string, string> = {
|
|
72
|
+
low: '1k',
|
|
73
|
+
medium: '2k',
|
|
74
|
+
high: '4k',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Normalize a saved size value into a current dropdown option. */
|
|
78
|
+
function normalizeSize(value: string): string {
|
|
79
|
+
if ((SIZES as readonly string[]).includes(value)) return value
|
|
80
|
+
return LEGACY_SIZE_TO_RATIO[value] ?? 'auto'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Normalize a saved quality value into a current dropdown option. */
|
|
84
|
+
function normalizeQuality(value: string): string {
|
|
85
|
+
if ((QUALITIES as readonly string[]).includes(value)) return value
|
|
86
|
+
return LEGACY_QUALITY_TO_RES[value] ?? 'auto'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clampPreviewScale(scale: number): number {
|
|
90
|
+
return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read the current config from the settings scope snapshot. */
|
|
94
|
+
function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
|
|
95
|
+
const [value, setValue] = useState(scope.getSnapshot().value)
|
|
96
|
+
useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
|
|
97
|
+
return value
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Track one redacted secret field without exposing its value to the panel. */
|
|
101
|
+
function useSecretSet(scope: ImageGenScope, field: string): boolean {
|
|
102
|
+
const [isSet, setIsSet] = useState(scope.getSecretSetSnapshot(field))
|
|
103
|
+
useEffect(() => scope.subscribeSecretSets(() => { setIsSet(scope.getSecretSetSnapshot(field)) }), [field, scope])
|
|
104
|
+
return isSet
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Tick a seconds counter while `running`. */
|
|
108
|
+
function useElapsed(running: boolean, startedAt: number | null): number {
|
|
109
|
+
const [elapsed, setElapsed] = useState(0)
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!running || startedAt === null) {
|
|
112
|
+
setElapsed(0)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const update = (): void => {
|
|
116
|
+
setElapsed(Math.max(1, Math.round((Date.now() - startedAt) / 1000)))
|
|
117
|
+
}
|
|
118
|
+
update()
|
|
119
|
+
const timer = window.setInterval(update, 1000)
|
|
120
|
+
return () => window.clearInterval(timer)
|
|
121
|
+
}, [running, startedAt])
|
|
122
|
+
return elapsed
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Data URL for a generated image. */
|
|
126
|
+
function srcOf(image: GeneratedImage): string {
|
|
127
|
+
return `data:${image.mime};base64,${image.b64}`
|
|
86
128
|
}
|
|
87
129
|
|
|
88
|
-
/**
|
|
89
|
-
function
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
130
|
+
/** Decode one durable conversation attachment into the panel's image shape. */
|
|
131
|
+
async function attachmentToGenerated(ref: ImageAttachmentRef): Promise<GeneratedImage> {
|
|
132
|
+
const query = new URLSearchParams({
|
|
133
|
+
attachment_id: String(ref.attachmentId),
|
|
134
|
+
media_type: ref.mediaType,
|
|
135
|
+
bytes: String(ref.bytes),
|
|
136
|
+
width: String(ref.width),
|
|
137
|
+
height: String(ref.height),
|
|
138
|
+
})
|
|
139
|
+
const response = await fetch(`${AGENT_IMAGE_API}?${query.toString()}`)
|
|
140
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
141
|
+
const blob = await response.blob()
|
|
142
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
143
|
+
const reader = new FileReader()
|
|
144
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
|
145
|
+
reader.onerror = () => reject(new Error('image read failed'))
|
|
146
|
+
reader.readAsDataURL(blob)
|
|
147
|
+
})
|
|
148
|
+
const comma = dataUrl.indexOf(',')
|
|
149
|
+
if (comma < 0) throw new Error('image decode failed')
|
|
150
|
+
return { b64: dataUrl.slice(comma + 1), mime: ref.mediaType }
|
|
93
151
|
}
|
|
94
152
|
|
|
95
|
-
/**
|
|
96
|
-
function
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
153
|
+
/** Convert a generated image into the browser-owned draft format. */
|
|
154
|
+
function generatedImageToFile(image: GeneratedImage, index: number): File {
|
|
155
|
+
const binary = atob(image.b64)
|
|
156
|
+
const bytes = new Uint8Array(binary.length)
|
|
157
|
+
for (let offset = 0; offset < binary.length; offset += 1) bytes[offset] = binary.charCodeAt(offset)
|
|
158
|
+
return new File([bytes], `dsh-image-${index + 1}.${extensionOf(image.mime)}`, { type: image.mime })
|
|
100
159
|
}
|
|
101
160
|
|
|
102
|
-
/**
|
|
103
|
-
function
|
|
104
|
-
const [
|
|
161
|
+
/** Follow the native session selection while the image panel stays mounted. */
|
|
162
|
+
function useCurrentSessionId(sessions: ISessions | undefined): SessionId | undefined {
|
|
163
|
+
const [sessionId, setSessionId] = useState<SessionId | undefined>(() => sessions?.list.getSnapshot().current)
|
|
105
164
|
useEffect(() => {
|
|
106
|
-
if (
|
|
107
|
-
|
|
165
|
+
if (sessions === undefined) {
|
|
166
|
+
setSessionId(undefined)
|
|
108
167
|
return
|
|
109
168
|
}
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return () => window.clearInterval(timer)
|
|
116
|
-
}, [running, startedAt])
|
|
117
|
-
return elapsed
|
|
169
|
+
const sync = (): void => { setSessionId(sessions.list.getSnapshot().current) }
|
|
170
|
+
sync()
|
|
171
|
+
return sessions.list.subscribe(sync)
|
|
172
|
+
}, [sessions])
|
|
173
|
+
return sessionId
|
|
118
174
|
}
|
|
119
175
|
|
|
120
|
-
/**
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
async function historyImagesToGenerated(refs: HistoryImageRef[]): Promise<GeneratedImage[]> {
|
|
129
|
-
return Promise.all(refs.map(async ref => {
|
|
130
|
-
const response = await fetch(ref.url)
|
|
131
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
132
|
-
const blob = await response.blob()
|
|
133
|
-
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
134
|
-
const reader = new FileReader()
|
|
135
|
-
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
|
136
|
-
reader.onerror = () => reject(new Error('image read failed'))
|
|
137
|
-
reader.readAsDataURL(blob)
|
|
138
|
-
})
|
|
139
|
-
const comma = dataUrl.indexOf(',')
|
|
140
|
-
return {
|
|
141
|
-
b64: comma >= 0 ? dataUrl.slice(comma + 1) : '',
|
|
142
|
-
mime: ref.mime,
|
|
143
|
-
...ref.revisedPrompt === undefined ? {} : { revisedPrompt: ref.revisedPrompt },
|
|
176
|
+
/** Find the host mounted in the shell's left navigation region. */
|
|
177
|
+
function useSidebarHistoryHost(): HTMLDivElement | null {
|
|
178
|
+
const [host, setHost] = useState<HTMLDivElement | null>(() => (
|
|
179
|
+
document.querySelector<HTMLDivElement>('[data-dsh-imagegen-history-host]')
|
|
180
|
+
))
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const sync = (): void => {
|
|
183
|
+
setHost(document.querySelector<HTMLDivElement>('[data-dsh-imagegen-history-host]'))
|
|
144
184
|
}
|
|
145
|
-
|
|
185
|
+
sync()
|
|
186
|
+
const observer = new MutationObserver(sync)
|
|
187
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
188
|
+
return () => observer.disconnect()
|
|
189
|
+
}, [])
|
|
190
|
+
return host
|
|
146
191
|
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
192
|
+
|
|
193
|
+
/** Fetch persisted history image refs and decode them back to in-memory
|
|
194
|
+
* GeneratedImage[] (base64), so the canvas/preview can reuse the same
|
|
195
|
+
* rendering path as a fresh generation. */
|
|
196
|
+
async function historyImagesToGenerated(refs: HistoryImageRef[]): Promise<GeneratedImage[]> {
|
|
197
|
+
return Promise.all(refs.map(async ref => {
|
|
198
|
+
const response = await fetch(ref.url)
|
|
199
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
200
|
+
const blob = await response.blob()
|
|
201
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
202
|
+
const reader = new FileReader()
|
|
203
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
|
204
|
+
reader.onerror = () => reject(new Error('image read failed'))
|
|
205
|
+
reader.readAsDataURL(blob)
|
|
206
|
+
})
|
|
207
|
+
const comma = dataUrl.indexOf(',')
|
|
208
|
+
return {
|
|
209
|
+
b64: comma >= 0 ? dataUrl.slice(comma + 1) : '',
|
|
210
|
+
mime: ref.mime,
|
|
211
|
+
...ref.revisedPrompt === undefined ? {} : { revisedPrompt: ref.revisedPrompt },
|
|
212
|
+
}
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Compact, locale-independent timestamp for history entries. */
|
|
217
|
+
function formatTime(timestamp: number): string {
|
|
218
|
+
const d = new Date(timestamp)
|
|
219
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
220
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Studio tabs: the two generation modes plus the gallery view. */
|
|
224
|
+
type PanelTab = GenerateMode | 'gallery'
|
|
225
|
+
|
|
226
|
+
type GalleryFilter = string
|
|
227
|
+
type ComparisonSession = { taskIds: string[]; prompt: string; comparisonId: string }
|
|
228
|
+
type HistoryGroup = { key: string; entries: HistoryEntry[]; models: string[] }
|
|
229
|
+
|
|
230
|
+
function modelsOfHistoryEntry(entry: HistoryEntry): string[] {
|
|
231
|
+
return entry.comparisonModels?.length !== undefined && entry.comparisonModels.length > 1
|
|
232
|
+
? entry.comparisonModels
|
|
233
|
+
: [entry.model]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Collapse the per-model history rows that belong to one comparison run. */
|
|
237
|
+
function groupHistoryEntries(entries: HistoryEntry[]): HistoryGroup[] {
|
|
238
|
+
const groups = new Map<string, HistoryGroup>()
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
const key = entry.comparisonId ?? entry.id
|
|
241
|
+
const existing = groups.get(key)
|
|
242
|
+
if (existing === undefined) {
|
|
243
|
+
groups.set(key, { key, entries: [entry], models: modelsOfHistoryEntry(entry) })
|
|
244
|
+
} else {
|
|
245
|
+
existing.entries.push(entry)
|
|
246
|
+
existing.models = [...new Set([...existing.models, ...modelsOfHistoryEntry(entry)])]
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return [...groups.values()]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function newComparisonId(): string {
|
|
253
|
+
const cryptoApi = globalThis.crypto
|
|
254
|
+
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID()
|
|
255
|
+
return `comparison-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Render the studio. */
|
|
162
259
|
export function ImageGenPanel(props: {
|
|
163
260
|
api: ImageGenApi
|
|
164
261
|
scope: ImageGenScope
|
|
262
|
+
sessions?: ISessions
|
|
263
|
+
conversation?: ConversationService
|
|
165
264
|
}) {
|
|
166
|
-
const { api, scope } = props
|
|
167
|
-
const config = useConfig(scope)
|
|
168
|
-
const enabled = config?.enabled ?? true
|
|
169
|
-
// Channel-aware model options: the panel lists every configured alias
|
|
170
|
-
// (default channel first); legacy flat fields remain the upgrade fallback.
|
|
171
|
-
const modelOptions = imageModelOptions(config)
|
|
172
|
-
const hasChannels = (config?.channels ?? []).length > 0
|
|
173
|
-
// With channels configured, the model list is exactly the configured aliases
|
|
174
|
-
// (possibly empty — never fall back to the hardcoded legacy defaults).
|
|
175
|
-
const imageModels = hasChannels ? modelOptions.models : normalizeImageModels(config?.imageModels)
|
|
176
|
-
const defaultChannelId = modelOptions.defaultChannelId
|
|
177
|
-
const apiUrl = defaultChannelId !== undefined && (config?.channels ?? []).length > 0
|
|
178
|
-
? (config!.channels!.find(channel => channel.id === defaultChannelId)?.apiUrl ?? '')
|
|
179
|
-
: (config?.apiUrl ?? '')
|
|
180
|
-
const configured = apiUrl.trim() !== ''
|
|
181
|
-
const legacyKeySet = useSecretSet(scope, 'apiKey')
|
|
182
|
-
const promptKeySet = useSecretSet(scope, 'promptApiKey')
|
|
183
|
-
const channelKeySet = (config?.channels ?? []).some(channel => scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`))
|
|
184
|
-
const apiKeySet = (config?.channels ?? []).length > 0 ? channelKeySet : legacyKeySet
|
|
185
|
-
const connected = enabled && configured && apiKeySet
|
|
186
|
-
|
|
187
|
-
const [tab, setTab] = useState<PanelTab>('text')
|
|
188
|
-
const [prompt, setPrompt] = useState('')
|
|
189
|
-
const [size, setSize] = useState<string>('auto')
|
|
190
|
-
const [quality, setQuality] = useState<string>('auto')
|
|
191
|
-
const [count, setCount] = useState(1)
|
|
192
|
-
const [detail, setDetail] = useState('')
|
|
193
|
-
const [model, setModel] = useState<string>('')
|
|
194
|
-
const [compareEnabled, setCompareEnabled] = useState(false)
|
|
195
|
-
const [compareModels, setCompareModels] = useState<string[]>([])
|
|
196
|
-
const [modelOpen, setModelOpen] = useState(false)
|
|
197
|
-
const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
|
|
265
|
+
const { api, scope, sessions, conversation } = props
|
|
266
|
+
const config = useConfig(scope)
|
|
267
|
+
const enabled = config?.enabled ?? true
|
|
268
|
+
// Channel-aware model options: the panel lists every configured alias
|
|
269
|
+
// (default channel first); legacy flat fields remain the upgrade fallback.
|
|
270
|
+
const modelOptions = imageModelOptions(config)
|
|
271
|
+
const hasChannels = (config?.channels ?? []).length > 0
|
|
272
|
+
// With channels configured, the model list is exactly the configured aliases
|
|
273
|
+
// (possibly empty — never fall back to the hardcoded legacy defaults).
|
|
274
|
+
const imageModels = hasChannels ? modelOptions.models : normalizeImageModels(config?.imageModels)
|
|
275
|
+
const defaultChannelId = modelOptions.defaultChannelId
|
|
276
|
+
const apiUrl = defaultChannelId !== undefined && (config?.channels ?? []).length > 0
|
|
277
|
+
? (config!.channels!.find(channel => channel.id === defaultChannelId)?.apiUrl ?? '')
|
|
278
|
+
: (config?.apiUrl ?? '')
|
|
279
|
+
const configured = apiUrl.trim() !== ''
|
|
280
|
+
const legacyKeySet = useSecretSet(scope, 'apiKey')
|
|
281
|
+
const promptKeySet = useSecretSet(scope, 'promptApiKey')
|
|
282
|
+
const channelKeySet = (config?.channels ?? []).some(channel => scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`))
|
|
283
|
+
const apiKeySet = (config?.channels ?? []).length > 0 ? channelKeySet : legacyKeySet
|
|
284
|
+
const connected = enabled && configured && apiKeySet
|
|
285
|
+
|
|
286
|
+
const [tab, setTab] = useState<PanelTab>('text')
|
|
287
|
+
const [prompt, setPrompt] = useState('')
|
|
288
|
+
const [size, setSize] = useState<string>('auto')
|
|
289
|
+
const [quality, setQuality] = useState<string>('auto')
|
|
290
|
+
const [count, setCount] = useState(1)
|
|
291
|
+
const [detail, setDetail] = useState('')
|
|
292
|
+
const [model, setModel] = useState<string>('')
|
|
293
|
+
const [compareEnabled, setCompareEnabled] = useState(false)
|
|
294
|
+
const [compareModels, setCompareModels] = useState<string[]>([])
|
|
295
|
+
const [modelOpen, setModelOpen] = useState(false)
|
|
296
|
+
const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
|
|
198
297
|
const [images, setImages] = useState<GeneratedImage[]>([])
|
|
199
|
-
const [
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const [
|
|
205
|
-
const [
|
|
206
|
-
const [
|
|
207
|
-
const [
|
|
208
|
-
const [
|
|
209
|
-
const [
|
|
210
|
-
const [
|
|
211
|
-
const [
|
|
212
|
-
const [
|
|
213
|
-
const [
|
|
214
|
-
const [
|
|
215
|
-
const [
|
|
216
|
-
const [
|
|
217
|
-
const [
|
|
218
|
-
const [
|
|
219
|
-
const [
|
|
220
|
-
const [
|
|
221
|
-
const [
|
|
222
|
-
const [
|
|
223
|
-
const [
|
|
224
|
-
const [
|
|
225
|
-
const [
|
|
226
|
-
const [
|
|
227
|
-
const [
|
|
228
|
-
const [
|
|
229
|
-
const [
|
|
230
|
-
const [
|
|
231
|
-
const [
|
|
232
|
-
const [
|
|
233
|
-
const [
|
|
234
|
-
const [
|
|
235
|
-
const [
|
|
298
|
+
const [addingToConversation, setAddingToConversation] = useState<number | null>(null)
|
|
299
|
+
const [conversationMessage, setConversationMessage] = useState<string | null>(null)
|
|
300
|
+
const [error, setError] = useState<string | null>(null)
|
|
301
|
+
// Submission is brief; actual generation stays visible until the host
|
|
302
|
+
// queue reports that every queued/running task has finished.
|
|
303
|
+
const [submitting, setSubmitting] = useState(false)
|
|
304
|
+
const [enhancing, setEnhancing] = useState(false)
|
|
305
|
+
const [configGuide, setConfigGuide] = useState<'generation' | 'enhancement' | 'disabled' | null>(null)
|
|
306
|
+
const [history, setHistory] = useState<HistoryEntry[]>([])
|
|
307
|
+
const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
|
|
308
|
+
const [gallery, setGallery] = useState<HistoryEntry[]>([])
|
|
309
|
+
const [galleryViewingId, setGalleryViewingId] = useState<string | null>(null)
|
|
310
|
+
const [galleryAdding, setGalleryAdding] = useState(false)
|
|
311
|
+
const [galleryMessage, setGalleryMessage] = useState<string | null>(null)
|
|
312
|
+
const [galleryFilter, setGalleryFilter] = useState<GalleryFilter>('all')
|
|
313
|
+
const [galleryRatio, setGalleryRatio] = useState('all')
|
|
314
|
+
const [galleryTagFilter, setGalleryTagFilter] = useState<string | null>(null)
|
|
315
|
+
const [galleryView, setGalleryView] = useState<'masonry' | 'grid'>('masonry')
|
|
316
|
+
const [gallerySort, setGallerySort] = useState<'newest' | 'oldest'>('newest')
|
|
317
|
+
const [galleryQuery, setGalleryQuery] = useState('')
|
|
318
|
+
const [galleryTagInput, setGalleryTagInput] = useState('')
|
|
319
|
+
const [editingGalleryTagsId, setEditingGalleryTagsId] = useState<string | null>(null)
|
|
320
|
+
const [galleryTagEditInput, setGalleryTagEditInput] = useState('')
|
|
321
|
+
const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set())
|
|
322
|
+
const [gallerySelecting, setGallerySelecting] = useState(false)
|
|
323
|
+
const [historyQuery, setHistoryQuery] = useState('')
|
|
324
|
+
const [historyModelFilter, setHistoryModelFilter] = useState('all')
|
|
325
|
+
const [historyRatioFilter, setHistoryRatioFilter] = useState('all')
|
|
326
|
+
const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
|
|
327
|
+
const [previewScale, setPreviewScale] = useState(1)
|
|
328
|
+
const [promptCopied, setPromptCopied] = useState(false)
|
|
329
|
+
const [update, setUpdate] = useState<UpdateInfo | null>(null)
|
|
330
|
+
const [updating, setUpdating] = useState(false)
|
|
331
|
+
const [updateMessage, setUpdateMessage] = useState<string | null>(null)
|
|
332
|
+
const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
|
|
333
|
+
const [libraryOpen, setLibraryOpen] = useState(false)
|
|
334
|
+
const [tasks, setTasks] = useState<GenerationTask[]>([])
|
|
335
|
+
const tasksRef = useRef<GenerationTask[]>([])
|
|
336
|
+
const [taskTrayOpen, setTaskTrayOpen] = useState(false)
|
|
337
|
+
const [comparison, setComparison] = useState<ComparisonSession | null>(null)
|
|
236
338
|
const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
})
|
|
254
|
-
}, [imageModelKey])
|
|
255
|
-
|
|
256
|
-
const filteredGallery = gallery
|
|
257
|
-
.filter(entry => {
|
|
258
|
-
if (galleryFilter === 'all') return true
|
|
259
|
-
if (galleryFilter === 'text' || galleryFilter === 'edit') return entry.mode === galleryFilter
|
|
260
|
-
return entry.model === galleryFilter
|
|
261
|
-
})
|
|
262
|
-
.filter(entry => galleryRatio === 'all' || normalizeSize(entry.size) === galleryRatio)
|
|
263
|
-
.filter(entry => galleryTagFilter === null || (entry.tags ?? []).includes(galleryTagFilter))
|
|
264
|
-
.filter(entry => galleryQuery.trim() === '' || `${entry.prompt} ${entry.model} ${(entry.tags ?? []).join(' ')}`.toLocaleLowerCase().includes(galleryQuery.trim().toLocaleLowerCase()))
|
|
265
|
-
.slice()
|
|
266
|
-
.sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
|
|
267
|
-
|
|
268
|
-
const galleryTagOptions = [...new Set(gallery.flatMap(entry => entry.tags ?? []))].sort((a, b) => a.localeCompare(b))
|
|
269
|
-
const galleryModels = [...new Set([...imageModels, ...gallery.map(entry => entry.model)])]
|
|
270
|
-
|
|
271
|
-
const filteredHistory = history.filter(entry => {
|
|
272
|
-
const query = historyQuery.trim().toLocaleLowerCase()
|
|
273
|
-
return (query === '' || `${entry.prompt} ${entry.model}`.toLocaleLowerCase().includes(query))
|
|
274
|
-
&& (historyModelFilter === 'all' || entry.model === historyModelFilter)
|
|
275
|
-
&& (historyRatioFilter === 'all' || normalizeSize(entry.size) === historyRatioFilter)
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
// Load the host-persisted history and gallery once on mount (they live in
|
|
279
|
-
// ~/.dsh on the DSH host, so every browser/device sees the same lists).
|
|
339
|
+
const currentSessionId = useCurrentSessionId(sessions)
|
|
340
|
+
const sidebarHistoryHost = useSidebarHistoryHost()
|
|
341
|
+
const modeModels = tab === 'edit'
|
|
342
|
+
? imageModels.filter(candidate => describeModel(candidate).supportsEdit)
|
|
343
|
+
: imageModels
|
|
344
|
+
const fileInput = useRef<HTMLInputElement>(null)
|
|
345
|
+
const previewStage = useRef<HTMLDivElement>(null)
|
|
346
|
+
const activeTasks = tasks.filter(task => task.status === 'queued' || task.status === 'running')
|
|
347
|
+
const activeTask = activeTasks.find(task => task.status === 'running') ?? activeTasks[0]
|
|
348
|
+
const generating = submitting || activeTasks.length > 0
|
|
349
|
+
const generationStartedAt = activeTask?.startedAt ?? activeTask?.createdAt ?? null
|
|
350
|
+
const elapsed = useElapsed(generating, generationStartedAt)
|
|
351
|
+
|
|
352
|
+
// A saved settings change is authoritative. Keep the active selection and
|
|
353
|
+
// comparison choices in that allow-list without disturbing valid choices.
|
|
354
|
+
const imageModelKey = modeModels.join('\u0000')
|
|
280
355
|
useEffect(() => {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
.
|
|
284
|
-
.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
356
|
+
setModel(previous => modeModels.includes(previous) ? previous : modeModels[0] ?? '')
|
|
357
|
+
setCompareModels(previous => {
|
|
358
|
+
const retained = previous.filter(candidate => modeModels.includes(candidate))
|
|
359
|
+
return retained.length > 0 ? retained : modeModels[0] === undefined ? [] : [modeModels[0]]
|
|
360
|
+
})
|
|
361
|
+
}, [imageModelKey])
|
|
362
|
+
|
|
363
|
+
const filteredGallery = gallery
|
|
364
|
+
.filter(entry => {
|
|
365
|
+
if (galleryFilter === 'all') return true
|
|
366
|
+
if (galleryFilter === 'text' || galleryFilter === 'edit') return entry.mode === galleryFilter
|
|
367
|
+
return entry.model === galleryFilter
|
|
368
|
+
})
|
|
369
|
+
.filter(entry => galleryRatio === 'all' || normalizeSize(entry.size) === galleryRatio)
|
|
370
|
+
.filter(entry => galleryTagFilter === null || (entry.tags ?? []).includes(galleryTagFilter))
|
|
371
|
+
.filter(entry => galleryQuery.trim() === '' || `${entry.prompt} ${entry.model} ${(entry.tags ?? []).join(' ')}`.toLocaleLowerCase().includes(galleryQuery.trim().toLocaleLowerCase()))
|
|
372
|
+
.slice()
|
|
373
|
+
.sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
|
|
374
|
+
|
|
375
|
+
const galleryTagOptions = [...new Set(gallery.flatMap(entry => entry.tags ?? []))].sort((a, b) => a.localeCompare(b))
|
|
376
|
+
const galleryModels = [...new Set([...imageModels, ...gallery.map(entry => entry.model)])]
|
|
377
|
+
|
|
378
|
+
const filteredHistory = groupHistoryEntries(history).filter(group => group.entries.some(entry => {
|
|
379
|
+
const query = historyQuery.trim().toLocaleLowerCase()
|
|
380
|
+
const models = modelsOfHistoryEntry(entry)
|
|
381
|
+
return (query === '' || `${entry.prompt} ${models.join(' ')}`.toLocaleLowerCase().includes(query))
|
|
382
|
+
&& (historyModelFilter === 'all' || models.includes(historyModelFilter))
|
|
383
|
+
&& (historyRatioFilter === 'all' || normalizeSize(entry.size) === historyRatioFilter)
|
|
384
|
+
}))
|
|
385
|
+
|
|
386
|
+
// Load the host-persisted history and gallery once on mount (they live in
|
|
387
|
+
// ~/.dsh on the DSH host, so every browser/device sees the same lists).
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
let disposed = false
|
|
390
|
+
api.historyList()
|
|
391
|
+
.then(entries => { if (!disposed) setHistory(entries) })
|
|
392
|
+
.catch(() => { /* history unavailable — leave the list empty */ })
|
|
393
|
+
api.galleryList()
|
|
394
|
+
.then(entries => { if (!disposed) setGallery(entries) })
|
|
395
|
+
.catch(() => { /* gallery unavailable — leave the list empty */ })
|
|
396
|
+
return () => { disposed = true }
|
|
289
397
|
}, [api])
|
|
290
398
|
|
|
399
|
+
// Chat toolviews publish durable refs after they finish loading. Decode the
|
|
400
|
+
// refs through the same host-authorized route and make them the current
|
|
401
|
+
// canvas result for the selected session.
|
|
291
402
|
useEffect(() => {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
setError(completed.result.historyError ?? null)
|
|
304
|
-
}
|
|
305
|
-
return next
|
|
403
|
+
const onChatImages = (event: Event): void => {
|
|
404
|
+
const detail = (event as CustomEvent<ChatImageEventDetail>).detail
|
|
405
|
+
if (detail === undefined || currentSessionId === undefined || detail.sessionId !== currentSessionId) return
|
|
406
|
+
void Promise.all(detail.refs.map(attachmentToGenerated))
|
|
407
|
+
.then(next => {
|
|
408
|
+
setTab('text')
|
|
409
|
+
setImages(next)
|
|
410
|
+
setComparison(null)
|
|
411
|
+
setViewingHistoryId(null)
|
|
412
|
+
setGalleryViewingId(null)
|
|
413
|
+
setError(null)
|
|
306
414
|
})
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
refresh()
|
|
310
|
-
const timer = window.setInterval(refresh, 1500)
|
|
311
|
-
return () => { disposed = true; window.clearInterval(timer) }
|
|
312
|
-
}, [api, comparison])
|
|
313
|
-
|
|
314
|
-
// Close the model dropdown when clicking anywhere outside it.
|
|
315
|
-
const modelMenuRef = useRef<HTMLDivElement>(null)
|
|
316
|
-
useEffect(() => {
|
|
317
|
-
if (!modelOpen) return
|
|
318
|
-
const onPointer = (event: MouseEvent | FocusEvent): void => {
|
|
319
|
-
const target = event.target
|
|
320
|
-
if (target instanceof Node && modelMenuRef.current?.contains(target)) return
|
|
321
|
-
setModelOpen(false)
|
|
322
|
-
}
|
|
323
|
-
document.addEventListener('mousedown', onPointer)
|
|
324
|
-
document.addEventListener('focusin', onPointer)
|
|
325
|
-
return () => {
|
|
326
|
-
document.removeEventListener('mousedown', onPointer)
|
|
327
|
-
document.removeEventListener('focusin', onPointer)
|
|
328
|
-
}
|
|
329
|
-
}, [modelOpen])
|
|
330
|
-
|
|
331
|
-
// Release checks are host-mediated and intentionally best-effort: a GitHub
|
|
332
|
-
// outage must never make the image-generation studio unavailable.
|
|
333
|
-
useEffect(() => {
|
|
334
|
-
let disposed = false
|
|
335
|
-
api.updateCheck()
|
|
336
|
-
.then(info => {
|
|
337
|
-
if (!disposed && info.updateAvailable) setUpdate(info)
|
|
338
|
-
})
|
|
339
|
-
.catch(() => { /* update discovery is optional */ })
|
|
340
|
-
return () => { disposed = true }
|
|
341
|
-
}, [api])
|
|
342
|
-
|
|
343
|
-
const applyUpdate = async (): Promise<void> => {
|
|
344
|
-
if (update === null || updating) return
|
|
345
|
-
setUpdating(true)
|
|
346
|
-
setUpdateMessage(null)
|
|
347
|
-
setUpdateResult(null)
|
|
348
|
-
try {
|
|
349
|
-
const result = await api.updateApply(update.latestVersion)
|
|
350
|
-
setUpdateMessage(tt('update.success', { version: result.updatedVersion }))
|
|
351
|
-
setUpdateResult('success')
|
|
352
|
-
} catch {
|
|
353
|
-
setUpdateMessage(tt('update.failed'))
|
|
354
|
-
setUpdateResult('failed')
|
|
355
|
-
} finally {
|
|
356
|
-
setUpdating(false)
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const openSettingsGuide = (kind: 'generation' | 'enhancement' | 'disabled'): void => {
|
|
361
|
-
setConfigGuide(kind)
|
|
362
|
-
const openPluginSettings = (): void => {
|
|
363
|
-
const pluginButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(插件|Plugins)$/.test(button.textContent?.trim() ?? ''))
|
|
364
|
-
pluginButton?.click()
|
|
365
|
-
window.setTimeout(() => {
|
|
366
|
-
const imageGenButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /dsh-imagegen/i.test(button.textContent ?? ''))
|
|
367
|
-
if (imageGenButton?.getAttribute('aria-expanded') !== 'true') imageGenButton?.click()
|
|
368
|
-
}, 0)
|
|
415
|
+
.catch(caught => { setError(errorMessage(caught)) })
|
|
369
416
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
417
|
+
document.addEventListener(CHAT_IMAGE_EVENT, onChatImages)
|
|
418
|
+
return () => document.removeEventListener(CHAT_IMAGE_EVENT, onChatImages)
|
|
419
|
+
}, [currentSessionId])
|
|
420
|
+
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
let disposed = false
|
|
423
|
+
const refresh = (): void => {
|
|
424
|
+
void api.taskList().then(next => {
|
|
425
|
+
if (disposed) return
|
|
426
|
+
const newlyCompleted = next.filter(task => task.status === 'completed'
|
|
427
|
+
&& task.result !== undefined
|
|
428
|
+
&& !tasksRef.current.some(old => old.id === task.id && old.status === 'completed'))
|
|
429
|
+
tasksRef.current = next
|
|
430
|
+
setTasks(previous => {
|
|
431
|
+
const completed = next.find(task => task.status === 'completed'
|
|
432
|
+
&& !previous.some(old => old.id === task.id && old.status === 'completed')
|
|
433
|
+
&& !comparison?.taskIds.includes(task.id))
|
|
434
|
+
if (completed?.result !== undefined) {
|
|
435
|
+
setImages(completed.result.images)
|
|
436
|
+
if (completed.result.history !== undefined) setHistory(completed.result.history)
|
|
437
|
+
setError(completed.result.historyError ?? null)
|
|
438
|
+
}
|
|
439
|
+
return next
|
|
440
|
+
})
|
|
441
|
+
if (newlyCompleted.length > 0) {
|
|
442
|
+
void api.historyList().then(entries => {
|
|
443
|
+
if (!disposed) setHistory(entries)
|
|
444
|
+
}).catch(() => {})
|
|
445
|
+
}
|
|
446
|
+
}).catch(() => {})
|
|
447
|
+
}
|
|
448
|
+
refresh()
|
|
449
|
+
const timer = window.setInterval(refresh, 1500)
|
|
450
|
+
return () => { disposed = true; window.clearInterval(timer) }
|
|
451
|
+
}, [api, comparison])
|
|
452
|
+
|
|
453
|
+
// Close the model dropdown when clicking anywhere outside it.
|
|
454
|
+
const modelMenuRef = useRef<HTMLDivElement>(null)
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
if (!modelOpen) return
|
|
457
|
+
const onPointer = (event: MouseEvent | FocusEvent): void => {
|
|
458
|
+
const target = event.target
|
|
459
|
+
if (target instanceof Node && modelMenuRef.current?.contains(target)) return
|
|
460
|
+
setModelOpen(false)
|
|
461
|
+
}
|
|
462
|
+
document.addEventListener('mousedown', onPointer)
|
|
463
|
+
document.addEventListener('focusin', onPointer)
|
|
464
|
+
return () => {
|
|
465
|
+
document.removeEventListener('mousedown', onPointer)
|
|
466
|
+
document.removeEventListener('focusin', onPointer)
|
|
467
|
+
}
|
|
468
|
+
}, [modelOpen])
|
|
469
|
+
|
|
470
|
+
// Release checks are host-mediated and intentionally best-effort: a GitHub
|
|
471
|
+
// outage must never make the image-generation studio unavailable.
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
let disposed = false
|
|
474
|
+
api.updateCheck()
|
|
475
|
+
.then(info => {
|
|
476
|
+
if (!disposed && info.updateAvailable) setUpdate(info)
|
|
477
|
+
})
|
|
478
|
+
.catch(() => { /* update discovery is optional */ })
|
|
479
|
+
return () => { disposed = true }
|
|
480
|
+
}, [api])
|
|
481
|
+
|
|
482
|
+
const applyUpdate = async (): Promise<void> => {
|
|
483
|
+
if (update === null || updating) return
|
|
484
|
+
setUpdating(true)
|
|
485
|
+
setUpdateMessage(null)
|
|
486
|
+
setUpdateResult(null)
|
|
487
|
+
try {
|
|
488
|
+
const result = await api.updateApply(update.latestVersion)
|
|
489
|
+
setUpdateMessage(tt('update.success', { version: result.updatedVersion }))
|
|
490
|
+
setUpdateResult('success')
|
|
491
|
+
} catch {
|
|
492
|
+
setUpdateMessage(tt('update.failed'))
|
|
493
|
+
setUpdateResult('failed')
|
|
494
|
+
} finally {
|
|
495
|
+
setUpdating(false)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const openSettingsGuide = (kind: 'generation' | 'enhancement' | 'disabled'): void => {
|
|
500
|
+
setConfigGuide(kind)
|
|
501
|
+
const openPluginSettings = (): void => {
|
|
502
|
+
const pluginButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(插件|Plugins)$/.test(button.textContent?.trim() ?? ''))
|
|
503
|
+
pluginButton?.click()
|
|
504
|
+
window.setTimeout(() => {
|
|
505
|
+
const imageGenButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /dsh-imagegen/i.test(button.textContent ?? ''))
|
|
506
|
+
if (imageGenButton?.getAttribute('aria-expanded') !== 'true') imageGenButton?.click()
|
|
507
|
+
}, 0)
|
|
508
|
+
}
|
|
509
|
+
const settingsButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(设置|Settings)$/.test(button.textContent?.trim() ?? ''))
|
|
510
|
+
if (settingsButton?.getAttribute('aria-expanded') !== 'true') settingsButton?.click()
|
|
511
|
+
window.setTimeout(openPluginSettings, 0)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const enhanceCurrentPrompt = async (): Promise<void> => {
|
|
515
|
+
if (prompt.trim() === '' || enhancing) return
|
|
516
|
+
const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || configured
|
|
517
|
+
if ((config?.promptModel ?? '').trim() === '' || !promptEndpointConfigured || (!promptKeySet && !apiKeySet)) {
|
|
518
|
+
openSettingsGuide('enhancement')
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
setEnhancing(true)
|
|
522
|
+
setError(null)
|
|
523
|
+
try {
|
|
524
|
+
setPrompt(await api.enhancePrompt(prompt))
|
|
525
|
+
} catch (caught) {
|
|
526
|
+
setError(errorMessage(caught))
|
|
527
|
+
} finally {
|
|
528
|
+
setEnhancing(false)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Read an uploaded reference image into a data URL. */
|
|
533
|
+
const acceptFile = (file: File | undefined): void => {
|
|
534
|
+
if (file === undefined) return
|
|
535
|
+
if (!file.type.startsWith('image/')) {
|
|
536
|
+
setError(tt('edit.uploadHint'))
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
if (file.size > REF_IMAGE_MAX_BYTES) {
|
|
540
|
+
setError(tt('edit.uploadHint'))
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
const reader = new FileReader()
|
|
544
|
+
reader.onload = () => {
|
|
545
|
+
if (typeof reader.result === 'string') setRefImage({ dataUrl: reader.result, name: file.name })
|
|
546
|
+
}
|
|
547
|
+
reader.onerror = () => { setError(tt('edit.uploadHint')) }
|
|
548
|
+
reader.readAsDataURL(file)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Run one generation. */
|
|
552
|
+
const handleGenerate = async (): Promise<void> => {
|
|
553
|
+
if (submitting) return
|
|
554
|
+
if (!enabled) {
|
|
555
|
+
openSettingsGuide('disabled')
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
if (!configured || !apiKeySet) {
|
|
559
|
+
openSettingsGuide('generation')
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
const promptText = prompt.trim()
|
|
563
|
+
if (promptText === '') {
|
|
564
|
+
setError(tt('prompt.required'))
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
if (tab === 'edit' && refImage === null) {
|
|
568
|
+
setError(tt('edit.required'))
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
const request: GenerateRequest = {
|
|
572
|
+
mode: tab === 'gallery' ? 'text' : tab,
|
|
573
|
+
model: modeModels.includes(model) ? model : modeModels[0] ?? '',
|
|
574
|
+
prompt: promptText,
|
|
575
|
+
size,
|
|
576
|
+
quality,
|
|
577
|
+
n: count,
|
|
578
|
+
detail,
|
|
579
|
+
...defaultChannelId !== undefined ? { channelId: defaultChannelId } : {},
|
|
580
|
+
...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
|
|
581
|
+
...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
|
|
582
|
+
}
|
|
583
|
+
setError(null)
|
|
584
|
+
setSubmitting(true)
|
|
585
|
+
try {
|
|
586
|
+
const targetModels = (compareEnabled ? compareModels : [request.model]).filter(candidate => modeModels.includes(candidate))
|
|
587
|
+
if (targetModels.length === 0) {
|
|
588
|
+
setError(tt('compare.selectRequired'))
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
const comparisonId = targetModels.length > 1 ? newComparisonId() : undefined
|
|
592
|
+
const comparisonFields = comparisonId === undefined ? {} : { comparisonId, comparisonModels: targetModels }
|
|
593
|
+
const submitted = await Promise.all(targetModels.map(targetModel => api.taskSubmit({ ...request, model: targetModel, ...comparisonFields })))
|
|
594
|
+
setTasks(previous => [...submitted, ...previous.filter(item => !submitted.some(task => task.id === item.id))])
|
|
595
|
+
setComparison(comparisonId === undefined ? null : { taskIds: submitted.map(task => task.id), prompt: promptText, comparisonId })
|
|
596
|
+
} catch (caught) {
|
|
597
|
+
setError(errorMessage(caught))
|
|
598
|
+
} finally {
|
|
599
|
+
setSubmitting(false)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Open the full-screen image preview at a given index. */
|
|
604
|
+
const openPreview = (previewImages: GeneratedImage[], index: number): void => {
|
|
605
|
+
setPreview({ images: previewImages, index })
|
|
606
|
+
setPreviewScale(1)
|
|
607
|
+
setPromptCopied(false)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const closePreview = (): void => {
|
|
611
|
+
setPreview(null)
|
|
612
|
+
setPreviewScale(1)
|
|
613
|
+
setPromptCopied(false)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Step the preview by ±1, wrapping around. */
|
|
617
|
+
const stepPreview = (delta: number): void => {
|
|
618
|
+
setPreviewScale(1)
|
|
619
|
+
setPromptCopied(false)
|
|
620
|
+
setPreview(current => {
|
|
621
|
+
if (current === null) return null
|
|
622
|
+
const total = current.images.length
|
|
623
|
+
return { images: current.images, index: (current.index + delta + total) % total }
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Keyboard navigation for the preview overlay.
|
|
628
|
+
useEffect(() => {
|
|
629
|
+
if (preview === null) return
|
|
630
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
631
|
+
if (event.key === 'Escape') closePreview()
|
|
632
|
+
else if (event.key === 'ArrowLeft') stepPreview(-1)
|
|
633
|
+
else if (event.key === 'ArrowRight') stepPreview(1)
|
|
634
|
+
else if (event.key === '+' || event.key === '=') setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP))
|
|
635
|
+
else if (event.key === '-') setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP))
|
|
636
|
+
else if (event.key === '0') setPreviewScale(1)
|
|
637
|
+
}
|
|
638
|
+
window.addEventListener('keydown', onKey)
|
|
639
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
640
|
+
}, [preview])
|
|
641
|
+
|
|
642
|
+
// A scaled image owns real scrollable space, rather than being visually
|
|
643
|
+
// transformed and clipped. Recenter the viewport after every zoom or slide.
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
if (preview === null) return
|
|
646
|
+
const frame = window.requestAnimationFrame(() => {
|
|
647
|
+
const stage = previewStage.current
|
|
648
|
+
if (stage === null) return
|
|
649
|
+
stage.scrollLeft = Math.max(0, (stage.scrollWidth - stage.clientWidth) / 2)
|
|
650
|
+
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) / 2)
|
|
651
|
+
})
|
|
652
|
+
return () => window.cancelAnimationFrame(frame)
|
|
653
|
+
}, [preview, previewScale])
|
|
654
|
+
|
|
655
|
+
const loadHistoryGroup = async (group: HistoryGroup): Promise<GeneratedImage[]> => {
|
|
656
|
+
const loaded = await Promise.all(group.entries.map(entry => historyImagesToGenerated(entry.images)))
|
|
657
|
+
return loaded.flat()
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** View every model result from one comparison as one canvas result set. */
|
|
661
|
+
const viewHistoryGroup = async (group: HistoryGroup): Promise<void> => {
|
|
662
|
+
const entry = group.entries[0]
|
|
663
|
+
if (entry === undefined) return
|
|
664
|
+
// History is also the bridge out of the gallery: show the image workspace
|
|
665
|
+
// immediately, then hydrate the selected result into its canvas.
|
|
666
|
+
setTab('text')
|
|
384
667
|
try {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
setError(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
668
|
+
setImages(await loadHistoryGroup(group))
|
|
669
|
+
setComparison(null)
|
|
670
|
+
setError(null)
|
|
671
|
+
setViewingHistoryId(entry.id)
|
|
672
|
+
setGalleryViewingId(null)
|
|
673
|
+
} catch (caught) {
|
|
674
|
+
setError(errorMessage(caught))
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Restore one comparison group, including its selected model set. */
|
|
679
|
+
const restoreHistoryGroup = async (group: HistoryGroup): Promise<void> => {
|
|
680
|
+
const entry = group.entries[0]
|
|
681
|
+
if (entry === undefined) return
|
|
682
|
+
try {
|
|
683
|
+
const restored = await loadHistoryGroup(group)
|
|
684
|
+
setTab(entry.mode)
|
|
685
|
+
setPrompt(entry.prompt)
|
|
686
|
+
setSize(normalizeSize(entry.size))
|
|
687
|
+
setQuality(normalizeQuality(entry.quality))
|
|
688
|
+
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
689
|
+
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
690
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
691
|
+
setCompareModels(group.models.filter(candidate => imageModels.includes(candidate)))
|
|
692
|
+
setCompareEnabled(group.models.filter(candidate => imageModels.includes(candidate)).length > 1)
|
|
693
|
+
setRefImage(null)
|
|
694
|
+
setImages(restored)
|
|
695
|
+
setComparison(null)
|
|
696
|
+
setError(null)
|
|
697
|
+
setViewingHistoryId(entry.id)
|
|
698
|
+
setGalleryViewingId(null)
|
|
699
|
+
} catch (caught) {
|
|
700
|
+
setError(errorMessage(caught))
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Remove every persisted row belonging to one comparison group. */
|
|
705
|
+
const deleteHistoryGroup = async (group: HistoryGroup): Promise<void> => {
|
|
706
|
+
const ids = new Set(group.entries.map(entry => entry.id))
|
|
707
|
+
setHistory(previous => previous.filter(entry => !ids.has(entry.id)))
|
|
708
|
+
if (viewingHistoryId !== null && ids.has(viewingHistoryId)) setViewingHistoryId(null)
|
|
709
|
+
try {
|
|
710
|
+
let next = history
|
|
711
|
+
for (const id of ids) next = await api.historyRemove(id)
|
|
712
|
+
setHistory(next)
|
|
713
|
+
} catch {
|
|
714
|
+
// Keep the optimistic local removal.
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Remove all history entries. */
|
|
719
|
+
const clearHistory = async (): Promise<void> => {
|
|
720
|
+
setHistory([])
|
|
721
|
+
setViewingHistoryId(null)
|
|
722
|
+
try {
|
|
723
|
+
setHistory(await api.historyClear())
|
|
724
|
+
} catch {
|
|
725
|
+
// Keep the cleared local state.
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Add one generated image to the gallery (host deduplicates by content).
|
|
730
|
+
* `entry` makes the action available from a history/gallery list item (its
|
|
731
|
+
* metadata + first image are saved); otherwise the current form state is
|
|
732
|
+
* used. */
|
|
733
|
+
const addToGallery = async (image: GeneratedImage, entry?: HistoryEntry): Promise<void> => {
|
|
734
|
+
if (galleryAdding || tab === 'gallery') return
|
|
735
|
+
const source = entry ?? viewingEntry ?? {
|
|
736
|
+
mode: tab === 'edit' ? 'edit' as GenerateMode : 'text' as GenerateMode,
|
|
737
|
+
model,
|
|
738
|
+
prompt: prompt.trim(),
|
|
739
|
+
size,
|
|
740
|
+
quality,
|
|
741
|
+
detail,
|
|
742
|
+
...refImage !== null ? { refName: refImage.name } : {},
|
|
743
|
+
}
|
|
744
|
+
setGalleryAdding(true)
|
|
745
|
+
try {
|
|
746
|
+
const result = await api.galleryAppend({
|
|
747
|
+
id: '', // the host assigns a fresh id
|
|
748
|
+
createdAt: Date.now(),
|
|
749
|
+
mode: source.mode,
|
|
750
|
+
model: source.model,
|
|
751
|
+
prompt: source.prompt,
|
|
752
|
+
size: source.size,
|
|
753
|
+
quality: source.quality,
|
|
754
|
+
detail: source.detail,
|
|
755
|
+
n: 1,
|
|
756
|
+
images: [image],
|
|
757
|
+
...source.refName === undefined ? {} : { refName: source.refName },
|
|
758
|
+
})
|
|
759
|
+
setGallery(result.entries)
|
|
760
|
+
setGalleryMessage(result.added ? tt('gallery.added') : tt('gallery.already'))
|
|
761
|
+
window.setTimeout(() => { setGalleryMessage(null) }, 2200)
|
|
762
|
+
} catch (caught) {
|
|
763
|
+
setError(errorMessage(caught))
|
|
764
|
+
} finally {
|
|
765
|
+
setGalleryAdding(false)
|
|
766
|
+
}
|
|
410
767
|
}
|
|
411
768
|
|
|
412
|
-
/**
|
|
413
|
-
const
|
|
414
|
-
if (
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
return
|
|
418
|
-
}
|
|
419
|
-
if (!configured || !apiKeySet) {
|
|
420
|
-
openSettingsGuide('generation')
|
|
421
|
-
return
|
|
422
|
-
}
|
|
423
|
-
const promptText = prompt.trim()
|
|
424
|
-
if (promptText === '') {
|
|
425
|
-
setError(tt('prompt.required'))
|
|
769
|
+
/** Put a generated image into the native conversation composer. */
|
|
770
|
+
const addImageToConversation = async (image: GeneratedImage, index: number): Promise<void> => {
|
|
771
|
+
if (addingToConversation !== null) return
|
|
772
|
+
if (conversation === undefined || sessions === undefined || currentSessionId === undefined) {
|
|
773
|
+
setError(tt('conversation.noSession'))
|
|
426
774
|
return
|
|
427
775
|
}
|
|
428
|
-
|
|
429
|
-
|
|
776
|
+
const sessionScope = sessions.scope(currentSessionId)
|
|
777
|
+
if (sessionScope === undefined) {
|
|
778
|
+
setError(tt('conversation.unavailable'))
|
|
430
779
|
return
|
|
431
780
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
prompt: promptText,
|
|
436
|
-
size,
|
|
437
|
-
quality,
|
|
438
|
-
n: count,
|
|
439
|
-
detail,
|
|
440
|
-
...defaultChannelId !== undefined ? { channelId: defaultChannelId } : {},
|
|
441
|
-
...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
|
|
442
|
-
...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
|
|
443
|
-
}
|
|
444
|
-
setError(null)
|
|
445
|
-
setSubmitting(true)
|
|
781
|
+
setAddingToConversation(index)
|
|
782
|
+
let attachments: ReturnType<ConversationService['createDraftImages']> = []
|
|
783
|
+
let added = false
|
|
446
784
|
try {
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
785
|
+
const file = generatedImageToFile(image, index)
|
|
786
|
+
attachments = conversation.createDraftImages([file])
|
|
787
|
+
const input = conversation.input.for(sessionScope)
|
|
788
|
+
if (!input.addImages(attachments.map(attachment => attachment.id))) {
|
|
789
|
+
throw new Error(tt('conversation.busy'))
|
|
451
790
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
setError(errorMessage(caught))
|
|
457
|
-
} finally {
|
|
458
|
-
setSubmitting(false)
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** Open the full-screen image preview at a given index. */
|
|
463
|
-
const openPreview = (previewImages: GeneratedImage[], index: number): void => {
|
|
464
|
-
setPreview({ images: previewImages, index })
|
|
465
|
-
setPreviewScale(1)
|
|
466
|
-
setPromptCopied(false)
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const closePreview = (): void => {
|
|
470
|
-
setPreview(null)
|
|
471
|
-
setPreviewScale(1)
|
|
472
|
-
setPromptCopied(false)
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/** Step the preview by ±1, wrapping around. */
|
|
476
|
-
const stepPreview = (delta: number): void => {
|
|
477
|
-
setPreviewScale(1)
|
|
478
|
-
setPromptCopied(false)
|
|
479
|
-
setPreview(current => {
|
|
480
|
-
if (current === null) return null
|
|
481
|
-
const total = current.images.length
|
|
482
|
-
return { images: current.images, index: (current.index + delta + total) % total }
|
|
483
|
-
})
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Keyboard navigation for the preview overlay.
|
|
487
|
-
useEffect(() => {
|
|
488
|
-
if (preview === null) return
|
|
489
|
-
const onKey = (event: KeyboardEvent): void => {
|
|
490
|
-
if (event.key === 'Escape') closePreview()
|
|
491
|
-
else if (event.key === 'ArrowLeft') stepPreview(-1)
|
|
492
|
-
else if (event.key === 'ArrowRight') stepPreview(1)
|
|
493
|
-
else if (event.key === '+' || event.key === '=') setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP))
|
|
494
|
-
else if (event.key === '-') setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP))
|
|
495
|
-
else if (event.key === '0') setPreviewScale(1)
|
|
496
|
-
}
|
|
497
|
-
window.addEventListener('keydown', onKey)
|
|
498
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
499
|
-
}, [preview])
|
|
500
|
-
|
|
501
|
-
// A scaled image owns real scrollable space, rather than being visually
|
|
502
|
-
// transformed and clipped. Recenter the viewport after every zoom or slide.
|
|
503
|
-
useEffect(() => {
|
|
504
|
-
if (preview === null) return
|
|
505
|
-
const frame = window.requestAnimationFrame(() => {
|
|
506
|
-
const stage = previewStage.current
|
|
507
|
-
if (stage === null) return
|
|
508
|
-
stage.scrollLeft = Math.max(0, (stage.scrollWidth - stage.clientWidth) / 2)
|
|
509
|
-
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) / 2)
|
|
510
|
-
})
|
|
511
|
-
return () => window.cancelAnimationFrame(frame)
|
|
512
|
-
}, [preview, previewScale])
|
|
513
|
-
|
|
514
|
-
/** Load a past generation's images into the canvas. */
|
|
515
|
-
const viewHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
516
|
-
try {
|
|
517
|
-
setImages(await historyImagesToGenerated(entry.images))
|
|
518
|
-
setError(null)
|
|
519
|
-
setViewingHistoryId(entry.id)
|
|
520
|
-
setGalleryViewingId(null)
|
|
521
|
-
} catch (caught) {
|
|
522
|
-
setError(errorMessage(caught))
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** Restore a past generation's parameters (and its images) into the form. */
|
|
527
|
-
const restoreHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
528
|
-
try {
|
|
529
|
-
const restored = await historyImagesToGenerated(entry.images)
|
|
530
|
-
setTab(entry.mode)
|
|
531
|
-
setPrompt(entry.prompt)
|
|
532
|
-
setSize(normalizeSize(entry.size))
|
|
533
|
-
setQuality(normalizeQuality(entry.quality))
|
|
534
|
-
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
535
|
-
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
536
|
-
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
537
|
-
setRefImage(null)
|
|
538
|
-
setImages(restored)
|
|
539
|
-
setError(null)
|
|
540
|
-
setViewingHistoryId(entry.id)
|
|
541
|
-
setGalleryViewingId(null)
|
|
542
|
-
} catch (caught) {
|
|
543
|
-
setError(errorMessage(caught))
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** Remove one history entry. */
|
|
548
|
-
const deleteHistoryEntry = async (id: string): Promise<void> => {
|
|
549
|
-
setHistory(history.filter(entry => entry.id !== id))
|
|
550
|
-
if (viewingHistoryId === id) setViewingHistoryId(null)
|
|
551
|
-
try {
|
|
552
|
-
setHistory(await api.historyRemove(id))
|
|
553
|
-
} catch {
|
|
554
|
-
// Keep the optimistic local removal.
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/** Remove all history entries. */
|
|
559
|
-
const clearHistory = async (): Promise<void> => {
|
|
560
|
-
setHistory([])
|
|
561
|
-
setViewingHistoryId(null)
|
|
562
|
-
try {
|
|
563
|
-
setHistory(await api.historyClear())
|
|
564
|
-
} catch {
|
|
565
|
-
// Keep the cleared local state.
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/** Add one generated image to the gallery (host deduplicates by content).
|
|
570
|
-
* `entry` makes the action available from a history/gallery list item (its
|
|
571
|
-
* metadata + first image are saved); otherwise the current form state is
|
|
572
|
-
* used. */
|
|
573
|
-
const addToGallery = async (image: GeneratedImage, entry?: HistoryEntry): Promise<void> => {
|
|
574
|
-
if (galleryAdding || tab === 'gallery') return
|
|
575
|
-
const source = entry ?? viewingEntry ?? {
|
|
576
|
-
mode: tab === 'edit' ? 'edit' as GenerateMode : 'text' as GenerateMode,
|
|
577
|
-
model,
|
|
578
|
-
prompt: prompt.trim(),
|
|
579
|
-
size,
|
|
580
|
-
quality,
|
|
581
|
-
detail,
|
|
582
|
-
...refImage !== null ? { refName: refImage.name } : {},
|
|
583
|
-
}
|
|
584
|
-
setGalleryAdding(true)
|
|
585
|
-
try {
|
|
586
|
-
const result = await api.galleryAppend({
|
|
587
|
-
id: '', // the host assigns a fresh id
|
|
588
|
-
createdAt: Date.now(),
|
|
589
|
-
mode: source.mode,
|
|
590
|
-
model: source.model,
|
|
591
|
-
prompt: source.prompt,
|
|
592
|
-
size: source.size,
|
|
593
|
-
quality: source.quality,
|
|
594
|
-
detail: source.detail,
|
|
595
|
-
n: 1,
|
|
596
|
-
images: [image],
|
|
597
|
-
...source.refName === undefined ? {} : { refName: source.refName },
|
|
598
|
-
})
|
|
599
|
-
setGallery(result.entries)
|
|
600
|
-
setGalleryMessage(result.added ? tt('gallery.added') : tt('gallery.already'))
|
|
601
|
-
window.setTimeout(() => { setGalleryMessage(null) }, 2200)
|
|
791
|
+
added = true
|
|
792
|
+
if (input.state.getSnapshot().draft.trim() === '' && prompt.trim() !== '') input.setDraft(prompt.trim())
|
|
793
|
+
setConversationMessage(tt('conversation.added'))
|
|
794
|
+
window.setTimeout(() => { setConversationMessage(null) }, 2200)
|
|
602
795
|
} catch (caught) {
|
|
796
|
+
if (!added) conversation.releaseDraftImages(attachments)
|
|
603
797
|
setError(errorMessage(caught))
|
|
604
798
|
} finally {
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/** Add one history entry's first image to the gallery (fetches it from the
|
|
610
|
-
* history image route, then delegates to addToGallery). */
|
|
611
|
-
const addHistoryEntryToGallery = async (entry: HistoryEntry): Promise<void> => {
|
|
612
|
-
if (galleryAdding || entry.images.length === 0) return
|
|
613
|
-
try {
|
|
614
|
-
const [image] = await historyImagesToGenerated(entry.images.slice(0, 1))
|
|
615
|
-
if (image === undefined) return
|
|
616
|
-
await addToGallery(image, entry)
|
|
617
|
-
} catch (caught) {
|
|
618
|
-
setError(errorMessage(caught))
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/** View a gallery image in the canvas. */
|
|
623
|
-
const viewGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
624
|
-
try {
|
|
625
|
-
const restored = await historyImagesToGenerated(entry.images)
|
|
626
|
-
setImages(restored)
|
|
627
|
-
setError(null)
|
|
628
|
-
setViewingHistoryId(null)
|
|
629
|
-
setGalleryViewingId(entry.id)
|
|
630
|
-
if (restored.length > 0) openPreview(restored, 0)
|
|
631
|
-
} catch (caught) {
|
|
632
|
-
setError(errorMessage(caught))
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
/** Restore a gallery entry's parameters (and its images) into the form. */
|
|
637
|
-
const restoreGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
638
|
-
try {
|
|
639
|
-
const restored = await historyImagesToGenerated(entry.images)
|
|
640
|
-
setTab(entry.mode)
|
|
641
|
-
setPrompt(entry.prompt)
|
|
642
|
-
setSize(normalizeSize(entry.size))
|
|
643
|
-
setQuality(normalizeQuality(entry.quality))
|
|
644
|
-
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
645
|
-
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
646
|
-
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
647
|
-
setRefImage(null)
|
|
648
|
-
setImages(restored)
|
|
649
|
-
setError(null)
|
|
650
|
-
setViewingHistoryId(null)
|
|
651
|
-
setGalleryViewingId(null)
|
|
652
|
-
} catch (caught) {
|
|
653
|
-
setError(errorMessage(caught))
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/** Remove one gallery entry. */
|
|
658
|
-
const deleteGalleryEntry = async (id: string): Promise<void> => {
|
|
659
|
-
setGallery(gallery.filter(entry => entry.id !== id))
|
|
660
|
-
if (galleryViewingId === id) setGalleryViewingId(null)
|
|
661
|
-
try {
|
|
662
|
-
setGallery(await api.galleryRemove(id))
|
|
663
|
-
} catch {
|
|
664
|
-
// Keep the optimistic local removal.
|
|
799
|
+
setAddingToConversation(null)
|
|
665
800
|
}
|
|
666
801
|
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
try {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
} catch (caught) {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
802
|
+
|
|
803
|
+
/** Add one history entry's first image to the gallery (fetches it from the
|
|
804
|
+
* history image route, then delegates to addToGallery). */
|
|
805
|
+
const addHistoryEntryToGallery = async (entry: HistoryEntry): Promise<void> => {
|
|
806
|
+
if (galleryAdding || entry.images.length === 0) return
|
|
807
|
+
try {
|
|
808
|
+
const [image] = await historyImagesToGenerated(entry.images.slice(0, 1))
|
|
809
|
+
if (image === undefined) return
|
|
810
|
+
await addToGallery(image, entry)
|
|
811
|
+
} catch (caught) {
|
|
812
|
+
setError(errorMessage(caught))
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** View a gallery image in the canvas. */
|
|
817
|
+
const viewGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
818
|
+
try {
|
|
819
|
+
const restored = await historyImagesToGenerated(entry.images)
|
|
820
|
+
setImages(restored)
|
|
821
|
+
setError(null)
|
|
822
|
+
setViewingHistoryId(null)
|
|
823
|
+
setGalleryViewingId(entry.id)
|
|
824
|
+
if (restored.length > 0) openPreview(restored, 0)
|
|
825
|
+
} catch (caught) {
|
|
826
|
+
setError(errorMessage(caught))
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Restore a gallery entry's parameters (and its images) into the form. */
|
|
831
|
+
const restoreGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
832
|
+
try {
|
|
833
|
+
const restored = await historyImagesToGenerated(entry.images)
|
|
834
|
+
setTab(entry.mode)
|
|
835
|
+
setPrompt(entry.prompt)
|
|
836
|
+
setSize(normalizeSize(entry.size))
|
|
837
|
+
setQuality(normalizeQuality(entry.quality))
|
|
838
|
+
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
839
|
+
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
840
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
841
|
+
setRefImage(null)
|
|
842
|
+
setImages(restored)
|
|
843
|
+
setError(null)
|
|
844
|
+
setViewingHistoryId(null)
|
|
845
|
+
setGalleryViewingId(null)
|
|
846
|
+
} catch (caught) {
|
|
847
|
+
setError(errorMessage(caught))
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Remove one gallery entry. */
|
|
852
|
+
const deleteGalleryEntry = async (id: string): Promise<void> => {
|
|
853
|
+
setGallery(gallery.filter(entry => entry.id !== id))
|
|
854
|
+
if (galleryViewingId === id) setGalleryViewingId(null)
|
|
855
|
+
try {
|
|
856
|
+
setGallery(await api.galleryRemove(id))
|
|
857
|
+
} catch {
|
|
858
|
+
// Keep the optimistic local removal.
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Remove every gallery entry. */
|
|
863
|
+
const clearGalleryAll = async (): Promise<void> => {
|
|
864
|
+
setGallery([])
|
|
865
|
+
setGalleryViewingId(null)
|
|
866
|
+
try {
|
|
867
|
+
setGallery(await api.galleryClear())
|
|
868
|
+
} catch {
|
|
869
|
+
// Keep the cleared local state.
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const applyGalleryTags = async (): Promise<void> => {
|
|
874
|
+
const tags = galleryTagInput.split(',').map(tag => tag.trim()).filter(Boolean)
|
|
875
|
+
if (tags.length === 0 || selectedGalleryIds.size === 0) return
|
|
876
|
+
try {
|
|
877
|
+
let next = gallery
|
|
878
|
+
for (const id of selectedGalleryIds) {
|
|
879
|
+
const existing = next.find(entry => entry.id === id)?.tags ?? []
|
|
880
|
+
next = await api.gallerySetTags(id, [...existing, ...tags])
|
|
881
|
+
}
|
|
882
|
+
setGallery(next)
|
|
883
|
+
setGalleryTagInput('')
|
|
884
|
+
} catch (caught) { setError(errorMessage(caught)) }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const startEditingGalleryTags = (entry: HistoryEntry): void => {
|
|
888
|
+
setEditingGalleryTagsId(entry.id)
|
|
889
|
+
setGalleryTagEditInput((entry.tags ?? []).join(', '))
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const saveGalleryTags = async (id: string): Promise<void> => {
|
|
893
|
+
const tags = galleryTagEditInput.split(',').map(tag => tag.trim()).filter(Boolean)
|
|
894
|
+
try {
|
|
895
|
+
setGallery(await api.gallerySetTags(id, tags))
|
|
896
|
+
setEditingGalleryTagsId(null)
|
|
897
|
+
setGalleryTagEditInput('')
|
|
898
|
+
} catch (caught) { setError(errorMessage(caught)) }
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const toggleGallerySelection = (id: string): void => {
|
|
902
|
+
setSelectedGalleryIds(previous => {
|
|
903
|
+
const next = new Set(previous)
|
|
904
|
+
if (next.has(id)) next.delete(id)
|
|
905
|
+
else next.add(id)
|
|
906
|
+
return next
|
|
907
|
+
})
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const clearGallerySelection = (): void => {
|
|
911
|
+
setSelectedGalleryIds(new Set())
|
|
912
|
+
setGallerySelecting(false)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const exportGalleryJson = (): void => {
|
|
916
|
+
const entries = gallery.filter(entry => selectedGalleryIds.has(entry.id))
|
|
917
|
+
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
|
918
|
+
const url = URL.createObjectURL(blob)
|
|
919
|
+
const anchor = document.createElement('a')
|
|
920
|
+
anchor.href = url
|
|
921
|
+
anchor.download = `dsh-imagegen-gallery-${new Date().toISOString().slice(0, 10)}.json`
|
|
922
|
+
anchor.click()
|
|
923
|
+
URL.revokeObjectURL(url)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const downloadGalleryImages = (): void => {
|
|
927
|
+
gallery.filter(entry => selectedGalleryIds.has(entry.id)).forEach((entry, index) => {
|
|
928
|
+
const image = entry.images[0]
|
|
929
|
+
if (image === undefined) return
|
|
930
|
+
const anchor = document.createElement('a')
|
|
931
|
+
anchor.href = image.url
|
|
932
|
+
anchor.download = `dsh-gallery-${index + 1}.${extensionOf(image.mime)}`
|
|
933
|
+
anchor.click()
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const generateDisabled = submitting || modeModels.length === 0
|
|
938
|
+
const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
|
|
939
|
+
const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
|
|
940
|
+
const previewImage = preview === null ? null : preview.images[preview.index] ?? null
|
|
941
|
+
const comparisonTasks = comparison === null ? [] : comparison.taskIds.map(id => tasks.find(task => task.id === id)).filter((task): task is GenerationTask => task !== undefined)
|
|
942
|
+
const comparisonResults = comparisonTasks.filter(task => task.status === 'completed' && task.result !== undefined)
|
|
943
|
+
const previewFrameScale = Math.max(1, previewScale)
|
|
944
|
+
const previewImageScale = previewScale / previewFrameScale
|
|
945
|
+
|
|
946
|
+
const copyPreviewPrompt = async (text: string): Promise<void> => {
|
|
947
|
+
try {
|
|
948
|
+
if (navigator.clipboard?.writeText !== undefined) {
|
|
949
|
+
await navigator.clipboard.writeText(text)
|
|
950
|
+
} else {
|
|
951
|
+
const textarea = document.createElement('textarea')
|
|
952
|
+
textarea.value = text
|
|
953
|
+
textarea.style.position = 'fixed'
|
|
954
|
+
textarea.style.opacity = '0'
|
|
955
|
+
document.body.appendChild(textarea)
|
|
956
|
+
textarea.select()
|
|
957
|
+
const copied = document.execCommand('copy')
|
|
958
|
+
textarea.remove()
|
|
959
|
+
if (!copied) throw new Error('copy failed')
|
|
960
|
+
}
|
|
961
|
+
setPromptCopied(true)
|
|
962
|
+
window.setTimeout(() => { setPromptCopied(false) }, 1800)
|
|
963
|
+
} catch {
|
|
964
|
+
setPromptCopied(false)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
774
968
|
const addPreviewToEdit = (): void => {
|
|
775
|
-
if (previewImage === null || preview === null) return
|
|
776
|
-
setTab('edit')
|
|
777
|
-
setRefImage({
|
|
778
|
-
dataUrl: srcOf(previewImage),
|
|
779
|
-
name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
|
|
780
|
-
})
|
|
781
|
-
if (prompt.trim() === '' && previewImage.revisedPrompt !== undefined) setPrompt(previewImage.revisedPrompt)
|
|
782
|
-
setError(null)
|
|
969
|
+
if (previewImage === null || preview === null) return
|
|
970
|
+
setTab('edit')
|
|
971
|
+
setRefImage({
|
|
972
|
+
dataUrl: srcOf(previewImage),
|
|
973
|
+
name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
|
|
974
|
+
})
|
|
975
|
+
if (prompt.trim() === '' && previewImage.revisedPrompt !== undefined) setPrompt(previewImage.revisedPrompt)
|
|
976
|
+
setError(null)
|
|
783
977
|
closePreview()
|
|
784
978
|
}
|
|
785
979
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
aria-label={tt('panel.githubTip')}
|
|
798
|
-
>
|
|
799
|
-
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
800
|
-
</a>
|
|
801
|
-
</span>
|
|
802
|
-
<button
|
|
803
|
-
type="button"
|
|
804
|
-
className={css.connectionStatus}
|
|
805
|
-
data-connected={connected ? 'true' : 'false'}
|
|
806
|
-
aria-label={tt(connected ? 'connection.connected' : 'connection.disconnected')}
|
|
807
|
-
>
|
|
808
|
-
<span className={css.connectionDot} aria-hidden="true" />
|
|
809
|
-
{tt(connected ? 'connection.connected' : 'connection.disconnected')}
|
|
810
|
-
</button>
|
|
980
|
+
// Render history into the shell sidebar so it remains a separate navigation
|
|
981
|
+
// surface from both the image workspace and the native conversation.
|
|
982
|
+
const historyPanel = (
|
|
983
|
+
<aside className={css.history} data-dsh-imagegen-history>
|
|
984
|
+
<header className={css.historyHeader}>
|
|
985
|
+
<span className={css.historyTitle}>{tt('history.title')}</span>
|
|
986
|
+
{history.length > 0 ? (
|
|
987
|
+
<button type="button" className={css.historyClear} onClick={() => { void clearHistory() }}>
|
|
988
|
+
{tt('history.clear')}
|
|
989
|
+
</button>
|
|
990
|
+
) : null}
|
|
811
991
|
</header>
|
|
812
992
|
|
|
813
|
-
{
|
|
814
|
-
<
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
</
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
</div>
|
|
825
|
-
) : null}
|
|
993
|
+
<div className={css.historyFilters}>
|
|
994
|
+
<input className={css.historySearch} value={historyQuery} onChange={event => { setHistoryQuery(event.target.value) }} placeholder={tt('history.search')} aria-label={tt('history.search')} />
|
|
995
|
+
<select value={historyModelFilter} onChange={event => { setHistoryModelFilter(event.target.value) }} aria-label={tt('history.model')}>
|
|
996
|
+
<option value="all">{tt('history.allModels')}</option>
|
|
997
|
+
{[...new Set(history.flatMap(entry => modelsOfHistoryEntry(entry)))].map(option => <option key={option} value={option}>{option}</option>)}
|
|
998
|
+
</select>
|
|
999
|
+
<select value={historyRatioFilter} onChange={event => { setHistoryRatioFilter(event.target.value) }} aria-label={tt('history.ratio')}>
|
|
1000
|
+
<option value="all">{tt('history.allRatios')}</option>
|
|
1001
|
+
{[...new Set(history.map(entry => normalizeSize(entry.size)))].map(option => <option key={option} value={option}>{option}</option>)}
|
|
1002
|
+
</select>
|
|
1003
|
+
</div>
|
|
826
1004
|
|
|
827
|
-
|
|
828
|
-
{
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1005
|
+
{filteredHistory.length === 0 ? (
|
|
1006
|
+
<div className={css.historyEmpty}>{tt('history.empty')}</div>
|
|
1007
|
+
) : (
|
|
1008
|
+
<div className={css.historyList}>
|
|
1009
|
+
{filteredHistory.map(group => {
|
|
1010
|
+
const entry = group.entries[0]!
|
|
1011
|
+
const isComparison = group.models.length > 1
|
|
1012
|
+
const imageCount = group.entries.reduce((total, item) => total + item.images.length, 0)
|
|
1013
|
+
return (
|
|
1014
|
+
<div
|
|
1015
|
+
key={group.key}
|
|
1016
|
+
className={css.historyItem}
|
|
1017
|
+
data-active={group.entries.some(item => item.id === viewingHistoryId) ? '' : undefined}
|
|
1018
|
+
data-comparison={isComparison ? '' : undefined}
|
|
1019
|
+
>
|
|
839
1020
|
<button
|
|
840
|
-
key={value}
|
|
841
1021
|
type="button"
|
|
842
|
-
className={css.
|
|
843
|
-
data-
|
|
844
|
-
onClick={() => {
|
|
1022
|
+
className={css.historyMain}
|
|
1023
|
+
data-dsh-imagegen-history-main=""
|
|
1024
|
+
onClick={() => { void viewHistoryGroup(group) }}
|
|
845
1025
|
>
|
|
846
|
-
|
|
847
|
-
|
|
1026
|
+
{entry.images.length > 0 ? (
|
|
1027
|
+
<img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
|
|
1028
|
+
) : (
|
|
1029
|
+
<span className={css.historyThumbPlaceholder} />
|
|
1030
|
+
)}
|
|
1031
|
+
<span className={css.historyInfo}>
|
|
1032
|
+
<span className={css.historyPrompt}>{entry.prompt}</span>
|
|
1033
|
+
<span className={css.historyMeta}>
|
|
1034
|
+
{isComparison ? tt('compare.title') : tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
|
|
1035
|
+
{' · '}{isComparison ? group.models.join(' · ') : entry.model}
|
|
1036
|
+
{' · '}{formatTime(entry.createdAt)}
|
|
1037
|
+
{' · '}{imageCount} {tt('history.images')}
|
|
1038
|
+
</span>
|
|
1039
|
+
</span>
|
|
848
1040
|
</button>
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
<div className={css.galleryFilterHeading}>{tt('gallery.ratio')}</div>
|
|
852
|
-
<div className={css.galleryRatioList}>
|
|
853
|
-
{(['all', '1:1', '3:4', '4:3', '16:9'] as const).map(ratio => (
|
|
854
|
-
<button key={ratio} type="button" className={css.galleryRatio} data-active={galleryRatio === ratio ? '' : undefined} onClick={() => { setGalleryRatio(ratio) }}>
|
|
855
|
-
{ratio === 'all' ? tt('gallery.all') : ratio}
|
|
856
|
-
</button>
|
|
857
|
-
))}
|
|
858
|
-
</div>
|
|
859
|
-
{galleryTagOptions.length > 0 ? (
|
|
860
|
-
<>
|
|
861
|
-
<div className={css.galleryFilterDivider} />
|
|
862
|
-
<div className={css.galleryFilterHeading}>{tt('gallery.tags')}</div>
|
|
863
|
-
<div className={css.galleryTagFilterList}>
|
|
864
|
-
{galleryTagOptions.map(tag => (
|
|
865
|
-
<button key={tag} type="button" className={css.galleryTagFilter} data-active={galleryTagFilter === tag ? '' : undefined} onClick={() => { setGalleryTagFilter(previous => previous === tag ? null : tag) }}>
|
|
866
|
-
<span>{tag}</span>
|
|
867
|
-
<span>{gallery.filter(entry => (entry.tags ?? []).includes(tag)).length}</span>
|
|
868
|
-
</button>
|
|
869
|
-
))}
|
|
870
|
-
</div>
|
|
871
|
-
</>
|
|
872
|
-
) : null}
|
|
873
|
-
<div className={css.galleryFilterNote}>{tt('gallery.filterHint')}</div>
|
|
874
|
-
</div>
|
|
875
|
-
) : null}
|
|
876
|
-
<div className={css.configScroll}>
|
|
877
|
-
{/* mode / gallery tabs */}
|
|
878
|
-
<section className={css.card}>
|
|
879
|
-
<div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
|
|
880
|
-
<Pill active={tab === 'text'} onClick={() => { setTab('text') }} className={css.modePill}>{tt('mode.text')}</Pill>
|
|
881
|
-
<Pill active={tab === 'edit'} onClick={() => { setTab('edit') }} className={css.modePill}>{tt('mode.edit')}</Pill>
|
|
882
|
-
<Pill active={tab === 'gallery'} onClick={() => { setTab('gallery') }} className={css.modePill}>{tt('gallery.title')}</Pill>
|
|
883
|
-
</div>
|
|
884
|
-
</section>
|
|
885
|
-
|
|
886
|
-
{/* reference image (edit mode) */}
|
|
887
|
-
{tab === 'edit' ? (
|
|
888
|
-
<section className={css.card}>
|
|
889
|
-
{refImage === null
|
|
890
|
-
? (
|
|
1041
|
+
<span className={css.historyActions}>
|
|
1042
|
+
{entry.images.length > 0 ? (
|
|
891
1043
|
<button
|
|
892
1044
|
type="button"
|
|
893
|
-
className={css.
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
event.preventDefault()
|
|
898
|
-
acceptFile(event.dataTransfer.files?.[0])
|
|
899
|
-
}}
|
|
1045
|
+
className={css.historyAction}
|
|
1046
|
+
disabled={galleryAdding}
|
|
1047
|
+
title={tt('gallery.add')}
|
|
1048
|
+
onClick={() => { void addHistoryEntryToGallery(entry) }}
|
|
900
1049
|
>
|
|
901
|
-
|
|
902
|
-
<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>
|
|
903
|
-
</span>
|
|
904
|
-
<span>{tt('edit.upload')}</span>
|
|
905
|
-
<span className={css.uploadHint}>{tt('edit.uploadHint')}</span>
|
|
1050
|
+
{tt('gallery.add')}
|
|
906
1051
|
</button>
|
|
907
|
-
)
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
<img className={css.referenceImage} src={refImage.dataUrl} alt={refImage.name} />
|
|
911
|
-
<div className={css.referenceActions}>
|
|
912
|
-
<Button variant="outline" size="sm" onClick={() => { fileInput.current?.click() }}>
|
|
913
|
-
{tt('edit.change')}
|
|
914
|
-
</Button>
|
|
915
|
-
<Button variant="outline" size="sm" onClick={() => { setRefImage(null) }}>
|
|
916
|
-
{tt('edit.remove')}
|
|
917
|
-
</Button>
|
|
918
|
-
</div>
|
|
919
|
-
</div>
|
|
920
|
-
)}
|
|
921
|
-
<input
|
|
922
|
-
ref={fileInput}
|
|
923
|
-
type="file"
|
|
924
|
-
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
925
|
-
className={css.hiddenFile}
|
|
926
|
-
onChange={(event) => {
|
|
927
|
-
acceptFile(event.target.files?.[0])
|
|
928
|
-
event.target.value = ''
|
|
929
|
-
}}
|
|
930
|
-
/>
|
|
931
|
-
</section>
|
|
932
|
-
) : null}
|
|
933
|
-
|
|
934
|
-
{/* prompt */}
|
|
935
|
-
<section className={css.card}>
|
|
936
|
-
<textarea
|
|
937
|
-
className={css.prompt}
|
|
938
|
-
value={prompt}
|
|
939
|
-
placeholder={tt('prompt.placeholder')}
|
|
940
|
-
onChange={(event) => { setPrompt(event.target.value) }}
|
|
941
|
-
/>
|
|
942
|
-
<div className={css.promptFooter}>
|
|
943
|
-
<button
|
|
944
|
-
type="button"
|
|
945
|
-
className={css.templatesButton}
|
|
946
|
-
title={tt('templates.title')}
|
|
947
|
-
onClick={() => { setLibraryOpen(true) }}
|
|
948
|
-
>
|
|
949
|
-
<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>
|
|
950
|
-
{tt('templates.open')}
|
|
951
|
-
</button>
|
|
952
|
-
<button
|
|
953
|
-
type="button"
|
|
954
|
-
className={css.enhanceButton}
|
|
955
|
-
disabled={prompt.trim() === '' || enhancing}
|
|
956
|
-
title={tt('prompt.enhanceHint')}
|
|
957
|
-
onClick={() => { void enhanceCurrentPrompt() }}
|
|
958
|
-
>
|
|
959
|
-
{enhancing ? tt('prompt.enhancing') : tt('prompt.enhance')}
|
|
960
|
-
</button>
|
|
961
|
-
<span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
|
|
962
|
-
</div>
|
|
963
|
-
</section>
|
|
964
|
-
|
|
965
|
-
{/* parameters */}
|
|
966
|
-
<section className={css.card}>
|
|
967
|
-
<div className={css.paramGroup}>
|
|
968
|
-
<span className={css.paramLabel}>{tt('params.size')}</span>
|
|
969
|
-
<div className={css.optionGrid}>
|
|
970
|
-
{SIZES.map(option => (
|
|
971
|
-
<Pill
|
|
972
|
-
key={option}
|
|
973
|
-
active={size === option}
|
|
974
|
-
onClick={() => { setSize(option) }}
|
|
975
|
-
className={css.optionPill}
|
|
976
|
-
>
|
|
977
|
-
{tt(SIZE_KEYS[option] ?? 'size.auto')}
|
|
978
|
-
</Pill>
|
|
979
|
-
))}
|
|
980
|
-
</div>
|
|
981
|
-
</div>
|
|
982
|
-
<div className={css.paramGroup}>
|
|
983
|
-
<span className={css.paramLabel}>{tt('params.quality')}</span>
|
|
984
|
-
<div className={css.optionRow}>
|
|
985
|
-
{QUALITIES.map(option => (
|
|
986
|
-
<Pill
|
|
987
|
-
key={option}
|
|
988
|
-
active={quality === option}
|
|
989
|
-
onClick={() => { setQuality(option) }}
|
|
990
|
-
className={css.optionPill}
|
|
991
|
-
>
|
|
992
|
-
{tt(`quality.${option}` as const)}
|
|
993
|
-
</Pill>
|
|
994
|
-
))}
|
|
995
|
-
</div>
|
|
996
|
-
</div>
|
|
997
|
-
<div className={css.paramGroup}>
|
|
998
|
-
<span className={css.paramLabel}>{tt('params.count')}</span>
|
|
999
|
-
<div className={css.optionRow}>
|
|
1000
|
-
{[1, 2, 3, 4].map(option => (
|
|
1001
|
-
<Pill
|
|
1002
|
-
key={option}
|
|
1003
|
-
active={count === option}
|
|
1004
|
-
onClick={() => { setCount(option) }}
|
|
1005
|
-
className={css.optionPill}
|
|
1006
|
-
>
|
|
1007
|
-
{tt(`count.${option === 1 ? 'one' : option === 2 ? 'two' : option === 3 ? 'three' : 'four'}` as const)}
|
|
1008
|
-
</Pill>
|
|
1009
|
-
))}
|
|
1010
|
-
</div>
|
|
1011
|
-
</div>
|
|
1012
|
-
<div className={css.paramGroup}>
|
|
1013
|
-
<span className={css.paramLabel}>{tt('params.detail')}</span>
|
|
1014
|
-
<div className={css.optionRow}>
|
|
1015
|
-
{DETAILS.map(option => (
|
|
1016
|
-
<Pill
|
|
1017
|
-
key={option === '' ? 'auto' : option}
|
|
1018
|
-
active={detail === option}
|
|
1019
|
-
onClick={() => { setDetail(option) }}
|
|
1020
|
-
className={css.optionPill}
|
|
1021
|
-
>
|
|
1022
|
-
{tt(option === '' ? 'detail.auto' : option === 'standard' ? 'detail.standard' : 'detail.high')}
|
|
1023
|
-
</Pill>
|
|
1024
|
-
))}
|
|
1025
|
-
</div>
|
|
1026
|
-
<span className={css.paramHint}>{tt('detail.hint')}</span>
|
|
1027
|
-
</div>
|
|
1028
|
-
</section>
|
|
1029
|
-
</div>
|
|
1030
|
-
|
|
1031
|
-
{/* footer: model + generate — a fixed sibling of the scroll area, so
|
|
1032
|
-
it never overlaps the cards scrolling above it. */}
|
|
1033
|
-
<section className={css.footer}>
|
|
1034
|
-
<label className={css.modelWrap}>
|
|
1035
|
-
<span className={css.modelLabel}>{tt('model.label')}</span>
|
|
1036
|
-
<span ref={modelMenuRef} className={css.modelMenu} data-open={modelOpen ? 'true' : 'false'}>
|
|
1037
|
-
<button
|
|
1038
|
-
type="button"
|
|
1039
|
-
className={css.modelSelect}
|
|
1040
|
-
disabled={submitting}
|
|
1041
|
-
aria-haspopup="listbox"
|
|
1042
|
-
aria-expanded={modelOpen}
|
|
1043
|
-
onClick={() => { setModelOpen(open => !open) }}
|
|
1044
|
-
>
|
|
1045
|
-
<span>{model}</span>
|
|
1046
|
-
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10.5L4 6h8z"/></svg>
|
|
1047
|
-
</button>
|
|
1048
|
-
{modelOpen ? (
|
|
1049
|
-
<div className={css.modelMenuList} role="listbox" aria-label={tt('model.label')}>
|
|
1050
|
-
{imageModels.map(option => (
|
|
1051
|
-
<button
|
|
1052
|
-
key={option}
|
|
1053
|
-
type="button"
|
|
1054
|
-
role="option"
|
|
1055
|
-
aria-selected={model === option}
|
|
1056
|
-
className={css.modelMenuItem}
|
|
1057
|
-
data-selected={model === option ? '' : undefined}
|
|
1058
|
-
onClick={() => { setModel(option); setModelOpen(false) }}
|
|
1059
|
-
>
|
|
1060
|
-
{option}
|
|
1061
|
-
</button>
|
|
1062
|
-
))}
|
|
1063
|
-
</div>
|
|
1064
|
-
) : null}
|
|
1065
|
-
</span>
|
|
1066
|
-
</label>
|
|
1067
|
-
<div className={css.compareControl}>
|
|
1068
|
-
<label className={css.compareToggle}>
|
|
1069
|
-
<input type="checkbox" checked={compareEnabled} onChange={event => { setCompareEnabled(event.target.checked) }} />
|
|
1070
|
-
<span>{tt('compare.enable')}</span>
|
|
1071
|
-
</label>
|
|
1072
|
-
{compareEnabled ? (
|
|
1073
|
-
<div className={css.compareModelChoices} role="group" aria-label={tt('compare.models')}>
|
|
1074
|
-
{imageModels.map(option => (
|
|
1075
|
-
<label key={option}>
|
|
1076
|
-
<input type="checkbox" checked={compareModels.includes(option)} onChange={() => { setCompareModels(previous => previous.includes(option) ? previous.filter(value => value !== option) : [...previous, option]) }} />
|
|
1077
|
-
<span>{option}</span>
|
|
1078
|
-
</label>
|
|
1079
|
-
))}
|
|
1080
|
-
</div>
|
|
1081
|
-
) : null}
|
|
1082
|
-
</div>
|
|
1083
|
-
<Button
|
|
1084
|
-
variant="primary"
|
|
1085
|
-
size="md"
|
|
1086
|
-
className={css.generateButton}
|
|
1087
|
-
disabled={generateDisabled}
|
|
1088
|
-
onClick={() => { void handleGenerate() }}
|
|
1089
|
-
>
|
|
1090
|
-
{generating ? (
|
|
1091
|
-
<span className={css.generateInner}>
|
|
1092
|
-
<span className={css.spinner} />
|
|
1093
|
-
{tt('generating')}
|
|
1094
|
-
</span>
|
|
1095
|
-
) : tt('generate')}
|
|
1096
|
-
</Button>
|
|
1097
|
-
</section>
|
|
1098
|
-
</aside>
|
|
1099
|
-
|
|
1100
|
-
{/* --------------------------------------------------------- canvas */}
|
|
1101
|
-
<section className={css.canvas} data-gallery={tab === 'gallery' ? 'true' : undefined}>
|
|
1102
|
-
{tab === 'gallery' ? (
|
|
1103
|
-
<div className={css.galleryWorkspace}>
|
|
1104
|
-
<header className={css.galleryToolbar}>
|
|
1105
|
-
<div>
|
|
1106
|
-
<h3 className={css.galleryHeading}>{tt('gallery.all')}</h3>
|
|
1107
|
-
<span className={css.galleryCount}>{tt('gallery.count', { count: filteredGallery.length })}</span>
|
|
1108
|
-
</div>
|
|
1109
|
-
<div className={css.galleryToolbarActions}>
|
|
1110
|
-
<input className={css.gallerySearch} value={galleryQuery} onChange={event => { setGalleryQuery(event.target.value) }} placeholder={tt('gallery.search')} aria-label={tt('gallery.search')} />
|
|
1111
|
-
<button type="button" className={css.gallerySelectMode} data-active={gallerySelecting ? '' : undefined} aria-pressed={gallerySelecting} onClick={() => { setGallerySelecting(previous => !previous) }}>
|
|
1112
|
-
{gallerySelecting ? tt('gallery.selectionDone') : tt('gallery.select')}
|
|
1052
|
+
) : null}
|
|
1053
|
+
<button type="button" className={css.historyAction} onClick={() => { void restoreHistoryGroup(group) }}>
|
|
1054
|
+
{tt('history.restore')}
|
|
1113
1055
|
</button>
|
|
1114
|
-
<
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
<button type="button" data-active={galleryView === 'grid' ? '' : undefined} onClick={() => { setGalleryView('grid') }} title={tt('gallery.grid')}>
|
|
1119
|
-
<span aria-hidden="true">▤</span> {tt('gallery.grid')}
|
|
1120
|
-
</button>
|
|
1121
|
-
</div>
|
|
1122
|
-
<select className={css.gallerySort} value={gallerySort} onChange={event => { setGallerySort(event.target.value as 'newest' | 'oldest') }} aria-label={tt('gallery.sort')}>
|
|
1123
|
-
<option value="newest">{tt('gallery.newest')}</option>
|
|
1124
|
-
<option value="oldest">{tt('gallery.oldest')}</option>
|
|
1125
|
-
</select>
|
|
1126
|
-
{gallery.length > 0 ? <button type="button" className={css.galleryClear} onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
|
|
1127
|
-
</div>
|
|
1128
|
-
</header>
|
|
1129
|
-
{selectedGalleryIds.size > 0 ? (
|
|
1130
|
-
<section className={css.gallerySelectionBar} aria-label={tt('gallery.selected', { count: selectedGalleryIds.size })}>
|
|
1131
|
-
<strong>{tt('gallery.selected', { count: selectedGalleryIds.size })}</strong>
|
|
1132
|
-
<input className={css.galleryTagInput} value={galleryTagInput} onChange={event => { setGalleryTagInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} />
|
|
1133
|
-
<button type="button" className={css.galleryBulkButton} disabled={galleryTagInput.trim() === ''} onClick={() => { void applyGalleryTags() }}>{tt('gallery.tagsApply')}</button>
|
|
1134
|
-
<button type="button" className={css.galleryBulkButton} onClick={downloadGalleryImages}>{tt('gallery.downloadSelected')}</button>
|
|
1135
|
-
<button type="button" className={css.galleryBulkButton} onClick={exportGalleryJson}>{tt('gallery.exportJson')}</button>
|
|
1136
|
-
<button type="button" className={css.gallerySelectionClear} onClick={clearGallerySelection}>{tt('gallery.selectionClear')}</button>
|
|
1137
|
-
</section>
|
|
1138
|
-
) : null}
|
|
1139
|
-
{filteredGallery.length === 0 ? (
|
|
1140
|
-
<div className={css.historyEmpty}>{tt('gallery.empty')}</div>
|
|
1141
|
-
) : (
|
|
1142
|
-
<div className={css.galleryMasonry} data-view={galleryView}>
|
|
1143
|
-
{filteredGallery.map(entry => {
|
|
1144
|
-
const image = entry.images[0]
|
|
1145
|
-
if (image === undefined) return null
|
|
1146
|
-
return (
|
|
1147
|
-
<article key={entry.id} className={css.galleryCard} data-selected={selectedGalleryIds.has(entry.id) ? '' : undefined}>
|
|
1148
|
-
<label className={css.gallerySelect} title={tt('gallery.select')}>
|
|
1149
|
-
<input type="checkbox" checked={selectedGalleryIds.has(entry.id)} onChange={() => { setGallerySelecting(true); toggleGallerySelection(entry.id) }} />
|
|
1150
|
-
</label>
|
|
1151
|
-
<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')}>
|
|
1152
|
-
<img className={css.galleryImage} src={image.url} alt={entry.prompt} />
|
|
1153
|
-
<span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
|
|
1154
|
-
</button>
|
|
1155
|
-
<div className={css.galleryCardFooter}>
|
|
1156
|
-
<span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
|
|
1157
|
-
<span className={css.galleryCardInfo}>
|
|
1158
|
-
<strong>{entry.prompt || tt('gallery.untitled')}</strong>
|
|
1159
|
-
<small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
|
|
1160
|
-
<span className={css.galleryTags}>
|
|
1161
|
-
{(entry.tags ?? []).map(tag => <button key={tag} type="button" onClick={() => { setGalleryTagFilter(tag) }}>{tag}</button>)}
|
|
1162
|
-
<button type="button" className={css.galleryTagEdit} onClick={() => { startEditingGalleryTags(entry) }} title={tt('gallery.editTags')}>{tt('gallery.tagsEditShort')}</button>
|
|
1163
|
-
</span>
|
|
1164
|
-
</span>
|
|
1165
|
-
<button type="button" className={css.galleryRemove} onClick={() => { void deleteGalleryEntry(entry.id) }} title={tt('gallery.delete')}>×</button>
|
|
1166
|
-
</div>
|
|
1167
|
-
{editingGalleryTagsId === entry.id ? (
|
|
1168
|
-
<form className={css.galleryTagEditor} onSubmit={event => { event.preventDefault(); void saveGalleryTags(entry.id) }}>
|
|
1169
|
-
<input value={galleryTagEditInput} onChange={event => { setGalleryTagEditInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} autoFocus />
|
|
1170
|
-
<button type="submit">{tt('gallery.tagsSave')}</button>
|
|
1171
|
-
<button type="button" onClick={() => { setEditingGalleryTagsId(null); setGalleryTagEditInput('') }}>{tt('gallery.tagsCancel')}</button>
|
|
1172
|
-
</form>
|
|
1173
|
-
) : null}
|
|
1174
|
-
</article>
|
|
1175
|
-
)
|
|
1176
|
-
})}
|
|
1177
|
-
</div>
|
|
1178
|
-
)}
|
|
1179
|
-
</div>
|
|
1180
|
-
) : null}
|
|
1181
|
-
{tab !== 'gallery' && tasks.length > 0 ? (
|
|
1182
|
-
<section className={css.taskTray} data-open={taskTrayOpen ? 'true' : 'false'} aria-label={tt('tasks.title')}>
|
|
1183
|
-
<header className={css.taskTrayHeader}>
|
|
1184
|
-
<button type="button" className={css.taskTrayToggle} aria-expanded={taskTrayOpen} onClick={() => { setTaskTrayOpen(open => !open) }}>
|
|
1185
|
-
<span>{tt('tasks.title')}</span>
|
|
1186
|
-
<span className={css.taskTrayCount}>{activeTasks.length}</span>
|
|
1187
|
-
<span className={css.taskTrayChevron} aria-hidden="true">{taskTrayOpen ? '⌃' : '⌄'}</span>
|
|
1188
|
-
</button>
|
|
1189
|
-
{taskTrayOpen ? <button type="button" className={css.taskTrayClose} aria-label={tt('preview.close')} onClick={() => { setTaskTrayOpen(false) }}>×</button> : null}
|
|
1190
|
-
</header>
|
|
1191
|
-
<div className={css.taskRows}>
|
|
1192
|
-
{tasks.slice(0, 5).map(task => (
|
|
1193
|
-
<div key={task.id} className={css.taskRow} data-status={task.status}>
|
|
1194
|
-
<span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
|
|
1195
|
-
<span className={css.taskPrompt}>{task.request.prompt}</span>
|
|
1196
|
-
{(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
|
|
1197
|
-
{task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
|
|
1198
|
-
</div>
|
|
1199
|
-
))}
|
|
1200
|
-
</div>
|
|
1201
|
-
</section>
|
|
1202
|
-
) : null}
|
|
1203
|
-
{tab !== 'gallery' && comparison !== null ? (
|
|
1204
|
-
<section className={css.comparisonBoard} aria-label={tt('compare.title')}>
|
|
1205
|
-
<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>
|
|
1206
|
-
<div className={css.comparisonGrid}>
|
|
1207
|
-
{comparisonTasks.map(task => (
|
|
1208
|
-
<article key={task.id}>
|
|
1209
|
-
<strong>{task.request.model}</strong>
|
|
1210
|
-
{task.result?.images[0] !== undefined ? <img src={srcOf(task.result.images[0])} alt={task.request.model} /> : <span>{tt(`tasks.${task.status}` as never)}</span>}
|
|
1211
|
-
</article>
|
|
1212
|
-
))}
|
|
1056
|
+
<button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryGroup(group) }}>
|
|
1057
|
+
{tt('history.delete')}
|
|
1058
|
+
</button>
|
|
1059
|
+
</span>
|
|
1213
1060
|
</div>
|
|
1214
|
-
|
|
1215
|
-
)
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
{submitting && activeTask === undefined
|
|
1221
|
-
? tt('canvas.submitting')
|
|
1222
|
-
: activeTask?.status === 'queued'
|
|
1223
|
-
? tt('canvas.queued')
|
|
1224
|
-
: tt('canvas.generating')}
|
|
1225
|
-
</span>
|
|
1226
|
-
<span className={css.canvasStateHint}>
|
|
1227
|
-
{activeTask?.status === 'queued'
|
|
1228
|
-
? tt('canvas.queueHint', { count: activeTasks.length })
|
|
1229
|
-
: tt('canvas.elapsed', { seconds: elapsed })}
|
|
1230
|
-
</span>
|
|
1231
|
-
</div>
|
|
1232
|
-
) : null}
|
|
1233
|
-
|
|
1234
|
-
{!generating && error !== null ? (
|
|
1235
|
-
<div className={css.canvasError} role="alert">{tt('canvas.error', { error })}</div>
|
|
1236
|
-
) : null}
|
|
1061
|
+
)
|
|
1062
|
+
})}
|
|
1063
|
+
</div>
|
|
1064
|
+
)}
|
|
1065
|
+
</aside>
|
|
1066
|
+
)
|
|
1237
1067
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1068
|
+
return (
|
|
1069
|
+
<div className={css.panel}>
|
|
1070
|
+
<header className={css.panelHeader}>
|
|
1071
|
+
<span className={css.panelHeading}>
|
|
1072
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
1073
|
+
<a
|
|
1074
|
+
className={css.githubLink}
|
|
1075
|
+
href="https://github.com/dickpy/dsh-imagegen"
|
|
1076
|
+
target="_blank"
|
|
1077
|
+
rel="noreferrer"
|
|
1078
|
+
title={tt('panel.githubTip')}
|
|
1079
|
+
aria-label={tt('panel.githubTip')}
|
|
1080
|
+
>
|
|
1081
|
+
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
1082
|
+
</a>
|
|
1083
|
+
</span>
|
|
1084
|
+
<button
|
|
1085
|
+
type="button"
|
|
1086
|
+
className={css.connectionStatus}
|
|
1087
|
+
data-connected={connected ? 'true' : 'false'}
|
|
1088
|
+
aria-label={tt(connected ? 'connection.connected' : 'connection.disconnected')}
|
|
1089
|
+
>
|
|
1090
|
+
<span className={css.connectionDot} aria-hidden="true" />
|
|
1091
|
+
{tt(connected ? 'connection.connected' : 'connection.disconnected')}
|
|
1092
|
+
</button>
|
|
1093
|
+
</header>
|
|
1094
|
+
|
|
1095
|
+
{update !== null ? (
|
|
1096
|
+
<div className={css.updateBanner} data-kind={updateResult === 'success' ? 'ok' : 'warn'}>
|
|
1097
|
+
<span className={css.updateText}>
|
|
1098
|
+
{updateMessage ?? tt('update.available', { version: update.latestVersion })}
|
|
1099
|
+
</span>
|
|
1100
|
+
<span className={css.updateActions}>
|
|
1101
|
+
<a className={css.updateRelease} href={update.releaseUrl} target="_blank" rel="noreferrer">{tt('update.release')}</a>
|
|
1102
|
+
<Button variant="primary" size="sm" disabled={updating || updateMessage !== null} onClick={() => { void applyUpdate() }}>
|
|
1103
|
+
{updating ? tt('update.installing') : tt('update.install')}
|
|
1104
|
+
</Button>
|
|
1105
|
+
</span>
|
|
1106
|
+
</div>
|
|
1107
|
+
) : null}
|
|
1108
|
+
|
|
1109
|
+
<div className={css.studio}>
|
|
1110
|
+
{/* ------------------------------- left history + generation workspace */}
|
|
1111
|
+
<div className={css.generation}>
|
|
1112
|
+
{/* ------------------------------------------------ config sidebar */}
|
|
1113
|
+
<aside className={css.config} data-gallery={tab === 'gallery' ? 'true' : undefined}>
|
|
1114
|
+
{tab === 'gallery' ? (
|
|
1115
|
+
<div className={css.galleryFilters}>
|
|
1116
|
+
<div className={css.galleryFilterHeading}>{tt('gallery.categories')}</div>
|
|
1117
|
+
{[
|
|
1118
|
+
['all', tt('gallery.all')],
|
|
1119
|
+
['text', tt('mode.text')],
|
|
1120
|
+
['edit', tt('mode.edit')],
|
|
1121
|
+
...galleryModels.map(value => [value, value]),
|
|
1122
|
+
].map(([value, label]) => (
|
|
1123
|
+
<button
|
|
1124
|
+
key={value}
|
|
1125
|
+
type="button"
|
|
1126
|
+
className={css.galleryFilter}
|
|
1127
|
+
data-active={galleryFilter === value ? '' : undefined}
|
|
1128
|
+
onClick={() => { setGalleryFilter(value) }}
|
|
1129
|
+
>
|
|
1130
|
+
<span>{label}</span>
|
|
1131
|
+
<span className={css.galleryFilterCount}>{gallery.filter(entry => value === 'all' || value === 'text' || value === 'edit' ? (value === 'all' ? true : entry.mode === value) : entry.model === value).length}</span>
|
|
1132
|
+
</button>
|
|
1133
|
+
))}
|
|
1134
|
+
<div className={css.galleryFilterDivider} />
|
|
1135
|
+
<div className={css.galleryFilterHeading}>{tt('gallery.ratio')}</div>
|
|
1136
|
+
<div className={css.galleryRatioList}>
|
|
1137
|
+
{(['all', '1:1', '3:4', '4:3', '16:9'] as const).map(ratio => (
|
|
1138
|
+
<button key={ratio} type="button" className={css.galleryRatio} data-active={galleryRatio === ratio ? '' : undefined} onClick={() => { setGalleryRatio(ratio) }}>
|
|
1139
|
+
{ratio === 'all' ? tt('gallery.all') : ratio}
|
|
1140
|
+
</button>
|
|
1141
|
+
))}
|
|
1142
|
+
</div>
|
|
1143
|
+
{galleryTagOptions.length > 0 ? (
|
|
1144
|
+
<>
|
|
1145
|
+
<div className={css.galleryFilterDivider} />
|
|
1146
|
+
<div className={css.galleryFilterHeading}>{tt('gallery.tags')}</div>
|
|
1147
|
+
<div className={css.galleryTagFilterList}>
|
|
1148
|
+
{galleryTagOptions.map(tag => (
|
|
1149
|
+
<button key={tag} type="button" className={css.galleryTagFilter} data-active={galleryTagFilter === tag ? '' : undefined} onClick={() => { setGalleryTagFilter(previous => previous === tag ? null : tag) }}>
|
|
1150
|
+
<span>{tag}</span>
|
|
1151
|
+
<span>{gallery.filter(entry => (entry.tags ?? []).includes(tag)).length}</span>
|
|
1152
|
+
</button>
|
|
1153
|
+
))}
|
|
1154
|
+
</div>
|
|
1155
|
+
</>
|
|
1156
|
+
) : null}
|
|
1157
|
+
<div className={css.galleryFilterNote}>{tt('gallery.filterHint')}</div>
|
|
1158
|
+
</div>
|
|
1159
|
+
) : null}
|
|
1160
|
+
<div className={css.configScroll}>
|
|
1161
|
+
{/* mode / gallery tabs */}
|
|
1162
|
+
<section className={css.card}>
|
|
1163
|
+
<div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
|
|
1164
|
+
<Pill active={tab === 'text'} onClick={() => { setTab('text') }} className={css.modePill}>{tt('mode.text')}</Pill>
|
|
1165
|
+
<Pill active={tab === 'edit'} onClick={() => { setTab('edit') }} className={css.modePill}>{tt('mode.edit')}</Pill>
|
|
1166
|
+
<Pill active={tab === 'gallery'} onClick={() => { setTab('gallery') }} className={css.modePill}>{tt('gallery.title')}</Pill>
|
|
1167
|
+
</div>
|
|
1168
|
+
</section>
|
|
1169
|
+
|
|
1170
|
+
{/* reference image (edit mode) */}
|
|
1171
|
+
{tab === 'edit' ? (
|
|
1172
|
+
<section className={css.card}>
|
|
1173
|
+
{refImage === null
|
|
1174
|
+
? (
|
|
1175
|
+
<button
|
|
1176
|
+
type="button"
|
|
1177
|
+
className={css.uploadBox}
|
|
1178
|
+
onClick={() => { fileInput.current?.click() }}
|
|
1179
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
1180
|
+
onDrop={(event) => {
|
|
1181
|
+
event.preventDefault()
|
|
1182
|
+
acceptFile(event.dataTransfer.files?.[0])
|
|
1183
|
+
}}
|
|
1184
|
+
>
|
|
1185
|
+
<span className={css.uploadIcon}>
|
|
1186
|
+
<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>
|
|
1187
|
+
</span>
|
|
1188
|
+
<span>{tt('edit.upload')}</span>
|
|
1189
|
+
<span className={css.uploadHint}>{tt('edit.uploadHint')}</span>
|
|
1190
|
+
</button>
|
|
1191
|
+
)
|
|
1192
|
+
: (
|
|
1193
|
+
<div className={css.reference}>
|
|
1194
|
+
<img className={css.referenceImage} src={refImage.dataUrl} alt={refImage.name} />
|
|
1195
|
+
<div className={css.referenceActions}>
|
|
1196
|
+
<Button variant="outline" size="sm" onClick={() => { fileInput.current?.click() }}>
|
|
1197
|
+
{tt('edit.change')}
|
|
1198
|
+
</Button>
|
|
1199
|
+
<Button variant="outline" size="sm" onClick={() => { setRefImage(null) }}>
|
|
1200
|
+
{tt('edit.remove')}
|
|
1201
|
+
</Button>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
)}
|
|
1205
|
+
<input
|
|
1206
|
+
ref={fileInput}
|
|
1207
|
+
type="file"
|
|
1208
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
1209
|
+
className={css.hiddenFile}
|
|
1210
|
+
onChange={(event) => {
|
|
1211
|
+
acceptFile(event.target.files?.[0])
|
|
1212
|
+
event.target.value = ''
|
|
1213
|
+
}}
|
|
1214
|
+
/>
|
|
1215
|
+
</section>
|
|
1216
|
+
) : null}
|
|
1217
|
+
|
|
1218
|
+
{/* prompt */}
|
|
1219
|
+
<section className={css.card}>
|
|
1220
|
+
<textarea
|
|
1221
|
+
className={css.prompt}
|
|
1222
|
+
value={prompt}
|
|
1223
|
+
placeholder={tt('prompt.placeholder')}
|
|
1224
|
+
onChange={(event) => { setPrompt(event.target.value) }}
|
|
1225
|
+
/>
|
|
1226
|
+
<div className={css.promptFooter}>
|
|
1227
|
+
<button
|
|
1228
|
+
type="button"
|
|
1229
|
+
className={css.templatesButton}
|
|
1230
|
+
title={tt('templates.title')}
|
|
1231
|
+
onClick={() => { setLibraryOpen(true) }}
|
|
1232
|
+
>
|
|
1233
|
+
<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>
|
|
1234
|
+
{tt('templates.open')}
|
|
1235
|
+
</button>
|
|
1236
|
+
<button
|
|
1237
|
+
type="button"
|
|
1238
|
+
className={css.enhanceButton}
|
|
1239
|
+
disabled={prompt.trim() === '' || enhancing}
|
|
1240
|
+
title={tt('prompt.enhanceHint')}
|
|
1241
|
+
onClick={() => { void enhanceCurrentPrompt() }}
|
|
1242
|
+
>
|
|
1243
|
+
{enhancing ? tt('prompt.enhancing') : tt('prompt.enhance')}
|
|
1244
|
+
</button>
|
|
1245
|
+
<span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
|
|
1246
|
+
</div>
|
|
1247
|
+
</section>
|
|
1248
|
+
|
|
1249
|
+
{/* parameters */}
|
|
1250
|
+
<section className={css.card}>
|
|
1251
|
+
<div className={css.paramGroup}>
|
|
1252
|
+
<span className={css.paramLabel}>{tt('params.size')}</span>
|
|
1253
|
+
<div className={css.optionGrid}>
|
|
1254
|
+
{SIZES.map(option => (
|
|
1255
|
+
<Pill
|
|
1256
|
+
key={option}
|
|
1257
|
+
active={size === option}
|
|
1258
|
+
onClick={() => { setSize(option) }}
|
|
1259
|
+
className={css.optionPill}
|
|
1260
|
+
>
|
|
1261
|
+
{tt(SIZE_KEYS[option] ?? 'size.auto')}
|
|
1262
|
+
</Pill>
|
|
1263
|
+
))}
|
|
1264
|
+
</div>
|
|
1265
|
+
</div>
|
|
1266
|
+
<div className={css.paramGroup}>
|
|
1267
|
+
<span className={css.paramLabel}>{tt('params.quality')}</span>
|
|
1268
|
+
<div className={css.optionRow}>
|
|
1269
|
+
{QUALITIES.map(option => (
|
|
1270
|
+
<Pill
|
|
1271
|
+
key={option}
|
|
1272
|
+
active={quality === option}
|
|
1273
|
+
onClick={() => { setQuality(option) }}
|
|
1274
|
+
className={css.optionPill}
|
|
1275
|
+
>
|
|
1276
|
+
{tt(`quality.${option}` as const)}
|
|
1277
|
+
</Pill>
|
|
1278
|
+
))}
|
|
1279
|
+
</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
<div className={css.paramGroup}>
|
|
1282
|
+
<span className={css.paramLabel}>{tt('params.count')}</span>
|
|
1283
|
+
<div className={css.optionRow}>
|
|
1284
|
+
{[1, 2, 3, 4].map(option => (
|
|
1285
|
+
<Pill
|
|
1286
|
+
key={option}
|
|
1287
|
+
active={count === option}
|
|
1288
|
+
onClick={() => { setCount(option) }}
|
|
1289
|
+
className={css.optionPill}
|
|
1290
|
+
>
|
|
1291
|
+
{tt(`count.${option === 1 ? 'one' : option === 2 ? 'two' : option === 3 ? 'three' : 'four'}` as const)}
|
|
1292
|
+
</Pill>
|
|
1293
|
+
))}
|
|
1294
|
+
</div>
|
|
1295
|
+
</div>
|
|
1296
|
+
<div className={css.paramGroup}>
|
|
1297
|
+
<span className={css.paramLabel}>{tt('params.detail')}</span>
|
|
1298
|
+
<div className={css.optionRow}>
|
|
1299
|
+
{DETAILS.map(option => (
|
|
1300
|
+
<Pill
|
|
1301
|
+
key={option === '' ? 'auto' : option}
|
|
1302
|
+
active={detail === option}
|
|
1303
|
+
onClick={() => { setDetail(option) }}
|
|
1304
|
+
className={css.optionPill}
|
|
1305
|
+
>
|
|
1306
|
+
{tt(option === '' ? 'detail.auto' : option === 'standard' ? 'detail.standard' : 'detail.high')}
|
|
1307
|
+
</Pill>
|
|
1308
|
+
))}
|
|
1309
|
+
</div>
|
|
1310
|
+
<span className={css.paramHint}>{tt('detail.hint')}</span>
|
|
1311
|
+
</div>
|
|
1312
|
+
</section>
|
|
1313
|
+
</div>
|
|
1314
|
+
|
|
1315
|
+
{/* footer: model + generate — a fixed sibling of the scroll area, so
|
|
1316
|
+
it never overlaps the cards scrolling above it. */}
|
|
1317
|
+
<section className={css.footer}>
|
|
1318
|
+
<label className={css.modelWrap}>
|
|
1319
|
+
<span className={css.modelLabel}>{tt('model.label')}</span>
|
|
1320
|
+
<span ref={modelMenuRef} className={css.modelMenu} data-open={modelOpen ? 'true' : 'false'}>
|
|
1321
|
+
<button
|
|
1322
|
+
type="button"
|
|
1323
|
+
className={css.modelSelect}
|
|
1324
|
+
disabled={submitting}
|
|
1325
|
+
aria-haspopup="listbox"
|
|
1326
|
+
aria-expanded={modelOpen}
|
|
1327
|
+
onClick={() => { setModelOpen(open => !open) }}
|
|
1328
|
+
>
|
|
1329
|
+
<span>{model || tt('model.noEditModels')}</span>
|
|
1330
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10.5L4 6h8z"/></svg>
|
|
1331
|
+
</button>
|
|
1332
|
+
{modelOpen ? (
|
|
1333
|
+
<div className={css.modelMenuList} role="listbox" aria-label={tt('model.label')}>
|
|
1334
|
+
{modeModels.map(option => (
|
|
1335
|
+
<button
|
|
1336
|
+
key={option}
|
|
1337
|
+
type="button"
|
|
1338
|
+
role="option"
|
|
1339
|
+
aria-selected={model === option}
|
|
1340
|
+
className={css.modelMenuItem}
|
|
1341
|
+
data-selected={model === option ? '' : undefined}
|
|
1342
|
+
onClick={() => { setModel(option); setModelOpen(false) }}
|
|
1343
|
+
>
|
|
1344
|
+
{option}
|
|
1345
|
+
</button>
|
|
1346
|
+
))}
|
|
1347
|
+
</div>
|
|
1348
|
+
) : null}
|
|
1349
|
+
</span>
|
|
1350
|
+
</label>
|
|
1351
|
+
<div className={css.compareControl}>
|
|
1352
|
+
<label className={css.compareToggle}>
|
|
1353
|
+
<input type="checkbox" checked={compareEnabled} onChange={event => { setCompareEnabled(event.target.checked) }} />
|
|
1354
|
+
<span>{tt('compare.enable')}</span>
|
|
1355
|
+
</label>
|
|
1356
|
+
{compareEnabled ? (
|
|
1357
|
+
<div className={css.compareModelChoices} role="group" aria-label={tt('compare.models')}>
|
|
1358
|
+
{modeModels.map(option => (
|
|
1359
|
+
<label key={option}>
|
|
1360
|
+
<input type="checkbox" checked={compareModels.includes(option)} onChange={() => { setCompareModels(previous => previous.includes(option) ? previous.filter(value => value !== option) : [...previous, option]) }} />
|
|
1361
|
+
<span>{option}</span>
|
|
1362
|
+
</label>
|
|
1363
|
+
))}
|
|
1364
|
+
</div>
|
|
1365
|
+
) : null}
|
|
1366
|
+
</div>
|
|
1367
|
+
<Button
|
|
1368
|
+
variant="primary"
|
|
1369
|
+
size="md"
|
|
1370
|
+
className={css.generateButton}
|
|
1371
|
+
disabled={generateDisabled}
|
|
1372
|
+
onClick={() => { void handleGenerate() }}
|
|
1373
|
+
>
|
|
1374
|
+
{generating ? (
|
|
1375
|
+
<span className={css.generateInner}>
|
|
1376
|
+
<span className={css.spinner} />
|
|
1377
|
+
{tt('generating')}
|
|
1378
|
+
</span>
|
|
1379
|
+
) : tt('generate')}
|
|
1380
|
+
</Button>
|
|
1381
|
+
</section>
|
|
1382
|
+
</aside>
|
|
1247
1383
|
|
|
1248
|
-
{
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
<
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1384
|
+
{/* ------------------------------------------------------- canvas */}
|
|
1385
|
+
<section className={css.canvas} data-gallery={tab === 'gallery' ? 'true' : undefined}>
|
|
1386
|
+
{tab === 'gallery' ? (
|
|
1387
|
+
<div className={css.galleryWorkspace}>
|
|
1388
|
+
<header className={css.galleryToolbar}>
|
|
1389
|
+
<div>
|
|
1390
|
+
<h3 className={css.galleryHeading}>{tt('gallery.all')}</h3>
|
|
1391
|
+
<span className={css.galleryCount}>{tt('gallery.count', { count: filteredGallery.length })}</span>
|
|
1392
|
+
</div>
|
|
1393
|
+
<div className={css.galleryToolbarActions}>
|
|
1394
|
+
<input className={css.gallerySearch} value={galleryQuery} onChange={event => { setGalleryQuery(event.target.value) }} placeholder={tt('gallery.search')} aria-label={tt('gallery.search')} />
|
|
1395
|
+
<button type="button" className={css.gallerySelectMode} data-active={gallerySelecting ? '' : undefined} aria-pressed={gallerySelecting} onClick={() => { setGallerySelecting(previous => !previous) }}>
|
|
1396
|
+
{gallerySelecting ? tt('gallery.selectionDone') : tt('gallery.select')}
|
|
1397
|
+
</button>
|
|
1398
|
+
<div className={css.galleryViewToggle} role="group" aria-label={tt('gallery.viewMode')}>
|
|
1399
|
+
<button type="button" data-active={galleryView === 'masonry' ? '' : undefined} onClick={() => { setGalleryView('masonry') }} title={tt('gallery.masonry')}>
|
|
1400
|
+
<span aria-hidden="true">▦</span> {tt('gallery.masonry')}
|
|
1401
|
+
</button>
|
|
1402
|
+
<button type="button" data-active={galleryView === 'grid' ? '' : undefined} onClick={() => { setGalleryView('grid') }} title={tt('gallery.grid')}>
|
|
1403
|
+
<span aria-hidden="true">▤</span> {tt('gallery.grid')}
|
|
1404
|
+
</button>
|
|
1405
|
+
</div>
|
|
1406
|
+
<select className={css.gallerySort} value={gallerySort} onChange={event => { setGallerySort(event.target.value as 'newest' | 'oldest') }} aria-label={tt('gallery.sort')}>
|
|
1407
|
+
<option value="newest">{tt('gallery.newest')}</option>
|
|
1408
|
+
<option value="oldest">{tt('gallery.oldest')}</option>
|
|
1409
|
+
</select>
|
|
1410
|
+
{gallery.length > 0 ? <button type="button" className={css.galleryClear} onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
|
|
1411
|
+
</div>
|
|
1412
|
+
</header>
|
|
1413
|
+
{selectedGalleryIds.size > 0 ? (
|
|
1414
|
+
<section className={css.gallerySelectionBar} aria-label={tt('gallery.selected', { count: selectedGalleryIds.size })}>
|
|
1415
|
+
<strong>{tt('gallery.selected', { count: selectedGalleryIds.size })}</strong>
|
|
1416
|
+
<input className={css.galleryTagInput} value={galleryTagInput} onChange={event => { setGalleryTagInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} />
|
|
1417
|
+
<button type="button" className={css.galleryBulkButton} disabled={galleryTagInput.trim() === ''} onClick={() => { void applyGalleryTags() }}>{tt('gallery.tagsApply')}</button>
|
|
1418
|
+
<button type="button" className={css.galleryBulkButton} onClick={downloadGalleryImages}>{tt('gallery.downloadSelected')}</button>
|
|
1419
|
+
<button type="button" className={css.galleryBulkButton} onClick={exportGalleryJson}>{tt('gallery.exportJson')}</button>
|
|
1420
|
+
<button type="button" className={css.gallerySelectionClear} onClick={clearGallerySelection}>{tt('gallery.selectionClear')}</button>
|
|
1421
|
+
</section>
|
|
1422
|
+
) : null}
|
|
1423
|
+
{filteredGallery.length === 0 ? (
|
|
1424
|
+
<div className={css.historyEmpty}>{tt('gallery.empty')}</div>
|
|
1425
|
+
) : (
|
|
1426
|
+
<div className={css.galleryMasonry} data-view={galleryView}>
|
|
1427
|
+
{filteredGallery.map(entry => {
|
|
1428
|
+
const image = entry.images[0]
|
|
1429
|
+
if (image === undefined) return null
|
|
1430
|
+
return (
|
|
1431
|
+
<article key={entry.id} className={css.galleryCard} data-selected={selectedGalleryIds.has(entry.id) ? '' : undefined}>
|
|
1432
|
+
<label className={css.gallerySelect} title={tt('gallery.select')}>
|
|
1433
|
+
<input type="checkbox" checked={selectedGalleryIds.has(entry.id)} onChange={() => { setGallerySelecting(true); toggleGallerySelection(entry.id) }} />
|
|
1434
|
+
</label>
|
|
1435
|
+
<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')}>
|
|
1436
|
+
<img className={css.galleryImage} src={image.url} alt={entry.prompt} />
|
|
1437
|
+
<span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
|
|
1438
|
+
</button>
|
|
1439
|
+
<div className={css.galleryCardFooter}>
|
|
1440
|
+
<span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
|
|
1441
|
+
<span className={css.galleryCardInfo}>
|
|
1442
|
+
<strong>{entry.prompt || tt('gallery.untitled')}</strong>
|
|
1443
|
+
<small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
|
|
1444
|
+
<span className={css.galleryTags}>
|
|
1445
|
+
{(entry.tags ?? []).map(tag => <button key={tag} type="button" onClick={() => { setGalleryTagFilter(tag) }}>{tag}</button>)}
|
|
1446
|
+
<button type="button" className={css.galleryTagEdit} onClick={() => { startEditingGalleryTags(entry) }} title={tt('gallery.editTags')}>{tt('gallery.tagsEditShort')}</button>
|
|
1447
|
+
</span>
|
|
1448
|
+
</span>
|
|
1449
|
+
<button type="button" className={css.galleryRemove} onClick={() => { void deleteGalleryEntry(entry.id) }} title={tt('gallery.delete')}>×</button>
|
|
1450
|
+
</div>
|
|
1451
|
+
{editingGalleryTagsId === entry.id ? (
|
|
1452
|
+
<form className={css.galleryTagEditor} onSubmit={event => { event.preventDefault(); void saveGalleryTags(entry.id) }}>
|
|
1453
|
+
<input value={galleryTagEditInput} onChange={event => { setGalleryTagEditInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} autoFocus />
|
|
1454
|
+
<button type="submit">{tt('gallery.tagsSave')}</button>
|
|
1455
|
+
<button type="button" onClick={() => { setEditingGalleryTagsId(null); setGalleryTagEditInput('') }}>{tt('gallery.tagsCancel')}</button>
|
|
1456
|
+
</form>
|
|
1457
|
+
) : null}
|
|
1458
|
+
</article>
|
|
1459
|
+
)
|
|
1460
|
+
})}
|
|
1461
|
+
</div>
|
|
1462
|
+
)}
|
|
1463
|
+
</div>
|
|
1464
|
+
) : null}
|
|
1465
|
+
{tab !== 'gallery' && tasks.length > 0 ? (
|
|
1466
|
+
<section className={css.taskTray} data-open={taskTrayOpen ? 'true' : 'false'} aria-label={tt('tasks.title')}>
|
|
1467
|
+
<header className={css.taskTrayHeader}>
|
|
1468
|
+
<button type="button" className={css.taskTrayToggle} aria-expanded={taskTrayOpen} onClick={() => { setTaskTrayOpen(open => !open) }}>
|
|
1469
|
+
<span>{tt('tasks.title')}</span>
|
|
1470
|
+
<span className={css.taskTrayCount}>{activeTasks.length}</span>
|
|
1471
|
+
<span className={css.taskTrayChevron} aria-hidden="true">{taskTrayOpen ? '⌃' : '⌄'}</span>
|
|
1472
|
+
</button>
|
|
1473
|
+
{taskTrayOpen ? <button type="button" className={css.taskTrayClose} aria-label={tt('preview.close')} onClick={() => { setTaskTrayOpen(false) }}>×</button> : null}
|
|
1474
|
+
</header>
|
|
1475
|
+
<div className={css.taskRows}>
|
|
1476
|
+
{tasks.slice(0, 5).map(task => (
|
|
1477
|
+
<div key={task.id} className={css.taskRow} data-status={task.status}>
|
|
1478
|
+
<span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
|
|
1479
|
+
<span className={css.taskPrompt}>{task.request.prompt}</span>
|
|
1480
|
+
{(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
|
|
1481
|
+
{task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
|
|
1482
|
+
</div>
|
|
1483
|
+
))}
|
|
1484
|
+
</div>
|
|
1485
|
+
</section>
|
|
1486
|
+
) : null}
|
|
1487
|
+
{tab !== 'gallery' && comparison !== null ? (
|
|
1488
|
+
<section className={css.comparisonBoard} aria-label={tt('compare.title')}>
|
|
1489
|
+
<header><div><strong>{tt('compare.title')}</strong><span>{comparisonResults.length} / {comparisonTasks.length}{generating ? ` · ${tt('canvas.elapsed', { seconds: elapsed })}` : ''}</span></div><button type="button" disabled={comparisonResults.length === 0} onClick={() => { setComparisonFullscreen(true) }}>{tt('compare.fullscreen')}</button></header>
|
|
1490
|
+
<div className={css.comparisonGrid}>
|
|
1491
|
+
{comparisonTasks.map(task => {
|
|
1492
|
+
const taskImages = task.result?.images ?? []
|
|
1493
|
+
const image = taskImages[0]
|
|
1494
|
+
return (
|
|
1495
|
+
<article key={task.id}>
|
|
1496
|
+
<strong>{task.request.model}</strong>
|
|
1497
|
+
{image !== undefined ? (
|
|
1498
|
+
<button
|
|
1499
|
+
type="button"
|
|
1500
|
+
className={css.comparisonImageButton}
|
|
1501
|
+
title={tt('preview.open')}
|
|
1502
|
+
onClick={() => { openPreview(taskImages, 0) }}
|
|
1503
|
+
>
|
|
1504
|
+
<img src={srcOf(image)} alt={task.request.model} />
|
|
1505
|
+
</button>
|
|
1506
|
+
) : <span>{tt(`tasks.${task.status}` as never)}</span>}
|
|
1507
|
+
</article>
|
|
1508
|
+
)
|
|
1509
|
+
})}
|
|
1510
|
+
</div>
|
|
1511
|
+
</section>
|
|
1512
|
+
) : null}
|
|
1513
|
+
{generating && comparison === null ? (
|
|
1514
|
+
<div className={css.canvasState} data-generation-state={activeTask?.status ?? 'submitting'} role="status">
|
|
1515
|
+
<span className={css.bigSpinner} />
|
|
1516
|
+
<span className={css.canvasStateTitle}>
|
|
1517
|
+
{submitting && activeTask === undefined
|
|
1518
|
+
? tt('canvas.submitting')
|
|
1519
|
+
: activeTask?.status === 'queued'
|
|
1520
|
+
? tt('canvas.queued')
|
|
1521
|
+
: tt('canvas.generating')}
|
|
1522
|
+
</span>
|
|
1523
|
+
<span className={css.canvasStateHint}>
|
|
1524
|
+
{activeTask?.status === 'queued'
|
|
1525
|
+
? tt('canvas.queueHint', { count: activeTasks.length })
|
|
1526
|
+
: tt('canvas.elapsed', { seconds: elapsed })}
|
|
1527
|
+
</span>
|
|
1528
|
+
</div>
|
|
1529
|
+
) : null}
|
|
1530
|
+
|
|
1531
|
+
{!generating && error !== null ? (
|
|
1532
|
+
<div className={css.canvasError} role="alert">{tt('canvas.error', { error })}</div>
|
|
1533
|
+
) : null}
|
|
1534
|
+
|
|
1535
|
+
{!generating && !error && images.length === 0 ? (
|
|
1536
|
+
<div className={css.canvasState}>
|
|
1537
|
+
<span className={css.canvasEmptyIcon}>
|
|
1538
|
+
<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>
|
|
1539
|
+
</span>
|
|
1540
|
+
<span className={css.canvasStateTitle}>{tt('canvas.emptyTitle')}</span>
|
|
1541
|
+
<span className={css.canvasStateHint}>{tt('canvas.emptyHint')}</span>
|
|
1542
|
+
</div>
|
|
1543
|
+
) : null}
|
|
1544
|
+
|
|
1545
|
+
{!generating && images.length > 0 ? (
|
|
1546
|
+
<div className={css.canvasBody}>
|
|
1547
|
+
<div className={css.canvasMeta}>
|
|
1548
|
+
<span>{tt('canvas.images', { count: images.length })}</span>
|
|
1549
|
+
{viewingEntry !== null || viewingGalleryEntry !== null ? (
|
|
1550
|
+
<span className={css.canvasHistoryTag}>
|
|
1551
|
+
{viewingEntry !== null
|
|
1552
|
+
? tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })
|
|
1553
|
+
: tt('gallery.viewing', { time: formatTime(viewingGalleryEntry!.createdAt) })}
|
|
1554
|
+
</span>
|
|
1555
|
+
) : null}
|
|
1556
|
+
</div>
|
|
1557
|
+
<div className={css.grid} data-count={images.length}>
|
|
1558
|
+
{images.map((image, index) => (
|
|
1559
|
+
<figure
|
|
1560
|
+
key={index}
|
|
1561
|
+
className={css.imageCard}
|
|
1562
|
+
role="button"
|
|
1563
|
+
tabIndex={0}
|
|
1564
|
+
title={tt('preview.open')}
|
|
1565
|
+
onClick={() => { openPreview(images, index) }}
|
|
1566
|
+
onKeyDown={(event) => {
|
|
1567
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1568
|
+
event.preventDefault()
|
|
1569
|
+
openPreview(images, index)
|
|
1570
|
+
}
|
|
1571
|
+
}}
|
|
1572
|
+
>
|
|
1573
|
+
<img
|
|
1574
|
+
className={css.image}
|
|
1575
|
+
src={srcOf(image)}
|
|
1576
|
+
alt={image.revisedPrompt ?? `${tt('panel.title')} ${index + 1}`}
|
|
1577
|
+
/>
|
|
1578
|
+
{image.revisedPrompt !== undefined ? (
|
|
1579
|
+
<figcaption className={css.imageCaption} title={image.revisedPrompt}>
|
|
1580
|
+
{tt('revisedPrompt', { prompt: image.revisedPrompt })}
|
|
1581
|
+
</figcaption>
|
|
1582
|
+
) : null}
|
|
1583
|
+
<span className={css.zoomHint}>
|
|
1584
|
+
<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>
|
|
1585
|
+
{tt('preview.open')}
|
|
1586
|
+
</span>
|
|
1290
1587
|
<button
|
|
1291
1588
|
type="button"
|
|
1292
1589
|
className={css.galleryAdd}
|
|
1293
|
-
title={tt('gallery.add')}
|
|
1294
|
-
disabled={galleryAdding}
|
|
1295
|
-
onClick={(event) => { event.stopPropagation(); void addToGallery(image) }}
|
|
1296
|
-
>
|
|
1297
|
-
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
|
|
1590
|
+
title={tt('gallery.add')}
|
|
1591
|
+
disabled={galleryAdding}
|
|
1592
|
+
onClick={(event) => { event.stopPropagation(); void addToGallery(image) }}
|
|
1593
|
+
>
|
|
1594
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
|
|
1298
1595
|
{tt('gallery.add')}
|
|
1299
1596
|
</button>
|
|
1300
|
-
<a
|
|
1301
|
-
className={css.download}
|
|
1302
|
-
href={srcOf(image)}
|
|
1303
|
-
download={`dsh-image-${index + 1}.${extensionOf(image.mime)}`}
|
|
1304
|
-
onClick={(event) => { event.stopPropagation() }}
|
|
1305
|
-
>
|
|
1306
|
-
{tt('download')}
|
|
1307
|
-
</a>
|
|
1308
|
-
</figure>
|
|
1309
|
-
))}
|
|
1310
|
-
</div>
|
|
1311
|
-
</div>
|
|
1312
|
-
) : null}
|
|
1313
|
-
</section>
|
|
1314
|
-
|
|
1315
|
-
{/* ------------------------ right column: gallery (tab) or history */}
|
|
1316
|
-
{tab === 'gallery' ? (
|
|
1317
|
-
<aside className={css.history}>
|
|
1318
|
-
<header className={css.historyHeader}>
|
|
1319
|
-
<span className={css.historyTitle}>{tt('gallery.title')}</span>
|
|
1320
|
-
{gallery.length > 0 ? (
|
|
1321
|
-
<button type="button" className={css.historyClear} onClick={() => { void clearGalleryAll() }}>
|
|
1322
|
-
{tt('gallery.clear')}
|
|
1323
|
-
</button>
|
|
1324
|
-
) : null}
|
|
1325
|
-
</header>
|
|
1326
|
-
|
|
1327
|
-
{gallery.length === 0 ? (
|
|
1328
|
-
<div className={css.historyEmpty}>{tt('gallery.empty')}</div>
|
|
1329
|
-
) : (
|
|
1330
|
-
<div className={css.historyList}>
|
|
1331
|
-
{gallery.map(entry => (
|
|
1332
|
-
<div
|
|
1333
|
-
key={entry.id}
|
|
1334
|
-
className={css.historyItem}
|
|
1335
|
-
data-active={entry.id === galleryViewingId ? '' : undefined}
|
|
1336
|
-
>
|
|
1337
|
-
<button
|
|
1338
|
-
type="button"
|
|
1339
|
-
className={css.historyMain}
|
|
1340
|
-
onClick={() => { void viewGalleryEntry(entry) }}
|
|
1341
|
-
>
|
|
1342
|
-
{entry.images.length > 0 ? (
|
|
1343
|
-
<img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
|
|
1344
|
-
) : (
|
|
1345
|
-
<span className={css.historyThumbPlaceholder} />
|
|
1346
|
-
)}
|
|
1347
|
-
<span className={css.historyInfo}>
|
|
1348
|
-
<span className={css.historyPrompt}>{entry.prompt}</span>
|
|
1349
|
-
<span className={css.historyMeta}>
|
|
1350
|
-
{tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
|
|
1351
|
-
{' · '}{formatTime(entry.createdAt)}
|
|
1352
|
-
</span>
|
|
1353
|
-
</span>
|
|
1354
|
-
</button>
|
|
1355
|
-
<span className={css.historyActions}>
|
|
1356
|
-
<button type="button" className={css.historyAction} onClick={() => { void restoreGalleryEntry(entry) }}>
|
|
1357
|
-
{tt('history.restore')}
|
|
1358
|
-
</button>
|
|
1359
|
-
<button type="button" className={css.historyAction} data-danger onClick={() => { void deleteGalleryEntry(entry.id) }}>
|
|
1360
|
-
{tt('gallery.delete')}
|
|
1361
|
-
</button>
|
|
1362
|
-
</span>
|
|
1363
|
-
</div>
|
|
1364
|
-
))}
|
|
1365
|
-
</div>
|
|
1366
|
-
)}
|
|
1367
|
-
</aside>
|
|
1368
|
-
) : (
|
|
1369
|
-
<aside className={css.history}>
|
|
1370
|
-
<header className={css.historyHeader}>
|
|
1371
|
-
<span className={css.historyTitle}>{tt('history.title')}</span>
|
|
1372
|
-
{history.length > 0 ? (
|
|
1373
|
-
<button type="button" className={css.historyClear} onClick={() => { void clearHistory() }}>
|
|
1374
|
-
{tt('history.clear')}
|
|
1375
|
-
</button>
|
|
1376
|
-
) : null}
|
|
1377
|
-
</header>
|
|
1378
|
-
|
|
1379
|
-
<div className={css.historyFilters}>
|
|
1380
|
-
<input className={css.historySearch} value={historyQuery} onChange={event => { setHistoryQuery(event.target.value) }} placeholder={tt('history.search')} aria-label={tt('history.search')} />
|
|
1381
|
-
<select value={historyModelFilter} onChange={event => { setHistoryModelFilter(event.target.value) }} aria-label={tt('history.model')}>
|
|
1382
|
-
<option value="all">{tt('history.allModels')}</option>
|
|
1383
|
-
{[...new Set(history.map(entry => entry.model))].map(option => <option key={option} value={option}>{option}</option>)}
|
|
1384
|
-
</select>
|
|
1385
|
-
<select value={historyRatioFilter} onChange={event => { setHistoryRatioFilter(event.target.value) }} aria-label={tt('history.ratio')}>
|
|
1386
|
-
<option value="all">{tt('history.allRatios')}</option>
|
|
1387
|
-
{[...new Set(history.map(entry => normalizeSize(entry.size)))].map(option => <option key={option} value={option}>{option}</option>)}
|
|
1388
|
-
</select>
|
|
1389
|
-
</div>
|
|
1390
|
-
|
|
1391
|
-
{filteredHistory.length === 0 ? (
|
|
1392
|
-
<div className={css.historyEmpty}>{tt('history.empty')}</div>
|
|
1393
|
-
) : (
|
|
1394
|
-
<div className={css.historyList}>
|
|
1395
|
-
{filteredHistory.map(entry => (
|
|
1396
|
-
<div
|
|
1397
|
-
key={entry.id}
|
|
1398
|
-
className={css.historyItem}
|
|
1399
|
-
data-active={entry.id === viewingHistoryId ? '' : undefined}
|
|
1400
|
-
>
|
|
1401
|
-
<button
|
|
1402
|
-
type="button"
|
|
1403
|
-
className={css.historyMain}
|
|
1404
|
-
onClick={() => { void viewHistoryEntry(entry) }}
|
|
1405
|
-
>
|
|
1406
|
-
{entry.images.length > 0 ? (
|
|
1407
|
-
<img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
|
|
1408
|
-
) : (
|
|
1409
|
-
<span className={css.historyThumbPlaceholder} />
|
|
1410
|
-
)}
|
|
1411
|
-
<span className={css.historyInfo}>
|
|
1412
|
-
<span className={css.historyPrompt}>{entry.prompt}</span>
|
|
1413
|
-
<span className={css.historyMeta}>
|
|
1414
|
-
{tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
|
|
1415
|
-
{' · '}{formatTime(entry.createdAt)}
|
|
1416
|
-
{' · '}{entry.images.length} {tt('history.images')}
|
|
1417
|
-
</span>
|
|
1418
|
-
</span>
|
|
1419
|
-
</button>
|
|
1420
|
-
<span className={css.historyActions}>
|
|
1421
|
-
{entry.images.length > 0 ? (
|
|
1422
1597
|
<button
|
|
1423
1598
|
type="button"
|
|
1424
|
-
className={css.
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
onClick={() => { void
|
|
1599
|
+
className={css.conversationAdd}
|
|
1600
|
+
title={tt('conversation.add')}
|
|
1601
|
+
disabled={addingToConversation !== null}
|
|
1602
|
+
onClick={(event) => { event.stopPropagation(); void addImageToConversation(image, index) }}
|
|
1428
1603
|
>
|
|
1429
|
-
|
|
1604
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 4.5h10v7H3z"/><path d="M5.5 2.5h5M8 6v4M6 8h4"/></svg>
|
|
1605
|
+
{addingToConversation === index ? tt('conversation.adding') : tt('conversation.add')}
|
|
1430
1606
|
</button>
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
{
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
</div>
|
|
1446
|
-
|
|
1447
|
-
{/* ------------------------------------------------ template library */}
|
|
1448
|
-
{libraryOpen ? (
|
|
1449
|
-
<TemplateLibrary
|
|
1450
|
-
api={api}
|
|
1451
|
-
onClose={() => { setLibraryOpen(false) }}
|
|
1452
|
-
onUse={(text) => {
|
|
1453
|
-
setTab('text')
|
|
1454
|
-
setPrompt(text)
|
|
1455
|
-
setError(null)
|
|
1456
|
-
setLibraryOpen(false)
|
|
1457
|
-
}}
|
|
1458
|
-
/>
|
|
1459
|
-
) : null}
|
|
1460
|
-
|
|
1461
|
-
{configGuide !== null ? (
|
|
1462
|
-
<div className={css.configGuide} role="dialog" aria-modal="true" aria-label={tt(`config.${configGuide}Title` as never)}>
|
|
1463
|
-
<div className={css.configGuideBody}>
|
|
1464
|
-
<strong>{tt(`config.${configGuide}Title` as never)}</strong>
|
|
1465
|
-
<span>{tt(`config.${configGuide}Hint` as never)}</span>
|
|
1466
|
-
<button type="button" onClick={() => { setConfigGuide(null) }}>{tt('preview.close')}</button>
|
|
1467
|
-
</div>
|
|
1607
|
+
<a
|
|
1608
|
+
className={css.download}
|
|
1609
|
+
href={srcOf(image)}
|
|
1610
|
+
download={`dsh-image-${index + 1}.${extensionOf(image.mime)}`}
|
|
1611
|
+
onClick={(event) => { event.stopPropagation() }}
|
|
1612
|
+
>
|
|
1613
|
+
{tt('download')}
|
|
1614
|
+
</a>
|
|
1615
|
+
</figure>
|
|
1616
|
+
))}
|
|
1617
|
+
</div>
|
|
1618
|
+
</div>
|
|
1619
|
+
) : null}
|
|
1620
|
+
</section>
|
|
1468
1621
|
</div>
|
|
1469
|
-
) : null}
|
|
1470
1622
|
|
|
1471
|
-
|
|
1472
|
-
<div className={css.comparisonFullscreen} role="dialog" aria-modal="true" aria-label={tt('compare.title')} onClick={() => { setComparisonFullscreen(false) }}>
|
|
1473
|
-
<button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setComparisonFullscreen(false) }}>×</button>
|
|
1474
|
-
<div className={css.comparisonFullscreenGrid} onClick={event => { event.stopPropagation() }}>
|
|
1475
|
-
{comparisonResults.map(task => (
|
|
1476
|
-
<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>
|
|
1477
|
-
))}
|
|
1478
|
-
</div>
|
|
1479
|
-
</div>, document.body) : null}
|
|
1623
|
+
</div>
|
|
1480
1624
|
|
|
1481
|
-
{
|
|
1482
|
-
|
|
1483
|
-
? createPortal(
|
|
1484
|
-
<div
|
|
1485
|
-
className={css.lightbox}
|
|
1486
|
-
role="dialog"
|
|
1487
|
-
aria-modal="true"
|
|
1488
|
-
aria-label={tt('preview.title')}
|
|
1489
|
-
onClick={closePreview}
|
|
1490
|
-
>
|
|
1491
|
-
<button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} title={tt('preview.close')} onClick={closePreview}>
|
|
1492
|
-
<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>
|
|
1493
|
-
</button>
|
|
1494
|
-
{preview.images.length > 1 ? (
|
|
1495
|
-
<>
|
|
1496
|
-
<button type="button" className={css.lightboxNav} data-dir="prev" aria-label={tt('preview.prev')} onClick={(event) => { event.stopPropagation(); stepPreview(-1) }}>
|
|
1497
|
-
<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>
|
|
1498
|
-
</button>
|
|
1499
|
-
<button type="button" className={css.lightboxNav} data-dir="next" aria-label={tt('preview.next')} onClick={(event) => { event.stopPropagation(); stepPreview(1) }}>
|
|
1500
|
-
<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>
|
|
1501
|
-
</button>
|
|
1502
|
-
</>
|
|
1503
|
-
) : null}
|
|
1504
|
-
<figure className={css.lightboxFigure} onClick={(event) => { event.stopPropagation() }}>
|
|
1505
|
-
<div
|
|
1506
|
-
ref={previewStage}
|
|
1507
|
-
className={css.lightboxStage}
|
|
1508
|
-
onWheel={(event) => {
|
|
1509
|
-
event.preventDefault()
|
|
1510
|
-
setPreviewScale(current => clampPreviewScale(current + (event.deltaY < 0 ? PREVIEW_SCALE_STEP : -PREVIEW_SCALE_STEP)))
|
|
1511
|
-
}}
|
|
1512
|
-
>
|
|
1513
|
-
<div
|
|
1514
|
-
className={css.lightboxScaleFrame}
|
|
1515
|
-
style={{ width: `${previewFrameScale * 100}%`, height: `${previewFrameScale * 100}%` }}
|
|
1516
|
-
>
|
|
1517
|
-
<img
|
|
1518
|
-
className={css.lightboxImage}
|
|
1519
|
-
style={{ width: `${previewImageScale * 100}%`, height: `${previewImageScale * 100}%` }}
|
|
1520
|
-
src={srcOf(previewImage)}
|
|
1521
|
-
alt={previewImage.revisedPrompt ?? tt('preview.title')}
|
|
1522
|
-
/>
|
|
1523
|
-
</div>
|
|
1524
|
-
</div>
|
|
1525
|
-
<div className={css.lightboxTools} role="group" aria-label={tt('preview.zoomControls')}>
|
|
1526
|
-
<button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomOut')} title={tt('preview.zoomOut')} onClick={() => { setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP)) }}>
|
|
1527
|
-
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
|
|
1528
|
-
</button>
|
|
1529
|
-
<button type="button" className={css.lightboxZoomLevel} aria-label={tt('preview.zoomReset')} title={tt('preview.zoomReset')} onClick={() => { setPreviewScale(1) }}>
|
|
1530
|
-
{tt('preview.zoomLevel', { percent: Math.round(previewScale * 100) })}
|
|
1531
|
-
</button>
|
|
1532
|
-
<button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomIn')} title={tt('preview.zoomIn')} onClick={() => { setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP)) }}>
|
|
1533
|
-
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M7 4.8v4.4M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
|
|
1534
|
-
</button>
|
|
1535
|
-
</div>
|
|
1536
|
-
{previewImage.revisedPrompt !== undefined ? (
|
|
1537
|
-
<div className={css.lightboxCaptionRow}>
|
|
1538
|
-
<figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
|
|
1539
|
-
{tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
|
|
1540
|
-
</figcaption>
|
|
1541
|
-
<button type="button" className={css.lightboxCopy} aria-label={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} title={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} onClick={() => { void copyPreviewPrompt(previewImage.revisedPrompt!) }}>
|
|
1542
|
-
{promptCopied ? (
|
|
1543
|
-
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 8l3 3 7-7"/></svg>
|
|
1544
|
-
) : (
|
|
1545
|
-
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="5" y="5" width="7" height="8" rx="1"/><path d="M3 10V3.8c0-.44.36-.8.8-.8H9"/></svg>
|
|
1546
|
-
)}
|
|
1547
|
-
<span>{tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')}</span>
|
|
1548
|
-
</button>
|
|
1549
|
-
</div>
|
|
1550
|
-
) : null}
|
|
1551
|
-
<div className={css.lightboxMeta}>
|
|
1552
|
-
<span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
|
|
1553
|
-
<span className={css.lightboxActions}>
|
|
1554
|
-
<button type="button" className={css.lightboxEdit} disabled={galleryAdding} onClick={() => { void addToGallery(previewImage) }}>
|
|
1555
|
-
{tt('gallery.add')}
|
|
1556
|
-
</button>
|
|
1557
|
-
<button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
|
|
1558
|
-
{tt('preview.addToEdit')}
|
|
1559
|
-
</button>
|
|
1560
|
-
<a
|
|
1561
|
-
className={css.lightboxDownload}
|
|
1562
|
-
href={srcOf(previewImage)}
|
|
1563
|
-
download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
|
|
1564
|
-
>
|
|
1565
|
-
{tt('download')}
|
|
1566
|
-
</a>
|
|
1567
|
-
</span>
|
|
1568
|
-
</div>
|
|
1569
|
-
</figure>
|
|
1570
|
-
</div>,
|
|
1571
|
-
document.body,
|
|
1572
|
-
)
|
|
1625
|
+
{sidebarHistoryHost !== null && historyPanel !== null
|
|
1626
|
+
? createPortal(historyPanel, sidebarHistoryHost)
|
|
1573
1627
|
: null}
|
|
1574
1628
|
|
|
1575
|
-
{/*
|
|
1629
|
+
{/* ------------------------------------------------ template library */}
|
|
1630
|
+
{libraryOpen ? (
|
|
1631
|
+
<TemplateLibrary
|
|
1632
|
+
api={api}
|
|
1633
|
+
onClose={() => { setLibraryOpen(false) }}
|
|
1634
|
+
onUse={(text) => {
|
|
1635
|
+
setTab('text')
|
|
1636
|
+
setPrompt(text)
|
|
1637
|
+
setError(null)
|
|
1638
|
+
setLibraryOpen(false)
|
|
1639
|
+
}}
|
|
1640
|
+
/>
|
|
1641
|
+
) : null}
|
|
1642
|
+
|
|
1643
|
+
{configGuide !== null ? (
|
|
1644
|
+
<div className={css.configGuide} role="dialog" aria-modal="true" aria-label={tt(`config.${configGuide}Title` as never)}>
|
|
1645
|
+
<div className={css.configGuideBody}>
|
|
1646
|
+
<strong>{tt(`config.${configGuide}Title` as never)}</strong>
|
|
1647
|
+
<span>{tt(`config.${configGuide}Hint` as never)}</span>
|
|
1648
|
+
<button type="button" onClick={() => { setConfigGuide(null) }}>{tt('preview.close')}</button>
|
|
1649
|
+
</div>
|
|
1650
|
+
</div>
|
|
1651
|
+
) : null}
|
|
1652
|
+
|
|
1653
|
+
{comparisonFullscreen && comparison !== null ? createPortal(
|
|
1654
|
+
<div className={css.comparisonFullscreen} role="dialog" aria-modal="true" aria-label={tt('compare.title')} onClick={() => { setComparisonFullscreen(false) }}>
|
|
1655
|
+
<button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setComparisonFullscreen(false) }}>×</button>
|
|
1656
|
+
<div className={css.comparisonFullscreenGrid} onClick={event => { event.stopPropagation() }}>
|
|
1657
|
+
{comparisonResults.map(task => (
|
|
1658
|
+
<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>
|
|
1659
|
+
))}
|
|
1660
|
+
</div>
|
|
1661
|
+
</div>, document.body) : null}
|
|
1662
|
+
|
|
1663
|
+
{/* -------------------------------------------------- preview overlay */}
|
|
1664
|
+
{preview !== null && previewImage !== null
|
|
1665
|
+
? createPortal(
|
|
1666
|
+
<div
|
|
1667
|
+
className={css.lightbox}
|
|
1668
|
+
role="dialog"
|
|
1669
|
+
aria-modal="true"
|
|
1670
|
+
aria-label={tt('preview.title')}
|
|
1671
|
+
onClick={closePreview}
|
|
1672
|
+
>
|
|
1673
|
+
<button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} title={tt('preview.close')} onClick={closePreview}>
|
|
1674
|
+
<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>
|
|
1675
|
+
</button>
|
|
1676
|
+
{preview.images.length > 1 ? (
|
|
1677
|
+
<>
|
|
1678
|
+
<button type="button" className={css.lightboxNav} data-dir="prev" aria-label={tt('preview.prev')} onClick={(event) => { event.stopPropagation(); stepPreview(-1) }}>
|
|
1679
|
+
<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>
|
|
1680
|
+
</button>
|
|
1681
|
+
<button type="button" className={css.lightboxNav} data-dir="next" aria-label={tt('preview.next')} onClick={(event) => { event.stopPropagation(); stepPreview(1) }}>
|
|
1682
|
+
<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>
|
|
1683
|
+
</button>
|
|
1684
|
+
</>
|
|
1685
|
+
) : null}
|
|
1686
|
+
<figure className={css.lightboxFigure} onClick={(event) => { event.stopPropagation() }}>
|
|
1687
|
+
<div
|
|
1688
|
+
ref={previewStage}
|
|
1689
|
+
className={css.lightboxStage}
|
|
1690
|
+
onWheel={(event) => {
|
|
1691
|
+
event.preventDefault()
|
|
1692
|
+
setPreviewScale(current => clampPreviewScale(current + (event.deltaY < 0 ? PREVIEW_SCALE_STEP : -PREVIEW_SCALE_STEP)))
|
|
1693
|
+
}}
|
|
1694
|
+
>
|
|
1695
|
+
<div
|
|
1696
|
+
className={css.lightboxScaleFrame}
|
|
1697
|
+
style={{ width: `${previewFrameScale * 100}%`, height: `${previewFrameScale * 100}%` }}
|
|
1698
|
+
>
|
|
1699
|
+
<img
|
|
1700
|
+
className={css.lightboxImage}
|
|
1701
|
+
style={{ width: `${previewImageScale * 100}%`, height: `${previewImageScale * 100}%` }}
|
|
1702
|
+
src={srcOf(previewImage)}
|
|
1703
|
+
alt={previewImage.revisedPrompt ?? tt('preview.title')}
|
|
1704
|
+
/>
|
|
1705
|
+
</div>
|
|
1706
|
+
</div>
|
|
1707
|
+
<div className={css.lightboxTools} role="group" aria-label={tt('preview.zoomControls')}>
|
|
1708
|
+
<button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomOut')} title={tt('preview.zoomOut')} onClick={() => { setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP)) }}>
|
|
1709
|
+
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
|
|
1710
|
+
</button>
|
|
1711
|
+
<button type="button" className={css.lightboxZoomLevel} aria-label={tt('preview.zoomReset')} title={tt('preview.zoomReset')} onClick={() => { setPreviewScale(1) }}>
|
|
1712
|
+
{tt('preview.zoomLevel', { percent: Math.round(previewScale * 100) })}
|
|
1713
|
+
</button>
|
|
1714
|
+
<button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomIn')} title={tt('preview.zoomIn')} onClick={() => { setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP)) }}>
|
|
1715
|
+
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M7 4.8v4.4M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
|
|
1716
|
+
</button>
|
|
1717
|
+
</div>
|
|
1718
|
+
{previewImage.revisedPrompt !== undefined ? (
|
|
1719
|
+
<div className={css.lightboxCaptionRow}>
|
|
1720
|
+
<figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
|
|
1721
|
+
{tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
|
|
1722
|
+
</figcaption>
|
|
1723
|
+
<button type="button" className={css.lightboxCopy} aria-label={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} title={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} onClick={() => { void copyPreviewPrompt(previewImage.revisedPrompt!) }}>
|
|
1724
|
+
{promptCopied ? (
|
|
1725
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 8l3 3 7-7"/></svg>
|
|
1726
|
+
) : (
|
|
1727
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="5" y="5" width="7" height="8" rx="1"/><path d="M3 10V3.8c0-.44.36-.8.8-.8H9"/></svg>
|
|
1728
|
+
)}
|
|
1729
|
+
<span>{tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')}</span>
|
|
1730
|
+
</button>
|
|
1731
|
+
</div>
|
|
1732
|
+
) : null}
|
|
1733
|
+
<div className={css.lightboxMeta}>
|
|
1734
|
+
<span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
|
|
1735
|
+
<span className={css.lightboxActions}>
|
|
1736
|
+
<button type="button" className={css.lightboxEdit} disabled={addingToConversation !== null} onClick={() => { void addImageToConversation(previewImage, preview.index) }}>
|
|
1737
|
+
{addingToConversation === preview.index ? tt('conversation.adding') : tt('conversation.add')}
|
|
1738
|
+
</button>
|
|
1739
|
+
<button type="button" className={css.lightboxEdit} disabled={galleryAdding} onClick={() => { void addToGallery(previewImage) }}>
|
|
1740
|
+
{tt('gallery.add')}
|
|
1741
|
+
</button>
|
|
1742
|
+
<button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
|
|
1743
|
+
{tt('preview.addToEdit')}
|
|
1744
|
+
</button>
|
|
1745
|
+
<a
|
|
1746
|
+
className={css.lightboxDownload}
|
|
1747
|
+
href={srcOf(previewImage)}
|
|
1748
|
+
download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
|
|
1749
|
+
>
|
|
1750
|
+
{tt('download')}
|
|
1751
|
+
</a>
|
|
1752
|
+
</span>
|
|
1753
|
+
</div>
|
|
1754
|
+
</figure>
|
|
1755
|
+
</div>,
|
|
1756
|
+
document.body,
|
|
1757
|
+
)
|
|
1758
|
+
: null}
|
|
1759
|
+
|
|
1760
|
+
{/* ------------------------------------------------- gallery toast */}
|
|
1576
1761
|
{galleryMessage !== null ? (
|
|
1577
1762
|
<div className={css.galleryToast} role="status">
|
|
1578
|
-
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
|
|
1763
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
|
|
1579
1764
|
{galleryMessage}
|
|
1580
1765
|
</div>
|
|
1581
1766
|
) : null}
|
|
1767
|
+
{conversationMessage !== null ? (
|
|
1768
|
+
<div className={css.conversationToast} role="status">
|
|
1769
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 4.5h10v7H3z"/><path d="M5.5 2.5h5M8 6v4M6 8h4"/></svg>
|
|
1770
|
+
{conversationMessage}
|
|
1771
|
+
</div>
|
|
1772
|
+
) : null}
|
|
1582
1773
|
</div>
|
|
1583
|
-
)
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
/** File extension for a MIME type (download filenames). */
|
|
1587
|
-
function extensionOf(mime: string): string {
|
|
1588
|
-
switch (mime.split(';')[0]!.trim()) {
|
|
1589
|
-
case 'image/jpeg': return 'jpg'
|
|
1590
|
-
case 'image/webp': return 'webp'
|
|
1591
|
-
case 'image/gif': return 'gif'
|
|
1592
|
-
default: return 'png'
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1774
|
+
)
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/** File extension for a MIME type (download filenames). */
|
|
1778
|
+
function extensionOf(mime: string): string {
|
|
1779
|
+
switch (mime.split(';')[0]!.trim()) {
|
|
1780
|
+
case 'image/jpeg': return 'jpg'
|
|
1781
|
+
case 'image/webp': return 'webp'
|
|
1782
|
+
case 'image/gif': return 'gif'
|
|
1783
|
+
default: return 'png'
|
|
1784
|
+
}
|
|
1785
|
+
}
|