/** * The AI 生图 studio: a three-column layout — left, a card-grouped * configuration sidebar (mode tabs, prompt with counter, rounded parameter * selectors, model dropdown + generate button); center, the result canvas; * right, a persistent generation history column. * * Controls ride the system UI primitives (@deepseek-ai/dsh-client-ui-primitives, * a platform module) so the studio matches the dsh shell look by construction. */ import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react' import { GenerationLoading } from './GenerationLoading' import { createPortal } from 'react-dom' import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ImageGenApi } from './api.ts' import { errorMessage, tt } from './helpers.ts' import { renderMarkdown } from './markdown.tsx' import { openImageGenConfig } from './config-entry.tsx' import { TemplateLibrary, TemplateLibraryButton } from './TemplateLibrary.tsx' import { ReferenceImages } from './ReferenceImages.tsx' import { clipboardImageFiles, EDIT_REFERENCE_LIMIT, editReferenceLimit, useEditReferences } from './reference-images.ts' import { mergeTemplatePrompt } from './template-library-state.ts' import { GooeyNav } from './GooeyNav.tsx' import { InspirationGallery } from './InspirationGallery.tsx' import { PptWorkspace, type PptImport } from './PptWorkspace.tsx' import { CanvasWorkspace } from './CanvasWorkspace.tsx' import { ModelPicker, resolveModelChoice, type ModelPickerValue } from './ModelPicker.tsx' import { useImageGenLanguageTick } from './use-language.ts' import type { DiagnosticEntry, EcommerceRefRole, GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, GenerationTaskStatus, GenerationTaskSummary, HistoryEntry, HistoryImageRef, ProductSetDraft, ProductSetSlot, UpdateInfo } from '../protocol.ts' import { AGENT_IMAGE_API, SUBSCRIPTION_API, SUBSCRIPTION_PROVIDERS, type SubscriptionProvider } from '../protocol.ts' import type { ImageGenConfig, ImageGenScope, ImageModelGroup } from './settings-scope.ts' import { imageModelOptions } from './settings-scope.ts' import { describeModel, promptCharLimit } from '../model-catalog.ts' import { CHAT_IMAGE_EVENT, type ChatImageEventDetail, type ConversationService } from './conversation-sync.ts' import { useConversationImageAttachment } from './use-conversation-image-attachment.tsx' import { ConversationSessionPicker } from './ConversationSessionPicker.tsx' import { Select } from './Select.tsx' import css from './panel.module.css' /** Size options, presented as aspect ratios (auto = let the model decide). * The host maps each ratio onto the model's own vocabulary: aspect_ratio for * Grok Imagine, the closest pixel size for OpenAI-compatible endpoints. */ const SIZES = ['auto', '1:1', '3:4', '4:3', '9:16', '2:3', '3:2', '16:9', '21:9'] as const /** Size option keys in the locale dictionary. */ /** Re-check Releases while the panel stays open; the host caches for 15 minutes. */ const UPDATE_CHECK_INTERVAL_MS = 30 * 60_000 const SIZE_KEYS: Record = { auto: 'size.auto', '1:1': 'size.square', '3:4': 'size.portrait34', '4:3': 'size.landscape43', '9:16': 'size.portrait916', '2:3': 'size.portrait23', '3:2': 'size.landscape32', '16:9': 'size.wide169', '21:9': 'size.ultrawide21', } /** Quality options, shown as output-resolution tiers (auto = let the model * decide). The host maps them: resolution for Grok, quality level for * OpenAI-compatible endpoints (1k→low, 2k→medium, 4k→high). */ const QUALITIES = ['auto', '1k', '2k', '4k'] as const /** Detail options ('' = omit the passthrough). */ const DETAILS = ['', 'standard', 'high'] as const const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024 // The local DSH attachment backend defaults to a 2000px per-side limit. Keep // the full-resolution result in the studio, but normalize the conversation // copy before it enters the native composer and durable edit staging route. const CONVERSATION_IMAGE_MAX_DIMENSION = 2000 const CONVERSATION_IMAGE_JPEG_QUALITY = 0.9 const PREVIEW_SCALE_MIN = 0.5 const PREVIEW_SCALE_MAX = 3 const PREVIEW_SCALE_STEP = 0.25 const CONFIG_COLLAPSED_STORAGE_KEY = 'dsh-imagegen-config-collapsed' const ECOMMERCE_DRAFT_STORAGE_KEY = 'dsh-imagegen-ecommerce-draft' /** Reference roles an uploaded product asset can play (slot selections can * also pick 'none'). */ const ECOMMERCE_ASSET_ROLES = ['product', 'packaging', 'detail', 'style'] as const type EcommerceAssetRole = Exclude const MAX_ECOMMERCE_ASSETS = 4 const ECOMMERCE_ROLE_PROMPT_LABELS: Record = { product: '商品主体', packaging: '包装', detail: '细节/角度', style: '风格参考', } /** One uploaded product asset. Session-only: data URLs are far too large for * the localStorage draft, so assets never persist across reloads. */ interface ProductAsset { id: string dataUrl: string name: string role: EcommerceAssetRole } const PRODUCT_SET_SLOTS: ProductSetSlot[] = [ { key: 'main', label: '主图', description: '干净背景,突出商品主体', count: 1, enabled: true, refRole: 'product' }, { key: 'selling-point', label: '卖点图', description: '用画面展示商品核心卖点', count: 2, enabled: true, refRole: 'product' }, { key: 'scene', label: '场景图', description: '真实生活或使用场景', count: 2, enabled: true, refRole: 'product' }, { key: 'detail', label: '细节图', description: '材质、结构或工艺特写', count: 1, enabled: true, refRole: 'detail' }, { key: 'spec', label: '规格图', description: '尺寸、容量或参数展示', count: 1, enabled: false, refRole: 'product' }, { key: 'model', label: '使用图', description: '人物上手或穿戴效果', count: 1, enabled: false, refRole: 'product' }, ] /** Legacy pixel sizes saved by older versions, mapped onto the current * aspect-ratio vocabulary so restoring old history entries still works. */ const LEGACY_SIZE_TO_RATIO: Record = { '512x512': '1:1', '1024x1024': '1:1', '1536x1024': '3:2', '1024x1536': '2:3', '1792x1024': '16:9', '1024x1792': '9:16', } /** Legacy quality levels saved by older versions, mapped onto resolution. */ const LEGACY_QUALITY_TO_RES: Record = { low: '1k', medium: '2k', high: '4k', } /** Normalize a saved size value into a current dropdown option. */ function normalizeSize(value: string): string { if ((SIZES as readonly string[]).includes(value)) return value return LEGACY_SIZE_TO_RATIO[value] ?? 'auto' } /** Normalize a saved quality value into a current dropdown option. */ function normalizeQuality(value: string): string { if ((QUALITIES as readonly string[]).includes(value)) return value return LEGACY_QUALITY_TO_RES[value] ?? 'auto' } function clampPreviewScale(scale: number): number { return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale)) } /** Keep the image canvas preference across panel remounts without making it * part of the host settings document. */ function readConfigCollapsed(): boolean { try { return window.localStorage.getItem(CONFIG_COLLAPSED_STORAGE_KEY) === 'true' } catch { return false } } const CHAT_COLLAPSED_STORAGE_KEY = 'dsh-imagegen:chat-collapsed' type SubscriptionChannelState = 'logged-in' | 'logged-out' | 'unknown' const INITIAL_SUBSCRIPTION_STATES: Record = { 'chatgpt-sub': 'unknown', 'grok-sub': 'unknown', 'google-sub': 'unknown', 'openrouter-sub': 'unknown', } const CONFIG_WIDTH_STORAGE_KEY = 'dsh-imagegen:config-width' const CONFIG_WIDTH_MIN = 260 const CONFIG_WIDTH_MAX = 480 const CONFIG_WIDTH_DEFAULT = 300 /** The chat pane starts collapsed unless the user explicitly opened it. */ function readChatOpen(): boolean { try { return window.localStorage.getItem(CHAT_COLLAPSED_STORAGE_KEY) === 'open' } catch { return false } } function readConfigWidth(): number { try { const raw = window.localStorage.getItem(CONFIG_WIDTH_STORAGE_KEY) if (raw === null) return CONFIG_WIDTH_DEFAULT const value = Number(raw) if (Number.isFinite(value) && value > 0) return Math.min(CONFIG_WIDTH_MAX, Math.max(CONFIG_WIDTH_MIN, Math.round(value))) } catch { /* storage unavailable */ } return CONFIG_WIDTH_DEFAULT } /** Read the current config from the settings scope snapshot. */ function useConfig(scope: ImageGenScope): ImageGenConfig | undefined { const [value, setValue] = useState(scope.getSnapshot().value) useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope]) return value } /** Track one redacted secret field without exposing its value to the panel. */ function useSecretSet(scope: ImageGenScope, field: string): boolean { const [isSet, setIsSet] = useState(scope.getSecretSetSnapshot(field)) useEffect(() => scope.subscribeSecretSets(() => { setIsSet(scope.getSecretSetSnapshot(field)) }), [field, scope]) return isSet } /** Tick a seconds counter while `running`. */ function useElapsed(running: boolean, startedAt: number | null): number { const [elapsed, setElapsed] = useState(0) useEffect(() => { if (!running || startedAt === null) { setElapsed(0) return } const update = (): void => { setElapsed(Math.max(1, Math.round((Date.now() - startedAt) / 1000))) } update() const timer = window.setInterval(update, 1000) return () => window.clearInterval(timer) }, [running, startedAt]) return elapsed } /** Data URL for a generated image. */ function srcOf(image: GeneratedImage): string { return `data:${image.mime};base64,${image.b64}` } /** Decode one durable conversation attachment into the panel's image shape. */ async function attachmentToGenerated(ref: ImageAttachmentRef): Promise { const query = new URLSearchParams({ attachment_id: String(ref.attachmentId), media_type: ref.mediaType, bytes: String(ref.bytes), width: String(ref.width), height: String(ref.height), }) const response = await fetch(`${AGENT_IMAGE_API}?${query.toString()}`) if (!response.ok) throw new Error(`HTTP ${response.status}`) const blob = await response.blob() const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '') reader.onerror = () => reject(new Error('image read failed')) reader.readAsDataURL(blob) }) const comma = dataUrl.indexOf(',') if (comma < 0) throw new Error('image decode failed') return { b64: dataUrl.slice(comma + 1), mime: ref.mediaType } } /** Convert a generated image into the browser-owned draft format. */ function generatedImageToFile(image: GeneratedImage, index: number): File { const binary = atob(image.b64) const bytes = new Uint8Array(binary.length) for (let offset = 0; offset < binary.length; offset += 1) bytes[offset] = binary.charCodeAt(offset) return new File([bytes], `dsh-image-${index + 1}.${extensionOf(image.mime)}`, { type: image.mime }) } /** Decode a data URL into a browser File for the native composer. */ function dataUrlToFile(dataUrl: string, name: string): File { const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/su.exec(dataUrl) if (match === null || match[1] === undefined || match[2] === undefined) throw new Error('image processing returned an invalid data URL') const binary = atob(match[2]) const bytes = new Uint8Array(binary.length) for (let offset = 0; offset < binary.length; offset += 1) bytes[offset] = binary.charCodeAt(offset) return new File([bytes], name, { type: match[1] }) } /** Read intrinsic dimensions without changing the original preview. */ function imageDimensions(dataUrl: string): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const image = new Image() image.onload = () => { const width = image.naturalWidth || image.width const height = image.naturalHeight || image.height if (width < 1 || height < 1) reject(new Error('image dimensions are unavailable')) else resolve({ width, height }) } image.onerror = () => reject(new Error('image decode failed')) image.src = dataUrl }) } /** Prepare the smaller conversation copy required by the host attachment policy. */ async function prepareConversationImage(image: GeneratedImage, index: number): Promise<{ file: File; dataUrl: string }> { const dataUrl = srcOf(image) const { width, height } = await imageDimensions(dataUrl) const longestSide = Math.max(width, height) if (longestSide <= CONVERSATION_IMAGE_MAX_DIMENSION) { return { file: generatedImageToFile(image, index), dataUrl } } const scale = CONVERSATION_IMAGE_MAX_DIMENSION / longestSide const targetWidth = Math.max(1, Math.round(width * scale)) const targetHeight = Math.max(1, Math.round(height * scale)) const canvas = document.createElement('canvas') canvas.width = targetWidth canvas.height = targetHeight const context = canvas.getContext('2d') if (context === null) throw new Error('image resize is unavailable in this browser') const source = await new Promise((resolve, reject) => { const sourceImage = new Image() sourceImage.onload = () => resolve(sourceImage) sourceImage.onerror = () => reject(new Error('image decode failed')) sourceImage.src = dataUrl }) context.drawImage(source, 0, 0, targetWidth, targetHeight) const resizedDataUrl = canvas.toDataURL('image/jpeg', CONVERSATION_IMAGE_JPEG_QUALITY) return { dataUrl: resizedDataUrl, file: dataUrlToFile(resizedDataUrl, `dsh-image-${index + 1}.jpg`), } } /** Follow the native session selection while the image panel stays mounted. */ function useCurrentSessionId(sessions: ISessions | undefined): SessionId | undefined { const [sessionId, setSessionId] = useState(() => sessions?.list.getSnapshot().current) useEffect(() => { if (sessions === undefined) { setSessionId(undefined) return } const sync = (): void => { setSessionId(sessions.list.getSnapshot().current) } sync() return sessions.list.subscribe(sync) }, [sessions]) return sessionId } /** Find the host mounted in the shell's left navigation region. */ function useSidebarHistoryHost(): HTMLDivElement | null { const [host, setHost] = useState(() => ( document.querySelector('[data-dsh-imagegen-history-host]') )) useEffect(() => { const sync = (): void => { setHost(document.querySelector('[data-dsh-imagegen-history-host]')) } sync() const observer = new MutationObserver(sync) observer.observe(document.body, { childList: true, subtree: true }) return () => observer.disconnect() }, []) return host } /** Fetch persisted history image refs and decode them back to in-memory * GeneratedImage[] (base64), so the canvas/preview can reuse the same * rendering path as a fresh generation. */ async function historyImagesToGenerated(refs: HistoryImageRef[]): Promise { return Promise.all(refs.map(async ref => { const response = await fetch(ref.url) if (!response.ok) throw new Error(`HTTP ${response.status}`) const blob = await response.blob() const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '') reader.onerror = () => reject(new Error('image read failed')) reader.readAsDataURL(blob) }) const comma = dataUrl.indexOf(',') return { b64: comma >= 0 ? dataUrl.slice(comma + 1) : '', mime: ref.mime, ...ref.revisedPrompt === undefined ? {} : { revisedPrompt: ref.revisedPrompt }, } })) } /** Compact, locale-independent timestamp for history entries. */ function formatTime(timestamp: number): string { const d = new Date(timestamp) const pad = (n: number): string => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } function defaultEcommerceDraft(): ProductSetDraft { return { projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', customLanguage: '', size: '1:1', productName: '', promptInfo: '', slots: PRODUCT_SET_SLOTS.map(slot => ({ ...slot })), } } /** Standard copy-language choices; custom keeps uncommon locales usable. */ const ECOMMERCE_COPY_LANGUAGES = [ ['中文', '中文'], ['English', 'English'], ['Русский', 'Русский'], ['日本語', '日本語'], ['한국어', '한국어'], ['Français', 'Français'], ['Deutsch', 'Deutsch'], ['Español', 'Español'], ['Português', 'Português'], ['custom', '自定义'], ] as const function effectiveEcommerceLanguage(draft: ProductSetDraft): string { return draft.language === 'custom' ? draft.customLanguage?.trim() ?? '' : draft.language } function ecommerceVisualBrief(slot: ProductSetSlot, index: number): string { const variants: Record = { main: ['以纯净高级背景完整展示商品主体,突出正面与侧面结构、材质和屏幕/镜头等关键设计,商品居中但保留自然阴影和真实比例'], 'selling-point': [ '制作卖点视觉图:用局部放大、功能结果或使用场景形成构图分区,突出核心功能和购买理由,不要只是商品正面照', '制作第二张卖点视觉图:聚焦差异化优势和附加价值,使用与第一张不同的视角、背景、光线和视觉重点', ], scene: [ '制作真实生活场景图:商品自然融入室内或日常环境,加入合理背景道具和环境光,呈现使用氛围,不要白底棚拍', '制作第二张场景图:换成不同环境或使用时刻,如户外、办公或休闲场景,机位、背景、光线和整体氛围必须与第一张明显不同', ], detail: ['制作微距细节图:近距离突出材质、工艺、接口或结构,浅景深、高细节,构图聚焦局部而非完整商品'], spec: ['制作规格信息图:突出尺寸、容量或参数关系,使用清晰的辅助构图和留白,不复制主图构图'], model: ['制作人物使用图:展示手持、佩戴或实际使用状态,突出人与商品的交互和真实尺度'], } const choices = variants[slot.key] ?? ['使用与同组其他图片不同的构图、视角、背景和视觉重点'] return choices[Math.min(index, choices.length - 1)]! } function ecommercePrompt(draft: ProductSetDraft, slot: ProductSetSlot, index = 0): string { const info = draft.promptInfo.trim() || '突出商品真实材质、结构和核心价值;保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件' const language = effectiveEcommerceLanguage(draft) || '中文' const product = draft.productName.trim() || '该商品' const refClause = slot.refRole !== undefined && slot.refRole !== 'none' ? `本图以上传的${ECOMMERCE_ROLE_PROMPT_LABELS[slot.refRole]}图片为参考,商品与风格必须与参考图保持一致;` : '' const distinct = slot.key === 'main' ? '' : '这不是商品主图的简单复刻:只复用参考图中的商品外观和身份,必须重新设计背景、机位、构图、道具和光效,不得输出与主图相同的白底正面居中画面。' return `生成一张电商${slot.label}。视觉目标:${ecommerceVisualBrief(slot, index)}。${distinct}商品身份与一致性约束:${product}的外形、比例、颜色、材质、镜头模组、Logo 与核心结构必须与${refClause === '' ? '文字描述' : '参考图'}保持一致,只允许改变场景、视角、构图和光效,不得重新发明或替换商品。商品品类:${draft.category};平台:${draft.platform};语言:${language}。${refClause}商品信息与要求:${info}。整体要求:商品主体清晰、比例真实、光线自然、细节真实、适合电商发布。` } /** Effective prompt for one image of a slot: the per-image override written in * the pre-generation preview board wins over the auto-composed prompt. */ function ecommerceSlotPrompt(draft: ProductSetDraft, slot: ProductSetSlot, index: number): string { const override = draft.promptOverrides?.[`${slot.key}-${index + 1}`] return override !== undefined && override.trim() !== '' ? override : ecommercePrompt(draft, slot, index) } /** Consistency prefix for slots generated after the main image exists. */ function withAnchorNote(prompt: string): string { return '商品套图一致性约束:附件是本套商品的主图,图中商品(外形、颜色、材质、Logo、包装文字)必须与附件完全一致,不得重新发明商品。' + prompt } /** Studio tabs: the two generation modes plus the gallery view. */ type PanelTab = GenerateMode | 'gallery' /** Top-level workspaces inside the panel. 'normal' is the classic studio; * more task-oriented modes (prototype, …) can join alongside 'ecommerce'. */ type PanelWorkspace = 'normal' | 'ecommerce' | 'canvas' | 'ppt' type GalleryFilter = string type ComparisonSession = { taskIds: string[]; prompt: string; comparisonId: string } type HistoryGroup = { key: string; entries: HistoryEntry[]; models: string[] } /** One image unit in the ecommerce results canvas: a live queue task or a * restored history entry of the viewed product set. */ interface EcommerceResultItem { id: string label: string slotKey: string status: GenerationTaskStatus model: string prompt: string error?: string images: GeneratedImage[] /** Queue id for live tasks; absent on history-restored items. */ taskId?: string /** The request to resubmit when regenerating this slot. */ source: GenerateRequest } function modelsOfHistoryEntry(entry: HistoryEntry): string[] { return entry.comparisonModels?.length !== undefined && entry.comparisonModels.length > 1 ? entry.comparisonModels : [entry.model] } /** Comparison runs collapse by comparisonId, product sets by projectId. */ function historyGroupKey(entry: HistoryEntry): string { if (entry.comparisonId !== undefined) return entry.comparisonId if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) return `project:${entry.projectId}` return entry.id } /** Collapse the per-model history rows that belong to one comparison run. */ function groupHistoryEntries(entries: HistoryEntry[]): HistoryGroup[] { const groups = new Map() for (const entry of entries) { const key = historyGroupKey(entry) const existing = groups.get(key) if (existing === undefined) { groups.set(key, { key, entries: [entry], models: modelsOfHistoryEntry(entry) }) } else { existing.entries.push(entry) existing.models = [...new Set([...existing.models, ...modelsOfHistoryEntry(entry)])] } } return [...groups.values()] } function newComparisonId(): string { const cryptoApi = globalThis.crypto if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID() return `comparison-${Date.now()}-${Math.random().toString(36).slice(2)}` } /** Fold a lightweight poll summary into a richer task already held locally. */ function mergeTaskSummary(summary: GenerationTaskSummary, previous: GenerationTask | undefined): GenerationTask { const { resultAvailable: _resultAvailable, ...task } = summary if (previous === undefined) return task const merged: GenerationTask = { ...previous, ...task, request: { ...previous.request, ...task.request }, } if (summary.resultAvailable && previous.result !== undefined) merged.result = previous.result return merged } /** Stable key for one channel+model pair in the comparison selector. */ function modelChoiceKey(choice: ModelPickerValue): string { return `${choice.channelId}\u0000${choice.model}` } /** Decode a comparison key, rejecting malformed persisted state. */ function modelChoiceFromKey(key: string): ModelPickerValue | undefined { const separator = key.indexOf('\u0000') if (separator < 0) return undefined const channelId = key.slice(0, separator) const model = key.slice(separator + 1) return model === '' ? undefined : { channelId, model } } /** Render the studio. */ export function ImageGenPanel(props: { api: ImageGenApi scope: ImageGenScope sessions?: ISessions conversation?: ConversationService /** The panel is visible; inactive panels keep state but defer optional work. */ active?: boolean }) { const { api, scope, sessions, conversation, active = true } = props const config = useConfig(scope) // The plugin language follows the DSH interface (bridged from ctx.locale); // this tick re-renders the tree so every tt() switches live — the template // library and the inspiration wall render inside this tree. useImageGenLanguageTick() const enabled = config?.enabled ?? true // Channel-aware model options: every selector keeps the owning channel so // repeated aliases can never route through the wrong endpoint. const modelOptions = imageModelOptions(config) const imageModels = modelOptions.models const modelGroups: ImageModelGroup[] = modelOptions.groups const defaultChannelId = modelOptions.defaultChannelId const defaultModel = modelOptions.defaultModel const apiUrl = defaultChannelId !== undefined && (config?.channels ?? []).length > 0 ? (config!.channels!.find(channel => channel.id === defaultChannelId)?.apiUrl ?? '') : (config?.apiUrl ?? '') const configured = apiUrl.trim() !== '' const legacyKeySet = useSecretSet(scope, 'apiKey') const promptKeySet = useSecretSet(scope, 'promptApiKey') const channelKeySet = (config?.channels ?? []).some(channel => scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)) const apiKeySet = (config?.channels ?? []).length > 0 ? channelKeySet : legacyKeySet const [subscriptionStates, setSubscriptionStates] = useState>(INITIAL_SUBSCRIPTION_STATES) const subscriptionChannelsKey = (config?.channels ?? []) .filter(channel => channel.auth === 'subscription' && channel.subscription !== undefined) .map(channel => `${channel.id}:${channel.subscription}`) .sort() .join('|') useEffect(() => { if (!active || subscriptionChannelsKey === '') return let disposed = false const refresh = (): void => { void fetch(SUBSCRIPTION_API.status, { method: 'POST', cache: 'no-store' }) .then(async response => await response.json() as { ok?: boolean; statuses?: Record }) .then(body => { if (disposed || body.ok !== true || body.statuses === undefined) return setSubscriptionStates(current => { const next = { ...current } for (const provider of SUBSCRIPTION_PROVIDERS) { const state = body.statuses?.[provider]?.state if (state === 'logged-in' || state === 'logged-out' || state === 'unknown') next[provider] = state } return next }) }) .catch(() => { /* status is advisory; generation still reports its real error */ }) } refresh() const timer = window.setInterval(refresh, 30_000) return () => { disposed = true; window.clearInterval(timer) } }, [active, subscriptionChannelsKey]) const connectedChannelIds = new Set( (config?.channels ?? []) .filter(channel => channel.auth === 'subscription' && channel.subscription !== undefined ? subscriptionStates[channel.subscription] !== 'logged-out' : channel.apiUrl.trim() !== '' && scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)) .map(channel => channel.id), ) const connected = enabled && (config?.channels ?? []).length > 0 ? connectedChannelIds.size > 0 : enabled && configured && apiKeySet /** Whether the channel behind one concrete model choice can submit now. */ const channelReady = (channelId: string): boolean => (config?.channels ?? []).length > 0 ? connectedChannelIds.has(channelId) : enabled && configured && apiKeySet const reusablePromptChannelIds = new Set( (config?.channels ?? []) .filter(channel => channel.auth !== 'subscription' && channel.apiUrlFull !== true && channel.apiUrl.trim() !== '' && scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)) .map(channel => channel.id), ) const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || reusablePromptChannelIds.size > 0 const promptKeyConfigured = promptKeySet || reusablePromptChannelIds.size > 0 const promptReady = enabled && (config?.promptModel ?? '').trim() !== '' && promptEndpointConfigured && promptKeyConfigured const [tab, setTab] = useState('text') const [workspace, setWorkspace] = useState('normal') const [pptImport, setPptImport] = useState() const pptImportId = useRef(0) const openPpt = (image: GeneratedImage, index = 0): void => { setPptImport({ id: ++pptImportId.current, file: generatedImageToFile(image, index) }) setWorkspace('ppt') setPreview(null) } const [canvasImportRequest, setCanvasImportRequest] = useState<{ source: 'history' | 'gallery'; entryId: string; imageIndex: number } | undefined>() /** Switch to a normal-generation tab, leaving any task workspace. */ const openTab = (next: PanelTab): void => { setWorkspace('normal') setTab(next) } const addEntryToCanvas = (source: 'history' | 'gallery', entryId: string, imageIndex = 0): void => { setCanvasImportRequest({ source, entryId, imageIndex }) setWorkspace('canvas') } const [prompt, setPrompt] = useState('') const [size, setSize] = useState('auto') const [quality, setQuality] = useState('auto') const [count, setCount] = useState(1) const [detail, setDetail] = useState('') const [model, setModel] = useState('') const [modelChannelId, setModelChannelId] = useState('') const [compareEnabled, setCompareEnabled] = useState(false) /** Compare selections are `${channelId}\0${model}` so duplicate aliases stay distinct. */ const [compareModels, setCompareModels] = useState([]) const [images, setImages] = useState([]) const [galleryConversationAddingId, setGalleryConversationAddingId] = useState(null) const [historyConversationAddingId, setHistoryConversationAddingId] = useState(null) const [conversationMessage, setConversationMessage] = useState(null) const [error, setError] = useState(null) const conversationAttachment = useConversationImageAttachment({ sessions, conversation, prompt, prepareImage: prepareConversationImage, attachImage: (sessionId, dataUrl, name) => api.attachConversationImage(sessionId, dataUrl, name), messages: { noSession: tt('conversation.noSession'), unavailable: tt('conversation.unavailable'), busy: tt('conversation.busy'), }, onError: caught => { setError(errorMessage(caught)) }, onAdded: () => { setConversationMessage(tt('conversation.added')) window.setTimeout(() => { setConversationMessage(null) }, 2200) }, }) const editReferences = useEditReferences(setError) // Submission is brief; actual generation stays visible until the host // queue reports that every queued/running task has finished. const [submitting, setSubmitting] = useState(false) const [enhancing, setEnhancing] = useState(false) const [configGuide, setConfigGuide] = useState<'generation' | 'enhancement' | 'disabled' | null>(null) const [history, setHistory] = useState([]) const [viewingHistoryId, setViewingHistoryId] = useState(null) const [gallery, setGallery] = useState([]) const [galleryViewingId, setGalleryViewingId] = useState(null) const [galleryAdding, setGalleryAdding] = useState(false) const [galleryMessage, setGalleryMessage] = useState(null) const [galleryFilter, setGalleryFilter] = useState('all') const galleryUploadRef = useRef(null) const [galleryRatio, setGalleryRatio] = useState('all') const [galleryTagFilter, setGalleryTagFilter] = useState(null) const [galleryView, setGalleryView] = useState<'masonry' | 'grid'>('masonry') const [gallerySort, setGallerySort] = useState<'newest' | 'oldest'>('newest') const [galleryQuery, setGalleryQuery] = useState('') const [galleryTagInput, setGalleryTagInput] = useState('') const [editingGalleryTagsId, setEditingGalleryTagsId] = useState(null) const [galleryTagEditInput, setGalleryTagEditInput] = useState('') const [selectedGalleryIds, setSelectedGalleryIds] = useState>(new Set()) const [gallerySelecting, setGallerySelecting] = useState(false) const [historyQuery, setHistoryQuery] = useState('') const [historyModelFilter, setHistoryModelFilter] = useState('all') const [historyRatioFilter, setHistoryRatioFilter] = useState('all') const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null) const [previewScale, setPreviewScale] = useState(1) const [promptCopied, setPromptCopied] = useState(false) const [update, setUpdate] = useState(null) const [updating, setUpdating] = useState(false) const [updateMessage, setUpdateMessage] = useState(null) const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null) const [updateOpen, setUpdateOpen] = useState(false) const updateAreaRef = useRef(null) const [libraryOpen, setLibraryOpen] = useState(false) const generationRef = useRef(null) useEffect(() => { setLibraryOpen(false) }, [workspace, tab]) const [tasks, setTasks] = useState([]) const tasksRef = useRef([]) const initialDataRequestedRef = useRef(false) const taskPollStartedAtRef = useRef(Date.now()) const observedTaskIdsRef = useRef>(new Set()) const submittedTaskIdsRef = useRef>(new Set()) const [taskTrayOpen, setTaskTrayOpen] = useState(false) /** #38: the retained record of upstream attempts, opened from the task tray. */ const [diagnosticsOpen, setDiagnosticsOpen] = useState(false) const [comparison, setComparison] = useState(null) const [comparisonFullscreen, setComparisonFullscreen] = useState(false) const [ecommerce, setEcommerce] = useState(() => { try { const saved = window.localStorage.getItem(ECOMMERCE_DRAFT_STORAGE_KEY) if (saved !== null) { const merged: ProductSetDraft = { ...defaultEcommerceDraft(), ...JSON.parse(saved) as Partial } // Drafts saved before reference roles existed keep working: every slot // defaults to following the product image. if (Array.isArray(merged.slots)) { merged.slots = merged.slots.map(slot => ({ ...slot, refRole: slot.refRole ?? 'product' })) } // Drafts from the three-field era (卖点/保护要素/风格) fold into 参数信息. merged.promptInfo = typeof merged.promptInfo === 'string' ? merged.promptInfo : '' if (merged.promptInfo.trim() === '') { const legacy = [merged.sellingPoints ?? '', merged.protectedFeatures ?? '', merged.styleHint ?? ''] .map(part => part.trim()) .filter(part => part !== '') if (legacy.length > 0) merged.promptInfo = legacy.join('\n') } return merged } } catch { /* ignore malformed or unavailable storage */ } return defaultEcommerceDraft() }) const [ecommercePreview, setEcommercePreview] = useState(false) const [ecommerceGenerating, setEcommerceGenerating] = useState(false) const [ecommerceEnhancing, setEcommerceEnhancing] = useState(false) const [ecommercePolishingKey, setEcommercePolishingKey] = useState(null) const [ecommerceProjectId, setEcommerceProjectId] = useState(null) const [ecommerceAssets, setEcommerceAssets] = useState([]) /** History-restored product set currently shown in the results canvas. */ const [ecommerceRestored, setEcommerceRestored] = useState<{ projectId: string; projectName: string; items: EcommerceResultItem[] } | null>(null) /** Pending main-image anchor: the main image task is in flight; once it * completes, the remaining slots are resubmitted with it as their shared * reference so every image in the set shows the same product. */ const [ecommerceAnchor, setEcommerceAnchor] = useState<{ projectId: string; mainTaskIds: string[]; remaining: GenerateRequest[] } | null>(null) const [ecommerceRefOpen, setEcommerceRefOpen] = useState(false) const [configCollapsed, setConfigCollapsed] = useState(readConfigCollapsed) const [chatOpen, setChatOpen] = useState(readChatOpen) const [configWidth, setConfigWidth] = useState(readConfigWidth) const configAsideRef = useRef(null) const currentSessionId = useCurrentSessionId(sessions) const sidebarHistoryHost = useSidebarHistoryHost() const modeGroups: ImageModelGroup[] = tab === 'edit' ? modelGroups .map(group => ({ ...group, models: group.models.filter(candidate => describeModel(candidate).supportsEdit) })) .filter(group => group.models.length > 0) : modelGroups const modeModels = [...new Set(modeGroups.flatMap(group => group.models))] const previewStage = useRef(null) const activeTasks = tasks.filter(task => task.status === 'queued' || task.status === 'running') const activeTask = activeTasks.find(task => task.status === 'running') ?? activeTasks[0] const generating = submitting || activeTasks.length > 0 const generationStartedAt = activeTask?.startedAt ?? activeTask?.createdAt ?? null const elapsed = useElapsed(generating, generationStartedAt) useEffect(() => { try { window.localStorage.setItem(ECOMMERCE_DRAFT_STORAGE_KEY, JSON.stringify(ecommerce)) } catch { /* optional draft persistence */ } }, [ecommerce]) useEffect(() => { try { window.localStorage.setItem(CONFIG_COLLAPSED_STORAGE_KEY, String(configCollapsed)) } catch { // Embedded shells may disable local storage; the in-memory toggle still works. } }, [configCollapsed]) // Chat-pane visibility rides a document-level attribute so the center-column // grid can drop the conversation entirely. Collapsed by default. useEffect(() => { if (chatOpen) delete document.documentElement.dataset.dshImagegenChatCollapsed else document.documentElement.dataset.dshImagegenChatCollapsed = '1' try { window.localStorage.setItem(CHAT_COLLAPSED_STORAGE_KEY, chatOpen ? 'open' : 'collapsed') } catch { /* optional */ } }, [chatOpen]) useEffect(() => () => { delete document.documentElement.dataset.dshImagegenChatCollapsed }, []) // Main-image anchor chain: when the main image task of a product set // completes, resubmit the remaining slots with the generated main image as // their shared reference. Cleared up front so a re-render cannot double- // submit; failures surface as a canvas error. useEffect(() => { if (ecommerceAnchor === null) return const anchor = ecommerceAnchor const mains = tasks.filter(task => anchor.mainTaskIds.includes(task.id)) if (mains.length === 0) return if (mains.every(task => task.status === 'failed' || task.status === 'cancelled')) { setEcommerceAnchor(null) setError(tt('ecommerce.anchorFailed')) return } const done = mains.find(task => task.status === 'completed' && task.result !== undefined && task.result.images.length > 0) if (done === undefined) return setEcommerceAnchor(null) const dataUrl = srcOf(done.result!.images[0]!) const requests = anchor.remaining.map(request => ({ ...request, mode: 'edit' as const, image: dataUrl, refName: 'set-main-anchor', prompt: withAnchorNote(request.prompt), })) void Promise.all(requests.map(request => api.taskSubmit(request))) .then(submitted => { for (const task of submitted) submittedTaskIdsRef.current.add(task.id) setTasks(previous => [...submitted, ...previous]) }) .catch(caught => { setError(errorMessage(caught)) }) }, [api, tasks, ecommerceAnchor]) // A saved settings change is authoritative. Keep both levels of the active // selection and comparison choices inside the current channel/model matrix. const modelPreferenceKey = `${modeGroups.map(group => `${group.id}:${group.models.join(',')}`).join('|')}\u0000${defaultModel ?? ''}` useEffect(() => { const preferred = defaultModel !== undefined && modeModels.includes(defaultModel) ? defaultModel : modeModels[0] ?? '' const selected = resolveModelChoice(modeGroups, { channelId: modelChannelId, model: preferred }, defaultChannelId) setModel(selected.model) setModelChannelId(selected.channelId) setCompareModels(previous => { const retained = previous.filter(key => { const choice = modelChoiceFromKey(key) if (choice === undefined) return false return modeGroups.some(group => group.id === choice.channelId && group.models.includes(choice.model)) }) if (retained.length > 0) return retained return selected.model === '' ? [] : [modelChoiceKey(selected)] }) }, [modelPreferenceKey]) const filteredGallery = gallery .filter(entry => { if (galleryFilter === 'all') return true if (galleryFilter === 'text' || galleryFilter === 'edit') return entry.mode === galleryFilter return entry.model === galleryFilter }) .filter(entry => galleryRatio === 'all' || normalizeSize(entry.size) === galleryRatio) .filter(entry => galleryTagFilter === null || (entry.tags ?? []).includes(galleryTagFilter)) .filter(entry => galleryQuery.trim() === '' || `${entry.prompt} ${entry.model} ${(entry.tags ?? []).join(' ')}`.toLocaleLowerCase().includes(galleryQuery.trim().toLocaleLowerCase())) .slice() .sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt) const galleryTagOptions = [...new Set(gallery.flatMap(entry => entry.tags ?? []))].sort((a, b) => a.localeCompare(b)) const galleryModels = [...new Set([...imageModels, ...gallery.map(entry => entry.model)])] const filteredHistory = groupHistoryEntries(history).filter(group => group.entries.some(entry => { const query = historyQuery.trim().toLocaleLowerCase() const models = modelsOfHistoryEntry(entry) return (query === '' || `${entry.prompt} ${models.join(' ')}`.toLocaleLowerCase().includes(query)) && (historyModelFilter === 'all' || models.includes(historyModelFilter)) && (historyRatioFilter === 'all' || normalizeSize(entry.size) === historyRatioFilter) })) // Load the host-persisted history and gallery once, when the panel first // becomes visible. Keeping this out of boot prevents eager full-size image // requests while image generation is closed. useEffect(() => { if (!active || initialDataRequestedRef.current) return initialDataRequestedRef.current = true api.historyList() .then(entries => { setHistory(entries) }) .catch(() => { /* history unavailable — leave the list empty */ }) api.galleryList() .then(entries => { setGallery(entries) }) .catch(() => { /* gallery unavailable — leave the list empty */ }) }, [api, active]) // Chat toolviews publish durable refs after they finish loading. Decode the // refs through the same host-authorized route and make them the current // canvas result for the selected session. useEffect(() => { const onChatImages = (event: Event): void => { const detail = (event as CustomEvent).detail if (detail === undefined || currentSessionId === undefined || detail.sessionId !== currentSessionId) return void Promise.all(detail.refs.map(attachmentToGenerated)) .then(next => { openTab('text') setImages(next) setComparison(null) setViewingHistoryId(null) setGalleryViewingId(null) setError(null) }) .catch(caught => { setError(errorMessage(caught)) }) } document.addEventListener(CHAT_IMAGE_EVENT, onChatImages) return () => document.removeEventListener(CHAT_IMAGE_EVENT, onChatImages) }, [currentSessionId]) useEffect(() => { let disposed = false let refreshing = false const refresh = (): void => { if (refreshing) return refreshing = true void (async () => { try { const summaries = await api.taskList() if (disposed) return const previousTasks = tasksRef.current const previousById = new Map(previousTasks.map(task => [task.id, task] as const)) for (const summary of summaries) { if (summary.status === 'queued' || summary.status === 'running') { observedTaskIdsRef.current.add(summary.id) } } // Hydrate only tasks created after this panel mounted, or tasks we // already saw running. Completed tasks from older sessions stay // lightweight; their images are available from history. const needsHydration = summaries.filter(summary => { const previous = previousById.get(summary.id) return summary.status === 'completed' && summary.resultAvailable && previous?.status !== 'completed' && (submittedTaskIdsRef.current.has(summary.id) || observedTaskIdsRef.current.has(summary.id) || summary.createdAt >= taskPollStartedAtRef.current) }) const fetched = await Promise.all(needsHydration.map(async summary => { try { return await api.taskGet(summary.id) } catch { return undefined } })) if (disposed) return const fullById = new Map( fetched .filter((task): task is GenerationTask => task !== undefined) .map(task => [task.id, task] as const), ) const pending = new Set( needsHydration .filter(summary => !fullById.has(summary.id)) .map(summary => summary.id), ) const next = summaries.flatMap(summary => { const full = fullById.get(summary.id) if (full !== undefined) return [full] const previous = previousById.get(summary.id) // Omit the task or keep its old active state when hydration fails; // the next poll then retries instead of settling without images. if (pending.has(summary.id)) return previous === undefined ? [] : [previous] return [mergeTaskSummary(summary, previous)] }) for (const task of fullById.values()) observedTaskIdsRef.current.add(task.id) const hydratedCompleted = next.filter(task => task.status === 'completed' && task.result !== undefined && !previousTasks.some(old => old.id === task.id && old.status === 'completed')) tasksRef.current = next setTasks(current => { const completed = next.find(task => task.status === 'completed' && task.result !== undefined && !current.some(old => old.id === task.id && old.status === 'completed') && !comparison?.taskIds.includes(task.id)) if (completed?.result !== undefined) { setImages(completed.result.images) if (completed.result.history !== undefined) setHistory(completed.result.history) setError(completed.result.historyError ?? null) } return next }) if (hydratedCompleted.length > 0) { void api.historyList().then(entries => { if (!disposed) setHistory(entries) }).catch(() => {}) } } catch { // A transient host error must not stop the polling loop. } finally { refreshing = false } })() } refresh() const timer = window.setInterval(refresh, 1500) return () => { disposed = true; window.clearInterval(timer) } }, [api, comparison]) // Release checks are host-mediated and intentionally best-effort: a GitHub // outage must never make the image-generation studio unavailable. The host // caches successful checks, while this timer/focus path picks up new Releases. useEffect(() => { let disposed = false let checking = false const check = (): void => { if (checking) return checking = true api.updateCheck() .then(info => { if (!disposed) { setUpdate(info.updateAvailable ? info : null) if (!info.updateAvailable) setUpdateOpen(false) } }) .catch(() => { /* update discovery is optional */ }) .finally(() => { checking = false }) } check() const timer = window.setInterval(check, UPDATE_CHECK_INTERVAL_MS) const onVisibility = (): void => { if (document.visibilityState === 'visible') check() } document.addEventListener('visibilitychange', onVisibility) return () => { disposed = true window.clearInterval(timer) document.removeEventListener('visibilitychange', onVisibility) } }, [api]) useEffect(() => { if (!updateOpen) return const close = (event: PointerEvent): void => { const target = event.target if (target instanceof Node && updateAreaRef.current?.contains(target) === true) return setUpdateOpen(false) } window.addEventListener('pointerdown', close, true) return () => window.removeEventListener('pointerdown', close, true) }, [updateOpen]) const applyUpdate = async (): Promise => { if (update === null || updating) return setUpdating(true) setUpdateMessage(null) setUpdateResult(null) try { const result = await api.updateApply(update.latestVersion) setUpdateMessage(tt('update.success', { version: result.updatedVersion })) setUpdateResult('success') } catch (caught) { setUpdateMessage(`${tt('update.failed')}\n${errorMessage(caught)}`) setUpdateResult('failed') } finally { setUpdating(false) } } const openSettingsGuide = (kind: 'generation' | 'enhancement' | 'disabled'): void => { setConfigGuide(kind) openImageGenConfig() } const enhanceCurrentPrompt = async (): Promise => { if (prompt.trim() === '' || enhancing) return if (!promptReady) { openSettingsGuide('enhancement') return } setEnhancing(true) setError(null) try { setPrompt(await api.enhancePrompt(prompt)) } catch (caught) { setError(errorMessage(caught)) } finally { setEnhancing(false) } } /** AI 帮写:整理/补全电商参数信息(提示词),走提示词增强通道。 */ const ecommerceEnhanceInfo = async (): Promise => { if (ecommerceEnhancing) return if (!promptReady) { openSettingsGuide('enhancement') return } setEcommerceEnhancing(true) setError(null) try { const instruction = [ '你是电商图文策划。请把下面的商品信息整理成一段可直接用于 AI 生图提示词的中文参数描述(120 字以内),', '涵盖商品主体、规格材质、核心卖点与必须保留的特征;信息缺失处按商品类目合理补全,不要解释,只输出整理结果。', `商品名称:${ecommerce.productName.trim() || '未提供'}`, `商品类目:${ecommerce.category}`, `商品平台:${ecommerce.platform}`, `已有信息:${ecommerce.promptInfo.trim() !== '' ? ecommerce.promptInfo : '无,请根据商品名称与类目合理补全'}`, ].join('\n') const result = await api.enhancePrompt(instruction) setEcommerce(previous => ({ ...previous, promptInfo: result })) } catch (caught) { setError(errorMessage(caught)) } finally { setEcommerceEnhancing(false) } } /** Polish one pre-generation ecommerce prompt through the chat model. */ const polishEcommercePrompt = async (slot: ProductSetSlot, index: number, key: string): Promise => { if (ecommercePolishingKey !== null) return if (!promptReady) { openSettingsGuide('enhancement') return } const source = ecommerce.promptOverrides?.[key]?.trim() || ecommerceSlotPrompt(ecommerce, slot, index) setEcommercePolishingKey(key) setError(null) try { const polished = await api.polishPrompt(source) setEcommerce(previous => ({ ...previous, promptOverrides: { ...previous.promptOverrides, [key]: polished }, })) } catch (caught) { setError(errorMessage(caught)) } finally { setEcommercePolishingKey(null) } } const referenceLimitForChoice = (choice: ModelPickerValue): number => editReferenceLimit(choice.model, config?.channels?.find(channel => channel.id === choice.channelId) ?? { apiUrl: config?.apiUrl ?? '', apiUrlFull: false, models: [] }, ) /** Read uploaded product assets into session-only data-URL chips. Product * refs cap at MAX_ECOMMERCE_ASSETS; the style group holds a single image * that gets replaced on re-upload. */ const acceptEcommerceFiles = (files: FileList | undefined, role: 'product' | 'style' = 'product'): void => { if (files === undefined) return const incoming = Array.from(files).filter(file => file.type.startsWith('image/') && file.size <= REF_IMAGE_MAX_BYTES) if (incoming.length === 0) { setError(tt('edit.uploadHint')) return } for (const file of incoming) { const reader = new FileReader() reader.onload = () => { if (typeof reader.result !== 'string') return const dataUrl = reader.result setEcommerceAssets(previous => { if (role === 'style') { const styleAsset = { id: newComparisonId(), dataUrl, name: file.name, role: 'style' as const } return [...previous.filter(item => item.role !== 'style'), styleAsset] } if (previous.filter(item => item.role !== 'style').length >= MAX_ECOMMERCE_ASSETS) { setError(tt('ecommerce.assetsFull')) return previous } return [...previous, { id: newComparisonId(), dataUrl, name: file.name, role: 'product' }] }) } reader.onerror = () => { setError(tt('edit.uploadHint')) } reader.readAsDataURL(file) } } /** Run one generation. */ const handleGenerate = async (): Promise => { if (submitting || (tab === 'edit' && editReferences.reading)) return if (!enabled) { openSettingsGuide('disabled') return } const selectedChoice = resolveModelChoice(modeGroups, { channelId: modelChannelId, model }, defaultChannelId) if (!channelReady(selectedChoice.channelId)) { openSettingsGuide('generation') return } const promptText = prompt.trim() if (promptText === '') { setError(tt('prompt.required')) return } if (tab === 'edit' && editReferences.images.length === 0) { setError(tt('edit.required')) return } const baseRequest: Omit = { mode: tab === 'edit' ? 'edit' : 'text', prompt: promptText, size, quality, n: count, detail, ...(tab === 'edit' && editReferences.images.length > 0 ? { image: editReferences.images[0]!.dataUrl, ...(editReferences.images.length > 1 ? { images: editReferences.images.slice(1).map(image => image.dataUrl) } : {}), refName: editReferences.images.map(image => image.name).join('、'), } : {}), } setError(null) setSubmitting(true) try { const targetChoices = (compareEnabled ? compareModels.map(modelChoiceFromKey).filter((choice): choice is ModelPickerValue => choice !== undefined) : [selectedChoice]) .filter(choice => modeGroups.some(group => group.id === choice.channelId && group.models.includes(choice.model))) if (targetChoices.length === 0) { setError(tt('compare.selectRequired')) return } if (!targetChoices.every(choice => channelReady(choice.channelId))) { openSettingsGuide('generation') return } const referenceLimit = Math.min(...targetChoices.map(referenceLimitForChoice)) if (tab === 'edit' && editReferences.images.length > referenceLimit) { setError(tt('edit.modelLimit', { count: referenceLimit })) return } const comparisonId = targetChoices.length > 1 ? newComparisonId() : undefined const comparisonFields = comparisonId === undefined ? {} : { comparisonId, comparisonModels: targetChoices.map(choice => choice.model) } const submitted = await Promise.all(targetChoices.map(choice => api.taskSubmit({ ...baseRequest, model: choice.model, ...choice.channelId === '' ? {} : { channelId: choice.channelId }, ...comparisonFields, }))) for (const task of submitted) submittedTaskIdsRef.current.add(task.id) setTasks(previous => [...submitted, ...previous.filter(item => !submitted.some(task => task.id === item.id))]) setComparison(comparisonId === undefined ? null : { taskIds: submitted.map(task => task.id), prompt: promptText, comparisonId }) } catch (caught) { setError(errorMessage(caught)) } finally { setSubmitting(false) } } const handleEcommerceGenerate = async (): Promise => { if (ecommerceGenerateDisabled) return const selectedChoice = resolveModelChoice(modeGroups, { channelId: modelChannelId, model }, defaultChannelId) if (!enabled || !channelReady(selectedChoice.channelId)) { openSettingsGuide('generation'); return } const projectId = ecommerce.projectId || newComparisonId() const buildRequest = (slot: ProductSetSlot, index: number): GenerateRequest => { // Each slot picks one reference by asset role; a slot without a matching // asset (or 'none') falls back to text-to-image. const refRole = slot.refRole ?? 'product' const asset = refRole === 'none' ? undefined : ecommerceAssets.find(item => item.role === refRole) return { mode: asset !== undefined ? 'edit' as const : 'text' as const, model: selectedChoice.model, prompt: ecommerceSlotPrompt(ecommerce, slot, index), size: ecommerce.size, quality, n: 1, detail, ...(selectedChoice.channelId === '' ? {} : { channelId: selectedChoice.channelId }), ...(asset !== undefined ? { image: asset.dataUrl, refName: asset.name } : {}), workflow: 'ecommerce' as const, projectId, projectName: ecommerce.productName.trim(), slotKey: `${slot.key}-${index + 1}`, slotLabel: slot.label, } } // Anchor chain: with a main-image slot enabled, only the main image is // submitted now; the remaining slots follow once it completes (see the // anchor effect) so the whole set shares one product. Without a main // slot, every slot submits immediately with its own reference. const mainSlots = ecommerceSlots.filter(slot => slot.key === 'main') const otherSlots = ecommerceSlots.filter(slot => slot.key !== 'main') const anchorChain = mainSlots.length > 0 && otherSlots.length > 0 const leadSlots = anchorChain ? mainSlots : ecommerceSlots const requests = leadSlots.flatMap(slot => Array.from({ length: slot.count }, (_, index) => buildRequest(slot, index))) const remaining = anchorChain ? otherSlots.flatMap(slot => Array.from({ length: slot.count }, (_, index) => { const { image: _image, refName: _refName, ...rest } = buildRequest(slot, index) return rest })) : [] setEcommerceGenerating(true); setSubmitting(true); setError(null); setEcommerceProjectId(projectId); setEcommerceRestored(null); setEcommerceAnchor(null) try { const submitted = await Promise.all(requests.map(request => api.taskSubmit(request))) for (const task of submitted) submittedTaskIdsRef.current.add(task.id) setTasks(previous => [...submitted, ...previous]) setEcommercePreview(false) if (anchorChain) setEcommerceAnchor({ projectId, mainTaskIds: submitted.map(task => task.id), remaining }) } catch (caught) { setError(errorMessage(caught)) } finally { setSubmitting(false); setEcommerceGenerating(false) } } /** Start over with a fresh product draft (the old results stay in history). */ const newEcommerceProduct = (): void => { setEcommerce(defaultEcommerceDraft()) setEcommercePreview(false) setEcommerceProjectId(null) setEcommerceRestored(null) setEcommerceAnchor(null) setEcommerceAssets([]) editReferences.clear() setError(null) } /** Re-run every image of one slot with its original request. */ const regenerateEcommerceSlot = async (label: string): Promise => { if (ecommerceGenerating) return const group = ecommerceMergedItems.filter(item => item.label === label) if (group.length === 0) return setEcommerceGenerating(true) setError(null) try { const submitted = await Promise.all(group.map(item => item.taskId === undefined ? api.taskSubmit({ ...item.source }) : api.taskRetry(item.taskId))) for (const task of submitted) submittedTaskIdsRef.current.add(task.id) setTasks(previous => [...submitted, ...previous]) } catch (caught) { setError(errorMessage(caught)) } finally { setEcommerceGenerating(false) } } /** Open one persisted product set from history: rebuild the grouped results * canvas from its entries. Reference images are not persisted, so restored * edit-mode slots regenerate as text-to-image. */ const viewEcommerceProject = async (group: HistoryGroup): Promise => { const entry = group.entries[0] if (entry === undefined || entry.projectId === undefined) return try { const items: EcommerceResultItem[] = await Promise.all(group.entries.map(async item => ({ id: item.id, label: item.slotLabel ?? '', slotKey: item.slotKey ?? '', status: 'completed' as const, model: item.model, prompt: item.prompt, images: await historyImagesToGenerated(item.images), source: { mode: item.mode === 'edit' ? 'text' as const : item.mode, model: item.model, prompt: item.prompt, size: item.size, quality: item.quality, detail: item.detail, n: 1, ...item.channelId !== undefined ? { channelId: item.channelId } : {}, workflow: 'ecommerce' as const, projectId: entry.projectId!, projectName: entry.projectName ?? '', slotKey: item.slotKey ?? '', slotLabel: item.slotLabel ?? '', }, }))) setWorkspace('ecommerce') setEcommerceRestored({ projectId: entry.projectId, projectName: entry.projectName ?? '', items }) setEcommerceProjectId(entry.projectId) setEcommercePreview(false) setError(null) setViewingHistoryId(entry.id) setGalleryViewingId(null) } catch (caught) { setError(errorMessage(caught)) } } /** Download a JSON manifest describing the whole product set (prompts, * slots and task outcomes) so results stay reproducible outside the panel. */ const exportEcommerceManifest = (): void => { const manifest = { project: { id: ecommerceProjectId, name: ecommerce.projectName || ecommerce.productName, productName: ecommerce.productName, category: ecommerce.category, platform: ecommerce.platform, language: ecommerce.language, size: ecommerce.size, promptInfo: ecommerce.promptInfo, }, generatedAt: new Date().toISOString(), images: ecommerceMergedItems.map(item => ({ slotKey: item.slotKey, slotLabel: item.label, status: item.status, model: item.model, prompt: item.prompt, error: item.error, })), } const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = `dsh-product-set-${(ecommerce.projectName || ecommerce.productName || 'set').replace(/[^\w-]+/g, '-')}.json` anchor.click() URL.revokeObjectURL(url) } const openPreview = (previewImages: GeneratedImage[], index: number): void => { setPreview({ images: previewImages, index }) setPreviewScale(1) setPromptCopied(false) } const closePreview = (): void => { setPreview(null) setPreviewScale(1) setPromptCopied(false) } /** Step the preview by ±1, wrapping around. */ const stepPreview = (delta: number): void => { setPreviewScale(1) setPromptCopied(false) setPreview(current => { if (current === null) return null const total = current.images.length return { images: current.images, index: (current.index + delta + total) % total } }) } // Keyboard navigation for the preview overlay. useEffect(() => { if (preview === null) return const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') closePreview() else if (event.key === 'ArrowLeft') stepPreview(-1) else if (event.key === 'ArrowRight') stepPreview(1) else if (event.key === '+' || event.key === '=') setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP)) else if (event.key === '-') setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP)) else if (event.key === '0') setPreviewScale(1) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [preview]) // A scaled image owns real scrollable space, rather than being visually // transformed and clipped. Recenter the viewport after every zoom or slide. useEffect(() => { if (preview === null) return const frame = window.requestAnimationFrame(() => { const stage = previewStage.current if (stage === null) return stage.scrollLeft = Math.max(0, (stage.scrollWidth - stage.clientWidth) / 2) stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) / 2) }) return () => window.cancelAnimationFrame(frame) }, [preview, previewScale]) const loadHistoryGroup = async (group: HistoryGroup): Promise => { const loaded = await Promise.all(group.entries.map(entry => historyImagesToGenerated(entry.images))) return loaded.flat() } /** View every model result from one comparison as one canvas result set. */ const viewHistoryGroup = async (group: HistoryGroup): Promise => { const entry = group.entries[0] if (entry === undefined) return // Product sets rebuild their grouped results canvas instead of the // generic image workspace. if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) { await viewEcommerceProject(group) return } // History is also the bridge out of the gallery: show the image workspace // immediately, then hydrate the selected result into its canvas. openTab('text') try { setImages(await loadHistoryGroup(group)) setComparison(null) setError(null) setViewingHistoryId(entry.id) setGalleryViewingId(null) } catch (caught) { setError(errorMessage(caught)) } } /** Remove every persisted row belonging to one comparison group. */ const deleteHistoryGroup = async (group: HistoryGroup): Promise => { const ids = new Set(group.entries.map(entry => entry.id)) setHistory(previous => previous.filter(entry => !ids.has(entry.id))) if (viewingHistoryId !== null && ids.has(viewingHistoryId)) setViewingHistoryId(null) if (ecommerceRestored !== null && group.entries.some(entry => entry.projectId === ecommerceRestored.projectId)) { setEcommerceRestored(null) setEcommerceProjectId(null) setEcommerceAnchor(null) } try { let next = history for (const id of ids) next = await api.historyRemove(id) setHistory(next) } catch { // Keep the optimistic local removal. } } /** Reset the workspace for a fresh image-generation run. */ const startNewCreation = (): void => { openTab('text') setPrompt('') editReferences.clear() setImages([]) setPreview(null) setPreviewScale(1) setPromptCopied(false) setViewingHistoryId(null) setGalleryViewingId(null) setGallerySelecting(false) setSelectedGalleryIds(new Set()) setComparison(null) setComparisonFullscreen(false) setEcommerceRestored(null) setError(null) setConversationMessage(null) setGalleryMessage(null) } /** Remove all history entries. */ const clearHistory = async (): Promise => { if (!window.confirm(tt('history.clearConfirm'))) return setHistory([]) setViewingHistoryId(null) try { setHistory(await api.historyClear()) } catch { // Keep the cleared local state. } } /** Add one generated image to the gallery (host deduplicates by content). * `entry` makes the action available from a history/gallery list item (its * metadata + first image are saved); otherwise the current form state is * used. */ const addToGallery = async (image: GeneratedImage, entry?: HistoryEntry): Promise => { if (galleryAdding || (workspace === 'normal' && tab === 'gallery')) return const source = entry ?? viewingEntry ?? { mode: tab === 'edit' ? 'edit' as GenerateMode : 'text' as GenerateMode, model, prompt: prompt.trim(), size, quality, detail, ...editReferences.images.length > 0 ? { refName: editReferences.images.map(image => image.name).join('、') } : {}, } setGalleryAdding(true) try { const result = await api.galleryAppend({ id: '', // the host assigns a fresh id createdAt: Date.now(), mode: source.mode, model: source.model, prompt: source.prompt, size: source.size, quality: source.quality, detail: source.detail, n: 1, images: [image], ...source.refName === undefined ? {} : { refName: source.refName }, }) setGallery(result.entries) setGalleryMessage(result.added ? tt('gallery.added') : tt('gallery.already')) window.setTimeout(() => { setGalleryMessage(null) }, 2200) } catch (caught) { setError(errorMessage(caught)) } finally { setGalleryAdding(false) } } /** Save local image files into the asset library so the canvas and studio * can reuse them later; entries dedupe by content host-side. */ const uploadGalleryFiles = async (files: File[]): Promise => { if (files.length === 0 || galleryAdding) return setGalleryAdding(true) try { let entries: HistoryEntry[] | undefined for (const file of files) { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result)) reader.onerror = () => reject(new Error(tt('canvas.dropSub'))) reader.readAsDataURL(file) }) const separator = dataUrl.indexOf(',') const result = await api.galleryAppend({ id: '', createdAt: Date.now(), mode: 'text', model: tt('gallery.uploadModel'), prompt: file.name.replace(/\.[a-z0-9]+$/i, ''), size: 'auto', quality: 'auto', detail: '', n: 1, images: [{ b64: separator >= 0 ? dataUrl.slice(separator + 1) : dataUrl, mime: file.type || 'image/png' }], }) entries = result.entries } if (entries !== undefined) { setGallery(entries) setGalleryMessage(tt('gallery.uploaded')) window.setTimeout(() => { setGalleryMessage(null) }, 2200) } } catch (caught) { setError(errorMessage(caught)) } finally { setGalleryAdding(false) } } /** Put a generated image into the current or explicitly selected conversation. */ const addImageToConversation = conversationAttachment.add /** Add one history entry's first image to the gallery (fetches it from the * history image route, then delegates to addToGallery). */ const addHistoryEntryToGallery = async (entry: HistoryEntry): Promise => { if (galleryAdding || entry.images.length === 0) return try { const [image] = await historyImagesToGenerated(entry.images.slice(0, 1)) if (image === undefined) return await addToGallery(image, entry) } catch (caught) { setError(errorMessage(caught)) } } /** Add one history entry's first image to the current chat draft. */ const addHistoryEntryToConversation = async (entry: HistoryEntry): Promise => { if (historyConversationAddingId !== null || conversationAttachment.busyKey !== null || conversationAttachment.pending !== null || galleryConversationAddingId !== null || entry.images.length === 0) return setHistoryConversationAddingId(entry.id) try { const [image] = await historyImagesToGenerated(entry.images.slice(0, 1)) if (image === undefined) return await addImageToConversation(image, 0, `history:${entry.id}`) } catch (caught) { setError(errorMessage(caught)) } finally { setHistoryConversationAddingId(null) } } /** Load a persisted gallery image and add it to the current chat draft. */ const addGalleryEntryToConversation = async (entry: HistoryEntry): Promise => { if (galleryConversationAddingId !== null || conversationAttachment.busyKey !== null || conversationAttachment.pending !== null || entry.images.length === 0) return setGalleryConversationAddingId(entry.id) try { const [image] = await historyImagesToGenerated(entry.images.slice(0, 1)) if (image === undefined) return await addImageToConversation(image, 0, `gallery:${entry.id}`) } catch (caught) { setError(errorMessage(caught)) } finally { setGalleryConversationAddingId(null) } } /** View a gallery image in the canvas. */ const viewGalleryEntry = async (entry: HistoryEntry): Promise => { try { const restored = await historyImagesToGenerated(entry.images) setImages(restored) setError(null) setViewingHistoryId(null) setGalleryViewingId(entry.id) if (restored.length > 0) openPreview(restored, 0) } catch (caught) { setError(errorMessage(caught)) } } /** Remove one gallery entry. */ const deleteGalleryEntry = async (id: string): Promise => { setGallery(gallery.filter(entry => entry.id !== id)) if (galleryViewingId === id) setGalleryViewingId(null) try { setGallery(await api.galleryRemove(id)) } catch { // Keep the optimistic local removal. } } /** Remove every gallery entry. */ const clearGalleryAll = async (): Promise => { if (!window.confirm(tt('gallery.clearConfirm'))) return setGallery([]) setGalleryViewingId(null) try { setGallery(await api.galleryClear()) } catch { // Keep the cleared local state. } } const applyGalleryTags = async (): Promise => { const tags = galleryTagInput.split(',').map(tag => tag.trim()).filter(Boolean) if (tags.length === 0 || selectedGalleryIds.size === 0) return try { let next = gallery for (const id of selectedGalleryIds) { const existing = next.find(entry => entry.id === id)?.tags ?? [] next = await api.gallerySetTags(id, [...existing, ...tags]) } setGallery(next) setGalleryTagInput('') } catch (caught) { setError(errorMessage(caught)) } } const startEditingGalleryTags = (entry: HistoryEntry): void => { setEditingGalleryTagsId(entry.id) setGalleryTagEditInput((entry.tags ?? []).join(', ')) } const saveGalleryTags = async (id: string): Promise => { const tags = galleryTagEditInput.split(',').map(tag => tag.trim()).filter(Boolean) try { setGallery(await api.gallerySetTags(id, tags)) setEditingGalleryTagsId(null) setGalleryTagEditInput('') } catch (caught) { setError(errorMessage(caught)) } } const toggleGallerySelection = (id: string): void => { setSelectedGalleryIds(previous => { const next = new Set(previous) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const clearGallerySelection = (): void => { setSelectedGalleryIds(new Set()) setGallerySelecting(false) } const exportGalleryJson = (): void => { const entries = gallery.filter(entry => selectedGalleryIds.has(entry.id)) const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = `dsh-imagegen-gallery-${new Date().toISOString().slice(0, 10)}.json` anchor.click() URL.revokeObjectURL(url) } const downloadGalleryImages = (): void => { gallery.filter(entry => selectedGalleryIds.has(entry.id)).forEach((entry, index) => { const image = entry.images[0] if (image === undefined) return const anchor = document.createElement('a') anchor.href = image.url anchor.download = `dsh-gallery-${index + 1}.${extensionOf(image.mime)}` anchor.click() }) } // Prompt hard limit of the model the generate button will actually use // (mirrors handleGenerate's pick). Over the limit the counter turns red and // the button locks — the engine would fast-fail anyway, but the user sees // why before spending a click. const activeModel = modeModels.includes(model) ? model : modeModels[0] ?? '' const promptLimit = promptCharLimit(activeModel) const promptOverLimit = promptLimit !== null && prompt.trim().length >= promptLimit const editTargetChoices = (compareEnabled ? compareModels.map(modelChoiceFromKey).filter((choice): choice is ModelPickerValue => choice !== undefined) : [resolveModelChoice(modeGroups, { channelId: modelChannelId, model }, defaultChannelId)]) .filter(choice => modeGroups.some(group => group.id === choice.channelId && group.models.includes(choice.model))) const editLimit = editTargetChoices.length > 0 ? Math.min(...editTargetChoices.map(referenceLimitForChoice)) : EDIT_REFERENCE_LIMIT const referencePolicyWarning = tab === 'edit' && editReferences.images.length > editLimit ? tt('edit.modelLimit', { count: editLimit }) : null const generateDisabled = submitting || modeModels.length === 0 || promptOverLimit || (tab === 'edit' && (editReferences.reading || referencePolicyWarning !== null)) const ecommerceSlots = ecommerce.slots.filter(slot => slot.enabled && slot.count > 0) const ecommerceTotal = ecommerceSlots.reduce((total, slot) => total + slot.count, 0) const ecommerceGenerateDisabled = submitting || ecommerceGenerating || ecommerceSlots.length === 0 || ecommerce.productName.trim() === '' || (ecommerce.language === 'custom' && effectiveEcommerceLanguage(ecommerce) === '') const ecommerceFileInput = useRef(null) /** Which group (主图/风格) the shared file input uploads into. */ const ecommerceUploadRoleRef = useRef<'product' | 'style'>('product') // The results canvas merges live tasks of the active project with restored // history entries of the same project; restored slots that were regenerated // this session are covered by their live counterparts (same slotKey). const ecommerceProjectTasks = ecommerceProjectId === null ? [] : tasks.filter(task => task.request.workflow === 'ecommerce' && task.request.projectId === ecommerceProjectId) const liveSlotKeys = new Set(ecommerceProjectTasks.map(task => task.request.slotKey ?? task.id)) const ecommerceMergedItems: EcommerceResultItem[] = [ ...ecommerceProjectTasks.map(task => ({ id: task.id, label: task.request.slotLabel ?? '', slotKey: task.request.slotKey ?? '', status: task.status, model: task.request.model, prompt: task.request.prompt, ...task.error !== undefined ? { error: task.error } : {}, images: task.result?.images ?? [], taskId: task.id, source: task.request, })), ...(ecommerceRestored !== null && ecommerceRestored.projectId === ecommerceProjectId ? ecommerceRestored.items.filter(item => !liveSlotKeys.has(item.slotKey)) : []), ] const ecommerceDoneCount = ecommerceMergedItems.filter(item => item.status === 'completed').length const ecommerceFailedCount = ecommerceMergedItems.filter(item => item.status === 'failed' || item.status === 'cancelled').length const ecommerceResultGroups = [...new Set(ecommerceMergedItems.map(item => item.label))] .filter(label => label !== '') .map(label => ({ label, items: ecommerceMergedItems.filter(item => item.label === label) })) // 套图结果左右布局:主图组独占左侧,其余分组在右侧纵排。live 任务的 // slotKey 带序号后缀(main-1),这里按 main 前缀识别主图组。 const ecommerceMainGroup = ecommerceResultGroups.find(group => group.items.some(item => item.slotKey === 'main' || item.slotKey.startsWith('main-'))) ?? null const ecommerceSideGroups = ecommerceResultGroups.filter(group => group !== ecommerceMainGroup) const renderEcommerceGroup = (group: { label: string, items: EcommerceResultItem[] }, main = false): React.JSX.Element => (
{group.label} {group.items.filter(item => item.status === 'completed').length}/{group.items.length}
{group.items.map(item => (
{item.status === 'completed' && item.images.length > 0 ? item.images.map((image, imageIndex) => (
{ openPreview(item.images, imageIndex) }} > {`${group.label} {group.label} event.stopPropagation()}> {tt('download')}
)) : ( {group.label} {tt(`tasks.${item.status}` as never)} {item.error !== undefined ? ` · ${item.error}` : ''} )}
))}
) const conversationBusy = conversationAttachment.busyKey !== null || conversationAttachment.pending !== null || galleryConversationAddingId !== null || historyConversationAddingId !== null const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null const previewImage = preview === null ? null : preview.images[preview.index] ?? null const comparisonTasks = comparison === null ? [] : comparison.taskIds.map(id => tasks.find(task => task.id === id)).filter((task): task is GenerationTask => task !== undefined) const comparisonResults = comparisonTasks.filter(task => task.status === 'completed' && task.result !== undefined) const previewFrameScale = Math.max(1, previewScale) const previewImageScale = previewScale / previewFrameScale /** Drag the config panel's right edge to resize it (persisted per browser). */ const onConfigResizeStart = (event: ReactPointerEvent): void => { if (event.button !== 0) return event.preventDefault() const aside = configAsideRef.current if (aside === null) return const left = aside.getBoundingClientRect().left const onMove = (move: PointerEvent): void => { const width = Math.round(Math.min(CONFIG_WIDTH_MAX, Math.max(CONFIG_WIDTH_MIN, move.clientX - left))) setConfigWidth(width) try { window.localStorage.setItem(CONFIG_WIDTH_STORAGE_KEY, String(width)) } catch { /* optional */ } } const onUp = (): void => { window.removeEventListener('pointermove', onMove) document.documentElement.style.removeProperty('cursor') document.documentElement.style.removeProperty('user-select') } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp, { once: true }) document.documentElement.style.setProperty('cursor', 'col-resize') document.documentElement.style.setProperty('user-select', 'none') } const copyPreviewPrompt = async (text: string): Promise => { try { if (navigator.clipboard?.writeText !== undefined) { await navigator.clipboard.writeText(text) } else { const textarea = document.createElement('textarea') textarea.value = text textarea.style.position = 'fixed' textarea.style.opacity = '0' document.body.appendChild(textarea) textarea.select() const copied = document.execCommand('copy') textarea.remove() if (!copied) throw new Error('copy failed') } setPromptCopied(true) window.setTimeout(() => { setPromptCopied(false) }, 1800) } catch { setPromptCopied(false) } } const addPreviewToEdit = (): void => { if (previewImage === null || preview === null) return openTab('edit') setError(null) editReferences.replace({ dataUrl: srcOf(previewImage), bytes: Math.floor(previewImage.b64.length * 3 / 4) - (previewImage.b64.endsWith('==') ? 2 : previewImage.b64.endsWith('=') ? 1 : 0), name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`, }) if (prompt.trim() === '' && previewImage.revisedPrompt !== undefined) setPrompt(previewImage.revisedPrompt) closePreview() } // Render history into the shell sidebar so it remains a separate navigation // surface from both the image workspace and the native conversation. const historyPanel = ( ) const isGallery = workspace === 'normal' && tab === 'gallery' const isGeneration = workspace === 'normal' && tab !== 'gallery' return (

{tt('panel.title')}

{update !== null ? ( {updateOpen ? (
{tt('update.title')} {tt('update.available', { version: update.latestVersion })}
{update.installMode === 'host-managed' ?
{tt('update.hostManagedHint')}
: null} {update.installMode === 'host' ?
{tt('update.hostHint')}
: null} {updateMessage !== null ?
{updateMessage}
: null}
{update.releaseNotes !== undefined && update.releaseNotes.trim() !== '' ? renderMarkdown(update.releaseNotes) :

{tt('update.noNotes')}

}
{tt('update.release')}
) : null}
) : null}
{ if (index === 0) openTab('text') else if (index === 1) openTab('gallery') else if (index === 2) setWorkspace('canvas') else if (index === 3) setWorkspace('ecommerce') else setWorkspace('ppt') }} items={[ { key: 'normal', label: tt('workspace.normal') }, { key: 'gallery', label: tt('gallery.title') }, { key: 'canvas', label: tt('workspace.canvas') }, { key: 'ecommerce', label: <>{tt('workspace.ecommerce')}{tt('ecommerce.badge')} }, { key: 'ppt', label: tt('ppt.nav') }, ]} />
{/* ------------------------------- left history + generation workspace */}
{ if (workspace !== 'normal' || tab !== 'edit') return const files = clipboardImageFiles(event.clipboardData) if (files.length > 0) { event.preventDefault(); event.stopPropagation(); editReferences.add(files) } }}> {workspace !== 'canvas' && workspace !== 'ppt' ? setLibraryOpen(true)} /> : null} {/* ------------------------------------------------ config sidebar */}