diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts index 7c58c3b7b..f4d97c9e3 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts @@ -3,11 +3,16 @@ * * 只做两件事:把命令名与参数名(camelCase)拼对,把结构化失败装成可判别的 `Error`。命令名在这 * 里唯一收口,调用方不写字符串命令名、也不自己嗅探失败形状。 + * + * **一条命令一个载体**:读、保存、构建各有自己的失败类型与 `as*` 守卫,`instanceof` 就是命令 + * 边界。这样调用方拿到的失败集合天然被命令限定,不需要再按命令挑变体。 */ import type { TauriInvoke } from '../../../../app/types'; -import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; +import type { XHSMiniToolExportBuildError } from '../generated/XHSMiniToolExportBuildError'; import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; +import type { XHSMiniToolExportReadError } from '../generated/XHSMiniToolExportReadError'; import type { XHSMiniToolExportRunResult } from '../generated/XHSMiniToolExportRunResult'; +import type { XHSMiniToolExportSaveError } from '../generated/XHSMiniToolExportSaveError'; import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState'; /** @@ -16,21 +21,53 @@ import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState * 与入队失败(`EnqueueFailureWrapper`)同一套路:构造时把整份载荷 `JSON.stringify` 进 * `Error.message`,分流只看 `error.type`,**不解析任何文案**。 */ -export class XhsMinitoolFailure extends Error { - readonly error: XHSMiniToolExportError; +export class XhsMinitoolReadFailure extends Error { + readonly error: XHSMiniToolExportReadError; - constructor(error: XHSMiniToolExportError) { + constructor(error: XHSMiniToolExportReadError) { super(JSON.stringify(error)); - this.name = 'XhsMinitoolFailure'; + this.name = 'XhsMinitoolReadFailure'; + this.error = error; + } +} + +export class XhsMinitoolSaveFailure extends Error { + readonly error: XHSMiniToolExportSaveError; + + constructor(error: XHSMiniToolExportSaveError) { + super(JSON.stringify(error)); + this.name = 'XhsMinitoolSaveFailure'; + this.error = error; + } +} + +export class XhsMinitoolBuildFailure extends Error { + readonly error: XHSMiniToolExportBuildError; + + constructor(error: XHSMiniToolExportBuildError) { + super(JSON.stringify(error)); + this.name = 'XhsMinitoolBuildFailure'; this.error = error; } } /** 认得出是结构化失败就取出载荷,否则返回 `null`(传输失败、命令 panic、参数序列化失败)。 */ -export function asXhsMinitoolFailure( +export function asXhsMinitoolReadFailure( error: unknown, -): XHSMiniToolExportError | null { - return error instanceof XhsMinitoolFailure ? error.error : null; +): XHSMiniToolExportReadError | null { + return error instanceof XhsMinitoolReadFailure ? error.error : null; +} + +export function asXhsMinitoolSaveFailure( + error: unknown, +): XHSMiniToolExportSaveError | null { + return error instanceof XhsMinitoolSaveFailure ? error.error : null; +} + +export function asXhsMinitoolBuildFailure( + error: unknown, +): XHSMiniToolExportBuildError | null { + return error instanceof XhsMinitoolBuildFailure ? error.error : null; } /** 读内容:面板渲染与自动刷新反复走的就是这一条,它拿不到注册表指纹。 */ @@ -38,8 +75,12 @@ export function readXhsMinitoolExport( invoke: TauriInvoke, projectPath: string, ): Promise { - return guardStructuredFailure(() => - invoke('read_xhs_minitool_export', { projectPath }), + return guardStructuredFailure( + () => + invoke('read_xhs_minitool_export', { + projectPath, + }), + XhsMinitoolReadFailure, ); } @@ -53,8 +94,9 @@ export function readXhsMinitoolExportHash( invoke: TauriInvoke, projectPath: string, ): Promise { - return guardStructuredFailure(() => - invoke('read_xhs_minitool_export_hash', { projectPath }), + return guardStructuredFailure( + () => invoke('read_xhs_minitool_export_hash', { projectPath }), + XhsMinitoolReadFailure, ); } @@ -67,12 +109,14 @@ export function saveXhsMinitoolExportForm( baseHash: string; }, ): Promise { - return guardStructuredFailure(() => - invoke('save_xhs_minitool_export_form', { - projectPath: input.projectPath, - form: input.form, - baseHash: input.baseHash, - }), + return guardStructuredFailure( + () => + invoke('save_xhs_minitool_export_form', { + projectPath: input.projectPath, + form: input.form, + baseHash: input.baseHash, + }), + XhsMinitoolSaveFailure, ); } @@ -80,20 +124,28 @@ export function runXhsMinitoolExportBuild( invoke: TauriInvoke, projectPath: string, ): Promise { - return guardStructuredFailure(() => - invoke('run_xhs_minitool_export_build', { - projectPath, - }), + return guardStructuredFailure( + () => + invoke('run_xhs_minitool_export_build', { + projectPath, + }), + XhsMinitoolBuildFailure, ); } -async function guardStructuredFailure(call: () => Promise): Promise { +async function guardStructuredFailure( + call: () => Promise, + FailureClass: new (error: Payload) => Error, +): Promise { try { return await call(); } catch (error) { // 真 `Error` 不是结构化拒绝:`JSON.stringify(new Error(...))` 只会得到 `{}`, // 装进 wrapper 就把真实原因丢了。原样抛出,交给调用方的 `instanceof` 分界。 if (error instanceof Error) throw error; - throw new XhsMinitoolFailure(error as XHSMiniToolExportError); + // 非对象同理(命令没注册、参数序列化失败时 Tauri 会拒绝一个裸字符串):装进 wrapper 只会 + // 得到一份没有 `type` 的假变体,调用方按变体分流时会走到不该到的分支。 + if (typeof error !== 'object' || error === null) throw error; + throw new FailureClass(error as Payload); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts index 6ae5552db..2527e7320 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts @@ -19,7 +19,7 @@ export async function saveXhsMinitoolFileToDisk(input: { title: string; }): Promise { const invoke = resolveTauriInvoke(); - if (!invoke) return '需要在客户端内打开导出面板才能保存文件'; + if (!invoke) return '要在客户端里打开导出面板才能保存文件'; let destination: string | null = null; try { destination = await saveNativeFileDialog({ diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts index 6203ed1f8..1f215e9a7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts @@ -1,98 +1,93 @@ /** - * 结构化失败 → 用户看得懂的一句话 + 下一步动作。 + * 结构化失败 → 两件互不派生的事:给用户看的一句话,和这次该由谁修。 * - * 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷 - * 字段只是拼句子的原料(路径、退出码、输出尾部、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。 + * 输入是**会出现在失败卡片上的失败**([`XhsMinitoolCardFailure`])——它按命令把三条失败表拼起来, + * 但不含 `formInvalid`(字段红字)与 `saveConflict`(冲突面板):那两种有专属 UI,到不了卡片, + * 也就不该在这张表里再分一次类。 * - * 这里是**纯映射**,不判「要不要上报」:上报判据写在 `useXhsMinitoolExport` 的 catch 里 - * (策略拒绝留在面板,宿主侧事实故障给完现场后原样抛出进错误池)。它与 - * [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`]「不抽 presenter」的差异是刻意的: - * 那条针对只有一个消费方的认证 catch,而这份映射有两个消费方——面板卡片与交给 agent 的修复指令 - * (两者必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉)。 + * 拆成两个纯函数,各答一件事: + * - `describeXhsMinitoolFailure` 只产用户侧的说法(一个字符串),句子按变体写死,**不从载荷里 + * "读"文案**; + * - `classifyXhsMinitoolFailure` 只回答「谁有能力修」(`XhsMinitoolFailureRepairability`),这是 + * 事实判断,不是按钮长什么样。 + * + * 分流只看 `error.type`,穷尽性由 `expectNever` 在编译期钉住。 + * + * 要交给 agent 的现场不从这里出——原始错误本身就是最好的现场,调用方直接 `JSON.stringify(error)` + * 把它塞进指令;别再抽一份「给 agent 的版本」,两份说法迟早会分叉。 */ import { expectNever } from '../../../../app/expectNever'; -import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; -import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields'; -import { describeOutputTail } from './xhsMinitoolOutputTail'; +import type { XHSMiniToolExportBuildError } from '../generated/XHSMiniToolExportBuildError'; +import type { XHSMiniToolExportReadError } from '../generated/XHSMiniToolExportReadError'; +import type { XHSMiniToolExportSaveError } from '../generated/XHSMiniToolExportSaveError'; /** - * 失败之后最合适的下一步,也就是那颗按钮干什么: - * - `adapt`:项目还没适配(没有构建脚本),该入队的是首次适配指令; - * - `repair`:适配过了但这次没跑通(脚本非零退出、产物不在、注册表被改坏),该入队的是修复指令; - * - `retry`:不用 agent 掺和,用户自己改或原样重试。 + * 会出现在失败卡片上的失败:读注册表、保存表单、跑构建三条命令里,除去有专属 UI 的两种。 + * + * `formInvalid` 由保存命令在写盘前挡下,走 `FormCard` 的字段红字;`saveConflict` 走冲突面板。 + * 两者都不是「失败卡片」的现场,所以不在这张表里。 */ -export type XhsMinitoolFailureAction = 'adapt' | 'repair' | 'retry'; +export type XhsMinitoolCardFailure = + | XHSMiniToolExportReadError + | XHSMiniToolExportBuildError + | Exclude< + XHSMiniToolExportSaveError, + { type: 'formInvalid' } | { type: 'saveConflict' } + >; -export type XhsMinitoolFailureNotice = { - /** 一句话说清哪儿不对。 */ - message: string; - /** 宿主给的现场(路径、退出码、输出尾部、解析器原文);没有就是空串。 */ - detail: string; - action: XhsMinitoolFailureAction; -}; +/** + * 谁有能力修这次失败。判据只有一条:**要改的东西在不在用户项目的代码里**。 + * - `CodeAgentRepairable`:毛病在项目代码 / 构建脚本 / 产物 / 导出信息文件里——还没适配、脚本跑不通、 + * 产物没产出、注册表被改坏。用户改不动,只有 code agent 能碰。 + * - `OnlyHumanRepairable`:要改的东西不在项目代码里,叫 agent 来它也没得改: + * - `commandDenied`:用户在项目权限策略(`.agent/policy.json` 的 `denied_commands`)里明确拉黑了 + * `command.exec`。这是用户自己的配置,而且 agent 受同一条策略约束——叫它来跑脚本正是策略禁止的事。 + * - `exportUnavailable`:宿主没跑起来,改代码没有用,只能重试。 + */ +export enum XhsMinitoolFailureRepairability { + CodeAgentRepairable, + OnlyHumanRepairable, +} -export function describeXhsMinitoolFailure( - error: XHSMiniToolExportError, -): XhsMinitoolFailureNotice { +export function classifyXhsMinitoolFailure( + error: XhsMinitoolCardFailure, +): XhsMinitoolFailureRepairability { switch (error.type) { case 'registryMalformed': - return { - message: '导出注册表被改成了宿主认不出的形状,宿主拒绝猜测它的内容。', - detail: error.cause, - action: 'repair', - }; - case 'formInvalid': - return { - message: `「${XHS_MINITOOL_FIELD_LABELS[error.field]}」不符合平台要求:${error.reason}`, - detail: '', - action: 'retry', - }; case 'buildScriptMissing': - return { - message: `项目里还没有 ${error.script} 脚本——这就是「还没适配」的判据。`, - detail: `宿主找过:${error.searched.join('、')}`, - action: 'adapt', - }; - case 'saveConflict': - return { - message: '注册表在别处被改过,而且改的字段和你的输入不一样。', - detail: '', - action: 'retry', - }; - case 'commandDenied': - return { - // 句子在这里拼:宿主只给被拒的命令点位,不拼人话。 - message: `项目权限策略拒绝执行:${error.commandId}`, - detail: '', - action: 'retry', - }; case 'commandFailed': - return { - message: - error.exitCode === null - ? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。' - : `构建脚本没有正常退出(exit code ${error.exitCode})。`, - detail: describeOutputTail(error), - action: 'repair', - }; case 'artifactMissing': - return { - message: `构建脚本跑完了,但 ${error.path} 不在。多数是把 zip 写到了别处。`, - detail: '', - action: 'repair', - }; + return XhsMinitoolFailureRepairability.CodeAgentRepairable; + case 'commandDenied': case 'exportUnavailable': - return { - message: '宿主这次没能执行导出。', - detail: error.cause, - action: 'retry', - }; + return XhsMinitoolFailureRepairability.OnlyHumanRepairable; default: expectNever(error); - return { - message: '导出失败,原因不在已知失败表里。', - detail: '', - action: 'retry', - }; + return XhsMinitoolFailureRepairability.OnlyHumanRepairable; + } +} + +export function describeXhsMinitoolFailure( + error: XhsMinitoolCardFailure, +): string { + switch (error.type) { + case 'registryMalformed': + return '导出信息的内容被改坏了,读不出来。可以让陶泥儿重新生成一份。'; + case 'buildScriptMissing': + // 用户不需要知道 npm 脚本叫什么、宿主在哪些目录里找过——那是内部实现。 + return '这个项目还没有配置好导出。先让陶泥儿帮你调通一次,之后就能直接打包了。'; + case 'commandDenied': + return '当前项目的权限设置不允许执行这次导出。'; + case 'commandFailed': + return error.exitCode === null + ? '导出没能启动,可能是运行环境的问题。' + : '导出中途出错了,没能生成代码包。'; + case 'artifactMissing': + return '导出跑完了,但没有生成代码包。可以让陶泥儿看看。'; + case 'exportUnavailable': + return '这次没能执行导出,请稍后重试。'; + default: + expectNever(error); + return '导出失败,原因不在已知失败表里。'; } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts index 6acae1028..e7e1f2ca2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts @@ -1,7 +1,7 @@ /** * 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。 * - * 宿主不预拼用户可见文案([`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`]):省略量是事实, + * 宿主不预拼用户可见文案:省略量是事实, * 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。 * 失败卡片与导出成功提示共用这一份,避免两处各写一句。 */ @@ -11,6 +11,6 @@ export function describeOutputTail(input: { }): string { const tail = input.outputTail.trim(); if (input.omittedCharacters <= 0) return tail; - const note = `(日志过长,已省略前 ${input.omittedCharacters} 个字符)`; + const note = `(内容较长,前面省略了 ${input.omittedCharacters} 个字符)`; return tail ? `${note}\n${tail}` : note; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx index a8d72065c..bbbbb8ca8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx @@ -8,10 +8,10 @@ import { PaneButton } from './PaneButton'; type Side = 'mine' | 'file'; /** - * 字段级冲突面板:文件在别处被改过,且和用户手里的值不同。 + * 字段级冲突面板:这份信息在别处被改过,且和用户手里的值不同。 * * 只列**真的不一样**的字段,每行二选一,默认「我的」——任何情况下都不静默覆盖用户输入,也不 - * 静默丢掉文件里的改动。宿主只是在保存时发现指纹对不上,判定与选择都在这一层。 + * 静默丢掉项目里的改动。宿主只是在保存时发现对不上,判定与选择都在这一层。 * * 调用方用 `key={conflict.contentHash}` 挂载:换一次冲突就是一次全新的选择。 */ @@ -40,14 +40,14 @@ export function ConflictCard({ return (

- 注册表在别处被改过 + 这份导出信息被改过

- {`下面 ${differing.length} 个字段两边不一样,逐个选一个再保存;没列出来的字段两边一致。`} + {`下面 ${differing.length} 项两边不一样,各选一个再保存;没列出来的两边一致。`}

{differing.map((meta) => ( @@ -70,7 +70,7 @@ export function ConflictCard({ /> diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx index 8ca95feba..9c0d55fc7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx @@ -32,10 +32,10 @@ export function CopyButton({ value, label }: { value: string; label: string }) { state === 'failed' ? `复制${label}失败,请手动复制` : `复制${label}` } disabled={!value} - className={`inline-flex shrink-0 items-center gap-1 rounded-lg border px-2 py-1 text-xs transition disabled:cursor-not-allowed disabled:opacity-45 ${ + className={`inline-flex shrink-0 items-center gap-1 rounded-[10px] border px-2.5 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-45 ${ state === 'failed' ? 'border-red-300 bg-red-50 text-red-600' - : 'border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]' + : 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]' }`} onClick={() => { setState('idle'); diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureCard.tsx index 287f4db2e..48ca11a74 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureCard.tsx @@ -1,12 +1,21 @@ -import type { XhsMinitoolFailureState } from '../../state/useXhsMinitoolExport'; +import type { XhsMinitoolCardFailure } from '../../state/xhsMinitoolFailure'; +import { + classifyXhsMinitoolFailure, + describeXhsMinitoolFailure, + XhsMinitoolFailureRepairability, +} from '../../state/xhsMinitoolFailure'; import { PaneButton } from './PaneButton'; /** - * 失败卡片:宿主给的 typed 失败原文(有一句人话 + 现场),加一颗按变体选出来的按钮。 + * 失败卡片:一句用户看得懂的话 + 一颗按「谁有能力修」选出来的按钮。 * - * 三颗按钮对应三种下一步:项目还没适配就入队首次适配指令,适配过但没跑通就入队修复指令, - * 宿主自身没跑起来(`exportUnavailable`)或用户能自己改的(表单、冲突)就只给重试——别把 - * agent 拉进来瞎猜。 + * 按钮由 `classifyXhsMinitoolFailure` 决定,不在这里按变体现猜: + * - `CodeAgentRepairable`:项目还没适配就给「让陶泥儿帮我调通」,适配过但这次没跑通就给「让陶泥儿来修」; + * - `OnlyHumanRepairable`(项目权限策略拉黑了 `command.exec`、宿主没跑起来):只给「重试」—— + * 要改的东西不在项目代码里,别把陶泥儿拉进来改一份没问题可改的代码。 + * + * 卡片只收 [`XhsMinitoolCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。 + * 宿主给的现场也不在这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。 */ export function FailureCard({ failure, @@ -14,37 +23,35 @@ export function FailureCard({ onRepair, onRetry, }: { - failure: XhsMinitoolFailureState; + failure: XhsMinitoolCardFailure; onAdapt: () => void; onRepair: () => void; onRetry: () => void; }) { - const { notice } = failure; + const repairability = classifyXhsMinitoolFailure(failure); return (
-

{notice.message}

- {notice.detail ? ( -
-          {notice.detail}
-        
- ) : null} +

+ {describeXhsMinitoolFailure(failure)} +

- {notice.action === 'adapt' ? ( - - 交给 code agent 适配 - - ) : notice.action === 'repair' ? ( - - 让 code agent 修 - - ) : ( + {repairability === + XhsMinitoolFailureRepairability.OnlyHumanRepairable ? ( 重试 + ) : failure.type === 'buildScriptMissing' ? ( + + 让陶泥儿帮我调通 + + ) : ( + + 让陶泥儿来修 + )}