@dickpy/dsh-imagegen 1.4.0 → 1.5.1

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 (54) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +270 -124
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/ecommerce-mode.png +0 -0
  5. package/docs/images/image-generation-studio-three-column.png +0 -0
  6. package/docs/images/imagegen-overview.png +0 -0
  7. package/docs/videos/agent-chat-edit.gif +0 -0
  8. package/docs/videos/agent-chat-edit.mp4 +0 -0
  9. package/lib/client.js +1873 -431
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +355 -116
  12. package/package.json +77 -70
  13. package/src/agent-image-tools.ts +447 -418
  14. package/src/client/ImageGenPanel.tsx +1243 -348
  15. package/src/client/SettingsCard.tsx +936 -936
  16. package/src/client/TemplateLibrary.tsx +336 -336
  17. package/src/client/api.ts +203 -193
  18. package/src/client/channels-form.ts +263 -263
  19. package/src/client/controller.ts +46 -46
  20. package/src/client/conversation-sync.ts +14 -14
  21. package/src/client/css-modules.d.ts +5 -5
  22. package/src/client/helpers.ts +33 -33
  23. package/src/client/image-toolview.module.css +73 -73
  24. package/src/client/image-toolview.tsx +34 -28
  25. package/src/client/index.ts +25 -24
  26. package/src/client/locales.ts +156 -28
  27. package/src/client/mount.tsx +117 -117
  28. package/src/client/panel.module.css +1243 -455
  29. package/src/client/settings-card.module.css +1023 -1023
  30. package/src/client/settings-form.ts +337 -336
  31. package/src/client/settings-scope.ts +302 -298
  32. package/src/client/sidebar-entry.ts +190 -190
  33. package/src/client/templates.module.css +453 -453
  34. package/src/edit-image-command.ts +110 -0
  35. package/src/engine.ts +520 -520
  36. package/src/gallery-store.ts +306 -286
  37. package/src/generation-runtime.ts +84 -79
  38. package/src/history-store.ts +270 -250
  39. package/src/image-format.ts +11 -11
  40. package/src/image-models.ts +19 -19
  41. package/src/index.ts +337 -318
  42. package/src/model-catalog.ts +115 -115
  43. package/src/presets.ts +71 -71
  44. package/src/prompt-enhancer.ts +137 -137
  45. package/src/protocol.ts +380 -338
  46. package/src/routes.ts +966 -916
  47. package/src/settings-compat.ts +60 -0
  48. package/src/task-queue.ts +113 -113
  49. package/src/templates/cases.json +10196 -10196
  50. package/src/templates-store.ts +278 -278
  51. package/src/updater.ts +117 -117
  52. package/docs/images/agent-chat-edit.png +0 -0
  53. package/docs/images/agent-chat-generate.png +0 -0
  54. package/docs/images/agent-chat-poster-workflow.png +0 -0
@@ -8,21 +8,22 @@
8
8
  * a platform module) so the studio matches the dsh shell look by construction.
9
9
  */
10
10
 
11
- import { useEffect, useRef, useState } from 'react'
11
+ import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
12
12
  import { createPortal } from 'react-dom'
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'
16
- import type { ImageGenApi } from './api.ts'
17
- import { errorMessage, tt } from './helpers.ts'
18
- import { TemplateLibrary } from './TemplateLibrary.tsx'
19
- import type { GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, HistoryEntry, HistoryImageRef, UpdateInfo } from '../protocol.ts'
20
- import { AGENT_IMAGE_API } from '../protocol.ts'
21
- import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
22
- import { imageModelOptions } from './settings-scope.ts'
23
- import { normalizeImageModels } from '../image-models.ts'
24
- import { describeModel } from '../model-catalog.ts'
25
- import { CHAT_IMAGE_EVENT, type ChatImageEventDetail, type ConversationService } from './conversation-sync.ts'
13
+ import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
14
+ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
15
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
16
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
17
+ import type { ImageGenApi } from './api.ts'
18
+ import { errorMessage, tt } from './helpers.ts'
19
+ import { TemplateLibrary } from './TemplateLibrary.tsx'
20
+ import type { EcommerceRefRole, GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, GenerationTaskStatus, HistoryEntry, HistoryImageRef, ProductSetDraft, ProductSetSlot, UpdateInfo } from '../protocol.ts'
21
+ import { AGENT_IMAGE_API } from '../protocol.ts'
22
+ import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
23
+ import { imageModelOptions } from './settings-scope.ts'
24
+ import { normalizeImageModels } from '../image-models.ts'
25
+ import { describeModel } from '../model-catalog.ts'
26
+ import { CHAT_IMAGE_EVENT, type ChatImageEventDetail, type ConversationService } from './conversation-sync.ts'
26
27
  import css from './panel.module.css'
27
28
 
28
29
  /** Size options, presented as aspect ratios (auto = let the model decide).
@@ -52,9 +53,44 @@ const QUALITIES = ['auto', '1k', '2k', '4k'] as const
52
53
  const DETAILS = ['', 'standard', 'high'] as const
53
54
 
54
55
  const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
56
+ // The local DSH attachment backend defaults to a 2000px per-side limit. Keep
57
+ // the full-resolution result in the studio, but normalize the conversation
58
+ // copy before it enters the native composer and durable edit staging route.
59
+ const CONVERSATION_IMAGE_MAX_DIMENSION = 2000
60
+ const CONVERSATION_IMAGE_JPEG_QUALITY = 0.9
55
61
  const PREVIEW_SCALE_MIN = 0.5
56
62
  const PREVIEW_SCALE_MAX = 3
57
63
  const PREVIEW_SCALE_STEP = 0.25
64
+ const CONFIG_COLLAPSED_STORAGE_KEY = 'dsh-imagegen-config-collapsed'
65
+ const ECOMMERCE_DRAFT_STORAGE_KEY = 'dsh-imagegen-ecommerce-draft'
66
+ /** Reference roles an uploaded product asset can play (slot selections can
67
+ * also pick 'none'). */
68
+ const ECOMMERCE_ASSET_ROLES = ['product', 'packaging', 'detail', 'style'] as const
69
+ type EcommerceAssetRole = Exclude<EcommerceRefRole, 'none'>
70
+ const MAX_ECOMMERCE_ASSETS = 4
71
+ const ECOMMERCE_ROLE_PROMPT_LABELS: Record<EcommerceAssetRole, string> = {
72
+ product: '商品主体',
73
+ packaging: '包装',
74
+ detail: '细节/角度',
75
+ style: '风格参考',
76
+ }
77
+ /** One uploaded product asset. Session-only: data URLs are far too large for
78
+ * the localStorage draft, so assets never persist across reloads. */
79
+ interface ProductAsset {
80
+ id: string
81
+ dataUrl: string
82
+ name: string
83
+ role: EcommerceAssetRole
84
+ }
85
+
86
+ const PRODUCT_SET_SLOTS: ProductSetSlot[] = [
87
+ { key: 'main', label: '主图', description: '干净背景,突出商品主体', count: 1, enabled: true, refRole: 'product' },
88
+ { key: 'selling-point', label: '卖点图', description: '用画面展示商品核心卖点', count: 2, enabled: true, refRole: 'product' },
89
+ { key: 'scene', label: '场景图', description: '真实生活或使用场景', count: 2, enabled: true, refRole: 'product' },
90
+ { key: 'detail', label: '细节图', description: '材质、结构或工艺特写', count: 1, enabled: true, refRole: 'detail' },
91
+ { key: 'spec', label: '规格图', description: '尺寸、容量或参数展示', count: 1, enabled: false, refRole: 'product' },
92
+ { key: 'model', label: '使用图', description: '人物上手或穿戴效果', count: 1, enabled: false, refRole: 'product' },
93
+ ]
58
94
 
59
95
  /** Legacy pixel sizes saved by older versions, mapped onto the current
60
96
  * aspect-ratio vocabulary so restoring old history entries still works. */
@@ -90,6 +126,41 @@ function clampPreviewScale(scale: number): number {
90
126
  return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
91
127
  }
92
128
 
129
+ /** Keep the image canvas preference across panel remounts without making it
130
+ * part of the host settings document. */
131
+ function readConfigCollapsed(): boolean {
132
+ try {
133
+ return window.localStorage.getItem(CONFIG_COLLAPSED_STORAGE_KEY) === 'true'
134
+ } catch {
135
+ return false
136
+ }
137
+ }
138
+
139
+ const CHAT_COLLAPSED_STORAGE_KEY = 'dsh-imagegen:chat-collapsed'
140
+ const CONFIG_WIDTH_STORAGE_KEY = 'dsh-imagegen:config-width'
141
+ const CONFIG_WIDTH_MIN = 260
142
+ const CONFIG_WIDTH_MAX = 480
143
+ const CONFIG_WIDTH_DEFAULT = 300
144
+
145
+ /** The chat pane starts collapsed unless the user explicitly opened it. */
146
+ function readChatOpen(): boolean {
147
+ try {
148
+ return window.localStorage.getItem(CHAT_COLLAPSED_STORAGE_KEY) === 'open'
149
+ } catch {
150
+ return false
151
+ }
152
+ }
153
+
154
+ function readConfigWidth(): number {
155
+ try {
156
+ const raw = window.localStorage.getItem(CONFIG_WIDTH_STORAGE_KEY)
157
+ if (raw === null) return CONFIG_WIDTH_DEFAULT
158
+ const value = Number(raw)
159
+ if (Number.isFinite(value) && value > 0) return Math.min(CONFIG_WIDTH_MAX, Math.max(CONFIG_WIDTH_MIN, Math.round(value)))
160
+ } catch { /* storage unavailable */ }
161
+ return CONFIG_WIDTH_DEFAULT
162
+ }
163
+
93
164
  /** Read the current config from the settings scope snapshot. */
94
165
  function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
95
166
  const [value, setValue] = useState(scope.getSnapshot().value)
@@ -123,72 +194,128 @@ function useElapsed(running: boolean, startedAt: number | null): number {
123
194
  }
124
195
 
125
196
  /** Data URL for a generated image. */
126
- function srcOf(image: GeneratedImage): string {
127
- return `data:${image.mime};base64,${image.b64}`
128
- }
129
-
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 }
151
- }
152
-
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 })
159
- }
160
-
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)
164
- useEffect(() => {
165
- if (sessions === undefined) {
166
- setSessionId(undefined)
167
- return
168
- }
169
- const sync = (): void => { setSessionId(sessions.list.getSnapshot().current) }
170
- sync()
171
- return sessions.list.subscribe(sync)
172
- }, [sessions])
173
- return sessionId
174
- }
175
-
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]'))
184
- }
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
191
- }
197
+ function srcOf(image: GeneratedImage): string {
198
+ return `data:${image.mime};base64,${image.b64}`
199
+ }
200
+
201
+ /** Decode one durable conversation attachment into the panel's image shape. */
202
+ async function attachmentToGenerated(ref: ImageAttachmentRef): Promise<GeneratedImage> {
203
+ const query = new URLSearchParams({
204
+ attachment_id: String(ref.attachmentId),
205
+ media_type: ref.mediaType,
206
+ bytes: String(ref.bytes),
207
+ width: String(ref.width),
208
+ height: String(ref.height),
209
+ })
210
+ const response = await fetch(`${AGENT_IMAGE_API}?${query.toString()}`)
211
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
212
+ const blob = await response.blob()
213
+ const dataUrl = await new Promise<string>((resolve, reject) => {
214
+ const reader = new FileReader()
215
+ reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
216
+ reader.onerror = () => reject(new Error('image read failed'))
217
+ reader.readAsDataURL(blob)
218
+ })
219
+ const comma = dataUrl.indexOf(',')
220
+ if (comma < 0) throw new Error('image decode failed')
221
+ return { b64: dataUrl.slice(comma + 1), mime: ref.mediaType }
222
+ }
223
+
224
+ /** Convert a generated image into the browser-owned draft format. */
225
+ function generatedImageToFile(image: GeneratedImage, index: number): File {
226
+ const binary = atob(image.b64)
227
+ const bytes = new Uint8Array(binary.length)
228
+ for (let offset = 0; offset < binary.length; offset += 1) bytes[offset] = binary.charCodeAt(offset)
229
+ return new File([bytes], `dsh-image-${index + 1}.${extensionOf(image.mime)}`, { type: image.mime })
230
+ }
231
+
232
+ /** Decode a data URL into a browser File for the native composer. */
233
+ function dataUrlToFile(dataUrl: string, name: string): File {
234
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/su.exec(dataUrl)
235
+ if (match === null || match[1] === undefined || match[2] === undefined) throw new Error('image processing returned an invalid data URL')
236
+ const binary = atob(match[2])
237
+ const bytes = new Uint8Array(binary.length)
238
+ for (let offset = 0; offset < binary.length; offset += 1) bytes[offset] = binary.charCodeAt(offset)
239
+ return new File([bytes], name, { type: match[1] })
240
+ }
241
+
242
+ /** Read intrinsic dimensions without changing the original preview. */
243
+ function imageDimensions(dataUrl: string): Promise<{ width: number; height: number }> {
244
+ return new Promise((resolve, reject) => {
245
+ const image = new Image()
246
+ image.onload = () => {
247
+ const width = image.naturalWidth || image.width
248
+ const height = image.naturalHeight || image.height
249
+ if (width < 1 || height < 1) reject(new Error('image dimensions are unavailable'))
250
+ else resolve({ width, height })
251
+ }
252
+ image.onerror = () => reject(new Error('image decode failed'))
253
+ image.src = dataUrl
254
+ })
255
+ }
256
+
257
+ /** Prepare the smaller conversation copy required by the host attachment policy. */
258
+ async function prepareConversationImage(image: GeneratedImage, index: number): Promise<{ file: File; dataUrl: string }> {
259
+ const dataUrl = srcOf(image)
260
+ const { width, height } = await imageDimensions(dataUrl)
261
+ const longestSide = Math.max(width, height)
262
+ if (longestSide <= CONVERSATION_IMAGE_MAX_DIMENSION) {
263
+ return { file: generatedImageToFile(image, index), dataUrl }
264
+ }
265
+
266
+ const scale = CONVERSATION_IMAGE_MAX_DIMENSION / longestSide
267
+ const targetWidth = Math.max(1, Math.round(width * scale))
268
+ const targetHeight = Math.max(1, Math.round(height * scale))
269
+ const canvas = document.createElement('canvas')
270
+ canvas.width = targetWidth
271
+ canvas.height = targetHeight
272
+ const context = canvas.getContext('2d')
273
+ if (context === null) throw new Error('image resize is unavailable in this browser')
274
+ const source = await new Promise<HTMLImageElement>((resolve, reject) => {
275
+ const sourceImage = new Image()
276
+ sourceImage.onload = () => resolve(sourceImage)
277
+ sourceImage.onerror = () => reject(new Error('image decode failed'))
278
+ sourceImage.src = dataUrl
279
+ })
280
+ context.drawImage(source, 0, 0, targetWidth, targetHeight)
281
+ const resizedDataUrl = canvas.toDataURL('image/jpeg', CONVERSATION_IMAGE_JPEG_QUALITY)
282
+ return {
283
+ dataUrl: resizedDataUrl,
284
+ file: dataUrlToFile(resizedDataUrl, `dsh-image-${index + 1}.jpg`),
285
+ }
286
+ }
287
+
288
+ /** Follow the native session selection while the image panel stays mounted. */
289
+ function useCurrentSessionId(sessions: ISessions | undefined): SessionId | undefined {
290
+ const [sessionId, setSessionId] = useState<SessionId | undefined>(() => sessions?.list.getSnapshot().current)
291
+ useEffect(() => {
292
+ if (sessions === undefined) {
293
+ setSessionId(undefined)
294
+ return
295
+ }
296
+ const sync = (): void => { setSessionId(sessions.list.getSnapshot().current) }
297
+ sync()
298
+ return sessions.list.subscribe(sync)
299
+ }, [sessions])
300
+ return sessionId
301
+ }
302
+
303
+ /** Find the host mounted in the shell's left navigation region. */
304
+ function useSidebarHistoryHost(): HTMLDivElement | null {
305
+ const [host, setHost] = useState<HTMLDivElement | null>(() => (
306
+ document.querySelector<HTMLDivElement>('[data-dsh-imagegen-history-host]')
307
+ ))
308
+ useEffect(() => {
309
+ const sync = (): void => {
310
+ setHost(document.querySelector<HTMLDivElement>('[data-dsh-imagegen-history-host]'))
311
+ }
312
+ sync()
313
+ const observer = new MutationObserver(sync)
314
+ observer.observe(document.body, { childList: true, subtree: true })
315
+ return () => observer.disconnect()
316
+ }, [])
317
+ return host
318
+ }
192
319
 
193
320
  /** Fetch persisted history image refs and decode them back to in-memory
194
321
  * GeneratedImage[] (base64), so the canvas/preview can reuse the same
@@ -220,24 +347,72 @@ function formatTime(timestamp: number): string {
220
347
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
221
348
  }
222
349
 
350
+ function defaultEcommerceDraft(): ProductSetDraft {
351
+ return {
352
+ projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', size: '1:1',
353
+ productName: '', sellingPoints: '', protectedFeatures: '', styleHint: '',
354
+ slots: PRODUCT_SET_SLOTS.map(slot => ({ ...slot })),
355
+ }
356
+ }
357
+
358
+ function ecommercePrompt(draft: ProductSetDraft, slot: ProductSetSlot): string {
359
+ const points = draft.sellingPoints.trim() || '突出商品真实材质、结构和核心价值'
360
+ const protectedFeatures = draft.protectedFeatures.trim() || '保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
361
+ const refClause = slot.refRole !== undefined && slot.refRole !== 'none'
362
+ ? `本图以上传的${ECOMMERCE_ROLE_PROMPT_LABELS[slot.refRole]}图片为参考,商品与风格必须与参考图保持一致;`
363
+ : ''
364
+ return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${draft.language}。商品卖点:${points}。必须遵守:${protectedFeatures}。${refClause}整体要求:商品主体清晰、比例真实、光线自然、画面干净、适合电商发布;${draft.styleHint.trim()}`
365
+ }
366
+
367
+ /** Consistency prefix for slots generated after the main image exists. */
368
+ function withAnchorNote(prompt: string): string {
369
+ return '商品套图一致性约束:附件是本套商品的主图,图中商品(外形、颜色、材质、Logo、包装文字)必须与附件完全一致,不得重新发明商品。' + prompt
370
+ }
371
+
223
372
  /** Studio tabs: the two generation modes plus the gallery view. */
224
373
  type PanelTab = GenerateMode | 'gallery'
225
374
 
375
+ /** Top-level workspaces inside the panel. 'normal' is the classic studio;
376
+ * more task-oriented modes (prototype, …) can join alongside 'ecommerce'. */
377
+ type PanelWorkspace = 'normal' | 'ecommerce'
378
+
226
379
  type GalleryFilter = string
227
380
  type ComparisonSession = { taskIds: string[]; prompt: string; comparisonId: string }
228
381
  type HistoryGroup = { key: string; entries: HistoryEntry[]; models: string[] }
229
382
 
383
+ /** One image unit in the ecommerce results canvas: a live queue task or a
384
+ * restored history entry of the viewed product set. */
385
+ interface EcommerceResultItem {
386
+ id: string
387
+ label: string
388
+ slotKey: string
389
+ status: GenerationTaskStatus
390
+ model: string
391
+ prompt: string
392
+ error?: string
393
+ images: GeneratedImage[]
394
+ /** The request to resubmit when regenerating this slot. */
395
+ source: GenerateRequest
396
+ }
397
+
230
398
  function modelsOfHistoryEntry(entry: HistoryEntry): string[] {
231
399
  return entry.comparisonModels?.length !== undefined && entry.comparisonModels.length > 1
232
400
  ? entry.comparisonModels
233
401
  : [entry.model]
234
402
  }
235
403
 
404
+ /** Comparison runs collapse by comparisonId, product sets by projectId. */
405
+ function historyGroupKey(entry: HistoryEntry): string {
406
+ if (entry.comparisonId !== undefined) return entry.comparisonId
407
+ if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) return `project:${entry.projectId}`
408
+ return entry.id
409
+ }
410
+
236
411
  /** Collapse the per-model history rows that belong to one comparison run. */
237
412
  function groupHistoryEntries(entries: HistoryEntry[]): HistoryGroup[] {
238
413
  const groups = new Map<string, HistoryGroup>()
239
414
  for (const entry of entries) {
240
- const key = entry.comparisonId ?? entry.id
415
+ const key = historyGroupKey(entry)
241
416
  const existing = groups.get(key)
242
417
  if (existing === undefined) {
243
418
  groups.set(key, { key, entries: [entry], models: modelsOfHistoryEntry(entry) })
@@ -256,13 +431,13 @@ function newComparisonId(): string {
256
431
  }
257
432
 
258
433
  /** Render the studio. */
259
- export function ImageGenPanel(props: {
260
- api: ImageGenApi
261
- scope: ImageGenScope
262
- sessions?: ISessions
263
- conversation?: ConversationService
264
- }) {
265
- const { api, scope, sessions, conversation } = props
434
+ export function ImageGenPanel(props: {
435
+ api: ImageGenApi
436
+ scope: ImageGenScope
437
+ sessions?: ISessions
438
+ conversation?: ConversationService
439
+ }) {
440
+ const { api, scope, sessions, conversation } = props
266
441
  const config = useConfig(scope)
267
442
  const enabled = config?.enabled ?? true
268
443
  // Channel-aware model options: the panel lists every configured alias
@@ -284,6 +459,12 @@ export function ImageGenPanel(props: {
284
459
  const connected = enabled && configured && apiKeySet
285
460
 
286
461
  const [tab, setTab] = useState<PanelTab>('text')
462
+ const [workspace, setWorkspace] = useState<PanelWorkspace>('normal')
463
+ /** Switch to a normal-generation tab, leaving any task workspace. */
464
+ const openTab = (next: PanelTab): void => {
465
+ setWorkspace('normal')
466
+ setTab(next)
467
+ }
287
468
  const [prompt, setPrompt] = useState('')
288
469
  const [size, setSize] = useState<string>('auto')
289
470
  const [quality, setQuality] = useState<string>('auto')
@@ -294,9 +475,10 @@ export function ImageGenPanel(props: {
294
475
  const [compareModels, setCompareModels] = useState<string[]>([])
295
476
  const [modelOpen, setModelOpen] = useState(false)
296
477
  const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
297
- const [images, setImages] = useState<GeneratedImage[]>([])
298
- const [addingToConversation, setAddingToConversation] = useState<number | null>(null)
299
- const [conversationMessage, setConversationMessage] = useState<string | null>(null)
478
+ const [images, setImages] = useState<GeneratedImage[]>([])
479
+ const [addingToConversation, setAddingToConversation] = useState<number | string | null>(null)
480
+ const [galleryConversationAddingId, setGalleryConversationAddingId] = useState<string | null>(null)
481
+ const [conversationMessage, setConversationMessage] = useState<string | null>(null)
300
482
  const [error, setError] = useState<string | null>(null)
301
483
  // Submission is brief; actual generation stays visible until the host
302
484
  // queue reports that every queued/running task has finished.
@@ -335,9 +517,39 @@ export function ImageGenPanel(props: {
335
517
  const tasksRef = useRef<GenerationTask[]>([])
336
518
  const [taskTrayOpen, setTaskTrayOpen] = useState(false)
337
519
  const [comparison, setComparison] = useState<ComparisonSession | null>(null)
338
- const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
339
- const currentSessionId = useCurrentSessionId(sessions)
340
- const sidebarHistoryHost = useSidebarHistoryHost()
520
+ const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
521
+ const [ecommerce, setEcommerce] = useState<ProductSetDraft>(() => {
522
+ try {
523
+ const saved = window.localStorage.getItem(ECOMMERCE_DRAFT_STORAGE_KEY)
524
+ if (saved !== null) {
525
+ const merged: ProductSetDraft = { ...defaultEcommerceDraft(), ...JSON.parse(saved) as Partial<ProductSetDraft> }
526
+ // Drafts saved before reference roles existed keep working: every slot
527
+ // defaults to following the product image.
528
+ if (Array.isArray(merged.slots)) {
529
+ merged.slots = merged.slots.map(slot => ({ ...slot, refRole: slot.refRole ?? 'product' }))
530
+ }
531
+ return merged
532
+ }
533
+ } catch { /* ignore malformed or unavailable storage */ }
534
+ return defaultEcommerceDraft()
535
+ })
536
+ const [ecommercePreview, setEcommercePreview] = useState(false)
537
+ const [ecommerceGenerating, setEcommerceGenerating] = useState(false)
538
+ const [ecommerceProjectId, setEcommerceProjectId] = useState<string | null>(null)
539
+ const [ecommerceAssets, setEcommerceAssets] = useState<ProductAsset[]>([])
540
+ /** History-restored product set currently shown in the results canvas. */
541
+ const [ecommerceRestored, setEcommerceRestored] = useState<{ projectId: string; projectName: string; items: EcommerceResultItem[] } | null>(null)
542
+ /** Pending main-image anchor: the main image task is in flight; once it
543
+ * completes, the remaining slots are resubmitted with it as their shared
544
+ * reference so every image in the set shows the same product. */
545
+ const [ecommerceAnchor, setEcommerceAnchor] = useState<{ projectId: string; mainTaskIds: string[]; remaining: GenerateRequest[] } | null>(null)
546
+ const [ecommerceRefOpen, setEcommerceRefOpen] = useState(false)
547
+ const [configCollapsed, setConfigCollapsed] = useState(readConfigCollapsed)
548
+ const [chatOpen, setChatOpen] = useState(readChatOpen)
549
+ const [configWidth, setConfigWidth] = useState(readConfigWidth)
550
+ const configAsideRef = useRef<HTMLElement>(null)
551
+ const currentSessionId = useCurrentSessionId(sessions)
552
+ const sidebarHistoryHost = useSidebarHistoryHost()
341
553
  const modeModels = tab === 'edit'
342
554
  ? imageModels.filter(candidate => describeModel(candidate).supportsEdit)
343
555
  : imageModels
@@ -349,10 +561,63 @@ export function ImageGenPanel(props: {
349
561
  const generationStartedAt = activeTask?.startedAt ?? activeTask?.createdAt ?? null
350
562
  const elapsed = useElapsed(generating, generationStartedAt)
351
563
 
564
+ useEffect(() => {
565
+ try { window.localStorage.setItem(ECOMMERCE_DRAFT_STORAGE_KEY, JSON.stringify(ecommerce)) } catch { /* optional draft persistence */ }
566
+ }, [ecommerce])
567
+
568
+ useEffect(() => {
569
+ try {
570
+ window.localStorage.setItem(CONFIG_COLLAPSED_STORAGE_KEY, String(configCollapsed))
571
+ } catch {
572
+ // Embedded shells may disable local storage; the in-memory toggle still works.
573
+ }
574
+ }, [configCollapsed])
575
+
576
+ // Chat-pane visibility rides a document-level attribute so the center-column
577
+ // grid can drop the conversation entirely. Collapsed by default.
578
+ useEffect(() => {
579
+ if (chatOpen) delete document.documentElement.dataset.dshImagegenChatCollapsed
580
+ else document.documentElement.dataset.dshImagegenChatCollapsed = '1'
581
+ try { window.localStorage.setItem(CHAT_COLLAPSED_STORAGE_KEY, chatOpen ? 'open' : 'collapsed') } catch { /* optional */ }
582
+ }, [chatOpen])
583
+
584
+ useEffect(() => () => { delete document.documentElement.dataset.dshImagegenChatCollapsed }, [])
585
+
586
+ // Main-image anchor chain: when the main image task of a product set
587
+ // completes, resubmit the remaining slots with the generated main image as
588
+ // their shared reference. Cleared up front so a re-render cannot double-
589
+ // submit; failures surface as a canvas error.
590
+ useEffect(() => {
591
+ if (ecommerceAnchor === null) return
592
+ const anchor = ecommerceAnchor
593
+ const mains = tasks.filter(task => anchor.mainTaskIds.includes(task.id))
594
+ if (mains.length === 0) return
595
+ if (mains.every(task => task.status === 'failed' || task.status === 'cancelled')) {
596
+ setEcommerceAnchor(null)
597
+ setError(tt('ecommerce.anchorFailed'))
598
+ return
599
+ }
600
+ const done = mains.find(task => task.status === 'completed' && task.result !== undefined && task.result.images.length > 0)
601
+ if (done === undefined) return
602
+ setEcommerceAnchor(null)
603
+ const dataUrl = srcOf(done.result!.images[0]!)
604
+ const requests = anchor.remaining.map(request => ({
605
+ ...request,
606
+ mode: 'edit' as const,
607
+ image: dataUrl,
608
+ refName: 'set-main-anchor',
609
+ prompt: withAnchorNote(request.prompt),
610
+ }))
611
+ void Promise.all(requests.map(request => api.taskSubmit(request)))
612
+ .then(submitted => { setTasks(previous => [...submitted, ...previous]) })
613
+ .catch(caught => { setError(errorMessage(caught)) })
614
+ }, [api, tasks, ecommerceAnchor])
615
+
616
+
352
617
  // A saved settings change is authoritative. Keep the active selection and
353
618
  // comparison choices in that allow-list without disturbing valid choices.
354
619
  const imageModelKey = modeModels.join('\u0000')
355
- useEffect(() => {
620
+ useEffect(() => {
356
621
  setModel(previous => modeModels.includes(previous) ? previous : modeModels[0] ?? '')
357
622
  setCompareModels(previous => {
358
623
  const retained = previous.filter(candidate => modeModels.includes(candidate))
@@ -394,29 +659,29 @@ export function ImageGenPanel(props: {
394
659
  .then(entries => { if (!disposed) setGallery(entries) })
395
660
  .catch(() => { /* gallery unavailable — leave the list empty */ })
396
661
  return () => { disposed = true }
397
- }, [api])
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.
402
- useEffect(() => {
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)
414
- })
415
- .catch(caught => { setError(errorMessage(caught)) })
416
- }
417
- document.addEventListener(CHAT_IMAGE_EVENT, onChatImages)
418
- return () => document.removeEventListener(CHAT_IMAGE_EVENT, onChatImages)
419
- }, [currentSessionId])
662
+ }, [api])
663
+
664
+ // Chat toolviews publish durable refs after they finish loading. Decode the
665
+ // refs through the same host-authorized route and make them the current
666
+ // canvas result for the selected session.
667
+ useEffect(() => {
668
+ const onChatImages = (event: Event): void => {
669
+ const detail = (event as CustomEvent<ChatImageEventDetail>).detail
670
+ if (detail === undefined || currentSessionId === undefined || detail.sessionId !== currentSessionId) return
671
+ void Promise.all(detail.refs.map(attachmentToGenerated))
672
+ .then(next => {
673
+ openTab('text')
674
+ setImages(next)
675
+ setComparison(null)
676
+ setViewingHistoryId(null)
677
+ setGalleryViewingId(null)
678
+ setError(null)
679
+ })
680
+ .catch(caught => { setError(errorMessage(caught)) })
681
+ }
682
+ document.addEventListener(CHAT_IMAGE_EVENT, onChatImages)
683
+ return () => document.removeEventListener(CHAT_IMAGE_EVENT, onChatImages)
684
+ }, [currentSessionId])
420
685
 
421
686
  useEffect(() => {
422
687
  let disposed = false
@@ -548,6 +813,33 @@ export function ImageGenPanel(props: {
548
813
  reader.readAsDataURL(file)
549
814
  }
550
815
 
816
+ /** Read uploaded product assets into session-only data-URL chips, capped at
817
+ * MAX_ECOMMERCE_ASSETS. Each starts as the product-role reference. */
818
+ const acceptEcommerceFiles = (files: FileList | undefined): void => {
819
+ if (files === undefined) return
820
+ const incoming = Array.from(files).filter(file => file.type.startsWith('image/') && file.size <= REF_IMAGE_MAX_BYTES)
821
+ if (incoming.length === 0) {
822
+ setError(tt('edit.uploadHint'))
823
+ return
824
+ }
825
+ for (const file of incoming) {
826
+ const reader = new FileReader()
827
+ reader.onload = () => {
828
+ if (typeof reader.result !== 'string') return
829
+ const dataUrl = reader.result
830
+ setEcommerceAssets(previous => {
831
+ if (previous.length >= MAX_ECOMMERCE_ASSETS) {
832
+ setError(tt('ecommerce.assetsFull'))
833
+ return previous
834
+ }
835
+ return [...previous, { id: newComparisonId(), dataUrl, name: file.name, role: 'product' }]
836
+ })
837
+ }
838
+ reader.onerror = () => { setError(tt('edit.uploadHint')) }
839
+ reader.readAsDataURL(file)
840
+ }
841
+ }
842
+
551
843
  /** Run one generation. */
552
844
  const handleGenerate = async (): Promise<void> => {
553
845
  if (submitting) return
@@ -569,7 +861,7 @@ export function ImageGenPanel(props: {
569
861
  return
570
862
  }
571
863
  const request: GenerateRequest = {
572
- mode: tab === 'gallery' ? 'text' : tab,
864
+ mode: tab === 'edit' ? 'edit' : 'text',
573
865
  model: modeModels.includes(model) ? model : modeModels[0] ?? '',
574
866
  prompt: promptText,
575
867
  size,
@@ -600,7 +892,163 @@ export function ImageGenPanel(props: {
600
892
  }
601
893
  }
602
894
 
603
- /** Open the full-screen image preview at a given index. */
895
+ const handleEcommerceGenerate = async (): Promise<void> => {
896
+ if (ecommerceGenerateDisabled) return
897
+ if (!enabled || !configured || !apiKeySet) { openSettingsGuide('generation'); return }
898
+ const projectId = ecommerce.projectId || newComparisonId()
899
+ const buildRequest = (slot: ProductSetSlot, index: number): GenerateRequest => {
900
+ // Each slot picks one reference by asset role; a slot without a matching
901
+ // asset (or 'none') falls back to text-to-image.
902
+ const refRole = slot.refRole ?? 'product'
903
+ const asset = refRole === 'none' ? undefined : ecommerceAssets.find(item => item.role === refRole)
904
+ return {
905
+ mode: asset !== undefined ? 'edit' as const : 'text' as const,
906
+ model: modeModels.includes(model) ? model : modeModels[0] ?? '',
907
+ prompt: ecommercePrompt(ecommerce, slot),
908
+ size: ecommerce.size,
909
+ quality,
910
+ n: 1,
911
+ detail,
912
+ ...(defaultChannelId !== undefined ? { channelId: defaultChannelId } : {}),
913
+ ...(asset !== undefined ? { image: asset.dataUrl, refName: asset.name } : {}),
914
+ workflow: 'ecommerce' as const,
915
+ projectId,
916
+ projectName: ecommerce.projectName.trim() || ecommerce.productName.trim(),
917
+ slotKey: `${slot.key}-${index + 1}`,
918
+ slotLabel: slot.label,
919
+ }
920
+ }
921
+ // Anchor chain: with a main-image slot enabled, only the main image is
922
+ // submitted now; the remaining slots follow once it completes (see the
923
+ // anchor effect) so the whole set shares one product. Without a main
924
+ // slot, every slot submits immediately with its own reference.
925
+ const mainSlots = ecommerceSlots.filter(slot => slot.key === 'main')
926
+ const otherSlots = ecommerceSlots.filter(slot => slot.key !== 'main')
927
+ const anchorChain = mainSlots.length > 0 && otherSlots.length > 0
928
+ const leadSlots = anchorChain ? mainSlots : ecommerceSlots
929
+ const requests = leadSlots.flatMap(slot => Array.from({ length: slot.count }, (_, index) => buildRequest(slot, index)))
930
+ const remaining = anchorChain
931
+ ? otherSlots.flatMap(slot => Array.from({ length: slot.count }, (_, index) => {
932
+ const { image: _image, refName: _refName, ...rest } = buildRequest(slot, index)
933
+ return rest
934
+ }))
935
+ : []
936
+ setEcommerceGenerating(true); setSubmitting(true); setError(null); setEcommerceProjectId(projectId); setEcommerceRestored(null); setEcommerceAnchor(null)
937
+ try {
938
+ const submitted = await Promise.all(requests.map(request => api.taskSubmit(request)))
939
+ setTasks(previous => [...submitted, ...previous])
940
+ setEcommercePreview(false)
941
+ if (anchorChain) setEcommerceAnchor({ projectId, mainTaskIds: submitted.map(task => task.id), remaining })
942
+ } catch (caught) { setError(errorMessage(caught)) } finally { setSubmitting(false); setEcommerceGenerating(false) }
943
+ }
944
+
945
+ /** Start over with a fresh product draft (the old results stay in history). */
946
+ const newEcommerceProduct = (): void => {
947
+ setEcommerce(defaultEcommerceDraft())
948
+ setEcommercePreview(false)
949
+ setEcommerceProjectId(null)
950
+ setEcommerceRestored(null)
951
+ setEcommerceAnchor(null)
952
+ setEcommerceAssets([])
953
+ setRefImage(null)
954
+ setError(null)
955
+ }
956
+
957
+ /** Re-run every image of one slot with its original request. */
958
+ const regenerateEcommerceSlot = async (label: string): Promise<void> => {
959
+ if (ecommerceGenerating) return
960
+ const group = ecommerceMergedItems.filter(item => item.label === label)
961
+ if (group.length === 0) return
962
+ setEcommerceGenerating(true)
963
+ setError(null)
964
+ try {
965
+ const submitted = await Promise.all(group.map(item => api.taskSubmit({ ...item.source })))
966
+ setTasks(previous => [...submitted, ...previous])
967
+ } catch (caught) {
968
+ setError(errorMessage(caught))
969
+ } finally {
970
+ setEcommerceGenerating(false)
971
+ }
972
+ }
973
+
974
+ /** Open one persisted product set from history: rebuild the grouped results
975
+ * canvas from its entries. Reference images are not persisted, so restored
976
+ * edit-mode slots regenerate as text-to-image. */
977
+ const viewEcommerceProject = async (group: HistoryGroup): Promise<void> => {
978
+ const entry = group.entries[0]
979
+ if (entry === undefined || entry.projectId === undefined) return
980
+ try {
981
+ const items: EcommerceResultItem[] = await Promise.all(group.entries.map(async item => ({
982
+ id: item.id,
983
+ label: item.slotLabel ?? '',
984
+ slotKey: item.slotKey ?? '',
985
+ status: 'completed' as const,
986
+ model: item.model,
987
+ prompt: item.prompt,
988
+ images: await historyImagesToGenerated(item.images),
989
+ source: {
990
+ mode: item.mode === 'edit' ? 'text' as const : item.mode,
991
+ model: item.model,
992
+ prompt: item.prompt,
993
+ size: item.size,
994
+ quality: item.quality,
995
+ detail: item.detail,
996
+ n: 1,
997
+ ...item.channelId !== undefined ? { channelId: item.channelId } : {},
998
+ workflow: 'ecommerce' as const,
999
+ projectId: entry.projectId!,
1000
+ projectName: entry.projectName ?? '',
1001
+ slotKey: item.slotKey ?? '',
1002
+ slotLabel: item.slotLabel ?? '',
1003
+ },
1004
+ })))
1005
+ setWorkspace('ecommerce')
1006
+ setEcommerceRestored({ projectId: entry.projectId, projectName: entry.projectName ?? '', items })
1007
+ setEcommerceProjectId(entry.projectId)
1008
+ setEcommercePreview(false)
1009
+ setError(null)
1010
+ setViewingHistoryId(entry.id)
1011
+ setGalleryViewingId(null)
1012
+ } catch (caught) {
1013
+ setError(errorMessage(caught))
1014
+ }
1015
+ }
1016
+
1017
+ /** Download a JSON manifest describing the whole product set (prompts,
1018
+ * slots and task outcomes) so results stay reproducible outside the panel. */
1019
+ const exportEcommerceManifest = (): void => {
1020
+ const manifest = {
1021
+ project: {
1022
+ id: ecommerceProjectId,
1023
+ name: ecommerce.projectName || ecommerce.productName,
1024
+ productName: ecommerce.productName,
1025
+ category: ecommerce.category,
1026
+ platform: ecommerce.platform,
1027
+ language: ecommerce.language,
1028
+ size: ecommerce.size,
1029
+ sellingPoints: ecommerce.sellingPoints,
1030
+ protectedFeatures: ecommerce.protectedFeatures,
1031
+ styleHint: ecommerce.styleHint,
1032
+ },
1033
+ generatedAt: new Date().toISOString(),
1034
+ images: ecommerceMergedItems.map(item => ({
1035
+ slotKey: item.slotKey,
1036
+ slotLabel: item.label,
1037
+ status: item.status,
1038
+ model: item.model,
1039
+ prompt: item.prompt,
1040
+ error: item.error,
1041
+ })),
1042
+ }
1043
+ const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' })
1044
+ const url = URL.createObjectURL(blob)
1045
+ const anchor = document.createElement('a')
1046
+ anchor.href = url
1047
+ anchor.download = `dsh-product-set-${(ecommerce.projectName || ecommerce.productName || 'set').replace(/[^\w-]+/g, '-')}.json`
1048
+ anchor.click()
1049
+ URL.revokeObjectURL(url)
1050
+ }
1051
+
604
1052
  const openPreview = (previewImages: GeneratedImage[], index: number): void => {
605
1053
  setPreview({ images: previewImages, index })
606
1054
  setPreviewScale(1)
@@ -658,15 +1106,21 @@ export function ImageGenPanel(props: {
658
1106
  }
659
1107
 
660
1108
  /** 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')
667
- try {
668
- setImages(await loadHistoryGroup(group))
669
- setComparison(null)
1109
+ const viewHistoryGroup = async (group: HistoryGroup): Promise<void> => {
1110
+ const entry = group.entries[0]
1111
+ if (entry === undefined) return
1112
+ // Product sets rebuild their grouped results canvas instead of the
1113
+ // generic image workspace.
1114
+ if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) {
1115
+ await viewEcommerceProject(group)
1116
+ return
1117
+ }
1118
+ // History is also the bridge out of the gallery: show the image workspace
1119
+ // immediately, then hydrate the selected result into its canvas.
1120
+ openTab('text')
1121
+ try {
1122
+ setImages(await loadHistoryGroup(group))
1123
+ setComparison(null)
670
1124
  setError(null)
671
1125
  setViewingHistoryId(entry.id)
672
1126
  setGalleryViewingId(null)
@@ -679,9 +1133,15 @@ export function ImageGenPanel(props: {
679
1133
  const restoreHistoryGroup = async (group: HistoryGroup): Promise<void> => {
680
1134
  const entry = group.entries[0]
681
1135
  if (entry === undefined) return
1136
+ // The product-set form draft cannot be rebuilt from a compiled prompt, so
1137
+ // restoring a product set reopens its grouped results canvas.
1138
+ if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) {
1139
+ await viewEcommerceProject(group)
1140
+ return
1141
+ }
682
1142
  try {
683
1143
  const restored = await loadHistoryGroup(group)
684
- setTab(entry.mode)
1144
+ openTab(entry.mode)
685
1145
  setPrompt(entry.prompt)
686
1146
  setSize(normalizeSize(entry.size))
687
1147
  setQuality(normalizeQuality(entry.quality))
@@ -706,6 +1166,11 @@ export function ImageGenPanel(props: {
706
1166
  const ids = new Set(group.entries.map(entry => entry.id))
707
1167
  setHistory(previous => previous.filter(entry => !ids.has(entry.id)))
708
1168
  if (viewingHistoryId !== null && ids.has(viewingHistoryId)) setViewingHistoryId(null)
1169
+ if (ecommerceRestored !== null && group.entries.some(entry => entry.projectId === ecommerceRestored.projectId)) {
1170
+ setEcommerceRestored(null)
1171
+ setEcommerceProjectId(null)
1172
+ setEcommerceAnchor(null)
1173
+ }
709
1174
  try {
710
1175
  let next = history
711
1176
  for (const id of ids) next = await api.historyRemove(id)
@@ -715,8 +1180,30 @@ export function ImageGenPanel(props: {
715
1180
  }
716
1181
  }
717
1182
 
1183
+ /** Reset the workspace for a fresh image-generation run. */
1184
+ const startNewCreation = (): void => {
1185
+ openTab('text')
1186
+ setPrompt('')
1187
+ setRefImage(null)
1188
+ setImages([])
1189
+ setPreview(null)
1190
+ setPreviewScale(1)
1191
+ setPromptCopied(false)
1192
+ setViewingHistoryId(null)
1193
+ setGalleryViewingId(null)
1194
+ setGallerySelecting(false)
1195
+ setSelectedGalleryIds(new Set())
1196
+ setComparison(null)
1197
+ setComparisonFullscreen(false)
1198
+ setEcommerceRestored(null)
1199
+ setError(null)
1200
+ setConversationMessage(null)
1201
+ setGalleryMessage(null)
1202
+ }
1203
+
718
1204
  /** Remove all history entries. */
719
1205
  const clearHistory = async (): Promise<void> => {
1206
+ if (!window.confirm(tt('history.clearConfirm'))) return
720
1207
  setHistory([])
721
1208
  setViewingHistoryId(null)
722
1209
  try {
@@ -730,8 +1217,8 @@ export function ImageGenPanel(props: {
730
1217
  * `entry` makes the action available from a history/gallery list item (its
731
1218
  * metadata + first image are saved); otherwise the current form state is
732
1219
  * used. */
733
- const addToGallery = async (image: GeneratedImage, entry?: HistoryEntry): Promise<void> => {
734
- if (galleryAdding || tab === 'gallery') return
1220
+ const addToGallery = async (image: GeneratedImage, entry?: HistoryEntry): Promise<void> => {
1221
+ if (galleryAdding || (workspace === 'normal' && tab === 'gallery')) return
735
1222
  const source = entry ?? viewingEntry ?? {
736
1223
  mode: tab === 'edit' ? 'edit' as GenerateMode : 'text' as GenerateMode,
737
1224
  model,
@@ -764,41 +1251,49 @@ export function ImageGenPanel(props: {
764
1251
  } finally {
765
1252
  setGalleryAdding(false)
766
1253
  }
767
- }
768
-
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'))
774
- return
775
- }
776
- const sessionScope = sessions.scope(currentSessionId)
777
- if (sessionScope === undefined) {
778
- setError(tt('conversation.unavailable'))
779
- return
780
- }
781
- setAddingToConversation(index)
782
- let attachments: ReturnType<ConversationService['createDraftImages']> = []
783
- let added = false
784
- try {
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'))
790
- }
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)
795
- } catch (caught) {
796
- if (!added) conversation.releaseDraftImages(attachments)
797
- setError(errorMessage(caught))
798
- } finally {
799
- setAddingToConversation(null)
800
- }
801
- }
1254
+ }
1255
+
1256
+ /** Put a generated image into the native conversation composer. */
1257
+ const addImageToConversation = async (image: GeneratedImage, index: number, actionKey: number | string = index): Promise<void> => {
1258
+ if (addingToConversation !== null) return
1259
+ if (conversation === undefined || sessions === undefined || currentSessionId === undefined) {
1260
+ setError(tt('conversation.noSession'))
1261
+ return
1262
+ }
1263
+ const sessionScope = sessions.scope(currentSessionId)
1264
+ if (sessionScope === undefined) {
1265
+ setError(tt('conversation.unavailable'))
1266
+ return
1267
+ }
1268
+ setAddingToConversation(actionKey)
1269
+ let attachments: ReturnType<ConversationService['createDraftImages']> = []
1270
+ let added = false
1271
+ try {
1272
+ const prepared = await prepareConversationImage(image, index)
1273
+ const file = prepared.file
1274
+ attachments = conversation.createDraftImages([file])
1275
+ const input = conversation.input.for(sessionScope)
1276
+ if (!input.addImages(attachments.map(attachment => attachment.id))) {
1277
+ throw new Error(tt('conversation.busy'))
1278
+ }
1279
+ added = true
1280
+ try {
1281
+ await api.attachConversationImage(String(currentSessionId), prepared.dataUrl, file.name)
1282
+ } catch (caught) {
1283
+ for (const attachment of attachments) input.removeImage(attachment.id)
1284
+ added = false
1285
+ throw caught
1286
+ }
1287
+ if (input.state.getSnapshot().draft.trim() === '' && prompt.trim() !== '') input.setDraft(prompt.trim())
1288
+ setConversationMessage(tt('conversation.added'))
1289
+ window.setTimeout(() => { setConversationMessage(null) }, 2200)
1290
+ } catch (caught) {
1291
+ if (!added) conversation.releaseDraftImages(attachments)
1292
+ setError(errorMessage(caught))
1293
+ } finally {
1294
+ setAddingToConversation(null)
1295
+ }
1296
+ }
802
1297
 
803
1298
  /** Add one history entry's first image to the gallery (fetches it from the
804
1299
  * history image route, then delegates to addToGallery). */
@@ -813,6 +1308,21 @@ export function ImageGenPanel(props: {
813
1308
  }
814
1309
  }
815
1310
 
1311
+ /** Load a persisted gallery image and add it to the current chat draft. */
1312
+ const addGalleryEntryToConversation = async (entry: HistoryEntry): Promise<void> => {
1313
+ if (galleryConversationAddingId !== null || addingToConversation !== null || entry.images.length === 0) return
1314
+ setGalleryConversationAddingId(entry.id)
1315
+ try {
1316
+ const [image] = await historyImagesToGenerated(entry.images.slice(0, 1))
1317
+ if (image === undefined) return
1318
+ await addImageToConversation(image, 0, `gallery:${entry.id}`)
1319
+ } catch (caught) {
1320
+ setError(errorMessage(caught))
1321
+ } finally {
1322
+ setGalleryConversationAddingId(null)
1323
+ }
1324
+ }
1325
+
816
1326
  /** View a gallery image in the canvas. */
817
1327
  const viewGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
818
1328
  try {
@@ -831,7 +1341,7 @@ export function ImageGenPanel(props: {
831
1341
  const restoreGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
832
1342
  try {
833
1343
  const restored = await historyImagesToGenerated(entry.images)
834
- setTab(entry.mode)
1344
+ openTab(entry.mode)
835
1345
  setPrompt(entry.prompt)
836
1346
  setSize(normalizeSize(entry.size))
837
1347
  setQuality(normalizeQuality(entry.quality))
@@ -861,6 +1371,7 @@ export function ImageGenPanel(props: {
861
1371
 
862
1372
  /** Remove every gallery entry. */
863
1373
  const clearGalleryAll = async (): Promise<void> => {
1374
+ if (!window.confirm(tt('gallery.clearConfirm'))) return
864
1375
  setGallery([])
865
1376
  setGalleryViewingId(null)
866
1377
  try {
@@ -935,6 +1446,39 @@ export function ImageGenPanel(props: {
935
1446
  }
936
1447
 
937
1448
  const generateDisabled = submitting || modeModels.length === 0
1449
+ const ecommerceSlots = ecommerce.slots.filter(slot => slot.enabled && slot.count > 0)
1450
+ const ecommerceTotal = ecommerceSlots.reduce((total, slot) => total + slot.count, 0)
1451
+ const ecommerceGenerateDisabled = submitting || ecommerceGenerating || ecommerceSlots.length === 0 || ecommerce.productName.trim() === ''
1452
+ const ecommerceFileInput = useRef<HTMLInputElement>(null)
1453
+ // The results canvas merges live tasks of the active project with restored
1454
+ // history entries of the same project; restored slots that were regenerated
1455
+ // this session are covered by their live counterparts (same slotKey).
1456
+ const ecommerceProjectTasks = ecommerceProjectId === null
1457
+ ? []
1458
+ : tasks.filter(task => task.request.workflow === 'ecommerce' && task.request.projectId === ecommerceProjectId)
1459
+ const liveSlotKeys = new Set(ecommerceProjectTasks.map(task => task.request.slotKey ?? task.id))
1460
+ const ecommerceMergedItems: EcommerceResultItem[] = [
1461
+ ...ecommerceProjectTasks.map(task => ({
1462
+ id: task.id,
1463
+ label: task.request.slotLabel ?? '',
1464
+ slotKey: task.request.slotKey ?? '',
1465
+ status: task.status,
1466
+ model: task.request.model,
1467
+ prompt: task.request.prompt,
1468
+ ...task.error !== undefined ? { error: task.error } : {},
1469
+ images: task.result?.images ?? [],
1470
+ source: task.request,
1471
+ })),
1472
+ ...(ecommerceRestored !== null && ecommerceRestored.projectId === ecommerceProjectId
1473
+ ? ecommerceRestored.items.filter(item => !liveSlotKeys.has(item.slotKey))
1474
+ : []),
1475
+ ]
1476
+ const ecommerceDoneCount = ecommerceMergedItems.filter(item => item.status === 'completed').length
1477
+ const ecommerceFailedCount = ecommerceMergedItems.filter(item => item.status === 'failed' || item.status === 'cancelled').length
1478
+ const ecommerceResultGroups = [...new Set(ecommerceMergedItems.map(item => item.label))]
1479
+ .filter(label => label !== '')
1480
+ .map(label => ({ label, items: ecommerceMergedItems.filter(item => item.label === label) }))
1481
+ const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null
938
1482
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
939
1483
  const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
940
1484
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
@@ -943,6 +1487,29 @@ export function ImageGenPanel(props: {
943
1487
  const previewFrameScale = Math.max(1, previewScale)
944
1488
  const previewImageScale = previewScale / previewFrameScale
945
1489
 
1490
+ /** Drag the config panel's right edge to resize it (persisted per browser). */
1491
+ const onConfigResizeStart = (event: ReactPointerEvent<HTMLDivElement>): void => {
1492
+ if (event.button !== 0) return
1493
+ event.preventDefault()
1494
+ const aside = configAsideRef.current
1495
+ if (aside === null) return
1496
+ const left = aside.getBoundingClientRect().left
1497
+ const onMove = (move: PointerEvent): void => {
1498
+ const width = Math.round(Math.min(CONFIG_WIDTH_MAX, Math.max(CONFIG_WIDTH_MIN, move.clientX - left)))
1499
+ setConfigWidth(width)
1500
+ try { window.localStorage.setItem(CONFIG_WIDTH_STORAGE_KEY, String(width)) } catch { /* optional */ }
1501
+ }
1502
+ const onUp = (): void => {
1503
+ window.removeEventListener('pointermove', onMove)
1504
+ document.documentElement.style.removeProperty('cursor')
1505
+ document.documentElement.style.removeProperty('user-select')
1506
+ }
1507
+ window.addEventListener('pointermove', onMove)
1508
+ window.addEventListener('pointerup', onUp, { once: true })
1509
+ document.documentElement.style.setProperty('cursor', 'col-resize')
1510
+ document.documentElement.style.setProperty('user-select', 'none')
1511
+ }
1512
+
946
1513
  const copyPreviewPrompt = async (text: string): Promise<void> => {
947
1514
  try {
948
1515
  if (navigator.clipboard?.writeText !== undefined) {
@@ -965,107 +1532,123 @@ export function ImageGenPanel(props: {
965
1532
  }
966
1533
  }
967
1534
 
968
- const addPreviewToEdit = (): void => {
1535
+ const addPreviewToEdit = (): void => {
969
1536
  if (previewImage === null || preview === null) return
970
- setTab('edit')
1537
+ openTab('edit')
971
1538
  setRefImage({
972
1539
  dataUrl: srcOf(previewImage),
973
1540
  name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
974
1541
  })
975
1542
  if (prompt.trim() === '' && previewImage.revisedPrompt !== undefined) setPrompt(previewImage.revisedPrompt)
976
1543
  setError(null)
977
- closePreview()
978
- }
979
-
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}
991
- </header>
992
-
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>
1004
-
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
- >
1020
- <button
1021
- type="button"
1022
- className={css.historyMain}
1023
- data-dsh-imagegen-history-main=""
1024
- onClick={() => { void viewHistoryGroup(group) }}
1025
- >
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>
1040
- </button>
1041
- <span className={css.historyActions}>
1042
- {entry.images.length > 0 ? (
1043
- <button
1044
- type="button"
1045
- className={css.historyAction}
1046
- disabled={galleryAdding}
1047
- title={tt('gallery.add')}
1048
- onClick={() => { void addHistoryEntryToGallery(entry) }}
1049
- >
1050
- {tt('gallery.add')}
1051
- </button>
1052
- ) : null}
1053
- <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryGroup(group) }}>
1054
- {tt('history.restore')}
1055
- </button>
1056
- <button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryGroup(group) }}>
1057
- {tt('history.delete')}
1058
- </button>
1059
- </span>
1060
- </div>
1061
- )
1062
- })}
1063
- </div>
1064
- )}
1065
- </aside>
1066
- )
1067
-
1068
- return (
1544
+ closePreview()
1545
+ }
1546
+
1547
+ // Render history into the shell sidebar so it remains a separate navigation
1548
+ // surface from both the image workspace and the native conversation.
1549
+ const historyPanel = (
1550
+ <aside className={css.history} data-dsh-imagegen-history>
1551
+ <header className={css.historyHeader}>
1552
+ <span className={css.historyTitle}>{tt('history.title')}</span>
1553
+ <div className={css.historyHeaderActions}>
1554
+ <button
1555
+ type="button"
1556
+ className={css.historyNew}
1557
+ data-history-new=""
1558
+ aria-label={tt('canvas.new')}
1559
+ title={tt('canvas.newHint')}
1560
+ onClick={startNewCreation}
1561
+ >
1562
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
1563
+ </button>
1564
+ {history.length > 0 ? (
1565
+ <button type="button" className={css.historyClear} data-history-clear="" onClick={() => { void clearHistory() }}>
1566
+ {tt('history.clear')}
1567
+ </button>
1568
+ ) : null}
1569
+ </div>
1570
+ </header>
1571
+
1572
+ <div className={css.historyFilters}>
1573
+ <input className={css.historySearch} value={historyQuery} onChange={event => { setHistoryQuery(event.target.value) }} placeholder={tt('history.search')} aria-label={tt('history.search')} />
1574
+ <select value={historyModelFilter} onChange={event => { setHistoryModelFilter(event.target.value) }} aria-label={tt('history.model')}>
1575
+ <option value="all">{tt('history.allModels')}</option>
1576
+ {[...new Set(history.flatMap(entry => modelsOfHistoryEntry(entry)))].map(option => <option key={option} value={option}>{option}</option>)}
1577
+ </select>
1578
+ <select value={historyRatioFilter} onChange={event => { setHistoryRatioFilter(event.target.value) }} aria-label={tt('history.ratio')}>
1579
+ <option value="all">{tt('history.allRatios')}</option>
1580
+ {[...new Set(history.map(entry => normalizeSize(entry.size)))].map(option => <option key={option} value={option}>{option}</option>)}
1581
+ </select>
1582
+ </div>
1583
+
1584
+ {filteredHistory.length === 0 ? (
1585
+ <div className={css.historyEmpty}>{tt('history.empty')}</div>
1586
+ ) : (
1587
+ <div className={css.historyList}>
1588
+ {filteredHistory.map(group => {
1589
+ const entry = group.entries[0]!
1590
+ const isComparison = group.models.length > 1
1591
+ const imageCount = group.entries.reduce((total, item) => total + item.images.length, 0)
1592
+ return (
1593
+ <div
1594
+ key={group.key}
1595
+ className={css.historyItem}
1596
+ data-active={group.entries.some(item => item.id === viewingHistoryId) ? '' : undefined}
1597
+ data-comparison={isComparison ? '' : undefined}
1598
+ >
1599
+ <button
1600
+ type="button"
1601
+ className={css.historyMain}
1602
+ data-dsh-imagegen-history-main=""
1603
+ onClick={() => { void viewHistoryGroup(group) }}
1604
+ >
1605
+ {entry.images.length > 0 ? (
1606
+ <img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
1607
+ ) : (
1608
+ <span className={css.historyThumbPlaceholder} />
1609
+ )}
1610
+ <span className={css.historyInfo}>
1611
+ <span className={css.historyPrompt}>{entry.prompt}</span>
1612
+ <span className={css.historyMeta}>
1613
+ {isComparison
1614
+ ? tt('compare.title')
1615
+ : entry.workflow === 'ecommerce'
1616
+ ? `${tt('ecommerce.short')}${entry.projectName !== undefined && entry.projectName !== '' ? ` · ${entry.projectName}` : ''}`
1617
+ : tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
1618
+ {' · '}{isComparison ? group.models.join(' · ') : entry.model}
1619
+ {' · '}{formatTime(entry.createdAt)}
1620
+ {' · '}{imageCount} {tt('history.images')}
1621
+ </span>
1622
+ </span>
1623
+ </button>
1624
+ <span className={css.historyActions}>
1625
+ {entry.images.length > 0 ? (
1626
+ <button
1627
+ type="button"
1628
+ className={css.historyAction}
1629
+ disabled={galleryAdding}
1630
+ title={tt('gallery.add')}
1631
+ onClick={() => { void addHistoryEntryToGallery(entry) }}
1632
+ >
1633
+ {tt('gallery.add')}
1634
+ </button>
1635
+ ) : null}
1636
+ <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryGroup(group) }}>
1637
+ {tt('history.restore')}
1638
+ </button>
1639
+ <button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryGroup(group) }}>
1640
+ {tt('history.delete')}
1641
+ </button>
1642
+ </span>
1643
+ </div>
1644
+ )
1645
+ })}
1646
+ </div>
1647
+ )}
1648
+ </aside>
1649
+ )
1650
+
1651
+ return (
1069
1652
  <div className={css.panel}>
1070
1653
  <header className={css.panelHeader}>
1071
1654
  <span className={css.panelHeading}>
@@ -1081,15 +1664,34 @@ export function ImageGenPanel(props: {
1081
1664
  <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
1665
  </a>
1083
1666
  </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>
1667
+ <nav className={css.topNav} role="tablist" aria-label={tt('workspace.label')}>
1668
+ <button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab !== 'gallery' ? '' : undefined} onClick={() => { if (workspace !== 'normal' || tab === 'gallery') openTab('text') }}>{tt('workspace.normal')}</button>
1669
+ <button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab === 'gallery' ? '' : undefined} onClick={() => { openTab('gallery') }}>{tt('gallery.title')}</button>
1670
+ <span className={css.topNavDivider} aria-hidden="true" />
1671
+ <button type="button" className={css.topNavItem} data-active={workspace === 'ecommerce' ? '' : undefined} onClick={() => { setWorkspace('ecommerce') }}>{tt('workspace.ecommerce')}<span className={css.previewBadge}>{tt('ecommerce.badge')}</span></button>
1672
+ </nav>
1673
+ <span className={css.panelHeaderActions}>
1674
+ <button
1675
+ type="button"
1676
+ className={css.chatToggle}
1677
+ data-open={chatOpen ? 'true' : 'false'}
1678
+ aria-pressed={chatOpen}
1679
+ title={chatOpen ? tt('chat.collapse') : tt('chat.expand')}
1680
+ onClick={() => { setChatOpen(open => !open) }}
1681
+ >
1682
+ <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 3.5h11v7.5H6.8L3.8 13.6v-2.6H2.5z"/></svg>
1683
+ {tt('chat.toggle')}
1684
+ </button>
1685
+ <button
1686
+ type="button"
1687
+ className={css.connectionStatus}
1688
+ data-connected={connected ? 'true' : 'false'}
1689
+ aria-label={tt(connected ? 'connection.connected' : 'connection.disconnected')}
1690
+ >
1691
+ <span className={css.connectionDot} aria-hidden="true" />
1692
+ {tt(connected ? 'connection.connected' : 'connection.disconnected')}
1693
+ </button>
1694
+ </span>
1093
1695
  </header>
1094
1696
 
1095
1697
  {update !== null ? (
@@ -1106,12 +1708,33 @@ export function ImageGenPanel(props: {
1106
1708
  </div>
1107
1709
  ) : null}
1108
1710
 
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' ? (
1711
+ <div className={css.studio}>
1712
+ {/* ------------------------------- left history + generation workspace */}
1713
+ <div className={css.generation}>
1714
+ {/* ------------------------------------------------ config sidebar */}
1715
+ <aside
1716
+ ref={configAsideRef}
1717
+ className={css.config}
1718
+ style={{ '--dsh-imagegen-config-width': `${configWidth}px` } as CSSProperties}
1719
+ data-collapsed={configCollapsed ? 'true' : 'false'}
1720
+ data-gallery={workspace === 'normal' && tab === 'gallery' ? 'true' : undefined}
1721
+ >
1722
+ <div className={css.configResizer} title={tt('config.resizeHint')} onPointerDown={onConfigResizeStart} />
1723
+ <div className={css.configHeader}>
1724
+ <button
1725
+ type="button"
1726
+ className={css.configToggle}
1727
+ aria-expanded={!configCollapsed}
1728
+ aria-label={tt(configCollapsed ? 'panel.expandConfig' : 'panel.collapseConfig')}
1729
+ title={tt(configCollapsed ? 'panel.expandConfig' : 'panel.collapseConfig')}
1730
+ onClick={() => { setConfigCollapsed(previous => !previous) }}
1731
+ >
1732
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1733
+ <path d={configCollapsed ? 'M6 3l5 5-5 5' : 'M10 3L5 8l5 5'} />
1734
+ </svg>
1735
+ </button>
1736
+ </div>
1737
+ {workspace === 'normal' && tab === 'gallery' ? (
1115
1738
  <div className={css.galleryFilters}>
1116
1739
  <div className={css.galleryFilterHeading}>{tt('gallery.categories')}</div>
1117
1740
  {[
@@ -1158,16 +1781,182 @@ export function ImageGenPanel(props: {
1158
1781
  </div>
1159
1782
  ) : null}
1160
1783
  <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>
1784
+ {/* generation sub-modes live inside the normal workspace */}
1785
+ {workspace === 'normal' && tab !== 'gallery' ? (
1786
+ <section className={css.card}>
1787
+ <div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
1788
+ <Pill active={tab === 'text'} onClick={() => { setTab('text') }} className={css.modePill}>{tt('mode.text')}</Pill>
1789
+ <Pill active={tab === 'edit'} onClick={() => { setTab('edit') }} className={css.modePill}>{tt('mode.edit')}</Pill>
1790
+ </div>
1791
+ </section>
1792
+ ) : null}
1793
+
1794
+ {workspace === 'ecommerce' ? (
1795
+ <section className={css.ecommerceWorkspace} data-ecommerce-workspace="">
1796
+ <div className={css.ecommerceSection}>
1797
+ <h3>{tt('ecommerce.product')}</h3>
1798
+ <label className={css.ecommerceField}>
1799
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.productName')}</span>
1800
+ <input value={ecommerce.productName} placeholder={tt('ecommerce.productName')} onChange={event => setEcommerce(previous => ({ ...previous, productName: event.target.value }))} />
1801
+ </label>
1802
+ <label className={css.ecommerceField}>
1803
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.projectName')}</span>
1804
+ <input value={ecommerce.projectName} placeholder={tt('ecommerce.projectName')} onChange={event => setEcommerce(previous => ({ ...previous, projectName: event.target.value }))} />
1805
+ </label>
1806
+ {ecommerceAssets.length === 0 ? (
1807
+ <button
1808
+ type="button"
1809
+ className={css.ecommerceUploadHero}
1810
+ data-ecommerce-upload=""
1811
+ onClick={() => { ecommerceFileInput.current?.click() }}
1812
+ onDragOver={(event) => { event.preventDefault() }}
1813
+ onDrop={(event) => {
1814
+ event.preventDefault()
1815
+ acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
1816
+ }}
1817
+ >
1818
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10V3.5"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M3 9.5V12a1.5 1.5 0 001.5 1.5h7A1.5 1.5 0 0013 12V9.5"/></svg>
1819
+ <span>{tt('ecommerce.uploadRef')}</span>
1820
+ <small>{tt('edit.uploadHint')}</small>
1821
+ </button>
1822
+ ) : (
1823
+ <div className={css.ecommerceAssets}>
1824
+ {ecommerceAssets.map(asset => (
1825
+ <div key={asset.id} className={css.ecommerceAsset} data-ecommerce-asset="">
1826
+ <img src={asset.dataUrl} alt={asset.name} />
1827
+ <select
1828
+ value={asset.role}
1829
+ data-ecommerce-asset-role=""
1830
+ aria-label={tt('ecommerce.refSelect')}
1831
+ onChange={event => setEcommerceAssets(previous => previous.map(item => item.id === asset.id ? { ...item, role: event.target.value as EcommerceAssetRole } : item))}
1832
+ >
1833
+ {ECOMMERCE_ASSET_ROLES.map(role => (
1834
+ <option key={role} value={role}>{tt(`ecommerce.role.${role}` as never)}</option>
1835
+ ))}
1836
+ </select>
1837
+ <button type="button" aria-label={tt('edit.remove')} onClick={() => { setEcommerceAssets(previous => previous.filter(item => item.id !== asset.id)) }}>×</button>
1838
+ </div>
1839
+ ))}
1840
+ {ecommerceAssets.length < MAX_ECOMMERCE_ASSETS ? (
1841
+ <button
1842
+ type="button"
1843
+ className={css.ecommerceAssetAdd}
1844
+ data-ecommerce-upload=""
1845
+ title={tt('ecommerce.uploadRef')}
1846
+ onClick={() => { ecommerceFileInput.current?.click() }}
1847
+ onDragOver={(event) => { event.preventDefault() }}
1848
+ onDrop={(event) => {
1849
+ event.preventDefault()
1850
+ acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
1851
+ }}
1852
+ >
1853
+ <span aria-hidden="true">+</span>
1854
+ <small>{tt('ecommerce.uploadShort')}</small>
1855
+ </button>
1856
+ ) : null}
1857
+ </div>
1858
+ )}
1859
+ </div>
1860
+ <div className={css.ecommerceSection}>
1861
+ <h3>{tt('ecommerce.params')}</h3>
1862
+ <div className={css.ecommerceParamGrid}>
1863
+ <label className={css.ecommerceField}>
1864
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.platformLabel')}</span>
1865
+ <select value={ecommerce.platform} onChange={event => setEcommerce(previous => ({ ...previous, platform: event.target.value }))}><option>通用</option><option>淘宝 / 京东</option><option>Amazon</option></select>
1866
+ </label>
1867
+ <label className={css.ecommerceField}>
1868
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.languageLabel')}</span>
1869
+ <select value={ecommerce.language} onChange={event => setEcommerce(previous => ({ ...previous, language: event.target.value }))}><option>中文</option><option>English</option></select>
1870
+ </label>
1871
+ <label className={css.ecommerceField}>
1872
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
1873
+ <select value={ecommerce.size} onChange={event => setEcommerce(previous => ({ ...previous, size: event.target.value }))}>{SIZES.filter(size => size !== 'auto').map(size => <option key={size}>{size}</option>)}</select>
1874
+ </label>
1875
+ <label className={css.ecommerceField}>
1876
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.categoryLabel')}</span>
1877
+ <select value={ecommerce.category} onChange={event => setEcommerce(previous => ({ ...previous, category: event.target.value }))}><option>通用商品</option><option>食品饮料</option><option>美妆个护</option><option>服装配饰</option><option>家居用品</option><option>3C 数码</option></select>
1878
+ </label>
1879
+ </div>
1880
+ </div>
1881
+ <div className={css.ecommerceSection}>
1882
+ <h3>{tt('ecommerce.sellingTitle')}</h3>
1883
+ <textarea value={ecommerce.sellingPoints} placeholder={tt('ecommerce.sellingPoints')} onChange={event => setEcommerce(previous => ({ ...previous, sellingPoints: event.target.value }))} />
1884
+ </div>
1885
+ <div className={css.ecommerceSection}>
1886
+ <h3>{tt('ecommerce.setStructure')}<small className={css.ecommerceSectionHint}>{tt('ecommerce.multiSelect')}</small></h3>
1887
+ <div className={css.ecommerceStructureGrid}>
1888
+ {ecommerce.slots.map(slot => (
1889
+ <button
1890
+ key={slot.key}
1891
+ type="button"
1892
+ className={css.ecommerceSlotCard}
1893
+ data-active={slot.enabled ? '' : undefined}
1894
+ title={`${slot.label}:${slot.description}`}
1895
+ onClick={() => setEcommerce(previous => ({ ...previous, slots: previous.slots.map(item => item.key === slot.key ? { ...item, enabled: !item.enabled } : item) }))}
1896
+ >
1897
+ {slot.label}
1898
+ {slot.enabled ? (
1899
+ <span
1900
+ className={css.ecommerceSlotCount}
1901
+ title={tt('ecommerce.countHint')}
1902
+ onClick={event => {
1903
+ event.stopPropagation()
1904
+ setEcommerce(previous => ({ ...previous, slots: previous.slots.map(item => item.key === slot.key ? { ...item, count: item.count >= 4 ? 1 : item.count + 1 } : item) }))
1905
+ }}
1906
+ >
1907
+ {slot.count}
1908
+ </span>
1909
+ ) : null}
1910
+ </button>
1911
+ ))}
1912
+ </div>
1913
+ {ecommerceSlots.length > 0 ? (
1914
+ <>
1915
+ <button type="button" className={css.ecommerceAdvancedToggle} aria-expanded={ecommerceRefOpen} onClick={() => { setEcommerceRefOpen(open => !open) }}>
1916
+ {tt('ecommerce.refSettings')}
1917
+ <span className={css.ecommerceAdvancedChevron} aria-hidden="true">{ecommerceRefOpen ? '⌃' : '⌄'}</span>
1918
+ </button>
1919
+ {ecommerceRefOpen ? (
1920
+ <div className={css.ecommerceAdvancedBody}>
1921
+ {ecommerceSlots.map(slot => (
1922
+ <label key={slot.key} className={css.ecommerceRefRow}>
1923
+ <span>{slot.label}</span>
1924
+ <select value={slot.refRole ?? 'product'} data-ecommerce-ref-select="" aria-label={`${tt('ecommerce.refSelect')} · ${slot.label}`} onChange={event => setEcommerce(previous => ({ ...previous, slots: previous.slots.map(item => item.key === slot.key ? { ...item, refRole: event.target.value as EcommerceRefRole } : item) }))}>
1925
+ <option value="none">{tt('ecommerce.refNone')}</option>
1926
+ {ECOMMERCE_ASSET_ROLES.map(role => <option key={role} value={role}>{tt(`ecommerce.role.${role}` as never)}</option>)}
1927
+ </select>
1928
+ </label>
1929
+ ))}
1930
+ </div>
1931
+ ) : null}
1932
+ </>
1933
+ ) : null}
1934
+ </div>
1935
+ <div className={css.ecommerceSection}>
1936
+ <h3>{tt('ecommerce.generation')}</h3>
1937
+ <select value={modeModels.includes(model) ? model : modeModels[0] ?? ''} aria-label={tt('model.label')} onChange={event => setModel(event.target.value)}>{modeModels.map(option => <option key={option} value={option}>{option}</option>)}</select>
1938
+ <div className={css.optionRow}>{QUALITIES.map(option => <Pill key={option} active={quality === option} onClick={() => { setQuality(option) }} className={css.optionPill}>{tt(`quality.${option}` as const)}</Pill>)}</div>
1939
+ </div>
1940
+ <div className={css.ecommerceSection}>
1941
+ <h3>{tt('ecommerce.styleTitle')}</h3>
1942
+ <textarea value={ecommerce.styleHint} placeholder={tt('ecommerce.styleHint')} onChange={event => setEcommerce(previous => ({ ...previous, styleHint: event.target.value }))} />
1943
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.protectedLabel')}</span>
1944
+ <textarea value={ecommerce.protectedFeatures} placeholder={tt('ecommerce.protectedFeatures')} onChange={event => setEcommerce(previous => ({ ...previous, protectedFeatures: event.target.value }))} />
1945
+ </div>
1946
+ <input
1947
+ ref={ecommerceFileInput}
1948
+ type="file"
1949
+ multiple
1950
+ accept="image/png,image/jpeg,image/webp,image/gif"
1951
+ className={css.hiddenFile}
1952
+ onChange={(event) => {
1953
+ acceptEcommerceFiles(event.target.files ?? undefined)
1954
+ event.target.value = ''
1955
+ }}
1956
+ />
1957
+ </section>
1958
+ ) : null}
1169
1959
 
1170
- {/* reference image (edit mode) */}
1171
1960
  {tab === 'edit' ? (
1172
1961
  <section className={css.card}>
1173
1962
  {refImage === null
@@ -1215,7 +2004,8 @@ export function ImageGenPanel(props: {
1215
2004
  </section>
1216
2005
  ) : null}
1217
2006
 
1218
- {/* prompt */}
2007
+ {/* prompt (normal workspace only — ecommerce has its own form) */}
2008
+ {workspace === 'normal' ? (<>
1219
2009
  <section className={css.card}>
1220
2010
  <textarea
1221
2011
  className={css.prompt}
@@ -1310,12 +2100,36 @@ export function ImageGenPanel(props: {
1310
2100
  <span className={css.paramHint}>{tt('detail.hint')}</span>
1311
2101
  </div>
1312
2102
  </section>
2103
+ </>) : null}
1313
2104
  </div>
1314
2105
 
1315
2106
  {/* footer: model + generate — a fixed sibling of the scroll area, so
1316
2107
  it never overlaps the cards scrolling above it. */}
1317
2108
  <section className={css.footer}>
1318
- <label className={css.modelWrap}>
2109
+ {workspace === 'ecommerce' ? (
2110
+ <div className={css.ecommerceFooterBody}>
2111
+ {ecommercePreview ? (
2112
+ <>
2113
+ <div className={css.ecommercePlanMini}>
2114
+ <strong>{tt('ecommerce.planTitle', { count: ecommerceTotal })}</strong>
2115
+ <div className={css.ecommercePlanList}>
2116
+ {ecommerceSlots.map(slot => <div key={slot.key}><span>{slot.label}</span><span>×{slot.count}</span></div>)}
2117
+ </div>
2118
+ <div className={css.ecommercePlanNote}>{tt('ecommerce.anchorNote')}</div>
2119
+ {ecommerceAssets.length === 0 ? <div className={css.ecommercePlanWarn}>{tt('ecommerce.noAssetWarn')}</div> : null}
2120
+ </div>
2121
+ <Button variant="primary" size="md" className={css.ecommercePrimaryAction} disabled={ecommerceGenerateDisabled} onClick={() => { void handleEcommerceGenerate() }}>{ecommerceGenerating ? tt('generating') : tt('ecommerce.confirm')}</Button>
2122
+ <button type="button" className={css.ecommercePlanBack} onClick={() => { setEcommercePreview(false) }}>{tt('gallery.tagsCancel')}</button>
2123
+ </>
2124
+ ) : (
2125
+ <>
2126
+ <span className={css.ecommerceFooterHint}>{ecommerceTotal > 0 ? tt('ecommerce.footerReady', { count: ecommerceTotal }) : tt('ecommerce.footerEmpty')}</span>
2127
+ <Button variant="primary" size="md" className={css.ecommercePrimaryAction} disabled={ecommerce.productName.trim() === '' || ecommerceTotal === 0} onClick={() => setEcommercePreview(true)}>{tt('ecommerce.preview')}</Button>
2128
+ </>
2129
+ )}
2130
+ </div>
2131
+ ) : null}
2132
+ {workspace === 'ecommerce' ? null : <label className={css.modelWrap}>
1319
2133
  <span className={css.modelLabel}>{tt('model.label')}</span>
1320
2134
  <span ref={modelMenuRef} className={css.modelMenu} data-open={modelOpen ? 'true' : 'false'}>
1321
2135
  <button
@@ -1347,8 +2161,8 @@ export function ImageGenPanel(props: {
1347
2161
  </div>
1348
2162
  ) : null}
1349
2163
  </span>
1350
- </label>
1351
- <div className={css.compareControl}>
2164
+ </label>}
2165
+ {workspace !== 'ecommerce' ? <div className={css.compareControl}>
1352
2166
  <label className={css.compareToggle}>
1353
2167
  <input type="checkbox" checked={compareEnabled} onChange={event => { setCompareEnabled(event.target.checked) }} />
1354
2168
  <span>{tt('compare.enable')}</span>
@@ -1363,8 +2177,8 @@ export function ImageGenPanel(props: {
1363
2177
  ))}
1364
2178
  </div>
1365
2179
  ) : null}
1366
- </div>
1367
- <Button
2180
+ </div> : null}
2181
+ {workspace !== 'ecommerce' ? <Button
1368
2182
  variant="primary"
1369
2183
  size="md"
1370
2184
  className={css.generateButton}
@@ -1377,13 +2191,13 @@ export function ImageGenPanel(props: {
1377
2191
  {tt('generating')}
1378
2192
  </span>
1379
2193
  ) : tt('generate')}
1380
- </Button>
2194
+ </Button> : null}
1381
2195
  </section>
1382
- </aside>
1383
-
1384
- {/* ------------------------------------------------------- canvas */}
1385
- <section className={css.canvas} data-gallery={tab === 'gallery' ? 'true' : undefined}>
1386
- {tab === 'gallery' ? (
2196
+ </aside>
2197
+
2198
+ {/* ------------------------------------------------------- canvas */}
2199
+ <section className={css.canvas} data-gallery={workspace === 'normal' && tab === 'gallery' ? 'true' : undefined}>
2200
+ {workspace === 'normal' && tab === 'gallery' ? (
1387
2201
  <div className={css.galleryWorkspace}>
1388
2202
  <header className={css.galleryToolbar}>
1389
2203
  <div>
@@ -1407,7 +2221,7 @@ export function ImageGenPanel(props: {
1407
2221
  <option value="newest">{tt('gallery.newest')}</option>
1408
2222
  <option value="oldest">{tt('gallery.oldest')}</option>
1409
2223
  </select>
1410
- {gallery.length > 0 ? <button type="button" className={css.galleryClear} onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
2224
+ {gallery.length > 0 ? <button type="button" className={css.galleryClear} data-gallery-clear="" onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
1411
2225
  </div>
1412
2226
  </header>
1413
2227
  {selectedGalleryIds.size > 0 ? (
@@ -1436,6 +2250,19 @@ export function ImageGenPanel(props: {
1436
2250
  <img className={css.galleryImage} src={image.url} alt={entry.prompt} />
1437
2251
  <span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
1438
2252
  </button>
2253
+ <div className={css.galleryCardActions}>
2254
+ <button
2255
+ type="button"
2256
+ className={css.galleryCardAction}
2257
+ data-gallery-add-conversation=""
2258
+ disabled={conversationBusy}
2259
+ title={tt('conversation.addHint')}
2260
+ onClick={(event) => { event.stopPropagation(); void addGalleryEntryToConversation(entry) }}
2261
+ >
2262
+ <svg viewBox="0 0 16 16" width="13" height="13" 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>
2263
+ {galleryConversationAddingId === entry.id || addingToConversation === `gallery:${entry.id}` ? tt('conversation.adding') : tt('conversation.add')}
2264
+ </button>
2265
+ </div>
1439
2266
  <div className={css.galleryCardFooter}>
1440
2267
  <span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
1441
2268
  <span className={css.galleryCardInfo}>
@@ -1462,7 +2289,75 @@ export function ImageGenPanel(props: {
1462
2289
  )}
1463
2290
  </div>
1464
2291
  ) : null}
1465
- {tab !== 'gallery' && tasks.length > 0 ? (
2292
+ {workspace === 'ecommerce' ? (
2293
+ <div className={css.ecommerceResults} data-ecommerce-results="">
2294
+ <header className={css.ecommerceResultsHeader}>
2295
+ <div>
2296
+ <h3>{tt('ecommerce.results.title')}</h3>
2297
+ {ecommerceRestored !== null && ecommerceRestored.projectId === ecommerceProjectId && ecommerceRestored.projectName !== '' ? (
2298
+ <span>{ecommerceRestored.projectName}</span>
2299
+ ) : null}
2300
+ {ecommerceMergedItems.length > 0 ? (
2301
+ <span>
2302
+ {tt('ecommerce.results.progress', { done: ecommerceDoneCount, total: ecommerceMergedItems.length })}
2303
+ {ecommerceFailedCount > 0 ? ` · ${tt('ecommerce.results.failed', { count: ecommerceFailedCount })}` : ''}
2304
+ </span>
2305
+ ) : null}
2306
+ {ecommerceAnchor !== null ? <span data-ecommerce-anchor="">{tt('ecommerce.anchorPending')}</span> : null}
2307
+ </div>
2308
+ <div className={css.ecommerceResultsActions}>
2309
+ {ecommerceMergedItems.length > 0 ? <button type="button" className={css.galleryBulkButton} data-ecommerce-export="" onClick={exportEcommerceManifest}>{tt('ecommerce.results.export')}</button> : null}
2310
+ <button type="button" className={css.galleryBulkButton} data-ecommerce-new="" onClick={newEcommerceProduct}>{tt('ecommerce.results.newProduct')}</button>
2311
+ </div>
2312
+ </header>
2313
+ {ecommerceMergedItems.length === 0 ? (
2314
+ <div className={css.ecommerceResultsEmpty}>{tt('ecommerce.results.empty')}</div>
2315
+ ) : (
2316
+ <div className={css.ecommerceGroups}>
2317
+ {ecommerceResultGroups.map(group => (
2318
+ <section key={group.label} className={css.ecommerceGroup} data-ecommerce-group={group.label}>
2319
+ <header>
2320
+ <strong>{group.label}</strong>
2321
+ <span>{group.items.filter(item => item.status === 'completed').length}/{group.items.length}</span>
2322
+ <button type="button" className={css.galleryBulkButton} disabled={ecommerceGenerating} onClick={() => { void regenerateEcommerceSlot(group.label) }}>{tt('ecommerce.results.regenerate')}</button>
2323
+ </header>
2324
+ <div className={css.ecommerceGroupGrid}>
2325
+ {group.items.map(item => (
2326
+ <div key={item.id} className={css.ecommerceTaskCard} data-status={item.status}>
2327
+ {item.status === 'completed' && item.images.length > 0 ? item.images.map((image, imageIndex) => (
2328
+ <figure
2329
+ key={imageIndex}
2330
+ className={css.imageCard}
2331
+ role="button"
2332
+ tabIndex={0}
2333
+ title={tt('preview.open')}
2334
+ onClick={() => { openPreview(item.images, imageIndex) }}
2335
+ >
2336
+ <img className={css.image} src={srcOf(image)} alt={`${group.label} ${imageIndex + 1}`} />
2337
+ <span className={css.ecommerceResultBadge}>{group.label}</span>
2338
+ <span className={css.ecommerceTaskActions} onClick={event => event.stopPropagation()}>
2339
+ <a className={css.ecommerceActionChip} href={srcOf(image)} download={`product-${item.slotKey || item.id}-${imageIndex + 1}.${extensionOf(image.mime)}`}>{tt('download')}</a>
2340
+ <button type="button" className={css.ecommerceActionChip} disabled={galleryAdding} onClick={() => { void addToGallery(image) }}>{tt('gallery.add')}</button>
2341
+ <button type="button" className={css.ecommerceActionChip} disabled={conversationBusy} onClick={() => { void addImageToConversation(image, imageIndex, `${item.id}:${imageIndex}`) }}>{addingToConversation === `${item.id}:${imageIndex}` ? tt('conversation.adding') : tt('conversation.add')}</button>
2342
+ </span>
2343
+ </figure>
2344
+ )) : (
2345
+ <span className={css.ecommerceTaskState}>
2346
+ <b>{group.label}</b>
2347
+ {tt(`tasks.${item.status}` as never)}
2348
+ {item.error !== undefined ? ` · ${item.error}` : ''}
2349
+ </span>
2350
+ )}
2351
+ </div>
2352
+ ))}
2353
+ </div>
2354
+ </section>
2355
+ ))}
2356
+ </div>
2357
+ )}
2358
+ </div>
2359
+ ) : null}
2360
+ {(workspace === 'ecommerce' || tab !== 'gallery') && tasks.length > 0 ? (
1466
2361
  <section className={css.taskTray} data-open={taskTrayOpen ? 'true' : 'false'} aria-label={tt('tasks.title')}>
1467
2362
  <header className={css.taskTrayHeader}>
1468
2363
  <button type="button" className={css.taskTrayToggle} aria-expanded={taskTrayOpen} onClick={() => { setTaskTrayOpen(open => !open) }}>
@@ -1484,7 +2379,7 @@ export function ImageGenPanel(props: {
1484
2379
  </div>
1485
2380
  </section>
1486
2381
  ) : null}
1487
- {tab !== 'gallery' && comparison !== null ? (
2382
+ {workspace === 'normal' && tab !== 'gallery' && comparison !== null ? (
1488
2383
  <section className={css.comparisonBoard} aria-label={tt('compare.title')}>
1489
2384
  <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
2385
  <div className={css.comparisonGrid}>
@@ -1510,7 +2405,7 @@ export function ImageGenPanel(props: {
1510
2405
  </div>
1511
2406
  </section>
1512
2407
  ) : null}
1513
- {generating && comparison === null ? (
2408
+ {generating && comparison === null && workspace !== 'ecommerce' ? (
1514
2409
  <div className={css.canvasState} data-generation-state={activeTask?.status ?? 'submitting'} role="status">
1515
2410
  <span className={css.bigSpinner} />
1516
2411
  <span className={css.canvasStateTitle}>
@@ -1532,7 +2427,7 @@ export function ImageGenPanel(props: {
1532
2427
  <div className={css.canvasError} role="alert">{tt('canvas.error', { error })}</div>
1533
2428
  ) : null}
1534
2429
 
1535
- {!generating && !error && images.length === 0 ? (
2430
+ {!generating && !error && images.length === 0 && workspace !== 'ecommerce' ? (
1536
2431
  <div className={css.canvasState}>
1537
2432
  <span className={css.canvasEmptyIcon}>
1538
2433
  <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>
@@ -1542,7 +2437,7 @@ export function ImageGenPanel(props: {
1542
2437
  </div>
1543
2438
  ) : null}
1544
2439
 
1545
- {!generating && images.length > 0 ? (
2440
+ {!generating && images.length > 0 && workspace !== 'ecommerce' ? (
1546
2441
  <div className={css.canvasBody}>
1547
2442
  <div className={css.canvasMeta}>
1548
2443
  <span>{tt('canvas.images', { count: images.length })}</span>
@@ -1584,27 +2479,27 @@ export function ImageGenPanel(props: {
1584
2479
  <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
2480
  {tt('preview.open')}
1586
2481
  </span>
1587
- <button
1588
- type="button"
1589
- className={css.galleryAdd}
2482
+ <button
2483
+ type="button"
2484
+ className={css.galleryAdd}
1590
2485
  title={tt('gallery.add')}
1591
2486
  disabled={galleryAdding}
1592
2487
  onClick={(event) => { event.stopPropagation(); void addToGallery(image) }}
1593
2488
  >
1594
2489
  <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>
1595
- {tt('gallery.add')}
1596
- </button>
1597
- <button
1598
- type="button"
1599
- className={css.conversationAdd}
1600
- title={tt('conversation.add')}
1601
- disabled={addingToConversation !== null}
1602
- onClick={(event) => { event.stopPropagation(); void addImageToConversation(image, index) }}
1603
- >
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')}
1606
- </button>
1607
- <a
2490
+ {tt('gallery.add')}
2491
+ </button>
2492
+ <button
2493
+ type="button"
2494
+ className={css.conversationAdd}
2495
+ title={tt('conversation.add')}
2496
+ disabled={conversationBusy}
2497
+ onClick={(event) => { event.stopPropagation(); void addImageToConversation(image, index) }}
2498
+ >
2499
+ <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>
2500
+ {addingToConversation === index ? tt('conversation.adding') : tt('conversation.add')}
2501
+ </button>
2502
+ <a
1608
2503
  className={css.download}
1609
2504
  href={srcOf(image)}
1610
2505
  download={`dsh-image-${index + 1}.${extensionOf(image.mime)}`}
@@ -1617,22 +2512,22 @@ export function ImageGenPanel(props: {
1617
2512
  </div>
1618
2513
  </div>
1619
2514
  ) : null}
1620
- </section>
1621
- </div>
1622
-
1623
- </div>
1624
-
1625
- {sidebarHistoryHost !== null && historyPanel !== null
1626
- ? createPortal(historyPanel, sidebarHistoryHost)
1627
- : null}
1628
-
1629
- {/* ------------------------------------------------ template library */}
2515
+ </section>
2516
+ </div>
2517
+
2518
+ </div>
2519
+
2520
+ {sidebarHistoryHost !== null && historyPanel !== null
2521
+ ? createPortal(historyPanel, sidebarHistoryHost)
2522
+ : null}
2523
+
2524
+ {/* ------------------------------------------------ template library */}
1630
2525
  {libraryOpen ? (
1631
2526
  <TemplateLibrary
1632
2527
  api={api}
1633
2528
  onClose={() => { setLibraryOpen(false) }}
1634
2529
  onUse={(text) => {
1635
- setTab('text')
2530
+ openTab('text')
1636
2531
  setPrompt(text)
1637
2532
  setError(null)
1638
2533
  setLibraryOpen(false)
@@ -1732,11 +2627,11 @@ export function ImageGenPanel(props: {
1732
2627
  ) : null}
1733
2628
  <div className={css.lightboxMeta}>
1734
2629
  <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) }}>
2630
+ <span className={css.lightboxActions}>
2631
+ <button type="button" className={css.lightboxEdit} disabled={conversationBusy} title={tt('conversation.addHint')} onClick={() => { void addImageToConversation(previewImage, preview.index) }}>
2632
+ {addingToConversation === preview.index ? tt('conversation.adding') : tt('conversation.add')}
2633
+ </button>
2634
+ <button type="button" className={css.lightboxEdit} disabled={galleryAdding} onClick={() => { void addToGallery(previewImage) }}>
1740
2635
  {tt('gallery.add')}
1741
2636
  </button>
1742
2637
  <button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
@@ -1758,19 +2653,19 @@ export function ImageGenPanel(props: {
1758
2653
  : null}
1759
2654
 
1760
2655
  {/* ------------------------------------------------- gallery toast */}
1761
- {galleryMessage !== null ? (
1762
- <div className={css.galleryToast} role="status">
2656
+ {galleryMessage !== null ? (
2657
+ <div className={css.galleryToast} role="status">
1763
2658
  <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>
1764
- {galleryMessage}
1765
- </div>
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}
1773
- </div>
2659
+ {galleryMessage}
2660
+ </div>
2661
+ ) : null}
2662
+ {conversationMessage !== null ? (
2663
+ <div className={css.conversationToast} role="status">
2664
+ <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>
2665
+ {conversationMessage}
2666
+ </div>
2667
+ ) : null}
2668
+ </div>
1774
2669
  )
1775
2670
  }
1776
2671