Files
omniai-web/src/features/workbench/workbenchConstants.ts
T
OmniAI Developer 192be0e701 feat: 内测申请弹窗 + 电商功能介绍页样式优化
- 新增 BetaApplicationModal 组件,支持文本输入、单/多选、签字等交互

- 顶部通知铃铛左侧添加「内测申请」按钮(脉冲动画)

- 电商功能介绍页等比例放大,减少空白,布局更紧凑

- 右侧卡片区域放大,卡片内容清晰可见
2026-06-08 14:40:47 +08:00

441 lines
14 KiB
TypeScript

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<ReferenceItem, "token" | "id" | "name" | "kind" | "previewUrl" | "remoteUrl">;
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 4K" },
{ 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<ChatMessage>): 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],
};
}