Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
import { HAPPY_HORSE_UI_MODEL } from "./happyHorseRouting";
import { VIDU_UI_MODEL } from "./viduRouting";
import { PIXVERSE_UI_MODEL } from "./pixverseRouting";
export const ENTERPRISE_KLING_MODEL = "kling-3.0-dashscope";
export const ENTERPRISE_WANXIANG_I2V_MODEL = "wan2.7-i2v";
export const ENTERPRISE_VIDEO_MODEL_OPTIONS = [
{
value: HAPPY_HORSE_UI_MODEL,
label: "HappyHorse 1.0 · 0.72 积分/秒起",
description: "自动匹配文生视频、首帧图生视频或参考图生视频",
},
{
value: VIDU_UI_MODEL,
label: "Vidu Q3 Turbo · 0.40 积分/秒起",
description: "自动匹配文生视频或图生视频,支持16秒",
},
{
value: PIXVERSE_UI_MODEL,
label: "PixVerse V6 · 0.40 积分/秒起",
description: "自动匹配文生视频或图生视频,擅长动作特效",
},
{
value: ENTERPRISE_WANXIANG_I2V_MODEL,
label: "万相 图生视频 · 0.60 积分/秒起",
description: "图生视频模型,支持首帧图驱动",
},
{
value: ENTERPRISE_KLING_MODEL,
label: "Kling V3 Omni · 0.60 积分/秒起",
description: "支持文生视频、图生视频及多模态参考生成",
},
] as const;
export const ENTERPRISE_VIDEO_RESOLUTION_OPTIONS = [
{ value: "720P", label: "720P" },
{ value: "1080P", label: "1080P" },
] as const;
export const ENTERPRISE_DEFAULT_VIDEO_MODEL = HAPPY_HORSE_UI_MODEL;
export const ENTERPRISE_DEFAULT_VIDEO_RESOLUTION = "1080P";
export interface EnterpriseVideoPricingInput {
model: string;
resolution: string;
durationSeconds: number;
muted?: boolean;
hasReferenceVideo?: boolean;
}
export function normalizeEnterpriseResolution(value: string): "720P" | "1080P" {
return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P";
}
export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput): number {
const resolution = normalizeEnterpriseResolution(input.resolution);
const model = String(input.model || "").toLowerCase();
if (model.includes("happyhorse")) {
return resolution === "720P" ? 0.72 : 1.28;
}
if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("animate-mix")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("s2v")) {
return resolution === "720P" ? 0.6 : 1;
}
if (model.includes("vidu")) {
return resolution === "720P" ? 0.4 : 0.8;
}
if (model.includes("pixverse")) {
return resolution === "720P" ? 0.4 : 0.8;
}
if (model.includes("kling")) {
if (input.muted) {
if (input.hasReferenceVideo) return resolution === "720P" ? 0.9 : 1.2;
return resolution === "720P" ? 0.6 : 0.8;
}
return resolution === "720P" ? 0.9 : 1.2;
}
throw new Error(`Unsupported enterprise video model: ${input.model}`);
}
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2));
}
+59
View File
@@ -0,0 +1,59 @@
const ERROR_REPORT_ENDPOINT = "/api/client-errors";
interface ErrorReport {
message: string;
stack?: string;
source: "boundary" | "unhandled" | "rejection" | "manual";
url: string;
timestamp: number;
userAgent: string;
sessionId?: string;
}
let reportQueue: ErrorReport[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function getSessionId(): string | undefined {
try {
const raw = localStorage.getItem("omniai:session") || sessionStorage.getItem("omniai:session");
if (!raw) return undefined;
const parsed = JSON.parse(raw);
return parsed?.user?.sessionId ?? undefined;
} catch {
return undefined;
}
}
function flush() {
if (reportQueue.length === 0) return;
const batch = reportQueue.splice(0, 10);
const baseUrl = import.meta.env.VITE_API_BASE_URL || "";
const url = `${baseUrl}${ERROR_REPORT_ENDPOINT}`;
const token = localStorage.getItem("omniai:token") || sessionStorage.getItem("omniai:token") || "";
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
navigator.sendBeacon?.(url, new Blob([JSON.stringify({ errors: batch })], { type: "application/json" }));
}
function scheduleFlush() {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
flush();
}, 2000);
}
export function reportError(error: unknown, source: ErrorReport["source"] = "manual") {
const err = error instanceof Error ? error : new Error(String(error));
const report: ErrorReport = {
message: err.message,
stack: err.stack?.slice(0, 2000),
source,
url: window.location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
sessionId: getSessionId(),
};
reportQueue.push(report);
scheduleFlush();
}
+58
View File
@@ -0,0 +1,58 @@
export const HAPPY_HORSE_UI_MODEL = "happyhorse-1.0";
export const HAPPY_HORSE_UI_LABEL = "HappyHorse 1.0";
export const HAPPY_HORSE_T2V_MODEL = "happyhorse-1.0-t2v";
export const HAPPY_HORSE_I2V_MODEL = "happyhorse-1.0-i2v";
export const HAPPY_HORSE_R2V_MODEL = "happyhorse-1.0-r2v";
export interface HappyHorseModelOption {
value: string;
label: string;
description?: string;
badge?: string;
}
export function isHappyHorseModel(model: string | undefined | null): boolean {
return String(model || "").toLowerCase().includes("happyhorse");
}
export function toHappyHorseDisplayModel(model: string): string {
return isHappyHorseModel(model) ? HAPPY_HORSE_UI_MODEL : model;
}
export function normalizeHappyHorseModelOptions<T extends HappyHorseModelOption>(options: T[]): T[] {
let hasHappyHorse = false;
return options.reduce<T[]>((result, option) => {
if (!isHappyHorseModel(option.value)) {
result.push(option);
return result;
}
if (hasHappyHorse) return result;
hasHappyHorse = true;
result.push({
...option,
value: HAPPY_HORSE_UI_MODEL,
label: HAPPY_HORSE_UI_LABEL,
description: "自动匹配文生、首帧图生或参考图生视频",
});
return result;
}, []);
}
export function resolveHappyHorseRequestModel(input: {
model: string;
referenceUrls?: string[];
imageReferenceCount?: number;
}): string {
if (!isHappyHorseModel(input.model)) return input.model;
const imageReferenceCount =
typeof input.imageReferenceCount === "number"
? input.imageReferenceCount
: (input.referenceUrls || []).filter((url) => String(url || "").trim()).length;
if (imageReferenceCount <= 0) return HAPPY_HORSE_T2V_MODEL;
if (imageReferenceCount === 1) return HAPPY_HORSE_I2V_MODEL;
return HAPPY_HORSE_R2V_MODEL;
}
+40
View File
@@ -0,0 +1,40 @@
export interface ImageModelVisibilityOption {
value: string;
label?: string;
}
export interface ImageModelVisibilitySession {
user?: {
role?: unknown;
enterpriseRole?: unknown;
isEnterpriseAdmin?: unknown;
} | null;
}
function normalizeRole(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function isVipImageModelOption(option: ImageModelVisibilityOption): boolean {
const value = String(option.value || "").trim().toLowerCase();
const label = String(option.label || "").trim().toLowerCase();
return value.endsWith("-vip") || value.includes("-vip-") || /\bvip\b/.test(label);
}
export function isAdminImageModelUser(session: ImageModelVisibilitySession | null | undefined): boolean {
const user = session?.user;
if (!user) return false;
return (
normalizeRole(user.role) === "admin" ||
normalizeRole(user.enterpriseRole) === "admin" ||
user.isEnterpriseAdmin === true
);
}
export function filterImageModelOptionsForSession<T extends ImageModelVisibilityOption>(
options: T[],
session: ImageModelVisibilitySession | null | undefined,
): T[] {
if (isAdminImageModelUser(session)) return options;
return options.filter((option) => !isVipImageModelOption(option));
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Shared mention trigger detection.
*
* Detects when the user has typed "@" followed by an optional query
* at the cursor position, and returns the match info for opening
* a mention panel.
*/
/** Characters that BLOCK @ trigger (only @ itself, to prevent @@). */
const BLOCKED_BEFORE_AT = /[@]/;
export interface MentionTriggerMatch {
/** Index of the @ character in the full text. */
atIndex: number;
/** The query text after @ (may be empty). */
query: string;
}
/**
* Check whether the text before the cursor contains an active @ mention trigger.
*
* Rules:
* - @ must be at position 0, or preceded by a boundary character
* (whitespace, Chinese/English punctuation, brackets).
* - The query (text after @ up to the cursor) must not contain
* any boundary character or space.
* - Returns null if no trigger is found.
*/
export function detectMentionTrigger(textBeforeCursor: string): MentionTriggerMatch | null {
const atIdx = textBeforeCursor.lastIndexOf("@");
if (atIdx < 0) return null;
// @ must be at start or not preceded by another @
if (atIdx > 0 && BLOCKED_BEFORE_AT.test(textBeforeCursor[atIdx - 1])) {
return null;
}
const query = textBeforeCursor.slice(atIdx + 1);
// Query must not contain spaces or punctuation that would break a mention token
if (/[\s,。、;:!??!.,;:(){}\[\]<>""''《》【】@]/.test(query)) {
return null;
}
return { atIndex: atIdx, query };
}
/** Token pattern for @图片1, @视频1, @文本1, @音频1, @附件1, @素材1, etc. */
export const MENTION_TOKEN_RE = /@(?:图片|视频|文本|音频|附件|素材)\d+/g;
+53
View File
@@ -0,0 +1,53 @@
/**
* Shared model option utilities.
*
* Single source of truth for image/video quality option lookups and defaults,
* consumed by both the canvas and workbench feature modules.
*/
import type { CanvasOption } from "../features/canvas/canvasTypes";
import {
fallbackVideoQualityOptions,
image4kCapableModels,
imageQualityOptions,
videoDefaultQualityByModel,
videoResolutionByModel,
} from "../features/canvas/canvasConstants";
import { toHappyHorseDisplayModel } from "./happyHorseRouting";
import { toPixverseDisplayModel } from "./pixverseRouting";
import { toViduDisplayModel } from "./viduRouting";
// ─── Image quality ────────────────────────────────────────────────────────────
export function getImageQualityOptions(model: string): CanvasOption[] {
return image4kCapableModels.has(String(model || "").toLowerCase())
? imageQualityOptions
: imageQualityOptions.filter((option) => option.value !== "4K");
}
export function getDefaultImageQuality(model: string): string {
const options = getImageQualityOptions(model);
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
}
// ─── Video quality ────────────────────────────────────────────────────────────
function normalizeVideoModel(model: string): string {
return toPixverseDisplayModel(toViduDisplayModel(toHappyHorseDisplayModel(model)));
}
export function getVideoQualityOptions(model: string): CanvasOption[] {
const normalized = normalizeVideoModel(model);
return videoResolutionByModel[normalized] || fallbackVideoQualityOptions;
}
export function getDefaultVideoQuality(model: string): string {
const options = getVideoQualityOptions(model);
const normalized = normalizeVideoModel(model);
const preferred = videoDefaultQualityByModel[normalized] || "pro";
return options.some((option) => option.value === preferred) ? preferred : options[0]?.value || preferred;
}
export function getVideoQualityLabel(model: string, value: string): string {
return getVideoQualityOptions(model).find((item) => item.value === value)?.label || value;
}
+17
View File
@@ -0,0 +1,17 @@
const OSS_HOST_RE = /\.aliyuncs\.com/;
export function ossThumb(url: string | undefined | null, width = 400): string {
if (!url) return "";
if (!OSS_HOST_RE.test(url)) return url;
if (url.includes("x-oss-process")) return url;
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}x-oss-process=image/resize,w_${width}/format,webp`;
}
export function ossWebp(url: string | undefined | null): string {
if (!url) return "";
if (!OSS_HOST_RE.test(url)) return url;
if (url.includes("x-oss-process")) return url;
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}x-oss-process=image/format,webp`;
}
+58
View File
@@ -0,0 +1,58 @@
export const PIXVERSE_UI_MODEL = "pixverse-c1";
export const PIXVERSE_UI_LABEL = "PixVerse V6";
export const PIXVERSE_T2V_MODEL = "pixverse-c1-t2v";
export const PIXVERSE_I2V_MODEL = "pixverse-c1-i2v";
export const PIXVERSE_KF2V_MODEL = "pixverse-c1-kf2v";
export interface PixverseModelOption {
value: string;
label: string;
description?: string;
badge?: string;
}
export function isPixverseModel(model: string | undefined | null): boolean {
return String(model || "").toLowerCase().includes("pixverse");
}
export function toPixverseDisplayModel(model: string): string {
return isPixverseModel(model) ? PIXVERSE_UI_MODEL : model;
}
export function normalizePixverseModelOptions<T extends PixverseModelOption>(options: T[]): T[] {
let hasPixverse = false;
return options.reduce<T[]>((result, option) => {
if (!isPixverseModel(option.value)) {
result.push(option);
return result;
}
if (hasPixverse) return result;
hasPixverse = true;
result.push({
...option,
value: PIXVERSE_UI_MODEL,
label: PIXVERSE_UI_LABEL,
description: "自动匹配文生视频或图生视频",
});
return result;
}, []);
}
export function resolvePixverseRequestModel(input: {
model: string;
referenceUrls?: string[];
imageReferenceCount?: number;
}): string {
if (!isPixverseModel(input.model)) return input.model;
const imageReferenceCount =
typeof input.imageReferenceCount === "number"
? input.imageReferenceCount
: (input.referenceUrls || []).filter((url) => String(url || "").trim()).length;
if (imageReferenceCount >= 2) return PIXVERSE_KF2V_MODEL;
if (imageReferenceCount <= 0) return PIXVERSE_T2V_MODEL;
return PIXVERSE_I2V_MODEL;
}
+16
View File
@@ -0,0 +1,16 @@
import { resolveHappyHorseRequestModel } from "./happyHorseRouting";
import { resolveViduRequestModel } from "./viduRouting";
import { resolvePixverseRequestModel } from "./pixverseRouting";
export interface ResolveModelInput {
model: string;
referenceUrls?: string[];
imageReferenceCount?: number;
}
export function resolveVideoRequestModel(input: ResolveModelInput): string {
let model = resolveHappyHorseRequestModel(input);
model = resolveViduRequestModel({ ...input, model });
model = resolvePixverseRequestModel({ ...input, model });
return model;
}
+32
View File
@@ -0,0 +1,32 @@
/** Summarize a URL for display in tool pages. */
export function summarizeUrl(value: string): string {
try {
const url = new URL(value);
const lastSegment = url.pathname.split("/").filter(Boolean).pop();
return lastSegment ? `${url.host}/.../${lastSegment}` : url.host;
} catch {
return value.length > 72 ? `${value.slice(0, 34)}...${value.slice(-28)}` : value;
}
}
/** Format a byte count to human-readable file size. */
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 KB";
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
/** Read a File as a data URL string via FileReader. */
export function fileToDataUrl(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取素材失败"));
reader.readAsDataURL(file);
});
}
/** Return a Promise that resolves after the given milliseconds. */
export function wait(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Translate API error messages to user-friendly Chinese.
*
* Classifies errors into categories and provides user-friendly messages
* with suggested recovery actions.
*/
export type TaskErrorCategory =
| "content_policy"
| "auth_failure"
| "insufficient_balance"
| "unsupported_model"
| "concurrency_busy"
| "invalid_asset"
| "network_failure"
| "timeout"
| "cancelled"
| "unknown";
export interface TaskErrorInfo {
category: TaskErrorCategory;
message: string;
action: string;
}
const ERROR_RULES: Array<{
pattern: RegExp;
category: TaskErrorCategory;
message: string;
action: string;
}> = [
// Content policy
{
pattern: /violated? our (?:relevant )?policies|content policies|violated? content policy|content.*filter|safety.*filter|moderation|blocked by.*filter|nsfw|inappropriate|explicit.*content|adult.*content/i,
category: "content_policy",
message: "输入词汇包含违规信息,已停止生成",
action: "修改提示词后重试",
},
// Auth failure
{
pattern: /unauthorized|authentication.*fail|invalid.*token|token.*expired|session.*expired|401|login.*required/i,
category: "auth_failure",
message: "登录已过期,请重新登录",
action: "重新登录",
},
// Insufficient balance
{
pattern: /insufficient.*balance|余额不足|积分不足|INSUFFICIENT_BALANCE|balance.*not.*enough|402/i,
category: "insufficient_balance",
message: "余额不足,请充值后重试",
action: "去充值",
},
// Concurrency busy
{
pattern: /concurrency pool.*full|pool is full|concurrency.*limit|too many.*concurrent|排队繁忙/i,
category: "concurrency_busy",
message: "当前模型排队繁忙,请稍后重试或切换其他模型",
action: "稍后重试或切换模型",
},
// Rate limit
{
pattern: /rate limit|too many requests|429/i,
category: "concurrency_busy",
message: "请求过于频繁,请稍后再试",
action: "稍后重试",
},
// Unsupported model
{
pattern: /unsupported.*model|model.*not.*support|model.*not.*found|ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED|not.*available/i,
category: "unsupported_model",
message: "当前模型暂不可用,请切换其他模型重试",
action: "切换模型",
},
// Invalid asset / upload
{
pattern: /upload.*fail|asset.*fail|素材|invalid.*image|invalid.*video|file.*too.*large/i,
category: "invalid_asset",
message: "素材上传失败,请重新上传后重试",
action: "重新上传素材",
},
// Network failure
{
pattern: /network|connection|fetch failed|ECONNREFUSED|ENOTFOUND|socket.*hang/i,
category: "network_failure",
message: "网络错误,请检查网络后重试",
action: "检查网络后重试",
},
// Timeout
{
pattern: /timeout|timed? out|ETIMEDOUT/i,
category: "timeout",
message: "任务超时,请稍后在任务历史中查看结果",
action: "稍后重试",
},
// Quota exceeded
{
pattern: /quota exceeded|quota.*limit/i,
category: "insufficient_balance",
message: "配额已用完,请联系管理员",
action: "联系管理员",
},
// Cancelled
{
pattern: /cancelled|已取消/i,
category: "cancelled",
message: "已取消",
action: "重新开始",
},
// All providers failed
{
pattern: /all.*providers.*failed|provider.*fail/i,
category: "concurrency_busy",
message: "所有可用模型均暂时不可用,请稍后重试",
action: "稍后重试",
},
// Upstream / service error
{
pattern: /upstream.*error|文本服务返回|服务返回.*HTTP|openai_error|internal.*server.*error|500|502|503/i,
category: "network_failure",
message: "AI 服务暂时不可用,请稍后重试",
action: "稍后重试",
},
// Access denied / forbidden
{
pattern: /access.*denied|forbidden|403|permission.*denied|权限/i,
category: "auth_failure",
message: "模型权限未开通,请联系管理员",
action: "联系管理员",
},
// Image format / size issues
{
pattern: /image.*too.*large|image.*format|图片.*大小|图片.*格式|invalid.*file.*type/i,
category: "invalid_asset",
message: "图片格式或大小不符合要求,请调整后重试",
action: "调整图片后重试",
},
// Aborted (user or timeout abort)
{
pattern: /aborted|abort/i,
category: "timeout",
message: "请求已中断,请重试",
action: "重试",
},
];
/**
* Classify an API error into a structured result with category, message, and action.
*/
export function classifyTaskError(error: string | undefined | null): TaskErrorInfo {
if (!error) {
return { category: "unknown", message: "任务失败,请重试", action: "重试" };
}
for (const rule of ERROR_RULES) {
if (rule.pattern.test(error)) {
return { category: rule.category, message: rule.message, action: rule.action };
}
}
const hasChinese = /[一-鿿]/.test(error);
if (hasChinese) {
const truncated = error.length > 80 ? `${error.slice(0, 80)}...` : error;
return { category: "unknown", message: truncated, action: "重试" };
}
return { category: "unknown", message: "服务异常,请稍后重试", action: "重试" };
}
/**
* Translate an API error message to a user-friendly Chinese message.
* Convenience wrapper around classifyTaskError.
*/
export function translateTaskError(error: string | undefined | null): string {
return classifyTaskError(error).message;
}
+56
View File
@@ -0,0 +1,56 @@
export const VIDU_UI_MODEL = "vidu-q3-turbo";
export const VIDU_UI_LABEL = "Vidu Q3 Turbo";
export const VIDU_T2V_MODEL = "vidu-q3-turbo-t2v";
export const VIDU_I2V_MODEL = "vidu-q3-turbo-i2v";
export interface ViduModelOption {
value: string;
label: string;
description?: string;
badge?: string;
}
export function isViduModel(model: string | undefined | null): boolean {
return String(model || "").toLowerCase().includes("vidu");
}
export function toViduDisplayModel(model: string): string {
return isViduModel(model) ? VIDU_UI_MODEL : model;
}
export function normalizeViduModelOptions<T extends ViduModelOption>(options: T[]): T[] {
let hasVidu = false;
return options.reduce<T[]>((result, option) => {
if (!isViduModel(option.value)) {
result.push(option);
return result;
}
if (hasVidu) return result;
hasVidu = true;
result.push({
...option,
value: VIDU_UI_MODEL,
label: VIDU_UI_LABEL,
description: "自动匹配文生视频或图生视频",
});
return result;
}, []);
}
export function resolveViduRequestModel(input: {
model: string;
referenceUrls?: string[];
imageReferenceCount?: number;
}): string {
if (!isViduModel(input.model)) return input.model;
const imageReferenceCount =
typeof input.imageReferenceCount === "number"
? input.imageReferenceCount
: (input.referenceUrls || []).filter((url) => String(url || "").trim()).length;
if (imageReferenceCount <= 0) return VIDU_T2V_MODEL;
return VIDU_I2V_MODEL;
}