From 7614f741e15140af642d671d53a3b75145ae610f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 16:09:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF=EF=BC=9A?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=80=E5=BE=8B=E8=B5=B0=E9=98=BB=E6=96=AD?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=EF=BC=8C=E5=8E=BB=E6=8E=89=E3=80=8C=E5=B7=B2?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=88=B0=20=E2=80=A6=E3=80=8D=E4=B8=8E?= =?UTF-8?q?=E6=95=B4=E6=9D=A1=E9=80=9A=E7=9F=A5=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败现场从就地卡片改成阻断弹窗(`FailureDialog` 取代 `FailureCard`):左半本来就矮,卡片会被滚出视野,而失败是用户必须看见的东西; - 弹窗按「谁有能力修」给按钮:项目还没适配给「让陶泥儿帮我调通」,适配过但这次没跑通给「让陶泥儿来修」,改不动的(权限策略拉黑 `command.exec`、宿主没跑起来)不给陶泥儿入口;每条都带「关闭」,关掉的现场记进 `dismissedFailure`,同一个现场不会因为下一次重读又跳回来——没有「重试」,关掉回面板点「打包」是同一件事; - 一键适配也清掉失败现场:从「还没配置好导出」那条弹窗进来的就是同一个 `buildScriptMissing`,不清它弹窗关不上; - 删掉通知行(那段 `role="status"`)与整个 `notice` 状态:保存产物、打包结果、入队结果都不再在导出面板里回执;读不到内容仍由那段占位文案交代,真故障仍走错误池; - 保存产物不再返回「已保存到 …」:落点与结果都是用户在原生对话框里亲手给的,失败只留一条控制台痕迹; - 输出尾部的拼句模块 `xhsMinitoolOutputTail.ts` 随之删除(唯一调用方就是那条通知); - 用例跟着改:删掉截断拼句那条,失败相关改称弹窗并断言 `role="dialog"`,补一条「关掉同一个现场不再弹」。 --- .../export/state/useXhsMinitoolExport.ts | 87 ++++++++---------- .../export/state/xhsMinitoolDownload.ts | 32 +++---- .../export/state/xhsMinitoolOutputTail.ts | 16 ---- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 47 ++++------ .../export/tabs/xiaohongshu/FailureCard.tsx | 59 ------------ .../export/tabs/xiaohongshu/FailureDialog.tsx | 74 +++++++++++++++ .../tests/xhsMinitoolExport.test.tsx | 91 ++++++++++--------- 7 files changed, 194 insertions(+), 212 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureCard.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index bc1cf6086..c6ebd6ebd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -36,7 +36,6 @@ import { import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure'; import { classifyXhsMinitoolFailure, - describeXhsMinitoolFailure, XhsMinitoolFailureRepairability, } from './xhsMinitoolFailure'; import { @@ -44,7 +43,6 @@ import { buildXhsMinitoolErrorFixInstruction, buildXhsMinitoolFormFillInstruction, } from './xhsMinitoolInstruction'; -import { describeOutputTail } from './xhsMinitoolOutputTail'; export type XhsMinitoolExportState = ReturnType; @@ -57,8 +55,6 @@ const AUTOSAVE_DELAY_MS = 600; * 每次只读内容(不含指纹),而且只在用户手上没有未保存输入时才采纳表单,所以轮询不会吃掉输入。 */ const CONTENT_REFRESH_INTERVAL_MS = 2000; -const NO_HOST_NOTICE = '要在客户端里打开导出面板,才能保存信息并生成代码包'; -const CONTENT_UNAVAILABLE_NOTICE = '没能读到导出信息,正在自动重试…'; /** 三个字段就是全部内容,逐字段比即可,不必引入深比较。 */ function sameForm( @@ -89,9 +85,11 @@ export function useXhsMinitoolExport({ const [fieldError, setFieldError] = useState(null); const [conflict, setConflict] = useState(null); const [failure, setFailure] = useState(null); - // 界面上「有事要说」的那一句:读不到内容、打包结果、指令没发出去、失败修复的原因。 - // 用户刚点的动作本身不回执——「已交给陶泥儿」这类复述是噪音。 - const [notice, setNotice] = useState(''); + /** + * 被用户关掉的失败现场指纹:内容读不回来时每 2 秒重读一次,同一个现场会一直被送回来, + * 关掉之后就不该再弹;换了一个新的现场(指纹不同)照弹。 + */ + const [dismissedFailure, setDismissedFailure] = useState(''); const [loading, setLoading] = useState(false); /** * 首次读取是否已经落定(无论成功、结构化失败还是认不出形状的拒绝)。 @@ -175,10 +173,7 @@ export function useXhsMinitoolExport({ const refresh = useCallback(async (): Promise => { if (!projectPath) return null; const invoke = resolveTauriInvoke(); - if (!invoke) { - setNotice(NO_HOST_NOTICE); - return null; - } + if (!invoke) return null; // 只有首次读取才翻 `loading`:自动重读每 2 秒一次,每次都翻一下这个状态,界面上的 // 按钮与卡片就会跟着一闪一闪(未适配提示、按钮行的「适配」都以它为判据之一)。 const isInitialRead = !initialReadSettledRef.current; @@ -201,11 +196,6 @@ export function useXhsMinitoolExport({ setFieldError(null); setConflict(null); setFailure(null); - // 只撤掉「读不到」那一句:打包结果、失败修复的原因也是 `notice`,读到内容不等于用户读过了它, - // 每 2 秒重读一次就把它们擦掉会让这些话根本来不及被看见。 - setNotice((previous) => - previous === CONTENT_UNAVAILABLE_NOTICE ? '' : previous, - ); baselineRef.current = await readXhsMinitoolExportHash( invoke, projectPath, @@ -214,9 +204,8 @@ export function useXhsMinitoolExport({ } catch (error) { const structured = asXhsMinitoolReadFailure(error); if (!structured) { - // 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):面板留一句实话, - // 现场原样抛给错误池结算。没有这一句,面板会停在「正在读取」上装死。 - setNotice(CONTENT_UNAVAILABLE_NOTICE); + // 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):现场原样抛给错误池 + // 结算;面板那一侧退回「没能读到导出信息,正在自动重试…」,不会停在「正在读取」上装死。 throwToErrorPool(String(error), error); return null; } @@ -257,10 +246,7 @@ export function useXhsMinitoolExport({ async (next: XHSMiniToolExportForm, baseHash: string): Promise => { if (!projectPath) return false; const invoke = resolveTauriInvoke(); - if (!invoke) { - setNotice(NO_HOST_NOTICE); - return false; - } + if (!invoke) return false; try { baselineRef.current = await saveXhsMinitoolExportForm(invoke, { projectPath, @@ -335,21 +321,11 @@ export function useXhsMinitoolExport({ const buildOnce = useCallback(async (): Promise => { if (!projectPath) return; const invoke = resolveTauriInvoke(); - if (!invoke) { - setNotice(NO_HOST_NOTICE); - return; - } - setNotice(''); + if (!invoke) return; setFailure(null); try { - const result = await runXhsMinitoolExportBuild(invoke, projectPath); + await runXhsMinitoolExportBuild(invoke, projectPath); setArtifactReady(true); - // 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。 - setNotice( - ['代码包已重新生成,点「下载」保存到本地。', describeOutputTail(result)] - .filter((line) => line.trim() !== '') - .join('\n'), - ); } catch (error) { setArtifactReady(false); const structured = asXhsMinitoolBuildFailure(error); @@ -423,16 +399,12 @@ export function useXhsMinitoolExport({ /** * 把一条指令当用户消息发给项目会话。 * - * 成功不回执:这几个动作都是用户刚点的那颗按钮,面板再复述一句「已交给陶泥儿」只是噪音。 - * 留在界面上的只有两种话——发不出去时的实话,和失败修复必须留下的出错原因(失败卡片收掉之后, - * 那是唯一还写着「刚才为什么开这一轮」的地方)。 + * 入队结果不在导出面板里复述:走的与会话输入框同一条 `submit`,忙态、队满、发不出去都由会话 + * 那一侧告知;用户刚点的动作再铺一句回执只是噪音。 */ const sendToAgent = useCallback( - async (instruction: string, sentNotice = ''): Promise => { - const handedOver = await submitTurn([ - { type: 'input_text', text: instruction }, - ]); - setNotice(handedOver ? sentNotice : '这次没能发出去,原因在右侧会话里'); + async (instruction: string): Promise => { + await submitTurn([{ type: 'input_text', text: instruction }]); }, [submitTurn], ); @@ -442,6 +414,13 @@ export function useXhsMinitoolExport({ setAdaptPromptDismissed(true); }, []); + /** 关掉失败弹窗:现场记进 `dismissedFailure`,同一个现场不会因为下一次重读又跳出来。 */ + const dismissFailure = useCallback((): void => { + if (!failure) return; + setDismissedFailure(JSON.stringify(failure)); + setFailure(null); + }, [failure]); + /** * 「帮我填」:把手上没保存的输入先落盘,再让 agent 直接改注册表把表单三项填好。 * @@ -461,10 +440,16 @@ export function useXhsMinitoolExport({ await sendToAgent(buildXhsMinitoolFormFillInstruction()); }, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]); - /** 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 */ + /** + * 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 + * + * 也清掉失败现场:入口可能来自失败弹窗(「还没配置好导出」那是同一个 `buildScriptMissing`), + * 留着它弹窗就关不上,而这一颗按钮已经把用户要交给陶泥儿的事交出去了。 + */ const adapt = useCallback((): void => { if (!projectPath) return; setAdaptPromptDismissed(true); + setFailure(null); void sendToAgent(buildXhsMinitoolAdaptationInstruction()); }, [projectPath, sendToAgent]); @@ -475,7 +460,7 @@ export function useXhsMinitoolExport({ * 项目权限策略拉黑了 `command.exec` 是用户自己的配置(agent 受同一条策略约束)、宿主没跑起来改代码没用—— * 这些叫 agent 进来它只会去改一份没问题可改的代码。 * - * 用户回执带失败原因:卡片随即被撤掉,这句提示是面板上唯一还留着「刚才为什么开这一轮」的地方。 + * 失败现场当场清掉:弹窗由它开关,指令本身已经带着原始错误交给陶泥儿了。 */ const repair = useCallback((): void => { if (!projectPath || !failure) return; @@ -485,10 +470,8 @@ export function useXhsMinitoolExport({ ) { return; } - // 回执复述用户侧的说法;给 agent 的指令直接带原始错误,两者互不派生。 - const reason = describeXhsMinitoolFailure(failure); setFailure(null); - void sendToAgent(buildXhsMinitoolErrorFixInstruction(failure), reason); + void sendToAgent(buildXhsMinitoolErrorFixInstruction(failure)); }, [projectPath, failure, sendToAgent]); /** @@ -497,13 +480,16 @@ export function useXhsMinitoolExport({ * 同步细节(写回基线、保存中、待保存)刻意不在这里:它们没有对应的界面,露出来只会诱使调用方 * 拿它做判断。刷新也不是对外动作——重读内容由自动刷新和「打包」之后的现算负责。 */ + /** 对外只给没被关掉的那个现场:关掉的还是同一个就不弹。 */ + const visibleFailure = + failure && JSON.stringify(failure) === dismissedFailure ? null : failure; + return { form, hasScript, fieldError, conflict, - failure, - notice, + failure: visibleFailure, loading, readSettled, running, @@ -517,5 +503,6 @@ export function useXhsMinitoolExport({ repair, fillForm, dismissAdaptPrompt, + dismissFailure, }; } 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 2527e7320..3f9bbb48e 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 @@ -3,13 +3,15 @@ * * 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护 * 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。 + * + * 不回话也不抛:落点与结果都是用户在原生对话框里亲手给的,面板不再补一句「已保存到 …」; + * 失败只留一条控制台痕迹,不打断用户手上的事。 */ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { resolveTauriInvoke } from '../../../../app/tauri'; import { resourceFileNameFromPath } from '../../../../features/resource-canvas/resourceCanvasAssetTransferModel'; -/** 返回给用户看的一句话;用户取消对话框时返回空串(不打扰)。 */ export async function saveXhsMinitoolFileToDisk(input: { projectPath: string; /** 项目内相对路径,交给宿主的既有门禁复核。 */ @@ -17,9 +19,9 @@ export async function saveXhsMinitoolFileToDisk(input: { /** 路径里取不出文件名时用的兜底名。 */ fallbackFileName: string; title: string; -}): Promise { +}): Promise { const invoke = resolveTauriInvoke(); - if (!invoke) return '要在客户端里打开导出面板才能保存文件'; + if (!invoke) return; let destination: string | null = null; try { destination = await saveNativeFileDialog({ @@ -30,22 +32,20 @@ export async function saveXhsMinitoolFileToDisk(input: { title: input.title, }); } catch (error) { - return error instanceof Error ? error.message : String(error); + console.warn('导出产物保存对话框没能打开', error); + return; } - if (!destination) return ''; + // 用户取消对话框:什么都没发生,也没有话要说。 + if (!destination) return; try { - const result = await invoke<{ destinationPath: string }>( - 'save_local_project_asset_file', - { - input: { - projectPath: input.projectPath, - relativePath: input.relativePath, - destinationPath: destination, - }, + await invoke('save_local_project_asset_file', { + input: { + projectPath: input.projectPath, + relativePath: input.relativePath, + destinationPath: destination, }, - ); - return `已保存到 ${result.destinationPath}`; + }); } catch (error) { - return error instanceof Error ? error.message : String(error); + console.warn('导出产物没能写到你选的位置', error); } } 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 deleted file mode 100644 index e7e1f2ca2..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。 - * - * 宿主不预拼用户可见文案:省略量是事实, - * 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。 - * 失败卡片与导出成功提示共用这一份,避免两处各写一句。 - */ -export function describeOutputTail(input: { - outputTail: string; - omittedCharacters: number; -}): string { - const tail = input.outputTail.trim(); - if (input.omittedCharacters <= 0) return tail; - 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/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index e323992bc..414cb0e62 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -1,11 +1,9 @@ -import { useState } from 'react'; - import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction'; import { AdaptPromptDialog } from './AdaptPromptDialog'; import { ConflictCard } from './ConflictCard'; -import { FailureCard } from './FailureCard'; +import { FailureDialog } from './FailureDialog'; import { FormCard } from './FormCard'; import { PaneButton } from './PaneButton'; @@ -19,7 +17,9 @@ import { PaneButton } from './PaneButton'; * 内容与脚本判据按固定间隔重读(所以没有刷新按钮)。 * * 「还没配置好导出」不再是一张常驻卡片,而是阻断弹窗:重读每 2 秒一次,卡片会跟着重读闪,弹窗则 - * 只在用户真的需要做选择时出现一次。可见文案说用户的话,不出现脚本名、注册表、zip 这些实现词。 + * 只在用户真的需要做选择时出现一次。失败现场同理走阻断弹窗(`FailureDialog`):左半本来就矮, + * 卡片会被滚出视野,而失败是用户必须看见的东西。可见文案说用户的话,不出现脚本名、注册表、zip + * 这些实现词。 */ export function ArtifactsPane({ minitool, @@ -28,8 +28,7 @@ export function ArtifactsPane({ minitool: XhsMinitoolExportState; projectPath: string | null; }) { - const [downloadNotice, setDownloadNotice] = useState(''); - const { form, conflict } = minitool; + const { form, conflict, failure } = minitool; /** * 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下 * 「还没适配」的结论——那种情况只该显示读取中或失败现场。 @@ -47,6 +46,7 @@ export function ArtifactsPane({ */ const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed; + // 保存不打回执:用户刚在原生对话框里选完落点,磁盘上有没有那个文件是他自己看得见的事实。 const downloadIcon = (): void => { if (!projectPath || !form) return; void saveXhsMinitoolFileToDisk({ @@ -54,7 +54,7 @@ export function ArtifactsPane({ relativePath: form.iconPath.trim(), fallbackFileName: 'icon.png', title: '保存图标文件', - }).then(setDownloadNotice); + }); }; const downloadArtifact = (): void => { @@ -64,7 +64,7 @@ export function ArtifactsPane({ relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, fallbackFileName: 'xhs-minitool.zip', title: '保存代码包', - }).then(setDownloadNotice); + }); }; return ( @@ -84,10 +84,10 @@ export function ArtifactsPane({ /> ) : !minitool.readSettled ? (

正在读取导出信息…

- ) : // 读失败时上面不重复一句话:失败卡片拿着宿主的现场和下一步。 - minitool.failure ? null : ( + ) : // 读失败时上面不重复一句话:失败弹窗拿着宿主的现场和下一步。 + failure ? null : (

- {minitool.notice || '没能读到导出信息,正在自动重试…'} + 没能读到导出信息,正在自动重试…

)} @@ -136,24 +136,6 @@ export function ArtifactsPane({ onResolve={minitool.resolveConflict} /> ) : null} - - {minitool.failure ? ( - void minitool.runExport()} - /> - ) : null} - - {downloadNotice || minitool.notice ? ( -

- {downloadNotice || minitool.notice} -

- ) : null} + + ); } 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 deleted file mode 100644 index 48ca11a74..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { XhsMinitoolCardFailure } from '../../state/xhsMinitoolFailure'; -import { - classifyXhsMinitoolFailure, - describeXhsMinitoolFailure, - XhsMinitoolFailureRepairability, -} from '../../state/xhsMinitoolFailure'; -import { PaneButton } from './PaneButton'; - -/** - * 失败卡片:一句用户看得懂的话 + 一颗按「谁有能力修」选出来的按钮。 - * - * 按钮由 `classifyXhsMinitoolFailure` 决定,不在这里按变体现猜: - * - `CodeAgentRepairable`:项目还没适配就给「让陶泥儿帮我调通」,适配过但这次没跑通就给「让陶泥儿来修」; - * - `OnlyHumanRepairable`(项目权限策略拉黑了 `command.exec`、宿主没跑起来):只给「重试」—— - * 要改的东西不在项目代码里,别把陶泥儿拉进来改一份没问题可改的代码。 - * - * 卡片只收 [`XhsMinitoolCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。 - * 宿主给的现场也不在这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。 - */ -export function FailureCard({ - failure, - onAdapt, - onRepair, - onRetry, -}: { - failure: XhsMinitoolCardFailure; - onAdapt: () => void; - onRepair: () => void; - onRetry: () => void; -}) { - const repairability = classifyXhsMinitoolFailure(failure); - return ( -
-

- {describeXhsMinitoolFailure(failure)} -

-
- {repairability === - XhsMinitoolFailureRepairability.OnlyHumanRepairable ? ( - - 重试 - - ) : failure.type === 'buildScriptMissing' ? ( - - 让陶泥儿帮我调通 - - ) : ( - - 让陶泥儿来修 - - )} -
-
- ); -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx new file mode 100644 index 000000000..de983eca8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx @@ -0,0 +1,74 @@ +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; +import type { XhsMinitoolCardFailure } from '../../state/xhsMinitoolFailure'; +import { + classifyXhsMinitoolFailure, + describeXhsMinitoolFailure, + XhsMinitoolFailureRepairability, +} from '../../state/xhsMinitoolFailure'; +import { PaneButton } from './PaneButton'; + +/** + * 失败弹窗:一句用户看得懂的话 + 一颗按「谁有能力修」选出来的按钮。 + * + * 每一种失败都走这里(原来是一张就地卡片):左半本来就不高,卡片会被滚出视野,而失败恰恰是 + * 用户必须看见的东西——尤其是「打完了却没有产物」那一种,他正等着下载。与「还没配置好导出」 + * 同一种阻断层,背景点击与 Esc 不生效;两颗按钮都会清掉失败现场,所以弹窗随之关闭。 + * + * 按钮由 `classifyXhsMinitoolFailure` 决定,不在这里按变体现猜: + * - `CodeAgentRepairable`:项目还没适配就给「让陶泥儿帮我调通」,适配过但这次没跑通就给「让陶泥儿来修」; + * - `OnlyHumanRepairable`(项目权限策略拉黑了 `command.exec`、宿主没跑起来):不给陶泥儿入口—— + * 要改的东西不在项目代码里,把陶泥儿拉进来只会让它去改一份没问题可改的代码。 + * + * 每条失败都带「关闭」:关掉之后弹窗收起、现场不再重复弹(下一次重读会再送同一个现场回来), + * 用户想自己重来就回面板点「打包」。没有「重试」——它跟关掉再点一次「打包」是同一件事。 + * + * 只收 [`XhsMinitoolCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。宿主给的现场也不在 + * 这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。 + */ +export function FailureDialog({ + failure, + onAdapt, + onRepair, + onDismiss, +}: { + /** 没有失败现场时为 `null`,弹窗关着。 */ + failure: XhsMinitoolCardFailure | null; + onAdapt: () => void; + onRepair: () => void; + onDismiss: () => void; +}) { + const repairability = failure ? classifyXhsMinitoolFailure(failure) : null; + const agentAction = + !failure || + repairability === XhsMinitoolFailureRepairability.OnlyHumanRepairable + ? null + : failure.type === 'buildScriptMissing' + ? '让陶泥儿帮我调通' + : '让陶泥儿来修'; + + return ( + +

+ {failure ? describeXhsMinitoolFailure(failure) : null} +

+
+ 关闭 + {agentAction === null ? null : ( + + {agentAction} + + )} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 48d772549..ba30ec15d 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -4,7 +4,7 @@ * * 三个切面各钉一块: * - 交给 code agent 的指令正文(纯字符串组装,含三条硬契约与要拷的示例脚本); - * - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步); + * - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败弹窗的下一步); * - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。 */ import { writeText } from '@tauri-apps/plugin-clipboard-manager'; @@ -462,7 +462,7 @@ describe('面板左半', () => { ).toBeNull(); }); - it('读落定之后失败就不再假装还在读:上面交给失败卡片,不重复一句话', () => { + it('读落定之后失败就不再假装还在读:交给失败弹窗,不重复一句话', () => { renderPane( fakeMinitool({ form: null, @@ -472,7 +472,8 @@ describe('面板左半', () => { ); expect(screen.queryByText('正在读取导出信息…')).toBeNull(); - expect(screen.getByRole('alert')).toBeTruthy(); + // 失败一律走阻断弹窗:左半矮,就地卡片会被滚出视野。 + expect(screen.getByRole('dialog', { name: '导出失败' })).toBeTruthy(); }); it('认不出形状的拒绝给一句实话,不装死在「正在读取」上', () => { @@ -505,7 +506,7 @@ describe('面板左半', () => { }); }); - it('失败卡片按变体给出「让陶泥儿调通」或「让陶泥儿来修」', () => { + it('失败弹窗按变体给出「让陶泥儿调通」或「让陶泥儿来修」', () => { // 还没配置好导出:入队的必须是首次适配指令,修复指令会引用一份还不存在的适配说明。 const neverAdapted = fakeMinitool({ hasScript: false, @@ -517,7 +518,7 @@ describe('面板左半', () => { }); renderPane(neverAdapted); - // 卡片说用户看得懂的话,不把脚本名与宿主找过哪些目录抖出来。 + // 弹窗说用户看得懂的话,不把脚本名与宿主找过哪些目录抖出来。 screen.getByText( '这个项目还没有配置好导出。先让陶泥儿帮你调通一次,之后就能直接打包了。', ); @@ -526,6 +527,7 @@ describe('面板左半', () => { fireEvent.click(screen.getByRole('button', { name: '让陶泥儿帮我调通' })); expect(neverAdapted.adapt).toHaveBeenCalledTimes(1); expect(neverAdapted.repair).not.toHaveBeenCalled(); + expect(neverAdapted.dismissFailure).not.toHaveBeenCalled(); // 配置过但这次没跑通:入队修复指令,带上这次的现场。 cleanup(); @@ -544,9 +546,9 @@ describe('面板左半', () => { expect(broken.adapt).not.toHaveBeenCalled(); }); - it('只能人来改的失败,卡片不给任何「让陶泥儿」入口', () => { + it('只能人来改的失败,弹窗不给任何「让陶泥儿」入口,只给关闭', () => { // command.exec 被项目权限策略拉黑,改策略的是用户,agent 受同一条策略约束; - // 宿主没跑起来更是只能重试。`saveConflict` 到不了这里——它有冲突面板。 + // 宿主没跑起来也一样。`saveConflict` 到不了这里——它有冲突面板。 const humanOnlyFailures: XhsMinitoolCardFailure[] = [ { type: 'commandDenied', commandId: 'command.exec' }, { type: 'exportUnavailable', cause: '项目根不可用:找不到目录' }, @@ -560,13 +562,49 @@ describe('面板左半', () => { expect( screen.queryByRole('button', { name: '让陶泥儿帮我调通' }), ).toBeNull(); - // 用户能做的只有重试;现场留着,等用户去界面上改。 - fireEvent.click(screen.getByRole('button', { name: '重试' })); - expect(minitool.runExport).toHaveBeenCalledTimes(1); + // 没有「重试」这颗按钮:关掉弹窗回面板自己点「打包」是同一件事。 + expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + expect(minitool.dismissFailure).toHaveBeenCalledTimes(1); + expect(minitool.runExport).not.toHaveBeenCalled(); expect(minitool.repair).not.toHaveBeenCalled(); expect(minitool.adapt).not.toHaveBeenCalled(); } }); + + it('关掉失败弹窗后,下一次重读把同一个现场送回来也不再弹', async () => { + // 内容读不回来时每 2 秒重读一次,同一个现场会一直被送回来:关一次就该一直安静, + // 否则弹窗会自己跳出来跟用户抢界面。 + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + throw { type: 'registryMalformed', cause: 'bad json' }; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => + expect(result.current.failure).toEqual({ + type: 'registryMalformed', + cause: 'bad json', + }), + ); + + await act(async () => { + result.current.dismissFailure(); + }); + expect(result.current.failure).toBeNull(); + + // 「打包」会现算一次要不要构建,这条路上又读了一次内容——同一个现场不许再弹。 + await act(async () => { + await result.current.packageAll(); + }); + expect(result.current.failure).toBeNull(); + }); }); describe('命令失败各有一条表', () => { @@ -779,34 +817,6 @@ describe('状态 hook', () => { expect(result.current.artifactReady).toBe(false); }); - it('输出被宿主截断时,说明那句话由前端拼', async () => { - const invoke = vi.fn(async (command: string) => { - if (command === 'read_xhs_minitool_export') { - return { form: FORM, hasScript: true }; - } - if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; - return { outputTail: 'zip 已生成', omittedCharacters: 4096 }; - }); - installTauriRuntime({ core: { invoke } }); - const { result } = renderHook(() => - useXhsMinitoolExport({ - enabled: true, - projectPath: PROJECT_PATH, - submitTurn: vi.fn(async () => true), - }), - ); - await waitFor(() => expect(result.current.form).toEqual(FORM)); - - await act(async () => { - await result.current.runExport(); - }); - - // 宿主只回事实(原文 + 省略量),提示里那句「已省略」是这里拼的。 - expect(result.current.notice).toBe( - '代码包已重新生成,点「下载」保存到本地。\n(内容较长,前面省略了 4096 个字符)\nzip 已生成', - ); - }); - it('打包先把没保存的输入落盘,成功后再跑构建', async () => { const calls: string[] = []; const invoke = vi.fn(async (command: string) => { @@ -885,9 +895,7 @@ describe('状态 hook', () => { expect(calls).toContain('read_xhs_minitool_export'); expect(calls).not.toContain('run_xhs_minitool_export_build'); // 还没有脚本就把「让陶泥儿帮你调通」重新递出来,而不是报个错把人晾在原地。 - // 这一路只重新武装弹窗,状态行不再复述一遍。 expect(result.current.adaptPromptDismissed).toBe(false); - expect(result.current.notice).toBe(''); expect(result.current.running).toBe(false); }); @@ -981,7 +989,6 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await expect(result.current.runExport()).rejects.toBe(transport); - expect(result.current.notice).toBe(''); expect(result.current.failure).toBeNull(); // 同一个故障只抛一次:轮询每 2 秒重读一次,不能让错误池的 count 变成轮询次数。 await expect(result.current.runExport()).resolves.toBeUndefined(); @@ -1230,7 +1237,6 @@ function fakeMinitool( fieldError: null, conflict: null, failure: null, - notice: '', loading: false, readSettled: true, running: false, @@ -1244,6 +1250,7 @@ function fakeMinitool( repair: vi.fn(), fillForm: vi.fn(async () => undefined), dismissAdaptPrompt: vi.fn(), + dismissFailure: vi.fn(), ...overrides, }; }