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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user