@dickpy/dsh-imagegen 1.5.3 → 1.5.4

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.
@@ -17,7 +17,8 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
17
17
  import type { ImageGenApi } from './api.ts'
18
18
  import { errorMessage, tt } from './helpers.ts'
19
19
  import { TemplateLibrary } from './TemplateLibrary.tsx'
20
- import { InspirationGallery } from './InspirationGallery.tsx'
20
+ import { InspirationGallery } from './InspirationGallery.tsx'
21
+ import { CanvasWorkspace } from './CanvasWorkspace.tsx'
21
22
  import { useImageGenLanguageTick } from './use-language.ts'
22
23
  import type { EcommerceRefRole, GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, GenerationTaskStatus, HistoryEntry, HistoryImageRef, ProductSetDraft, ProductSetSlot, UpdateInfo } from '../protocol.ts'
23
24
  import { AGENT_IMAGE_API } from '../protocol.ts'
@@ -351,19 +352,38 @@ function formatTime(timestamp: number): string {
351
352
 
352
353
  function defaultEcommerceDraft(): ProductSetDraft {
353
354
  return {
354
- projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', size: '1:1',
355
+ projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', customLanguage: '', size: '1:1',
355
356
  productName: '', sellingPoints: '', protectedFeatures: '', styleHint: '',
356
357
  slots: PRODUCT_SET_SLOTS.map(slot => ({ ...slot })),
357
358
  }
358
359
  }
359
360
 
361
+ /** Standard copy-language choices; custom keeps uncommon locales usable. */
362
+ const ECOMMERCE_COPY_LANGUAGES = [
363
+ ['中文', '中文'],
364
+ ['English', 'English'],
365
+ ['Русский', 'Русский'],
366
+ ['日本語', '日本語'],
367
+ ['한국어', '한국어'],
368
+ ['Français', 'Français'],
369
+ ['Deutsch', 'Deutsch'],
370
+ ['Español', 'Español'],
371
+ ['Português', 'Português'],
372
+ ['custom', '自定义'],
373
+ ] as const
374
+
375
+ function effectiveEcommerceLanguage(draft: ProductSetDraft): string {
376
+ return draft.language === 'custom' ? draft.customLanguage?.trim() ?? '' : draft.language
377
+ }
378
+
360
379
  function ecommercePrompt(draft: ProductSetDraft, slot: ProductSetSlot): string {
361
380
  const points = draft.sellingPoints.trim() || '突出商品真实材质、结构和核心价值'
362
381
  const protectedFeatures = draft.protectedFeatures.trim() || '保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
382
+ const language = effectiveEcommerceLanguage(draft) || '中文'
363
383
  const refClause = slot.refRole !== undefined && slot.refRole !== 'none'
364
384
  ? `本图以上传的${ECOMMERCE_ROLE_PROMPT_LABELS[slot.refRole]}图片为参考,商品与风格必须与参考图保持一致;`
365
385
  : ''
366
- return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${draft.language}。商品卖点:${points}。必须遵守:${protectedFeatures}。${refClause}整体要求:商品主体清晰、比例真实、光线自然、画面干净、适合电商发布;${draft.styleHint.trim()}`
386
+ return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${language}。商品卖点:${points}。必须遵守:${protectedFeatures}。${refClause}整体要求:商品主体清晰、比例真实、光线自然、画面干净、适合电商发布;${draft.styleHint.trim()}`
367
387
  }
368
388
 
369
389
  /** Consistency prefix for slots generated after the main image exists. */
@@ -376,7 +396,7 @@ type PanelTab = GenerateMode | 'gallery'
376
396
 
377
397
  /** Top-level workspaces inside the panel. 'normal' is the classic studio;
378
398
  * more task-oriented modes (prototype, …) can join alongside 'ecommerce'. */
379
- type PanelWorkspace = 'normal' | 'ecommerce'
399
+ type PanelWorkspace = 'normal' | 'ecommerce' | 'canvas'
380
400
 
381
401
  type GalleryFilter = string
382
402
  type ComparisonSession = { taskIds: string[]; prompt: string; comparisonId: string }
@@ -464,13 +484,18 @@ export function ImageGenPanel(props: {
464
484
  const apiKeySet = (config?.channels ?? []).length > 0 ? channelKeySet : legacyKeySet
465
485
  const connected = enabled && configured && apiKeySet
466
486
 
467
- const [tab, setTab] = useState<PanelTab>('text')
468
- const [workspace, setWorkspace] = useState<PanelWorkspace>('normal')
487
+ const [tab, setTab] = useState<PanelTab>('text')
488
+ const [workspace, setWorkspace] = useState<PanelWorkspace>('normal')
489
+ const [canvasImportRequest, setCanvasImportRequest] = useState<{ source: 'history' | 'gallery'; entryId: string; imageIndex: number } | undefined>()
469
490
  /** Switch to a normal-generation tab, leaving any task workspace. */
470
- const openTab = (next: PanelTab): void => {
471
- setWorkspace('normal')
472
- setTab(next)
473
- }
491
+ const openTab = (next: PanelTab): void => {
492
+ setWorkspace('normal')
493
+ setTab(next)
494
+ }
495
+ const addEntryToCanvas = (source: 'history' | 'gallery', entryId: string, imageIndex = 0): void => {
496
+ setCanvasImportRequest({ source, entryId, imageIndex })
497
+ setWorkspace('canvas')
498
+ }
474
499
  const [prompt, setPrompt] = useState('')
475
500
  const [size, setSize] = useState<string>('auto')
476
501
  const [quality, setQuality] = useState<string>('auto')
@@ -484,6 +509,7 @@ export function ImageGenPanel(props: {
484
509
  const [images, setImages] = useState<GeneratedImage[]>([])
485
510
  const [addingToConversation, setAddingToConversation] = useState<number | string | null>(null)
486
511
  const [galleryConversationAddingId, setGalleryConversationAddingId] = useState<string | null>(null)
512
+ const [historyConversationAddingId, setHistoryConversationAddingId] = useState<string | null>(null)
487
513
  const [conversationMessage, setConversationMessage] = useState<string | null>(null)
488
514
  const [error, setError] = useState<string | null>(null)
489
515
  // Submission is brief; actual generation stays visible until the host
@@ -919,7 +945,7 @@ export function ImageGenPanel(props: {
919
945
  ...(asset !== undefined ? { image: asset.dataUrl, refName: asset.name } : {}),
920
946
  workflow: 'ecommerce' as const,
921
947
  projectId,
922
- projectName: ecommerce.projectName.trim() || ecommerce.productName.trim(),
948
+ projectName: ecommerce.productName.trim(),
923
949
  slotKey: `${slot.key}-${index + 1}`,
924
950
  slotLabel: slot.label,
925
951
  }
@@ -1135,38 +1161,6 @@ export function ImageGenPanel(props: {
1135
1161
  }
1136
1162
  }
1137
1163
 
1138
- /** Restore one comparison group, including its selected model set. */
1139
- const restoreHistoryGroup = async (group: HistoryGroup): Promise<void> => {
1140
- const entry = group.entries[0]
1141
- if (entry === undefined) return
1142
- // The product-set form draft cannot be rebuilt from a compiled prompt, so
1143
- // restoring a product set reopens its grouped results canvas.
1144
- if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) {
1145
- await viewEcommerceProject(group)
1146
- return
1147
- }
1148
- try {
1149
- const restored = await loadHistoryGroup(group)
1150
- openTab(entry.mode)
1151
- setPrompt(entry.prompt)
1152
- setSize(normalizeSize(entry.size))
1153
- setQuality(normalizeQuality(entry.quality))
1154
- setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
1155
- setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
1156
- setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
1157
- setCompareModels(group.models.filter(candidate => imageModels.includes(candidate)))
1158
- setCompareEnabled(group.models.filter(candidate => imageModels.includes(candidate)).length > 1)
1159
- setRefImage(null)
1160
- setImages(restored)
1161
- setComparison(null)
1162
- setError(null)
1163
- setViewingHistoryId(entry.id)
1164
- setGalleryViewingId(null)
1165
- } catch (caught) {
1166
- setError(errorMessage(caught))
1167
- }
1168
- }
1169
-
1170
1164
  /** Remove every persisted row belonging to one comparison group. */
1171
1165
  const deleteHistoryGroup = async (group: HistoryGroup): Promise<void> => {
1172
1166
  const ids = new Set(group.entries.map(entry => entry.id))
@@ -1314,6 +1308,21 @@ export function ImageGenPanel(props: {
1314
1308
  }
1315
1309
  }
1316
1310
 
1311
+ /** Add one history entry's first image to the current chat draft. */
1312
+ const addHistoryEntryToConversation = async (entry: HistoryEntry): Promise<void> => {
1313
+ if (historyConversationAddingId !== null || addingToConversation !== null || galleryConversationAddingId !== null || entry.images.length === 0) return
1314
+ setHistoryConversationAddingId(entry.id)
1315
+ try {
1316
+ const [image] = await historyImagesToGenerated(entry.images.slice(0, 1))
1317
+ if (image === undefined) return
1318
+ await addImageToConversation(image, 0, `history:${entry.id}`)
1319
+ } catch (caught) {
1320
+ setError(errorMessage(caught))
1321
+ } finally {
1322
+ setHistoryConversationAddingId(null)
1323
+ }
1324
+ }
1325
+
1317
1326
  /** Load a persisted gallery image and add it to the current chat draft. */
1318
1327
  const addGalleryEntryToConversation = async (entry: HistoryEntry): Promise<void> => {
1319
1328
  if (galleryConversationAddingId !== null || addingToConversation !== null || entry.images.length === 0) return
@@ -1343,27 +1352,6 @@ export function ImageGenPanel(props: {
1343
1352
  }
1344
1353
  }
1345
1354
 
1346
- /** Restore a gallery entry's parameters (and its images) into the form. */
1347
- const restoreGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
1348
- try {
1349
- const restored = await historyImagesToGenerated(entry.images)
1350
- openTab(entry.mode)
1351
- setPrompt(entry.prompt)
1352
- setSize(normalizeSize(entry.size))
1353
- setQuality(normalizeQuality(entry.quality))
1354
- setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
1355
- setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
1356
- setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
1357
- setRefImage(null)
1358
- setImages(restored)
1359
- setError(null)
1360
- setViewingHistoryId(null)
1361
- setGalleryViewingId(null)
1362
- } catch (caught) {
1363
- setError(errorMessage(caught))
1364
- }
1365
- }
1366
-
1367
1355
  /** Remove one gallery entry. */
1368
1356
  const deleteGalleryEntry = async (id: string): Promise<void> => {
1369
1357
  setGallery(gallery.filter(entry => entry.id !== id))
@@ -1454,7 +1442,7 @@ export function ImageGenPanel(props: {
1454
1442
  const generateDisabled = submitting || modeModels.length === 0
1455
1443
  const ecommerceSlots = ecommerce.slots.filter(slot => slot.enabled && slot.count > 0)
1456
1444
  const ecommerceTotal = ecommerceSlots.reduce((total, slot) => total + slot.count, 0)
1457
- const ecommerceGenerateDisabled = submitting || ecommerceGenerating || ecommerceSlots.length === 0 || ecommerce.productName.trim() === ''
1445
+ const ecommerceGenerateDisabled = submitting || ecommerceGenerating || ecommerceSlots.length === 0 || ecommerce.productName.trim() === '' || (ecommerce.language === 'custom' && effectiveEcommerceLanguage(ecommerce) === '')
1458
1446
  const ecommerceFileInput = useRef<HTMLInputElement>(null)
1459
1447
  // The results canvas merges live tasks of the active project with restored
1460
1448
  // history entries of the same project; restored slots that were regenerated
@@ -1484,7 +1472,7 @@ export function ImageGenPanel(props: {
1484
1472
  const ecommerceResultGroups = [...new Set(ecommerceMergedItems.map(item => item.label))]
1485
1473
  .filter(label => label !== '')
1486
1474
  .map(label => ({ label, items: ecommerceMergedItems.filter(item => item.label === label) }))
1487
- const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null
1475
+ const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null || historyConversationAddingId !== null
1488
1476
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
1489
1477
  const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
1490
1478
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
@@ -1567,6 +1555,16 @@ export function ImageGenPanel(props: {
1567
1555
  >
1568
1556
  <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>
1569
1557
  </button>
1558
+ <button
1559
+ type="button"
1560
+ className={css.historyNew}
1561
+ data-history-open-folder=""
1562
+ aria-label={tt('gallery.openFolder')}
1563
+ title={tt('gallery.openFolderHint')}
1564
+ onClick={() => { void api.openDataFolder() }}
1565
+ >
1566
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M1.5 4.2A1.2 1.2 0 0 1 2.7 3h2.9l1.6 1.9h6.1A1.2 1.2 0 0 1 14.5 6.1v6.2a1.2 1.2 0 0 1-1.2 1.2H2.7a1.2 1.2 0 0 1-1.2-1.2z" /></svg>
1567
+ </button>
1570
1568
  {history.length > 0 ? (
1571
1569
  <button type="button" className={css.historyClear} data-history-clear="" onClick={() => { void clearHistory() }}>
1572
1570
  {tt('history.clear')}
@@ -1628,22 +1626,53 @@ export function ImageGenPanel(props: {
1628
1626
  </span>
1629
1627
  </button>
1630
1628
  <span className={css.historyActions}>
1629
+ {entry.images.length > 0 ? (
1630
+ <button
1631
+ type="button"
1632
+ className={css.historyIconAction}
1633
+ disabled={conversationBusy}
1634
+ title={`${tt('conversation.add')}:${tt('conversation.addHint')}`}
1635
+ aria-label={tt('conversation.add')}
1636
+ data-history-add-conversation=""
1637
+ onClick={() => { void addHistoryEntryToConversation(entry) }}
1638
+ >
1639
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 2.5h8A1.5 1.5 0 0 1 13.5 4v5a1.5 1.5 0 0 1-1.5 1.5H8.5L5.5 13v-2.5H4A1.5 1.5 0 0 1 2.5 9V4A1.5 1.5 0 0 1 4 2.5z" /></svg>
1640
+ </button>
1641
+ ) : null}
1631
1642
  {entry.images.length > 0 ? (
1632
1643
  <button
1633
1644
  type="button"
1634
- className={css.historyAction}
1645
+ className={css.historyIconAction}
1635
1646
  disabled={galleryAdding}
1636
1647
  title={tt('gallery.add')}
1648
+ aria-label={tt('gallery.add')}
1649
+ data-history-add-gallery=""
1637
1650
  onClick={() => { void addHistoryEntryToGallery(entry) }}
1638
1651
  >
1639
- {tt('gallery.add')}
1640
- </button>
1641
- ) : null}
1642
- <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryGroup(group) }}>
1643
- {tt('history.restore')}
1644
- </button>
1645
- <button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryGroup(group) }}>
1646
- {tt('history.delete')}
1652
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5" /><circle cx="5.9" cy="6.1" r="1" /><path d="M13.5 10.2l-3.1-3.1L4.6 13" /></svg>
1653
+ </button>
1654
+ ) : null}
1655
+ {entry.images.length > 0 ? (
1656
+ <button
1657
+ type="button"
1658
+ className={css.historyIconAction}
1659
+ title={tt('canvas.addToCanvas')}
1660
+ aria-label={tt('canvas.addToCanvas')}
1661
+ data-history-add-canvas=""
1662
+ onClick={() => { addEntryToCanvas('history', entry.id, 0) }}
1663
+ >
1664
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5" /><path d="M5 8h6M8 5v6" /></svg>
1665
+ </button>
1666
+ ) : null}
1667
+ <button
1668
+ type="button"
1669
+ className={css.historyIconAction}
1670
+ data-danger
1671
+ title={tt('history.delete')}
1672
+ aria-label={tt('history.delete')}
1673
+ onClick={() => { void deleteHistoryGroup(group) }}
1674
+ >
1675
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 4.5h11" /><path d="M6 4.5V3.2a.7.7 0 0 1 .7-.7h2.6a.7.7 0 0 1 .7.7v1.3" /><path d="M4.3 4.5l.6 8.1a1 1 0 0 0 1 .9h4.2a1 1 0 0 0 1-.9l.6-8.1" /><path d="M6.7 7v4M9.3 7v4" /></svg>
1647
1676
  </button>
1648
1677
  </span>
1649
1678
  </div>
@@ -1670,11 +1699,12 @@ export function ImageGenPanel(props: {
1670
1699
  <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>
1671
1700
  </a>
1672
1701
  </span>
1673
- <nav className={css.topNav} role="tablist" aria-label={tt('workspace.label')}>
1674
- <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>
1675
- <button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab === 'gallery' ? '' : undefined} onClick={() => { openTab('gallery') }}>{tt('gallery.title')}</button>
1676
- <span className={css.topNavDivider} aria-hidden="true" />
1677
- <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>
1702
+ <nav className={css.topNav} role="tablist" aria-label={tt('workspace.label')}>
1703
+ <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>
1704
+ <button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab === 'gallery' ? '' : undefined} onClick={() => { openTab('gallery') }}>{tt('gallery.title')}</button>
1705
+ <span className={css.topNavDivider} aria-hidden="true" />
1706
+ <button type="button" className={css.topNavItem} data-active={workspace === 'canvas' ? '' : undefined} onClick={() => { setWorkspace('canvas') }}>{tt('workspace.canvas')}</button>
1707
+ <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>
1678
1708
  </nav>
1679
1709
  <span className={css.panelHeaderActions}>
1680
1710
  <button
@@ -1716,7 +1746,7 @@ export function ImageGenPanel(props: {
1716
1746
 
1717
1747
  <div className={css.studio}>
1718
1748
  {/* ------------------------------- left history + generation workspace */}
1719
- <div className={css.generation}>
1749
+ <div className={css.generation} data-workspace={workspace}>
1720
1750
  {/* ------------------------------------------------ config sidebar */}
1721
1751
  <aside
1722
1752
  ref={configAsideRef}
@@ -1805,10 +1835,6 @@ export function ImageGenPanel(props: {
1805
1835
  <span className={css.ecommerceFieldLabel}>{tt('ecommerce.productName')}</span>
1806
1836
  <input value={ecommerce.productName} placeholder={tt('ecommerce.productName')} onChange={event => setEcommerce(previous => ({ ...previous, productName: event.target.value }))} />
1807
1837
  </label>
1808
- <label className={css.ecommerceField}>
1809
- <span className={css.ecommerceFieldLabel}>{tt('ecommerce.projectName')}</span>
1810
- <input value={ecommerce.projectName} placeholder={tt('ecommerce.projectName')} onChange={event => setEcommerce(previous => ({ ...previous, projectName: event.target.value }))} />
1811
- </label>
1812
1838
  {ecommerceAssets.length === 0 ? (
1813
1839
  <button
1814
1840
  type="button"
@@ -1872,7 +1898,18 @@ export function ImageGenPanel(props: {
1872
1898
  </label>
1873
1899
  <label className={css.ecommerceField}>
1874
1900
  <span className={css.ecommerceFieldLabel}>{tt('ecommerce.languageLabel')}</span>
1875
- <select value={ecommerce.language} onChange={event => setEcommerce(previous => ({ ...previous, language: event.target.value }))}><option>中文</option><option>English</option></select>
1901
+ <select aria-label={tt('ecommerce.languageLabel')} value={ecommerce.language} onChange={event => setEcommerce(previous => ({ ...previous, language: event.target.value }))}>
1902
+ {ECOMMERCE_COPY_LANGUAGES.map(([value, label]) => <option key={value} value={value}>{value === 'custom' ? tt('ecommerce.customLanguageOption') : label}</option>)}
1903
+ </select>
1904
+ {ecommerce.language === 'custom' ? (
1905
+ <input
1906
+ value={ecommerce.customLanguage ?? ''}
1907
+ maxLength={40}
1908
+ placeholder={tt('ecommerce.customLanguagePlaceholder')}
1909
+ aria-label={tt('ecommerce.customLanguageLabel')}
1910
+ onChange={event => setEcommerce(previous => ({ ...previous, customLanguage: event.target.value }))}
1911
+ />
1912
+ ) : null}
1876
1913
  </label>
1877
1914
  <label className={css.ecommerceField}>
1878
1915
  <span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
@@ -1918,12 +1955,12 @@ export function ImageGenPanel(props: {
1918
1955
  </div>
1919
1956
  {ecommerceSlots.length > 0 ? (
1920
1957
  <>
1921
- <button type="button" className={css.ecommerceAdvancedToggle} aria-expanded={ecommerceRefOpen} onClick={() => { setEcommerceRefOpen(open => !open) }}>
1922
- {tt('ecommerce.refSettings')}
1958
+ <button type="button" className={css.ecommerceAdvancedToggle} aria-expanded={ecommerceRefOpen} aria-controls="dsh-ecommerce-reference-settings" onClick={() => { setEcommerceRefOpen(open => !open) }}>
1959
+ <span>{tt('ecommerce.refSettings')}</span>
1923
1960
  <span className={css.ecommerceAdvancedChevron} aria-hidden="true">{ecommerceRefOpen ? '⌃' : '⌄'}</span>
1924
1961
  </button>
1925
1962
  {ecommerceRefOpen ? (
1926
- <div className={css.ecommerceAdvancedBody}>
1963
+ <div id="dsh-ecommerce-reference-settings" className={css.ecommerceAdvancedBody}>
1927
1964
  {ecommerceSlots.map(slot => (
1928
1965
  <label key={slot.key} className={css.ecommerceRefRow}>
1929
1966
  <span>{slot.label}</span>
@@ -2199,9 +2236,24 @@ export function ImageGenPanel(props: {
2199
2236
  ) : tt('generate')}
2200
2237
  </Button> : null}
2201
2238
  </section>
2202
- </aside>
2203
-
2204
- {/* ------------------------------------------------------- canvas */}
2239
+ </aside>
2240
+
2241
+ {workspace === 'canvas' ? (
2242
+ <CanvasWorkspace
2243
+ api={api}
2244
+ imageModels={imageModels}
2245
+ defaultChannelId={defaultChannelId}
2246
+ connected={connected}
2247
+ history={history}
2248
+ gallery={gallery}
2249
+ tasks={tasks}
2250
+ importRequest={canvasImportRequest}
2251
+ onImportRequestHandled={() => { setCanvasImportRequest(undefined) }}
2252
+ onOpenSettings={() => { openSettingsGuide('generation') }}
2253
+ />
2254
+ ) : null}
2255
+
2256
+ {/* ------------------------------------------------------- canvas */}
2205
2257
  <section className={css.canvas} data-gallery={workspace === 'normal' && tab === 'gallery' ? 'true' : undefined}>
2206
2258
  {workspace === 'normal' && tab === 'gallery' ? (
2207
2259
  <div className={css.galleryWorkspace}>
@@ -2227,6 +2279,7 @@ export function ImageGenPanel(props: {
2227
2279
  <option value="newest">{tt('gallery.newest')}</option>
2228
2280
  <option value="oldest">{tt('gallery.oldest')}</option>
2229
2281
  </select>
2282
+ <button type="button" className={css.galleryClear} data-gallery-open-folder="" title={tt('gallery.openFolderHint')} onClick={() => { void api.openDataFolder() }}>{tt('gallery.openFolder')}</button>
2230
2283
  {gallery.length > 0 ? <button type="button" className={css.galleryClear} data-gallery-clear="" onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
2231
2284
  </div>
2232
2285
  </header>
@@ -2257,7 +2310,7 @@ export function ImageGenPanel(props: {
2257
2310
  <span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
2258
2311
  </button>
2259
2312
  <div className={css.galleryCardActions}>
2260
- <button
2313
+ <button
2261
2314
  type="button"
2262
2315
  className={css.galleryCardAction}
2263
2316
  data-gallery-add-conversation=""
@@ -2266,9 +2319,19 @@ export function ImageGenPanel(props: {
2266
2319
  onClick={(event) => { event.stopPropagation(); void addGalleryEntryToConversation(entry) }}
2267
2320
  >
2268
2321
  <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>
2269
- {galleryConversationAddingId === entry.id || addingToConversation === `gallery:${entry.id}` ? tt('conversation.adding') : tt('conversation.add')}
2270
- </button>
2271
- </div>
2322
+ {galleryConversationAddingId === entry.id || addingToConversation === `gallery:${entry.id}` ? tt('conversation.adding') : tt('conversation.add')}
2323
+ </button>
2324
+ <button
2325
+ type="button"
2326
+ className={css.galleryCardAction}
2327
+ data-gallery-add-canvas=""
2328
+ title={tt('canvas.addToCanvas')}
2329
+ onClick={(event) => { event.stopPropagation(); addEntryToCanvas('gallery', entry.id, 0) }}
2330
+ >
2331
+ <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5" /><path d="M5 8h6M8 5v6" /></svg>
2332
+ {tt('canvas.addToCanvas')}
2333
+ </button>
2334
+ </div>
2272
2335
  <div className={css.galleryCardFooter}>
2273
2336
  <span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
2274
2337
  <span className={css.galleryCardInfo}>