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}`; }