fix(canvas): 生成失败提示 - 统一转换用户可读错误 (#669)
Co-authored-by: linxi <linxi@enjoysoftware.cn>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
|
||||
type ReferenceCell = { rowId: string; columnIndex: number };
|
||||
|
||||
@@ -465,9 +466,9 @@ type RowStatus = { label: string; tone: RowStatusTone; loading: boolean; retryab
|
||||
|
||||
function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined): RowStatus {
|
||||
if (hasNodeMedia(output)) return { label: "生成完成", tone: "success", loading: false, retryable: false };
|
||||
if (item?.status === "failed") return { label: item.errorDetails || "生成失败", tone: "error", loading: false, retryable: true };
|
||||
if (item?.status === "failed") return { label: item.errorDetails ? generationErrorMessage(item.errorDetails) : "生成失败", tone: "error", loading: false, retryable: true };
|
||||
if (item?.status === "cancelled") return { label: "已停止", tone: "error", loading: false, retryable: false };
|
||||
if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", tone: "loading", loading: true, retryable: false };
|
||||
if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", tone: "error", loading: false, retryable: false };
|
||||
if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails ? generationErrorMessage(output.metadata.errorDetails) : "生成失败", tone: "error", loading: false, retryable: false };
|
||||
return { label: "待生成", tone: "idle", loading: false, retryable: false };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { CreativeProposalCard, CreativeQuestionCard, CreativeQuoteCard } from "@/components/creation/creative-agent-cards";
|
||||
import { snapshotCreativePlan } from "@/lib/creation/creative-plan";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
|
||||
export type CanvasCreativeDetail = {
|
||||
kind: "creative-interaction";
|
||||
@@ -33,7 +34,7 @@ export function creativeProposalFailure(detail: CanvasCreativeDetail, config: Ai
|
||||
const proposal = normalizeCreativeProposal(detail.input.proposal, "validation", 1, config, detail.state.references);
|
||||
assertCreativeBriefSpecifications(proposal, detail.state.brief);
|
||||
} catch (cause) {
|
||||
return cause instanceof Error ? cause.message : "方案结构未通过校验";
|
||||
return generationErrorMessage(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ export function CanvasCreativeInteraction(props: Props) {
|
||||
const presentationAttempted = useRef(false);
|
||||
const hadControl = useRef(false);
|
||||
const scope = getActiveUserScope();
|
||||
const invoke = (promise?: Promise<unknown>) => { if (promise) { setError(""); void promise.catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "操作未完成"); }); } };
|
||||
const invoke = (promise?: Promise<unknown>) => { if (promise) { setError(""); void promise.catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(generationErrorMessage(cause)); }); } };
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -132,11 +133,12 @@ export function CanvasCreativeInteraction(props: Props) {
|
||||
props.onContinue("保留已确认的要求,在已授权范围内继续当前任务,自行处理常规选择和技术问题;确实阻塞时再询问。");
|
||||
};
|
||||
const rawError = proposalFailure || error || view.error;
|
||||
const userError = rawError ? generationErrorMessage(rawError) : undefined;
|
||||
const failedGenerations = view.state.media.filter((item) => item.status === "failed" && item.failureKind !== "observation");
|
||||
const onlyFailedRemaining = failedGenerations.length > 0 && !view.state.media.some((item) => ["queued", "running", "write_failed"].includes(item.status) || item.failureKind === "observation" && item.status === "failed");
|
||||
const feedback = onlyFailedRemaining ? "部分作品生成失败。请在失败作品下方重新生成,先核对费用;已成功的作品会保留。" : rawError ? /恢复|连接|网络|接管|执行权|代次/.test(rawError) ? "状态读取未完成,可以继续处理重试。已有作品会保留。"
|
||||
: /报价|费用|价格|过期/.test(rawError) ? "生成费用需要重新确认,继续后会显示最新价格。"
|
||||
: /素材|参考|引用/.test(rawError) ? "参考素材还需要核对,助手会继续处理,必要时请你选择。"
|
||||
const feedback = onlyFailedRemaining ? "部分作品生成失败。请在失败作品下方重新生成,先核对费用;已成功的作品会保留。" : userError ? /恢复|连接|网络|接管|执行权|代次/.test(userError) ? "状态读取未完成,可以继续处理重试。已有作品会保留。"
|
||||
: /报价|费用|价格|过期/.test(userError) ? "生成费用需要重新确认,继续后会显示最新价格。"
|
||||
: /素材|参考|引用/.test(userError) ? "参考素材还需要核对,助手会继续处理,必要时请你选择。"
|
||||
: "这一步还没有完成。已有内容已保留,可以继续处理或修改要求。" : undefined;
|
||||
const submitAnswers = async (submitted: CreativeAnswers) => {
|
||||
await controller.current!.answer(submitted, false);
|
||||
@@ -163,14 +165,14 @@ export function CanvasCreativeInteraction(props: Props) {
|
||||
{!item.error && !hasResource && item.taskId && <p className="creative-agent-muted">{view.busy ? item.status === "running" ? "正在生成,完成后自动显示。" : "已提交,正在等待生成结果。" : "任务已提交。点击下方“继续处理”读取最新结果,无需重复确认费用。"}</p>}
|
||||
{hasResource ? <CreativeAgentMedia storageKey={item.storageKey!} video={video} alt={item.ref} actions={props.active ? <Dropdown trigger={["click"]} disabled={blocked} menu={{ items: [{ key: "adjust", label: "调整作品" }, { key: "redo", label: "重新生成(费用另行确认)" }], onClick: ({ key }) => key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}><Button type="text" disabled={blocked}>更多</Button></Dropdown> : null} /> : null}
|
||||
{item.error && <p className="creative-agent-muted">这项作品还需要处理,已有结果会保留。</p>}
|
||||
{item.error && <details className="creative-agent-receipt"><summary>查看失败原因</summary><p>{item.error}</p></details>}
|
||||
{item.error && <details className="creative-agent-receipt"><summary>查看失败原因</summary><p>{generationErrorMessage(item.error)}</p></details>}
|
||||
{props.active && item.status === "failed" && item.failureKind !== "observation" && <Button type="primary" disabled={blocked} onClick={() => invoke(controller.current?.redo(item.ref))}>重新生成此项 · 先确认费用</Button>}
|
||||
{!hasResource && props.active ? <div className="creative-agent-actions"><Dropdown trigger={["click"]} disabled={blocked} menu={{ items: [{ key: "adjust", label: "调整作品" }, { key: "redo", label: "重新生成(费用另行确认)" }], onClick: ({ key }) => key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}><Button type="text" disabled={blocked}>更多</Button></Dropdown></div> : null}
|
||||
</div>;
|
||||
})}
|
||||
{!props.active && rawError && <details className="creative-agent-receipt"><summary>这一步未完成,后续对话中可继续调整</summary><p>{feedback}</p></details>}
|
||||
{!props.active && userError && <details className="creative-agent-receipt"><summary>这一步未完成,后续对话中可继续调整</summary><p>{feedback}</p></details>}
|
||||
{props.active && <div className="creative-agent-next" role="status">
|
||||
<p>{view.busy ? "正在处理,请稍候…" : !view.hasControl ? rawError ? "暂时无法恢复当前创作,可以重试连接。已有作品会保留。" : "正在恢复当前创作,请稍候…" : view.state.modificationRequested ? "在下方告诉我想改哪里,调整后再确认。" : feedback || (view.quote ? "确认费用后,就开始本批制作。" : awaitingProposal ? "看看方案是否符合你的想法,确认后继续。" : question?.status === "pending" ? "选择一个答案,也可以在下方直接补充。" : status === "completed" ? "本阶段已完成。可以查看作品,或告诉我想改哪里。" : "准备好了,可以继续。")}</p>
|
||||
<p>{view.busy ? "正在处理,请稍候…" : !view.hasControl ? userError ? "暂时无法恢复当前创作,可以重试连接。已有作品会保留。" : "正在恢复当前创作,请稍候…" : view.state.modificationRequested ? "在下方告诉我想改哪里,调整后再确认。" : feedback || (view.quote ? "确认费用后,就开始本批制作。" : awaitingProposal ? "看看方案是否符合你的想法,确认后继续。" : question?.status === "pending" ? "选择一个答案,也可以在下方直接补充。" : status === "completed" ? "本阶段已完成。可以查看作品,或告诉我想改哪里。" : "准备好了,可以继续。")}</p>
|
||||
<div className="creative-agent-actions">
|
||||
{!view.busy && !view.hasControl && <Button onClick={() => invoke(view.run ? controller.current?.takeControl() : controller.current?.load(detail.runId, detail.state))}>重试连接</Button>}
|
||||
{view.busy && ["running", "waiting_task", "waiting_canvas"].includes(status || "") ? <Button onClick={() => invoke(controller.current?.pause())}>停止后续制作</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { ResponseInputMessage } from "@/services/api/image";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { dynamicCreativePlan, type CreativeDynamicPlan } from "./creative-plan";
|
||||
import { normalizeCreativeField } from "./creative-agent-contract";
|
||||
import { CREATIVE_SCENARIOS, type CreativeAnswers, type CreativeBrief, type CreativeGenerationItem, type CreativePlan, type CreativeProposal, type CreativeQuestionRequest, type CreativeScenarioId } from "./creative-agent-contract";
|
||||
@@ -185,6 +186,6 @@ export function creativePlan(state: CreativeAgentState): CreativePlan | undefine
|
||||
return { id: state.proposal?.id ?? state.questions!.interactionId, steps: [
|
||||
{ id: "brief", title: "了解需求", status: state.questions?.status === "pending" ? "waiting" : "completed" },
|
||||
{ id: "proposal", title: "确认创意方案", status: state.canvasApplied ? "completed" : state.proposal ? "waiting" : "pending" },
|
||||
...state.media.map((item) => ({ id: item.ref, title: state.proposal?.workflow.nodes.find((node) => node.ref === item.ref)?.title || item.ref, status: item.status === "ready" ? "completed" as const : item.status === "failed" || item.status === "write_failed" ? "failed" as const : item.status === "running" || item.status === "queued" ? "running" as const : item.submissionId ? "waiting" as const : "pending" as const, nodeIds: [item.nodeId], detail: item.error })),
|
||||
...state.media.map((item) => ({ id: item.ref, title: state.proposal?.workflow.nodes.find((node) => node.ref === item.ref)?.title || item.ref, status: item.status === "ready" ? "completed" as const : item.status === "failed" || item.status === "write_failed" ? "failed" as const : item.status === "running" || item.status === "queued" ? "running" as const : item.submissionId ? "waiting" as const : "pending" as const, nodeIds: [item.nodeId], detail: item.error ? generationErrorMessage(item.error) : undefined })),
|
||||
] };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CreativePlan, CreativePlanStep } from "./creative-agent-contract";
|
||||
import type { CreativeAgentState } from "./creative-agent-state";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
|
||||
export type CreativePlannedStep = {
|
||||
id: string;
|
||||
@@ -58,7 +59,7 @@ export function dynamicCreativePlan(state: CreativeAgentState): CreativePlan | u
|
||||
else if (media.some((item) => item.submissionId)) status = "waiting";
|
||||
}
|
||||
}
|
||||
return { id: step.id, title: step.title, status, nodeIds, detail: step.phase === "media" ? state.media.filter((item) => step.mediaRefs.includes(item.ref)).map((item) => item.error).filter(Boolean).join(";") || undefined : undefined };
|
||||
return { id: step.id, title: step.title, status, nodeIds, detail: step.phase === "media" ? state.media.filter((item) => step.mediaRefs.includes(item.ref)).map((item) => item.error ? generationErrorMessage(item.error) : "").filter(Boolean).join(";") || undefined : undefined };
|
||||
}) };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,28 @@ export const CONTENT_MODERATION_MESSAGE = "内容审核未通过,本次平台
|
||||
|
||||
const DEFAULT_GENERATION_ERROR_MESSAGE = "生成失败,请稍后重试。";
|
||||
const NETWORK_ERROR_MESSAGE = "网络异常。";
|
||||
const MODEL_CAPABILITY_ERROR_MESSAGE = "所选模型不支持当前生成方式或输入,请切换模型或调整输入后重试。";
|
||||
const MODEL_PARAMETER_ERROR_MESSAGE = "当前模型不支持这组参数或参考素材,请调整输入或切换模型后重试。";
|
||||
const MODEL_SERVICE_ERROR_MESSAGE = "模型服务处理失败,请稍后重试或换用其他模型。";
|
||||
|
||||
const REASON_MESSAGES: Record<string, string> = {
|
||||
model_price_not_configured: "当前模型暂未配置价格,请换用其他模型或联系管理员。",
|
||||
model_route_unavailable: "当前模型暂时没有可用渠道,请稍后重试或换用其他模型。",
|
||||
provider_request_failed: MODEL_SERVICE_ERROR_MESSAGE,
|
||||
model_catalog_mismatch: "模型配置已更新,请重新选择模型后再试。",
|
||||
invalid_model_selection: "模型选择无效,请重新选择模型后再试。",
|
||||
quota_exceeded: "积分或额度不足,请充值后再试。",
|
||||
rate_limited: "请求过于频繁,请稍后再试。",
|
||||
timeout: "模型服务响应超时,请稍后再试。",
|
||||
bad_gateway: "模型服务暂时不可用,请稍后再试。",
|
||||
unavailable: "模型服务暂时不可用,请稍后再试。",
|
||||
upstream_dns_failed: "模型服务暂时不可用,请稍后再试。",
|
||||
unauthorized: "当前登录状态或权限不足,请重新登录后再试。",
|
||||
forbidden: "当前账号没有执行此操作的权限。",
|
||||
failed_precondition: "当前请求条件不满足,请检查输入或换用其他模型。",
|
||||
conflict: "请求状态已发生变化,请刷新后再试。",
|
||||
internal: "系统暂时无法完成生成,请稍后再试。",
|
||||
};
|
||||
|
||||
export type GenerationFailureMetadata = {
|
||||
errorDetails: string;
|
||||
@@ -25,21 +47,29 @@ export function generationErrorMessage(error: unknown) {
|
||||
const raw = rawGenerationError(error);
|
||||
if (isContentModerationError(raw)) return contentModerationMessage(raw);
|
||||
|
||||
const reason = structuredErrorReason(error);
|
||||
if (reason === "model_capability_not_supported") return MODEL_CAPABILITY_ERROR_MESSAGE;
|
||||
if (reason === "invalid_argument" && isModelCapabilityFailure(raw)) return MODEL_CAPABILITY_ERROR_MESSAGE;
|
||||
if (reason === "invalid_argument" && isModelParameterFailure(raw)) return MODEL_PARAMETER_ERROR_MESSAGE;
|
||||
if (reason && REASON_MESSAGES[reason]) return REASON_MESSAGES[reason];
|
||||
|
||||
const providerMessage = extractStructuredProviderMessage(raw) || extractWrappedProviderMessage(raw);
|
||||
const displayMessage = providerMessage || raw;
|
||||
if (isContentModerationError(displayMessage)) return contentModerationMessage(displayMessage);
|
||||
const resourceStorageMessage = resourceStorageFailureMessage(raw) || resourceStorageFailureMessage(displayMessage);
|
||||
if (resourceStorageMessage) return resourceStorageMessage;
|
||||
if (isNetworkFailure(displayMessage)) return NETWORK_ERROR_MESSAGE;
|
||||
if (!providerMessage) {
|
||||
if (!displayMessage.includes(";上游:")) {
|
||||
if (hasHttpStatus(raw, 429)) return "服务当前繁忙,请稍后重试。";
|
||||
if (hasHttpStatus(raw, 401, 403)) return "生成服务鉴权失败,请检查渠道配置。";
|
||||
if (hasHttpStatus(raw, 404)) return "生成服务地址不可用,请检查渠道配置。";
|
||||
if (hasHttpStatus(raw, 500, 502, 503, 504)) return NETWORK_ERROR_MESSAGE;
|
||||
}
|
||||
if (!providerMessage && !keepsProviderDetail(displayMessage)) {
|
||||
if (hasHttpStatus(raw, 429)) return "服务当前繁忙,请稍后重试。";
|
||||
if (hasHttpStatus(raw, 401, 403)) return "生成服务鉴权失败,请检查渠道配置。";
|
||||
if (hasHttpStatus(raw, 404)) return "生成服务地址不可用,请检查渠道配置。";
|
||||
if (hasHttpStatus(raw, 500, 502, 503, 504)) return NETWORK_ERROR_MESSAGE;
|
||||
if (containsInfrastructureDetails(raw)) return NETWORK_ERROR_MESSAGE;
|
||||
}
|
||||
if (isModelCapabilityFailure(displayMessage)) return MODEL_CAPABILITY_ERROR_MESSAGE;
|
||||
if (isModelParameterFailure(displayMessage)) return MODEL_PARAMETER_ERROR_MESSAGE;
|
||||
if (keepsProviderDetail(displayMessage)) return displayMessage;
|
||||
if (isTechnicalProviderMessage(displayMessage)) return MODEL_SERVICE_ERROR_MESSAGE;
|
||||
return displayMessage || DEFAULT_GENERATION_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
@@ -88,6 +118,39 @@ function rawGenerationError(error: unknown) {
|
||||
return providerPayloadMessage(error);
|
||||
}
|
||||
|
||||
function structuredErrorReason(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
const record = error as Record<string, unknown>;
|
||||
if (typeof record.reason === "string" && record.reason.trim()) return record.reason.trim().toLowerCase();
|
||||
if (record.cause && record.cause !== error) return structuredErrorReason(record.cause);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isModelCapabilityFailure(value: string) {
|
||||
return /所选模型不支持当前请求|模型不支持当前请求|不支持操作\s+|能力类型不匹配/i.test(value);
|
||||
}
|
||||
|
||||
function isModelParameterFailure(value: string) {
|
||||
return /不支持参数|超出支持范围|数量需在|至少需要\s+\d+\s+个|暂时无法满足这组输入和参数/i.test(value);
|
||||
}
|
||||
|
||||
function isTechnicalProviderMessage(value: string) {
|
||||
const text = value.trim();
|
||||
if (!text) return false;
|
||||
if (/(?:provider request failed|invalid_request_error|internal_server_error|bad_request|unauthorized|forbidden|not_found|upstream_error|request failed with status code|http\s*\d{3})/i.test(text)) return true;
|
||||
// 纯英文的 SDK/网关错误通常不是面向终端用户的说明;中文供应商原因仍允许展示。
|
||||
return !/[\u3400-\u9fff]/.test(text) && /^[\w .,:;_/'"()\-]+$/.test(text) && text.length > 18;
|
||||
}
|
||||
|
||||
function keepsProviderDetail(value: string) {
|
||||
const marker = ";上游:";
|
||||
const index = value.indexOf(marker);
|
||||
if (index < 0) return false;
|
||||
const detail = value.slice(index + marker.length).trim();
|
||||
if (!detail || isNetworkFailure(detail) || containsInfrastructureDetails(detail)) return false;
|
||||
return !/\bHTTP\s*5\d{2}\b/i.test(detail);
|
||||
}
|
||||
|
||||
function extractStructuredProviderMessage(raw: string) {
|
||||
for (let index = raw.indexOf("{"); index >= 0; index = raw.indexOf("{", index + 1)) {
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { withRemoteUserDataSyncExclusive } from "./user-data-sync";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { creativeVideoSpecificationError } from "@/lib/creation/creative-agent-state";
|
||||
import { updateCreativePlan } from "@/lib/creation/creative-plan";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
|
||||
export type CreativeCanvasAdapter = { canvasId: string; read: () => CanvasSnapshot; apply: (ops: CanvasOperation[]) => Promise<CanvasSnapshot> };
|
||||
export type CreativeControllerView = { run?: CreationRun; state: CreativeAgentState; busy: boolean; hasControl: boolean; error?: string; quote?: CreativeQuote };
|
||||
@@ -59,7 +60,7 @@ export class CreativeAgentController {
|
||||
this.busy = true; this.error = undefined; this.emit();
|
||||
try { if (this.run) await this.ensureControl(takeOver); await operation(); }
|
||||
catch (error) {
|
||||
if (!this.disposed && !(error instanceof DOMException && error.name === "AbortError")) { this.error = error instanceof Error ? error.message : "操作未完成"; this.emit(); }
|
||||
if (!this.disposed && !(error instanceof DOMException && error.name === "AbortError")) { this.error = generationErrorMessage(error); this.emit(); }
|
||||
throw error;
|
||||
} finally { this.busy = false; this.emit(); }
|
||||
}
|
||||
@@ -88,7 +89,7 @@ export class CreativeAgentController {
|
||||
if (!this.hasControl) {
|
||||
if (!this.busy) void this.ensureControl().then(() => { this.error = undefined; this.emit(); }).catch((error) => {
|
||||
if (this.disposed) return;
|
||||
this.error = error instanceof Error ? error.message : "连接恢复失败,请重试";
|
||||
this.error = generationErrorMessage(error);
|
||||
this.emit();
|
||||
});
|
||||
return;
|
||||
@@ -233,7 +234,7 @@ export class CreativeAgentController {
|
||||
if (!pending?.length) return undefined;
|
||||
const amount = pending.reduce((sum, item) => sum + item.quote.amountMicrocredits, 0);
|
||||
const estimated = pending.some((item) => item.quote.estimated);
|
||||
return { id: pending.map((item) => item.id).join(":"), title: this.state.planning && !this.state.planning.consumed ? "确认本次理解与规划费用" : "确认本批生成费用", items: pending.map((item) => { const media = this.state.media.find((media) => media.submissionId === item.id); const node = this.state.proposal?.workflow.nodes.find((node) => node.ref === media?.ref); return { id: item.id, label: node?.title || "理解需求并生成问答或方案", model: item.quote.model, quantity: 1, specification: [...Object.entries(item.quote.options || {}).filter(([, value]) => value !== undefined && value !== "").map(([key, value]) => `${({ size: "比例/尺寸", videoSeconds: "时长(秒)", vquality: "清晰度", quality: "质量", count: "数量" } as Record<string, string>)[key] || key}:${String(value)}`), `${item.quote.billingMode === "token" ? "按用量" : item.quote.billingMode === "per_second" ? "按秒" : "按次"}计费,本项${item.quote.estimated ? "预计" : ""} ${(item.quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`].join(" · ") }; }), amountLabel: `${estimated ? "预计 " : ""}${(amount / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`, basis: estimated ? "按实际用量结算,此金额为估算,不是费用上限。仅批准列出的调用。" : "按当前报价,仅批准本批列出的生成项。", expiresAt: pending.map((item) => item.quote.expiresAt).sort()[0], approvedQuantity: pending.filter((item) => item.approvedAt).length };
|
||||
return { id: pending.map((item) => item.id).join(":"), title: this.state.planning && !this.state.planning.consumed ? "确认本次理解与规划费用" : "确认本批生成费用", items: pending.map((item) => { const media = this.state.media.find((media) => media.submissionId === item.id); const node = this.state.proposal?.workflow.nodes.find((node) => node.ref === media?.ref); return { id: item.id, label: node?.title || "理解需求并生成问答或方案", model: item.quote.model, quantity: 1, specification: [...Object.entries(item.quote.options || {}).filter(([, value]) => value !== undefined && value !== "").map(([key, value]) => `${({ size: "比例/尺寸", videoSeconds: "时长(秒)", vquality: "清晰度", quality: "质量", count: "数量" } as Record<string, string>)[key] || key}:${String(value)}`), `${item.quote.billingMode === "token" ? "按用量" : item.quote.billingMode === "per_second" ? "按秒" : item.quote.billingMode === "per_character" ? "按字符" : "按次"}计费,本项${item.quote.estimated ? "预计" : ""} ${(item.quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`].join(" · ") }; }), amountLabel: `${estimated ? "预计 " : ""}${(amount / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`, basis: estimated ? "按实际用量结算,此金额为估算,不是费用上限。仅批准列出的调用。" : "按当前报价,仅批准本批列出的生成项。", expiresAt: pending.map((item) => item.quote.expiresAt).sort()[0], approvedQuantity: pending.filter((item) => item.approvedAt).length };
|
||||
}
|
||||
|
||||
async approvePayment() {
|
||||
@@ -293,13 +294,13 @@ export class CreativeAgentController {
|
||||
this.upsertSubmission({ ...this.submissions.find((item) => item.id === id)!, taskId: task.id });
|
||||
this.setMedia(media.ref, { taskId: task.id, status: task.status === "succeeded" ? "running" : "queued", error: undefined });
|
||||
await this.save("waiting_task");
|
||||
} catch (error) { this.assertLive(); errors.push(error instanceof Error ? error.message : "提交未确认"); }
|
||||
} catch (error) { this.assertLive(); errors.push(generationErrorMessage(error)); }
|
||||
}
|
||||
// 等待已提交项,不会因观察失败创建新的任务。
|
||||
for (const id of ids) {
|
||||
const media = this.state.media.find((item) => item.submissionId === id);
|
||||
if (media?.taskId && media.status !== "ready") {
|
||||
try { await this.observeMedia(media); } catch (error) { this.assertLive(); errors.push(error instanceof Error ? error.message : "生成未完成"); }
|
||||
try { await this.observeMedia(media); } catch (error) { this.assertLive(); errors.push(generationErrorMessage(error)); }
|
||||
}
|
||||
}
|
||||
if (errors.length) { await this.save("paused"); throw new Error([...new Set(errors)].join(";")); }
|
||||
@@ -452,7 +453,7 @@ export class CreativeAgentController {
|
||||
task = await this.waitTask(media.taskId!, { signal: this.abort.signal, onTaskUpdate: (task) => { observedStatus = task.status; if (!this.disposed) this.setMedia(media.ref, { status: task.status === "queued" ? "queued" : "running" }); } });
|
||||
} catch (error) {
|
||||
if (this.abort.signal.aborted) throw error;
|
||||
this.setMedia(media.ref, { status: "failed", failureKind: observedStatus === "failed" || observedStatus === "cancelled" ? "generation" : "observation", error: error instanceof Error ? error.message : "生成失败" }); await this.save("paused"); throw error;
|
||||
this.setMedia(media.ref, { status: "failed", failureKind: observedStatus === "failed" || observedStatus === "cancelled" ? "generation" : "observation", error: generationErrorMessage(error) }); await this.save("paused"); throw error;
|
||||
}
|
||||
this.guard();
|
||||
try {
|
||||
@@ -473,7 +474,7 @@ export class CreativeAgentController {
|
||||
throw new Error(specificationError);
|
||||
}
|
||||
this.setMedia(media.ref, { status: "ready", storageKey: output.storageKey, error: undefined }); await this.save("waiting_task");
|
||||
} catch (error) { if (this.abort.signal.aborted) throw error; this.setMedia(media.ref, { status: this.state.media.find((item) => item.ref === media.ref)?.status === "failed" ? "failed" : "write_failed", error: error instanceof Error ? error.message : "资源回写失败" }); await this.save("paused"); throw error; }
|
||||
} catch (error) { if (this.abort.signal.aborted) throw error; this.setMedia(media.ref, { status: this.state.media.find((item) => item.ref === media.ref)?.status === "failed" ? "failed" : "write_failed", error: generationErrorMessage(error) }); await this.save("paused"); throw error; }
|
||||
}
|
||||
async resume() {
|
||||
await this.action(async () => {
|
||||
@@ -505,7 +506,7 @@ export class CreativeAgentController {
|
||||
const errors: string[] = [];
|
||||
for (const media of this.state.media) {
|
||||
const submission = this.submissions.find((item) => item.id === media.submissionId);
|
||||
if (submission?.taskId && media.status !== "ready") { this.setMedia(media.ref, { taskId: submission.taskId }); try { await this.observeMedia({ ...media, taskId: submission.taskId }); } catch (error) { this.assertLive(); errors.push(error instanceof Error ? error.message : "生成未完成"); } }
|
||||
if (submission?.taskId && media.status !== "ready") { this.setMedia(media.ref, { taskId: submission.taskId }); try { await this.observeMedia({ ...media, taskId: submission.taskId }); } catch (error) { this.assertLive(); errors.push(generationErrorMessage(error)); } }
|
||||
}
|
||||
if (errors.length) { await this.save("paused"); throw new Error([...new Set(errors)].join(";")); }
|
||||
const approved = this.state.pendingPayment?.filter((id) => this.submissions.some((item) => item.id === id && item.approvedAt));
|
||||
|
||||
@@ -140,8 +140,8 @@ describe("创作控制器恢复", () => {
|
||||
});
|
||||
test("第一项失败不阻止同批成功结果回写", async () => {
|
||||
const media = ["a", "b"].map((ref) => ({ ref, nodeId: ref, attempt: 1, submissionId: ref, taskId: ref, status: "queued" as const }));
|
||||
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, media }, "paused", [submission("a", "a", "a"), submission("b", "b", "b")], async (id) => { if (id === "a") throw new Error("A生成失败"); return { id, status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:output", dataUrl: "/api/resources/output/file" }] }) } as GenerationTask; });
|
||||
try { await h.controller.load("run"); await expect(h.controller.resume()).rejects.toThrow("A生成失败"); expect(h.view().state.media.map((item) => item.status)).toEqual(["failed", "ready"]); expect(h.counters().executions).toBe(0); expect(h.counters().commits).toBe(1); } finally { h.controller.dispose(); }
|
||||
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, media }, "paused", [submission("a", "a", "a"), submission("b", "b", "b")], async (id) => { if (id === "a") throw new Error("provider request failed: invalid_request_error"); return { id, status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:output", dataUrl: "/api/resources/output/file" }] }) } as GenerationTask; });
|
||||
try { await h.controller.load("run"); await expect(h.controller.resume()).rejects.toThrow("模型服务处理失败,请稍后重试或换用其他模型。"); expect(h.view().state.media[0].error).toBe("模型服务处理失败,请稍后重试或换用其他模型。"); expect(h.view().state.media.map((item) => item.status)).toEqual(["failed", "ready"]); expect(h.counters().executions).toBe(0); expect(h.counters().commits).toBe(1); } finally { h.controller.dispose(); }
|
||||
});
|
||||
test("暂停后迟到的任务回调不能写画布", async () => {
|
||||
let resolveTask!: (task: GenerationTask) => void;
|
||||
|
||||
Reference in New Issue
Block a user