import { isServerRequestError } from "../../api/serverConnection"; import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy"; import type { WebGenerationPreviewTask } from "../../types"; import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle"; import type { ReactNode } from "react"; export type WorkbenchMode = "chat" | "image" | "video"; export type ToolbarMenuId = | "studio-mode" | "chat-model" | "chat-speed" | "chat-depth" | "image-model" | "image-settings" | "image-grid-mode" | "video-model" | "video-mode" | "video-ratio" | "video-duration" | "video-quality" | null; export type ReferenceKind = "image" | "video" | "audio" | "file"; export interface WorkbenchOption { value: string; label: string; description?: string; badge?: string; } export interface WorkbenchFieldGroup { label: string; value: string; options: WorkbenchOption[]; onChange: (value: string) => void; kind?: "ratio" | "pill"; columns?: 2 | 3 | 4; icon?: ReactNode; } export interface ReferenceItem { id: string; kind: ReferenceKind; name: string; previewUrl?: string; file?: File; remoteUrl?: string; token: string; fingerprint?: string; originalSize?: number; compressed?: boolean; } export type PromptMentionItem = Pick; export interface PromptMentionTokenRange { start: number; end: number; item: PromptMentionItem; } export interface ChatAttachment { kind: ReferenceKind; name: string; token: string; previewUrl?: string; remoteUrl?: string; } export interface ChatMessage { id: string; role: "user" | "assistant"; author: string; mode: WorkbenchMode; body: string; prompt?: string; createdAt: string; status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout"; taskLifecycleStatus?: GenerationLifecycleStatus; taskRefundStatus?: TaskRefundStatus; taskUsage?: TextTokenUsage; 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[]; }; } export interface DeleteDialogState { projectId: string; title: string; } export 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; } export interface WorkbenchResultActionPayload { title: string; prompt: string; resultUrl: string; resultType: "image" | "video"; taskId?: string; resultOriginalUrl?: string; resultOssKey?: string; resultMimeType?: string; } // ─── Constants ─────────────────────────────────────────────────────── export const MESSAGE_STORAGE_KEY = "omniai-web-workbench-messages"; export const ACTIVE_CONVERSATION_STORAGE_KEY = "omniai-web-workbench-active-conversation-id"; export const PROMPT_HISTORY_STORAGE_KEY = "omniai-web-workbench-prompt-history"; export const TASK_KEEPALIVE_STORAGE_KEY = "omniai-web-workbench-active-tasks"; export const WORKBENCH_TASK_STALE_MS = 6 * 60 * 60 * 1000; export const WORKBENCH_TASK_MAX_POLL_FAILURES = 10; export const REFERENCE_IMAGE_COMPRESS_THRESHOLD = 10 * 1024 * 1024; export const REFERENCE_IMAGE_MAX_DIMENSION = 1920; export const REFERENCE_IMAGE_INITIAL_QUALITY = 0.84; export const REFERENCE_IMAGE_MIN_QUALITY = 0.62; export const CHAT_MODEL = "gemini-3.1-pro"; export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [ { value: "gemini", label: "Gemini" }, { value: "wanxian", label: "万相" }, { value: "deepseek", label: "DeepSeek" }, ]; export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [ { value: "default", label: "默认" }, { value: "high", label: "思考速度:高" }, { value: "ultra", label: "思考速度:急速" }, ]; export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [ { value: "default", label: "默认" }, { value: "strong", label: "推理深度:强" }, { value: "extreme", label: "推理深度:极限" }, ]; export const CHAT_NATURAL_SYSTEM_PROMPT = [ "你是 OmniAI 的创作协作助手,像一个正在一起工作的同伴一样说话。", `默认使用自然、简洁的中文,不要官腔,不要机械套话,不要频繁使用“首先、其次、最后”这种模板。`, "先直接回应用户当前关心的点;需要拆解时,用短段落或少量要点,把下一步说清楚。", `不说“作为一个 AI”,不做空泛总结,不编造不确定的信息。`, "当用户在排查问题或调整页面时,优先给判断、原因和可执行的下一步。", ].join("\n"); export const CHAT_TURN_STYLE_REMINDER = [ "本轮回答继续保持像正常人协作的口吻:", `不要以"好的,以下是""当然可以""根据你的需求"这类模板开头。`, "能一句话说清就先一句话说清;需要展开时再分点。", "少用宏大标题,多用具体判断和下一步动作。", ].join("\n"); export const NON_CONVERSATIONAL_ASSISTANT_TEXT = new Set([ "我先看一下上下文,马上接上。", "我在整理,马上说清楚。", "正在读取当前模式、模型、规格和参考素材,准备创建生成任务。", "Task submitted, generating...", "任务已提交,正在生成中...", "AI 正在整理回答...", ]); export const MODE_META: Record< WorkbenchMode, { label: string; menuLabel: string; accent: string; placeholder: string; description: string; subline: string; taskType: WebGenerationPreviewTask["type"]; } > = { chat: { label: "OmniChat", menuLabel: "对话模式", accent: "#6be7ff", placeholder: "把创意、脚本、素材要求或工作流目标发给我", description: "直接对话、拆解需求、整理上下文,并把想法推进到可执行结果。", subline: "适合连续协作、问答推演、脚本整理和工作流规划。", taskType: "agent", }, image: { label: "图像生成", menuLabel: "图像生成", accent: "#00b1cc", placeholder: "描述角色、场景、商品图、首帧或尾帧画面", description: "在同一界面完成文生图、图生图、参考图管理和候选筛选。", subline: "模型、比例、清晰度和多宫格保持在同一条工作链里。", taskType: "image", }, video: { label: "视频生成", menuLabel: "视频生成", accent: "#2197ff", placeholder: "描述成片目标、人物、场景、镜头运动、节奏、比例和时长", description: "用统一工作台管理起始帧、动作描述、镜头节奏和视频输出。", subline: "支持首尾帧、参考素材、比例、时长和画质等关键设置。", taskType: "video", }, }; export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as WorkbenchMode[]).map((mode) => ({ value: mode, label: MODE_META[mode].menuLabel, description: MODE_META[mode].subline, })); export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [ { value: "wan2.7-image-pro", label: "wan 2.7 Pro" }, { value: "wan2.7-image", label: "wan 2.7" }, { value: "gpt-image-2", label: "omni-GPT" }, { value: "gpt-image-2-vip", label: "omni-GPT VIP" }, { value: "nano-banana-pro", label: "omni-水果 Pro" }, { value: "nano-banana-2", label: "omni-水果 2" }, { value: "nano-banana-fast", label: "omni-水果" }, ]; export const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option })); export 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" }, ]; export const GRID_MODE_OPTIONS: WorkbenchOption[] = [ { value: "single", label: "单图" }, { value: "grid-4", label: "4 宫格" }, { value: "grid-9", label: "9 宫格" }, { value: "grid-25", label: "25 宫格" }, ]; export const GRID_SUPPORTED_MODELS = new Set([ "wan2.7-image-pro", "wan2.7-image", "gpt-image-2", "gpt-image-2-vip", ]); export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [ { value: "omni", label: "全能参考" }, { value: "start-end", label: "首尾帧" }, ]; export const VIDEO_DURATION_OPTIONS: WorkbenchOption[] = [ { 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" }, ]; // ─── Shared helpers ────────────────────────────────────────────────── export 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 ""; } } export 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"; } } export function userKey(base: string): string { return `${base}:${getSessionUserId()}`; } export function createId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } export 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}`; } export 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; } export 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, })); } export 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(), })); } export function getErrorText(error: unknown): string { return error instanceof Error ? error.message : String(error || "Unknown error"); } export function isAuthFailure(error: unknown): boolean { return isServerRequestError(error) && (error.status === 401 || error.status === 403); } export 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); } export function isInsufficientBalanceMessage(msg: string | undefined | null): boolean { if (!msg) return false; return /余额不足|积分不足|insufficient.?balance/i.test(msg); } export function isTransientMessage(message: ChatMessage): boolean { return (message.status === "thinking" || message.status === "queued") && !message.taskId; } export 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) ); }); } export function shouldPersistPatch(patch: Partial): boolean { return ( patch.status === "completed" || patch.status === "failed" || patch.status === "local_timeout" || patch.status === "stopping" || typeof patch.taskId === "string" || typeof patch.resultUrl === "string" || typeof patch.resultOssKey === "string" || typeof patch.resultOriginalUrl === "string" || typeof patch.resultMimeType === "string" || typeof patch.taskRefundStatus === "string" || typeof patch.taskLifecycleStatus === "string" || typeof patch.taskUsage === "object" ); } export function buildAssistantResult( mode: WorkbenchMode, model: string, specs: string[], referenceCount: number, ): ChatMessage["result"] { if (mode === "image") { return { title: "图像任务已创建", summary: referenceCount > 0 ? "已携带参考图,后续结果会进入资产库和画布。" : "已按当前模型和规格进入图像生成流程。", specs, }; } if (mode === "video") { return { title: "视频任务已创建", summary: referenceCount > 0 ? "已携带参考素材,生成后可继续拆分镜头并发布案例。" : "已按当前镜头设置进入视频生成流程。", specs, }; } return { title: "Agent 已接管", summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。", specs: [model, ...specs], }; }