@dickpy/dsh-imagegen 1.5.2 → 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,9 @@ 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'
22
+ import { useImageGenLanguageTick } from './use-language.ts'
21
23
  import type { EcommerceRefRole, GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, GenerationTaskStatus, HistoryEntry, HistoryImageRef, ProductSetDraft, ProductSetSlot, UpdateInfo } from '../protocol.ts'
22
24
  import { AGENT_IMAGE_API } from '../protocol.ts'
23
25
  import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
@@ -350,19 +352,38 @@ function formatTime(timestamp: number): string {
350
352
 
351
353
  function defaultEcommerceDraft(): ProductSetDraft {
352
354
  return {
353
- projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', size: '1:1',
355
+ projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', customLanguage: '', size: '1:1',
354
356
  productName: '', sellingPoints: '', protectedFeatures: '', styleHint: '',
355
357
  slots: PRODUCT_SET_SLOTS.map(slot => ({ ...slot })),
356
358
  }
357
359
  }
358
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
+
359
379
  function ecommercePrompt(draft: ProductSetDraft, slot: ProductSetSlot): string {
360
380
  const points = draft.sellingPoints.trim() || '突出商品真实材质、结构和核心价值'
361
381
  const protectedFeatures = draft.protectedFeatures.trim() || '保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
382
+ const language = effectiveEcommerceLanguage(draft) || '中文'
362
383
  const refClause = slot.refRole !== undefined && slot.refRole !== 'none'
363
384
  ? `本图以上传的${ECOMMERCE_ROLE_PROMPT_LABELS[slot.refRole]}图片为参考,商品与风格必须与参考图保持一致;`
364
385
  : ''
365
- 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()}`
366
387
  }
367
388
 
368
389
  /** Consistency prefix for slots generated after the main image exists. */
@@ -375,7 +396,7 @@ type PanelTab = GenerateMode | 'gallery'
375
396
 
376
397
  /** Top-level workspaces inside the panel. 'normal' is the classic studio;
377
398
  * more task-oriented modes (prototype, …) can join alongside 'ecommerce'. */
378
- type PanelWorkspace = 'normal' | 'ecommerce'
399
+ type PanelWorkspace = 'normal' | 'ecommerce' | 'canvas'
379
400
 
380
401
  type GalleryFilter = string
381
402
  type ComparisonSession = { taskIds: string[]; prompt: string; comparisonId: string }
@@ -440,6 +461,10 @@ export function ImageGenPanel(props: {
440
461
  }) {
441
462
  const { api, scope, sessions, conversation } = props
442
463
  const config = useConfig(scope)
464
+ // The plugin language follows the DSH interface (bridged from ctx.locale);
465
+ // this tick re-renders the tree so every tt() switches live — the template
466
+ // library and the inspiration wall render inside this tree.
467
+ useImageGenLanguageTick()
443
468
  const enabled = config?.enabled ?? true
444
469
  // Channel-aware model options: the panel lists every configured alias
445
470
  // (default channel first); legacy flat fields remain the upgrade fallback.
@@ -459,13 +484,18 @@ export function ImageGenPanel(props: {
459
484
  const apiKeySet = (config?.channels ?? []).length > 0 ? channelKeySet : legacyKeySet
460
485
  const connected = enabled && configured && apiKeySet
461
486
 
462
- const [tab, setTab] = useState<PanelTab>('text')
463
- 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>()
464
490
  /** Switch to a normal-generation tab, leaving any task workspace. */
465
- const openTab = (next: PanelTab): void => {
466
- setWorkspace('normal')
467
- setTab(next)
468
- }
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
+ }
469
499
  const [prompt, setPrompt] = useState('')
470
500
  const [size, setSize] = useState<string>('auto')
471
501
  const [quality, setQuality] = useState<string>('auto')
@@ -479,6 +509,7 @@ export function ImageGenPanel(props: {
479
509
  const [images, setImages] = useState<GeneratedImage[]>([])
480
510
  const [addingToConversation, setAddingToConversation] = useState<number | string | null>(null)
481
511
  const [galleryConversationAddingId, setGalleryConversationAddingId] = useState<string | null>(null)
512
+ const [historyConversationAddingId, setHistoryConversationAddingId] = useState<string | null>(null)
482
513
  const [conversationMessage, setConversationMessage] = useState<string | null>(null)
483
514
  const [error, setError] = useState<string | null>(null)
484
515
  // Submission is brief; actual generation stays visible until the host
@@ -914,7 +945,7 @@ export function ImageGenPanel(props: {
914
945
  ...(asset !== undefined ? { image: asset.dataUrl, refName: asset.name } : {}),
915
946
  workflow: 'ecommerce' as const,
916
947
  projectId,
917
- projectName: ecommerce.projectName.trim() || ecommerce.productName.trim(),
948
+ projectName: ecommerce.productName.trim(),
918
949
  slotKey: `${slot.key}-${index + 1}`,
919
950
  slotLabel: slot.label,
920
951
  }
@@ -1130,38 +1161,6 @@ export function ImageGenPanel(props: {
1130
1161
  }
1131
1162
  }
1132
1163
 
1133
- /** Restore one comparison group, including its selected model set. */
1134
- const restoreHistoryGroup = async (group: HistoryGroup): Promise<void> => {
1135
- const entry = group.entries[0]
1136
- if (entry === undefined) return
1137
- // The product-set form draft cannot be rebuilt from a compiled prompt, so
1138
- // restoring a product set reopens its grouped results canvas.
1139
- if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) {
1140
- await viewEcommerceProject(group)
1141
- return
1142
- }
1143
- try {
1144
- const restored = await loadHistoryGroup(group)
1145
- openTab(entry.mode)
1146
- setPrompt(entry.prompt)
1147
- setSize(normalizeSize(entry.size))
1148
- setQuality(normalizeQuality(entry.quality))
1149
- setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
1150
- setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
1151
- setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
1152
- setCompareModels(group.models.filter(candidate => imageModels.includes(candidate)))
1153
- setCompareEnabled(group.models.filter(candidate => imageModels.includes(candidate)).length > 1)
1154
- setRefImage(null)
1155
- setImages(restored)
1156
- setComparison(null)
1157
- setError(null)
1158
- setViewingHistoryId(entry.id)
1159
- setGalleryViewingId(null)
1160
- } catch (caught) {
1161
- setError(errorMessage(caught))
1162
- }
1163
- }
1164
-
1165
1164
  /** Remove every persisted row belonging to one comparison group. */
1166
1165
  const deleteHistoryGroup = async (group: HistoryGroup): Promise<void> => {
1167
1166
  const ids = new Set(group.entries.map(entry => entry.id))
@@ -1309,6 +1308,21 @@ export function ImageGenPanel(props: {
1309
1308
  }
1310
1309
  }
1311
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
+
1312
1326
  /** Load a persisted gallery image and add it to the current chat draft. */
1313
1327
  const addGalleryEntryToConversation = async (entry: HistoryEntry): Promise<void> => {
1314
1328
  if (galleryConversationAddingId !== null || addingToConversation !== null || entry.images.length === 0) return
@@ -1338,27 +1352,6 @@ export function ImageGenPanel(props: {
1338
1352
  }
1339
1353
  }
1340
1354
 
1341
- /** Restore a gallery entry's parameters (and its images) into the form. */
1342
- const restoreGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
1343
- try {
1344
- const restored = await historyImagesToGenerated(entry.images)
1345
- openTab(entry.mode)
1346
- setPrompt(entry.prompt)
1347
- setSize(normalizeSize(entry.size))
1348
- setQuality(normalizeQuality(entry.quality))
1349
- setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
1350
- setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
1351
- setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
1352
- setRefImage(null)
1353
- setImages(restored)
1354
- setError(null)
1355
- setViewingHistoryId(null)
1356
- setGalleryViewingId(null)
1357
- } catch (caught) {
1358
- setError(errorMessage(caught))
1359
- }
1360
- }
1361
-
1362
1355
  /** Remove one gallery entry. */
1363
1356
  const deleteGalleryEntry = async (id: string): Promise<void> => {
1364
1357
  setGallery(gallery.filter(entry => entry.id !== id))
@@ -1449,7 +1442,7 @@ export function ImageGenPanel(props: {
1449
1442
  const generateDisabled = submitting || modeModels.length === 0
1450
1443
  const ecommerceSlots = ecommerce.slots.filter(slot => slot.enabled && slot.count > 0)
1451
1444
  const ecommerceTotal = ecommerceSlots.reduce((total, slot) => total + slot.count, 0)
1452
- 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) === '')
1453
1446
  const ecommerceFileInput = useRef<HTMLInputElement>(null)
1454
1447
  // The results canvas merges live tasks of the active project with restored
1455
1448
  // history entries of the same project; restored slots that were regenerated
@@ -1479,7 +1472,7 @@ export function ImageGenPanel(props: {
1479
1472
  const ecommerceResultGroups = [...new Set(ecommerceMergedItems.map(item => item.label))]
1480
1473
  .filter(label => label !== '')
1481
1474
  .map(label => ({ label, items: ecommerceMergedItems.filter(item => item.label === label) }))
1482
- const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null
1475
+ const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null || historyConversationAddingId !== null
1483
1476
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
1484
1477
  const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
1485
1478
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
@@ -1562,6 +1555,16 @@ export function ImageGenPanel(props: {
1562
1555
  >
1563
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>
1564
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>
1565
1568
  {history.length > 0 ? (
1566
1569
  <button type="button" className={css.historyClear} data-history-clear="" onClick={() => { void clearHistory() }}>
1567
1570
  {tt('history.clear')}
@@ -1623,22 +1626,53 @@ export function ImageGenPanel(props: {
1623
1626
  </span>
1624
1627
  </button>
1625
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}
1626
1642
  {entry.images.length > 0 ? (
1627
1643
  <button
1628
1644
  type="button"
1629
- className={css.historyAction}
1645
+ className={css.historyIconAction}
1630
1646
  disabled={galleryAdding}
1631
1647
  title={tt('gallery.add')}
1648
+ aria-label={tt('gallery.add')}
1649
+ data-history-add-gallery=""
1632
1650
  onClick={() => { void addHistoryEntryToGallery(entry) }}
1633
1651
  >
1634
- {tt('gallery.add')}
1635
- </button>
1636
- ) : null}
1637
- <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryGroup(group) }}>
1638
- {tt('history.restore')}
1639
- </button>
1640
- <button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryGroup(group) }}>
1641
- {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>
1642
1676
  </button>
1643
1677
  </span>
1644
1678
  </div>
@@ -1665,11 +1699,12 @@ export function ImageGenPanel(props: {
1665
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>
1666
1700
  </a>
1667
1701
  </span>
1668
- <nav className={css.topNav} role="tablist" aria-label={tt('workspace.label')}>
1669
- <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>
1670
- <button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab === 'gallery' ? '' : undefined} onClick={() => { openTab('gallery') }}>{tt('gallery.title')}</button>
1671
- <span className={css.topNavDivider} aria-hidden="true" />
1672
- <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>
1673
1708
  </nav>
1674
1709
  <span className={css.panelHeaderActions}>
1675
1710
  <button
@@ -1711,7 +1746,7 @@ export function ImageGenPanel(props: {
1711
1746
 
1712
1747
  <div className={css.studio}>
1713
1748
  {/* ------------------------------- left history + generation workspace */}
1714
- <div className={css.generation}>
1749
+ <div className={css.generation} data-workspace={workspace}>
1715
1750
  {/* ------------------------------------------------ config sidebar */}
1716
1751
  <aside
1717
1752
  ref={configAsideRef}
@@ -1800,10 +1835,6 @@ export function ImageGenPanel(props: {
1800
1835
  <span className={css.ecommerceFieldLabel}>{tt('ecommerce.productName')}</span>
1801
1836
  <input value={ecommerce.productName} placeholder={tt('ecommerce.productName')} onChange={event => setEcommerce(previous => ({ ...previous, productName: event.target.value }))} />
1802
1837
  </label>
1803
- <label className={css.ecommerceField}>
1804
- <span className={css.ecommerceFieldLabel}>{tt('ecommerce.projectName')}</span>
1805
- <input value={ecommerce.projectName} placeholder={tt('ecommerce.projectName')} onChange={event => setEcommerce(previous => ({ ...previous, projectName: event.target.value }))} />
1806
- </label>
1807
1838
  {ecommerceAssets.length === 0 ? (
1808
1839
  <button
1809
1840
  type="button"
@@ -1867,7 +1898,18 @@ export function ImageGenPanel(props: {
1867
1898
  </label>
1868
1899
  <label className={css.ecommerceField}>
1869
1900
  <span className={css.ecommerceFieldLabel}>{tt('ecommerce.languageLabel')}</span>
1870
- <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}
1871
1913
  </label>
1872
1914
  <label className={css.ecommerceField}>
1873
1915
  <span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
@@ -1913,12 +1955,12 @@ export function ImageGenPanel(props: {
1913
1955
  </div>
1914
1956
  {ecommerceSlots.length > 0 ? (
1915
1957
  <>
1916
- <button type="button" className={css.ecommerceAdvancedToggle} aria-expanded={ecommerceRefOpen} onClick={() => { setEcommerceRefOpen(open => !open) }}>
1917
- {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>
1918
1960
  <span className={css.ecommerceAdvancedChevron} aria-hidden="true">{ecommerceRefOpen ? '⌃' : '⌄'}</span>
1919
1961
  </button>
1920
1962
  {ecommerceRefOpen ? (
1921
- <div className={css.ecommerceAdvancedBody}>
1963
+ <div id="dsh-ecommerce-reference-settings" className={css.ecommerceAdvancedBody}>
1922
1964
  {ecommerceSlots.map(slot => (
1923
1965
  <label key={slot.key} className={css.ecommerceRefRow}>
1924
1966
  <span>{slot.label}</span>
@@ -2194,9 +2236,24 @@ export function ImageGenPanel(props: {
2194
2236
  ) : tt('generate')}
2195
2237
  </Button> : null}
2196
2238
  </section>
2197
- </aside>
2198
-
2199
- {/* ------------------------------------------------------- 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 */}
2200
2257
  <section className={css.canvas} data-gallery={workspace === 'normal' && tab === 'gallery' ? 'true' : undefined}>
2201
2258
  {workspace === 'normal' && tab === 'gallery' ? (
2202
2259
  <div className={css.galleryWorkspace}>
@@ -2222,6 +2279,7 @@ export function ImageGenPanel(props: {
2222
2279
  <option value="newest">{tt('gallery.newest')}</option>
2223
2280
  <option value="oldest">{tt('gallery.oldest')}</option>
2224
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>
2225
2283
  {gallery.length > 0 ? <button type="button" className={css.galleryClear} data-gallery-clear="" onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
2226
2284
  </div>
2227
2285
  </header>
@@ -2252,7 +2310,7 @@ export function ImageGenPanel(props: {
2252
2310
  <span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
2253
2311
  </button>
2254
2312
  <div className={css.galleryCardActions}>
2255
- <button
2313
+ <button
2256
2314
  type="button"
2257
2315
  className={css.galleryCardAction}
2258
2316
  data-gallery-add-conversation=""
@@ -2261,9 +2319,19 @@ export function ImageGenPanel(props: {
2261
2319
  onClick={(event) => { event.stopPropagation(); void addGalleryEntryToConversation(entry) }}
2262
2320
  >
2263
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>
2264
- {galleryConversationAddingId === entry.id || addingToConversation === `gallery:${entry.id}` ? tt('conversation.adding') : tt('conversation.add')}
2265
- </button>
2266
- </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>
2267
2335
  <div className={css.galleryCardFooter}>
2268
2336
  <span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
2269
2337
  <span className={css.galleryCardInfo}>