diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index 0265be90e..b9190513c 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -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 }; } diff --git a/web/src/components/canvas/canvas-creative-interaction.tsx b/web/src/components/canvas/canvas-creative-interaction.tsx index 8b7d26bec..3c52f3f33 100644 --- a/web/src/components/canvas/canvas-creative-interaction.tsx +++ b/web/src/components/canvas/canvas-creative-interaction.tsx @@ -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) => { if (promise) { setError(""); void promise.catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "操作未完成"); }); } }; + const invoke = (promise?: Promise) => { 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 &&

{view.busy ? item.status === "running" ? "正在生成,完成后自动显示。" : "已提交,正在等待生成结果。" : "任务已提交。点击下方“继续处理”读取最新结果,无需重复确认费用。"}

} {hasResource ? key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}> : null} /> : null} {item.error &&

这项作品还需要处理,已有结果会保留。

} - {item.error &&
查看失败原因

{item.error}

} + {item.error &&
查看失败原因

{generationErrorMessage(item.error)}

} {props.active && item.status === "failed" && item.failureKind !== "observation" && } {!hasResource && props.active ?
key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}>
: null} ; })} - {!props.active && rawError &&
这一步未完成,后续对话中可继续调整

{feedback}

} + {!props.active && userError &&
这一步未完成,后续对话中可继续调整

{feedback}

} {props.active &&
-

{view.busy ? "正在处理,请稍候…" : !view.hasControl ? rawError ? "暂时无法恢复当前创作,可以重试连接。已有作品会保留。" : "正在恢复当前创作,请稍候…" : view.state.modificationRequested ? "在下方告诉我想改哪里,调整后再确认。" : feedback || (view.quote ? "确认费用后,就开始本批制作。" : awaitingProposal ? "看看方案是否符合你的想法,确认后继续。" : question?.status === "pending" ? "选择一个答案,也可以在下方直接补充。" : status === "completed" ? "本阶段已完成。可以查看作品,或告诉我想改哪里。" : "准备好了,可以继续。")}

+

{view.busy ? "正在处理,请稍候…" : !view.hasControl ? userError ? "暂时无法恢复当前创作,可以重试连接。已有作品会保留。" : "正在恢复当前创作,请稍候…" : view.state.modificationRequested ? "在下方告诉我想改哪里,调整后再确认。" : feedback || (view.quote ? "确认费用后,就开始本批制作。" : awaitingProposal ? "看看方案是否符合你的想法,确认后继续。" : question?.status === "pending" ? "选择一个答案,也可以在下方直接补充。" : status === "completed" ? "本阶段已完成。可以查看作品,或告诉我想改哪里。" : "准备好了,可以继续。")}

{!view.busy && !view.hasControl && } {view.busy && ["running", "waiting_task", "waiting_canvas"].includes(status || "") ? diff --git a/web/src/lib/creation/creative-agent-state.ts b/web/src/lib/creation/creative-agent-state.ts index dae89d21b..2ad8b6ba3 100644 --- a/web/src/lib/creation/creative-agent-state.ts +++ b/web/src/lib/creation/creative-agent-state.ts @@ -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 })), ] }; } diff --git a/web/src/lib/creation/creative-plan.ts b/web/src/lib/creation/creative-plan.ts index 239a421c3..c11d0fad4 100644 --- a/web/src/lib/creation/creative-plan.ts +++ b/web/src/lib/creation/creative-plan.ts @@ -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 }; }) }; } diff --git a/web/src/lib/generation-error.ts b/web/src/lib/generation-error.ts index 8ba19fd1f..75ba21c24 100644 --- a/web/src/lib/generation-error.ts +++ b/web/src/lib/generation-error.ts @@ -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 = { + 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; + 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 { diff --git a/web/src/services/creative-agent-controller.ts b/web/src/services/creative-agent-controller.ts index 8e84e9506..1f005c716 100644 --- a/web/src/services/creative-agent-controller.ts +++ b/web/src/services/creative-agent-controller.ts @@ -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 }; 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)[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)[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)); diff --git a/web/test/creative-agent-controller.test.ts b/web/test/creative-agent-controller.test.ts index 322b88ea8..eb81c704c 100644 --- a/web/test/creative-agent-controller.test.ts +++ b/web/test/creative-agent-controller.test.ts @@ -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;