fix: 统一AI生成失败提示

This commit is contained in:
hh
2026-07-15 13:12:35 +08:00
parent 38d8c8ad54
commit e81d7170ed
19 changed files with 1220 additions and 88 deletions
+9 -2
View File
@@ -19,6 +19,7 @@ import type {
UserPreference
} from "./types";
import { publicModelDisplayName } from "./model-display";
import { isPublicGenerationError, presentGenerationError } from "./generation-error";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
import {
AccountPage,
@@ -73,6 +74,12 @@ const crumbLabels: Partial<Record<Page, string>> = {
刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */
const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`;
type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string };
function generationTaskErrorText(error: unknown, fallback: string): string {
if (!isPublicGenerationError(error)) return fallback;
const presentation = presentGenerationError(error);
return `${presentation.title}${presentation.description}`;
}
function loadImgwb(mode?: string): ImgwbSaved | null {
try {
const raw = localStorage.getItem(imgwbKey(mode));
@@ -622,7 +629,7 @@ export function App() {
if (!TERMINAL.has(t.status)) continue;
pending.delete(t.id);
if (t.status === "succeeded") assets.push(...(t.assets || []));
else if (t.error_message) lastErr = t.error_message;
else if (t.error_message) lastErr = generationTaskErrorText(t.error, t.error_message);
}
// 进度回写:保留还在跑的 id + 已出结果,供刷新后恢复
saveImgwb(mode, { pending: [...pending], results: assets });
@@ -658,7 +665,7 @@ export function App() {
if (!TERMINAL.has(t.status)) continue;
pending.delete(t.id);
if (t.status === "succeeded") assets.push(...(t.assets || []));
else if (t.error_message) error = t.error_message;
else if (t.error_message) error = generationTaskErrorText(t.error, t.error_message);
}
}
return { assets, error };
+3 -2
View File
@@ -389,7 +389,8 @@ export const api = {
return request<{
running: boolean;
status: string;
error: string;
error?: unknown;
error_message: string;
entities: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }>;
}>(`/api/projects/${projectId}/extract-status/`);
},
@@ -730,7 +731,7 @@ export const api = {
return request<{ conversation_id: string; tasks: ImageConversationTask[] }>(`/api/ai/image-conversations/${id}/tasks/`);
},
generateImageStatus(ids: string[]) {
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
return request<{ tasks: { id: string; status: string; error?: unknown; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
},
// —— 自由创作·视频生成 ——
// 提交秒回(火山 create 同步、慢轮询交给 worker 兜底 + 前端主动 poll);校验失败 400 带中文 detail。
@@ -3,6 +3,7 @@ import { useRef } from "react";
import { Download, Heart, RotateCcw, Trash2 } from "lucide-react";
import type { FreeVideoTask } from "../../types";
import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants";
import { isPublicGenerationError, presentGenerationError } from "../../generation-error";
function ratioStyle(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
@@ -22,6 +23,9 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
const inFlight = isInFlight(task.status);
const failed = task.status === "failed" || task.status === "cancelled";
const done = task.status === "succeeded" && !!task.video_url;
const errorText = isPublicGenerationError(task.error)
? (() => { const presentation = presentGenerationError(task.error); return `${presentation.title}${presentation.description}`; })()
: task.error_message || "视频生成失败,请重试";
return (
<div className={`fc-card${done ? " done" : ""}`}>
@@ -45,7 +49,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
{failed && (
<div className="fc-card-failed">
<span className="pill pill-l2 pill-err"><span className="dot" /></span>
<p>{task.error_message || "生成失败,请重试"}</p>
<p>{errorText}</p>
<div className="hstack">
<button type="button" className="btn btn-sm" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<RotateCcw size={13} />
+97
View File
@@ -0,0 +1,97 @@
/**
* AI 生成失败的普通用户展示层。
*
* 页面只消费这里的稳定 code,不解析供应商名称、HTTP 状态、URL 或英文异常。
* 本文件不发请求、不依赖 React,后续脚本/图片/视频页面可按需接入。
*/
export type GenerationOperation =
| "script_generate"
| "image_generate"
| "base_asset_generate"
| "triview_generate"
| "storyboard_generate"
| "video_generate"
| "voiceover_generate"
| "entity_extract";
export type GenerationErrorCode =
| "provider_quota_exhausted"
| "provider_rate_limited"
| "provider_unavailable"
| "provider_config_error"
| "model_unavailable"
| "content_rejected"
| "invalid_input"
| "asset_unavailable"
| "user_credit_insufficient"
| "task_timeout"
| "processing_failed"
| "unknown";
export type GenerationErrorAction = "retry" | "retry_later" | "revise_input" | "recharge" | "contact_support" | "none";
export type PublicGenerationError = {
domain: "generation";
code: GenerationErrorCode;
operation: GenerationOperation;
action: GenerationErrorAction;
retryable: boolean;
reference_id?: string;
};
export type GenerationErrorPresentation = {
title: string;
description: string;
action: GenerationErrorAction;
retryable: boolean;
referenceId?: string;
};
const OPERATION_LABEL: Record<GenerationOperation, string> = {
script_generate: "脚本",
image_generate: "图片",
base_asset_generate: "基础素材",
triview_generate: "三视图",
storyboard_generate: "故事板",
video_generate: "视频",
voiceover_generate: "配音",
entity_extract: "脚本信息"
};
type Copy = (operation: string) => Pick<GenerationErrorPresentation, "title" | "description">;
const ERROR_COPY: Record<GenerationErrorCode, Copy> = {
provider_quota_exhausted: (operation) => ({ title: `${operation}暂时不可用`, description: "生成服务正在处理中,请稍后再试。" }),
provider_rate_limited: (operation) => ({ title: `${operation}请求较多`, description: "当前生成请求较多,请稍后再试。" }),
provider_unavailable: (operation) => ({ title: `${operation}暂时不可用`, description: "生成服务暂时不可用,请稍后重试。" }),
provider_config_error: (operation) => ({ title: `${operation}暂时不可用`, description: "平台正在处理该问题,请稍后再试。" }),
model_unavailable: (operation) => ({ title: `${operation}暂时不可用`, description: "当前生成能力暂不可用,请稍后再试。" }),
content_rejected: (operation) => ({ title: `${operation}内容需要调整`, description: "内容未通过生成审核,请调整描述或素材后重试。" }),
invalid_input: (operation) => ({ title: `${operation}内容需检查`, description: "请检查描述、参数或素材格式后重试。" }),
asset_unavailable: () => ({ title: "素材暂不可用", description: "引用的素材不存在或暂不可用,请更换后重试。" }),
user_credit_insufficient: () => ({ title: "可用积分不足", description: "充值后可继续生成。" }),
task_timeout: (operation) => ({ title: `${operation}生成时间过长`, description: "服务响应较慢,未完成生成,请重试。" }),
processing_failed: (operation) => ({ title: `${operation}结果处理失败`, description: "本次未生成可用结果,请重试。" }),
unknown: (operation) => ({ title: `${operation}遇到问题`, description: "请稍后重试;若多次出现,请提供任务编号以便排查。" })
};
export function isPublicGenerationError(value: unknown): value is PublicGenerationError {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PublicGenerationError>;
return candidate.domain === "generation"
&& typeof candidate.code === "string" && Object.hasOwn(ERROR_COPY, candidate.code)
&& typeof candidate.operation === "string" && Object.hasOwn(OPERATION_LABEL, candidate.operation)
&& typeof candidate.action === "string"
&& typeof candidate.retryable === "boolean";
}
export function presentGenerationError(error: PublicGenerationError): GenerationErrorPresentation {
const copy = ERROR_COPY[error.code] ?? ERROR_COPY.unknown;
return {
...copy(OPERATION_LABEL[error.operation] ?? "生成"),
action: error.action,
retryable: error.retryable,
referenceId: error.reference_id
};
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { Play } from "lucide-react";
import { api, ApiError } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
import { publicModelDisplayName } from "../model-display";
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@@ -39,6 +40,11 @@ function statusLabel(status: string): string {
if (status === "needs_review") return "待确认";
return "待生成";
}
function generationTaskErrorText(error: unknown, fallback: string): string {
if (!isPublicGenerationError(error)) return fallback;
const presentation = presentGenerationError(error);
return `${presentation.title}${presentation.description}`;
}
// 毫秒 → mm:ss(时间轴 / 字幕展示)
function fmtMs(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
@@ -830,7 +836,7 @@ export function PipelinePage(props: {
setExtractState("idle"); setExtractMsg(""); // 闸门据 entities_extracted 已落库 → 渲染层自动隐藏
} else if (s.status === "failed") {
setExtractState("idle"); setExtractMsg("");
setExtractErr(s.error || "提取失败,请重试");
setExtractErr(generationTaskErrorText(s.error, s.error_message || "提取失败请重试"));
} else {
setExtractState("idle"); setExtractMsg(""); // 既不在途也无成败(理论不至于):收尾,别把 loading 永久挂住
}
@@ -1439,7 +1445,14 @@ export function PipelinePage(props: {
ok = true;
} else if (evt.type === "error") {
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", `生成失败:${typeof evt.detail === "string" ? evt.detail : "请稍后重试"}`);
const publicError = isPublicGenerationError(evt.error) ? evt.error : null;
if (publicError) {
const presentation = presentGenerationError(publicError);
pushMsg("ai", `${presentation.title}${presentation.description}`);
} else {
// 兼容尚未接入统一错误对象的安全本地校验帧。
pushMsg("ai", typeof evt.detail === "string" ? evt.detail : "脚本遇到问题,请稍后重试。");
}
}
},
ac.signal
+1
View File
@@ -584,6 +584,7 @@ export type FreeVideoTask = {
actual_tokens: number;
estimated_cost: string;
actual_cost: string;
error?: unknown;
error_message: string;
is_deleted?: boolean;
purged_at?: string | null;