@dickpy/dsh-imagegen 1.4.0 → 1.5.0

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