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