diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d10e44eca..2d163c7ee 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -167,6 +167,7 @@ module.exports = { // ts-rs 生成绑定:不接受 eslint --fix 二次改写,必须与原始输出逐字节一致 'packages/shared/src/contracts/generated/**', 'apps/ai-game-creator-shell/src/view/project-development/chat/generated/**', + 'apps/ai-game-creator-shell/src/view/project-development/export/generated/**', 'apps/ai-game-creator-shell/src/services/generated/**', 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', // 审核 Skill Pack:内容按 SHA-256 定址并经 include_bytes! 编译进客户端, 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 new file mode 100644 index 000000000..571241657 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -0,0 +1,253 @@ +/** + * 小红书小工具导出 tab 的状态:注册表读写、表单自动保存、冲突、构建、把任务交给 code agent。 + * + * 挂在面板根(`usePanel` → `useXiaohongshuTab`)上,不挂在 tab 页里:切到别的 tab 时页面组件 + * 会卸载,但「读到了什么、有什么冲突、正在跑什么」必须留在面板上。 + * + * 两条边界写死在这里: + * - agent 调用只有用户显式点「适配 / 让 agent 修」才会发生,保存与导出**不触发任何** agent 调用; + * - `hasScript` 永远来自宿主的现算结果,前端不自己读 `package.json`、也不缓存这个事实。 + */ +import { useCallback, useEffect, useState } from 'react'; + +import { resolveTauriInvoke } from '../../../../app/tauri'; +import type { UserContentPart } from '../../chat/generated/UserContentPart'; +import type { FormInvalid } from '../generated/FormInvalid'; +import type { SaveConflict } from '../generated/SaveConflict'; +import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; +import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; +import { + asXhsMinitoolFailure, + readXhsMinitoolExport, + runXhsMinitoolExportBuild, + saveXhsMinitoolExportForm, +} from './xhsMinitoolApi'; +import { + describeXhsMinitoolFailure, + type XhsMinitoolFailureNotice, +} from './xhsMinitoolFailure'; +import { + buildXhsMinitoolAdaptationInstruction, + buildXhsMinitoolRepairInstruction, + XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, +} from './xhsMinitoolInstruction'; + +export type XhsMinitoolExportState = ReturnType; + +/** + * 一次失败的完整现场:给用户看的说法,加上原始载荷(「让 agent 修」要用它拼指令)。 + */ +export type XhsMinitoolFailureState = { + notice: XhsMinitoolFailureNotice; + error: XHSMiniToolExportError; +}; + +/** 输入停下多久之后自动保存:够长到不打断打字,够短到用户切走的时候不丢字。 */ +const AUTOSAVE_DELAY_MS = 600; +const NO_HOST_NOTICE = + '需要在客户端内打开导出面板,宿主才能读写注册表并运行构建脚本'; + +/** 把结构化失败收成现场:给用户看的说法 + 原始载荷。 */ +function failureState(error: XHSMiniToolExportError): XhsMinitoolFailureState { + return { notice: describeXhsMinitoolFailure(error), error }; +} + +export function useXhsMinitoolExport({ + enabled, + projectPath, + submitTurn, +}: { + /** 进过一次小红书 tab 且项目路径就绪后才读注册表:进入前不产生任何文件访问。 */ + enabled: boolean; + projectPath: string | null; + /** 往当前项目会话里发一条用户消息(`chat.submit`);返回值是「这条草稿能不能清」。 */ + submitTurn: (content: UserContentPart[]) => Promise; +}) { + const [form, setForm] = useState(null); + const [hasScript, setHasScript] = useState(false); + const [contentHash, setContentHash] = useState(''); + const [fieldError, setFieldError] = useState(null); + const [conflict, setConflict] = useState(null); + const [failure, setFailure] = useState(null); + const [notice, setNotice] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [running, setRunning] = useState(false); + const [dirty, setDirty] = useState(false); + + /** 读现场:注册表(宿主缺则补空表单)+ 脚本是否存在。刷新按钮走的就是这一条。 */ + const load = useCallback(async (): Promise => { + if (!projectPath) return; + const invoke = resolveTauriInvoke(); + if (!invoke) { + setNotice(NO_HOST_NOTICE); + return; + } + setLoading(true); + try { + const state = await readXhsMinitoolExport(invoke, projectPath); + setForm(state.form); + setHasScript(state.hasScript); + setContentHash(state.contentHash); + setFieldError(null); + setConflict(null); + setFailure(null); + setDirty(false); + } catch (error) { + const structured = asXhsMinitoolFailure(error); + if (structured) setFailure(failureState(structured)); + else setNotice(error instanceof Error ? error.message : String(error)); + } finally { + setLoading(false); + } + }, [projectPath]); + + useEffect(() => { + if (!enabled) return; + void load(); + }, [enabled, load]); + + const persist = useCallback( + async (next: XHSMiniToolExportForm, baseHash: string): Promise => { + if (!projectPath) return; + const invoke = resolveTauriInvoke(); + if (!invoke) { + setNotice(NO_HOST_NOTICE); + return; + } + setSaving(true); + try { + const state = await saveXhsMinitoolExportForm(invoke, { + projectPath, + form: next, + baseHash, + }); + setHasScript(state.hasScript); + setContentHash(state.contentHash); + setFieldError(null); + setConflict(null); + setFailure(null); + setDirty(false); + } catch (error) { + // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 + // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 + setDirty(false); + const structured = asXhsMinitoolFailure(error); + if (!structured) { + setNotice(error instanceof Error ? error.message : String(error)); + return; + } + if (structured.type === 'formInvalid') setFieldError(structured); + else if (structured.type === 'saveConflict') setConflict(structured); + else setFailure(failureState(structured)); + } finally { + setSaving(false); + } + }, + [projectPath], + ); + + useEffect(() => { + if (!enabled || !form || !dirty || conflict || fieldError) return; + const timer = setTimeout(() => { + void persist(form, contentHash); + }, AUTOSAVE_DELAY_MS); + return () => clearTimeout(timer); + }, [enabled, form, dirty, conflict, fieldError, contentHash, persist]); + + /** 改一个字段。改哪个字段就清哪个字段的旧报错——别让已经改好的红字继续挂着。 */ + const updateField = useCallback( + (field: keyof XHSMiniToolExportForm, value: string): void => { + setForm((previous) => + previous ? { ...previous, [field]: value } : previous, + ); + setFieldError((previous) => + previous && previous.field === field ? null : previous, + ); + setDirty(true); + }, + [], + ); + + /** 用户逐字段选完之后落盘:带着冲突时读到的指纹重试,成功即自然解开冲突。 */ + const resolveConflict = useCallback( + (resolved: XHSMiniToolExportForm): void => { + if (!conflict) return; + setForm(resolved); + void persist(resolved, conflict.contentHash); + }, + [conflict, persist], + ); + + /** 导出:每次都重新跑脚本、重新产出 zip,没有"上次的产物还能用"这条捷径。 */ + const runExport = useCallback(async (): Promise => { + if (!projectPath) return; + const invoke = resolveTauriInvoke(); + if (!invoke) { + setNotice(NO_HOST_NOTICE); + return; + } + setRunning(true); + setNotice(''); + setFailure(null); + try { + await runXhsMinitoolExportBuild(invoke, projectPath); + setNotice(`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`); + } catch (error) { + const structured = asXhsMinitoolFailure(error); + if (structured) setFailure(failureState(structured)); + else setNotice(error instanceof Error ? error.message : String(error)); + } finally { + setRunning(false); + } + }, [projectPath]); + + const sendToAgent = useCallback( + async (instruction: string): Promise => { + const handedOver = await submitTurn([ + { type: 'input_text', text: instruction }, + ]); + setNotice( + handedOver + ? '已把任务交给 code agent,右侧会话里能看到它的进展' + : '这条任务没有发出去,原因在右侧会话里', + ); + }, + [submitTurn], + ); + + /** 一键适配:把首次适配指令发给同一个项目会话。 */ + const adapt = useCallback((): void => { + if (!projectPath) return; + void sendToAgent(buildXhsMinitoolAdaptationInstruction(projectPath)); + }, [projectPath, sendToAgent]); + + /** 按当前失败现场让 agent 修。失败卡片上的那颗按钮走这里。 */ + const repair = useCallback((): void => { + if (!projectPath || !failure) return; + setFailure(null); + void sendToAgent( + buildXhsMinitoolRepairInstruction(projectPath, failure.error), + ); + }, [projectPath, failure, sendToAgent]); + + return { + form, + hasScript, + contentHash, + fieldError, + conflict, + failure, + notice, + loading, + saving, + running, + dirty, + updateField, + load, + resolveConflict, + runExport, + adapt, + repair, + }; +} 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 new file mode 100644 index 000000000..502cec317 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts @@ -0,0 +1,82 @@ +/** + * 小红书导出命令的 JS 侧入口。 + * + * 只做两件事:把命令名与参数名(camelCase)拼对,把结构化失败装成可判别的 `Error`。命令名在这 + * 里唯一收口,调用方不写字符串命令名、也不自己嗅探失败形状。 + */ +import type { TauriInvoke } from '../../../../app/types'; +import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; +import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; +import type { XHSMiniToolExportRunResult } from '../generated/XHSMiniToolExportRunResult'; +import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState'; + +/** + * 命令失败在 JS 侧的载体:`error` 就是 ts-rs 生成的判别联合。 + * + * 与入队失败(`EnqueueFailureWrapper`)同一套路:构造时把整份载荷 `JSON.stringify` 进 + * `Error.message`,分流只看 `error.type`,**不解析任何文案**。 + */ +export class XhsMinitoolFailure extends Error { + readonly error: XHSMiniToolExportError; + + constructor(error: XHSMiniToolExportError) { + super(JSON.stringify(error)); + this.name = 'XhsMinitoolFailure'; + this.error = error; + } +} + +/** 认得出是结构化失败就取出载荷,否则返回 `null`(传输失败、命令 panic、参数序列化失败)。 */ +export function asXhsMinitoolFailure( + error: unknown, +): XHSMiniToolExportError | null { + return error instanceof XhsMinitoolFailure ? error.error : null; +} + +export function readXhsMinitoolExport( + invoke: TauriInvoke, + projectPath: string, +): Promise { + return guardStructuredFailure(() => + invoke('read_xhs_minitool_export', { projectPath }), + ); +} + +export function saveXhsMinitoolExportForm( + invoke: TauriInvoke, + input: { + projectPath: string; + form: XHSMiniToolExportForm; + baseHash: string; + }, +): Promise { + return guardStructuredFailure(() => + invoke('save_xhs_minitool_export_form', { + projectPath: input.projectPath, + form: input.form, + baseHash: input.baseHash, + }), + ); +} + +export function runXhsMinitoolExportBuild( + invoke: TauriInvoke, + projectPath: string, +): Promise { + return guardStructuredFailure(() => + invoke('run_xhs_minitool_export_build', { + projectPath, + }), + ); +} + +async function guardStructuredFailure(call: () => Promise): 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); + } +} 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 new file mode 100644 index 000000000..f91bbc09b --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts @@ -0,0 +1,47 @@ +/** + * 把导出产物(icon / zip)经原生保存对话框落到用户选定的位置。 + * + * 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护 + * 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。 + */ +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; + +import { resolveTauriInvoke } from '../../../../app/tauri'; + +/** 返回给用户看的一句话;用户取消对话框时返回空串(不打扰)。 */ +export async function saveXhsMinitoolFileToDisk(input: { + projectPath: string; + /** 项目内相对路径,交给宿主的既有门禁复核。 */ + relativePath: string; + /** 对话框里的默认文件名。 */ + defaultFileName: string; + title: string; +}): Promise { + const invoke = resolveTauriInvoke(); + if (!invoke) return '需要在客户端内打开导出面板才能保存文件'; + let destination: string | null = null; + try { + destination = await saveNativeFileDialog({ + defaultPath: input.defaultFileName, + title: input.title, + }); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + if (!destination) return ''; + try { + const result = await invoke<{ destinationPath: string }>( + '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); + } +} 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 new file mode 100644 index 000000000..aa0764118 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts @@ -0,0 +1,79 @@ +/** + * 结构化失败 → 用户看得懂的一句话 + 下一步动作。 + * + * 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷 + * 字段只是拼句子的原料(路径、退出码、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。 + */ +import { expectNever } from '../../../../app/expectNever'; +import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; +import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields'; + +/** 失败之后最合适的下一步:交给 code agent 修,还是原样重试。 */ +export type XhsMinitoolFailureAction = 'adapt' | 'retry'; + +export type XhsMinitoolFailureNotice = { + /** 一句话说清哪儿不对。 */ + message: string; + /** 宿主给的现场(路径、退出码、输出尾部、解析器原文);没有就是空串。 */ + detail: string; + action: XhsMinitoolFailureAction; +}; + +export function describeXhsMinitoolFailure( + error: XHSMiniToolExportError, +): XhsMinitoolFailureNotice { + switch (error.type) { + case 'registryMalformed': + return { + message: '导出注册表被改成了宿主认不出的形状,宿主拒绝猜测它的内容。', + detail: error.cause, + action: 'adapt', + }; + 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 'commandFailed': + return { + message: + error.exitCode === null + ? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。' + : `构建脚本没有正常退出(exit code ${error.exitCode})。`, + detail: error.outputTail, + action: 'adapt', + }; + case 'artifactMissing': + return { + message: `构建脚本跑完了,但 ${error.path} 不在。多数是把 zip 写到了别处。`, + detail: '', + action: 'adapt', + }; + case 'exportUnavailable': + return { + message: '宿主这次没能执行导出。', + detail: error.cause, + action: 'retry', + }; + default: + expectNever(error); + return { + message: '导出失败,原因不在已知失败表里。', + detail: '', + action: 'retry', + }; + } +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts new file mode 100644 index 000000000..3ae193078 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts @@ -0,0 +1,47 @@ +/** + * 三个表单字段的展示元数据:标签、占位文案、字数上限。 + * + * 字段身份直接用 ts-rs 生成的 `XHSMiniToolExportField`(`"name" | "introduction" | "iconPath"`): + * 它与表单对象的键一一对应,所以 `form[field]` 天然成立,不需要再维护一份字段映射。 + */ +import type { XHSMiniToolExportField } from '../generated/XHSMiniToolExportField'; + +export type XhsMinitoolFieldMeta = { + field: XHSMiniToolExportField; + label: string; + placeholder: string; + /** 宿主硬校验的字数上限;icon 不是文本字段,没有这一项。 */ + maxChars?: number; + multiline: boolean; +}; + +/** 表单展示顺序。加字段时只改这里,保存与复制都按字段身份走。 */ +export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ + { + field: 'name', + label: '名称', + placeholder: '最多 14 个字', + maxChars: 14, + multiline: false, + }, + { + field: 'introduction', + label: '简介', + placeholder: '最多 14 个字', + maxChars: 14, + multiline: true, + }, + { + field: 'iconPath', + label: 'icon 文件', + placeholder: '项目内相对路径,例如 assets/icon.png', + multiline: false, + }, +]; + +export const XHS_MINITOOL_FIELD_LABELS: Record = + { + name: '名称', + introduction: '简介', + iconPath: 'icon 文件', + }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts new file mode 100644 index 000000000..93ce6fc77 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts @@ -0,0 +1,105 @@ +/** + * 交给 code agent 的指令正文:首次适配,以及按失败现场修复。 + * + * 纯函数 + 契约常量:宿主只认「npm 脚本名 + 产物路径 + 注册表形状」,其余(用哪版 vite 配置、 + * 中间目录叫什么、怎么调 pack.mjs)都留给 agent 在用户项目里实验。这里写的每条契约都能在 + * `src-tauri/src/export/draft/xhs_minitool/` 里找到对应的硬校验,改一边必须改另一边。 + */ +import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; +import { describeXhsMinitoolFailure } from './xhsMinitoolFailure'; + +/** 目标名,与 `model.ts` 的 `TARGET_META` 人话一致。 */ +export const XHS_MINITOOL_TARGET_LABEL = '小红书小工具'; +/** npm 脚本名:冒号形式是宿主 `command.exec` 白名单的要求,不是审美选择。 */ +export const XHS_MINITOOL_SCRIPT_NAME = 'build:xhs-minitool'; +/** 交付产物:宿主与用户都按这个相对路径找它。 */ +export const XHS_MINITOOL_ARTIFACT_RELATIVE_PATH = '.export/xhs-minitool.zip'; +/** 注册表:宿主读写,形状固定。 */ +export const XHS_MINITOOL_REGISTRY_RELATIVE_PATH = '.export/xhs-minitool.json'; +/** 首次适配要读的 skill 名。 */ +export const XHS_MINITOOL_SKILL_NAME = 'vite-export-xhs-minitool'; + +/** 注册表的规范形状:agent 可以填值,但不许增删键。 */ +export const XHS_MINITOOL_REGISTRY_SHAPE = + '{"form":{"name":"","introduction":"","iconPath":""}}'; + +/** skill 示例脚本 → 用户项目里的落点:拷贝是显式要求,落点由宿主约定。 */ +export const XHS_MINITOOL_COPIED_SCRIPTS: ReadonlyArray<{ + from: string; + to: string; +}> = [ + { from: 'scripts/pack.mjs', to: '.export/xhs-minitool.pack.mjs' }, + { from: 'scripts/validate.mjs', to: '.export/xhs-minitool.validate.mjs' }, + { + from: 'scripts/vite.config.xhs-minitool.mjs', + to: '.export/xhs-minitool.vite.config.mjs', + }, +]; + +/** + * 首次适配指令。 + * + * 三条硬要求按「宿主会拒绝什么」排:脚本名、产物路径、注册表形状;其余是做法与坑。 + * 结尾要求把结论写回脚本顶部注释——下次改动靠它,不靠聊天记录。 + */ +export function buildXhsMinitoolAdaptationInstruction( + projectPath: string, +): string { + const copied = XHS_MINITOOL_COPIED_SCRIPTS.map( + (entry) => ` - ${entry.from} → ${entry.to}`, + ).join('\n'); + return [ + `【任务】把当前项目适配成能产出「${XHS_MINITOOL_TARGET_LABEL}」制品的项目,并真的跑通一次。`, + '', + `项目根:${projectPath}`, + '', + '宿主按下面三条硬契约校验,请逐条满足(做不到就直接说明卡在哪一条,不要糊一个假产物):', + `1. npm 包里声明脚本 \`${XHS_MINITOOL_SCRIPT_NAME}\`。npm 包根 = 声明 package.json 的那个目录(通常是 game/)。`, + `2. 跑完这个脚本,\`${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\` 必须存在且非空(相对项目根)。`, + `3. \`${XHS_MINITOOL_REGISTRY_RELATIVE_PATH}\` 是宿主的注册表,形状只能是 ${XHS_MINITOOL_REGISTRY_SHAPE}:`, + ' 三个字段都是字符串,不要增删键、不要加状态字段。可以按产物实际情况填 name 与 introduction;', + ' iconPath 是项目内相对路径,留空由用户在面板里选文件。', + '', + `读 \`${XHS_MINITOOL_SKILL_NAME}\` skill,并把它的示例脚本**显式拷贝**到 .export/ 下(这份拷贝属于用户项目,允许你按本项目情况改路径、入口与依赖):`, + copied, + '', + '两个必须避开的坑:', + '- 打包脚本的 `--out-dir` 只允许指向 vite 的构建输出目录:它会就地删掉该目录里白名单之外的扩展名文件。指向源码目录会删掉用户的代码。', + '- `.export/` 不进项目快照:换机或恢复版本后适配成果不跟随,需要重新适配。这不用你处理,知道即可。', + '', + '最后:在 npm 包根跑一次 `npm run ' + + XHS_MINITOOL_SCRIPT_NAME + + '`,确认 zip 真的产出;', + '然后把这次适配的结论追加到 `.export/xhs-minitool.pack.mjs` 顶部的注释里(不超过 20 行:', + '用了哪些文件、zip 里是什么、下次要改什么、哪些只是权宜之计)。那份注释是给下次的你和用户看的。', + ].join('\n'); +} + +/** + * 按失败现场修复的指令。 + * + * 只带宿主的失败事实与同一份契约,不猜原因:agent 自己能跑脚本、能读日志。 + */ +export function buildXhsMinitoolRepairInstruction( + projectPath: string, + error: XHSMiniToolExportError, +): string { + const notice = describeXhsMinitoolFailure(error); + return [ + `【任务】修复「${XHS_MINITOOL_TARGET_LABEL}」导出失败,修到跑通为止。`, + '', + `项目根:${projectPath}`, + `宿主刚刚报的失败:${notice.message}`, + ...(notice.detail ? [``, '宿主给的现场:', notice.detail] : []), + '', + '同一份硬契约(宿主会按它校验):', + `1. npm 包根(声明 package.json 的目录,通常是 game/)里声明脚本 \`${XHS_MINITOOL_SCRIPT_NAME}\`。`, + `2. 跑完它,\`${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\` 必须存在且非空。`, + `3. 打包装脚本的 \`--out-dir\` 只能指向 vite 构建输出目录,绝不能指向源码目录。`, + '', + `上下文:适配说明与上次的结论在 .export/xhs-minitool.pack.mjs 顶部注释里,示例脚本来自 \`${XHS_MINITOOL_SKILL_NAME}\` skill。`, + '', + `请直接动手:在 npm 包根跑 \`npm run ${XHS_MINITOOL_SCRIPT_NAME}\` 复现 → 改脚本 → 再跑通;`, + '把这次的修复结论追加到 `.export/xhs-minitool.pack.mjs` 顶部注释(不超过 20 行)。', + ].join('\n'); +}