Initial ecommerce standalone package
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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",
|
||||
description: "自动匹配文生视频、首帧图生视频或参考图生视频",
|
||||
},
|
||||
{
|
||||
value: VIDU_UI_MODEL,
|
||||
label: "Vidu Q3 Turbo",
|
||||
description: "自动匹配文生视频或图生视频,支持16秒",
|
||||
},
|
||||
{
|
||||
value: PIXVERSE_UI_MODEL,
|
||||
label: "PixVerse V6",
|
||||
description: "自动匹配文生视频或图生视频,擅长动作特效",
|
||||
},
|
||||
{
|
||||
value: ENTERPRISE_WANXIANG_I2V_MODEL,
|
||||
label: "万相 图生视频",
|
||||
description: "图生视频模型,支持首帧图驱动",
|
||||
},
|
||||
{
|
||||
value: ENTERPRISE_KLING_MODEL,
|
||||
label: "Kling V3 Omni",
|
||||
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";
|
||||
const CREDITS_PER_CNY = 100;
|
||||
|
||||
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.6 : 1.0;
|
||||
}
|
||||
|
||||
if (model.includes("pixverse")) {
|
||||
return resolution === "720P" ? 0.6 : 1.0;
|
||||
}
|
||||
|
||||
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 * CREDITS_PER_CNY).toFixed(2));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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 payload = new Blob([JSON.stringify({ errors: batch })], { type: "application/json" });
|
||||
if (navigator.sendBeacon?.(ERROR_REPORT_ENDPOINT, payload)) return;
|
||||
|
||||
void fetch(ERROR_REPORT_ENDPOINT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ errors: batch }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Browser notification + in-app toast for generation task completions.
|
||||
* Falls back gracefully when Notification API is unavailable.
|
||||
*/
|
||||
|
||||
import { toast } from "../components/toast/toastStore";
|
||||
|
||||
let permissionGranted = false;
|
||||
|
||||
async function requestPermission(): Promise<boolean> {
|
||||
if (permissionGranted) return true;
|
||||
if (typeof Notification === "undefined") return false;
|
||||
if (Notification.permission === "granted") { permissionGranted = true; return true; }
|
||||
if (Notification.permission === "denied") return false;
|
||||
try {
|
||||
const result = await Notification.requestPermission();
|
||||
permissionGranted = result === "granted";
|
||||
return permissionGranted;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyTaskCompleted(label: string, mode: "image" | "video" = "image") {
|
||||
const emoji = mode === "video" ? "🎬" : "🖼️";
|
||||
const title = `${emoji} ${label}生成完成`;
|
||||
const body = "点击返回查看生成结果";
|
||||
|
||||
// Browser notification (background tab)
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||
try { new Notification(title, { body, icon: "/favicon.ico", tag: "gen-complete" }); } catch { /* */ }
|
||||
}
|
||||
|
||||
// In-app toast
|
||||
dispatchGenToast(title);
|
||||
}
|
||||
|
||||
// Use the existing toast system for in-app notifications
|
||||
function dispatchGenToast(msg: string) {
|
||||
toast(msg, "success");
|
||||
}
|
||||
|
||||
/** Call once on app init to pre-warm permission. */
|
||||
export async function initNotificationPermission() {
|
||||
if (typeof Notification === "undefined") return;
|
||||
if (Notification.permission === "default") {
|
||||
// Don't prompt immediately — wait for first user interaction
|
||||
document.addEventListener("click", () => requestPermission(), { once: true });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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 getImageQualityOptionsForContext(
|
||||
model: string,
|
||||
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
|
||||
): CanvasOption[] {
|
||||
const options = getImageQualityOptions(model);
|
||||
const shouldLimitTo2K =
|
||||
String(model || "").toLowerCase() === "wan2.7-image-pro" &&
|
||||
(context?.hasReferenceImages || context?.isGridMode);
|
||||
return shouldLimitTo2K ? options.filter((option) => option.value !== "4K") : options;
|
||||
}
|
||||
|
||||
export function getDefaultImageQuality(model: string): string {
|
||||
const options = getImageQualityOptions(model);
|
||||
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
|
||||
}
|
||||
|
||||
export function getDefaultImageQualityForContext(
|
||||
model: string,
|
||||
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
|
||||
): string {
|
||||
const options = getImageQualityOptionsForContext(model, context);
|
||||
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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { classifyTaskError, type TaskErrorCategory } from "./translateTaskError";
|
||||
|
||||
export type GenerationLifecycleStatus =
|
||||
| "creating"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "failed"
|
||||
| "completed"
|
||||
| "local_timeout";
|
||||
|
||||
export type TaskRefundStatus = "not_charged" | "pending_refund" | "refunded" | "manual_review" | "unknown";
|
||||
|
||||
export interface TaskTimeoutPolicy {
|
||||
submitTimeoutMs: number;
|
||||
noProgressTimeoutMs: number;
|
||||
maxRuntimeMs: number;
|
||||
}
|
||||
|
||||
export interface TaskFailureInfo {
|
||||
category: TaskErrorCategory;
|
||||
message: string;
|
||||
actionLabel: string;
|
||||
retryable: boolean;
|
||||
refundStatus: TaskRefundStatus;
|
||||
refundHint: string;
|
||||
}
|
||||
|
||||
export interface TextTokenUsage {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
const CREDITS_PER_CNY = 100;
|
||||
|
||||
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
|
||||
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY;
|
||||
|
||||
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||
submitTimeoutMs: 90_000,
|
||||
noProgressTimeoutMs: 120_000,
|
||||
maxRuntimeMs: 10 * 60_000,
|
||||
};
|
||||
|
||||
const VIDEO_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||
submitTimeoutMs: 120_000,
|
||||
noProgressTimeoutMs: 120_000,
|
||||
maxRuntimeMs: 20 * 60_000,
|
||||
};
|
||||
|
||||
const VIDEO_LONG_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||
submitTimeoutMs: 120_000,
|
||||
noProgressTimeoutMs: 180_000,
|
||||
maxRuntimeMs: 30 * 60_000,
|
||||
};
|
||||
|
||||
const VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||
submitTimeoutMs: 120_000,
|
||||
noProgressTimeoutMs: 180_000,
|
||||
maxRuntimeMs: 15 * 60_000,
|
||||
};
|
||||
|
||||
const TEXT_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||
submitTimeoutMs: 30_000,
|
||||
noProgressTimeoutMs: 60_000,
|
||||
maxRuntimeMs: 5 * 60_000,
|
||||
};
|
||||
|
||||
export function getTaskTimeoutPolicy(input: {
|
||||
kind?: "image" | "video" | "text";
|
||||
model?: string | null;
|
||||
operation?: string | null;
|
||||
}): TaskTimeoutPolicy {
|
||||
if (input.operation === "video-super-resolution") return VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY;
|
||||
if (input.kind === "image") return IMAGE_TIMEOUT_POLICY;
|
||||
if (input.kind === "text") return TEXT_TIMEOUT_POLICY;
|
||||
const model = String(input.model || "").toLowerCase();
|
||||
if (/kling|wan|veo|sora|hailuo|vidu|pixverse|happyhorse/.test(model)) return VIDEO_LONG_TIMEOUT_POLICY;
|
||||
return VIDEO_TIMEOUT_POLICY;
|
||||
}
|
||||
|
||||
export function isTaskLocallyTimedOut(input: {
|
||||
startedAt: number;
|
||||
lastProgressAt: number;
|
||||
now?: number;
|
||||
policy: TaskTimeoutPolicy;
|
||||
progress?: number;
|
||||
}): "no_progress" | "max_runtime" | null {
|
||||
const now = input.now || Date.now();
|
||||
const progress = Number(input.progress || 0);
|
||||
if (now - input.startedAt >= input.policy.maxRuntimeMs) return "max_runtime";
|
||||
if (progress > 0 && progress < 100 && now - input.lastProgressAt >= input.policy.noProgressTimeoutMs) {
|
||||
return "no_progress";
|
||||
}
|
||||
if (progress <= 0 && now - input.startedAt >= input.policy.submitTimeoutMs) return "no_progress";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildLocalTimeoutMessage(kind: "image" | "video" | "text" = "video"): string {
|
||||
if (kind === "text") {
|
||||
return "本地等待已超时,已停止前端动画。若服务端稍后返回,请以会话记录和积分流水为准。";
|
||||
}
|
||||
const label = kind === "image" ? "图片" : "视频";
|
||||
return `${label}任务长时间没有进展,已停止本地等待并释放前端占用。服务端任务仍可能稍后完成,请到任务历史或资产页查看结果;如已扣费,系统会在失败结算后按积分流水退回。`;
|
||||
}
|
||||
|
||||
export function buildTaskFailureInfo(
|
||||
error: string | undefined | null,
|
||||
options: { refundStatus?: TaskRefundStatus; charged?: boolean; submitted?: boolean } = {},
|
||||
): TaskFailureInfo {
|
||||
const classified = classifyTaskError(error);
|
||||
const submitted = options.submitted !== false;
|
||||
const refundStatus: TaskRefundStatus =
|
||||
options.refundStatus ||
|
||||
(submitted
|
||||
? classified.category === "insufficient_balance" || classified.category === "auth_failure"
|
||||
? "not_charged"
|
||||
: "unknown"
|
||||
: "not_charged");
|
||||
|
||||
const refundHint = getRefundHint(refundStatus);
|
||||
return {
|
||||
category: classified.category,
|
||||
message: `${classified.message}${refundHint ? `\n\n${refundHint}` : ""}`,
|
||||
actionLabel: classified.action,
|
||||
retryable: !["auth_failure", "insufficient_balance", "content_policy"].includes(classified.category),
|
||||
refundStatus,
|
||||
refundHint,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRefundHint(status: TaskRefundStatus): string {
|
||||
switch (status) {
|
||||
case "not_charged":
|
||||
return "提交未进入扣费结算,未产生积分消耗。";
|
||||
case "pending_refund":
|
||||
return "任务已失败,若已扣费,系统会自动退回,请以积分流水为准。";
|
||||
case "refunded":
|
||||
return "失败扣费已退回,请在积分流水中核对。";
|
||||
case "manual_review":
|
||||
return "退款状态需要人工核对,请联系管理员并提供任务 ID。";
|
||||
default:
|
||||
return "如已扣费,系统将在任务失败后自动退回;请以积分流水为准。";
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
|
||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
|
||||
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
|
||||
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
|
||||
}
|
||||
|
||||
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
|
||||
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
|
||||
if (!usage) return rule;
|
||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
|
||||
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
|
||||
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user