diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index 250f023..ab12424 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -7,9 +7,7 @@ import { CloseOutlined, CopyOutlined, DeleteOutlined, - DownOutlined, DownloadOutlined, - FileTextOutlined, FullscreenOutlined, LoadingOutlined, MessageOutlined, @@ -20,7 +18,6 @@ import { ReloadOutlined, SendOutlined, SettingOutlined, - SoundOutlined, StopOutlined, ThunderboltOutlined, VideoCameraOutlined, @@ -45,16 +42,9 @@ import { assetClient } from "../../api/assetClient"; import { communityClient } from "../../api/communityClient"; import { RechargeModal } from "../../components/RechargeModal/RechargeModal"; -function getCachedRole(): string { - try { - const raw = window.localStorage.getItem("omniai-web-session"); - if (!raw) return ""; - return String(JSON.parse(raw)?.user?.role || "").trim().toLowerCase(); - } catch { return ""; } -} import { conversationClient, type ConversationSummary } from "../../api/conversationClient"; import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient"; -import { buildApiUrl, buildAuthHeaders, isServerRequestError } from "../../api/serverConnection"; +import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import type { WebProjectSummary } from "../../types"; import { @@ -81,10 +71,7 @@ import { import { isViduModel } from "../../utils/viduRouting"; import { isPixverseModel } from "../../utils/pixverseRouting"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; -import { - ENTERPRISE_DEFAULT_VIDEO_MODEL, - ENTERPRISE_VIDEO_MODEL_OPTIONS, -} from "../../utils/enterpriseVideoPolicy"; +import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy"; import { getImageQualityOptions, getDefaultImageQuality, @@ -96,1217 +83,131 @@ import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibil import { persistWorkbenchResultAsset, type PersistedWorkbenchResultAsset } from "./workbenchResultPersistence"; import { SmoothedProgressBar } from "./SmoothedProgressBar"; import { useSmoothedProgress } from "../../hooks/useSmoothedProgress"; +import { + type WorkbenchMode, + type ToolbarMenuId, + type ReferenceKind, + type WorkbenchOption, + type WorkbenchFieldGroup, + type ReferenceItem, + type PromptMentionItem, + type PromptMentionTokenRange, + type ChatAttachment, + type ChatMessage, + type DeleteDialogState, + type WorkbenchKeepaliveTask, + MODE_META, + MODE_OPTIONS, + IMAGE_MODEL_OPTIONS, + VIDEO_MODEL_OPTIONS, + RATIO_OPTIONS, + GRID_MODE_OPTIONS, + VIDEO_FRAME_OPTIONS, + VIDEO_DURATION_OPTIONS, + MESSAGE_STORAGE_KEY, + ACTIVE_CONVERSATION_STORAGE_KEY, + PROMPT_HISTORY_STORAGE_KEY, + TASK_KEEPALIVE_STORAGE_KEY, + WORKBENCH_TASK_STALE_MS, + WORKBENCH_TASK_MAX_POLL_FAILURES, + REFERENCE_IMAGE_COMPRESS_THRESHOLD, + REFERENCE_IMAGE_MAX_DIMENSION, + REFERENCE_IMAGE_INITIAL_QUALITY, + REFERENCE_IMAGE_MIN_QUALITY, + CHAT_MODEL, + CHAT_NATURAL_SYSTEM_PROMPT, + CHAT_TURN_STYLE_REMINDER, + NON_CONVERSATIONAL_ASSISTANT_TEXT, + getCachedRole, + getSessionUserId, + userKey, + createId, + formatWorkbenchTimestamp, + parseWorkbenchTimestampValue, + buildChatAttachments, + buildNaturalChatHistoryMessages, + getErrorText, + isAuthFailure, + isInsufficientBalance, + isInsufficientBalanceMessage, + isTransientMessage, + getPersistableMessages, + shouldPersistPatch, + buildAssistantResult, +} from "./workbenchConstants"; +import { + readStoredMessages, + readStoredPromptHistory, + readStoredActiveConversationId, + persistActiveConversationId, + persistMessages, + clearWorkbenchLocalState, + persistPromptHistory, + buildRecoverableTaskFromMessage, + readStoredKeepaliveTasks, + persistKeepaliveTasks, +} from "./workbenchStorage"; +import { + getRatioOptionClassName, + getSettingsGridColumnsClassName, + getReferenceAccept, + getReferenceUploadLabel, + getReferenceLimit, + getReferenceKindLabel, + getReferenceEmptyCopy, + hexToRgbTriplet, + inferReferenceKind, + disposeReferencePreview, + fileToDataUrl, + bytesToHex, + buildReferenceFingerprint, + canCompressReferenceImage, + compressReferenceImageIfNeeded, + buildReferenceToken, + resolveReferenceUrls, +} from "./workbenchReferenceUtils"; +import { + renderPromptPreviewNodes, + getPromptMentionTokenRanges, + removePromptMentionTokenFromText, + removePromptTextRange, +} from "./workbenchMentionUtils"; +import { + findPromptMentionRangeInside, + findPromptMentionRangeOverlap, + ReferenceInlinePreview, + ReferencePreview, + PromptPreviewLayer, +} from "./WorkbenchPromptPreview"; +import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips"; + +export type { WorkbenchResultActionPayload } from "./workbenchConstants"; interface WorkbenchPageProps { isAuthenticated: boolean; session: WebUserSession | null; onRequireLogin: (input: CreatePreviewTaskInput) => void; - onOpenResultInCanvas?: (payload: WorkbenchResultActionPayload) => void; + onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void; onRefreshUsage?: () => void; } -export interface WorkbenchResultActionPayload { - title: string; - prompt: string; - resultUrl: string; - resultType: "image" | "video"; - taskId?: string; - resultOriginalUrl?: string; - resultOssKey?: string; - resultMimeType?: string; -} +// ─── Component ─────────────────────────────────────────────────────────── -type WorkbenchMode = "chat" | "image" | "video"; -type ToolbarMenuId = - | "studio-mode" - | "image-model" - | "image-settings" - | "image-grid-mode" - | "video-model" - | "video-mode" - | "video-ratio" - | "video-duration" - | "video-quality" - | null; -type ReferenceKind = "image" | "video" | "audio" | "file"; +// (All types, constants, helpers, and sub-components extracted to sibling modules) -type PromptMentionItem = Pick; +// ─── REDUNDANT LOCAL DEFINITIONS REMOVED ───────────────────────────── +// The following block (originally ~1200 lines of types, constants, utility +// functions, and sub-components) has been extracted to sibling modules: +// workbenchConstants.ts, workbenchStorage.ts, workbenchReferenceUtils.ts, +// workbenchMentionUtils.tsx, WorkbenchPromptPreview.tsx, WorkbenchSelectChips.tsx +// and is now imported at the top of this file. -interface PromptMentionTokenRange { - start: number; - end: number; - item: PromptMentionItem; -} - -interface WorkbenchOption { - value: string; - label: string; - description?: string; - badge?: string; -} - -interface WorkbenchFieldGroup { - label: string; - value: string; - options: WorkbenchOption[]; - onChange: (value: string) => void; - kind?: "ratio" | "pill"; - columns?: 2 | 3 | 4; - icon?: ReactNode; -} - -interface ReferenceItem { - id: string; - kind: ReferenceKind; - name: string; - previewUrl?: string; - file?: File; - remoteUrl?: string; - token: string; - fingerprint?: string; - originalSize?: number; - compressed?: boolean; -} - -type ChatAttachment = Pick; - -function buildChatAttachments(items: ReferenceItem[]): ChatAttachment[] { - return items.map((item) => ({ - kind: item.kind, - name: item.name, - token: item.token, - previewUrl: item.remoteUrl || item.previewUrl, - remoteUrl: item.remoteUrl, - })); -} - -interface ChatMessage { - id: string; - role: "user" | "assistant"; - author: string; - mode: WorkbenchMode; - body: string; - prompt?: string; - createdAt: string; - status?: "thinking" | "queued" | "completed" | "failed"; - taskId?: string; - conversationId?: number; - taskProgress?: number; - taskStatusLabel?: string; - attachments?: ChatAttachment[]; - resultUrl?: string; - resultType?: "image" | "video"; - resultOriginalUrl?: string; - resultOssKey?: string; - resultMimeType?: string; - result?: { - title: string; - summary: string; - specs: string[]; - }; -} - -interface DeleteDialogState { - projectId: string; - title: string; -} - -function getSessionUserId(): string { - try { - const raw = window.localStorage.getItem("omniai-web-session"); - if (!raw) return "anon"; - const id = JSON.parse(raw)?.user?.id; - return id ? String(id) : "anon"; - } catch { return "anon"; } -} - -function userKey(base: string): string { - return `${base}:${getSessionUserId()}`; -} - -const MESSAGE_STORAGE_KEY = "omniai-web-workbench-messages"; -const ACTIVE_CONVERSATION_STORAGE_KEY = "omniai-web-workbench-active-conversation-id"; -const PROMPT_HISTORY_STORAGE_KEY = "omniai-web-workbench-prompt-history"; -const TASK_KEEPALIVE_STORAGE_KEY = "omniai-web-workbench-active-tasks"; -const WORKBENCH_TASK_STALE_MS = 6 * 60 * 60 * 1000; -const WORKBENCH_TASK_MAX_POLL_FAILURES = 10; -const REFERENCE_IMAGE_COMPRESS_THRESHOLD = 10 * 1024 * 1024; -const REFERENCE_IMAGE_MAX_DIMENSION = 1920; -const REFERENCE_IMAGE_INITIAL_QUALITY = 0.84; -const REFERENCE_IMAGE_MIN_QUALITY = 0.62; -const CHAT_MODEL = "gemini-3.1-pro"; -const CHAT_NATURAL_SYSTEM_PROMPT = [ - "你是 OmniAI 的创作协作助手,像一个正在一起工作的同伴一样说话。", - "默认使用自然、简洁的中文,不要官腔,不要机械套话,不要频繁使用“首先、其次、最后”这种模板。", - "先直接回应用户当前关心的点;需要拆解时,用短段落或少量要点,把下一步说清楚。", - "不说“作为一个 AI”,不做空泛总结,不编造不确定的信息。", - "当用户在排查问题或调整页面时,优先给判断、原因和可执行的下一步。", -].join("\n"); -const CHAT_TURN_STYLE_REMINDER = [ - "本轮回答继续保持像正常人协作的口吻:", - "不要以“好的,以下是”“当然可以”“根据你的需求”这类模板开头。", - "能一句话说清就先一句话说清;需要展开时再分点。", - "少用宏大标题,多用具体判断和下一步动作。", -].join("\n"); - -const NON_CONVERSATIONAL_ASSISTANT_TEXT = new Set([ - "我先看一下上下文,马上接上。", - "我在整理,马上说清楚。", - "正在读取当前模式、模型、规格和参考素材,准备创建生成任务。", - "Task submitted, generating...", - "任务已提交,正在生成中...", - "AI 正在整理回答...", -]); - -function buildNaturalChatHistoryMessages(messages: ChatMessage[]): Array<{ role: "user" | "assistant"; content: string }> { - return messages - .filter((message) => { - const body = message.body.trim(); - if (!body) return false; - if (message.role === "user") return true; - if (message.mode !== "chat") return false; - if (message.status === "thinking" || message.status === "queued") return false; - if (NON_CONVERSATIONAL_ASSISTANT_TEXT.has(body)) return false; - return true; - }) - .slice(-10) - .map((message) => ({ - role: message.role, - content: message.body.trim(), - })); -} - -const MODE_META: Record< - WorkbenchMode, - { - label: string; - menuLabel: string; - accent: string; - icon: ReactNode; - placeholder: string; - description: string; - subline: string; - taskType: WebGenerationPreviewTask["type"]; - } -> = { - chat: { - label: "OmniChat", - menuLabel: "对话模式", - accent: "#6be7ff", - icon: , - placeholder: "把创意、脚本、素材要求或工作流目标发给我", - description: "直接对话、拆解需求、整理上下文,并把想法推进到可执行结果。", - subline: "适合连续协作、问答推演、脚本整理和工作流规划。", - taskType: "agent", - }, - image: { - label: "图像生成", - menuLabel: "图像生成", - accent: "#00b1cc", - icon: , - placeholder: "描述角色、场景、商品图、首帧或尾帧画面", - description: "在同一界面完成文生图、图生图、参考图管理和候选筛选。", - subline: "模型、比例、清晰度和多宫格保持在同一条工作链里。", - taskType: "image", - }, - video: { - label: "视频生成", - menuLabel: "视频生成", - accent: "#2197ff", - icon: , - placeholder: "描述成片目标、人物、场景、镜头运动、节奏、比例和时长", - description: "用统一工作台管理起始帧、动作描述、镜头节奏和视频输出。", - subline: "支持首尾帧、参考素材、比例、时长和画质等关键设置。", - taskType: "video", - }, +const MODE_ICONS: Record = { + chat: , + image: , + video: , }; -const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as WorkbenchMode[]).map((mode) => ({ - value: mode, - label: MODE_META[mode].menuLabel, - description: MODE_META[mode].subline, -})); - -const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [ - { value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K · 0.20 积分" }, - { value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" }, - { value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" }, - { value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" }, - { value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" }, - { value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" }, - { value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" }, -]; - -const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option })); -const RATIO_OPTIONS: WorkbenchOption[] = [ - { value: "21:9", label: "21:9" }, - { value: "16:9", label: "16:9" }, - { value: "4:3", label: "4:3" }, - { value: "1:1", label: "1:1" }, - { value: "3:4", label: "3:4" }, - { value: "9:16", label: "9:16" }, -]; - -const GRID_MODE_OPTIONS: WorkbenchOption[] = [ - { value: "single", label: "单图" }, - { value: "grid-4", label: "4 宫格" }, - { value: "grid-9", label: "9 宫格" }, - { value: "grid-25", label: "25 宫格" }, -]; - -const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [ - { value: "omni", label: "全能参考" }, - { value: "start-end", label: "首尾帧" }, -]; - -const VIDEO_DURATION_OPTIONS: WorkbenchOption[] = [ - { value: "4", label: "4s" }, - { value: "5", label: "5s" }, - { value: "6", label: "6s" }, - { value: "7", label: "7s" }, - { value: "8", label: "8s" }, - { value: "9", label: "9s" }, - { value: "10", label: "10s" }, - { value: "11", label: "11s" }, - { value: "12", label: "12s" }, - { value: "13", label: "13s" }, - { value: "14", label: "14s" }, - { value: "15", label: "15s" }, -]; - -function createId(prefix: string) { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function formatWorkbenchTimestamp(date = new Date()): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day} ${hours}:${minutes}`; -} - -function parseWorkbenchTimestampValue(value: string): number { - const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/); - if (matched) { - const [, year, month, day, hours, minutes] = matched; - return new Date(Number(year), Number(month) - 1, Number(day), Number(hours), Number(minutes)).getTime(); - } - - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : NaN; -} - -function readStoredMessages(): ChatMessage[] { - if (typeof window === "undefined") return []; - - try { - const raw = window.localStorage.getItem(userKey(MESSAGE_STORAGE_KEY)); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) return []; - return parsed.filter((item): item is ChatMessage => { - if (!item || typeof item !== "object") return false; - const candidate = item as Partial; - return ( - typeof candidate.id === "string" && - (candidate.role === "user" || candidate.role === "assistant") && - typeof candidate.body === "string" - ); - }); - } catch { - return []; - } -} - -function readStoredPromptHistory(): string[] { - if (typeof window === "undefined") return []; - - try { - const raw = window.localStorage.getItem(userKey(PROMPT_HISTORY_STORAGE_KEY)); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; - } catch { - return []; - } -} - -function readStoredActiveConversationId(messages: ChatMessage[] = []): number | null { - if (typeof window === "undefined") return null; - - try { - const raw = window.localStorage.getItem(userKey(ACTIVE_CONVERSATION_STORAGE_KEY)); - const value = raw ? Number(raw) : NaN; - if (Number.isFinite(value) && value > 0) return value; - } catch { - // Active conversation recovery is optional. - } - - for (let index = messages.length - 1; index >= 0; index -= 1) { - const candidate = messages[index]?.conversationId; - if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) { - return candidate; - } - } - - return null; -} - -function persistActiveConversationId(conversationId: number | null) { - if (typeof window === "undefined") return; - - try { - if (conversationId && Number.isFinite(conversationId)) { - window.localStorage.setItem(userKey(ACTIVE_CONVERSATION_STORAGE_KEY), String(conversationId)); - } else { - window.localStorage.removeItem(userKey(ACTIVE_CONVERSATION_STORAGE_KEY)); - } - } catch { - // Local history is a convenience; generation still works without it. - } -} - -function persistMessages(messages: ChatMessage[]) { - try { - window.localStorage.setItem(userKey(MESSAGE_STORAGE_KEY), JSON.stringify(messages.slice(-60))); - } catch { - // Local history is a convenience; generation still works without it. - } -} - -function clearWorkbenchLocalState() { - if (typeof window === "undefined") return; - - try { - window.localStorage.removeItem(userKey(MESSAGE_STORAGE_KEY)); - window.localStorage.removeItem(userKey(ACTIVE_CONVERSATION_STORAGE_KEY)); - window.localStorage.removeItem(userKey(TASK_KEEPALIVE_STORAGE_KEY)); - } catch { - // Logout cleanup should never block the UI. - } -} - -function isTransientMessage(message: ChatMessage): boolean { - return (message.status === "thinking" || message.status === "queued") && !message.taskId; -} - -function getPersistableMessages(messages: ChatMessage[]): ChatMessage[] { - return messages.filter((message, index) => { - if (isTransientMessage(message)) return false; - if (message.role === "assistant") return true; - - const nextMessage = messages[index + 1]; - return ( - nextMessage?.role === "assistant" && - nextMessage.conversationId === message.conversationId && - !isTransientMessage(nextMessage) - ); - }); -} - -async function resolveReferenceUrls(items: ReferenceItem[]): Promise { - const tasks = items.map(async (item) => { - if (item.remoteUrl) return item.remoteUrl; - - if (!item.file) { - if (item.previewUrl && /^https?:\/\//i.test(item.previewUrl)) { - return item.previewUrl; - } - return null; - } - - const url = await resolvePreUploadedUrl(item.file, item.name, item.fingerprint); - if (url) { - item.remoteUrl = url; - return url; - } - return null; - }); - - const results = await Promise.all(tasks); - return results.filter((url): url is string => url !== null); -} - -function shouldPersistPatch(patch: Partial): boolean { - return ( - patch.status === "completed" || - patch.status === "failed" || - typeof patch.taskId === "string" || - typeof patch.resultUrl === "string" || - typeof patch.resultOssKey === "string" || - typeof patch.resultOriginalUrl === "string" || - typeof patch.resultMimeType === "string" - ); -} - -function getErrorText(error: unknown): string { - return error instanceof Error ? error.message : String(error || "Unknown error"); -} - -function isAuthFailure(error: unknown): boolean { - return isServerRequestError(error) && (error.status === 401 || error.status === 403); -} - -function isInsufficientBalance(error: unknown): boolean { - if (isServerRequestError(error) && error.status === 402) return true; - const msg = error instanceof Error ? error.message : String(error || ""); - return /余额不足|积分不足|insufficient.?balance/i.test(msg); -} - -function isInsufficientBalanceMessage(msg: string | undefined | null): boolean { - if (!msg) return false; - return /余额不足|积分不足|insufficient.?balance/i.test(msg); -} - -function persistPromptHistory(history: string[]) { - try { - window.localStorage.setItem(userKey(PROMPT_HISTORY_STORAGE_KEY), JSON.stringify(history.slice(0, 20))); - } catch { - // Local history is optional. - } -} - -interface WorkbenchKeepaliveTask { - taskId: string; - conversationId: number; - assistantMessageId: string; - concurrencySlotId?: string; - operation?: "generation" | "video-super-resolution"; - mode: "image" | "video"; - modelLabel: string; - specs: string[]; - referenceCount: number; - progress: number; - statusLabel: string; - startedAt: number; -} - -function buildRecoverableTaskFromMessage(conversationId: number, message: ChatMessage): WorkbenchKeepaliveTask | null { - if (message.role !== "assistant") return null; - if (!(message.status === "thinking" && message.taskId && message.mode !== "chat")) return null; - if (message.mode !== "image" && message.mode !== "video") return null; - if (Date.now() - parseWorkbenchTimestampValue(message.createdAt) > WORKBENCH_TASK_STALE_MS) return null; - - const specs = message.result?.specs || []; - return { - taskId: message.taskId, - conversationId, - assistantMessageId: message.id, - operation: message.taskStatusLabel?.includes("超分") ? "video-super-resolution" : "generation", - mode: message.mode, - modelLabel: specs[0] || message.author || message.mode, - specs, - referenceCount: message.attachments?.length || 0, - progress: Math.max(10, Math.min(99, Number(message.taskProgress || 30))), - statusLabel: message.taskStatusLabel || "任务恢复中...", - startedAt: parseWorkbenchTimestampValue(message.createdAt) || Date.now(), - }; -} - -function readStoredKeepaliveTasks(): Record { - if (typeof window === "undefined") return {}; - try { - const raw = window.localStorage.getItem(userKey(TASK_KEEPALIVE_STORAGE_KEY)); - if (!raw) return {}; - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; - - const tasks: Record = {}; - Object.values(parsed as Record>).forEach((task) => { - if ( - task && - typeof task.taskId === "string" && - typeof task.conversationId === "number" && - typeof task.assistantMessageId === "string" && - (task.mode === "image" || task.mode === "video") - ) { - tasks[task.taskId] = { - taskId: task.taskId, - conversationId: task.conversationId, - assistantMessageId: task.assistantMessageId, - concurrencySlotId: typeof task.concurrencySlotId === "string" ? task.concurrencySlotId : undefined, - operation: task.operation === "video-super-resolution" ? "video-super-resolution" : "generation", - mode: task.mode, - modelLabel: task.modelLabel || task.mode, - specs: Array.isArray(task.specs) ? task.specs.filter((item): item is string => typeof item === "string") : [], - referenceCount: Number(task.referenceCount || 0), - progress: Number(task.progress || 0), - statusLabel: task.statusLabel || "Generating...", - startedAt: Number(task.startedAt || Date.now()), - }; - } - }); - return tasks; - } catch { - return {}; - } -} - -function persistKeepaliveTasks(tasks: Record) { - try { - window.localStorage.setItem(userKey(TASK_KEEPALIVE_STORAGE_KEY), JSON.stringify(tasks)); - } catch { - // Task restore is best-effort. - } -} - -function getRatioOptionClassName(value: string) { - return `ai-workbench-ratio-option__preview--${value.replace(":", "-")}`; -} - -function getSettingsGridColumnsClassName(columns: WorkbenchFieldGroup["columns"] = 3) { - return `ai-workbench-settings-panel__grid--cols-${columns}`; -} - -function getReferenceAccept(mode: WorkbenchMode, videoFrameMode?: string) { - if (mode === "chat") return ".docx,.txt,.md,.xlsx,.xls,.png,.jpg,.jpeg,.gif,.webp"; - if (mode === "image") return "image/*"; - if (videoFrameMode === "start-end") return "image/*"; - return "image/*,video/mp4,video/quicktime,video/webm,video/x-msvideo,.mp4,.mov,.webm,.avi,audio/mpeg,audio/mp3,audio/wav,audio/x-wav,.mp3,.wav"; -} - -function getReferenceUploadLabel(mode: WorkbenchMode) { - if (mode === "video") return "参考内容"; - if (mode === "image") return "参考图"; - return "附件"; -} - -function getReferenceLimit(mode: WorkbenchMode, videoFrameMode?: string) { - if (mode === "video" && videoFrameMode === "start-end") return 2; - if (mode === "video") return 12; - if (mode === "image") return 9; - return 4; -} - -function getReferenceKindLabel(kind: ReferenceKind) { - if (kind === "image") return "图片"; - if (kind === "video") return "视频"; - if (kind === "audio") return "音频"; - return "附件"; -} - -function getReferenceEmptyCopy(mode: WorkbenchMode) { - if (mode === "video") return "上传最多12个参考素材,首尾帧模式仅保留2张图片,输入文字或 @ 引用内容,自由组合图、文、音、视频多元素"; - if (mode === "image") return "最多上传9张参考图,输入文字或 @ 引用内容,控制角色、风格和构图"; - return "上传附件后可用 @ 引用,帮助 Agent 读取上下文"; -} - -function hexToRgbTriplet(hex: string) { - const normalized = hex.replace("#", ""); - const full = normalized.length === 3 - ? normalized - .split("") - .map((char) => `${char}${char}`) - .join("") - : normalized; - - const value = Number.parseInt(full, 16); - const r = (value >> 16) & 255; - const g = (value >> 8) & 255; - const b = value & 255; - return `${r}, ${g}, ${b}`; -} - -function inferReferenceKind(file: File, mode: WorkbenchMode): ReferenceKind { - if (file.type.startsWith("image/")) return "image"; - if (file.type.startsWith("video/")) return "video"; - if (file.type.startsWith("audio/")) return "audio"; - return mode === "chat" ? "file" : "image"; -} - -function disposeReferencePreview(item: Pick) { - if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); -} - -function fileToDataUrl(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read reference file")); - }; - reader.onerror = () => reject(reader.error || new Error("Unable to read reference file")); - reader.readAsDataURL(file); - }); -} - -function bytesToHex(buffer: ArrayBuffer) { - return Array.from(new Uint8Array(buffer)) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - -async function buildReferenceFingerprint(file: File, kind: ReferenceKind) { - if (kind === "image" && window.crypto?.subtle) { - const digest = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer()); - return `image:${bytesToHex(digest)}`; - } - return `${kind}:${file.name}:${file.size}:${file.lastModified}:${file.type}`; -} - -function canCompressReferenceImage(file: File) { - return ( - file.size > REFERENCE_IMAGE_COMPRESS_THRESHOLD && - file.type.startsWith("image/") && - !/svg|gif/i.test(file.type) - ); -} - -function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) { - return new Promise((resolve) => { - canvas.toBlob(resolve, type, quality); - }); -} - -function getCompressedImageName(fileName: string) { - const baseName = fileName.replace(/\.[^.]+$/, ""); - return `${baseName || "reference"}.jpg`; -} - -async function compressReferenceImageIfNeeded(file: File) { - if (!canCompressReferenceImage(file)) { - return { file, compressed: false }; - } - - try { - const bitmap = await createImageBitmap(file); - const scale = Math.min(1, REFERENCE_IMAGE_MAX_DIMENSION / Math.max(bitmap.width, bitmap.height)); - let width = Math.max(1, Math.round(bitmap.width * scale)); - let height = Math.max(1, Math.round(bitmap.height * scale)); - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context) { - bitmap.close(); - return { file, compressed: false }; - } - - const render = () => { - canvas.width = width; - canvas.height = height; - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, width, height); - context.drawImage(bitmap, 0, 0, width, height); - }; - - const encode = async () => { - let quality = REFERENCE_IMAGE_INITIAL_QUALITY; - let nextBlob = await canvasToBlob(canvas, "image/jpeg", quality); - while (nextBlob && nextBlob.size > REFERENCE_IMAGE_COMPRESS_THRESHOLD && quality > REFERENCE_IMAGE_MIN_QUALITY) { - quality = Math.max(REFERENCE_IMAGE_MIN_QUALITY, quality - 0.08); - nextBlob = await canvasToBlob(canvas, "image/jpeg", quality); - } - return nextBlob; - }; - - render(); - let blob = await encode(); - while (blob && blob.size > REFERENCE_IMAGE_COMPRESS_THRESHOLD && Math.max(width, height) > 960) { - width = Math.max(1, Math.round(width * 0.82)); - height = Math.max(1, Math.round(height * 0.82)); - render(); - blob = await encode(); - } - bitmap.close(); - - if (!blob || blob.size >= file.size) { - return { file, compressed: false }; - } - - return { - file: new File([blob], getCompressedImageName(file.name), { - type: "image/jpeg", - lastModified: file.lastModified, - }), - compressed: true, - }; - } catch { - return { file, compressed: false }; - } -} - -function buildReferenceToken(kind: ReferenceKind, index: number) { - if (kind === "image") return `@图片${index}`; - if (kind === "video") return `@视频${index}`; - if (kind === "audio") return `@音频${index}`; - return `@附件${index}`; -} - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function normalizePromptWhitespace(value: string) { - return value.replace(/[ \t]{2,}/g, " ").trim(); -} - -function removePromptMentionTokenFromText(text: string, token: string) { - if (!token) return text; - const escapedToken = escapeRegExp(token); - return normalizePromptWhitespace( - text.replace(new RegExp(`(^|\\s)${escapedToken}(?=\\s|$)`, "g"), " "), - ); -} - -function removePromptTextRange(text: string, start: number, end: number) { - return normalizePromptWhitespace(`${text.slice(0, start)}${text.slice(end)}`); -} - -function ReferenceInlinePreview({ - item, -}: { - item: Pick; -}) { - if ((item.kind === "image" || item.kind === "video") && item.previewUrl) { - return item.kind === "video" ? ( -