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