Files
omniai-web/src/utils/taskLifecycle.ts
T
stringadmin 178a2c47da feat: add task lifecycle management and improve generation reliability
Centralize timeout policies, stall detection, and error classification
for image/video/text generation tasks. Improve ecommerce OSS upload flow
and add script evaluation enhancements.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 01:06:48 +08:00

161 lines
5.7 KiB
TypeScript

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;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
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 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
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}`;
}