diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c4712a723..15f154c0d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -167,8 +167,12 @@ 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! 编译进客户端, + // 不接受 eslint --fix 改写(.prettierignore 同样忽略该目录,避免指纹漂移) + 'apps/ai-game-creator-shell/src-tauri/resources/agc-skills/**', 'target', 'src/main.tsx', 'src/App.tsx', diff --git a/.gitattributes b/.gitattributes index a63eef88a..16eded8f0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -30,4 +30,5 @@ packages/shared/src/contracts/generated/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/features/ui-editor/types/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/view/project-development/chat/generated/** linguist-generated=true whitespace=-trailing-space +apps/ai-game-creator-shell/src/view/project-development/export/generated/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/services/generated/** linguist-generated=true whitespace=-trailing-space diff --git a/.prettierignore b/.prettierignore index 446fa63d9..0561423f2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ packages/shared/src/contracts/generated/ public/Icons apps/ai-game-creator-shell/src/features/ui-editor/types/ 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-tauri/resources/agc-skills/ # 预览桥脚本:随包注入浏览器的资源,按字节搬运自 Rust 内联字符串,禁止 prettier 二次改写 diff --git a/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs b/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs index b92b38e7c..c04f75f56 100644 --- a/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs +++ b/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs @@ -7,8 +7,10 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { + collectBundledFiles, computeSkillContentFingerprint, inspectSkillPack, + isSafeSkillRelativePath, } from './skill-pack-manifest.mjs'; test('bundled skill pack manifest is synchronized', () => { @@ -16,6 +18,37 @@ test('bundled skill pack manifest is synchronized', () => { assert.deepEqual(result.mismatches, []); }); +test('hidden skill paths are rejected and never declared', () => { + assert.equal(isSafeSkillRelativePath('SKILL.md'), true); + assert.equal(isSafeSkillRelativePath('references/contract.md'), true); + assert.equal(isSafeSkillRelativePath('.selective_rule.txt'), false); + assert.equal(isSafeSkillRelativePath('scripts/.pack.test.mjs'), false); + assert.equal(isSafeSkillRelativePath('.hidden/SKILL.md'), false); +}); + +test('skill pack collector ignores hidden files and directories', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-skill-hidden-')); + try { + fs.mkdirSync(path.join(root, 'demo', 'scripts'), { recursive: true }); + fs.writeFileSync(path.join(root, 'demo', 'SKILL.md'), '# demo\n'); + fs.writeFileSync(path.join(root, 'demo', '.selective_rule.txt'), 'local\n'); + fs.writeFileSync( + path.join(root, 'demo', 'scripts', 'pack.mjs'), + 'export {};\n', + ); + fs.writeFileSync( + path.join(root, 'demo', 'scripts', '.pack.test.mjs'), + 'test\n', + ); + assert.deepEqual(collectBundledFiles(root), [ + 'demo/SKILL.md', + 'demo/scripts/pack.mjs', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('skill content fingerprint canonicalizes CRLF', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-skill-pack-')); try { diff --git a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs index 88c717208..c03be2ede 100644 --- a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs +++ b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs @@ -13,7 +13,9 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([ 'agc-project-structure', 'agc-unity-editor', 'agc-web-game-development', + 'platform-abstract', 'taonier-art-assets', + 'vite-export-xhs-minitool', ]); const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); @@ -27,6 +29,17 @@ function canonicalTextBytes(filePath) { return Buffer.from(decoded.replaceAll('\r\n', '\n'), 'utf8'); } +/** + * 隐藏文件/目录名(以 `.` 开头)永远不进入审核 Skill Pack。 + * + * 约定:Skill 目录里以 `.` 开头的文件和目录是本地开发辅助(例如 + * `.selective_rule.txt`、`scripts/.pack.test.mjs`),既不写进 manifest, + * 也不做指纹、不安装、不可通过 `agc_read_skill_resource` 读取。 + */ +export function isHiddenSkillEntryName(name) { + return typeof name === 'string' && name.startsWith('.'); +} + export function isSafeSkillRelativePath(value) { if ( typeof value !== 'string' || @@ -40,7 +53,11 @@ export function isSafeSkillRelativePath(value) { return value .split('/') .every( - (segment) => segment.length > 0 && segment !== '.' && segment !== '..', + (segment) => + segment.length > 0 && + segment !== '.' && + segment !== '..' && + !isHiddenSkillEntryName(segment), ); } @@ -70,10 +87,14 @@ export function computeSkillContentFingerprint(rootDir, entry) { return digest.digest('hex'); } -function collectBundledFiles(rootDir) { +export function collectBundledFiles(rootDir) { const files = []; const walk = (directory, prefix) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + // 隐藏文件/目录不参与审核:它们是本地开发辅助,不声明、不指纹、不安装。 + if (isHiddenSkillEntryName(entry.name)) { + continue; + } const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; const absolutePath = path.join(directory, entry.name); if (entry.isSymbolicLink()) { diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index 0437256ed..b51fa82bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -4,6 +4,8 @@ "conversation.list.description": "按序读取当前项目已记录的 Codex 返回摘要。", "conversation.read.description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。", "agc_read_skill_resource.description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。", + "agc_install_skill_resource.description": "把审核通过的 AGC Skill 自带文件原样复制到当前项目相对路径;宿主直接读内置字节,不会像读取那样被大文件截断,也不需要原生 cp 或审批,一次一个文件。仍走 agc_write_file 的合同与租约门。", + "agc_install_skill_resource.parameters.destinationPath": "当前项目根下的目标相对路径,例如 scripts/validate.mjs", "agc_write_file.description": "把文本写入当前 AGC 项目的相对路径,用于代码、配置、资源依赖或说明文件。", "agc_apply_patch.description": "使用官方 apply_patch 语法修改当前项目,支持 Add/Delete/Update/Move。固定当前项目为工作目录;一次最多64KiB UTF-8、256个操作,并受实际平台参数上限约束。完整检查全部源与目标后执行;失败可能已部分修改,先读取当前文件再提出新补丁。该工具可与独立的读取、生成和计划调用并行;同文件修改与依赖其结果的构建、检查须等待补丁回执。超时、取消或 needsReconciliation=true 时停止,不自动重放。", "agc_update_plan.description": "更新当前回合的进度计划,字段与 update_plan 相同:可选 explanation,以及 plan 中的 step/status(pending、in_progress、completed)。它可与其它独立工具并行;同一计划的连续更新按依赖顺序提交。计划完成只表示进度,不代替宿主交付验收。", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json index 5116ddcbe..d0fc2ea62 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json @@ -2,7 +2,7 @@ "identity": "对外身份:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问名称或能力时,以陶泥儿的身份回答。用户明确询问底层实现时可如实说明 Codex app-server 的作用。", "hostDelivery": "交付要求:当用户要求你制作、完成或交付游戏(例如“做一个游戏”“做一个可运行的游戏”“交付游戏”)时,调用 agc_register_delivery_contract 登记本轮 scope、changeKind 和 requirements,由你结合用户输入理解意图。只生图、普通修改、读取、咨询或不操作无需合同。文件写入、命令、生成和验证不以合同为前提。每项有唯一ID,仅支持visual或gameplay(scenario);登记后不能扩项。新Web合同由宿主补充现有双端视觉和固定玩法要求,以登记回包为准;跑酷选择runner-v1,俄罗斯方块选择tetris-v1,其余按真实能力选择固定场景。按实际需要构建和测试,构建命令返回值不是合同验收项。不能提交passed或改写证据。agc_delivery_status只返回当前评估,不会结束执行;完成本轮用户要求并正常回复后,宿主才复核已登记合同。", "deliveryFeedback": "本轮已登记合同的验收尚未通过。读取agc_delivery_status,针对未满足的视觉/玩法要求修复并提供真实证据;不得扩项、提交passed或改写证据。按实际需要构建和测试,再执行必要的固定双端场景。原用户目标与本轮登记要求保持不变。\n\n未满足项:\n{detail}", - "engineering": "AGC 工程要求:当前 cwd 是用户选择的项目目录。先读取适用的 AGENTS.md、README 或项目说明,识别实际引擎与工程结构。用户明确指定编辑器或引擎,而当前目录缺少对应工程结构时,先说明不匹配并澄清;用户确认继续当前工程或提供匹配目录后再执行。Cocos Creator 项目优先通过 `agc_cocos_execute` 或 `cocos.editor.execute` 操作已打开的编辑器。新 Web 游戏使用 npm + Vite;二维游戏使用 Phaser 4.2.1,以 `import Phaser from 'phaser'` 导入;三维游戏自行选择合适的三维技术栈。依赖统一使用 npm 包。Phaser 迁移:读取已有 game/index.html,将状态、输入、敌人/守卫、波次、胜负、重开和画布绘制迁移到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后启动 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布由单一机制居中:使用 Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 直接父容器使用尺寸明确的普通 block;使用 CSS 居中时,Phaser autoCenter 设为 NO_CENTER。外围布局可使用 flex/grid。预览偏移先检查并修正项目自身的 CSS 与 Phaser 配置。布局修改后按项目 scripts 构建 dist,在桌面、移动视口和 resize 后确认 canvas 相对父容器的中心误差不超过 1 CSS px、无溢出。简单修改聚焦用户要求及不可替代的最小验证;安装依赖、构建和试玩按此范围执行。源码和命令优先使用 cwd 相对路径,依赖安装与构建使用项目 npm scripts;原生文件读取、搜索、命令和图片查看按当前工具目录使用。源码局部补丁调用 `agc_apply_patch`,支持官方 Add/Delete/Update/Move 语法并固定当前项目目录;多步骤进度调用 `agc_update_plan`,计划状态不代替验收证据。完整文本写入可使用 `agc_write_file`,content 仅填写目标文件的完整原始 UTF-8 正文。可用能力包括原生文件、搜索、命令、图片查看、Skill、`agc_tools` 和用户已启用的第三方 MCP;用户指定工具时先查当前可用工具并调用,缺失时如实说明。资源工具按当前 schema 使用;Skill references 按需读取。完整新游戏或按策划案实现时执行 agc-game-production-workflow,依次完成“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”。需要视觉素材时执行 taonier-art-assets:检查已登记资源,缺少或不适用时调用生图/编辑工具,读取结果的相对路径和登记身份,将真实素材接入源码并验证显示后再交付。你负责推进任务和按范围试玩。", + "engineering": "AGC 工程要求:当前 cwd 是用户选择的项目目录。先读取适用的 AGENTS.md、README 或项目说明,识别实际引擎与工程结构。用户明确指定编辑器或引擎,而当前目录缺少对应工程结构时,先说明不匹配并澄清;用户确认继续当前工程或提供匹配目录后再执行。Cocos Creator 项目优先通过 `agc_cocos_execute` 或 `cocos.editor.execute` 操作已打开的编辑器。新 Web 游戏使用 npm + Vite;二维游戏使用 Phaser 4.2.1,以 `import Phaser from 'phaser'` 导入;三维游戏自行选择合适的三维技术栈。依赖统一使用 npm 包。Phaser 迁移:读取已有 game/index.html,将状态、输入、敌人/守卫、波次、胜负、重开和画布绘制迁移到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后启动 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布由单一机制居中:使用 Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 直接父容器使用尺寸明确的普通 block;使用 CSS 居中时,Phaser autoCenter 设为 NO_CENTER。外围布局可使用 flex/grid。预览偏移先检查并修正项目自身的 CSS 与 Phaser 配置。布局修改后按项目 scripts 构建 dist,在桌面、移动视口和 resize 后确认 canvas 相对父容器的中心误差不超过 1 CSS px、无溢出。简单修改聚焦用户要求及不可替代的最小验证;安装依赖、构建和试玩按此范围执行。源码和命令优先使用 cwd 相对路径,依赖安装与构建使用项目 npm scripts;原生文件读取、搜索、命令和图片查看按当前工具目录使用。源码局部补丁调用 `agc_apply_patch`,支持官方 Add/Delete/Update/Move 语法并固定当前项目目录;多步骤进度调用 `agc_update_plan`,计划状态不代替验收证据。完整文本写入可使用 `agc_write_file`,content 仅填写目标文件的完整原始 UTF-8 正文。复制 Skill 自带文件(脚本、配置、参考文档)进项目时改用 `agc_install_skill_resource`,一次一个文件、`destinationPath` 为目标相对路径;不要用 `agc_write_file` 重抄大文件正文,也不要依赖原生 `cp`。可用能力包括原生文件、搜索、命令、图片查看、Skill、`agc_tools` 和用户已启用的第三方 MCP;用户指定工具时先查当前可用工具并调用,缺失时如实说明。资源工具按当前 schema 使用;Skill references 按需读取。完整新游戏或按策划案实现时执行 agc-game-production-workflow,依次完成“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”。需要视觉素材时执行 taonier-art-assets:检查已登记资源,缺少或不适用时调用生图/编辑工具,读取结果的相对路径和登记身份,将真实素材接入源码并验证显示后再交付。你负责推进任务和按范围试玩。", "unityPlugin": "Unity 编辑器能力由 agc_unity_execute(Runtime 工具名 unity.editor.execute)提供。当前工程是 Unity 时使用该工具执行 C#,先读取实际场景与对象再修改。仅提交 code;缺少工具时报告该能力不可用,不要自行安装或改写插件。结果待人工核对、超时或断线时,禁止自动重发、重启插件或切换项目以绕过阻断。只有真实 completed 回执才可报告成功。", "godotPlugin": "Godot 编辑器能力由 agc_godot_execute(Runtime 工具名 godot.editor.execute)提供。当前工程是 Godot 时使用该工具执行支持 return/await 的 GDScript 函数体,先读取真实场景再修改;不改写为 Phaser。不要自行安装插件、写入库文件或描述文件。仅提交 code,不提供项目、进程、端口、令牌或库路径;缺少工具时报告该能力不可用。编译或确定运行失败可修正代码;结果待人工核对、超时或断线时禁止自动重发、重启插件或切换项目绕过阻断。只有真实 completed 回执才可报告成功。", "editorGuide": "常用编辑器操作:Unity 先读 agc-unity-editor,Godot 先读 agc-godot-editor。可用原生 Skill 读取,或调用 agc_read_skill_resource,skillName 为对应名称、relativePath 为 SKILL.md,再按入口读取操作参考。指南提供场景、对象/节点、资源、UI、保存和撤销示例;只读说明不代表编辑器工具已可用,实际执行仍检查当前工具。", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 142e16b6d..0b6b975d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.42", + "version": "2026-08-26.70", "skills": [ { "name": "agc-unity-editor", @@ -166,6 +166,45 @@ "references/projection-contract.md" ], "sha256": "77c69762910891e0eef5557480a5e46e743f5dec78ee958679e8d6f1e7990531" + }, + { + "name": "platform-abstract", + "purpose": "把环境相关的调用从 core 里收口,让同一份逻辑能跑在多个 target 上", + "triggers": [ + "抽离平台相关能力", + "同一份逻辑要跑在多个 target", + "新增非 Web 构建目标" + ], + "requiredTools": [], + "files": [ + "SKILL.md" + ], + "sha256": "26e5b0b1d2fac6321eff0c834e15bce83ce70a435965c510254e40f3c16370e2" + }, + { + "name": "vite-export-xhs-minitool", + "purpose": "把 vite 项目导出并打包为符合小红书小工具规范的 zip 制品", + "triggers": [ + "导出小红书小工具制品", + "校验或打包 xhs minitool 产物", + "适配小红书小工具运行环境" + ], + "requiredTools": [], + "files": [ + "SKILL.md", + "references/cross-platform-h5.md", + "references/css-compatibility.md", + "references/device-capabilities.md", + "references/js-api.md", + "references/js-compatibility.md", + "references/manual-checks.md", + "references/performance-budget.md", + "references/zip-artifact-spec.md", + "scripts/pack.mjs", + "scripts/validate.mjs", + "scripts/vite.config.xhs-minitool.mjs" + ], + "sha256": "fd869242fdd40bb46fc3fbf30d1509c79d306e769e87dad3430b2ab0e5449b12" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/platform-abstract/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/platform-abstract/SKILL.md new file mode 100644 index 000000000..25268ac5b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/platform-abstract/SKILL.md @@ -0,0 +1,84 @@ +--- +name: platform-abstract +description: 把环境相关的调用从 core 里收口,让同一份逻辑能跑在多个 target 上。 +--- + +# 抽出平台抽象 + +core 不直接调环境 API;每个 target 给一份**同名导出**的实现,构建时选一份。 + +## 例子 + +```ts +// platform/web.ts +export async function save(score: number) { + const res = await fetch('/api/score', { + method: 'POST', + body: JSON.stringify({ score }), + }); + if (!res.ok) throw new Error('save failed'); +} + +// platform/platform-b.ts —— 同名同参,实现不同 +export async function save(score: number) { + await host.request({ url: '/api/score', method: 'POST', data: { score } }); +} + +// caller —— 不认识 fetch / host +import { save } from '#platform'; +export async function saveScore(score: number) { + await save(score); +} +``` + +「接口」就是两边的同名导出,靠 TS 结构化类型在**各自的 target build** 里校验。两份实现漂移只会在各自构建时暴露——口头约定即可,不需要单点类型。`#platform` 怎么指到具体实现见「选实现」。 + +## 结构 + +``` +src/ +├── .../ +│ └── use-case.ts # 只 import '#platform' +└── platform/ # 每个 target 一份同名导出 + ├── web.ts + └── platform-b.ts +``` + +## vite.config + +`vite.config.ts` 就是**默认(web)**的那份,保持不动;新 target 单独一份,用 `mergeConfig` 在默认之上叠差异。不要再加 `vite.config.base.ts`。 + +```ts +// vite.config.ts —— 默认 = web +import path from 'node:path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + resolve: { + alias: { '#platform': path.resolve(__dirname, 'src/platform/web.ts') }, + }, +}); +``` + +```ts +// vite.config.platform-b.ts —— 只写差异 +import path from 'node:path'; +import { defineConfig, mergeConfig } from 'vite'; +import a from './vite.config'; + +export default mergeConfig( + a, + defineConfig({ + build: { outDir: 'dist-platform-b' }, + resolve: { + alias: { '#platform': path.resolve(__dirname, 'src/platform/platform-b.ts') }, + }, + }), +); +``` + +## 选实现 + +只有 alias 一种:caller 只 import `'#platform'`,每份 config 指向自己的实现。 + +alias 必须用**对象**写法——`mergeConfig` 才会按 key 覆盖;写成数组会拼接,两份实现同时留着。`#platform` 是打包期 alias,TS 侧还要在 tsconfig 里配一条同名 `paths`,否则类型检查找不到模块。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt new file mode 100644 index 000000000..359267b98 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt @@ -0,0 +1,59 @@ +【取舍记录】minitool-zip-builder MUST-only 清理 + +原则 +- references/ 只保留 MUST(必须 / 须 / 禁止 / 不得),非 MUST 的建议、可选、工具用法全部删除。 +- 脚本能确定性判定的内容,不再在 skill 里重复,避免两处漂移。 + +已删除的文件 +- scripts/audit_artifact.mjs + - 原因:体积审计已折进 scripts/validate.mjs。 + - 影响:目录或 zip 的体积门禁改由 validate 检查;pack 打包后会自动复跑。 + +逐文件删除与去处 +- SKILL.md + - 步骤 9:删掉 tsc / Vite 已保证项(语法错误、未定义函数、加载顺序),改为指向 references/MANUAL_CHECKS.md。 + - 步骤 10:删掉 Node / Python / 人工分支,改为运行 validate.mjs 与 pack.mjs。 + - 步骤 3 与 Reference 表:删掉已不存在的「目录结构 / 路径规则 / 打包自检」描述。 + - 被外部精简重写为 vite-export-xhs-minitool,只剩 Reference 表,原步骤 9 / 10 已不存在;Reference 表指向「index.html 要求」。 +- zip-artifact-spec.md + - 删 §1 目录结构与打包方式:pack.mjs 保证 zip 根 index.html、不多套层。 + - 删 §4 路径与引用规则:base:'./' 与 pack.mjs 保证相对路径。 + - 删 §6 打包前自检:validate / pack / MANUAL_CHECKS 已覆盖。 + - 删 §5 表格里「资源全为相对路径」「脚本外置」两行(前者 Vite 保证,后者 §3 CSP 已声明)。 + - 删 §5 的「index.html 模板」整段 HTML 骨架与其中的 reset / 字体 CSS;标题改为「index.html 要求」,只留要求表与 说明。 + - 原因:模板是 pack 之后的 dist 形状,对 Vite 源 index.html 是错的(源需要 <script type="module"> 入口);reset / 字体 CSS 不是容器要求;doctype / charset / lang 由 validate 的 *_MISSING 兜。 + - 删 §5 表格里 viewport(width=device-width / initial-scale=1.0 / viewport-fit=cover)、不用 `<base href>`、不自建 CSP `<meta>` 三行。 + - 原因:validate 的 VIEWPORT_MISSING / VIEWPORT_WIDTH_MISSING / VIEWPORT_SCALE_MISSING / VIEWPORT_FIT_MISSING / BASE_URL / CSP_META 已确定性判定,不在 skill 里重复。 + - 保留 §2 文件类型、§3 容器 CSP、§5 的「不引用外部资源」与 `<title>` 说明。 +- device-capabilities.md + - 删 §6 能力扫描清单:validate 的禁用 API code 已全覆盖。 + - 保留 §1–§5(可用能力、禁用能力与替代、常见交互写法)。 +- js-compatibility.md + - 删 §1 的 ES2017 语法枚举与「避免 lookbehind」:语法转译由 Vite target 保证;lookbehind 已由 validate 报 REGEX_LOOKBEHIND。 + - 删 §2 的 Vite target 片段:已在 vite.config.xhs-minitool.mjs。 + - 删 §4 的日期解析 / Intl 布局余量建议,保留「不依赖对象遍历顺序」。 + - 删 §5 交付检查。 + - 保留 §3 Web API 能力检测、§4 跨内核 MUST。 +- css-compatibility.md + - 删 §5 的两条 Autoprefixer 行(可选工具用法)。 + - 删 §7 交付检查;§6 改为只保留「必须跟随可视高度时」的处理要求。 + - 保留 §1–§4、§6(基线层、Chrome 61 不可唯一实现表、行为检测写法、安全区)。 +- cross-platform-h5.md + - 删「优先 Pointer Events」的优先措辞;删「系统字体栈」建议;删 §5 表格的「建议」列。 + - 保留安全区组合、不写死宽度、图片自适应、§6 自检。 +- performance-budget.md + - 删 §1 的 Node / Python 选择命令与 audit 脚本调用;§1 只留 10 MiB 与单条 Base64 1 MiB 两条硬门禁,指向 validate。 + - 删 §1 的 2 MiB / 100 KiB / 5 MiB 建议阈值(仍在 validate 里作为 WARN,见下)。 + - 删 §6 交付检查;删「优先按顺序缩减」「优先 WebP」「优先短片段」「优先对象 URL」「优先 WebGL 1」「避免无意义绘制」「代码可按设备数据调整」等非 MUST。 + - 保留 §2 分页 / 虚拟化 / 防抖 / 分批、§3 preload+poster+释放 objectURL+不得长 Base64、§4 逐项关闭 / 禁止每帧回读 / visibilitychange / contextlost、§5 不按机型 + 必须兜底。 +- js-api.md + - 未删,全文保留(API 契约本身即硬约束)。文件中少量「建议」是 API 用法提示,未按 MUST-only 过滤。 + +仅保留在代码、未写回文档的非 MUST(如需调整改这里或改脚本) +- validate 的 WARN 阈值:zip > 2 MiB、单条 Base64 > 100 KiB、单个文本 > 2 MiB、文本合计 > 5 MiB。 +- validate 的检查码与禁用 API 模式表。 +- MANUAL_CHECKS.md 的 7 条脚本测不到项。 + +口径说明(避免后续误判为缺失) +- zip-artifact-spec.md 删除 §1 / §4 / §6 后,保留 §2 / §3 / §5,编号故意留空,未重排,避免打断跨文件引用。 +- device-capabilities.md 现止于 §5;performance-budget.md 现止于 §5;js-compatibility.md 现止于 §4;css-compatibility.md 现止于 §6。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md new file mode 100644 index 000000000..769fff1fc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md @@ -0,0 +1,52 @@ +--- +name: vite-export-xhs-minitool +description: >- + vite 项目导出适配小红书小工具的制品 +metadata: + version: "1.7.0" +--- + +# 目标: + +* 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip + 1. 复制 (并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. + 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: + `references/manual-checks.md` + 3. 使用 `scripts/pack.mjs` 打包成zip; `--zip-out` 默认 **`../.export/xhs-minitool.zip`** (假设 cwd 是 `game/` 子工程, + 产物落在 **项目根** `.export/xhs-minitool.zip`; 相对 cwd 解析) + 4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本 + +* 不干扰正常的web构建 + +# 假设和默认项: + +* vite项目目录: game/ +* 各个工具 (包括示例vite配置) 默认cwd 就是 vite 项目根(所以对于非标准的目录结构需要给出显式的参数来适应) + +# 脚本参数: + +| 脚本 | 参数 | +|----------------------------------------|----------------------------------------------------------------------------------------------------------------| +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(默认 `dist-xhs-minitool`;与打包时的 `--vite-built-dir` 必须是同一个目录) | +| `scripts/validate.mjs` | `[project]`(默认 `dist-xhs-minitool`)、`--json` | +| `scripts/pack.mjs` | `--vite-built-dir <dir>`(默认 `dist-xhs-minitool`)、`--zip-out <path>`(默认 `../.export/xhs-minitool.zip`) | + +# 一些情况: + +* 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 +* 如果使用了外部能力, 参考platform-abstract skill对项目先重构 +* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 + +以下是参考文档;「何时读」命中时必须读: + +## Reference + +| 文档 | 何时读 | +|-------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| [js-api.md](references/js-api.md) | 本地 API 快照; | +| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写/改 `index.html`、选择文件类型或处理脚本外置 / 容器 CSP 时 | +| [device-capabilities.md](references/device-capabilities.md) | 处理端能力时:哪些 Web 能力可用 / 不可用及替代写法、如何实现常见交互(手势、拍照、选图等) | +| [js-compatibility.md](references/js-compatibility.md) | 写 JS / 选择构建产物时:Chrome 61 硬基线、不可用 API 与替代写法 | +| [css-compatibility.md](references/css-compatibility.md) | 写 CSS / 选择构建产物时:Chrome 61 硬基线、不支持的能力与替代写法 | +| [cross-platform-h5.md](references/cross-platform-h5.md) | 适配多端时:触摸、滚动、安全区、PC 模拟器与真机差异 | +| [performance-budget.md](references/performance-budget.md) | 包体、静态数据、Base64、媒体、长列表与 WebGL 资源控制和降级 | diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md new file mode 100644 index 000000000..e77a10f56 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md @@ -0,0 +1,68 @@ +# 跨端 H5 适配 + +> 小工具同一份 H5 同时跑在 PC 模拟器与真机 WebView。以下是保证两端一致体验的适配要点。 + +CSS 的最低语法与布局能力以 [css-compatibility.md](./css-compatibility.md) 为准;本文件只说明触摸、滚动、安全区和设备形态差异。 + +--- + +## 1. 触摸 + +```css +body { -webkit-touch-callout: none; } +.touchable:active { opacity: 0.7; } +html { touch-action: manipulation; } +``` + +交互用 Pointer Events(`pointerdown/move/up`)统一处理鼠标与触摸;纯触摸场景用 `touchstart/touchmove/touchend`。 + +--- + +## 2. 滚动 + +```css +.scroll-container { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; +} +``` + +纵向回弹由容器控制,HTML 无需额外配置。 + +--- + +## 3. 安全区 + +```css +.custom-nav { padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); } +.bottom-bar { padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); } +``` + +需配合 `<meta name="viewport" ... viewport-fit=cover>`。PC 模拟器不产生真实 `env()`,而是注入 `--safe-area-inset-*` 变量模拟安全区;真机 `env()` 为真实值。用 `var(--safe-area-inset-*, env(...))` 组合,两端都生效。 + +--- + +## 4. 布局与媒体 + +- 页面级容器用 `%` / `flex` / `vw`,勿写死 `width: 375px` +- 图片 `max-width: 100%` + +--- + +## 5. PC 模拟器 vs 真机 + +| 特性 | PC 模拟器 | 真机 | +|--------|-------------------------------------|----------------| +| 触摸 | 鼠标 → touch 模拟 | 原生 touch | +| 安全区 | 注入 `--safe-area-inset-*` 变量模拟 | `env()` 真实值 | +| 软键盘 | 无 | 遮挡输入框 | + +--- + +## 6. 自检 + +- [ ] 交互用 pointer / touch events,未依赖鼠标 hover 才能触发的关键操作 +- [ ] 布局自适应,无写死像素宽度 +- [ ] 安全区用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合,配合 `viewport-fit=cover` +- [ ] 图片自适应且体积受控 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md new file mode 100644 index 000000000..8b1e192ab --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md @@ -0,0 +1,57 @@ +# CSS 兼容性规范 + +> 目标内核固定为 **Android 8.1 出场 Chrome / WebView 61**。晚于 61 的 CSS 能力一律不得出现在最终产物里:浏览器会静默忽略,`validate.mjs` 判为 `CSS_UNSUPPORTED_FEATURE`(ERROR)。 + +## 1. 硬性要求 + +- 最终 zip 内的 CSS 必须能被 Chrome 61 完整解析;不得保留任何晚于 61 的选择器、声明或 at-rule。 +- 浏览器静默丢弃无法解析的 CSS,不像 JS 会抛异常;兼容性以最终产物为准,源码用了构建工具不代表产物已兼容。 +- 不用 UA、Android 版本或机型字符串决定样式能力。 + +## 2. Chrome 61 不支持的能力与替代写法 + +| 能力 | 必须改用的写法 | +|-------------------------------------------------------------|---------------------------------------------------------------------------------------| +| Flex `gap` / `row-gap` / `column-gap` | Flex 用子项单边 `margin`;Grid 用 `grid-gap` | +| `aspect-ratio` | 固定媒体尺寸,或用百分比 `padding-top` 比例盒;内容绝对定位 | +| `min()` / `max()` / `clamp()` | 固定值、百分比或 `calc()`;需要分档时用媒体查询 | +| `inset`、`margin-inline`、`padding-block` 等逻辑属性 / 简写 | `top/right/bottom/left` 与 `margin-left/right`、`padding-top/bottom` 等物理属性 | +| `overflow: clip` | `overflow: hidden` | +| `:focus-visible` | `:focus` | +| `:has()` | 由 JS 在父元素上切换状态 class | +| `@container` | viewport 媒体查询,或由 JS 按容器尺寸切换 class | +| `subgrid` | 普通 Grid、Flex 或显式轨道尺寸 | +| `@layer` / `@property` | 只允许构建期展开;最终产物不得保留 | +| `dvh` / `svh` / `lvh` | `%` / `100vh`;受软键盘影响的全屏高度用 JS 维护 CSS 变量并保留 `100vh` 兜底 | +| `color-mix()`、`oklab()`、`oklch()` 等现代颜色 | `#hex`、`rgb()`、`rgba()` 或 `hsl()` | +| `backdrop-filter` | 先给不依赖模糊的实色 / 半透明背景 | +| `text-wrap: balance` 等现代排版属性 | 保留普通换行;不要让其决定关键区域高度 | + +## 3. 布局与前缀 + +Chrome 61 可使用 Flexbox、基础 Grid、媒体查询、CSS Variables、`calc()`、transform、transition 和 animation。仍须处理以下跨端差异: + +- Flex 子项内有长文本、图片或滚动区域时,按方向显式设置 `min-width: 0` 或 `min-height: 0`,避免内容撑破容器。 +- Flex 间距用子项单边 `margin`;Grid 间距用 `grid-gap`。两者都不要用裸 `gap`。 +- 需要隐藏文本时使用 `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`。多行截断使用 `display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: <行数>; overflow: hidden`,并保证截断失效时页面仍可用。 +- `user-select`、`appearance`、文字截断和毛玻璃等 WebKit 相关能力按需同时写 `-webkit-` 前缀与标准声明。不要机械给所有属性加前缀。 +- 关键操作不能只在 `:hover` 出现;触摸端默认可见,鼠标悬停效果可放 `@media (hover: hover)`。 + +## 4. 视口与安全区 + +- 页面宽度使用 `%`、Flex 或基础 Grid,不写死 `375px` 等单一机型宽度。 +- `100vh` 在移动端地址栏、容器高度变化和软键盘出现时可能不等于可视高度。必须跟随可视高度时,由 JS 监听尺寸变化并维护 `--app-height`,同时保留 `100vh` 回退。 +- 安全区规则须配合 `viewport-fit=cover`,用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合;具体见 [cross-platform-h5.md](./cross-platform-h5.md)。 +- 现代桌面浏览器或 PC 模拟器通过不等于 Chrome 61 通过。能够运行旧内核时,至少检查首屏、滚动区、弹层、表单、横竖屏 / 尺寸变化和核心交互;无法运行时在交付说明中标记“Chrome 61 CSS 兼容性未实测”。 + +## 5. 构建链目标 + +项目已有构建链时,浏览器目标至少设置为: + +```text +Chrome >= 61 +ios_saf >= 18.4 +``` + +- 压缩器也须使用相同浏览器目标,避免把兼容写法重新合并成 Chrome 61 无法解析的现代语法。 +- 交付前检查构建后的 CSS;zip 中只保留最终静态产物,不带 source map 和构建配置。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md new file mode 100644 index 000000000..8b1882f31 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md @@ -0,0 +1,109 @@ +# 小工具能力清单 + +> 小工具运行在受限容器中:**纯本地、不联网**,把它当作一个能力受限的浏览器页面。 +> **以本文为基线**:命中「不可用」项必须移除或改用替代写法。 +> +> **实现优先级**:先获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc)并查找匹配的容器能力;文档明确支持时必须优先使用,仅在没有匹配能力或当前环境不满足文档条件时才采用兼容的 Web 方案。 + +## 目录 + +- §1 可用能力 +- §2 不可用能力(Web API) +- §3 不可用行为 +- §4 WebGL / 图形计算边界 +- §5 常见交互怎么实现 + +--- + +## 1. 可用能力 + +### 页面与渲染 + +标准 HTML / CSS / JS 可用,但最终产物须满足目标内核基线:JS 见 [js-compatibility.md](./js-compatibility.md),CSS 见 [css-compatibility.md](./css-compatibility.md)。可使用基线内的 Flexbox / Grid / 动画 / 媒体查询、Canvas 2D(`getContext('2d')`)、WebGL(`getContext('webgl'/'webgl2')`,能力边界见 §4、性能与低端机降级见 [performance-budget.md](./performance-budget.md) §4–5),文本选择不限制。 + +### 媒体与文件 + +| 能力 | 用法 | 约束 | +|-----------------|--------------------------------------------------------|----------------------------------------------------------------| +| 摄像头 | `navigator.mediaDevices.getUserMedia({ video: true })` | 用户手势触发 + 系统弹窗授权 | +| 麦克风 | `navigator.mediaDevices.getUserMedia({ audio: true })` | 用户手势触发 + 系统弹窗授权 | +| 选择图片 / 拍照 | `<input type="file">` | 系统选择器接管,**仅能选图片和视频**(无论 `accept` 如何设置) | +| 音视频播放 | `<video>` / `<audio>` | 内联播放,媒体文件须打包在内 | + +### 数据存储 + +数据存储方案以[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc)的当前规则为准。数据按小工具隔离,不保证永久持久化。 + +### 交互 + +`alert()` / `confirm()` 可用,以原生 UI 展示。 + +--- + +## 2. 不可用能力(Web API) + +以下 API 已禁用,调用会抛异常、返回空值或被拦截,必须移除或改用替代写法。 + +| 分类 | 涉及 API | 替代方案 | +|----------|--------------------------------------------------------------------------------------------------------------------------|------------------------------------------------| +| 定位 | `navigator.geolocation.getCurrentPosition` / `watchPosition` | 移除 | +| 剪贴板 | `navigator.clipboard.readText` / `writeText`、`document.execCommand('copy'/'cut'/'paste')` | 展示可选中文本,引导用户长按 / 选中手动复制 | +| 硬件连接 | `navigator.bluetooth` / `navigator.usb` / `navigator.hid` / `navigator.serial` | 移除 | +| 传感器 | `new Accelerometer()` / `new Gyroscope()` / `new Magnetometer()`、环境光、`DeviceMotionEvent` / `DeviceOrientationEvent` | 改用触摸 / 指针手势(见 §5),摇一摇类移除 | +| 实时通信 | `new WebSocket()`、`new EventSource()`、`new RTCPeerConnection()` | 移除(不联网,无轮询替代) | +| 后台运行 | Web Worker、SharedWorker、Service Worker(`navigator.serviceWorker.register`) | 移除,逻辑放主线程 | +| 屏幕 | `getDisplayMedia`(屏幕共享)、`Element.requestFullscreen`(全屏由容器统一管理) | 全屏用 CSS 沉浸式布局实现视觉全屏 | +| 设备信息 | `navigator.getBattery`、`navigator.connection`、`navigator.mediaDevices.enumerateDevices` | 移除 | +| 存储进阶 | `navigator.storage.persist`(持久化)、跨域存储访问 | 移除;获取在线文档并按当前能力替代关系选择方案 | +| 凭据 | `navigator.credentials.get` / `create`(WebAuthn)、`navigator.locks` | 移除 | +| 窗口 | `window.open`(弹新窗口)、`window.prompt` | 单页内 JS 切换视图 DOM;输入用页内 Modal | + +移动端 WebView 本身也不支持:支付 `PaymentRequest`、系统通知 / 推送、NFC、MIDI、XR / AR / VR、后台同步 / 下载、PWA 安装、窗口管理、指针 / 键盘锁定。一律移除。 + +--- + +## 3. 不可用行为 + +| 行为 | 说明 | 替代方案 | +|-------------------|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| 网络请求 | `fetch` / `XMLHttpRequest`、加载外部图片 / 字体 / 媒体等一切联网请求 | 所有资源打包在内,改本地相对引用;仅小型配置 / 数据可随包提供,大型只读数据集不适合小工具,见 [performance-budget.md](./performance-budget.md) §2 | +| 动态执行代码 | `eval()`、`new Function()` | 改写为静态逻辑 | +| WebAssembly | WASM 编译执行(依赖 WASM 的库无法运行) | 移除或改用纯 JS 实现 | +| iframe / object | 内嵌 iframe / object,或被外部页面嵌入 | 内容直接写进页面 | +| 表单跳转提交 | `<form>` 提交跳转 | `addEventListener('submit', e => e.preventDefault())` 后用 JS 处理 | +| 文件下载 | `a[download]`、blob 下载 | 移除 | +| 打开外链 / 新窗口 | `target="_blank"`、`window.open`、跳转站外 URL | 单页内 JS 切换视图 DOM | +| 跳转其他小工具 | 小工具间互相跳转 | 移除 | +| 长按菜单 | 系统长按菜单已禁用 | 用自定义交互替代 | +| 插件 | Flash 等浏览器插件 | 移除 | + +--- + +## 4. WebGL / 图形计算边界 + +纯 WebGL 渲染可用,组合能力受限: + +| 场景 | 是否可用 | +|---------------------------------------------------------|---------------------------| +| 包内资源 / Canvas / 内存对象作为纹理 | ✅ | +| 外部域名图片作为纹理 | 🔴 不联网,纹理须打包在内 | +| 依赖 WASM 的加速库(Draco / Basis / ONNX / 抠图算法等) | 🔴 | +| 依赖 Worker 的离屏渲染(OffscreenCanvas + Worker) | 🔴 | +| SharedArrayBuffer 多线程 | 🔴 | + +WebGL 适合用包内资源做本地渲染;AI 图像处理等重计算(需联网或 WASM 模型)无法支持。WebGL 可用不等于低端真机性能足够:DPR、像素、纹理、draw call、几何预算、动态降档与兜底必须遵守 [performance-budget.md](./performance-budget.md) §4–5。 + +--- + +## 5. 常见交互怎么实现 + +| 需求 | 实现 | +|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| 容器提供的功能 | 获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc),优先使用其中匹配的能力;仅在无匹配能力或不满足文档条件时采用兼容的 Web 方案 | +| 手势 / 拖拽 / 滑动 | `addEventListener('touchstart'/'touchmove'/'touchend')` 或 Pointer Events(`pointerdown`/`move`/`up`) | +| 拍照 / 录音 | `getUserMedia(...)`,由按钮点击等用户手势触发 + 授权 | +| 选择图片 / 视频 | `<input type="file">` | +| 复制文本 | 展示可选中文本,引导用户长按 / 选中复制 | +| 视觉全屏 | CSS 布局(`100vh` / flex + 隐藏滚动) | +| 页面跳转 | 单页内用 JS 切换视图 DOM | +| 输入弹窗 | 页内 Modal 组件 | diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-api.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-api.md new file mode 100644 index 000000000..61cda4744 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-api.md @@ -0,0 +1,310 @@ +# 小工具 JS API 本地参考 + +> 本文是 2026-09-23 的本地快照,按 `docs/index.html` 的 JS API 章节复刻,保留参数表、约束与示例。实现时应优先获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc);仅当远程文档无法获取时,才以本文作为 API 契约参考。 + +容器自动注入 `window.xhs`,无需在包内引入 SDK。端能力从 `window.xhs.miniTool` 调用。 + +## 调用约定 + +- 传入 `success`、`fail`、`complete` 任一回调时,API 返回 `undefined`;均不传时返回 Promise。 +- 成功结果包含 `errMsg: "<api>:ok"` 与对应业务字段;失败结果包含 `errMsg: "<api>:fail ..."` 与可选 `errCode`。 +- 调用前检查 `window.xhs`、`window.xhs.miniTool` 和具体方法是否存在,并为低版本或未注入环境提供降级处理。 +- 只调用本文列出的 API,不直接调用原生 bridge。 + +```js +const miniTool = window.xhs && window.xhs.miniTool; + +if (miniTool && typeof miniTool.saveImageToPhotosAlbum === "function") { + try { + await miniTool.saveImageToPhotosAlbum({ filePath }); + } catch (error) { + console.log(error.errMsg, error.errCode); + } +} +``` + +## API 一览 + +| API | 用途 | 最低客户端版本 | +|---------------------------------------------------------------------------------------------------------------------------|--------------------------------|----------------| +| `postNote` | 打开笔记发布页并携带内容和媒体 | — | +| `saveImageToPhotosAlbum` | 保存图片至系统相册 | — | +| `writeTempFile` | 将 base64 写入临时文件 | — | +| `getLaunchOptions` / `window.xhs.launchOptions` | 获取启动参数和环境信息 | — | +| `setStorage` / `getStorage` / `getStorageInfo` / `removeStorage` / `clearStorage` | 小工具本地缓存 | 9.46 | +| `saveFile` / `writeFile` / `appendFile` / `readFile` / `readDir` / `statFile` / `unlink` / `mkdir` / `getFileStorageInfo` | 本地文件系统 | 9.49 | +| `interactionOpenApi` | 唤起评论区并携带评论草稿 | 9.49 | + +图片、视频与封面等媒体字段只接受 `data:` base64 或本地文件路径;容器不联网,网络地址不可用。体积较大的 base64 建议先用 `writeTempFile` 换成 `filePath` 再传递。 + +## 启动参数与版本判断 + +同步读取启动参数: + +```js +const launchOptions = window.xhs && window.xhs.launchOptions; +const userDataPath = launchOptions && launchOptions.miniToolEnv && launchOptions.miniToolEnv.userDataPath; +``` + +同步值不可用时,检查 `getLaunchOptions` 存在后异步读取: + +```js +const miniTool = window.xhs && window.xhs.miniTool; +const launchOptions = await miniTool.getLaunchOptions(); +const { userDataPath } = launchOptions.miniToolEnv; +``` + +`miniToolEnv.userDataPath` 是持久文件目录根路径。仅在它之后拼接相对路径;不要硬编码、解析或改写端上返回的文件句柄。 + +`miniToolEnv.buildVersion` 的末三位为编译序号,判断客户端版本时忽略。例如 `9462004` 代表客户端 `9.46.2`,用于版本比较的值为 `9462`: + +```js +function getClientVersion(buildVersion) { + return Math.floor((Number(buildVersion) || 0) / 1000); +} + +function isClientVersionAtLeast(buildVersion, minimumClientVersion) { + return getClientVersion(buildVersion) >= minimumClientVersion; +} +``` + +完整的同步优先、异步回退读取方式: + +```js +function readBuildVersion(launchOptions) { + const miniToolEnv = launchOptions && launchOptions.miniToolEnv; + return Number(miniToolEnv && miniToolEnv.buildVersion) || 0; +} + +async function getBuildVersion() { + const xhs = window.xhs; + const syncBuildVersion = readBuildVersion(xhs && xhs.launchOptions); + if (syncBuildVersion) return syncBuildVersion; + + const miniTool = xhs && xhs.miniTool; + if (!miniTool || typeof miniTool.getLaunchOptions !== "function") return 0; + + try { + return readBuildVersion(await miniTool.getLaunchOptions()); + } catch (error) { + return 0; + } +} +``` + +## postNote + +打开笔记发布页。`mediaInfo` 必传,`image_resources`、`video_resources`、`live_photo_sources` 至少提供一种。 + +| 字段 | 类型 | 说明 | +|--------------------------------|-----------------------------|------------------------------------------------------------| +| `title` | string | 标题,最长 20 字 | +| `content` | string | 正文,最长 1000 字 | +| `pageType` | string | `video_publish`、`photo_publish` 或 `slides_edit`(9.43+) | +| `mediaInfo.image_resources` | `{ url }[]` | 图片,1–18 张;`url` 为 data URI 或本地路径 | +| `mediaInfo.video_resources` | `{ video_url, cover_url? }` | 单个视频及可选封面 | +| `mediaInfo.live_photo_sources` | `{ url, video_url }[]` | 实况照片,1–18 组(9.43+) | + +```js +await window.xhs.miniTool.postNote({ + title: "我的作品", + content: "用小工具生成的", + pageType: "photo_publish", + mediaInfo: { + image_resources: [{ url: "data:image/png;base64,..." }], + }, +}); +``` + +```js +// 视频笔记 +await window.xhs.miniTool.postNote({ + pageType: "video_publish", + mediaInfo: { + video_resources: { video_url: videoPath, cover_url: coverPath }, + }, +}); + +// 实况笔记(客户端 9.43+) +await window.xhs.miniTool.postNote({ + pageType: "slides_edit", + mediaInfo: { + live_photo_sources: [{ url: coverPath, video_url: videoPath }], + }, +}); +``` + +成功回调只表示发布页已被唤起并由用户点击发布,不代表笔记最终审核通过;不要据此做强一致业务状态。 + +## saveImageToPhotosAlbum + +| 字段 | 类型 | 必填 | 说明 | +|------------|--------|------|--------------------------------------------------------------------------------------| +| `filePath` | string | 是 | 本地图片:`data:` base64 或 `writeTempFile` 返回的路径;不支持 `http(s)://` 网络地址 | + +```js +const dataUrl = canvas.toDataURL("image/png"); +await window.xhs.miniTool.saveImageToPhotosAlbum({ filePath: dataUrl }); +``` + +应由用户点击等主动操作触发;首次调用可能请求相册权限。大图建议先通过 `writeTempFile` 落成文件,再保存至相册。 + +## writeTempFile + +将 Canvas 或选图结果等 base64 数据写为临时文件: + +```js +const { filePath } = await window.xhs.miniTool.writeTempFile({ + data: canvas.toDataURL("image/png"), +}); + +await window.xhs.miniTool.saveImageToPhotosAlbum({ filePath }); +await window.xhs.miniTool.postNote({ + mediaInfo: { image_resources: [{ url: filePath }] }, +}); +``` + +| 字段 | 类型 | 说明 | +|------------|--------|---------------------------------------------------| +| `data` | string | 必填,base64 数据;支持带 `data:` 前缀的 data URI | +| `filePath` | string | 成功返回的临时文件路径 | + +临时文件应即用即弃,不可作为长期持久化路径。支持常见图片与视频类型:png、jpeg、webp、gif、mp4。 + +## Storage 本地缓存 + +Storage API 在客户端 9.46+ 可用。`data` 只支持 JSON 字符串;对象或数组需要先序列化,读取后再解析。 + +| API | 参数 | 结果 / 说明 | +|------------------|----------------------------------------------------|--------------------------------------------------| +| `setStorage` | `key: string`、`data: string`、`encrypt?: boolean` | 写入或覆盖缓存 | +| `getStorage` | `key: string`、`encrypt?: boolean` | 返回 `{ data }`;`encrypt` 与写入时一致 | +| `getStorageInfo` | 无业务参数 | 返回 `{ keys, currentSize, limitSize }`,单位 KB | +| `removeStorage` | `key: string` | 删除指定缓存 | +| `clearStorage` | 无业务参数 | 清空当前小工具缓存 | + +单个 key 最大 1MB,当前小工具总缓存最大 10MB;`encrypt` 默认 `false`。 + +以下封装会在 9.46+ 使用 Storage,并在低版本回退到浏览器存储;调用方必须处理其返回的 `false`,不能假设数据已成功持久化: + +```js +const STORAGE_MIN_CLIENT_VERSION = 9460; + +async function setLocalData(key, data) { + let serializedData; + try { + serializedData = JSON.stringify(data); + } catch (error) { + return false; + } + if (typeof serializedData !== "string") return false; + + const buildVersion = await getBuildVersion(); + const miniTool = window.xhs && window.xhs.miniTool; + + if ( + isClientVersionAtLeast(buildVersion, STORAGE_MIN_CLIENT_VERSION) && + miniTool && + typeof miniTool.setStorage === "function" + ) { + try { + await miniTool.setStorage({ key, data: serializedData }); + return true; + } catch (error) { + return false; + } + } + + try { + localStorage.setItem(key, serializedData); + return true; + } catch (error) { + return false; + } +} +``` + +```js +await window.xhs.miniTool.setStorage({ + key: "profile", + data: JSON.stringify({ nickname: "小红薯" }), +}); + +const { data } = await window.xhs.miniTool.getStorage({ key: "profile" }); +const profile = data === null ? null : JSON.parse(data); +``` + +低版本可以按需降级到浏览器存储,但必须处理读写失败,并容忍数据丢失或被清理。 + +## 文件系统 + +文件系统 API 在客户端 9.49+ 可用。文件和二进制数据使用文件系统保存,目录根路径来自 `miniToolEnv.userDataPath`。 + +| API | 参数 | 结果 / 说明 | +| --- | --- | --- | +| `saveFile` | `tempFilePath: string`、`filePath?: string \| null` | 将临时文件移动至持久目录,返回 `{ savedFilePath }` | +| `writeFile` | `filePath`、`data`、`encoding: "utf8" \| "base64"` | 覆盖写入,返回 `{ writtenBytes }` | +| `appendFile` | `filePath`、`data`、`encoding: "utf8" \| "base64"` | 追加写入,返回 `{ writtenBytes }` | +| `readFile` | `filePath`、`encoding`、`position?`、`length?` | 返回 `{ data, bytesRead, eof }` | +| `readDir` | `dirPath` | 返回 `{ files, truncated }` | +| `statFile` | `filePath` | 返回 `{ size, lastModified, isDir }` | +| `unlink` | `filePath` | 删除持久目录中的文件 | +| `mkdir` | `dirPath`、`recursive?` | 创建持久目录 | +| `getFileStorageInfo` | 无业务参数 | 返回 `{ usedBytes, limitBytes, fileCount, tmpUsedBytes, writeChunkMaxBytes, readChunkMaxBytes }` | + +```js +const options = await window.xhs.miniTool.getLaunchOptions(); +const filePath = options.miniToolEnv.userDataPath + "/drafts/note.json"; + +await window.xhs.miniTool.writeFile({ + filePath, + data: JSON.stringify({ title: "草稿" }), + encoding: "utf8", +}); + +const { data } = await window.xhs.miniTool.readFile({ + filePath, + encoding: "utf8", +}); +``` + +- `writeFile` 为覆盖写,`appendFile` 为追加写。写大文件时,第一片使用 `writeFile`,后续片串行使用 `appendFile`。 +- 分片大小以 `getFileStorageInfo` 返回的 `writeChunkMaxBytes` 和 `readChunkMaxBytes` 为准,不要硬编码。 +- 渲染图片或视频时直接使用文件句柄作为 `img.src`、`video.src` 或 CSS 资源;需要字节时才使用 `readFile`。 +- `usr` 是本地工作区,不是备份空间。卸载、清数据或包清理后可能丢失,重要数据应可重建或由用户导出。 + +## interactionOpenApi 发布评论 + +评论区能力在客户端 9.49+ 可用,应由用户点击等主动操作触发。调用后容器会统一关闭小工具,再拉起评论区。 + +| 字段 | 类型 | 说明 | +|--------------------------------|---------|------------------------------------------------------| +| `payload` | object | 必填,评论草稿;字段由评论侧定义 | +| `payload.action` | string | 发布评论时使用 `post_comment` | +| `payload.content` | string | 可选,评论文本 | +| `payload.media_bean` | array | 可选,有序图片列表;媒体路径使用本地文件句柄 | +| `payload.miniToolSnapshotInfo` | string | 可选,小工具附加状态 JSON 字符串;最大 2KB,超限无效 | +| `saveToAlbum` | boolean | 可选,是否将图片同步保存到相册;默认 `true` | + +```js +const result = await window.xhs.miniTool.interactionOpenApi({ + payload: { + action: "post_comment", + content: "快来和我 PK!", + media_bean: [{ + media_type: "image", + cover_image_url: imageFilePath, + }], + miniToolSnapshotInfo: JSON.stringify({ page: "result" }), + }, + saveToAlbum: true, +}); + +// { routed, savedToAlbum, albumFailReason? } +``` + +- 评论文本和图片均可不传,仍可唤起评论区。 +- 目前仅支持图片:`{ media_type: "image", cover_image_url }`;不支持视频或实况图。 +- 媒体路径必须是容器可访问的本地文件句柄;不支持网络 URL、`data:` URI 或绝对路径。 +- 用户从评论区重新打开小工具时,可读取有效的 `miniToolSnapshotInfo` 以恢复业务状态;恢复逻辑由开发者实现。 +- `routed` 表示评论侧路由是否成功。相册保存失败时,原因通过可选字段 `albumFailReason` 返回。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md new file mode 100644 index 000000000..e1173f30d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md @@ -0,0 +1,42 @@ +# JavaScript 兼容性规范 + +> 目标内核固定为 **Android 8.1 出场 Chrome / WebView 61**。最终代码必须能在 Chrome 61 解析和运行;晚于 Chrome 61 的语法与运行时 API 一律不得出现在最终产物里,`validate.mjs` 判为 ERROR。Chrome 61 完整支持 ES2017,最终代码以 ES2017 为构建目标。 + +## 1. 语法基线 + +最终 zip 中的 JS 须兼容 Chrome 61: + +- ES2018+ 语法须由构建链转译,例如对象 spread、异步迭代、可选链、空值合并、逻辑赋值、class 私有字段、static block、BigInt 字面量和 top-level await;晚于 ES2017 的运行时 API 须改用基线写法。 + +语法不兼容会在脚本解析阶段直接失败,无法通过运行时 `if` 兜底。 + +## 2. 有构建链与无构建链 + +### 直接交付静态三件套 + +没有现成构建链时,不为兼容性临时引入 Babel、core-js 或新的 npm 依赖;直接按 ES2017 编写。 + +### 项目已有构建链 + +构建目标须为 ES2017 / Chrome 61,由 `vite.config.xhs-minitool.mjs` 固化。 + +最终产物须: + +- 转译到 ES2017 / Chrome 61; +- 只把构建后的静态文件放进 zip,不带 `node_modules`、source map 或构建配置。 + +转译只解决语法,不会自动补齐所有运行时 API。不要因为构建成功就假定新 API 可用。 + +## 3. 运行时 API + +- ES2017 内置 API 和基础 DOM API 可直接使用。 +- `String.prototype.replaceAll`、`Array.prototype.at`、`Object.hasOwn`、`structuredClone` 等更新 API 晚于 Chrome 61,最终产物不得直接使用(`validate.mjs` 的 `MODERN_RUNTIME_API` 会拦下);用 `split()/join()`、下标、`Object.prototype.hasOwnProperty.call`、手写深拷贝等基线写法替代。 +- 其余非基础 Web API 使用前做能力检测;不可用时降级为简单替代实现或给出清晰提示。 +- 只补功能实际需要的小型本地 fallback,不引入整套通用 polyfill;所有代码仍须随包离线交付。 +- 能力检测基于对象 / 方法是否存在,不按 UA、机型或系统版本字符串分支。 +- 不为被容器明确禁止的能力添加 polyfill;能力边界仍以 [device-capabilities.md](./device-capabilities.md) 为准。 + +## 4. 跨内核行为 + +- 不依赖对象遍历顺序表达业务优先级;需要顺序时使用数组。 +- 触摸、滚动和安全区规则见 [cross-platform-h5.md](./cross-platform-h5.md)。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md new file mode 100644 index 000000000..c3e3c9eed --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md @@ -0,0 +1,14 @@ +## 端能力 +- 相机 / 麦克风由用户手势触发,并处理系统弹窗授权。 + +## JS 兼容 +- 除 `replaceAll` / `.at` / `Object.hasOwn` / `structuredClone` 这类晚于 Chrome 61 的 API(须用基线写法替代)外,使用非基础 Web API 前先做能力检测;不可用时降级为简单替代实现或给出清晰提示;不按 UA、机型或系统版本字符串分支。 + +## CSS 兼容 +- 最终产物只使用 Chrome 61 可解析的 CSS;`validate.mjs` 已拦下 gap、`min()/max()/clamp()`、逻辑属性、`:focus-visible`、`:has()`、`@container`、`dvh` 等,额外人工确认构建后没有残留。 +- Flex 间距用子项 `margin`,Grid 用 `grid-gap`;不使用 flex `gap`。 +- Flex 子项内有长文本、图片或滚动区域时,按方向显式设置 `min-width: 0` 或 `min-height: 0`。 +- 关键操作不能只在 `:hover` 出现,触摸端默认可见;安全区用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合,配合 `viewport-fit=cover`。 + +## 性能 +- WebGL 有动态降档、页面隐藏暂停、context lost 处理和非 WebGL 兜底。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md new file mode 100644 index 000000000..f514fb557 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md @@ -0,0 +1,98 @@ +# 小工具性能预算与降级规范 + +> 本文提供移动端 WebView 的设计预算与降级要求。Skill 只能检查静态产物和代码设计,不能从源码推断真实帧率、内存或真机表现;没有运行数据时必须明确标记“未实测”。 + +## 目录 + +- §1 交付门禁 +- §2 静态数据与长列表 +- §3 图片、音频与视频 +- §4 WebGL 资源与降级基线 +- §5 运行时降级与兜底 + +--- + +## 1. 交付门禁 + +| 项目 | 门禁 | 原因 | +| --- | --- | --- | +| 最终 zip | **不超过 10 MiB** | 10 MiB 是上传上限,不是性能目标 | +| 单条 Base64 | 解码后不超过 1 MiB;超过 1 MiB 必须改成独立包内文件 | Base64 体积约增加 1/3,还会产生字符串与解码副本 | + +单条 Base64 由 [validate.mjs](../scripts/validate.mjs) 在产物目录检查;最终 zip 包体由 [pack.mjs](../scripts/pack.mjs) 在打包后检查。 + +## 2. 静态数据与长列表 + +`.js` / `.json` 是静态资源,不是数据库。**不要把数万条记录、完整业务库、日志或抓取结果生成成 JS 数组。** JSON 与 JS 分文件只能改善组织,不能消除下载、解析和内存成本。 + +按以下顺序缩减: + +1. 只保留完成核心功能必需的字段和记录,删除重复字段、长描述和历史快照。 +2. 能预计算的统计、搜索索引和分类结果在构建期生成;不要在首屏对全量数据反复遍历。 +3. 大型只读数据集改为摘要、分段样例或让用户通过 `<input type="file">` 按需导入。若完整离线数据不可删且仍超门禁,应明确说明该需求不适合小工具,而不是继续打包。 +4. 用户产生或导入的数据需要持久化时,根据任务开始时从在线文档整理的端能力、最低版本和替代关系选择方案;没有适用于目标客户端的端能力时使用浏览器本地存储。运行期存储不得用于掩盖巨大的内置种子数据。 + +渲染列表时: + +- 首屏只创建可见项;长列表使用分页或虚拟滚动,不一次性拼接整份 `innerHTML`。 +- 搜索输入按交互频率和数据规模采用防抖,避免每次按键都触发完整查询;最终输入应及时执行。数据量较大时可预先建立小型索引,避免反复扫描所有字段。 +- 非首屏工作分批执行并主动让出主线程,避免把解析、计算和渲染集中在同一个同步流程中。 +- 不在循环中反复读写布局属性;批量生成 DOM 后一次挂载。 + +--- + +## 3. 图片、音频与视频 + +- 小型图片、图标等可以使用 Base64;超过 1 MiB 不允许内嵌。 +- 图片按真机展示尺寸缩放并压缩;不要为了 300 px 展示区域打包 4K 原图。 +- 音视频先裁剪时长,再降低分辨率、帧率和码率。5 分钟视频通常不适合随小工具离线交付。 +- 视频设置 `preload="metadata"` 或 `preload="none"`,提供 `poster`;未进入播放页前不要创建或解码媒体。 +- 同时只保留必要的媒体实例;离开页面后暂停播放、清空不再使用的 `src` 并释放对象 URL。 + +`FileReader.readAsDataURL()` 可用于用户刚选择的小文件预览或在线文档明确要求 data URI 的短暂转换;大文件不要把结果持久写回静态源码,并在不用时 `URL.revokeObjectURL()`。注意:即使体积很小,`<video>` / `<audio>` 的 `data:` 媒体源仍不受容器 CSP 支持,应引用包内媒体文件。 + +--- + +## 4. WebGL 资源与降级基线 + +WebGL 是可用能力,不代表所有设备都能稳定运行复杂场景。以下数值是生成代码时采用的保守设计预算,不是 Skill 对实际性能的测量结果。目标体验按 **30 FPS 可交互**设计,60 FPS 仅作为有运行数据支持时的高档增强。 + +普通 Canvas 2D 不纳入本节的 GPU 分档要求;按实际展示尺寸创建画布。只有使用 WebGL 上下文时才执行以下资源预算、降档和 context 兜底。 + +### 初始预算 + +| 指标 | 默认档 | 低档 / 降级档 | +| --- | --- | --- | +| WebGL drawing buffer DPR | `min(devicePixelRatio, 1.5)` | `1` | +| WebGL drawing buffer 像素数 | 不超过约 200 万 | 不超过约 100 万 | +| 单张纹理边长 | 不超过 2048 | 不超过 1024 | +| 估算纹理显存 | 不超过 64 MiB | 不超过 32 MiB | +| 每帧 draw call | 不超过 100 | 不超过 50 | +| 每帧三角形 | 不超过 100k | 不超过 50k | +| 帧率目标 | 稳定 30 FPS,设备有余量再升档 | 稳定 24–30 FPS | + +这些是移动 WebView 的保守初始预算;没有实测数据时保持预算与降级路径,不得声称已经达到某个帧率或通过真机性能验收。 + +### 渲染规则 + +- 兼容 WebGL 1;使用 WebGL 2 特性时必须检测能力并提供 WebGL 1 或非 WebGL 兜底。 +- 初始化先使用低 / 中档,不按高 DPR 直接创建最大缓冲区。尺寸变化时重新计算并继续受像素预算约束。 +- 纹理使用实际需要的尺寸;复用纹理、材质、几何体和 framebuffer。估算 RGBA8 纹理最低占用:`宽 × 高 × 4`,mipmap 还会额外增加约 1/3。 +- 合并可合并的几何与 draw call,视锥 / 距离裁剪不可见对象;粒子、阴影、后处理、透明叠加和实时反射必须能逐项关闭。 +- shader 在初始化或切换场景时编译;纹理上传和模型解析分批进行,不在动画帧中首次集中完成。 +- 不在动画循环内创建对象 / 数组 / 大字符串;禁止每帧 `readPixels()`、`toDataURL()`、大面积 `getImageData()` 或同步回读 GPU。 +- 页面不可见时通过 `visibilitychange` 停止 `requestAnimationFrame`、媒体和定时器;恢复后重置时间差,不补算大量帧。 +- 处理 `webglcontextlost` / `webglcontextrestored`;context 丢失时停止渲染并展示轻量状态,不要无限重建。 + +--- + +## 5. 运行时降级与兜底 + +不要依赖机型名单。以实际帧耗时和能力检测决定档位: + +1. 首次进入采用低 / 中档,逐步加载非必要效果。 +2. 运行时观测到持续掉帧或交互响应变差时,逐级降低 DPR、粒子数、阴影、后处理、可视距离和动画频率。没有运行数据时只确认降级路径存在,不判断是否达到触发条件。 +3. 降到最低档仍不可交互时,停止高成本循环并切换 Canvas 2D、静态图或简化 DOM 视图。 +4. `getContext()` 失败、shader 编译 / 链接失败或 context 反复丢失时,必须进入可理解的兜底界面;不能白屏、死循环重试或持续弹错。 + +如果核心功能并不依赖 3D,默认选择 DOM / CSS / Canvas 2D。WebGL 应解决明确的视觉或计算需求,而不是作为普通表单、列表和信息展示的默认技术栈。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md new file mode 100644 index 000000000..5113cd724 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md @@ -0,0 +1,59 @@ +# 小工具 ZIP 静态包构建规范 + +> 小工具是基于离线 H5 的 app 形式,**纯本地、不联网**,所有资源须打包在 zip 内。窗口样式、导航栏、下拉刷新等外壳行为由**容器**统一控制,无需在包内声明。 + +## 目录 + +- §2 支持的文件类型 +- §3 资源加载规则(容器 CSP) +- §5 index.html 要求 + +--- + +## 2. 支持的文件类型 + +zip 内仅允许以下类型: + +| 类型 | 用途 | +|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------| +| `.html` | 入口,有且只有一个 `index.html` | +| `.css` | 样式文件 | +| `.js` | 脚本文件 | +| `.png` / `.jpg` / `.jpeg` / `.gif` / `.webp` / `.svg` | 图片资源 | +| `.woff` / `.woff2` | 字体文件 | +| `.json` | 小型静态数据 / 配置;不得作为大型内置数据库,体积门禁见 [performance-budget.md](./performance-budget.md) | + +--- + +## 3. 资源加载规则(容器 CSP) + +容器对页面**如何加载各类资源**有强制约束。除包内文件外,按类型另允许 `data:` / `blob:` 等内存来源。 + +| 资源类型 | 允许 | 禁止 | +|------------------------------|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| 脚本 `<script>` | 引用包内脚本 `<script src="./app.js">`(同源外链) | 内联 `<script>...</script>`;行内事件 `onclick="..."`;`javascript:` URI;`eval()` / `new Function()`;WebAssembly;外部域名 / `data:` / `blob:` 脚本 | +| 样式 `<style>` / `<link>` | 内联 `<style>`、行内 `style="..."`、包内样式表 | 外部域名样式表 | +| 图片 `<img>` / CSS 背景图 | 包内图片 `<img src="./a.png">`;`data:` URI(base64 内嵌);`blob:`(`createObjectURL` 内存对象,如选图预览) | 外部域名图片 | +| 字体 `@font-face` | 包内字体文件 | 外部域名字体 | +| 音视频 `<video>` / `<audio>` | 包内媒体文件 | 外部域名媒体、`data:` / `blob:` 媒体 | +| iframe / object | — | 全部禁止 | + +关键点: + +- **脚本必须外置**:容器 CSP 的 `script-src` 不含 `unsafe-inline`,内联 `<script>...</script>`、行内事件 `onclick="..."`、`javascript:` URI 均不可用。JS 写进包内 `.js` 用 `<script src>` 引入,事件用 `addEventListener` 绑定。 +- **脚本必须是经典脚本**:只用 `<script src="./app.js">`,**不要 `type="module"`**,JS 里也不要 `import` / `export`。zip 离线加载、无目录服务,module 的相对 `import` 解析不可靠,典型症状是「页面渲染出来但 JS 完全不执行」。要拆多个 JS 文件时按依赖顺序写多个 `<script src>`,靠 `window` 命名空间协作,并避免 top-level `await`。 +- **脚本须兼容目标 WebView**:直接交付的 JS 可使用 ES2017;已有构建链可使用更新语法,但最终须转译为面向 Chrome 61 的 ES2017 产物,见 [js-compatibility.md](./js-compatibility.md)。 +- **样式可内联**:`<style>` 与 `style="..."` 都能用,无需外置。 +- **样式须兼容目标 WebView**:只使用 Chrome 61 可解析的 CSS;`gap`、`min()/max()/clamp()`、逻辑属性等晚于 61 的能力由 `validate.mjs` 判为 ERROR,见 [css-compatibility.md](./css-compatibility.md)。 +- **选图预览**:`<img src>` 配 `data:`(`FileReader.readAsDataURL`)或 `blob:`(`URL.createObjectURL`)均可显示;大图优先 `blob:` 并及时 `URL.revokeObjectURL()`。静态资源不得转成长 Base64 塞进源码,见 [performance-budget.md](./performance-budget.md)。 +- 外部 CDN 一律加载不到,所有资源全部打包进小工具。 + +--- + +## 5. index.html 要求 + +| 规则 | 原因 | +|-------------------------------------------------|----------------------------------| +| 不引用任何外部资源(图片 / CSS / JS / 字体) | 外部资源加载不到,须全部打进 zip | + +- `<title>` 仅影响文档标题;导航栏标题由容器 UI 配置。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs new file mode 100644 index 000000000..2804d7bb4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test, { after } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { auditZipFile, packMinitool } from './pack.mjs'; +import { findUnsupportedFiles } from './validate.mjs'; + +const PACK = fileURLToPath(new URL('./pack.mjs', import.meta.url)); +const BASE = mkdtempSync(join(tmpdir(), 'xhs-pack-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const VALID_HTML = '<!doctype html><html lang="zh-CN"><head>' + + '<meta charset="UTF-8">' + + '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">' + + '</head><body><script src="./app.js"></script></body></html>'; + +/** 造一个最小的 vite 构建产物目录;extra 用来放白名单外的文件。 */ +function writeBuiltDir(name, extra = {}) { + const root = join(BASE, name); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8'); + writeFileSync(join(root, 'app.js'), "console.log('ok');\n", 'utf8'); + for (const [relativePath, content] of Object.entries(extra)) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } + return root; +} + +test('zip budget is classified', () => { + assert.deepEqual(auditZipFile('tool.zip', 1024), []); + assert.equal(auditZipFile('tool.zip', 3 * 1024 * 1024)[0].code, 'ZIP_OVER_RECOMMENDED'); + assert.equal(auditZipFile('tool.zip', 11 * 1024 * 1024)[0].code, 'ZIP_OVER_HARD_LIMIT'); +}); + +test('findUnsupportedFiles 只报白名单外的扩展名', () => { + const root = writeBuiltDir('precheck', { + 'notes.md': 'readme\n', + 'src/config.ts': 'export const x = 1;\n', + }); + const found = findUnsupportedFiles(root).map((item) => item.path).sort(); + assert.deepEqual(found, ['notes.md', 'src/config.ts']); +}); + +test('存在不支持文件时打包整体失败,且不删除任何文件', () => { + const root = writeBuiltDir('no-delete', { + 'notes.md': 'readme\n', + 'style.scss': 'body { color: red; }\n', + }); + assert.throws( + () => packMinitool({ + cwd: BASE, + viteBuiltDir: 'no-delete', + zipOut: 'no-delete-out/xhs-minitool.zip', + }), + /没有删除任何文件/, + ); + assert.ok(existsSync(join(root, 'notes.md')), 'notes.md 被删了'); + assert.ok(existsSync(join(root, 'style.scss')), 'style.scss 被删了'); + assert.equal(existsSync(join(BASE, 'no-delete-out')), false, '失败时不应写出 zip'); +}); + +test('makeZip 的校验也发生在落盘前,失败时不留 zip', () => { + // 缺 index.html:文件扩展名都合法,预检会放行,失败点在 makeZip。 + const noIndex = writeBuiltDir('no-index'); + rmSync(join(noIndex, 'index.html')); + const noIndexOut = join(BASE, 'no-index-out', 'xhs-minitool.zip'); + assert.throws( + () => packMinitool({ cwd: BASE, viteBuiltDir: 'no-index', zipOut: noIndexOut }), + /缺少 index\.html/, + ); + assert.equal(existsSync(noIndexOut), false, '缺 index.html 时不应写出 zip'); + + // 含禁止条目:vite.config.js 扩展名在白名单里,但命中 FORBIDDEN_ENTRY。 + writeBuiltDir('forbidden-entry', { 'vite.config.js': 'export default {};\n' }); + const forbiddenOut = join(BASE, 'forbidden-entry-out', 'xhs-minitool.zip'); + assert.throws( + () => packMinitool({ + cwd: BASE, + viteBuiltDir: 'forbidden-entry', + zipOut: forbiddenOut, + }), + /禁止文件/, + ); + assert.equal(existsSync(forbiddenOut), false, '含禁止条目时不应写出 zip'); +}); + +test('干净产物目录仍能正常打包', () => { + writeBuiltDir('clean'); + const zipOut = join(BASE, 'clean-out', 'xhs-minitool.zip'); + const code = packMinitool({ + cwd: BASE, + viteBuiltDir: 'clean', + zipOut, + }); + assert.equal(code, 0); + assert.ok(existsSync(zipOut), 'zip 未生成'); +}); + +test('--zip-out 默认 ../.export/xhs-minitool.zip(假设 cwd 是 game/)', () => { + const game = join(BASE, 'default-layout', 'game'); + writeBuiltDir(join('default-layout', 'game', 'dist-xhs-minitool')); + assert.equal(packMinitool({ cwd: game }), 0); + assert.ok( + existsSync(join(BASE, 'default-layout', '.export', 'xhs-minitool.zip')), + '默认落点不在项目根的 .export/', + ); +}); + +test('pack 只打 zip,不再改写产物目录', () => { + const root = writeBuiltDir('no-rewrite'); + // 故意留一份「构建后还没收尾」的 index.html:type=module / crossorigin / 根路径脚本。 + const unfinalized = '<!doctype html><html lang="zh-CN"><head>' + + '<meta charset="UTF-8">' + + '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">' + + '<script type="module" crossorigin src="/app.js"></script>' + + '</head><body></body></html>'; + writeFileSync(join(root, 'index.html'), unfinalized, 'utf8'); + + const zipOut = join(BASE, 'no-rewrite-out', 'xhs-minitool.zip'); + assert.equal(packMinitool({ cwd: BASE, viteBuiltDir: 'no-rewrite', zipOut }), 0); + assert.equal( + readFileSync(join(root, 'index.html'), 'utf8'), + unfinalized, + 'pack 改写了 index.html;收尾应属于 vite.config.xhs-minitool.mjs 的插件', + ); +}); + +test('--zip-out 落在 cwd 的 .export 下时给出 workdir 警告', () => { + writeBuiltDir('workdir'); + const warnings = []; + const original = console.warn; + console.warn = (line) => warnings.push(String(line)); + try { + // 可疑:相对 cwd 解析成 <cwd>/.export/...;cwd 若是 game/ 子工程就落错地方。 + packMinitool({ cwd: BASE, viteBuiltDir: 'workdir', zipOut: '.export/xhs-minitool.zip' }); + // 别的相对落点(不落在 cwd 的 .export 下)不提醒。 + packMinitool({ + cwd: BASE, + viteBuiltDir: 'workdir', + zipOut: join('other', 'xhs-minitool.zip'), + }); + } finally { + console.warn = original; + } + assert.equal( + warnings.filter((line) => line.includes('--zip-out')).length, + 1, + warnings.join('\n'), + ); +}); + +test('CLI 拒绝不认识的参数,不写出 zip', () => { + const dir = writeBuiltDir('cli'); + const zipOut = join(BASE, 'cli-out.zip'); + const result = spawnSync( + process.execPath, + [PACK, '--vite-built-dir', dir, '--out-dir', dir, '--zip-out', zipOut], + { cwd: BASE, encoding: 'utf8' }, + ); + assert.equal(result.status, 2); + assert.match(result.stderr, /不认识的参数/); + assert.equal(existsSync(zipOut), false, '参数被拒时不应写出 zip'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs new file mode 100644 index 000000000..6ef18d7b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs @@ -0,0 +1,424 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test, { after } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { splitUrl, unquote, validate } from './validate.mjs'; + +const VALIDATOR = fileURLToPath(new URL('./validate.mjs', import.meta.url)); +const BASE = mkdtempSync(join(tmpdir(), 'xhs-validator-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const VALID_HTML = '<!doctype html><html lang="zh-CN"><head>' + + '<meta charset="UTF-8">' + + '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">' + + '<link rel="stylesheet" href="./styles.css">' + + '</head><body>' + + '<img src="./icon.svg" alt="">' + + '<script src="./app.js"></script>' + + '</body></html>'; +const VALID_FILES = { + 'styles.css': ".hero { background: url('./icon.svg'); }\n", + 'app.js': "console.log('ok');\n", + 'icon.svg': '<svg xmlns="http://www.w3.org/2000/svg"></svg>\n', +}; + +function writeProject(root, html, extra = {}) { + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'index.html'), html, 'utf8'); + for (const [name, content] of Object.entries(extra)) { + const path = join(root, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } +} + +function codes(root) { + return new Set(validate(root).findings.map((item) => item.code)); +} + +function runCli(root, ...args) { + return spawnSync(process.execPath, [VALIDATOR, root, ...args], { + encoding: 'utf8', + }); +} + +function expectCodes(name, html, extra, expected) { + const root = join(BASE, name); + writeProject(root, html, extra); + const actual = codes(root); + const missing = [...expected].filter((code) => !actual.has(code)); + assert.deepEqual(missing, [], `${name}: missing ${missing}; actual=${[...actual].sort()}`); +} + +test('valid project has no findings', () => { + const root = join(BASE, 'valid'); + writeProject(root, VALID_HTML, VALID_FILES); + assert.deepEqual(validate(root).findings, []); +}); + +test('inline and unclosed scripts are rejected', () => { + expectCodes( + 'inline_script', + "<!doctype html><script>console.log('inline')</script>", + {}, + ['INLINE_SCRIPT'], + ); + expectCodes( + 'unclosed_inline_script', + "<!doctype html><script>console.log('inline')", + {}, + ['INLINE_SCRIPT', 'UNCLOSED_SCRIPT_TAG'], + ); +}); + +test('resource paths must stay packaged and relative', () => { + expectCodes( + 'resource_escape', + '<!doctype html><script src="../outside.js"></script>', + {}, + ['RESOURCE_OUTSIDE_ROOT'], + ); + expectCodes( + 'absolute_resource', + '<!doctype html><script src="/app.js"></script>', + {}, + ['ABSOLUTE_RESOURCE_PATH'], + ); + expectCodes( + 'missing_css_url', + '<!doctype html><link rel="stylesheet" href="./styles.css">', + { 'styles.css': ".hero { background: url('./missing.png'); }\n" }, + ['MISSING_LOCAL_RESOURCE'], + ); +}); + +test('navigation and embedded content are rejected', () => { + expectCodes( + 'mailto_navigation', + '<!doctype html><a href="mailto:test@example.com">mail</a>', + {}, + ['LINK_NAVIGATION'], + ); + expectCodes( + 'meta_refresh', + '<!doctype html><meta http-equiv="refresh" content="0;url=next.html">', + {}, + ['META_REFRESH'], + ); + expectCodes( + 'base_url', + '<!doctype html><base href="./assets/">', + {}, + ['BASE_URL'], + ); + expectCodes( + 'form_navigation', + '<!doctype html><form action="javascript:void(0)"></form>', + {}, + ['FORM_NAVIGATION'], + ); +}); + +test('unsupported mobile APIs are flagged', () => { + expectCodes( + 'unsupported_mobile_apis', + '<!doctype html><script src="./app.js"></script>', + { + 'app.js': 'new PaymentRequest([], {});\n' + + 'Notification.requestPermission();\n' + + 'new NDEFReader();\n' + + 'navigator.requestMIDIAccess();\n' + + "navigator.xr.requestSession('inline');\n" + + 'document.body.requestPointerLock();\n' + + 'new SyncManager();\n', + }, + [ + 'PAYMENT_REQUEST', 'NOTIFICATION_API', 'NFC_API', + 'MIDI_API', 'XR_API', 'POINTER_KEYBOARD_LOCK', 'BACKGROUND_SYNC', + ], + ); +}); + +test('network, storage, filesystem and download APIs are flagged', () => { + expectCodes( + 'network_storage_filesystem_download', + '<!doctype html><script src="./app.js"></script>', + { + 'app.js': "navigator.sendBeacon('/log', 'x');\n" + + "new WebTransport('https://example.com');\n" + + 'navigator.storage.persist();\n' + + 'showSaveFilePicker();\n' + + "anchor.download = 'file.txt';\n", + }, + [ + 'NETWORK_BEACON', 'NETWORK_WEBTRANSPORT', 'PERSISTENT_STORAGE', + 'FILE_SYSTEM_ACCESS', 'PROGRAMMATIC_DOWNLOAD', + ], + ); +}); + +test('PWA manifest, plugins and area navigation are rejected', () => { + expectCodes( + 'pwa_plugin_and_area_navigation', + '<!doctype html><link rel="manifest" href="./manifest.json">' + + '<applet></applet><map><area href="./next.html"></map>', + { 'manifest.json': '{}\n' }, + ['PWA_MANIFEST', 'EMBEDDED_CONTENT', 'LINK_NAVIGATION'], + ); +}); + +test('data/blob, javascript: and dynamic paths are classified', () => { + expectCodes( + 'data_blob', + '<!doctype html><img src="data:image/png;base64,AA">' + + '<source srcset="data:image/png;base64,AA">' + + '<script src="blob:abc"></script>', + {}, + ['IMAGE_DATA_BLOB_VERSION', 'DISALLOWED_DATA_BLOB'], + ); + expectCodes( + 'javascript_uri', + '<!doctype html><a href="javascript:void(0)">x</a><img src="javascript:alert(1)">', + {}, + ['JAVASCRIPT_URI'], + ); + expectCodes( + 'dynamic_path', + '<!doctype html><script src="./{{name}}.js"></script>', + {}, + ['DYNAMIC_RESOURCE_PATH'], + ); +}); + +test('inline events, file accept limits and form review produce warnings', () => { + const root = join(BASE, 'warnings'); + writeProject( + root, + '<!doctype html><body onload="x()">' + + '<input type="file" accept="image/*,.txt">' + + '<form action="#"></form>' + + '</body>', + ); + const actual = codes(root); + for (const code of ['INLINE_EVENT', 'FILE_ACCEPT_LIMIT', 'FORM_REVIEW']) { + assert.ok(actual.has(code), `missing ${code}; actual=${[...actual].sort()}`); + } +}); + +test('multiple or missing entry HTML files are rejected', () => { + const many = join(BASE, 'multi_html'); + writeProject(many, '<!doctype html>', { 'other.html': '<!doctype html>' }); + assert.ok(codes(many).has('HTML_ENTRY_COUNT')); + + const none = join(BASE, 'no_html'); + mkdirSync(none, { recursive: true }); + writeFileSync(join(none, 'app.js'), 'console.log(1);\n'); + assert.ok(codes(none).has('HTML_ENTRY_COUNT')); +}); + +test('entry must be root index.html', () => { + const renamed = join(BASE, 'renamed_entry'); + mkdirSync(renamed, { recursive: true }); + writeFileSync(join(renamed, 'main.html'), '<!doctype html>', 'utf8'); + assert.ok(codes(renamed).has('ENTRY_NOT_INDEX_HTML')); + + const nested = join(BASE, 'nested_entry'); + mkdirSync(join(nested, 'app'), { recursive: true }); + writeFileSync(join(nested, 'app', 'index.html'), '<!doctype html>', 'utf8'); + assert.ok(codes(nested).has('ENTRY_NOT_INDEX_HTML')); +}); + +test('html template and classic-script requirements are enforced', () => { + expectCodes( + 'html_template', + '<!doctype html><html><head>' + + '<meta http-equiv="Content-Security-Policy" content="default-src \'self\'">' + + '</head><body><script type="module" src="./app.js"></script></body></html>', + { 'app.js': 'export const a = 1;\nconst b = obj?.c ?? 2;\n' }, + [ + 'CHARSET_MISSING', 'VIEWPORT_MISSING', 'HTML_LANG_MISSING', 'CSP_META', + 'MODULE_SCRIPT', 'ESM_SYNTAX', 'ES2018_PLUS_SYNTAX', + ], + ); +}); + +test('Chrome 61 不支持的 CSS 与运行时 API 直接报 ERROR', () => { + const root = join(BASE, 'modern_css'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'styles.css': '.a { display: flex; gap: 8px; width: min(100%, 20rem); color: oklch(0.5 0.1 20); }\n', + 'app.js': "list.replaceAll('a', 'b');\nObject.hasOwn({}, 'x');\n", + }); + const findings = validate(root).findings; + const css = findings.find((item) => item.code === 'CSS_UNSUPPORTED_FEATURE'); + assert.ok(css, `missing CSS_UNSUPPORTED_FEATURE; actual=${[...codes(root)].sort()}`); + assert.equal(css.severity, 'ERROR'); + const runtime = findings.find((item) => item.code === 'MODERN_RUNTIME_API'); + assert.ok(runtime, `missing MODERN_RUNTIME_API; actual=${[...codes(root)].sort()}`); + assert.equal(runtime.severity, 'ERROR'); +}); + +test('grid-gap / -webkit- 前缀写法不算 Chrome 61 不支持', () => { + const root = join(BASE, 'legacy_gap'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'styles.css': '.grid { display: grid; grid-gap: 8px; }\n' + + '.cols { -webkit-column-gap: 8px; }\n', + }); + assert.ok(!codes(root).has('CSS_UNSUPPORTED_FEATURE'), `actual=${[...codes(root)].sort()}`); +}); + +test('字符串与注释里的现代语法 / #hex / #选择器不算命中', () => { + const root = join(BASE, 'js_noise'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': '// obj?.c ?? 2\n' + + 'var color = "#e8f4ff";\n' + + "var hit = document.querySelector('#hit');\n" + + '// import.meta 也只出现在注释里\n', + }); + assert.ok(!codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`); +}); + +test('注释与字符串里的 API 名不算命中,但字符串参数规则仍生效', () => { + const noisy = join(BASE, 'api_noise'); + writeProject(noisy, VALID_HTML, { + ...VALID_FILES, + // `fetch(` / `XMLHttpRequest` / `new Function(` / `eval(` 只出现在注释与字符串里:不是调用。 + 'app.js': '// 不能用 fetch( 和 XMLHttpRequest 请求网络\n' + + 'var note = "new Function( 也不允许";\n' + + "var evalNote = 'eval(\\'x\\')';\n", + }); + const noisyCodes = codes(noisy); + assert.ok(!noisyCodes.has('NETWORK_FETCH'), `actual=${[...noisyCodes].sort()}`); + assert.ok(!noisyCodes.has('NETWORK_XHR'), `actual=${[...noisyCodes].sort()}`); + assert.ok(!noisyCodes.has('DYNAMIC_FUNCTION'), `actual=${[...noisyCodes].sort()}`); + assert.ok(!noisyCodes.has('DYNAMIC_EVAL'), `actual=${[...noisyCodes].sort()}`); + + const real = join(BASE, 'api_real'); + writeProject(real, VALID_HTML, { + ...VALID_FILES, + // 这条规则要匹配的就是字符串参数:剥掉字符串就会漏检。 + 'app.js': "document.querySelector('a').setAttribute('download', '');\n", + }); + const realCodes = codes(real); + assert.ok(realCodes.has('PROGRAMMATIC_DOWNLOAD'), `actual=${[...realCodes].sort()}`); +}); + +test('CSS 里的外部 URL 仍然报错(不会被当 // 注释抹掉)', () => { + const root = join(BASE, 'css_external_url'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'styles.css': ".hero { background: url('https://cdn.evil.example/bg.png'); }\n", + }); + assert.ok(codes(root).has('EXTERNAL_URL'), `actual=${[...codes(root)].sort()}`); +}); + +test('JS 字符串里的外部 URL 仍然报错', () => { + const root = join(BASE, 'js_external_url'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': "fetch('https://api.evil.example/data');\n", + }); + const actual = codes(root); + assert.ok(actual.has('NETWORK_FETCH'), `actual=${[...actual].sort()}`); + assert.ok(actual.has('EXTERNAL_URL'), `actual=${[...actual].sort()}`); +}); + +test('真正的 class 私有字段仍然报错', () => { + const root = join(BASE, 'private_field_real'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': 'class Counter { #count = 0; }\n', + }); + assert.ok(codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`); +}); + +test('oversized inline base64 is warned', () => { + const root = join(BASE, 'base64_budget'); + const payload = 'A'.repeat(200 * 1024); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': `var img = "data:image/png;base64,${payload}";\n`, + }); + assert.ok(codes(root).has('BASE64_LARGE'), `actual=${[...codes(root)].sort()}`); +}); + +test('unsupported file types and invalid UTF-8 text are rejected', () => { + const unsupported = join(BASE, 'unsupported_file'); + writeProject(unsupported, '<!doctype html>', { 'app.ts': 'let x = 1;\n' }); + assert.ok(codes(unsupported).has('UNSUPPORTED_FILE')); + + const invalid = join(BASE, 'invalid_utf8'); + mkdirSync(invalid, { recursive: true }); + writeFileSync(join(invalid, 'index.html'), Buffer.from([ + 0x3c, 0x21, 0x64, 0x6f, 0x63, 0x74, 0x79, 0x70, 0x65, 0x20, 0xff, 0xfe, + ])); + assert.ok(codes(invalid).has('TEXT_DECODE')); +}); + +test('symlinks are rejected', (t) => { + const root = join(BASE, 'symlink_case'); + const outside = join(BASE, 'outside.js'); + writeProject(root, '<!doctype html><title>ok'); + writeFileSync(outside, "console.log('outside')\n"); + try { + symlinkSync(outside, join(root, 'linked.js')); + } catch { + t.skip('symlink creation not permitted on this platform'); + return; + } + assert.ok(codes(root).has('SYMLINK')); +}); + +test('CLI exit codes: pass, warning and error', () => { + const validRoot = join(BASE, 'valid_cli'); + writeProject(validRoot, VALID_HTML, VALID_FILES); + const validCli = runCli(validRoot, '--json'); + assert.equal(validCli.status, 0, validCli.stderr); + const payload = JSON.parse(validCli.stdout); + assert.deepEqual(payload.summary, { errors: 0, warnings: 0 }); + + const warningRoot = join(BASE, 'warning_cli'); + writeProject(warningRoot, '' + + '' + + ''); + // 有 WARNING 也照样通过:退出码只由 ERROR 决定,没有 --strict 这种「警告即失败」的模式。 + assert.equal(runCli(warningRoot).status, 0); + + const errorRoot = join(BASE, 'error_cli'); + writeProject(errorRoot, ''); + assert.equal(runCli(errorRoot).status, 1); +}); + +test('CLI rejects missing directories and bad arguments', () => { + assert.equal(runCli(join(BASE, 'does-not-exist')).status, 2); + const bad = spawnSync(process.execPath, [VALIDATOR, '--nope'], { encoding: 'utf8' }); + assert.equal(bad.status, 2); +}); + +test('splitUrl mirrors urlsplit for packaged references', () => { + assert.deepEqual(splitUrl('./a.png'), { scheme: '', netloc: '', path: './a.png' }); + assert.deepEqual(splitUrl('//cdn.example.com/a.png'), { + scheme: '', netloc: 'cdn.example.com', path: '/a.png', + }); + assert.deepEqual(splitUrl('https://example.com/a.png'), { + scheme: 'https', netloc: 'example.com', path: '/a.png', + }); + assert.deepEqual(splitUrl('mailto:a@b.c'), { scheme: 'mailto', netloc: '', path: 'a@b.c' }); + assert.deepEqual(splitUrl('?x=1'), { scheme: '', netloc: '', path: '' }); +}); + +test('unquote tolerates malformed escapes', () => { + assert.equal(unquote('a%20b.png'), 'a b.png'); + assert.equal(unquote('100%25.png'), '100%.png'); + assert.equal(unquote('bad%zz.png'), 'bad%zz.png'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs new file mode 100644 index 000000000..1ebc6451d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * vite.config.xhs-minitool.mjs 的收尾插件端到端测试:真跑一遍 `vite build`, + * 验证 dist 在 build 结束时已经合规(相对路径、无 module 痕迹、脚本在 body 末尾、 + * 空目录被清掉、icon 兜底),而 pack 只负责打 zip。 + * + * 这是本地开发辅助(`.` 前缀),不随 Skill Pack 发布。 + */ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { after } from 'node:test'; + +import { packMinitool } from './pack.mjs'; +import { findUnsupportedFiles, validate } from './validate.mjs'; +import { defineMinitoolConfig, pruneEmptyDirs } from './vite.config.xhs-minitool.mjs'; + +const BASE = mkdtempSync(join(tmpdir(), 'xhs-vite-config-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const SOURCE_HTML = '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' fixture\n' + + ' \n' + + ' \n' + + '
\n' + + ' \n' + + ' \n' + + '\n'; + +/** 造一个最小 vite 项目;返回项目根。 */ +function writeProject(name) { + const root = join(BASE, name); + mkdirSync(join(root, 'src'), { recursive: true }); + mkdirSync(join(root, 'public', 'icons'), { recursive: true }); + mkdirSync(join(root, 'public', 'empty-dir'), { recursive: true }); + writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8'); + writeFileSync( + join(root, 'src', 'main.js'), + "import './style.css';\ndocument.getElementById('app').textContent = 'ok';\n", + 'utf8', + ); + writeFileSync(join(root, 'src', 'style.css'), 'body { color: red; }\n', 'utf8'); + for (const icon of ['icon-192.svg', 'icon-512.svg']) { + writeFileSync( + join(root, 'public', 'icons', icon), + '\n', + 'utf8', + ); + } + return root; +} + +test('vite 构建收尾:dist 在 closeBundle 后即可直接打 zip', async () => { + const root = writeProject('finalized'); + const { build } = await import('vite'); + const config = defineMinitoolConfig({ root, logLevel: 'silent' }); + + await build({ ...config, configFile: false }); + const outDir = join(root, 'dist-xhs-minitool'); + + assert.ok(existsSync(join(outDir, 'index.html')), 'index.html 未生成'); + assert.ok(existsSync(join(outDir, 'app.js')), 'app.js 未生成'); + assert.ok(existsSync(join(outDir, 'app.css')), 'app.css 未生成'); + + const html = readFileSync(join(outDir, 'index.html'), 'utf8'); + const head = html.slice(0, html.search(/<\/head>/i)); + assert.doesNotMatch(html, /type="module"/i, '仍残留 type="module"'); + assert.doesNotMatch(html, /crossorigin/i, '仍残留 crossorigin'); + assert.doesNotMatch(html, /rel="modulepreload"/i, '仍残留 modulepreload'); + assert.doesNotMatch(html, /\b(?:src|href)="\//, '仍有根路径资源引用'); + assert.doesNotMatch(head, /`); + return ''; + }, + ); + + if (scripts.length) { + if (/<\/body>/i.test(html)) { + html = html.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n `); + } else { + html += `\n${scripts.join('\n')}\n`; + } + } + + writeFileSync(htmlPath, html); +} + +/** + * publicDir 被关掉(build.copyPublicDir: false)时,把小红书要求的 icon 兜底复制进 dist。 + * + * @param {string} outDir + * @param {string} publicDir + */ +export function copyFallbackIcons(outDir, publicDir) { + if (!publicDir) return; + for (const icon of FALLBACK_ICONS) { + const dest = join(outDir, 'icons', icon); + const src = join(publicDir, 'icons', icon); + if (!existsSync(dest) && existsSync(src)) { + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + } + } +} + +/** 递归删除空目录;zip 里不留无意义目录条目。 */ +export function pruneEmptyDirs(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + pruneEmptyDirs(path); + if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); + } + } +} + +/** + * dist 收尾插件:在 bundle 写盘后原地整理产物,让 pack 只需打 zip。 + * + * 用 `closeBundle`(最后一个构建钩子)而不是 `writeBundle`:publicDir 已复制完, + * 且排在其它插件之后,不会和还在写文件的插件抢目录。`apply: 'build'` 保证 dev server 不触发。 + * + * 注意:rollup 在构建失败时**也会**调 `closeBundle`。所以用 `buildEnd(error)` 记下失败, + * 失败就直接返回——否则会留下只复制了 icon 的半成品 dist,还会用「index.html 不存在」 + * 覆盖掉真正的语法报错。 + * + * @returns {import('vite').Plugin} + */ +export function xhsMinitoolArtifactPlugin() { + /** @type {string} */ + let outDir = ''; + /** @type {string} */ + let publicDir = ''; + let buildFailed = false; + + return { + name: ARTIFACT_PLUGIN_NAME, + apply: 'build', + enforce: 'post', + configResolved(config) { + // config.build.outDir 是相对 config.root 的路径,必须自己解析成绝对路径。 + outDir = resolve(config.root, config.build.outDir); + publicDir = config.publicDir; + }, + buildEnd(error) { + buildFailed = Boolean(error); + }, + closeBundle() { + if (buildFailed) return; + // 先校验 index.html:指错目录的成功构建在这里就失败,不会先建出一堆 icon。 + patchIndexHtml(outDir); + copyFallbackIcons(outDir, publicDir); + pruneEmptyDirs(outDir); + }, + }; +} + +export const minitoolBaseConfig = { + base: './', + build: { + target: MINITOOL_JS_TARGET, + cssTarget: MINITOOL_CSS_TARGET, + outDir: 'dist-xhs-minitool', + emptyOutDir: true, + sourcemap: false, + assetsInlineLimit: 0, + cssCodeSplit: false, + modulePreload: false, + rollupOptions: { + output: { + format: 'iife', + inlineDynamicImports: true, + entryFileNames: 'app.js', + chunkFileNames: 'chunk-[name].js', + assetFileNames: (assetInfo) => { + const name = assetInfo.name || ''; + if (name.endsWith('.css')) return 'app.css'; + if (name.endsWith('.woff2') || name.endsWith('.woff')) { + return 'fonts/[name][extname]'; + } + if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) { + return 'icons/[name][extname]'; + } + return 'assets/[name][extname]'; + }, + }, + }, + }, + plugins: [xhsMinitoolArtifactPlugin()], +}; + +/** + * 合并项目配置,并保证收尾插件一定在 `plugins` 里。 + * + * `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方 + * 若先展开 `minitoolBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后 + * 追加一个新实例:无论 overrides 怎么给,插件都在最后且只跑一次。 + * + * @param {import('vite').UserConfig} [overrides] + * @returns {import('vite').UserConfig} + */ +export function defineMinitoolConfig(overrides = {}) { + const merged = mergeConfig(defineConfig(minitoolBaseConfig), overrides); + merged.plugins = [ + ...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME), + xhsMinitoolArtifactPlugin(), + ]; + return merged; +} + +export default defineMinitoolConfig(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index cd6a725f4..3a48046b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -7358,7 +7358,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es printf '%s\n' '{"id":2,"result":{}}' IFS= read -r skills_list case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac -printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' +printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"}],"errors":[]}]}}' while IFS= read -r line; do :; done "#, ) @@ -8103,7 +8103,7 @@ while IFS= read -r line; do case "$line" in *'"method":"initialize"'*) printf '{{"id":%s,"result":{{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}}}\n' "$id" ;; *'"method":"skills/extraRoots/set"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; - *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}}],"errors":[]}}]}}}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}},{{"name":"platform-abstract"}},{{"name":"vite-export-xhs-minitool"}}],"errors":[]}}]}}}}\n' "$id" ;; *'"method":"thread/start"'*) printf '{{"id":%s,"result":{{"thread":{{"id":"thread-1"}}}}}}\n' "$id" ;; *'"method":"thread/inject_items"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; *'"method":"turn/start"'*) @@ -8273,7 +8273,7 @@ while IFS= read -r line; do case "$line" in *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; - *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"}],"errors":[]}]}}\n' "$id" ;; *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; *'"method":"turn/start"'*) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 6c5aa6d50..5bfb13f89 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -245,6 +245,25 @@ fn direct_tools_mcp_specs_for_plugins( "additionalProperties": false } }), + json!({ + "name": "agc_install_skill_resource", + "description": prompt_text!("directTools.agc_install_skill_resource.description"), + "inputSchema": { + "type": "object", + "properties": { + "skillName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "relativePath": { "type": "string", "minLength": 1, "maxLength": 240 }, + "destinationPath": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": prompt_text!("directTools.agc_install_skill_resource.parameters.destinationPath") + } + }, + "required": ["skillName", "relativePath", "destinationPath"], + "additionalProperties": false + } + }), json!({ "name": "agc_write_file", "description": prompt_text!("directTools.agc_write_file.description"), @@ -1037,11 +1056,7 @@ fn call_agc_read_skill_resource(arguments: &Value) -> Value { Ok(value) => value, Err(error) => return mcp_tool_result(error, Vec::new(), true), }; - if Path::new(&relative).is_absolute() - || relative.contains("..") - || relative.contains(':') - || relative.contains('\\') - { + if skill_resource_relative_path_is_unsafe(&relative) { return mcp_tool_result("Skill 资源路径不安全".to_string(), Vec::new(), true); } match read_agc_skill_resource(&format!("{skill}/{relative}")) { @@ -1050,6 +1065,40 @@ fn call_agc_read_skill_resource(arguments: &Value) -> Value { } } +/// Skill 资源相对路径的安全边界:`agc_read_skill_resource` 与 +/// `agc_install_skill_resource` 共用同一份判定。 +fn skill_resource_relative_path_is_unsafe(relative: &str) -> bool { + Path::new(relative).is_absolute() + || relative.contains("..") + || relative.contains(':') + || relative.contains('\\') +} + +/// 组装 `agc_install_skill_resource` 的落盘请求:宿主侧直接读已审核的内置字节, +/// 再复用 `agc_write_file` 的入参规则(项目相对路径 + 内容边界),写入仍走同一条合同 lease 门。 +/// +/// 正文不经过模型上下文,因此既不会被大文件截断,也不依赖原生 `cp` 及其审批。 +fn install_skill_resource_write_arguments(arguments: &Value) -> Result { + validate_tool_object_fields(arguments, &["skillName", "relativePath", "destinationPath"])?; + let skill = bounded_tool_string(arguments, "skillName", 120)?; + let relative = bounded_tool_string(arguments, "relativePath", 240)?; + if skill_resource_relative_path_is_unsafe(&relative) { + return Err("Skill 资源路径不安全".to_string()); + } + let destination = bounded_tool_string(arguments, "destinationPath", 512)?; + let content = read_agc_skill_resource(&format!("{skill}/{relative}"))?; + let write_arguments = json!({ "path": destination, "content": content }); + validate_write_file_arguments(&write_arguments)?; + Ok(write_arguments) +} + +async fn call_agc_install_skill_resource(arguments: &Value) -> Value { + match install_skill_resource_write_arguments(arguments) { + Ok(write_arguments) => call_client_tool_bridge("agc_write_file", &write_arguments).await, + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> Value { if !enabled { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -1534,6 +1583,7 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< .await } "agc_write_file" => call_agc_write_file(&arguments).await, + "agc_install_skill_resource" => call_agc_install_skill_resource(&arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] "agc_cocos_execute" => call_agc_cocos_execute(&arguments).await, #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] @@ -2750,6 +2800,7 @@ mod tests { "conversation.list", "conversation.read", "agc_read_skill_resource", + "agc_install_skill_resource", "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", @@ -3270,6 +3321,66 @@ mod tests { assert_eq!(denied_windows_absolute["isError"], true); } + #[test] + fn install_skill_resource_rewrites_reviewed_bytes_into_one_write_arguments() { + let write = install_skill_resource_write_arguments(&json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md", + "destinationPath": "scripts/structure-contract.md" + })) + .expect("reviewed skill resource composes a write"); + assert_eq!(write["path"], "scripts/structure-contract.md"); + assert_eq!( + write["content"], + read_agc_skill_resource("agc-project-structure/references/structure-contract.md") + .expect("reviewed bytes") + ); + // 只暴露 agc_write_file 的两个入参,模型自带的正文没有旁路通道。 + assert_eq!(write.as_object().expect("object").len(), 2); + } + + #[test] + fn install_skill_resource_rejects_unreviewed_or_unsafe_targets() { + for arguments in [ + // 未登记的资源 + json!({ + "skillName": "agc-project-structure", + "relativePath": "references/not-in-manifest.md", + "destinationPath": "scripts/x.md" + }), + // 资源路径穿越 + json!({ + "skillName": "agc-project-structure", + "relativePath": "../../auth.json", + "destinationPath": "scripts/x.md" + }), + // Windows 绝对路径 + json!({ + "skillName": "agc-project-structure", + "relativePath": r"C:\temp\SKILL.md", + "destinationPath": "scripts/x.md" + }), + // 目标路径越出项目根 + json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md", + "destinationPath": "../outside.md" + }), + // 模型自带正文的旁路字段 + json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md", + "destinationPath": "scripts/x.md", + "content": "model-supplied" + }), + ] { + assert!( + install_skill_resource_write_arguments(&arguments).is_err(), + "{arguments} must be rejected before any bridge call" + ); + } + } + #[test] fn editor_guides_are_available_through_the_existing_skill_resource_tool() { for (skill, engine) in [("agc-unity-editor", "Unity"), ("agc-godot-editor", "Godot")] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 7354b7ce8..e6615f2bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -6,7 +6,7 @@ use std::path::{Component, Path}; const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json"); const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1"; -pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 8] = [ +pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 10] = [ "agc-browser-playtest", "agc-client-projection", "agc-game-production-workflow", @@ -14,10 +14,12 @@ pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 8] = [ "agc-project-structure", "agc-unity-editor", "agc-web-game-development", + "platform-abstract", "taonier-art-assets", + "vite-export-xhs-minitool", ]; -const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 23] = [ +const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 36] = [ ( "agc-unity-editor/SKILL.md", include_bytes!("../../resources/agc-skills/agc-unity-editor/SKILL.md"), @@ -122,6 +124,76 @@ const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 23] = [ "../../resources/agc-skills/taonier-art-assets/references/platform-art-contract.md" ), ), + ( + "platform-abstract/SKILL.md", + include_bytes!("../../resources/agc-skills/platform-abstract/SKILL.md"), + ), + ( + "vite-export-xhs-minitool/SKILL.md", + include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/SKILL.md"), + ), + ( + "vite-export-xhs-minitool/references/cross-platform-h5.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md" + ), + ), + ( + "vite-export-xhs-minitool/references/css-compatibility.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md" + ), + ), + ( + "vite-export-xhs-minitool/references/device-capabilities.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md" + ), + ), + ( + "vite-export-xhs-minitool/references/js-api.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/js-api.md" + ), + ), + ( + "vite-export-xhs-minitool/references/js-compatibility.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md" + ), + ), + ( + "vite-export-xhs-minitool/references/manual-checks.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md" + ), + ), + ( + "vite-export-xhs-minitool/references/performance-budget.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md" + ), + ), + ( + "vite-export-xhs-minitool/references/zip-artifact-spec.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md" + ), + ), + ( + "vite-export-xhs-minitool/scripts/pack.mjs", + include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs"), + ), + ( + "vite-export-xhs-minitool/scripts/validate.mjs", + include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs"), + ), + ( + "vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs" + ), + ), ]; #[derive(Clone, Debug, Deserialize)] @@ -150,15 +222,27 @@ pub(crate) struct AgcSkillCatalogEntry { pub(crate) description: String, } +/// 隐藏文件/目录名(以 `.` 开头)永远不进入审核 Skill Pack。 +/// +/// 与 `scripts/skill-pack-manifest.mjs` 的 `isHiddenSkillEntryName` 保持同一口径: +/// Skill 目录里以 `.` 开头的文件和目录是本地开发辅助(例如 `.selective_rule.txt`、 +/// `scripts/.pack.test.mjs`),既不写进 manifest,也不做指纹、不安装、不可读取。 +fn is_hidden_skill_entry_name(name: &str) -> bool { + name.starts_with('.') +} + fn is_safe_skill_relative_path(value: &str) -> bool { let path = Path::new(value); !value.is_empty() && !value.contains('\\') && !value.contains(':') && !path.is_absolute() - && path - .components() - .all(|component| matches!(component, Component::Normal(part) if !part.is_empty())) + && path.components().all(|component| match component { + Component::Normal(part) => { + !part.is_empty() && !is_hidden_skill_entry_name(part.to_string_lossy().as_ref()) + } + _ => false, + }) } fn bundled_skill_file(path: &str) -> Option<&'static [u8]> { @@ -449,6 +533,10 @@ mod tests { assert!(!is_safe_skill_relative_path("C:/temp/SKILL.md")); assert!(!is_safe_skill_relative_path(r"\\server\share\SKILL.md")); assert!(!is_safe_skill_relative_path(r"references\contract.md")); + // 隐藏文件/目录不进入审核 Skill Pack,声明了也必须被拒绝。 + assert!(!is_safe_skill_relative_path(".selective_rule.txt")); + assert!(!is_safe_skill_relative_path("scripts/.pack.test.mjs")); + assert!(!is_safe_skill_relative_path(".hidden/SKILL.md")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index ff2565b84..560b839d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -830,6 +830,22 @@ pub(crate) fn save_local_project_asset_file( save_local_project_asset_file_at(input) } +/// 在系统文件管理器里定位一份项目内产物。 +/// +/// 保存到用户选定位置失败时的兜底出口:原件还在项目里,把它的位置打开,用户自己复制。 +/// 路径由 [`reveal_local_project_asset_file_at`] 按「能保存就能定位」的同一条门禁复核。 +#[tauri::command] +pub(crate) fn reveal_local_project_asset_file( + app: tauri::AppHandle, + project_path: String, + relative_path: String, +) -> Result<(), String> { + let path = reveal_local_project_asset_file_at(project_path.trim(), relative_path.trim())?; + app.opener() + .reveal_item_in_dir(path) + .map_err(|error| format!("打开文件管理器失败:{error}")) +} + #[tauri::command] pub(crate) async fn read_local_project_image_preview( preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index b19959983..7f4e1aadf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -7,6 +7,10 @@ use asset_generation_tasks::*; use client_extensions::*; use environment_check::preflight_web_game_creation; use error_report::*; +use export::draft::xhs_minitool::commands::{ + read_xhs_minitool_export, read_xhs_minitool_export_hash, run_xhs_minitool_export_build, + save_xhs_minitool_export_form, +}; use game_distribution_publish::*; use plugin_host::{ call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, @@ -664,6 +668,7 @@ pub(super) fn run() { read_local_project_file, read_local_project_image_preview, save_local_project_asset_file, + reveal_local_project_asset_file, read_local_project_text_preview, read_local_project_structured_preview, read_local_project_media_preview, @@ -689,6 +694,10 @@ pub(super) fn run() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, + read_xhs_minitool_export, + read_xhs_minitool_export_hash, + save_xhs_minitool_export_form, + run_xhs_minitool_export_build, prepare_local_project_game_package, upload_local_project_game_package, list_local_project_export_packages, diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs new file mode 100644 index 000000000..733fe7291 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs @@ -0,0 +1 @@ +pub mod xhs_minitool; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs new file mode 100644 index 000000000..a95d729a0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs @@ -0,0 +1,377 @@ +//! 跑用户项目自己的导出脚本。 +//! +//! 宿主不碰 vite、不碰 `pack.mjs`、不解析 zip:它只是以既有 `command.exec` 的边界(脚本白名单、 +//! 反漂移 `expectedCommand`、沙箱、超时、输出清洗)运行项目声明的那条脚本,然后确认产物真的在。 +//! 中间目录叫什么、脚本内部怎么调 vite,都是 agent 与用户的事。 +//! +//! 与表单无关:表单不是构建输入,所以这里不校验表单,也不看 icon。 + +use std::path::Path; + +use crate::project::run_project_verification_with_commit_at; + +use super::commands::offload; +use super::dto::XHSMiniToolExportRunResult; +use super::error::{ + ArtifactMissing, CommandDenied, CommandFailed, ExportUnavailable, XHSMiniToolExportBuildError, +}; +use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME}; +use super::script::{self, NpmScript}; + +/// 权限点位复用既有命令口径:这就是在跑项目命令,不新增 `GAME_CREATION_APP_COMMANDS` 条目。 +const PERMISSION_COMMAND_ID: &str = "command.exec"; +/// 与前端一致:构建可能真的要跑一遍 vite,给到 `project.verify` 的同一档上限(300 秒)。 +const TIMEOUT_SECONDS: u64 = 300; +/// 回给前端的输出尾部上限(字符)。失败时用户要看的是最后几行报错,不是整份构建日志。 +const OUTPUT_TAIL_MAX_CHARS: usize = 4_000; + +pub(crate) async fn run( + root: &Path, +) -> Result { + // 找脚本、读权限策略、清上一次产物都是同步文件 IO:与读/存命令同一口径挪到阻塞线程池, + // 不占 async worker。 + let root = root.to_path_buf(); + let script = offload({ + let root = root.clone(); + move || prepare(&root) + }) + .await?; + + let result = run_project_verification_with_commit_at( + &root, + SCRIPT_NAME, + &script.command, + TIMEOUT_SECONDS, + script.cwd_relative, + // 导出不推进项目 revision,也不改任何正式状态:产物只是 `.export/` 下的一个文件。 + || Ok(()), + ) + .await + .map_err(ExportUnavailable::new)?; + + // `target_exec != "established"` 就是「脚本根本没跑起来」:沙箱建不起来(`not-attempted`)或目标 + // 可执行文件执行失败(`failed`)。这跟「脚本跑了但被信号杀掉」(`exit_code` 同样是 None,但 + // `target_exec` 是 `established`)不是一回事,所以判据用它,而不是 `exit_code.is_none()`。 + let script_launched = result.target_exec == "established"; + let exit_code = result.exit_code; + let timed_out = result.timed_out; + let output = result.output; + + // 收尾判产物同样是同步 IO(symlink_metadata),一样搬去阻塞线程池。 + offload(move || conclude(&root, exit_code, timed_out, script_launched, &output)).await +} + +/// 构建前置(同步):找到脚本、确认权限点位没被拒、清掉上一次的产物。 +/// +/// 产物判据只看「文件非空」,所以先清掉上一次的 zip:脚本这次什么都没写时,残留的旧 zip 会 +/// 让 no-op 脚本被判成功,用户上传的其实是上一轮的产物。删不掉(权限等)就 fail closed—— +/// 留着旧产物继续判成功,比这次构建失败更糟。 +fn prepare(root: &Path) -> Result { + let Some(script) = script::find(root) else { + return Err(script::missing_error()); + }; + // 用 typed 拒绝而不是 `enforce_project_permission_policy` 的字符串版本:那个版本把 + // 「策略拒绝」与「策略读不出来」拼成同一句人话,调用方就分不出「预期拒绝」与「宿主故障」了。 + crate::project::enforce_project_permission_policy_rejection(root, PERMISSION_COMMAND_ID) + .map_err(|rejection| match rejection { + crate::agent::tool::error::ProjectPermissionRejection::Denied { command_id } => { + XHSMiniToolExportBuildError::CommandDenied(CommandDenied { command_id }) + } + crate::agent::tool::error::ProjectPermissionRejection::PolicyUnavailable { cause } => { + XHSMiniToolExportBuildError::ExportUnavailable(ExportUnavailable::new(cause)) + } + })?; + + let artifact = artifact_path(root); + match std::fs::remove_file(&artifact) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(XHSMiniToolExportBuildError::ExportUnavailable( + ExportUnavailable::new(format!( + "清理上一次导出产物失败:{}:{error}", + artifact.display() + )), + )); + } + } + + Ok(script) +} + +/// 把宿主的构建结果收成导出结果:先判脚本有没有真的跑起来,再判成功,最后确认产物真的在。 +/// +/// 只吃四个字段而不是整份 `ProjectVerificationResult`:这条判据是导出链路里唯一"脚本跑完之后 +/// 算不算数"的规则,抽成纯函数才能脱离"真的跑一遍 npm"逐条测(未启动、非零退出、超时、产物缺失、 +/// 成功)。 +fn conclude( + root: &Path, + exit_code: Option, + timed_out: bool, + script_launched: bool, + output: &str, +) -> Result { + if !script_launched { + // 脚本根本没跑起来(沙箱建不起来 / 目标可执行文件执行失败):这是宿主侧事实故障, + // 不是项目代码的毛病。报 `exportUnavailable` 让前端按宿主故障处理(并抛进错误池上报), + // 而不是 `commandFailed` 去引导 code agent 改一份它改不动的宿主环境。 + let (output_tail, _) = tail(output); + return Err(XHSMiniToolExportBuildError::ExportUnavailable( + ExportUnavailable::new(output_tail), + )); + } + + if timed_out || exit_code != Some(0) { + let (output_tail, omitted_characters) = tail(output); + return Err(XHSMiniToolExportBuildError::CommandFailed(CommandFailed { + exit_code, + output_tail, + omitted_characters, + })); + } + + let path = artifact_path(root); + let produced = matches!( + std::fs::symlink_metadata(&path), + // `metadata` 会跟随符号链接:指向项目外某个非空文件的链接会被当成「产出成功」, + // 而下游下载路径又按 fail closed 拒收链接。这里用 symlink_metadata 与之一致。 + Ok(metadata) + if metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.len() > 0 + ); + if !produced { + // 脚本"成功"却没产出:多半是 agent 把 zip 写到了别处,或 pack.mjs 的参数没对齐。 + return Err(XHSMiniToolExportBuildError::ArtifactMissing( + ArtifactMissing { + path: artifact_relative_path(), + }, + )); + } + + // 成功也回输出尾部:`pack.mjs` 会把 zip 的条目数与体积打在这里,正是用户上传前要看的数字。 + let (output_tail, omitted_characters) = tail(output); + Ok(XHSMiniToolExportRunResult { + output_tail, + omitted_characters, + }) +} + +/// 只留尾部若干字符:整份构建日志塞进 IPC 没有意义,还会把界面拖住。 +/// +/// 返回(尾部原文, 省略了多少个字符)。省略量是**事实**,「省略前 N 个字符」那句话由前端拼—— +/// 宿主不预拼用户可见文案,否则前端没法按自己的现场排版,也没法判断这段尾巴是否完整。 +fn tail(output: &str) -> (String, usize) { + let trimmed = output.trim(); + let characters: Vec = trimmed.chars().collect(); + if characters.len() <= OUTPUT_TAIL_MAX_CHARS { + return (trimmed.to_string(), 0); + } + let skipped = characters.len() - OUTPUT_TAIL_MAX_CHARS; + let tail: String = characters[skipped..].iter().collect(); + (tail, skipped) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 每次都新建一个临时项目根:产物判据是文件系统事实,测试之间不能共享目录。 + fn fixture_root(label: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-build-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn write_artifact(root: &Path, bytes: &[u8]) { + let path = artifact_path(root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + } + + #[test] + fn keeps_short_output_as_is_and_omits_nothing() { + assert_eq!(tail(" 构建失败\n"), ("构建失败".to_string(), 0)); + } + + #[test] + fn keeps_only_the_tail_of_long_output_and_reports_the_fact() { + let output: String = "前".repeat(OUTPUT_TAIL_MAX_CHARS) + "后"; + let (kept, omitted) = tail(&output); + // 载荷里只有原文与省略量两个事实:说明那句话由前端拼,宿主不写。 + assert!(kept.ends_with('后')); + assert!(!kept.contains("省略")); + assert_eq!(omitted, 1); + } + + #[test] + fn reports_a_missing_script_before_touching_the_project() { + let root = fixture_root("missing-script"); + + let error = tauri::async_runtime::block_on(run(&root)) + .expect_err("no package.json means no script"); + assert!(matches!( + error, + XHSMiniToolExportBuildError::BuildScriptMissing(_) + )); + } + + #[test] + fn a_non_zero_exit_keeps_the_code_and_the_output_tail() { + let root = fixture_root("non-zero"); + + let error = conclude(&root, Some(1), false, true, "vite 构建失败\n入口不存在") + .expect_err("non-zero exit must fail"); + let XHSMiniToolExportBuildError::CommandFailed(failure) = error else { + panic!("非零退出必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, Some(1)); + assert_eq!(failure.output_tail, "vite 构建失败\n入口不存在"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_timeout_still_reports_command_failed_without_an_exit_code() { + let root = fixture_root("timeout"); + + let error = conclude(&root, None, true, true, "已取消") + .expect_err("timeout must fail even with exit code 0"); + let XHSMiniToolExportBuildError::CommandFailed(failure) = error else { + panic!("超时必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, None); + assert_eq!(failure.output_tail, "已取消"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_successful_script_without_the_artifact_is_reported_as_missing() { + let root = fixture_root("no-artifact"); + + let error = + conclude(&root, Some(0), false, true, "构建完成").expect_err("产物缺失必须失败"); + let XHSMiniToolExportBuildError::ArtifactMissing(missing) = error else { + panic!("没有 zip 必须映射成 artifactMissing"); + }; + assert_eq!(missing.path, ".export/xhs-minitool.zip"); + } + + #[test] + fn an_empty_artifact_file_does_not_count() { + let root = fixture_root("empty-artifact"); + write_artifact(&root, b""); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) + )); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_artifact_does_not_count() { + let root = fixture_root("symlink-artifact"); + let path = artifact_path(&root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let target = root.join("outside.zip"); + std::fs::write(&target, b"PK\x03\x04zip").unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + + assert!( + matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) + ), + "指向项目外非空文件的链接不能算产出成功" + ); + } + + #[test] + fn a_successful_script_with_a_non_empty_artifact_returns_the_output_tail() { + let root = fixture_root("ok"); + write_artifact(&root, b"PK\x03\x04zip"); + + let result = conclude( + &root, + Some(0), + false, + true, + "zip 已生成:.export/xhs-minitool.zip(3 个条目)", + ) + .expect("产物在就应当成功"); + assert!(result.output_tail.contains("3 个条目")); + assert_eq!(result.omitted_characters, 0); + } + + #[test] + fn a_script_that_never_launched_is_host_unavailable_not_command_failed() { + let root = fixture_root("not-launched"); + + // 沙箱建不起来或目标可执行文件执行失败:`exit_code` 同样是 None,但脚本根本没跑, + // 必须报宿主侧故障,不能让「让陶泥儿修」的按钮出现在一个它改不动的环境问题上。 + let error = conclude(&root, None, false, false, "命令沙箱不可用") + .expect_err("脚本没跑起来必须失败"); + let XHSMiniToolExportBuildError::ExportUnavailable(unavailable) = error else { + panic!("脚本未启动必须映射成 exportUnavailable"); + }; + assert_eq!(unavailable.cause, "命令沙箱不可用"); + } + + /// 真起进程跑一遍宿主链路:解析 npm 包、走 `command.exec` 边界执行项目脚本、再按产物判据收尾。 + /// + /// 默认 `#[ignore]`:它要本机 `node` / `npm` 与可用的命令沙箱,慢且依赖宿主环境,不进常规单测。 + /// 手动跑:`cargo test --bin genarrative-ai-game-creator-shell -- --ignored executes_the_project_script`。 + #[test] + #[ignore = "需要本机 node/npm 与可用命令沙箱,手动用 --ignored 运行"] + fn executes_the_project_script_and_requires_a_non_empty_artifact_each_time() { + const MARKER: &str = "打包完成:3 个条目"; + let root = fixture_root("real-run"); + std::fs::write( + root.join("package.json"), + format!( + "{{\"name\":\"xhs-real-run-fixture\",\"private\":true,\"scripts\":{{\"{SCRIPT_NAME}\":\"node make-artifact.mjs\"}}}}" + ), + ) + .unwrap(); + std::fs::write( + root.join("make-artifact.mjs"), + format!( + "import {{ mkdirSync, writeFileSync }} from 'node:fs';\n\ + mkdirSync('.export', {{ recursive: true }});\n\ + writeFileSync('.export/xhs-minitool.zip', Buffer.from('PK\\u0003\\u0004fixture'));\n\ + console.log('{MARKER}');\n" + ), + ) + .unwrap(); + + let first = tauri::async_runtime::block_on(run(&root)).expect("真实脚本应当跑通"); + assert!( + first.output_tail.contains(MARKER), + "输出尾部应当带回脚本的 stdout:{}", + first.output_tail + ); + + // 每一次导出都重新构建:删掉产物再点一次导出,必须是脚本重新产出的那一份。 + std::fs::remove_file(artifact_path(&root)).expect("删掉第一次的产物"); + tauri::async_runtime::block_on(run(&root)).expect("第二次导出也应当重新产出"); + assert!(artifact_path(&root).is_file(), "产物必须被重新写出来"); + + // 脚本"成功"却没产出:改成什么都不写,必须报 artifactMissing 而不是假成功。 + std::fs::write(root.join("make-artifact.mjs"), "console.log('nothing');\n").unwrap(); + std::fs::remove_file(artifact_path(&root)).unwrap(); + let error = + tauri::async_runtime::block_on(run(&root)).expect_err("脚本没产出 zip 时必须失败"); + assert!(matches!( + error, + XHSMiniToolExportBuildError::ArtifactMissing(_) + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs new file mode 100644 index 000000000..6bf86b987 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs @@ -0,0 +1,248 @@ +//! 导出面板用到的四个 `#[tauri::command]`。 +//! +//! 每个命令只做一件事,失败表也各有一条([`super::error`])。没有项目写锁、不推进 revision:宿主 +//! 在这里读写的,就是 `.export/` 下那一个注册表文件。 +//! +//! 「内容」与「注册表指纹」是两条命令:[`read_xhs_minitool_export`] 是面板自动刷新反复调的 +//! 那条,拿不到指纹;指纹(写回时的 `baseHash`)只在进编辑会话、或内容真的被采纳时单独取一次。 +//! 分开是为了让刷新动作在类型上就动不了写回基线——否则外部改动会被静默吞掉。 +//! +//! 入参在 JS 侧是 camelCase(Tauri 的既有口径):`projectPath`、`baseHash`。 +//! +//! 命令边界各记一条 `app_log!`(走在 `project/*` 模块同样的口径上):面板卡在「正在读取」时, +//! AppData 里的 `export.<命令>.failed` 那一行就是答案。读命令每 2 秒重试一次,所以**同一个失败只 +//! 记一次**,变了或中间成功过一次才再记——否则日志会被同一句刷满。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; + +use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRunResult, XHSMiniToolExportState}; +use super::error::{ + ExportUnavailable, XHSMiniToolExportBuildError, XHSMiniToolExportReadError, + XHSMiniToolExportSaveError, +}; +use super::registry::{self, RegistrySnapshot}; +use super::{build, script}; + +/// 读内容:注册表(缺则补一份空表单)+ 脚本是否存在。自动刷新反复调的就是这一条。 +#[tauri::command] +pub(crate) async fn read_xhs_minitool_export( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content(&project_path) + }) + .await; + report("read", &project_path, &result); + result +} + +/// 读注册表指纹:写回时原样带回做冲突判据。 +/// +/// 单独一条命令,且**只在内容被采纳时**取:让「刷新」这条高频路径在类型上碰不到写回基线。 +#[tauri::command] +pub(crate) async fn read_xhs_minitool_export_hash( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content_hash(&project_path) + }) + .await; + report("readHash", &project_path, &result); + result +} + +/// 保存表单:只按 `base_hash` 判冲突,成功后回新的指纹。 +/// +/// **不校验表单内容**——必填与字数上限由界面在提交前挡住(用户最终面对的是界面,这份表单也只是 +/// 一份草稿),宿主只负责把它原样写进注册表。`iconPath` 这类路径事实在真正读它的那条路(预览、 +/// 下载)由统一的路径门禁复核,不在这里预支。 +#[tauri::command] +pub(crate) async fn save_xhs_minitool_export_form( + project_path: String, + form: XHSMiniToolExportForm, + base_hash: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + let form = form.clone(); + let base_hash = base_hash.clone(); + move || save_form(&project_path, &form, &base_hash) + }) + .await; + report("save", &project_path, &result); + if let Ok(hash) = &result { + app_log!( + "export.save.ok project={project_path} nameChars={} introductionChars={} iconPath={} baseHash={} contentHash={}", + form.name.trim().chars().count(), + form.introduction.trim().chars().count(), + form.icon_path.trim(), + short(&base_hash), + short(hash), + ); + } + result +} + +/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。**不校验表单**——表单不是构建输入。 +#[tauri::command] +pub(crate) async fn run_xhs_minitool_export_build( + project_path: String, +) -> Result { + app_log!("export.build.start project={project_path}"); + let result = run_build(&project_path).await; + match &result { + Ok(run) => { + clear_failure("build", &project_path); + app_log!( + "export.build.ok project={project_path} outputChars={} omittedCharacters={}", + run.output_tail.chars().count(), + run.omitted_characters, + ); + } + Err(error) => log_failure("build", &project_path, error), + } + result +} + +/// `hasScript` 永远现算:注册表里不存、也不缓存这个事实。 +fn content(root: &Path, snapshot: RegistrySnapshot) -> XHSMiniToolExportState { + XHSMiniToolExportState { + form: snapshot.form, + has_script: script::find(root).is_some(), + } +} + +/// 项目根校验对三条命令都是同一种宿主故障:各命令用 `From` 自己收口。 +fn project_root(project_path: &str) -> Result { + let root = PathBuf::from(project_path); + crate::project::validate_project_root(&root).map_err(ExportUnavailable::new)?; + Ok(root) +} + +/// 把小块同步 IO 挪到 Tokio 的阻塞线程池。 +/// +/// 读命令每 2 秒被轮询一次,注册表 + `package.json` 探测虽然小,但直接在 `async fn` 里做同步 +/// 文件 IO 会占着 worker 线程,拖慢同一 runtime 上的其它异步任务(构建子进程、SpacetimeDB +/// 客户端)。任务 panic 时 `JoinError` 收口成 `ExportUnavailable`,走各命令既有的失败表。 +/// +/// 构建命令(`build::run`)的前后置同步 IO 也复用这里,所以对同级的 `build` 模块可见。 +pub(super) async fn offload(job: F) -> Result +where + T: Send + 'static, + E: From + Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + match tokio::task::spawn_blocking(job).await { + Ok(result) => result, + Err(error) => Err(E::from(ExportUnavailable::new(format!( + "后台执行导出读写任务失败:{error}" + )))), + } +} + +fn read_content(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(content( + &root, + registry::load::(&root)?, + )) +} + +fn read_content_hash(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(registry::load::(&root)?.content_hash) +} + +fn save_form( + project_path: &str, + form: &XHSMiniToolExportForm, + base_hash: &str, +) -> Result { + let root = project_root(project_path)?; + Ok(registry::save(&root, form, base_hash)?.content_hash) +} + +async fn run_build( + project_path: &str, +) -> Result { + let root = project_root(project_path)?; + build::run(&root).await +} + +/// 上一次记过的失败(按命令名 + 项目路径)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。 +/// +/// 去重键必须带上项目:`registryMalformed` 之类的现场文案不含文件路径,两个项目可能给出同一句 +/// `Debug`,只按命令名去重会把项目 B 的失败当成项目 A 的重复现场静默吞掉。 +static LAST_FAILURE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// 命令边界的统一收口:成功清掉去重记录,失败记一条。 +fn report(command: &'static str, project_path: &str, result: &Result) { + match result { + Ok(_) => clear_failure(command, project_path), + Err(error) => log_failure(command, project_path, error), + } +} + +/// 一条失败日志最多记多少个字符。 +/// +/// `CommandFailed` 的 `Debug` 里带着最多 4,000 字符的脚本输出尾部,原样落进 AppData 日志会把一次 +/// 失败撑成好几 KB,去重表也跟着留一份长字符串。日志只需要认得出「哪个变体、哪条命令失败」, +/// 完整现场已经结构化回给前端了。 +const MAX_LOGGED_FAILURE_CHARS: usize = 512; + +fn log_failure(command: &'static str, project_path: &str, error: &impl std::fmt::Debug) { + let message = bounded_for_log(&format!("{error:?}"), MAX_LOGGED_FAILURE_CHARS); + let key = (command, project_path.trim().to_string()); + if let Ok(mut last) = LAST_FAILURE.lock() { + if last.get(&key).is_some_and(|seen| *seen == message) { + return; + } + last.insert(key, message.clone()); + } + app_log!("export.{command}.failed project={project_path}: {message}"); +} + +/// 按**字符**(不是字节)截断日志文本,避免切坏 UTF-8;发生截断时补一个省略号说明。 +fn bounded_for_log(message: &str, max_chars: usize) -> String { + if message.chars().count() <= max_chars { + return message.to_string(); + } + let head: String = message.chars().take(max_chars).collect(); + format!("{head}…") +} + +fn clear_failure(command: &'static str, project_path: &str) { + if let Ok(mut last) = LAST_FAILURE.lock() { + last.remove(&(command, project_path.trim().to_string())); + } +} + +/// 日志里的指纹只留前 8 位:长度一样能看出「换了没有」,又不用把整串抄进日志。 +fn short(hash: &str) -> &str { + let end = hash.char_indices().nth(8).map_or(hash.len(), |(at, _)| at); + &hash[..end] +} + +#[cfg(test)] +mod tests { + use super::{bounded_for_log, short}; + + #[test] + fn short_keeps_first_eight_characters() { + assert_eq!(short("0123456789abcdef"), "01234567"); + assert_eq!(short("0123"), "0123"); + assert_eq!(short(""), ""); + } + + #[test] + fn bounded_for_log_keeps_short_text_and_truncates_on_char_boundaries() { + assert_eq!(bounded_for_log("构建失败", 8), "构建失败"); + // 按字符截断:4 个中文字符不会被切坏,末尾补省略号。 + assert_eq!(bounded_for_log("一二三四五六", 4), "一二三四…"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs new file mode 100644 index 000000000..f9bb9da18 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs @@ -0,0 +1,75 @@ +//! 小红书小工具导出的线上形状(Rust → TS 由 ts-rs 生成,生成物不手改)。 + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这三个值的唯一用途是让用户 +/// 复制粘贴到小红书平台的上传页。 +/// +/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能 +/// 被静默忽略后带着半份表单继续走。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportForm { + pub(crate) name: String, + pub(crate) introduction: String, + /// 项目内相对路径。icon 文件与目标无关,多个导出目标可以共用同一个。 + pub(crate) icon_path: String, +} + +impl XHSMiniToolExportForm { + /// 落盘形态:三个字段都去掉首尾空白。 + /// + /// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白, + /// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。 + pub(crate) fn normalized(&self) -> Self { + Self { + name: self.name.trim().to_string(), + introduction: self.introduction.trim().to_string(), + icon_path: self.icon_path.trim().to_string(), + } + } +} + +/// 注册表文件形状:`.export/xhs-minitool.json`。 +/// +/// 只有 `form` 一个键——没有 status、没有已适配标记、没有 revision,那些要么是 `package.json` +/// 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。 +/// +/// 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它 +/// 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportRegistry { + pub(crate) form: XHSMiniToolExportForm, +} + +/// 面板读到的现场内容。 +/// +/// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 +/// 永远从 npm 包的 `package.json` 现算,不落盘。也没有注册表指纹:那是写回时的同步细节, +/// 由 `read_xhs_minitool_export_hash` 单独给,自动刷新读的这条命令碰不到它。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportState { + pub(crate) form: XHSMiniToolExportForm, + /// `scripts["build:xhs-minitool"]` 是否存在。 + pub(crate) has_script: bool, +} + +/// 一次成功导出的结果。产物路径是常量(`.export/xhs-minitool.zip`),不进 DTO。 +/// +/// 不带 `exit_code`:走到这里的出口码必然是 0,带回来只是一个恒为 0 的死字段;失败侧 +/// (`CommandFailed`)自己带退出码。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportRunResult { + /// 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。 + pub(crate) output_tail: String, + /// 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。 + pub(crate) omitted_characters: usize, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs new file mode 100644 index 000000000..7c86b6869 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs @@ -0,0 +1,189 @@ +//! 命令边界的 typed 失败。 +//! +//! **一条命令一张失败表**,不合并成一张大枚举:调用方拿到的 `Result` 本身就限定了「这条命令 +//! 可能怎么失败」,JS 侧也因此不用按命令再挑一遍变体。 +//! +//! - [`XHSMiniToolExportReadError`]:读注册表(`read_xhs_minitool_export` / +//! `read_xhs_minitool_export_hash`); +//! - [`XHSMiniToolExportSaveError`]:写表单(`save_xhs_minitool_export_form`,自动保存与「打包」 +//! 前的 flush 走同一条)。`saveConflict` 只出现在这里——它是「这份输入还没落盘」的结果,不是 +//! 构建失败。表单内容的必填与字数由界面在提交前校验,宿主这一层不做业务校验,所以这张表里没有 +//! 「字段不合法」这一类; +//! - [`XHSMiniToolExportBuildError`]:跑构建(`run_xhs_minitool_export_build`)。构建不是表单的 +//! 消费者,所以这张表里没有表单类变体。 +//! +//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。每条命令都是 +//! 首个错误即返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。 +//! +//! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的 +//! TS 联合类型。 + +use serde::Serialize; +use ts_rs::TS; + +use super::dto::XHSMiniToolExportForm; + +/// 读注册表可能怎么失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportReadError { + /// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。 + RegistryMalformed(RegistryMalformed), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +/// 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportSaveError { + /// 注册表存在但不是本目标期望的形状。 + RegistryMalformed(RegistryMalformed), + /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 + SaveConflict(SaveConflict), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +/// 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportBuildError { + /// npm 包里没有 `build:xhs-minitool`;这就是「还没适配」的唯一现算判据。 + BuildScriptMissing(BuildScriptMissing), + /// 项目权限策略拒绝执行 `command.exec`:用户/项目策略说了不让跑,不是缺陷。 + CommandDenied(CommandDenied), + /// 脚本跑完没有正常收场(非零退出或超时)。 + CommandFailed(CommandFailed), + /// 脚本正常收场但产物不在。 + ArtifactMissing(ArtifactMissing), + /// 脚本无法启动或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct RegistryMalformed { + /// 解析器原文。它是引导语,不是给前端做判断的依据。 + pub(crate) cause: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct BuildScriptMissing { + /// 期望的脚本名(`build:xhs-minitool`)。 + pub(crate) script: String, + /// 宿主找过哪些 `package.json`,让用户知道去哪看。 + pub(crate) searched: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct SaveConflict { + /// 文件里现在是什么(字段级二选一的「文件里的」那一侧)。 + pub(crate) current: XHSMiniToolExportForm, + /// 冲突时文件的指纹;用户选完之后带着它重试保存。 + pub(crate) content_hash: String, +} + +/// 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障 +/// (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct CommandDenied { + /// 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。 + pub(crate) command_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct CommandFailed { + pub(crate) exit_code: Option, + /// 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。 + pub(crate) output_tail: String, + /// 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。 + pub(crate) omitted_characters: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct ArtifactMissing { + /// 产物相对项目根的路径。 + pub(crate) path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct ExportUnavailable { + pub(crate) cause: String, +} + +impl ExportUnavailable { + pub(crate) fn new(cause: impl Into) -> Self { + Self { + cause: cause.into(), + } + } +} + +/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。 +impl From for XHSMiniToolExportReadError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for XHSMiniToolExportSaveError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for XHSMiniToolExportBuildError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +/// 注册表层的失败折叠成各命令自己的表:读不出来的形状是 `registryMalformed`,其余是宿主故障。 +macro_rules! impl_from_registry_error { + ($error:ty) => { + impl From for $error { + fn from(error: crate::export::RegistryError) -> Self { + match error { + crate::export::RegistryError::Malformed { cause, .. } => { + Self::RegistryMalformed(RegistryMalformed { + cause: cause.to_string(), + }) + } + other => Self::ExportUnavailable(ExportUnavailable::new(other.to_string())), + } + } + } + }; +} + +impl_from_registry_error!(XHSMiniToolExportReadError); +impl_from_registry_error!(XHSMiniToolExportSaveError); diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs new file mode 100644 index 000000000..f78c6f691 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs @@ -0,0 +1,92 @@ +//! 小红书小工具在 `.export/` 下的固定名字与路径。 +//! +//! 这些名字同时出现在三处:宿主、agent 写的 npm 脚本、用户手动上传的文件名。所以它们是 +//! **契约常量**,只在这里定义一次,改名字要三处一起改。 + +use std::path::{Path, PathBuf}; + +use crate::export::EXPORT_WORK_RELATIVE_DIR; + +/// npm 脚本名。冒号形式不是审美选择:既有 `command.exec` 白名单只放行 `build` 与 `build:<后缀>`。 +pub(crate) const SCRIPT_NAME: &str = "build:xhs-minitool"; + +/// 注册表文件名(`.export/` 下)。 +pub(crate) const REGISTRY_FILE_NAME: &str = "xhs-minitool.json"; + +/// 交付产物文件名(`.export/` 下),人工上传用。 +pub(crate) const ARTIFACT_FILE_NAME: &str = "xhs-minitool.zip"; + +pub(crate) fn export_dir(root: &Path) -> PathBuf { + root.join(EXPORT_WORK_RELATIVE_DIR) +} + +pub(crate) fn registry_path(root: &Path) -> PathBuf { + export_dir(root).join(REGISTRY_FILE_NAME) +} + +pub(crate) fn artifact_path(root: &Path) -> PathBuf { + export_dir(root).join(ARTIFACT_FILE_NAME) +} + +/// 产物相对项目根的路径。前端下载与 prompt 用的都是它。 +pub(crate) fn artifact_relative_path() -> String { + format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") +} + +#[cfg(test)] +mod tests { + use std::path::Component; + + use super::*; + + /// 前端拿的是**字符串**相对路径(下载命令与 prompt 都用它),宿主写的是 `PathBuf`。两条路各自 + /// 拼一遍,必须落在同一个文件上——谁只改了一边,这条先红。 + #[test] + fn the_relative_path_names_the_same_file_the_host_writes() { + let root = Path::new("/tmp/project"); + assert_eq!(root.join(artifact_relative_path()), artifact_path(root)); + assert_eq!( + artifact_path(root) + .file_name() + .and_then(|name| name.to_str()), + Some(ARTIFACT_FILE_NAME) + ); + } + + /// 这个字符串会交给宿主的路径门禁(下载命令复核项目内相对路径)并写进 prompt:它必须是 + /// **项目内、单层、不带 `..`** 的相对路径。前端与 agent 都按它找文件,跑出 `.export/` 就是越界。 + #[test] + fn the_relative_path_cannot_walk_out_of_the_export_directory() { + let relative_path = artifact_relative_path(); + let relative = Path::new(&relative_path); + assert!(relative.is_relative()); + assert!(relative + .components() + .all(|component| matches!(component, Component::Normal(_)))); + // flat 布局:导出目录**直接**下的一个文件名,不为目标建子目录。 + assert_eq!(relative.parent(), Some(Path::new(EXPORT_WORK_RELATIVE_DIR))); + // 跨平台:它是给前端与 prompt 的字符串,Windows 上不许漏出 `\`。 + assert!(!relative_path.contains('\\')); + } + + /// flat 是刻意的决定:注册表与产物共用一个**项目根锚定**的目录,多目标也只是同目录不同文件名。 + /// 谁加了一层 per-target 子目录,这条就红。 + #[test] + fn every_file_of_this_target_shares_one_flat_directory() { + let root = Path::new("/tmp/project"); + assert_eq!(registry_path(root).parent(), artifact_path(root).parent()); + assert_eq!(export_dir(root).parent(), Some(root)); + assert_eq!( + export_dir(root).file_name().and_then(|name| name.to_str()), + Some(EXPORT_WORK_RELATIVE_DIR) + ); + } + + /// 脚本名不是自由文本:`command.exec` 的白名单只放行 `build` 与 `build:<安全后缀>`。 + /// 尤其不能退化成裸 `build`——那会跑掉项目自己的构建,产物永远不会出现。 + #[test] + fn the_script_name_keeps_the_form_the_command_allowlist_requires() { + assert_ne!(SCRIPT_NAME, "build"); + assert!(SCRIPT_NAME.starts_with("build:")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs new file mode 100644 index 000000000..3c58814d4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs @@ -0,0 +1,21 @@ +//! 小红书小工具导出目标。 +//! +//! 文件都堆在项目根的 `.export/`(见 [`crate::export::EXPORT_WORK_RELATIVE_DIR`]): +//! `xhs-minitool.json` 是注册表,`xhs-minitool.zip` 是交付给人工上传的产物,`xhs-minitool.*.mjs` +//! 是 agent 首次适配时从 `vite-export-xhs-minitool` skill 显式拷进来的脚本(允许它改)。共享 +//! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 +//! +//! 分层:`dto` 是线上形状,`error` 是命令边界失败表,`layout` 是契约常量,`registry` 管注册表, +//! `script` 管 npm 脚本发现,`build` 跑脚本并确认产物,`commands` 是唯一对外的命令面。 +//! +//! 表单**不在宿主侧做业务校验**:命令只把用户那份草稿原样读出来、写回去,必填与字数由界面在 +//! 提交前挡住。宿主要挡的只有「这份注册表文件写得对不对」这类 IO 事实(见 `registry`)。 + +pub mod commands; + +mod build; +mod dto; +mod error; +mod layout; +mod registry; +mod script; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs new file mode 100644 index 000000000..d5ed12aa5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs @@ -0,0 +1,179 @@ +//! 小红书注册表的读取与保存。 +//! +//! 文件级细节(自动创建、严格解析、原子写入、内容指纹)都在 [`crate::export::registry`];这里 +//! 只管本目标的事:注册表 ↔ 表单的映射,以及冲突判定。 +//! +//! 已知取舍(评审提出):这里用共享的 `load_or_create` / `save`,**不**额外检查注册表文件与 +//! `.export` 目录是不是符号链接。也就是说,如果 `.export/xhs-minitool.json` 被换成链接,读写会 +//! 落到项目外。 +//! +//! **决定不改,理由**:要挡的是「把项目内容交给打包链」的入口——产物(`build::conclude`)、脚本 +//! (`script::find`)和图标在预览 / 下载时走的路径门禁都拒链,因为它们把项目内容送出项目边界。 +//! 注册表读写的是用户自己项目里的一份草稿,用户既然能在磁盘上把它换成链接,就按「项目文件由用户 +//! 掌控」接受这个重定向;为它单独加守卫会改掉共享读写的契约,收益与成本不成比例。若以后注册表要 +//! 承载跨项目共享的内容,再按与那三处同口径补 `symlink_metadata` 检查。 + +use std::path::Path; + +use crate::export::{load_or_create, save as write_registry}; + +use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRegistry}; +use super::error::{SaveConflict, XHSMiniToolExportSaveError}; +use super::layout::registry_path; + +/// 注册表现场:表单,加上**这份内容**的指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegistrySnapshot { + pub(crate) form: XHSMiniToolExportForm, + pub(crate) content_hash: String, +} + +/// 读注册表;文件不存在时补一份全空表单。解析失败原样上抛成 `registryMalformed`。 +/// +/// 返回的错误类型由调用方定:读命令与保存命令各有一张失败表,两边都实现了 +/// `From`,所以这里泛型化,不替调用方选表。 +pub(crate) fn load(root: &Path) -> Result +where + E: From, +{ + let loaded = load_or_create(®istry_path(root), &XHSMiniToolExportRegistry::default())?; + Ok(RegistrySnapshot { + form: loaded.value.form, + content_hash: loaded.content_hash, + }) +} + +/// 保存表单。 +/// +/// `base_hash` 是调用方读到的指纹。文件没被换过就直接写;被换过但字段完全一致(只剩格式差异) +/// 就静默吸收、不写盘;字段真的不同则回 `saveConflict`,把文件里的值交给用户逐字段挑——**绝不** +/// 覆盖用户或 agent 的输入。 +pub(crate) fn save( + root: &Path, + form: &XHSMiniToolExportForm, + base_hash: &str, +) -> Result { + let path = registry_path(root); + // 读 `load_or_create` 与下面的 `write_registry` 之间没有锁,两步不是原子的:并发写(用户保存 + // 撞上 code agent 改同一个 `.export/xhs-minitool.json`)存在「后写覆盖先写」的窗口。 + // + // 这里明确接受这个窗口,不加 compare-and-swap:保存一律由用户手动触发(点保存 / 停手后的 + // 自动落盘 / 点打包前的 flush),用户本来就知道 code agent 在同一项目里改文件;真撞上时重新 + // 读一次注册表就能看到最新内容,不需要写入门禁级别的并发保证。把 `base_hash` 比较下沉进共享的 + // `crate::export::save`(原子重读 + 比较 + rename)会改掉所有导出目标的写契约,成本高于收益。 + let current = load_or_create(&path, &XHSMiniToolExportRegistry::default())?; + let form = form.normalized(); + if !current.created && current.content_hash != base_hash { + let current_form = current.value.form.normalized(); + if current_form != form { + return Err(XHSMiniToolExportSaveError::SaveConflict(SaveConflict { + current: current_form, + content_hash: current.content_hash, + })); + } + return Ok(RegistrySnapshot { + form: current_form, + content_hash: current.content_hash, + }); + } + let content_hash = write_registry(&path, &XHSMiniToolExportRegistry { form: form.clone() })?; + Ok(RegistrySnapshot { form, content_hash }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + fn form(name: &str) -> XHSMiniToolExportForm { + XHSMiniToolExportForm { + name: name.to_string(), + introduction: "简介".to_string(), + icon_path: "assets/icon.png".to_string(), + } + } + + #[test] + fn creates_an_empty_registry_on_first_read() { + let root = fixture_project("create"); + + let snapshot = load::(&root).expect("load"); + assert_eq!(snapshot.form, XHSMiniToolExportForm::default()); + assert!( + root.join(".export/xhs-minitool.json").is_file(), + "读取必须顺手补出空注册表" + ); + } + + #[test] + fn saves_and_normalizes_whitespace() { + let root = fixture_project("save"); + let base = load::(&root).expect("load"); + + let mut value = form(" 小工具 "); + value.introduction = " 一句话简介 ".to_string(); + let saved = save(&root, &value, &base.content_hash).expect("save"); + assert_eq!(saved.form.name, "小工具"); + assert_eq!(saved.form.introduction, "一句话简介"); + + let reread = load::(&root).expect("reload"); + assert_eq!(reread.form, saved.form); + assert_eq!(reread.content_hash, saved.content_hash); + } + + #[test] + fn absorbs_formatting_only_changes_without_rewriting() { + let root = fixture_project("format"); + let base = load::(&root).expect("load"); + // agent 重排了文件:字段没变,指纹变了。 + let compact = "{\"form\":{\"name\":\"\",\"introduction\":\"\",\"iconPath\":\"\"}}"; + fs::write(root.join(".export/xhs-minitool.json"), compact).unwrap(); + + let saved = save(&root, &XHSMiniToolExportForm::default(), &base.content_hash) + .expect("formatting-only change is absorbed"); + // 吸收的是「字段相同」这个现实:不覆写别人的排版,但指纹要跟着文件走。 + assert_eq!( + fs::read_to_string(root.join(".export/xhs-minitool.json")).unwrap(), + compact + ); + assert_eq!(saved.content_hash, crate::export::content_hash(compact)); + assert_ne!(saved.content_hash, base.content_hash); + } + + #[test] + fn refuses_to_overwrite_a_real_change() { + let root = fixture_project("conflict"); + let base = load::(&root).expect("load"); + let theirs = form("agent 写的"); + fs::write( + root.join(".export/xhs-minitool.json"), + serde_json::to_vec_pretty(&XHSMiniToolExportRegistry { + form: theirs.clone(), + }) + .unwrap(), + ) + .unwrap(); + + let error = save(&root, &form("用户写的"), &base.content_hash) + .expect_err("real change must not be overwritten"); + match error { + XHSMiniToolExportSaveError::SaveConflict(payload) => { + assert_eq!(payload.current, theirs); + } + other => panic!("expected saveConflict, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs new file mode 100644 index 000000000..c1f0fd81c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs @@ -0,0 +1,157 @@ +//! 找声明 `build:xhs-minitool` 的 npm 包。 +//! +//! 判据只有一个:`package.json` 的 `scripts` 里有没有这个名字。**不落盘、不缓存**——「已适配」 +//! 就是此刻这个问题的答案,用户手删脚本或换机器之后必须立刻反映出来。候选目录与发布流程 +//! (`resolve_publish_build_cwd`)同口径:项目根,或模板工程常用的 `game/` 子工程。 + +use std::path::Path; + +use super::error::{BuildScriptMissing, XHSMiniToolExportBuildError}; +use super::layout::SCRIPT_NAME; + +/// npm 工作目录候选,按顺序取第一个声明了脚本的。 +pub(crate) const NPM_WORK_CANDIDATES: [&str; 2] = [".", "game"]; + +/// 命中的 npm 脚本。 +pub(crate) struct NpmScript { + /// 运行脚本时的工作目录(相对项目根)。 + pub(crate) cwd_relative: &'static str, + /// `package.json` 里声明的脚本文本。交给运行器做反漂移的 `expectedCommand`。 + pub(crate) command: String, + /// 相对项目根的 `package.json` 路径,报错与提示都用它。 + pub(crate) package_json_relative: String, +} + +/// 找到脚本就返回它,否则 `None`。读不了或不是合法 JSON 的 `package.json` 直接跳过,与发布 +/// 流程保持一致:这里不是校验工程结构的地方,找不到就是「还没适配」。 +pub(crate) fn find(root: &Path) -> Option { + for cwd in NPM_WORK_CANDIDATES { + let package_root = if cwd == "." { + root.to_path_buf() + } else { + match crate::project::resolve_local_project_path(root, cwd) { + Ok(path) => path, + Err(_) => continue, + } + }; + let package_path = package_root.join("package.json"); + let Ok(metadata) = std::fs::symlink_metadata(&package_path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&package_path) else { + continue; + }; + let Ok(package) = serde_json::from_str::(&content) else { + continue; + }; + let Some(command) = package + .get("scripts") + .and_then(|scripts| scripts.get(SCRIPT_NAME)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + return Some(NpmScript { + cwd_relative: cwd, + command: command.to_string(), + package_json_relative: package_json_relative(cwd), + }); + } + None +} + +/// 找不到脚本时的 typed 失败:把找过的位置一起给出去,用户才知道去哪看。 +pub(crate) fn missing_error() -> XHSMiniToolExportBuildError { + XHSMiniToolExportBuildError::BuildScriptMissing(BuildScriptMissing { + script: SCRIPT_NAME.to_string(), + searched: NPM_WORK_CANDIDATES + .iter() + .map(|cwd| package_json_relative(cwd)) + .collect(), + }) +} + +fn package_json_relative(cwd: &str) -> String { + if cwd == "." { + "package.json".to_string() + } else { + format!("{cwd}/package.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-script-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + #[test] + fn finds_the_script_in_the_root_package() { + let root = fixture_project("root"); + fs::write( + root.join("package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"node pack.mjs\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "."); + assert_eq!(script.command, "node pack.mjs"); + assert_eq!(script.package_json_relative, "package.json"); + } + + #[test] + fn finds_the_script_in_the_game_subproject() { + let root = fixture_project("game"); + fs::create_dir_all(root.join("game")).unwrap(); + fs::write( + root.join("game/package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"vite build\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "game"); + assert_eq!(script.package_json_relative, "game/package.json"); + } + + #[test] + fn ignores_other_scripts_and_broken_packages() { + let root = fixture_project("missing"); + fs::write( + root.join("package.json"), + "{\"scripts\":{\"build\":\"vite build\"}}", + ) + .unwrap(); + assert!(find(&root).is_none()); + + fs::write(root.join("package.json"), "{ not json").unwrap(); + assert!(find(&root).is_none()); + + let error = missing_error(); + match error { + XHSMiniToolExportBuildError::BuildScriptMissing(payload) => { + assert_eq!(payload.script, SCRIPT_NAME); + assert_eq!(payload.searched, vec!["package.json", "game/package.json"]); + } + other => panic!("expected buildScriptMissing, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs new file mode 100644 index 000000000..9c02c65b2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs @@ -0,0 +1,20 @@ +//! 导出产物:把用户项目导出成第三方平台的制品。 +//! +//! 边界见 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的 +//! 2026-10-05 条目。一句话:宿主只做四件确定性的事——读写项目内 `.export/` 注册表、校验表单 +//! 与构建脚本是否存在、以既有 `command.exec` 边界运行项目自己的 npm 脚本、把产物交给用户。 +//! 适配与打包全部由 code agent 首次实验出来并固化成用户项目里的脚本;宿主不复刻平台规范、 +//! 不解析 zip 结构,也不替用户跑 `pack.mjs`。 + +pub mod draft; + +mod registry; + +pub(crate) use registry::{content_hash, load_or_create, save, Loaded, RegistryError}; + +/// 导出工作目录:项目根下的 `.export/`。 +/// +/// 全部导出目标共用一个 flat 目录:共享文件(例如多目标复用的同一个 icon)直接放这一层, +/// 目标专属文件挂 target 前缀(`xhs-minitool.json`、`xhs-minitool.zip`……)。整个目录排除出 +/// 项目快照——所以恢复或换机后适配成果不跟随,需要重新让 agent 适配。 +pub(crate) const EXPORT_WORK_RELATIVE_DIR: &str = ".export"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs new file mode 100644 index 000000000..96460f7d7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -0,0 +1,403 @@ +//! 导出注册表的**文件级**读写。 +//! +//! 这个模块只认「一个 JSON 文件 + 一个内容指纹」,不认任何 target:自动创建、严格解析、原子 +//! 写入与内容 hash 全部收在这里,目标模块只负责提供自己的 DTO 与路径。指纹是**原始文件字节** +//! 的 SHA-256,不写进文件——它是「有没有别人改过」的判据,不是持久化状态。 + +use std::fmt; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// 已读出的注册表值与它的内容指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Loaded { + pub(crate) value: T, + /// 原始文件内容的指纹;保存时用它判断「读完之后有没有别人写过」。 + pub(crate) content_hash: String, + /// 这次读取是不是刚补出来的空文件:刚补出来的文件没有可冲突的对象。 + pub(crate) created: bool, +} + +/// 注册表失败的底层现场。 +/// +/// 能拿到底层错误类型时(`io` / `serde`)就把类型带出来,这样 Rust 侧调用方可以按 +/// `std::io::ErrorKind` 或 serde 错误分流,日志也能沿 `source()` 追到根因;只有本模块自己拼 +/// 出来的一句话(例如「注册表路径缺少父目录」)才退化成 [`RegistryErrorCause::Message`]。 +/// `context` 是给原始错误补的中文说明,只影响展示,不影响类型。 +#[derive(Debug)] +pub(crate) enum RegistryErrorCause { + Io { + context: Option<&'static str>, + error: std::io::Error, + }, + Serde { + context: Option<&'static str>, + error: serde_json::Error, + }, + Message(String), +} + +impl RegistryErrorCause { + fn message(message: impl Into) -> Self { + Self::Message(message.into()) + } + + fn io(error: std::io::Error) -> Self { + Self::Io { + context: None, + error, + } + } + + fn io_with_context(context: &'static str, error: std::io::Error) -> Self { + Self::Io { + context: Some(context), + error, + } + } + + fn serde(error: serde_json::Error) -> Self { + Self::Serde { + context: None, + error, + } + } +} + +impl fmt::Display for RegistryErrorCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { context, error } => match context { + Some(context) => write!(formatter, "{context}:{error}"), + None => write!(formatter, "{error}"), + }, + Self::Serde { context, error } => match context { + Some(context) => write!(formatter, "{context}:{error}"), + None => write!(formatter, "{error}"), + }, + Self::Message(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for RegistryErrorCause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { error, .. } => Some(error), + Self::Serde { error, .. } => Some(error), + Self::Message(_) => None, + } + } +} + +/// 注册表文件级失败。目标模块负责把它映射成自己的 typed error。 +#[derive(Debug)] +pub(crate) enum RegistryError { + /// 路径不可读写(含父目录创建失败)。 + Io { + path: PathBuf, + cause: RegistryErrorCause, + }, + /// 文件内容不是本目标期望的形状。fail closed:不猜测、不修补。 + Malformed { + path: PathBuf, + cause: RegistryErrorCause, + }, + /// 宿主自己的 DTO 序列化失败,属于编程错误。 + Encode { cause: RegistryErrorCause }, +} + +impl std::error::Error for RegistryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { cause, .. } | Self::Malformed { cause, .. } | Self::Encode { cause } => { + Some(cause) + } + } + } +} + +impl fmt::Display for RegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, cause } => { + write!(formatter, "注册表读写失败:{}:{cause}", path.display()) + } + Self::Malformed { path, cause } => { + write!( + formatter, + "注册表内容不符合约定:{}:{cause}", + path.display() + ) + } + Self::Encode { cause } => write!(formatter, "注册表序列化失败:{cause}"), + } + } +} + +/// 原始内容指纹(SHA-256,小写十六进制)。 +pub(crate) fn content_hash(text: &str) -> String { + format!("{:x}", Sha256::digest(text.as_bytes())) +} + +/// 读注册表;文件不存在时先用 `empty` 的规范序列化补一份空注册表,再读回来。 +/// +/// 「自动创建」是产品要求:面板一打开就要能显示空表单,而不是先报「文件不存在」。 +/// +/// 这里明确接受一个并发窗口:`read_to_string` 返回 `NotFound` 到下面 `write_atomically` 之间 +/// 没有锁,如果这期间别的写者(code agent 的脚本、并发的保存命令)抢先建好了同一个文件,本次 +/// 写入会用空注册表把它盖掉。不为此加锁或改成「原子重读 + 比较」:注册表本来就是用户与 agent +/// 共写的本机文件,真撞上时重新读一次就能看到最新内容,写入门禁级别的并发保证成本高于收益 +///(目标模块 `xhs_minitool::registry::save` 对同类窗口也是明确接受)。 +pub(crate) fn load_or_create(path: &Path, empty: &T) -> Result, RegistryError> +where + T: Serialize + DeserializeOwned, +{ + let (text, created) = match fs::read_to_string(path) { + Ok(text) => (text, false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let created = encode(empty)?; + write_atomically(path, &created)?; + (created, true) + } + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + // 非 UTF-8 字节就是「内容不是本目标期望的形状」,与 JSON 解析失败同类;落进通用 Io 会 + // 被报成「路径 / 权限故障」,把用户和 agent 往修权限的方向引。 + return Err(RegistryError::Malformed { + path: path.to_path_buf(), + cause: RegistryErrorCause::io_with_context("注册表不是有效 UTF-8", error), + }); + } + Err(error) => { + return Err(RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io(error), + }); + } + }; + let value = decode(path, &text)?; + Ok(Loaded { + value, + content_hash: content_hash(&text), + created, + }) +} + +/// 以规范形式写回注册表,返回**新内容**的指纹。 +pub(crate) fn save(path: &Path, value: &T) -> Result { + let text = encode(value)?; + write_atomically(path, &text)?; + Ok(content_hash(&text)) +} + +/// 规范形式:两空格缩进 + 结尾换行。agent 会手改这个文件,人类可读比紧凑更重要。 +fn encode(value: &T) -> Result { + let mut text = serde_json::to_string_pretty(value).map_err(|error| RegistryError::Encode { + cause: RegistryErrorCause::serde(error), + })?; + text.push('\n'); + Ok(text) +} + +fn decode(path: &Path, text: &str) -> Result { + serde_json::from_str(text).map_err(|error| RegistryError::Malformed { + path: path.to_path_buf(), + cause: RegistryErrorCause::serde(error), + }) +} + +/// 先写同目录临时文件再原子替换。 +/// +/// 直接对目标 `File::create` 会在中途失败时同时留下半截文件和「原内容已被截断」两个后果; +/// manifest 与素材保存用的是同一套手法。 +fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> { + let parent = path.parent().ok_or_else(|| RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::message("注册表路径缺少父目录"), + })?; + fs::create_dir_all(parent).map_err(|error| RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io_with_context("创建导出目录失败", error), + })?; + let temp = temp_path(path); + let write = || -> std::io::Result<()> { + // `create_new`(O_EXCL)不跟随已存在的路径:项目里预先摆好的同名符号链接或残留文件会让 + // 这次写入直接失败,而不是把链接指向的文件截断成注册表内容。 + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; + file.write_all(text.as_bytes())?; + // 替换前落盘:只 flush 的话,崩溃窗口里换过去的可能是还没写完的内容。 + file.sync_all() + }; + if let Err(error) = write() { + let _ = fs::remove_file(&temp); + return Err(RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io(error), + }); + } + fs::rename(&temp, path).map_err(|error| { + let _ = fs::remove_file(&temp); + RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io(error), + } + })?; + // rename 只改目录条目:不 fsync 父目录的话,崩溃后「哪个文件才是新的」这件事可能没落盘, + // 数据虽然在磁盘上,目录项仍是旧的(甚至没有)。Windows 打不开目录句柄,打不开就跳过。 + if let Ok(directory) = fs::File::open(parent) { + let _ = directory.sync_all(); + } + Ok(()) +} + +fn temp_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "registry.json".to_string()); + // 临时文件名必须**每次写都不同**:只用 pid 的话,同一进程里两个并发写共用同一个临时文件, + // 一个会截断另一个正在写的内容,rename 也会互相抢。进程内原子计数器保证不重名。 + // + // 计数器从 0 重来,所以还要带上「现在」:上一次进程崩在 `create_new` 与 rename 之间时, + // 会留下 `.tmp` 残骸;系统把同一个 pid 再分给新进程时,只靠 pid + 序号会撞上那份残骸, + // 而 `create_new` 会直接以 already-exists 失败。纳秒时间戳让重启后的第一次写也不再撞名。 + let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + path.with_file_name(format!( + ".{file_name}.{}.{sequence}.{stamp}.tmp", + std::process::id() + )) +} + +/// 同进程内临时文件名的序号,见 [`temp_path`]。 +static TEMP_FILE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Serialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Fixture { + name: String, + } + + fn fixture_directory(prefix: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!( + "genarrative-export-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&directory).expect("create fixture directory"); + directory + } + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + #[test] + fn creates_empty_registry_and_keeps_its_hash_on_reread() { + let path = fixture_directory("create").join("target.json"); + let empty = Fixture { + name: String::new(), + }; + + let first = load_or_create(&path, &empty).expect("create registry"); + assert!(first.created); + assert_eq!(first.value, empty); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "{\n \"name\": \"\"\n}\n" + ); + + let second = load_or_create(&path, &empty).expect("reread registry"); + assert!(!second.created); + assert_eq!(second.content_hash, first.content_hash); + } + + #[test] + fn rejects_malformed_json_without_rewriting_it() { + let path = fixture_directory("malformed").join("target.json"); + fs::write(&path, "{ not json").unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("malformed must fail closed"); + assert!(matches!(error, RegistryError::Malformed { .. })); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + } + + #[test] + fn rejects_non_utf8_content_as_malformed() { + let path = fixture_directory("non-utf8").join("target.json"); + fs::write(&path, [0xff, 0xfe, 0x00]).unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("invalid utf-8 must fail closed"); + assert!( + matches!(error, RegistryError::Malformed { .. }), + "非 UTF-8 必须报成 malformed,而不是 host/path 故障:{error:?}" + ); + } + + #[test] + fn rejects_unknown_fields_so_agent_drift_is_visible() { + let path = fixture_directory("unknown").join("target.json"); + fs::write(&path, "{\"name\":\"a\",\"status\":\"ready\"}\n").unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("unknown field must fail"); + assert!(matches!(error, RegistryError::Malformed { .. })); + } + + #[test] + fn save_returns_the_hash_of_what_it_wrote() { + let path = fixture_directory("save") + .join(".export") + .join("target.json"); + let value = Fixture { + name: "小红书".to_string(), + }; + + let hash = save(&path, &value).expect("save registry"); + let loaded = load_or_create(&path, &value).expect("reload registry"); + assert_eq!(loaded.value, value); + assert_eq!(loaded.content_hash, hash); + } + + #[test] + fn error_keeps_the_underlying_io_kind_for_rust_callers() { + let error = RegistryError::Io { + path: PathBuf::from("/tmp/registry.json"), + cause: RegistryErrorCause::io_with_context( + "创建导出目录失败", + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + ), + }; + + // 展示文案保持原样,Rust 侧则能沿 source() 一路拿回 io::Error 并按 ErrorKind 分流。 + assert!(error.to_string().contains("创建导出目录失败")); + let cause = std::error::Error::source(&error).expect("cause"); + let io = cause + .source() + .and_then(|source| source.downcast_ref::()) + .expect("io source"); + assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs index 74a7d4b92..a58999cdb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs @@ -1,5 +1,5 @@ use crate::command_exec::resolve_project_command_spec_at; -use crate::project::{normalize_relative_path, should_skip_project_snapshot_path}; +use crate::project::{normalize_relative_path, should_skip_project_derived_path}; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; @@ -1046,7 +1046,7 @@ fn validate_commit_paths(paths: &[String]) -> Result, LocalGitCommit let normalized = normalize_relative_path(path) .map_err(|_| LocalGitCommitError::ordinary("Git 提交路径必须是规范化的项目相对路径"))?; if normalized != *path - || should_skip_project_snapshot_path(&normalized) + || should_skip_project_derived_path(&normalized) || !unique.insert(normalized.clone()) { return Err(LocalGitCommitError::ordinary( @@ -1971,7 +1971,7 @@ fn parse_status(root: &Path, output: &str) -> (Vec, Vec, Vec Option { let normalized = normalize_relative_path(path).ok()?; - if should_skip_project_snapshot_path(&normalized) { + if should_skip_project_derived_path(&normalized) { return None; } if !git_worktree_path_is_safe(root, &normalized) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 024853d87..2e968ac5f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2131,6 +2131,7 @@ mod diagnostic_log_tests { } } +pub mod export; #[cfg(test)] mod tests; pub mod ui_editor; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs index d63fef865..27f5b4226 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs @@ -210,6 +210,21 @@ pub(crate) fn save_local_project_asset_file_at( }) } +/// 解析要在系统文件管理器里定位的那份导出产物。 +/// +/// 与保存共用 [`resolve_export_source_file`]:只有项目根内真实存在的普通文件才会被打开,路径 +/// 门禁、敏感文件与控制面拒绝口径完全一致。它是"保存失败后的兜底出口"——不替用户挑落点,只把 +/// 原件所在位置打开,让他自己复制。 +pub(crate) fn reveal_local_project_asset_file_at( + project_path: &str, + relative_path: &str, +) -> Result { + let root = Path::new(project_path); + validate_project_root(root)?; + enforce_project_auto_permission_policy(root, "file.read")?; + resolve_export_source_file(root, relative_path) +} + #[cfg(test)] mod tests { use super::*; @@ -285,6 +300,23 @@ mod tests { let _ = std::fs::remove_dir_all(&destination_directory); } + #[test] + fn reveal_resolves_existing_project_file_and_rejects_missing() { + let (root, assets) = create_export_project(); + std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset"); + + let path = reveal_local_project_asset_file_at(&root.to_string_lossy(), "assets/hero.png") + .expect("reveal existing project file"); + assert_eq!(path, assets.join("hero.png")); + + let error = + reveal_local_project_asset_file_at(&root.to_string_lossy(), "assets/missing.png") + .expect_err("missing file must fail"); + assert!(error.contains("素材文件不存在或不可读"), "{error}"); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn rejects_directory_as_source_asset() { let (root, assets) = create_export_project(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 70b12ab48..e5b86cb40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -673,7 +673,7 @@ pub(crate) fn restore_local_project_checkpoint_at( } pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { - should_skip_project_snapshot_path(relative_path) + should_skip_project_derived_path(relative_path) || relative_path == ".agent/agent.db" || relative_path == PROJECT_PERMISSION_POLICY_PATH || relative_path == PROJECT_WRITE_LOCK_PATH @@ -787,7 +787,7 @@ pub(crate) fn read_checkpoint_files( } file.path = normalized; } - files.retain(|file| !should_skip_project_snapshot_path(&file.path)); + files.retain(|file| !should_skip_project_derived_path(&file.path)); Ok(files) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index bb5b21789..45e2168ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -397,15 +397,39 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool { || relative_path == PROJECT_INDEX_PATH || relative_path.starts_with(".agent/checkpoints/") || relative_path.starts_with(".agent/runtime/") - || should_skip_project_snapshot_path(relative_path) + || should_skip_project_derived_path(relative_path) } -/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的 -/// 本机控制面,不参与其中。 +/// Agent 视角的排除口径:`.agent` 是本机控制面,写入门禁(`agent/direct_patch.rs`)与 +/// Agent 上下文(prompt / tool bridge)拿它判断「Agent 能不能碰、看不看得到」。 +/// +/// 它**不**包含 `.export`:那个目录必须让 code agent 能写。把项目内容当数据来读的消费者 +/// (索引、checkpoint、git 检查)请改用 `should_skip_project_derived_path`。 pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { project_snapshot_path_is_excluded(relative_path, false) } +/// 索引、checkpoint 与 git 检查共用的排除口径:在本机控制面之外,再排除本机派生的导出工作目录 +/// (`.export`,见 `PROJECT_DERIVED_EXCLUDED_COMPONENTS`)。 +/// +/// 这些文件能从项目源码重跑出来,不该进索引、checkpoint 或版本库视角,也不该被当成项目变更; +/// 但 Agent 必须能写它们,所以不能并进 `should_skip_project_snapshot_path`——那份列表同时是 +/// `agc_apply_patch` 的写入门禁,并进去会让首次适配直接卡死。 +/// +/// `.export` 只认项目根那一层(`EXPORT_WORK_RELATIVE_DIR` 就是 `root/.export`):源码里合法的 +/// 嵌套目录 `game/.export/...` 是用户自己的文件,不能被误伤。 +pub(crate) fn should_skip_project_derived_path(relative_path: &str) -> bool { + project_snapshot_path_is_excluded(relative_path, false) + || relative_path + .split('/') + .find(|component| !component.is_empty()) + .is_some_and(|component| { + PROJECT_DERIVED_EXCLUDED_COMPONENTS + .iter() + .any(|candidate| component.eq_ignore_ascii_case(candidate)) + }) +} + /** * 项目快照同步(上传)的排除口径。 * @@ -416,6 +440,10 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { * * 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、 * `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。 + * + * 另外,同步还多认 `PROJECT_DERIVED_EXCLUDED_COMPONENTS`(`.export`):本机派生 + * 的导出工作目录不进同步,但 Agent 仍要能读写它。`.export` 同样只认项目根那一层, + * 源码里的嵌套 `game/.export/...` 不在此列。 */ pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool { project_snapshot_path_is_excluded(relative_path, true) @@ -488,6 +516,19 @@ const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[ /// 随快照同步;其它数据库与转储后缀仍然排除。 const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"]; +/** + * 本机派生的导出工作目录(`.export`):Agent 可见,其它消费者一律排除。 + * + * 不能并进上面的通用列表:那份列表同时是 Agent 的写入门禁(`agent/direct_patch.rs` 直接拿它 + * 拒绝路径),而 `.export` 恰恰必须让 code agent 能写——导出注册表、打包脚本拷贝都由它生成。 + * 放进去会让 `agc_apply_patch` 回「不得修改受保护或排除路径」,首次适配直接卡死。 + * + * 也不能只排快照同步:索引会把多 MB 的 zip 整份读进内存算 checksum,checkpoint 会把它复制进 + * `.agent/checkpoints`,git 检查会把它当未跟踪文件放出去,允许提交。所以「把项目内容当数据 + * 来读」的消费者(索引、checkpoint、git 检查)都走 `should_skip_project_derived_path`。 + */ +const PROJECT_DERIVED_EXCLUDED_COMPONENTS: &[&str] = &[".export"]; + fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool { let components = relative_path .split('/') @@ -497,7 +538,11 @@ fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: b if components.iter().any(|component| { PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str()) && !(include_agent_state && component == ".agent") - }) { + }) || (include_agent_state + && components.first().is_some_and(|component| { + PROJECT_DERIVED_EXCLUDED_COMPONENTS.contains(&component.as_str()) + })) + { return true; } let Some(file_name) = components.last() else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs index 73a7209c2..f344b9e82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -214,6 +214,11 @@ fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() "node_modules/pkg/index.js", "game/dist/bundle.js", "secrets/key.pem", + // 导出工作目录整体不进快照:zip 与脚本拷贝都能从项目源码重新跑出来。 + ".export/xhs-minitool.zip", + ".export/xhs-minitool.json", + ".export/xhs-minitool.pack.mjs", + ".EXPORT/xhs-minitool.zip", "", ] { assert!( @@ -226,6 +231,75 @@ fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() assert!(should_skip_project_snapshot_path(".agent/manifest.json")); assert!(should_skip_project_index_path(".agent/manifest.json")); assert!(should_skip_project_index_path(".agent/agent.db")); + + // `.export/` 不进 Agent 的写入门禁:通用口径同时是 `agc_apply_patch` 的拒路径依据, + // 一并拒掉会让 code agent 写不了适配脚本。 + assert!(!should_skip_project_snapshot_path( + ".export/xhs-minitool.pack.mjs" + )); + assert!(!should_skip_project_snapshot_path( + ".export/xhs-minitool.json" + )); + + // 但「把项目内容当数据来读」的消费者(索引 / checkpoint / git 检查)一律排除它: + // zip 能从项目源码重跑,不该进索引、checkpoint 清单或版本库视角。 + for derived_path in [ + ".export/xhs-minitool.zip", + ".export/xhs-minitool.json", + ".export/xhs-minitool.pack.mjs", + ".EXPORT/xhs-minitool.zip", + ] { + assert!( + should_skip_project_derived_path(derived_path), + "数据视角共用口径应排除本机派生目录:{derived_path}" + ); + assert!( + should_skip_project_index_path(derived_path), + "索引应排除本机派生目录:{derived_path}" + ); + assert!( + should_skip_project_restore_path(derived_path), + "checkpoint 恢复应排除本机派生目录:{derived_path}" + ); + } + // 普通源码目录不受影响,只有 `.export` 这类派生目录被额外排除。 + assert!(!should_skip_project_derived_path("game/main.ts")); + assert!(!should_skip_project_index_path("game/main.ts")); +} + +#[test] +fn derived_export_directory_is_matched_only_at_the_project_root() { + // 项目根的 `.export` 才是本机派生工作目录:数据视角与快照同步都排除。 + for root_export in [ + ".export/xhs-minitool.zip", + ".export/xhs-minitool.json", + ".EXPORT/xhs-minitool.zip", + ] { + assert!( + should_skip_project_derived_path(root_export), + "根目录的 .export 必须排除:{root_export}" + ); + assert!( + should_skip_project_snapshot_sync_path(root_export), + "根目录的 .export 不进快照同步:{root_export}" + ); + } + + // 嵌套在源码目录里的 `.export` 是用户自己的文件,不能按本机派生目录误伤。 + for nested in [ + "game/.export/local.ts", + "game/.export/xhs-minitool.zip", + "src/vendor/.export/keep.json", + ] { + assert!( + !should_skip_project_derived_path(nested), + "嵌套 .export 是用户源码,不该被数据视角排除:{nested}" + ); + assert!( + !should_skip_project_snapshot_sync_path(nested), + "嵌套 .export 是用户源码,不该被排除出快照同步:{nested}" + ); + } } #[test] diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 145a3fb0a..05e7bdca4 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -38,18 +38,11 @@ import type { LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, - LocalProjectExportPackageResult, LocalProjectKind, PendingUiConfirmation, ProjectPermissionPolicyView, TauriInvoke, } from './app/types'; -import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; -import { GamePublishBlockedDialog } from './components/game-distribution/GamePublishBlockedDialog'; -import { - GamePublishProgressDialog, - type GamePublishProgressState, -} from './components/game-distribution/GamePublishProgressDialog'; import { agentConversationId, createAgentChatRunId, @@ -107,6 +100,7 @@ import { DirectProjectChatView, type DirectProjectInitialTurn, } from './view/project-development/chat/DirectProjectChatView'; +import ArtifactsPanel from './view/project-development/export'; import { PlanningChatView } from './view/project-development/planning/PlanningChatView'; import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; @@ -324,15 +318,8 @@ export function App({ const chatComposerRef = useRef(null); const [chatAgentBusy, setChatAgentBusy] = useState(false); - // 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。 - const [publishPackageResult, setPublishPackageResult] = - useState(null); - const [publishPanelOpen, setPublishPanelOpen] = useState(false); - const [publishProgress, setPublishProgress] = - useState(null); - const [publishBlockedMessage, setPublishBlockedMessage] = useState< - string | null - >(null); + // 导出产物:全屏面板由工作台壳按需挂载,关闭即卸载,面板状态不留在壳上。 + const [exportPanelOpen, setExportPanelOpen] = useState(false); const [projectChatError, setProjectChatError] = useState(''); const designReplyAnimation = useDesignReplyAnimation(); const [designAgentStatus, setDesignAgentStatus] = useState(''); @@ -863,64 +850,6 @@ export function App({ } } - /** - * 导出试玩包并打开发布面板。 - * - * 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区, - * 导出失败在弹窗内回显;成功后才切换到发布资料面板。 - */ - async function requestGamePublish() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setPublishBlockedMessage('需要在 Tauri App 内发布'); - return; - } - const nextProjectPath = - resolveChatProjectPath(localProject) ?? projectPath.trim(); - if (!nextProjectPath) { - setPublishBlockedMessage('先打开一个项目再发布'); - return; - } - const publishManifest = manifestRef.current; - const hasCompletedPrototype = publishManifest.tasks.some( - (task) => task.id === 'code-prototype' && task.status === 'completed', - ); - const hasRunningPreview = - publishManifest.preview?.status === 'running' && - Boolean(publishManifest.preview.url?.trim()); - if (!hasCompletedPrototype && !hasRunningPreview) { - setPublishBlockedMessage( - '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', - ); - return; - } - setPublishProgress({ - phase: 'running', - message: '正在构建并打包试玩包,请稍候…', - step: 'build', - }); - try { - const result = await invoke( - 'export_local_project_package', - { projectPath: nextProjectPath }, - ); - setPublishProgress(null); - setPublishPackageResult(result); - setPublishPanelOpen(true); - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'project.export_package', - ); - } catch (error) { - setPublishProgress({ - phase: 'failed', - message: '发布失败', - detail: error instanceof Error ? error.message : String(error), - }); - } - } - async function confirmUiCommand() { const pending = pendingUiConfirmation; if (!pending) { @@ -1050,12 +979,14 @@ export function App({ * 切换项目作用域时把这条链路自己的聊天状态清干净。 * * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 + * 导出面板同样按项目打开,切换项目时必须一起关掉:它不带 `key`,留着会直接重绑到新项目。 */ function resetChatState() { setChatAgentBusy(false); setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); + setExportPanelOpen(false); designReplyAnimation.reset(); setDesignAgentStatus(''); setDesignAgentReasoning(''); @@ -1825,39 +1756,35 @@ export function App({ if (directProjectMode) { // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 + // + // 导出入口与面板共用**同一个已裁剪**的路径:`localProject?.projectPath || projectPath` + // 只判真值,纯空白的路径会照开门;面板下游又都用 `Boolean(projectPath)` 当有效判据。 + // 这里统一裁一次,空白即视作「没有项目」。 + const exportProjectPath = + (localProject?.projectPath || projectPath || '').trim() || null; return ( <> setExportPanelOpen(true) : undefined + } onCancelConfirmation={cancelUiCommandConfirmation} onConfirmConfirmation={confirmUiCommand} pendingConfirmation={pendingUiConfirmation} projectPath={localProject?.projectPath ?? projectPath ?? null} ref={directProjectChatRef} /> - setPublishPanelOpen(false)} - onPublished={(published) => { - // 发布成功会把平台作品绑定写回本地清单;用原生返回的整份 manifest 覆盖 - // 工作台状态,两处面板挂载点(普通项目 / DirectProject)共享同一份状态。 - if (published.manifest) setManifest(published.manifest); - }} - /> - setPublishProgress(null)} - /> - setPublishBlockedMessage(null)} - /> + {exportPanelOpen ? ( + setExportPanelOpen(false)} + /> + ) : null} ); } @@ -1974,24 +1901,6 @@ export function App({ onClose={() => setRuntimeConfigOpen(false)} /> ) : null} - setPublishPanelOpen(false)} - onPublished={(published) => { - if (published.manifest) setManifest(published.manifest); - }} - /> - setPublishProgress(null)} - /> - setPublishBlockedMessage(null)} - /> ); } diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 96d8b5c54..31c4e82b4 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -196,20 +196,6 @@ export function AuthenticatedClient({ setServerSelection(next); setLoginStatus(`已选择 ${preset} 服务器`); } - // 服务器选择是 Rust 客户端偏好:本地 debug 启动时读一次,打包产物始终用渠道默认值。 - useEffect(() => { - if (!clientServerSelectionEnabled) return; - let disposed = false; - void hydrateClientServerSelection().then((selection) => { - if (disposed) return; - setServerSelection(selection); - setCustomServerUrl(selection.customBaseUrl); - }); - return () => { - disposed = true; - }; - }, []); - useEffect(() => { const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { @@ -238,8 +224,20 @@ export function AuthenticatedClient({ setAuthCheckError(''); setAuthCheckStage('refresh'); try { + // 服务器选择先于认证读取落盘值:Rust 按 `expectedApiBaseUrl` 判定凭据归属,竞态下用 + // 渠道默认值会被当成「凭据属于另一个 origin」而误报未登录,自定义服务器每次启动都要重登。 + // 读服务器偏好同样是一次 IPC:不设围栏时,它卡住会让界面永远停在「刷新登录状态」, + // 既没有超时提示也没有重试路径(`hydrateClientServerSelection` 自己吞错、不会 reject)。 + const selection = await withAuthCheckTimeout( + hydrateClientServerSelection(), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取服务器偏好超时,请重试或重启客户端', + ); + if (!isActiveRun()) return; + setServerSelection(selection); + setCustomServerUrl(selection.customBaseUrl); const state = await withAuthCheckTimeout( - readClientAuthState(getClientServerBaseUrl()), + readClientAuthState(getClientServerBaseUrl(selection)), AUTH_CHECK_REQUEST_TIMEOUT_MS, '检查登录状态超时,请检查服务器地址和网络后重试', ); diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts index 4b98920e3..14fe848a6 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts @@ -12,6 +12,13 @@ import type { const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp'] as const; +/** + * 平台图标(小红书上传页等)只认 png/jpg/jpeg,webp 会被平台挡回来,所以这里的白名单比 + * 通用图片素材窄一档,上限也按平台口径收到 5 MiB。 + */ +const ICON_MEDIA_TYPES = ['image/png', 'image/jpeg'] as const; +const ICON_EXTENSIONS = ['png', 'jpg', 'jpeg'] as const; +const ICON_MAX_BYTES = 5 * 1024 * 1024; const FONT_MEDIA_TYPES = [ 'font/ttf', 'font/otf', @@ -37,6 +44,25 @@ function imageRemoteTypeFilter(asset: RemoteAssetCandidate) { ); } +function iconLocalTypeFilter(asset: LocalAssetCandidate) { + return ( + ICON_MEDIA_TYPES.includes( + asset.mediaType.toLowerCase() as (typeof ICON_MEDIA_TYPES)[number], + ) || /\.(png|jpe?g)$/iu.test(asset.localPath) + ); +} + +/** + * 云端素材只有 objectKey / 名称这几个线索。看得出扩展名的按图标白名单筛;看不出扩展名的 + * 放过去让宿主定夺——这里不是拒绝的唯一一道门。 + */ +function iconRemoteTypeFilter(asset: RemoteAssetCandidate) { + if (!imageRemoteTypeFilter(asset)) return false; + const key = asset.objectKey || asset.name; + if (!/\.[a-z0-9]+$/iu.test(key)) return true; + return /\.(png|jpe?g)$/iu.test(key); +} + function fontLocalTypeFilter(asset: LocalAssetCandidate) { return ( FONT_MEDIA_TYPES.includes( @@ -101,6 +127,42 @@ export const DESIGN_IMAGE_IMPORTER_SETTINGS: AssetImporterSettings = export const SPRITE_IMPORTER_SETTINGS: AssetImporterSettings = imageImporterSettings(imageRequirements); +/** + * 平台图标(小红书小工具等):本地项目与云端素材库两个来源都要能选,单张、png/jpg/jpeg、≤5 MiB。 + * 上限与白名单跟宿主 `validate_icon` 对齐,免得选完了保存时才被宿主的硬校验挡回来。 + */ +export const ICON_IMPORTER_SETTINGS: AssetImporterSettings = { + title: '选择图标', + ariaLabel: '选择图标', + icon: createElement(ImageIcon, { size: 16 }), + preview: ImageImporterPreview, + local: { + label: '本地项目', + typeFilter: iconLocalTypeFilter, + discoverUnregistered: true, + fileDialog: { + title: '选择图标', + filters: [{ name: 'Images', extensions: [...ICON_EXTENSIONS] }], + }, + requirements: { maxItems: 1, maxFileSizeBytes: ICON_MAX_BYTES }, + localPolicy: { + destination: 'assets/uploads', + acceptedMediaTypes: ICON_MEDIA_TYPES, + acceptedExtensions: ICON_EXTENSIONS, + }, + }, + remote: { + label: '云端素材库', + typeFilter: iconRemoteTypeFilter, + requirements: { maxItems: 1, maxFileSizeBytes: ICON_MAX_BYTES }, + remotePolicy: { + destination: 'assets/uploads', + acceptedMediaTypes: ICON_MEDIA_TYPES, + acceptedExtensions: ICON_EXTENSIONS, + }, + }, +}; + export const FONT_IMPORTER_SETTINGS: AssetImporterSettings = { title: '导入字体', ariaLabel: '导入字体', diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx new file mode 100644 index 000000000..c0119c2af --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -0,0 +1,546 @@ +import { PlatformGamePricingField } from '@genarrative/shared/components'; +import { Eye, Trash2 } from 'lucide-react'; + +import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution'; +import { + type GameDistributionPublishMetadata, + MAX_AGC_GAME_SCREENSHOTS, +} from '../../services/gameDistributionPublish'; +import { ThemedModal } from '../modal/ThemedModal'; +import { GamePublishPhaseSteps } from './GamePublishPhaseSteps'; +import { + type GameDistributionPublishFormState, + PANEL_IMAGE_ACCEPT, + publicationVersionStatusLabel, +} from './useGameDistributionPublishForm'; + +/** + * 两个弹窗面板的定色。 + * + * 仓库的样式约定是内联(Tailwind / JSX 内联样式),不为这种一次性定色新建 CSS 文件;提到模块级 + * 常量是为了不再每次渲染都新建一个样式对象。`ThemedModal` 的默认背景会用内联样式写死,所以 + * 这两处只能继续走 `panelStyle` 覆盖,不能只加 Tailwind 类。 + */ +const COVER_CONFIRM_PANEL_STYLE = { + background: '#fffaf7', + color: '#4f362d', +} as const; + +const SCREENSHOT_PREVIEW_PANEL_STYLE = { + background: '#1d1714', + color: '#fffaf7', +} as const; + +/** + * 主行动按钮文案:先按发布绑定状态(检查中 / 不可用 / 更新)选路,再按打包与上传阶段选路。 + */ +function resolvePublishActionLabel(state: { + publicationState: 'checking' | 'first-publish' | 'update' | 'unavailable'; + packaging: boolean; + busy: boolean; + generatingCover: boolean; + screenshotsUploading: boolean; + uploadingLabel: string; +}) { + if (state.packaging) return '正在打包…'; + if (state.busy) return '正在发布…'; + if (state.generatingCover) return '封面生成中…'; + if (state.screenshotsUploading) return '截图上传中…'; + if (state.uploadingLabel) return '素材上传中…'; + if (state.publicationState === 'update') return '更新游戏'; + if (state.publicationState === 'checking') return '正在检查发布状态…'; + if (state.publicationState === 'unavailable') return '无法确认发布状态'; + return '打包并发布'; +} + +/** + * 发布绑定状态 → 状态行标题。 + * + * 与 [`resolvePublishActionLabel`] 分开:那颗按钮的文案还要叠加打包 / 上传阶段,状态标题只认绑定。 + */ +function resolvePublicationStatusLabel( + state: GameDistributionPublishFormState['publicationState'], +) { + switch (state) { + case 'update': + return '更新游戏'; + case 'unavailable': + return '无法确认发布状态'; + case 'checking': + return '正在检查发布绑定…'; + case 'first-publish': + return '首次发布'; + } +} + +/** 截图选择按钮的文案:满 / 上传中 / 可添加三态,上限跟着常量走。 */ +function resolveScreenshotPickLabel(state: { + activeScreenshotCount: number; + screenshotsUploading: boolean; +}) { + if (state.activeScreenshotCount >= MAX_AGC_GAME_SCREENSHOTS) { + return `截图已满 ${MAX_AGC_GAME_SCREENSHOTS} 张`; + } + if (state.screenshotsUploading) return '截图上传中…'; + return '添加截图'; +} + +/** + * 发布资料表单的表现层(工作台内联形态)。 + * + * 状态全部来自 `form`(`useGameDistributionPublishForm` 的返回值);主行动是内容末尾整行 + * 橙色的「打包并发布」,校验 / 提交错误由面板根的「发布提示」弹窗承载。 + */ +export function GameDistributionPublishFormView({ + form, + packaging = false, +}: { + form: GameDistributionPublishFormState; + packaging?: boolean; +}) { + const { + title, + setTitle, + summary, + setSummary, + category, + setCategory, + busy, + clearError, + result, + cover, + setCover, + screenshots, + previewScreenshot, + setPreviewScreenshot, + uploadingLabel, + publicationState, + publicationBinding, + publicationRefreshed, + publicationMessage, + latestVersionNumber, + nextVersionNumber, + priceMode, + priceInput, + priceError, + setPriceMode, + setPriceInput, + setPriceError, + publishPhase, + coverConfirmOpen, + setCoverConfirmOpen, + generatingCover, + coverGenerationPrice, + coverGenerationDetail, + coverInputRef, + screenshotInputRef, + summaryTouchedRef, + categoryTouchedRef, + activeScreenshotCount, + screenshotsUploading, + coverPriceLabel, + handleCoverSelected, + handleGenerateCover, + handleScreenshotsSelected, + removeScreenshot, + handlePackageAndPublish, + } = form; + + const fieldsAndMedia = ( + <> +
+ {resolvePublicationStatusLabel(publicationState)} + {publicationState === 'update' && publicationBinding ? ( + + 线上最近提交{' '} + {latestVersionNumber > 0 ? `v${latestVersionNumber}` : '—'} ·{' '} + {publicationVersionStatusLabel(publicationBinding.status)} + {publicationRefreshed + ? '' + : '(未能刷新线上状态,发布以服务端校验为准)'} + + ) : publicationState === 'first-publish' ? ( + 这个项目还没有发布到游戏广场。 + ) : publicationState === 'checking' ? ( + 正在读取本地绑定与线上作品状态。 + ) : ( + + {publicationMessage || '无法确认该项目的线上发布状态,请稍后重试。'} + + )} + {publicationState === 'update' && publicationMessage ? ( + {publicationMessage} + ) : null} +
+
+ +