@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.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -182
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +1103 -837
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +265 -135
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -418
  10. package/src/client/ImageGenPanel.tsx +1699 -1508
  11. package/src/client/SettingsCard.tsx +936 -957
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -263
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +169 -158
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -594
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -1023
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -298
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -478
  31. package/src/gallery-store.ts +286 -286
  32. package/src/generation-runtime.ts +79 -75
  33. package/src/history-store.ts +250 -244
  34. package/src/image-format.ts +11 -11
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -318
  37. package/src/model-catalog.ts +115 -98
  38. package/src/presets.ts +71 -63
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -326
  41. package/src/routes.ts +916 -906
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. 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 css from './panel.module.css'
22
-
23
- /** Size options, presented as aspect ratios (auto = let the model decide).
24
- * The host maps each ratio onto the model's own vocabulary: aspect_ratio for
25
- * Grok Imagine, the closest pixel size for OpenAI-compatible endpoints. */
26
- const SIZES = ['auto', '1:1', '3:4', '4:3', '9:16', '2:3', '3:2', '16:9', '21:9'] as const
27
-
28
- /** Size option keys in the locale dictionary. */
29
- const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.portrait34' | 'size.landscape43' | 'size.portrait916' | 'size.portrait23' | 'size.landscape32' | 'size.wide169' | 'size.ultrawide21'> = {
30
- auto: 'size.auto',
31
- '1:1': 'size.square',
32
- '3:4': 'size.portrait34',
33
- '4:3': 'size.landscape43',
34
- '9:16': 'size.portrait916',
35
- '2:3': 'size.portrait23',
36
- '3:2': 'size.landscape32',
37
- '16:9': 'size.wide169',
38
- '21:9': 'size.ultrawide21',
39
- }
40
-
41
- /** Quality options, shown as output-resolution tiers (auto = let the model
42
- * decide). The host maps them: resolution for Grok, quality level for
43
- * OpenAI-compatible endpoints (1k→low, 2k→medium, 4k→high). */
44
- const QUALITIES = ['auto', '1k', '2k', '4k'] as const
45
-
46
- /** Detail options ('' = omit the passthrough). */
47
- const DETAILS = ['', 'standard', 'high'] as const
48
-
49
- const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
50
- const PREVIEW_SCALE_MIN = 0.5
51
- const PREVIEW_SCALE_MAX = 3
52
- const PREVIEW_SCALE_STEP = 0.25
53
-
54
- /** Legacy pixel sizes saved by older versions, mapped onto the current
55
- * aspect-ratio vocabulary so restoring old history entries still works. */
56
- const LEGACY_SIZE_TO_RATIO: Record<string, string> = {
57
- '512x512': '1:1',
58
- '1024x1024': '1:1',
59
- '1536x1024': '3:2',
60
- '1024x1536': '2:3',
61
- '1792x1024': '16:9',
62
- '1024x1792': '9:16',
63
- }
64
-
65
- /** Legacy quality levels saved by older versions, mapped onto resolution. */
66
- const LEGACY_QUALITY_TO_RES: Record<string, string> = {
67
- low: '1k',
68
- medium: '2k',
69
- high: '4k',
70
- }
71
-
72
- /** Normalize a saved size value into a current dropdown option. */
73
- function normalizeSize(value: string): string {
74
- if ((SIZES as readonly string[]).includes(value)) return value
75
- return LEGACY_SIZE_TO_RATIO[value] ?? 'auto'
76
- }
77
-
78
- /** Normalize a saved quality value into a current dropdown option. */
79
- function normalizeQuality(value: string): string {
80
- if ((QUALITIES as readonly string[]).includes(value)) return value
81
- return LEGACY_QUALITY_TO_RES[value] ?? 'auto'
82
- }
83
-
84
- function clampPreviewScale(scale: number): number {
85
- return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
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
- /** Read the current config from the settings scope snapshot. */
89
- function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
90
- const [value, setValue] = useState(scope.getSnapshot().value)
91
- useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
92
- return value
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
- /** Track one redacted secret field without exposing its value to the panel. */
96
- function useSecretSet(scope: ImageGenScope, field: string): boolean {
97
- const [isSet, setIsSet] = useState(scope.getSecretSetSnapshot(field))
98
- useEffect(() => scope.subscribeSecretSets(() => { setIsSet(scope.getSecretSetSnapshot(field)) }), [field, scope])
99
- return isSet
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
- /** Tick a seconds counter while `running`. */
103
- function useElapsed(running: boolean, startedAt: number | null): number {
104
- const [elapsed, setElapsed] = useState(0)
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 (!running || startedAt === null) {
107
- setElapsed(0)
165
+ if (sessions === undefined) {
166
+ setSessionId(undefined)
108
167
  return
109
168
  }
110
- const update = (): void => {
111
- setElapsed(Math.max(1, Math.round((Date.now() - startedAt) / 1000)))
112
- }
113
- update()
114
- const timer = window.setInterval(update, 1000)
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
- /** Data URL for a generated image. */
121
- function srcOf(image: GeneratedImage): string {
122
- return `data:${image.mime};base64,${image.b64}`
123
- }
124
-
125
- /** Fetch persisted history image refs and decode them back to in-memory
126
- * GeneratedImage[] (base64), so the canvas/preview can reuse the same
127
- * rendering path as a fresh generation. */
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
- /** Compact, locale-independent timestamp for history entries. */
149
- function formatTime(timestamp: number): string {
150
- const d = new Date(timestamp)
151
- const pad = (n: number): string => String(n).padStart(2, '0')
152
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
153
- }
154
-
155
- /** Studio tabs: the two generation modes plus the gallery view. */
156
- type PanelTab = GenerateMode | 'gallery'
157
-
158
- type GalleryFilter = string
159
- type ComparisonSession = { taskIds: string[]; prompt: string }
160
-
161
- /** Render the studio. */
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 [error, setError] = useState<string | null>(null)
200
- // Submission is brief; actual generation stays visible until the host
201
- // queue reports that every queued/running task has finished.
202
- const [submitting, setSubmitting] = useState(false)
203
- const [enhancing, setEnhancing] = useState(false)
204
- const [configGuide, setConfigGuide] = useState<'generation' | 'enhancement' | 'disabled' | null>(null)
205
- const [history, setHistory] = useState<HistoryEntry[]>([])
206
- const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
207
- const [gallery, setGallery] = useState<HistoryEntry[]>([])
208
- const [galleryViewingId, setGalleryViewingId] = useState<string | null>(null)
209
- const [galleryAdding, setGalleryAdding] = useState(false)
210
- const [galleryMessage, setGalleryMessage] = useState<string | null>(null)
211
- const [galleryFilter, setGalleryFilter] = useState<GalleryFilter>('all')
212
- const [galleryRatio, setGalleryRatio] = useState('all')
213
- const [galleryTagFilter, setGalleryTagFilter] = useState<string | null>(null)
214
- const [galleryView, setGalleryView] = useState<'masonry' | 'grid'>('masonry')
215
- const [gallerySort, setGallerySort] = useState<'newest' | 'oldest'>('newest')
216
- const [galleryQuery, setGalleryQuery] = useState('')
217
- const [galleryTagInput, setGalleryTagInput] = useState('')
218
- const [editingGalleryTagsId, setEditingGalleryTagsId] = useState<string | null>(null)
219
- const [galleryTagEditInput, setGalleryTagEditInput] = useState('')
220
- const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set())
221
- const [gallerySelecting, setGallerySelecting] = useState(false)
222
- const [historyQuery, setHistoryQuery] = useState('')
223
- const [historyModelFilter, setHistoryModelFilter] = useState('all')
224
- const [historyRatioFilter, setHistoryRatioFilter] = useState('all')
225
- const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
226
- const [previewScale, setPreviewScale] = useState(1)
227
- const [promptCopied, setPromptCopied] = useState(false)
228
- const [update, setUpdate] = useState<UpdateInfo | null>(null)
229
- const [updating, setUpdating] = useState(false)
230
- const [updateMessage, setUpdateMessage] = useState<string | null>(null)
231
- const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
232
- const [libraryOpen, setLibraryOpen] = useState(false)
233
- const [tasks, setTasks] = useState<GenerationTask[]>([])
234
- const [taskTrayOpen, setTaskTrayOpen] = useState(false)
235
- const [comparison, setComparison] = useState<ComparisonSession | null>(null)
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 fileInput = useRef<HTMLInputElement>(null)
238
- const previewStage = useRef<HTMLDivElement>(null)
239
- const activeTasks = tasks.filter(task => task.status === 'queued' || task.status === 'running')
240
- const activeTask = activeTasks.find(task => task.status === 'running') ?? activeTasks[0]
241
- const generating = submitting || activeTasks.length > 0
242
- const generationStartedAt = activeTask?.startedAt ?? activeTask?.createdAt ?? null
243
- const elapsed = useElapsed(generating, generationStartedAt)
244
-
245
- // A saved settings change is authoritative. Keep the active selection and
246
- // comparison choices in that allow-list without disturbing valid choices.
247
- const imageModelKey = imageModels.join('\u0000')
248
- useEffect(() => {
249
- setModel(previous => imageModels.includes(previous) ? previous : imageModels[0])
250
- setCompareModels(previous => {
251
- const retained = previous.filter(candidate => imageModels.includes(candidate))
252
- return retained.length > 0 ? retained : [imageModels[0]]
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
- let disposed = false
282
- api.historyList()
283
- .then(entries => { if (!disposed) setHistory(entries) })
284
- .catch(() => { /* history unavailable leave the list empty */ })
285
- api.galleryList()
286
- .then(entries => { if (!disposed) setGallery(entries) })
287
- .catch(() => { /* gallery unavailable — leave the list empty */ })
288
- return () => { disposed = true }
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
- let disposed = false
293
- const refresh = (): void => {
294
- void api.taskList().then(next => {
295
- if (disposed) return
296
- setTasks(previous => {
297
- const completed = next.find(task => task.status === 'completed'
298
- && !previous.some(old => old.id === task.id && old.status === 'completed')
299
- && !comparison?.taskIds.includes(task.id))
300
- if (completed?.result !== undefined) {
301
- setImages(completed.result.images)
302
- if (completed.result.history !== undefined) setHistory(completed.result.history)
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
- }).catch(() => {})
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
- const settingsButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(设置|Settings)$/.test(button.textContent?.trim() ?? ''))
371
- if (settingsButton?.getAttribute('aria-expanded') !== 'true') settingsButton?.click()
372
- window.setTimeout(openPluginSettings, 0)
373
- }
374
-
375
- const enhanceCurrentPrompt = async (): Promise<void> => {
376
- if (prompt.trim() === '' || enhancing) return
377
- const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || configured
378
- if ((config?.promptModel ?? '').trim() === '' || !promptEndpointConfigured || (!promptKeySet && !apiKeySet)) {
379
- openSettingsGuide('enhancement')
380
- return
381
- }
382
- setEnhancing(true)
383
- setError(null)
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
- setPrompt(await api.enhancePrompt(prompt))
386
- } catch (caught) {
387
- setError(errorMessage(caught))
388
- } finally {
389
- setEnhancing(false)
390
- }
391
- }
392
-
393
- /** Read an uploaded reference image into a data URL. */
394
- const acceptFile = (file: File | undefined): void => {
395
- if (file === undefined) return
396
- if (!file.type.startsWith('image/')) {
397
- setError(tt('edit.uploadHint'))
398
- return
399
- }
400
- if (file.size > REF_IMAGE_MAX_BYTES) {
401
- setError(tt('edit.uploadHint'))
402
- return
403
- }
404
- const reader = new FileReader()
405
- reader.onload = () => {
406
- if (typeof reader.result === 'string') setRefImage({ dataUrl: reader.result, name: file.name })
407
- }
408
- reader.onerror = () => { setError(tt('edit.uploadHint')) }
409
- reader.readAsDataURL(file)
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
- /** Run one generation. */
413
- const handleGenerate = async (): Promise<void> => {
414
- if (submitting) return
415
- if (!enabled) {
416
- openSettingsGuide('disabled')
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
- if (tab === 'edit' && refImage === null) {
429
- setError(tt('edit.required'))
776
+ const sessionScope = sessions.scope(currentSessionId)
777
+ if (sessionScope === undefined) {
778
+ setError(tt('conversation.unavailable'))
430
779
  return
431
780
  }
432
- const request: GenerateRequest = {
433
- mode: tab === 'gallery' ? 'text' : tab,
434
- model: imageModels.includes(model) ? model : imageModels[0],
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 targetModels = (compareEnabled ? compareModels : [request.model]).filter(candidate => imageModels.includes(candidate))
448
- if (targetModels.length === 0) {
449
- setError(tt('compare.selectRequired'))
450
- return
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
- const submitted = await Promise.all(targetModels.map(targetModel => api.taskSubmit({ ...request, model: targetModel })))
453
- setTasks(previous => [...submitted, ...previous.filter(item => !submitted.some(task => task.id === item.id))])
454
- setComparison(targetModels.length > 1 ? { taskIds: submitted.map(task => task.id), prompt: promptText } : null)
455
- } catch (caught) {
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
- setGalleryAdding(false)
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
- /** Remove every gallery entry. */
669
- const clearGalleryAll = async (): Promise<void> => {
670
- setGallery([])
671
- setGalleryViewingId(null)
672
- try {
673
- setGallery(await api.galleryClear())
674
- } catch {
675
- // Keep the cleared local state.
676
- }
677
- }
678
-
679
- const applyGalleryTags = async (): Promise<void> => {
680
- const tags = galleryTagInput.split(',').map(tag => tag.trim()).filter(Boolean)
681
- if (tags.length === 0 || selectedGalleryIds.size === 0) return
682
- try {
683
- let next = gallery
684
- for (const id of selectedGalleryIds) {
685
- const existing = next.find(entry => entry.id === id)?.tags ?? []
686
- next = await api.gallerySetTags(id, [...existing, ...tags])
687
- }
688
- setGallery(next)
689
- setGalleryTagInput('')
690
- } catch (caught) { setError(errorMessage(caught)) }
691
- }
692
-
693
- const startEditingGalleryTags = (entry: HistoryEntry): void => {
694
- setEditingGalleryTagsId(entry.id)
695
- setGalleryTagEditInput((entry.tags ?? []).join(', '))
696
- }
697
-
698
- const saveGalleryTags = async (id: string): Promise<void> => {
699
- const tags = galleryTagEditInput.split(',').map(tag => tag.trim()).filter(Boolean)
700
- try {
701
- setGallery(await api.gallerySetTags(id, tags))
702
- setEditingGalleryTagsId(null)
703
- setGalleryTagEditInput('')
704
- } catch (caught) { setError(errorMessage(caught)) }
705
- }
706
-
707
- const toggleGallerySelection = (id: string): void => {
708
- setSelectedGalleryIds(previous => {
709
- const next = new Set(previous)
710
- if (next.has(id)) next.delete(id)
711
- else next.add(id)
712
- return next
713
- })
714
- }
715
-
716
- const clearGallerySelection = (): void => {
717
- setSelectedGalleryIds(new Set())
718
- setGallerySelecting(false)
719
- }
720
-
721
- const exportGalleryJson = (): void => {
722
- const entries = gallery.filter(entry => selectedGalleryIds.has(entry.id))
723
- const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
724
- const url = URL.createObjectURL(blob)
725
- const anchor = document.createElement('a')
726
- anchor.href = url
727
- anchor.download = `dsh-imagegen-gallery-${new Date().toISOString().slice(0, 10)}.json`
728
- anchor.click()
729
- URL.revokeObjectURL(url)
730
- }
731
-
732
- const downloadGalleryImages = (): void => {
733
- gallery.filter(entry => selectedGalleryIds.has(entry.id)).forEach((entry, index) => {
734
- const image = entry.images[0]
735
- if (image === undefined) return
736
- const anchor = document.createElement('a')
737
- anchor.href = image.url
738
- anchor.download = `dsh-gallery-${index + 1}.${extensionOf(image.mime)}`
739
- anchor.click()
740
- })
741
- }
742
-
743
- const generateDisabled = submitting
744
- const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
745
- const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
746
- const previewImage = preview === null ? null : preview.images[preview.index] ?? null
747
- const comparisonTasks = comparison === null ? [] : comparison.taskIds.map(id => tasks.find(task => task.id === id)).filter((task): task is GenerationTask => task !== undefined)
748
- const comparisonResults = comparisonTasks.filter(task => task.status === 'completed' && task.result !== undefined)
749
- const previewFrameScale = Math.max(1, previewScale)
750
- const previewImageScale = previewScale / previewFrameScale
751
-
752
- const copyPreviewPrompt = async (text: string): Promise<void> => {
753
- try {
754
- if (navigator.clipboard?.writeText !== undefined) {
755
- await navigator.clipboard.writeText(text)
756
- } else {
757
- const textarea = document.createElement('textarea')
758
- textarea.value = text
759
- textarea.style.position = 'fixed'
760
- textarea.style.opacity = '0'
761
- document.body.appendChild(textarea)
762
- textarea.select()
763
- const copied = document.execCommand('copy')
764
- textarea.remove()
765
- if (!copied) throw new Error('copy failed')
766
- }
767
- setPromptCopied(true)
768
- window.setTimeout(() => { setPromptCopied(false) }, 1800)
769
- } catch {
770
- setPromptCopied(false)
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
- return (
787
- <div className={css.panel}>
788
- <header className={css.panelHeader}>
789
- <span className={css.panelHeading}>
790
- <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
791
- <a
792
- className={css.githubLink}
793
- href="https://github.com/dickpy/dsh-imagegen"
794
- target="_blank"
795
- rel="noreferrer"
796
- title={tt('panel.githubTip')}
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
- {update !== null ? (
814
- <div className={css.updateBanner} data-kind={updateResult === 'success' ? 'ok' : 'warn'}>
815
- <span className={css.updateText}>
816
- {updateMessage ?? tt('update.available', { version: update.latestVersion })}
817
- </span>
818
- <span className={css.updateActions}>
819
- <a className={css.updateRelease} href={update.releaseUrl} target="_blank" rel="noreferrer">{tt('update.release')}</a>
820
- <Button variant="primary" size="sm" disabled={updating || updateMessage !== null} onClick={() => { void applyUpdate() }}>
821
- {updating ? tt('update.installing') : tt('update.install')}
822
- </Button>
823
- </span>
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
- <div className={css.studio}>
828
- {/* ---------------------------------------------------- config sidebar */}
829
- <aside className={css.config} data-gallery={tab === 'gallery' ? 'true' : undefined}>
830
- {tab === 'gallery' ? (
831
- <div className={css.galleryFilters}>
832
- <div className={css.galleryFilterHeading}>{tt('gallery.categories')}</div>
833
- {[
834
- ['all', tt('gallery.all')],
835
- ['text', tt('mode.text')],
836
- ['edit', tt('mode.edit')],
837
- ...galleryModels.map(value => [value, value]),
838
- ].map(([value, label]) => (
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.galleryFilter}
843
- data-active={galleryFilter === value ? '' : undefined}
844
- onClick={() => { setGalleryFilter(value) }}
1022
+ className={css.historyMain}
1023
+ data-dsh-imagegen-history-main=""
1024
+ onClick={() => { void viewHistoryGroup(group) }}
845
1025
  >
846
- <span>{label}</span>
847
- <span className={css.galleryFilterCount}>{gallery.filter(entry => value === 'all' || value === 'text' || value === 'edit' ? (value === 'all' ? true : entry.mode === value) : entry.model === value).length}</span>
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
- <div className={css.galleryFilterDivider} />
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.uploadBox}
894
- onClick={() => { fileInput.current?.click() }}
895
- onDragOver={(event) => { event.preventDefault() }}
896
- onDrop={(event) => {
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
- <span className={css.uploadIcon}>
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
- <div className={css.reference}>
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
- <div className={css.galleryViewToggle} role="group" aria-label={tt('gallery.viewMode')}>
1115
- <button type="button" data-active={galleryView === 'masonry' ? '' : undefined} onClick={() => { setGalleryView('masonry') }} title={tt('gallery.masonry')}>
1116
- <span aria-hidden="true">▦</span> {tt('gallery.masonry')}
1117
- </button>
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
- </section>
1215
- ) : null}
1216
- {generating ? (
1217
- <div className={css.canvasState} data-generation-state={activeTask?.status ?? 'submitting'} role="status">
1218
- <span className={css.bigSpinner} />
1219
- <span className={css.canvasStateTitle}>
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
- {!generating && !error && images.length === 0 ? (
1239
- <div className={css.canvasState}>
1240
- <span className={css.canvasEmptyIcon}>
1241
- <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>
1242
- </span>
1243
- <span className={css.canvasStateTitle}>{tt('canvas.emptyTitle')}</span>
1244
- <span className={css.canvasStateHint}>{tt('canvas.emptyHint')}</span>
1245
- </div>
1246
- ) : null}
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
- {!generating && images.length > 0 ? (
1249
- <div className={css.canvasBody}>
1250
- <div className={css.canvasMeta}>
1251
- <span>{tt('canvas.images', { count: images.length })}</span>
1252
- {viewingEntry !== null || viewingGalleryEntry !== null ? (
1253
- <span className={css.canvasHistoryTag}>
1254
- {viewingEntry !== null
1255
- ? tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })
1256
- : tt('gallery.viewing', { time: formatTime(viewingGalleryEntry!.createdAt) })}
1257
- </span>
1258
- ) : null}
1259
- </div>
1260
- <div className={css.grid} data-count={images.length}>
1261
- {images.map((image, index) => (
1262
- <figure
1263
- key={index}
1264
- className={css.imageCard}
1265
- role="button"
1266
- tabIndex={0}
1267
- title={tt('preview.open')}
1268
- onClick={() => { openPreview(images, index) }}
1269
- onKeyDown={(event) => {
1270
- if (event.key === 'Enter' || event.key === ' ') {
1271
- event.preventDefault()
1272
- openPreview(images, index)
1273
- }
1274
- }}
1275
- >
1276
- <img
1277
- className={css.image}
1278
- src={srcOf(image)}
1279
- alt={image.revisedPrompt ?? `${tt('panel.title')} ${index + 1}`}
1280
- />
1281
- {image.revisedPrompt !== undefined ? (
1282
- <figcaption className={css.imageCaption} title={image.revisedPrompt}>
1283
- {tt('revisedPrompt', { prompt: image.revisedPrompt })}
1284
- </figcaption>
1285
- ) : null}
1286
- <span className={css.zoomHint}>
1287
- <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>
1288
- {tt('preview.open')}
1289
- </span>
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.historyAction}
1425
- disabled={galleryAdding}
1426
- title={tt('gallery.add')}
1427
- onClick={() => { void addHistoryEntryToGallery(entry) }}
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
- {tt('gallery.add')}
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
- ) : null}
1432
- <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryEntry(entry) }}>
1433
- {tt('history.restore')}
1434
- </button>
1435
- <button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryEntry(entry.id) }}>
1436
- {tt('history.delete')}
1437
- </button>
1438
- </span>
1439
- </div>
1440
- ))}
1441
- </div>
1442
- )}
1443
- </aside>
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
- {comparisonFullscreen && comparison !== null ? createPortal(
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
- {/* -------------------------------------------------- preview overlay */}
1482
- {preview !== null && previewImage !== null
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
- {/* ------------------------------------------------- gallery toast */}
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
+ }