From e3966bac727b8df57f3b6c8f1e5b1bf48f4dee32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 19:23:12 +0800 Subject: [PATCH 001/156] =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=8A=BD=E8=B1=A1=E4=B8=8E=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=B0=8F?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=B8=A4=E9=A1=B9=20AGC=20=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=20Skill=EF=BC=8C=E5=B9=B6=E8=AE=A9=E9=9A=90=E8=97=8F=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=B0=B8=E4=B8=8D=E8=BF=9B=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 platform-abstract 与 vite-export-xhs-minitool 两项审核 Skill - agc-skill-pack.v1 清单版本升至 2026-08-26.40,两个新条目 sha256 现场重算 - skill-pack 工具新增隐藏文件规则:. 前缀文件/目录不写清单、不参与指纹、不安装、不可读取 - JS 与 Rust 白名单同步扩到 10 项,AGC_SKILL_PACK_FILES 增加 13 条 include_bytes! - codex_app_server 三条 skills/list 测试夹具补齐两个新 Skill 名 - 补充隐藏路径拒绝与收集器忽略隐藏文件的 JS/Rust 测试 - 修掉 pack.mjs / validate.mjs 的 eslint 问题与三处空白,使其通过 pre-commit 与 git diff --check - 同步更新技术方案与决策记录 --- .../scripts/check-skill-pack.test.mjs | 33 + .../scripts/skill-pack-manifest.mjs | 25 +- .../resources/agc-skills/manifest.json | 41 +- .../agc-skills/platform-abstract/SKILL.md | 84 ++ .../.selective_rule.txt | 73 ++ .../vite-export-xhs-minitool/SKILL.md | 30 + .../references/cross-platform-h5.md | 68 ++ .../references/css-compatibility.md | 157 +++ .../references/device-capabilities.md | 109 ++ .../references/js-api.md | 310 +++++ .../references/js-compatibility.md | 42 + .../references/manual-checks.md | 14 + .../references/performance-budget.md | 98 ++ .../references/zip-artifact-spec.md | 62 + .../scripts/.pack.test.mjs | 11 + .../scripts/.validate.test.mjs | 344 ++++++ .../vite-export-xhs-minitool/scripts/pack.mjs | 386 +++++++ .../scripts/validate.mjs | 1007 +++++++++++++++++ .../scripts/vite.config.xhs-minitool.mjs | 62 + .../src/agent/codex_app_server/mod.rs | 6 +- .../src-tauri/src/agent/skill_pack.rs | 98 +- .../shared-memory/decision-log.md | 10 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 23 files changed, 3060 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/platform-abstract/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-api.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs create mode 100755 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs 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/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 12678da03..22b99d640 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.37", + "version": "2026-08-26.40", "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": "c5cae916577f1192972da5a986d8226ba56fbef1462243ca9e2b2d6bdd66ff58" } ] } 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..a7d556b5a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt @@ -0,0 +1,73 @@ +【取舍记录】minitool-zip-builder MUST-only 清理 +日期:2026-10-04 + +原则 +- 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 表:删掉已不存在的「目录结构 / 路径规则 / 打包自检」描述。 +- zip-artifact-spec.md + - 删 §1 目录结构与打包方式:pack.mjs 保证 zip 根 index.html、不多套层。 + - 删 §4 路径与引用规则:base:'./' 与 pack.mjs 保证相对路径。 + - 删 §6 打包前自检:validate / pack / MANUAL_CHECKS 已覆盖。 + - 删 §5 表格里「资源全为相对路径」「脚本外置」两行(前者 Vite 保证,后者 §3 CSP 已声明)。 + - 保留 §2 文件类型、§3 容器 CSP、§5 index.html 要求。 +- 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。 + +后续更新规则 +- references/ 只加 MUST。建议、经验、数值标定写进脚本(带检查码)或本文件,不写回 references。 + +二次清理(同日) +- zip-artifact-spec.md §5:删掉「index.html 模板」整段 HTML 骨架和其中的 reset / 字体 CSS;标题改为「index.html 要求」,只留要求表与 说明。 + - 原因:模板是 pack 之后的 dist 形状,对 Vite 源 index.html 是错的(源需要 <script type="module"> 入口);reset / 字体 CSS 不是容器要求;doctype / charset / lang 由 validate 的 *_MISSING 兜。 + - 保留的 4 条要求:viewport 含 width=device-width, initial-scale=1.0, viewport-fit=cover;不用 <base href>;不引用外部资源;不自建 CSP <meta>。 +- SKILL.md:被外部精简重写为 vite-export-xhs-minitool,只剩 Reference 表,原步骤 9 / 10 已不存在;Reference 表指向「index.html 要求」。 +- 本文件名由「取舍记录.txt」改为 selective_rule.txt。 + +目录调整(同日) +- packages/pack-script、packages/validate-script、packages/vite-config-example 已合并进本 skill: + - scripts/pack.mjs(原 packages/pack-script/pack.mjs) + - scripts/validate.mjs(原 packages/validate-script/scripts/validate-xhs-tool.mjs,已改名) + - scripts/vite.config.xhs-minitool.mjs(原 packages/vite-config-example/vite.config.xhs-minitool.mjs) + - references/MANUAL_CHECKS.md(原 packages/validate-script/MANUAL_CHECKS.md) +- pack.mjs 的 import 改为 './validate.mjs';performance-budget.md 的门禁链接改为 '../scripts/validate.mjs';xhs/packages 已空。 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..cb1889db2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md @@ -0,0 +1,30 @@ +--- +name: vite-export-xhs-minitool +description: >- + vite 项目导出适配小红书小工具的制品 +metadata: + version: "1.6.0" +--- + +1. 新增 `scripts/vite.config.xhs-minitool.mjs` (示例)作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. + +2. 使用 `scripts/validate.mjs` 构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: + `references/manual-checks.md` + +3. 使用 `scripts/pack.mjs` 打包成zip + +* 如果使用了外部能力, 参考platform-abstract skill对项目先重构 + +以下是其他参考文档, 非必要不读 + +## Reference + +| 文档 | 何时读 | +|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| [js-api.md](references/js-api.md) | 本地 API 快照; | +| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写 HTML 文件类型、容器 CSP、`index.html` 要求 | +| [device-capabilities.md](references/device-capabilities.md) | 处理端能力时:哪些 Web 能力可用 / 不可用及替代写法、如何实现常见交互(手势、拍照、选图等) | +| [js-compatibility.md](references/js-compatibility.md) | 写 JS / 选择构建产物时:Android 8.1 出场 Chrome / WebView 61 最低基线、Web API 检测与局部降级 | +| [css-compatibility.md](references/css-compatibility.md) | 写 CSS / 选择构建产物时:Chrome 61 基线、能力检测、现代 CSS 增强与局部回退 | +| [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..2b19208c2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md @@ -0,0 +1,157 @@ +# CSS 兼容性规范 + +> 小工具 CSS 的最低兼容基线是 **Android 8.1 出场 Chrome / WebView 61**。这不等于只能写旧 CSS:交付物采用“Chrome 61 可用的基线层 + 能力检测后的增强层”,在新内核上使用更合适的 CSS 能力。 + +## 1. 基线原则 + +- 基线层保证布局、文字、按钮和核心交互在 Chrome 61 可用;增强层可以使用新 CSS 改善布局、视觉或交互。 +- 按功能点提供回退与增强,不维护两套完整页面或两份完整样式表,避免双份样式逐渐漂移。 +- 根据特性选择检测方法:能用声明级回退的按层叠覆盖,能准确查询的使用 `@supports`,语法检测无法证明实际布局行为时使用 JS 做最小行为检测。 +- 浏览器会静默丢弃无法解析的选择器、声明或整条 at-rule。不能像 JS 一样靠异常发现 CSS 不兼容,必须检查最终产物和实际布局。 +- 兼容性以最终 zip 内的 CSS 为准。源码使用了构建工具,不代表产物已经兼容。 + +## 2. Chrome 61 不可作为唯一实现的能力 + +以下能力晚于 Chrome 61,不能作为唯一实现。先提供基线路径,再用适合该能力的检测方式启用增强。 + +| 能力 | 基线写法 / 降级方式 | +|-------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| Flexbox `gap` / `row-gap` / `column-gap` | 基线用子项单边 `margin`;增强用 JS 实际测量 Flex 布局后切换 class,不能只检测 `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 Queries(`@container`) | 使用 viewport 媒体查询,或由 JS 按容器尺寸切换 class | +| Subgrid | 使用普通 Grid、Flex 或显式轨道尺寸 | +| CSS Nesting、Cascade Layers(`@layer`)、`@property` | 已有构建链能可靠展开时才在源码使用;最终产物不得保留为核心规则 | +| `dvh` / `svh` / `lvh` | 先用 `%` / `100vh`;受软键盘影响的全屏高度用 JS 维护 CSS 变量并保留静态兜底 | +| `color-mix()`、`oklab()`、`oklch()` 等现代颜色 | 先写 `#hex`、`rgb()`、`rgba()` 或 `hsl()` 颜色 | +| `backdrop-filter` | 先给不依赖模糊的实色 / 半透明背景;模糊仅作增强,并同时考虑 `-webkit-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` 形成基线;需要时通过行为检测启用 `gap` 并清除 margin。动态增删子项时确认首尾间距仍正确。 +- Grid 只使用基础显式 / 隐式轨道;间距使用 Chrome 61 可解析的 `grid-gap`。不要使用 Subgrid、Masonry 或依赖新语法的自动布局。 +- 需要隐藏文本时使用 `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. 基线层与增强层 + +### 声明回退和 `@supports` + +先写 Chrome 61 可用的完整基线,再覆盖增强: + +```css +.panel { + background: rgba(255, 255, 255, 0.96); +} + +@supports ((-webkit-backdrop-filter: blur(12px)) or (backdrop-filter: blur(12px))) { + .panel { + background: rgba(255, 255, 255, 0.72); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + } +} +``` + +同一属性使用“旧值在前、新值在后”的回退顺序: + +```css +.page { + min-height: 100vh; + min-height: var(--app-height, 100vh); +} +``` + +安全区须先保留普通值,再使用容器注入变量与 `env()`: + +```css +.bottom-bar { + padding-bottom: 0; + padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); +} +``` + +`@supports` 适合检测 `aspect-ratio`、`backdrop-filter`、动态视口单位等能由单个声明准确表达的能力。需要由 JS 切换 class 时,可以使用 `CSS.supports()` 检测同类声明。任何检测路径都必须保留基础样式。 + +### Flex gap 必须检测布局行为 + +`CSS.supports('gap', '1px')` 和 `@supports (gap: 1px)` 只能证明浏览器认识该属性和值,不能证明 `gap` 在 Flexbox 中生效;支持 Grid gap、但不支持 Flex gap 的内核也可能通过语法检测。需要启用 Flex gap 时,实际创建一次 Flex 容器并测量: + +```js +function supportsFlexGap() { + var flex = document.createElement('div'); + flex.style.position = 'absolute'; + flex.style.visibility = 'hidden'; + flex.style.display = 'flex'; + flex.style.flexDirection = 'column'; + flex.style.rowGap = '1px'; + flex.appendChild(document.createElement('div')); + flex.appendChild(document.createElement('div')); + document.body.appendChild(flex); + var supported = flex.scrollHeight === 1; + flex.parentNode.removeChild(flex); + return supported; +} + +if (supportsFlexGap()) { + document.documentElement.classList.add('supports-flex-gap'); +} +``` + +对应 CSS 只维护一套组件规则,其中 margin 是基线,gap 是增强: + +```css +.actions { + display: flex; +} + +.actions > * + * { + margin-left: 12px; +} + +.supports-flex-gap .actions { + column-gap: 12px; +} + +.supports-flex-gap .actions > * + * { + margin-left: 0; +} +``` + +行为检测放在包内经典脚本中,并在 `document.body` 存在后、核心页面渲染前执行。检测一次即可,不要在 resize 或渲染循环中反复测量。 + +不要用 UA、Android 版本或机型字符串决定样式能力。能力检测决定是否启用增强,Chrome 61 基线层始终保留。 + +## 5. 有构建链与无构建链 + +### 直接交付静态文件 + +没有现成构建链时,不为 CSS 兼容性临时引入 PostCSS、Autoprefixer 或新的 npm 依赖;直接按 Chrome 61 基线编写。 + +### 项目已有构建链 + +将目标浏览器至少设置为: + +```text +Chrome >= 61 +ios_saf >= 18.4 +``` + +- 压缩器也须使用相同浏览器目标,避免把兼容写法重新合并成 Chrome 61 无法解析的现代语法。 +- 交付前检查构建后的 CSS;zip 中只保留最终静态产物,不带 source map 和构建配置。 + +## 6. 视口、安全区与实测 + +- 页面宽度使用 `%`、Flex 或基础 Grid,不写死 `375px` 等单一机型宽度。 +- `100vh` 在移动端地址栏、容器高度变化和软键盘出现时可能不等于可视高度。必须跟随可视高度时,由 JS 监听尺寸变化并维护 `--app-height`,同时保留 `100vh` 回退。 +- 安全区规则须配合 `viewport-fit=cover`,具体组合见 [cross-platform-h5.md](./cross-platform-h5.md)。 +- 现代桌面浏览器或 PC 模拟器通过不等于 Chrome 61 通过。能够运行旧内核时,至少检查首屏、滚动区、弹层、表单、横竖屏 / 尺寸变化和核心交互;无法运行时在交付说明中标记“Chrome 61 CSS 兼容性未实测”。 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..a1ad3db4b --- /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 兼容性规范 + +> 小工具 JavaScript 的最低兼容基线是 **Android 8.1 出场 Chrome / WebView 61**。最终代码必须能在 Chrome 61 解析和运行;iOS 18.4+ 支持的额外能力只能用于能力检测后的增强路径。Chrome 61 完整支持 ES2017,最终代码以 ES2017 为构建目标。 + +## 1. 语法基线 + +最终 zip 中的 JS 须兼容 Chrome 61: + +- ES2018+ 语法须由构建链转译,例如对象 spread、异步迭代、可选链、空值合并、逻辑赋值、class 私有字段、static block、BigInt 字面量和 top-level await;更新的运行时 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 不应直接作为唯一实现路径。 +- 使用非基础 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..166a65992 --- /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 兼容 +- 使用非基础 Web API 前先做能力检测;不可用时隐藏增强功能、使用简单替代实现或给出清晰提示;能力检测基于对象 / 方法是否存在,不按 UA、机型或系统版本字符串分支。 + +## CSS 兼容 +- Chrome 61 基线层能够独立完成核心布局和交互,新内核通过能力检测启用增强层。 +- Flex gap 使用布局行为检测,或仅使用 margin 基线;未把 `@supports (gap: …)` / `CSS.supports('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..20537d139 --- /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,62 @@ +# 小工具 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 增强;只做功能点级回退,不维护两套完整 CSS,见 [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 要求 + +| 规则 | 原因 | +|-------------------------------------------------------------------------|----------------------------------| +| viewport 含 `width=device-width, initial-scale=1.0, viewport-fit=cover` | 真机 + 模拟器布局与安全区 | +| 不用 `<base href>` | 破坏真机路径 | +| 不引用任何外部资源(图片 / CSS / JS / 字体) | 外部资源加载不到,须全部打进 zip | +| 不自建 CSP `<meta>` | 安全策略由容器统一管理 | + +- `<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..2c7f51d8a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { auditZipFile } from './pack.mjs'; + +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'); +}); 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..cadd10726 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs @@ -0,0 +1,344 @@ +#!/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('modern CSS and runtime APIs are flagged as warnings', () => { + const root = join(BASE, 'modern_css'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'styles.css': '.a { aspect-ratio: 1; gap: 8px; color: oklch(0.5 0.1 20); }\n', + 'app.js': "list.replaceAll('a', 'b');\nObject.hasOwn({}, 'x');\n", + }); + const actual = codes(root); + assert.ok(actual.has('CSS_MODERN_FEATURE'), `missing CSS_MODERN_FEATURE; actual=${[...actual].sort()}`); + assert.ok(actual.has('MODERN_RUNTIME_API'), `missing MODERN_RUNTIME_API; actual=${[...actual].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, strict 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, '' + + '' + + ''); + assert.equal(runCli(warningRoot).status, 0); + assert.equal(runCli(warningRoot, '--strict').status, 2); + + 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/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs new file mode 100644 index 000000000..58f52c9a2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -0,0 +1,386 @@ +#!/usr/bin/env node +/** + * 整理小红书小工具最终提交包(纯 Node,不依赖 zip / zipinfo 系统命令): + * - 去掉构建残留与不允许的文件类型 + * - 校正 HTML 资源为相对路径、去掉 module 痕迹 + * - 将脚本移到 body 末尾并保持顺序 + * - 用 node:zlib 写出 zip(index.html 在 zip 根目录、不多套一层目录) + * - 再跑一遍静态校验 + */ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { deflateRawSync } from 'node:zlib'; + +import { runCli } from './validate.mjs'; + +const MIB = 1024 * 1024; +const ZIP_HARD_LIMIT = 10 * MIB; +const ZIP_RECOMMENDED = 2 * MIB; + +const ALLOWED = new Set([ + '.html', + '.css', + '.js', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + '.woff', + '.woff2', + '.json', +]); + +const FORBIDDEN_ENTRY = + /(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i; + +const USAGE = `用法:node pack.mjs [--out-dir ] [--zip ] [--skip-validate] [--no-strict] + +把 vite dist-xhs-minitool 产物整理成符合小红书规范、根目录带 index.html 的 zip。`; + +function walk(dir) { + const out = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + const st = statSync(path); + if (st.isDirectory()) out.push(...walk(path)); + else out.push(path); + } + return out; +} + +function ensureAllowedOnly(outDir) { + for (const file of walk(outDir)) { + const ext = extname(file).toLowerCase(); + if (!ALLOWED.has(ext)) { + rmSync(file, { force: true }); + console.log(`removed unsupported: ${relative(outDir, file)}`); + } + } +} + +function patchIndexHtml(outDir) { + const htmlPath = join(outDir, 'index.html'); + if (!existsSync(htmlPath)) { + throw new Error(`${relative(process.cwd(), htmlPath)} 不存在,请先执行 vite build`); + } + + let html = readFileSync(htmlPath, 'utf8'); + + html = html.replace(/(href|src)="\/([^"]+)"/g, '$1="./$2"'); + html = html.replace(/\s+crossorigin(?:="[^"]*")?/g, ''); + html = html.replace(/\s+type="module"/gi, ''); + html = html.replace(/]+rel="(?:manifest|modulepreload)"[^>]*>/gi, ''); + + const scripts = []; + html = html.replace( + /]*)?\ssrc="(\.\/[^"]+)"(?:\s[^>]*)?>\s*<\/script>\s*/gi, + (_m, src) => { + scripts.push(``); + 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); +} + +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 }); + } + } +} + +/* ------------------------------------------------------------------ */ +/* 纯 Node zip 写入(store / deflate,UTF-8 文件名,ZIP64 直接报错) */ +/* ------------------------------------------------------------------ */ + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let c = 0xffffffff; + for (let i = 0; i < buffer.length; i += 1) { + c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +/** 收集目录下所有条目(目录条目在前,文件名统一 / 分隔,保持排序以稳定产物)。 */ +function collectZipEntries(rootDir) { + const entries = []; + const visit = (dir) => { + for (const name of readdirSync(dir).sort()) { + const full = join(dir, name); + const st = statSync(full); + const nameInZip = relative(rootDir, full).split(sep).join('/'); + if (st.isDirectory()) { + entries.push({ name: `${nameInZip}/`, dir: true, full }); + visit(full); + } else { + entries.push({ name: nameInZip, dir: false, full }); + } + } + }; + visit(rootDir); + return entries; +} + +export function writeZip(rootDir, zipPath) { + const entries = collectZipEntries(rootDir); + const now = new Date(); + const dosTime = + ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; + const dosDate = + (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; + + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const entry of entries) { + const nameBuf = Buffer.from(entry.name, 'utf8'); + let data = Buffer.alloc(0); + let method = 0; + let crc = 0; + let uncompressed = 0; + + if (!entry.dir) { + const raw = readFileSync(entry.full); + uncompressed = raw.length; + const deflated = deflateRawSync(raw, { level: 9 }); + if (deflated.length < raw.length) { + data = deflated; + method = 8; + } else { + data = raw; + } + crc = crc32(raw); + } + + if (data.length > 0xffffffff || uncompressed > 0xffffffff || offset > 0xffffffff) { + throw new Error(`条目 ${entry.name} 超过 4 GiB,当前 Node zip 实现不支持 ZIP64`); + } + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(uncompressed, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, nameBuf, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(uncompressed, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(entry.dir ? 0x10 : 0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBuf); + + offset += local.length + nameBuf.length + data.length; + } + + const localBuf = Buffer.concat(localParts); + const centralBuf = Buffer.concat(centralParts); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(localBuf.length, 16); + eocd.writeUInt16LE(0, 20); + + rmSync(zipPath, { force: true }); + writeFileSync(zipPath, Buffer.concat([localBuf, centralBuf, eocd])); + return entries.map((entry) => entry.name); +} + +export function auditZipFile(file, size) { + const findings = []; + const name = basename(file); + if (size > ZIP_HARD_LIMIT) { + findings.push({ + severity: 'ERROR', code: 'ZIP_OVER_HARD_LIMIT', path: name, line: 1, + message: `最终 zip 约 ${(size / MIB).toFixed(2)} MiB,超过 10 MiB 上传上限。`, + }); + } else if (size > ZIP_RECOMMENDED) { + findings.push({ + severity: 'WARNING', code: 'ZIP_OVER_RECOMMENDED', path: name, line: 1, + message: `最终 zip 约 ${(size / MIB).toFixed(2)} MiB,超过 2 MiB 建议值。`, + }); + } + return findings; +} + +function makeZip(outDir, zipPath) { + const entries = writeZip(outDir, zipPath); + if (!entries.includes('index.html')) { + throw new Error('zip 根目录缺少 index.html(可能多套了一层目录)'); + } + const bad = entries.find((entry) => FORBIDDEN_ENTRY.test(entry)); + if (bad) { + throw new Error(`zip 内含禁止文件:${bad}`); + } + const size = statSync(zipPath).size; + for (const item of auditZipFile(zipPath, size)) { + if (item.severity === 'ERROR') throw new Error(item.message); + console.warn(`[warn] ${item.message}`); + } + console.log(`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB)`); +} + +function readPackageName(cwd) { + const pkgPath = join(cwd, 'package.json'); + if (!existsSync(pkgPath)) return 'minitool'; + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + return pkg.name || 'minitool'; + } catch { + return 'minitool'; + } +} + +/** + * @param {{ + * cwd?: string, + * outDir?: string, + * zipName?: string, + * publicDir?: string, + * skipValidate?: boolean, + * strict?: boolean, + * }} [options] + */ +export function packMinitool(options = {}) { + const cwd = resolve(options.cwd || process.cwd()); + const outDir = resolve(cwd, options.outDir || 'dist-xhs-minitool'); + const zipName = options.zipName || `${readPackageName(cwd)}-xhs-tool.zip`; + const zipPath = resolve(cwd, zipName); + const publicDir = resolve(cwd, options.publicDir || 'public'); + const strict = options.strict !== false; + + if (!existsSync(outDir)) { + throw new Error(`缺少输出目录 ${outDir}`); + } + + for (const icon of ['icon-192.svg', 'icon-512.svg']) { + 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); + } + } + + ensureAllowedOnly(outDir); + pruneEmptyDirs(outDir); + patchIndexHtml(outDir); + makeZip(outDir, zipPath); + + console.log(`\n小红书小工具包已整理:${outDir}`); + + if (options.skipValidate) return 0; + + const args = [outDir]; + if (strict) args.push('--strict'); + const code = runCli(args); + if (code !== 0) { + process.exitCode = code; + } + return code; +} + +export default packMinitool; + +function isMainModule() { + const entry = process.argv[1]; + if (!entry) return false; + try { + if (realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) return true; + } catch { + /* fall through to URL comparison */ + } + try { + return import.meta.url === pathToFileURL(entry).href; + } catch { + return false; + } +} + +if (isMainModule()) { + const raw = process.argv.slice(2); + const flags = new Set(raw.filter((arg) => arg.startsWith('-'))); + + if (flags.has('--help') || flags.has('-h')) { + console.log(USAGE); + process.exit(0); + } + + const getFlagValue = (name) => { + const idx = raw.indexOf(name); + return idx === -1 ? undefined : raw[idx + 1]; + }; + + try { + const code = packMinitool({ + outDir: getFlagValue('--out-dir'), + zipName: getFlagValue('--zip'), + skipValidate: flags.has('--skip-validate'), + strict: !flags.has('--no-strict'), + }); + process.exit(code ?? 0); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs new file mode 100755 index 000000000..039afc65f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs @@ -0,0 +1,1007 @@ +#!/usr/bin/env node +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +export const ALLOWED_EXTENSIONS = new Set([ + '.html', '.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.webp', + '.svg', '.woff', '.woff2', '.json', +]); +export const TEXT_EXTENSIONS = new Set(['.html', '.css', '.js', '.svg', '.json']); +export const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.cache']); + +const SEVERITY_RANK = { ERROR: 0, WARNING: 1, INFO: 2 }; +const MEDIA_ACCEPT = new Set([ + 'image/*', 'video/*', + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', + '.mp4', '.mov', '.m4v', '.webm', +]); +const NONNETWORK_NAMESPACE_URLS = new Set([ + 'http://www.w3.org/1999/xhtml', + 'http://www.w3.org/1999/xlink', + 'http://www.w3.org/2000/svg', +]); +const RESOURCE_ATTRS = ['src', 'srcset', 'href', 'poster', 'action', 'formaction']; + +const MIB = 1024 * 1024; +const KIB = 1024; +const TEXT_FILE_WARN = 2 * MIB; +const TEXT_TOTAL_WARN = 5 * MIB; +const BASE64_HARD_LIMIT = 1 * MIB; +const BASE64_WARN_LIMIT = 100 * KIB; +/** 体积门禁只统计 performance-budget 列出的文本类型。 */ +const BUDGET_TEXT_EXTENSIONS = new Set(['.html', '.css', '.js', '.json']); + +const EXTERNAL_URL_RE = /https?:\/\/[^\s'"<>)}]+|\/\/(?:[a-z0-9-]+\.)*[a-z0-9-]+[^\s'"<>)}]*/gi; +const DATA_OR_BLOB_RE = /^(?:data|blob):/i; +const LINE_EVENT_RE = /^on[a-z]+$/i; +const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/; +const CSS_URL_RE = /url\(\s*(['"]?)(.*?)\1\s*\)/gids; +const CSS_IMPORT_RE = /@import\s+(?:url\(\s*)?(['"]?)([^'"\s);]+)\1\s*\)?/gidm; + +const NAMED_ENTITIES = new Map([ + ['amp', '&'], ['lt', '<'], ['gt', '>'], ['quot', '"'], ['apos', "'"], ['nbsp', '\u00a0'], +]); +const ENTITY_RE = /&(?:#([0-9]+)|#x([0-9a-fA-F]+)|([a-zA-Z][a-zA-Z0-9]*));?/g; + +const CODE_PATTERNS = [ + ['ERROR', 'NETWORK_FETCH', '禁止 fetch 网络请求。', /\bfetch\s*\(/g], + ['ERROR', 'NETWORK_XHR', '禁止 XMLHttpRequest 网络请求。', /\bXMLHttpRequest\b/g], + ['ERROR', 'NETWORK_WEBSOCKET', '禁止 WebSocket。', /\bWebSocket\b/g], + ['ERROR', 'NETWORK_SSE', '禁止 EventSource / SSE。', /\bEventSource\b/g], + ['ERROR', 'NETWORK_WEBRTC', '禁止 WebRTC。', /\b(?:RTCPeerConnection|RTCDataChannel)\b/g], + ['ERROR', 'NETWORK_BEACON', '禁止 sendBeacon 网络请求。', /\bnavigator\s*\.\s*sendBeacon\s*\(/g], + ['ERROR', 'NETWORK_WEBTRANSPORT', '禁止 WebTransport。', /\bWebTransport\s*\(/g], + ['ERROR', 'DYNAMIC_EVAL', '禁止 eval 动态执行代码。', /(? 引入。'); + } + for (const [pattern, label] of ES2018_SYNTAX_PATTERNS) { + for (const match of text.matchAll(pattern)) { + collector.add('ERROR', 'ES2018_PLUS_SYNTAX', path, lineAt(match.index), + `最终产物出现晚于 ES2017 的语法(${label});语法不兼容会在解析阶段直接失败,必须由构建链转译到 es2017 / chrome61。`); + } + } +} + +/** 单条内置 Base64 体积门禁:解码后 >1 MiB 为 ERROR,>100 KiB 为 WARNING。 */ +export function scanBase64(path, text, collector) { + const lineAt = createLineCounter(text); + for (const match of text.matchAll(BASE64_RE)) { + const payload = match[1]; + const padding = (payload.match(/=+$/) || [''])[0].length; + const bytes = Math.max(0, Math.floor((payload.length * 3) / 4) - padding); + const sizeText = bytes >= KIB ? `${(bytes / KIB).toFixed(1)} KiB` : `${bytes} B`; + if (bytes > BASE64_HARD_LIMIT) { + collector.add('ERROR', 'BASE64_OVER_LIMIT', path, lineAt(match.index), + `单条内置 Base64 解码后约 ${sizeText},超过 1 MiB 上限;必须改为包内独立文件。`); + } else if (bytes > BASE64_WARN_LIMIT) { + collector.add('WARNING', 'BASE64_LARGE', path, lineAt(match.index), + `单条内置 Base64 解码后约 ${sizeText},超过 100 KiB;优先改为包内独立文件。`); + } + } +} + +const USAGE = `用法:validate.mjs [project] [--json] [--strict] + +扫描小红书小工具项目的容器兼容性问题。 + [project] 产物目录,默认为 dist-xhs-minitool + --json 以 JSON 输出结果 + --strict 存在 WARNING 时也返回非零状态`; + +/* ------------------------------------------------------------------ */ +/* 工具函数 */ +/* ------------------------------------------------------------------ */ + +/** 返回一个把字符偏移量映射为 1 起始行号的函数。 */ +export function createLineCounter(text) { + const starts = [0]; + for (let i = 0; i < text.length; i += 1) { + if (text.charCodeAt(i) === 10) starts.push(i + 1); + } + return (offset) => { + let low = 0; + let high = starts.length - 1; + let found = 0; + while (low <= high) { + const mid = (low + high) >> 1; + if (starts[mid] <= offset) { + found = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return found + 1; + }; +} + +export function lineForOffset(text, offset) { + return createLineCounter(text)(offset); +} + +/** 近似 Python urllib.parse.urlsplit 的拆分结果(仅需 scheme/netloc/path)。 */ +export function splitUrl(value) { + let rest = value; + let scheme = ''; + const schemeMatch = SCHEME_RE.exec(value); + if (schemeMatch) { + scheme = schemeMatch[1].toLowerCase(); + rest = value.slice(schemeMatch[0].length); + } + let netloc = ''; + if (rest.startsWith('//')) { + const after = rest.slice(2); + const end = after.search(/[/?#]/); + if (end === -1) { + netloc = after; + rest = ''; + } else { + netloc = after.slice(0, end); + rest = after.slice(end); + } + } + const query = rest.search(/[?#]/); + const path = query === -1 ? rest : rest.slice(0, query); + return { scheme, netloc, path }; +} + +/** 近似 Python urllib.parse.unquote:容错处理非法百分号转义。 */ +export function unquote(value) { + try { + return decodeURIComponent(value); + } catch { + return value.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => + String.fromCharCode(Number.parseInt(hex, 16))); + } +} + +/** 近似 Python html.unescape 的基础实体解码(数字 + 常见命名实体)。 */ +export function decodeEntities(value) { + return value.replace(ENTITY_RE, (match, dec, hex, name) => { + if (dec !== undefined) { + const cp = Number(dec); + return cp <= 0x10ffff ? String.fromCodePoint(cp) : match; + } + if (hex !== undefined) { + const cp = Number.parseInt(hex, 16); + return cp <= 0x10ffff ? String.fromCodePoint(cp) : match; + } + return NAMED_ENTITIES.has(name) ? NAMED_ENTITIES.get(name) : match; + }); +} + +/** 近似 Python pathlib.Path.relative_to 的展示路径;越界时返回绝对路径。 */ +function displayPath(root, target) { + const rel = relative(root, target); + if (rel === '') return '.'; + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return target; + return rel; +} + +function isInside(root, target) { + const rel = relative(root, target); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function isRegularFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function expandHome(value) { + if (value === '~' || value.startsWith('~/') || value.startsWith('~\\')) { + const home = process.env.HOME || process.env.USERPROFILE || ''; + if (home) return join(home, value.slice(2)); + } + return value; +} + +/** 读取 UTF-8 文本;非法字节返回 null(对应原 Python TEXT_DECODE)。 */ +export function readText(path) { + try { + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(readFileSync(path)); + } catch { + return null; + } +} + +/* ------------------------------------------------------------------ */ +/* 结果收集 */ +/* ------------------------------------------------------------------ */ + +export class Collector { + constructor(root) { + this.root = resolve(root); + this.findings = []; + this._seen = new Set(); + } + + add(severity, code, targetPath, line, message) { + const path = displayPath(this.root, targetPath); + const item = { severity, code, path, line: Math.max(1, line), message }; + const key = [item.severity, item.code, item.path, item.line, item.message].join('\u0000'); + if (!this._seen.has(key)) { + this._seen.add(key); + this.findings.push(item); + } + } +} + +/* ------------------------------------------------------------------ */ +/* 本地引用与 CSS 扫描 */ +/* ------------------------------------------------------------------ */ + +export function checkLocalReference(value, source, collector, line, context) { + const parsed = splitUrl(value); + if (parsed.scheme) { + collector.add('ERROR', 'NONLOCAL_RESOURCE_SCHEME', source, line, + `${context} 使用了非本地 URL scheme:${parsed.scheme}:。`); + return; + } + if (parsed.netloc) { + collector.add('ERROR', 'EXTERNAL_RESOURCE', source, line, + `${context} 指向外部主机://${parsed.netloc}。`); + return; + } + if (!parsed.path) { + collector.add('ERROR', 'EMPTY_RESOURCE_PATH', source, line, + `${context} 没有可打包的本地资源路径:${value}`); + return; + } + if (parsed.path.startsWith('/')) { + collector.add('ERROR', 'ABSOLUTE_RESOURCE_PATH', source, line, + `${context} 必须使用包内相对路径,不能使用根路径:${value}`); + return; + } + + const target = resolve(dirname(source), unquote(parsed.path)); + if (!isInside(collector.root, target)) { + collector.add('ERROR', 'RESOURCE_OUTSIDE_ROOT', source, line, + `${context} 解析到项目目录之外:${value}`); + return; + } + + if (!isRegularFile(target)) { + collector.add('ERROR', 'MISSING_LOCAL_RESOURCE', source, line, `本地资源不存在:${value}`); + } +} + +export function scanCssReferences(path, text, collector, baseLine = 1) { + const lineAt = createLineCounter(text); + + for (const match of text.matchAll(CSS_IMPORT_RE)) { + const start = match.indices[2][0]; + const value = match[2].trim(); + const line = baseLine + lineAt(start) - 1; + if (DATA_OR_BLOB_RE.test(value)) { + collector.add('ERROR', 'CSS_IMPORT_DATA_BLOB', path, line, + '@import 不应使用 data:/blob:;将样式保存为包内 .css 文件。'); + } else { + checkLocalReference(value, path, collector, line, 'CSS @import'); + } + } + + for (const match of text.matchAll(CSS_URL_RE)) { + const start = match.indices[2][0]; + const value = match[2].trim(); + const line = baseLine + lineAt(start) - 1; + if (!value || value.startsWith('#')) continue; + if (DATA_OR_BLOB_RE.test(value)) { + collector.add('WARNING', 'CSS_DATA_BLOB_VERSION', path, line, + 'data:/blob: 图片存在 9.37 之前版本兼容性;确认是否需要 Canvas 替代方案。'); + continue; + } + checkLocalReference(value, path, collector, line, 'CSS url()'); + } + + for (const [pattern, label] of CSS_MODERN_PATTERNS) { + for (const match of text.matchAll(pattern)) { + collector.add('WARNING', 'CSS_MODERN_FEATURE', path, baseLine + lineAt(match.index) - 1, + `使用了晚于 Chrome 61 的 CSS 能力(${label});需保留基线写法并用能力检测启用增强。`); + } + } +} + +/* ------------------------------------------------------------------ */ +/* 代码扫描 */ +/* ------------------------------------------------------------------ */ + +export function scanCode(path, text, collector) { + const lineAt = createLineCounter(text); + + for (const [severity, code, message, pattern] of CODE_PATTERNS) { + for (const match of text.matchAll(pattern)) { + collector.add(severity, code, path, lineAt(match.index), message); + } + } + + for (const match of text.matchAll(EXTERNAL_URL_RE)) { + const value = match[0]; + if (NONNETWORK_NAMESPACE_URLS.has(value)) continue; + collector.add('ERROR', 'EXTERNAL_URL', path, lineAt(match.index), + `检测到外部 URL:${value}。所有资源和数据必须本地打包。`); + } + + if (extname(path).toLowerCase() === '.js') { + scanJsSyntax(path, text, collector); + } + + scanBase64(path, text, collector); + + if (extname(path).toLowerCase() === '.css') { + scanCssReferences(path, text, collector); + } +} + +/* ------------------------------------------------------------------ */ +/* HTML 解析 */ +/* ------------------------------------------------------------------ */ + +function isSpace(ch) { + return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v'; +} + +function parseTagAttributes(text, start) { + const attrs = []; + let pos = start; + let selfClosing = false; + let end = text.length; + + while (pos < text.length) { + while (pos < text.length && isSpace(text[pos])) pos += 1; + if (pos >= text.length) break; + if (text[pos] === '>') { + end = pos + 1; + break; + } + if (text[pos] === '/' && text[pos + 1] === '>') { + selfClosing = true; + end = pos + 2; + break; + } + if (text[pos] === '/') { + pos += 1; + continue; + } + + const nameStart = pos; + while (pos < text.length && !isSpace(text[pos]) && text[pos] !== '=' && + text[pos] !== '>' && text[pos] !== '/') { + pos += 1; + } + const name = text.slice(nameStart, pos).toLowerCase(); + if (!name) { + pos += 1; + continue; + } + + const afterName = pos; + while (pos < text.length && isSpace(text[pos])) pos += 1; + if (text[pos] === '=') { + pos += 1; + while (pos < text.length && isSpace(text[pos])) pos += 1; + const quote = text[pos]; + if (quote === '"' || quote === "'") { + pos += 1; + const valueStart = pos; + while (pos < text.length && text[pos] !== quote) pos += 1; + attrs.push([name, decodeEntities(text.slice(valueStart, pos))]); + if (pos < text.length) pos += 1; + } else { + const valueStart = pos; + while (pos < text.length && !isSpace(text[pos]) && text[pos] !== '>') pos += 1; + attrs.push([name, decodeEntities(text.slice(valueStart, pos))]); + } + } else { + pos = afterName; + attrs.push([name, '']); + } + } + + return { attrs, end, selfClosing }; +} + +export class XhsHtmlParser { + constructor(path, collector) { + this.path = path; + this.collector = collector; + this.scriptStack = []; + this.styleStack = []; + this.hasCharset = false; + this.hasViewport = false; + } + + handleStartTag(tag, attrs, line) { + this._handleTag(tag, attrs, line); + } + + handleEndTag(tag, _line) { + if (tag === 'script' && this.scriptStack.length) { + const context = this.scriptStack.pop(); + if (context.chunks.join('').trim()) { + this.collector.add('ERROR', 'INLINE_SCRIPT', this.path, context.line, + '禁止内联 '; + +/** 造一个最小的 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', + skipValidate: true, + }), + /没有删除任何文件/, + ); + 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('干净产物目录仍能正常打包', () => { + writeBuiltDir('clean'); + const zipOut = join(BASE, 'clean-out', 'xhs-minitool.zip'); + const code = packMinitool({ + cwd: BASE, + viteBuiltDir: 'clean', + zipOut, + skipValidate: true, + }); + assert.equal(code, 0); + assert.ok(existsSync(zipOut), 'zip 未生成'); +}); + +test('CLI 拒绝旧名 --out-dir,不再静默退回默认目录', () => { + const dir = writeBuiltDir('cli'); + const result = spawnSync( + process.execPath, + [PACK, '--vite-built-dir', dir, '--out-dir', dir, '--skip-validate'], + { cwd: BASE, encoding: 'utf8' }, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /--vite-built-dir/); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 91e4e5989..80d3f0e51 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -18,65 +18,51 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { deflateRawSync } from 'node:zlib'; -import { runCli } from './validate.mjs'; +import { findUnsupportedFiles, runCli } from './validate.mjs'; const MIB = 1024 * 1024; const ZIP_HARD_LIMIT = 10 * MIB; const ZIP_RECOMMENDED = 2 * MIB; -const ALLOWED = new Set([ - '.html', - '.css', - '.js', - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.svg', - '.woff', - '.woff2', - '.json', -]); - const FORBIDDEN_ENTRY = /(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i; -const USAGE = `用法:node pack.mjs [--out-dir ] [--zip ] [--zip-out ] [--skip-validate] [--no-strict] +const USAGE = `用法:node pack.mjs [--vite-built-dir ] [--zip ] [--zip-out ] [--skip-validate] [--no-strict] -把 vite dist-xhs-minitool 产物整理成符合小红书规范、根目录带 index.html 的 zip。 +把 vite 构建产物整理成符合小红书规范、根目录带 index.html 的 zip。 +--vite-built-dir vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 dist-xhs-minitool。 + 该目录出现白名单之外的文件类型时直接报错退出并列出文件,不会删除任何文件。 --zip 只给文件名时用:写到当前工作目录下。 --zip-out 需要指定落点(例如 AGC 导出面板要求的 .export/xhs-minitool.zip)时用: 相对路径按当前工作目录解析,父目录不存在会自动建。`; -function walk(dir) { - const out = []; - for (const name of readdirSync(dir)) { - const path = join(dir, name); - const st = statSync(path); - if (st.isDirectory()) out.push(...walk(path)); - else out.push(path); - } - return out; +/** + * 打包前预检:产物目录里出现小红书白名单之外的文件类型时整体失败,列出路径但**不改磁盘**。 + * + * 旧实现 `ensureAllowedOnly` 会就地删除这些文件;一旦 `--vite-built-dir` 指到源码目录, + * 就会静默删掉用户的 `.ts` / `.vue` / `.scss` 等源码。现在改为在改动产物之前拦下并报错, + * 删除与否由调用方按提示决定。 + */ +function assertNoUnsupportedFiles(viteBuiltDir) { + const unsupported = findUnsupportedFiles(viteBuiltDir); + if (unsupported.length === 0) return; + const list = unsupported + .map((item) => ` - ${item.path}${item.ext ? ` (${item.ext})` : ' (无扩展名)'}`) + .join('\n'); + throw new Error( + `vite 构建产物目录里有 ${unsupported.length} 个小红书不支持的文件;为避免误删,脚本没有删除任何文件:\n` + + `${list}\n` + + '请在 vite 构建里去来源(如 sourcemap: false),或把 --vite-built-dir 指向真正的构建输出目录。', + ); } -function ensureAllowedOnly(outDir) { - for (const file of walk(outDir)) { - const ext = extname(file).toLowerCase(); - if (!ALLOWED.has(ext)) { - rmSync(file, { force: true }); - console.log(`removed unsupported: ${relative(outDir, file)}`); - } - } -} - -function patchIndexHtml(outDir) { - const htmlPath = join(outDir, 'index.html'); +function patchIndexHtml(viteBuiltDir) { + const htmlPath = join(viteBuiltDir, 'index.html'); if (!existsSync(htmlPath)) { throw new Error(`${relative(process.cwd(), htmlPath)} 不存在,请先执行 vite build`); } @@ -267,8 +253,8 @@ export function auditZipFile(file, size) { return findings; } -function makeZip(outDir, zipPath) { - const entries = writeZip(outDir, zipPath); +function makeZip(viteBuiltDir, zipPath) { + const entries = writeZip(viteBuiltDir, zipPath); if (!entries.includes('index.html')) { throw new Error('zip 根目录缺少 index.html(可能多套了一层目录)'); } @@ -298,7 +284,7 @@ function readPackageName(cwd) { /** * @param {{ * cwd?: string, - * outDir?: string, + * viteBuiltDir?: string, * zipName?: string, * zipOut?: string, * publicDir?: string, @@ -308,7 +294,7 @@ function readPackageName(cwd) { */ export function packMinitool(options = {}) { const cwd = resolve(options.cwd || process.cwd()); - const outDir = resolve(cwd, options.outDir || 'dist-xhs-minitool'); + const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool'); const zipName = options.zipName || `${readPackageName(cwd)}-xhs-tool.zip`; // `zipOut` 优先:调用方(如 .export/ 下的项目拷贝)要的是确定的落点,而不是"当前目录下叫什么名字"。 const zipPath = options.zipOut @@ -317,14 +303,17 @@ export function packMinitool(options = {}) { const publicDir = resolve(cwd, options.publicDir || 'public'); const strict = options.strict !== false; - if (!existsSync(outDir)) { - throw new Error(`缺少输出目录 ${outDir}`); + if (!existsSync(viteBuiltDir)) { + throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); } + // 先预检、后改动:白名单外的文件直接报错,不删除。指错目录时也不会误删源码。 + assertNoUnsupportedFiles(viteBuiltDir); + // 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。 mkdirSync(dirname(zipPath), { recursive: true }); for (const icon of ['icon-192.svg', 'icon-512.svg']) { - const dest = join(outDir, 'icons', icon); + const dest = join(viteBuiltDir, 'icons', icon); const src = join(publicDir, 'icons', icon); if (!existsSync(dest) && existsSync(src)) { mkdirSync(dirname(dest), { recursive: true }); @@ -332,16 +321,15 @@ export function packMinitool(options = {}) { } } - ensureAllowedOnly(outDir); - pruneEmptyDirs(outDir); - patchIndexHtml(outDir); - makeZip(outDir, zipPath); + pruneEmptyDirs(viteBuiltDir); + patchIndexHtml(viteBuiltDir); + makeZip(viteBuiltDir, zipPath); - console.log(`\n小红书小工具包已整理:${outDir}`); + console.log(`\n小红书小工具包已整理:${viteBuiltDir}`); if (options.skipValidate) return 0; - const args = [outDir]; + const args = [viteBuiltDir]; if (strict) args.push('--strict'); const code = runCli(args); if (code !== 0) { @@ -376,6 +364,12 @@ if (isMainModule()) { process.exit(0); } + // 旧名不能静默退回默认目录:否则适配脚本里的 --out-dir 会被忽略,产物落到别处还查不出来。 + if (flags.has('--out-dir')) { + console.error('--out-dir 已更名为 --vite-built-dir,请改用新名字(`node pack.mjs --help`)。'); + process.exit(1); + } + const getFlagValue = (name) => { const idx = raw.indexOf(name); return idx === -1 ? undefined : raw[idx + 1]; @@ -383,7 +377,7 @@ if (isMainModule()) { try { const code = packMinitool({ - outDir: getFlagValue('--out-dir'), + viteBuiltDir: getFlagValue('--vite-built-dir'), zipName: getFlagValue('--zip'), zipOut: getFlagValue('--zip-out'), skipValidate: flags.has('--skip-validate'), diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs index 039afc65f..dcd50f979 100755 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs @@ -829,6 +829,28 @@ export function iterProjectFiles(root, collector) { return walkDirectory(resolve(root), collector); } +/** + * 预检:只挑出产物目录里白名单之外的文件类型,**不修改磁盘**。 + * + * 打包脚本会在改动产物之前先调用它:发现不支持的文件就整体失败并列出相对路径, + * 而不是像旧实现那样就地删除(指向源码目录时会静默删掉用户代码)。 + * 完整的静态检查仍由 `validate()` 在打包后负责。 + * + * @param {string} root + * @returns {{ path: string, ext: string }[]} path 为相对 root 的展示路径 + */ +export function findUnsupportedFiles(root) { + const collector = new Collector(root); + const unsupported = []; + for (const path of iterProjectFiles(collector.root, collector)) { + const ext = extname(path).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + unsupported.push({ path: displayPath(collector.root, path), ext }); + } + } + return unsupported; +} + export function validate(root) { const collector = new Collector(root); const htmlFiles = []; diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs index a848ec431..85cbce8b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs @@ -8,8 +8,8 @@ * import { defineMinitoolConfig } from '<此文件路径>'; * export default defineMinitoolConfig({ plugins: [...] }); * - * 下面 build.outDir 的值与打包脚本的 --out-dir 是同一个目录,两处必须一致: - * pack.mjs 会就地删掉该目录里白名单之外的扩展名文件,配错会删掉源码。 + * 下面 build.outDir 的值与打包脚本的 --vite-built-dir 是同一个目录,两处必须一致: + * pack.mjs 会在打包前预检该目录;出现白名单之外的文件类型会直接报错,不会删除任何文件。 * * 约束来源:minitool-zip-builder/references/js-compatibility.md、css-compatibility.md、 * zip-artifact-spec.md —— Chrome 61 / ES2017 基线、相对路径、IIFE 单入口、无 sourcemap。 From af19af0a2fb02ecc9ac4511967ebc1f871e83d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 14:32:16 +0800 Subject: [PATCH 048/156] =?UTF-8?q?=E6=B8=85=E6=8E=89=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E7=9B=B8=E5=85=B3=E7=9A=84=E8=BF=87=E6=97=B6?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删两份 ADR:`AGC命令错误结构化与错误报告口径`、`AGC认证失败的JS侧载体与抛出时机`;错误通道的现行口径只留在代码与该专题技术方案里; - 删导出面板的【实施计划】与【里程碑】两份计划,落地情况回到技术方案里一句话; - `docs/README.md` 去掉对上述四份的链接,技术方案里的失败通道段落跟着删; - `decision-log` 去掉 2026-10-01 那一段,2026-10-05 那段的标题与四条决策改成「同步细节与文案归属」;`pitfalls` 去掉两份 ADR 的引用。 --- docs/README.md | 4 +- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 125 -------------- ...GC认证失败的JS侧载体与抛出时机-2026-10-01.md | 158 ------------------ ...’】导出产物面板与小红书小工具导出-2026-10-05.md | 75 --------- ...】导出产物面板与小红书小工具导出-2026-10-05.md | 60 ------- .../shared-memory/decision-log.md | 27 +-- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 +- 9 files changed, 9 insertions(+), 449 deletions(-) delete mode 100644 docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md delete mode 100644 docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md delete mode 100644 docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md delete mode 100644 docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md diff --git a/docs/README.md b/docs/README.md index e0f65cc60..36aa06344 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ ## AI 游戏创作与 Agent Runtime -- [导出产物面板与小红书小工具导出](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-10-05-导出产物面板与小红书小工具导出)、[里程碑](./project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md)与[实施计划](./project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md):宿主只校验并运行项目内 `build:xhs-minitool`,适配由 code agent 首次实验固化;`.export/` flat 工作目录、内容 hash 冲突逐字段选择;失败按 typed 变体分流(策略拒绝留面板、宿主故障与未分类拒绝原样抛出进错误池)。已实现并通过本地定向验证,真实 vite 项目上的首轮适配与二次导出待运行时验收。 +- [导出产物面板与小红书小工具导出](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-10-05-导出产物面板与小红书小工具导出):宿主只校验并运行项目内 `build:xhs-minitool`,适配由 code agent 首次实验固化;`.export/` flat 工作目录、内容 hash 冲突逐字段选择。已实现并通过本地定向验证,真实 vite 项目上的首轮适配与二次导出待运行时验收。 - [客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md):本地 12 类事件采集、每 15 分钟上传、私有事件表、确认后清理与后台明细查询已完成隔离环境验收;不扩充采集范围、不做加密,未部署生产。配置要求及验证边界见第 13 节。 @@ -59,8 +59,6 @@ - [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 -- [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 -- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型(可枚举细分再 `switch (payload.reason)` 在类型化枚举上分流),系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 - [DirectProject 对话滚动与历史自动加载](./adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md):删掉「显示更早的对话」按钮改为自动加载 + 内联加载/错误行,回合 key 冻结锚点前插不跳,底部居中「回到底部 / 有新回复」胶囊,折叠展开按跟随状态贴底或保锚点。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md deleted file mode 100644 index 83fa95574..000000000 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ /dev/null @@ -1,125 +0,0 @@ -# 【ADR】AGC 命令错误结构化与错误报告口径 - -状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与 -[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)) - -## 背景 - -用户在登录页把密码输错一次,报告面板就出现两条"错误事件"并弹出「发现问题」: - -```text -登录失败:密码长度需要在 6 到 128 位之间 — auth · 1 次 -手机号或密码错误 — auth · 1 次 -``` - -根因不是文案,而是两件事叠加: - -1. 登录已经下沉到 Rust(`login_client_with_password` 等命令),命令失败以 `Err(String)` 返回; - Tauri 把 `String` 原样交给 JS,`invoke` 以**裸字符串**拒绝,前端拿到的东西没有任何类型信息。 -2. WebView 侧的 `shouldCaptureClientError` 对"非 object"值走默认 `return true`,于是 - `handleLoginSubmit` 的 catch 把"用户输错密码"当成缺陷事件报进了错误池。 - -这与 [`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md) -已写明的"预期的 4xx 登录/鉴权失败不进入错误报告池"直接冲突——口径早就定了,是错误通道的实现方式违背了它。 - -DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) §4): -命令返回结构化 typed error,前端按变体分流,"认不得的变体或非结构化错误"原样抛出走上报链路。 -本 ADR 把这套口径推广成 AGC 命令错误的通用约定,并同时收窄错误报告池的入口。 - -## 决策 - -### 1. 命令错误按具体变体建模,不按文案匹配 - -- Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` + - `#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到 - `apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。 -- **顶层只放调用方要分流的类别,可枚举的细分原因收进类型化 `reason` 字段**——既不拆成几十个顶层变体, - 也不用字符串。`reason` 自己也是 `#[derive(Serialize, TS)]` 的枚举。internally tagged 下无字段变体是 - `{ type: 'x' }`;newtype 变体是 `{ type: 'x' } & X`,ts-rs 为它生成 `generated/X.ts`。例如服务地址 - 校验是 `serverAddressRejected` + `ServerAddressReason`(`emptyOrTooLong` / `notAUrl` / `hasCredentials` / - `hasPathOrQueryOrFragment` / `notHttps` / `unsupportedScheme` / `outsideChannel`),网络失败是 - `authNetworkFailure` + `AuthNetworkReason`(`timeout` / `unreachable`),响应契约破损是 - `authResponseInvalid` + `AuthResponseInvalidReason`(`notJson` / `invalidBody` / `missingRefreshCookie` / - `missingUserIdentity` / `serverRejected`),而不是拆成 7 + 2 + 5 个顶层变体。 -- 前端 `switch (error.type)` 必须列全顶层变体:无字段变体直接取本 catch 的固定文案;带载荷变体先 `as` - 取自己的具名载荷类型,可枚举的细分再 `switch (payload.reason)` 在**类型化**的 `reason` 上分流(等价于 - Java 的嵌套 `switch`,仍不碰文案)。顶层与 `reason` 的 `default` 都用 `expectNever`,漏接变体或漏接 - `reason` 都是**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。 -- Rust **不预拼用户可见文案**:载荷只装原始事实(类型化的 `reason`、服务端 400 的原文 `serverMessage`、 - HTTP 状态码、本机 IO / 网络客户端构建失败的原始 `detail`),服务端没给原文就是 `None`;前缀与句式由 - 前端调用方在自己的 catch 分支按当前操作拼接。**原始错误必须留在载荷里**(调用方据此分流,报告包据此 - 诊断),同时另记一行本地日志;但 `detail` **不直接贴在界面上**:`clientSessionPersistFailed` / - `runtimeSessionInstallFailed` / `authClientInitFailed` 三个 catch 用本操作的固定文案,路径等敏感片段 - 由报告侧的 sanitize 换成占位符。 -- `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 - 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), EnqueueError>`),不是新约定。 - DirectProject 的三条通道固定为三张类型表:入队拒绝 `EnqueueError`、宿主内部回合错误 `TurnError` - (不导出、不跨进程)、`turn.completed.failure` 载荷 `TurnFailure`;类型随所属深模块命名 - (`agent/codex_app_server/turn_error.rs`、`agent/thread_manager/wire/failure.rs`),不再带 `Direct` 前缀; - 回合终态判定 `TurnCompletion` 是宿主内部判别联合(`agent/thread_manager/turn_completion.rs`),不导出、不进 `wire/`。 - `TurnFailure` 里仍带字符串的两臂专门标成"待清的债":`SuperErrorFromStringPlusStage`(`stage` 是 typed 枚举、 - `detail` 仍是产生层字符串)与 `Unclassified`(连阶段都没有),名字故意起丑,`detail` typed 化后即改名; - `TurnError::classify`(返回 `TurnErrorClassified{ShouldStop, ShouldContinue}`)是**唯一**投影点,任何地方都不许再把 typed 失败重包成"阶段失败 + 预拼文案"。 -- 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 - `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`), - 不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。 -- `429` 同样按路由判定:发码路由是频控(`smsCodeThrottled`),登录路由是「验证码错误次数过多」 - (复用 `phoneCodeLoginRejected`,仍是用户可修正的输入问题、不进池),其余路由的 `429` 才落到 - `unexpectedRejection`。 -- `/api/auth/phone/login` 的 `401` 只来自「用户不存在」(验证码错误/失效/过期都是 `400`),同样归 - `phoneCodeLoginRejected`;为此退役的 `smsCodeRejected` 曾把 401 冒充成「验证码错误或过期」,属于错配。 -- 本地前置校验只做布尔判定、拿不到更细事实时不编字段:手机号校验 `phone_is_valid` 只回 true/false, - 所以 `phoneNumberInvalid` 保持无字段,提示由调用方给固定文案;编不出事实的"细分原因"不硬造。 - -### 2. 报告池只收"没有任何调用方处理"的错误 - -谁抛出、谁判定。分层规则: - -- **预期业务拒绝**(用户输入、前置条件、预期 4xx):由调用方消化并给用户反馈,**永不进池**。 -- **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文交给错误池 - (`ClientAuthErrorWrapper` + `captureClientError`);`window.onerror` / `unhandledrejection` 只兜底 - 没人接手的错误。Rust 侧 agent 终态失败仍由失败投影入池。 -- **WebView 全局 handler 是兜底**:任何没人 catch 的错误都进池。 -- **408/5xx/网络的判定由调用方在 catch 里做**:AGC shell 的 WebView 侧没有 fetch 边界的自动判定 - (`shouldCaptureClientError` 只认测试构造过、生产代码从不产生的 `{status}` / `{networkError}` - 形状,随本 ADR 删除);4xx 一律不报,交给上层调用方。 - -### 3. 前端按变体分流(2026-10-01 修订) - -- 本节原先的"调用方用 `isClientAuthError` 读形状、`switch (error.type)` 分流、`default` 交池"已被 - [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](./【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md) - 取代:`invokeClientAuth` 把结构化拒绝装进 `ClientAuthErrorWrapper`(`error` 字段是判别联合),判定只写在 - catch 子句里,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as` 取具名载荷类型。 -- "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(failure)`,漏接变体是 - **编译错误**,不再是运行时报进池。 -- 系统变体与非结构化拒绝仍由调用方原样 `throw`,经全局 `unhandledrejection` 入池; - `captureClientError` 的 `instanceof ClientAuthErrorWrapper` 解包保持不变。 -- 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。 - -### 4. 报告面板与通知行为不变 - -默认选中快照中的全部事件、只由通知中的「查看并报告」打开、poisoned 快照用 fallback 等承诺保持不变; -本次只保证"不该进池的东西不再进池"。 - -## 后果与边界 - -- auth 三命令(`login_client_with_password`、`login_client_with_phone_code`、`send_client_phone_login_code`) - 及其共用链路(`request_auth` / `map_auth_failure` / `response_data` / `network_error_message`)全量改为 - `Result<_, ClientAuthError>`;`read_client_auth_state`、`refresh_client_auth_session`、 - `logout_client_session` 的失败面同步结构化(会话 401/403 仍是"未登录"路径,不是错误)。 -- 未识别变体不再靠运行时"上调"兜底:前端 switch 必须列全变体,靠 `expectNever` 在编译期挡住漏接。 -- 仍保留的显式采集点(`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic`) - 在后续变更里按同一口径重抛/删除,本 ADR 不改它们的行为。 - -## 验收 - -```text -npm run ai-game-creator-shell:typecheck -npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx -cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings -npm run ai-game-creator-shell:check:rust:shell -npm run check:generated-bindings -npm run check:doc-index -npm run check:encoding -git diff --check -``` diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md deleted file mode 100644 index 7a8a7aa85..000000000 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ /dev/null @@ -1,158 +0,0 @@ -# 【ADR】AGC 认证失败的 JS 侧载体与抛出时机 - -状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与 -[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)) - -前置:[`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](./【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) -已经把 Rust 侧的认证命令失败建模成 `ClientAuthError`,ts-rs 生成 -`{ type: 'x' } & X` 的判别联合。本 ADR 只解决这份结构化拒绝到了 WebView 之后 -"怎么传、谁来判、什么时候抛"。 - -## 背景 - -Rust 已经返回结构化错误,但 Tauri 的 `invoke` 拒绝值是**普通对象**,不是 `Error`: - -- 调用方 `catch (error)` 拿到的是 `{ type, reason, ... }`,没有栈。原样 `throw` 它, - 上报链路的 `error instanceof Error` 判断会把它降级成 `new Error(String(error))` - (`[object Object]`),文案与类型一起丢掉。 -- 前一版在渲染层加了 `isClientAuthError` / `getClientAuthErrorMessage` / - `presentAuthFailure` 三层:形状读取、文案回落、分类提示(均已删除)。它们既不是类型事实源,又在 - "取文案"里悄悄承担了"要不要上报"的判断,与"由调用方判定"的口径冲突。 - -## 决策 - -### 1. 不新增 JS 错误类型:直接用生成的 `ClientAuthError` - -ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generated/ClientAuthError.ts`), -前端只有这一个错误事实源,**不再另建 `ClientAuthFailure` 之类的手写类**——那只会退化成 -`payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。 - -原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 -**已有**的 `ClientAuthErrorWrapper`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个 -判别联合),并在构造时把整份载荷 `JSON.stringify` 写进 `Error.message`——上报事件因此拿到的是 -机器事实(变体名与载荷),而不是 `[object Object]`。它**不读任何变体字段、不拼用户文案**: -不塞 `context`,展示文案与上报的 `source` / `action` 都由调用 `captureClientError` 时的显式 -入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 - -- 只有带载荷的变体才有具名载荷类型:无字段变体在 ts-rs 里就是 `{ type: 'x' }`,不生成文件; - 有字段的变体才生成 `X.ts`(可枚举的细分 `reason` 字段自己也是生成的枚举,如 - `ServerAddressReason.ts` / `AuthNetworkReason.ts` / `AuthResponseInvalidReason.ts`)。这些具名类型是 §3 - 每个带载荷 `case` 里 `as X` 的目标,也正是"不要假设所有变体字段相同"的落点——没有字段可读的变体 - 不需要、也不允许硬造一个空载荷类型。 - -### 2. 一个包装函数:`invokeClientAuth` - -`clientAuth.ts` 里所有认证命令都经它调用: - -```ts -async function invokeClientAuth(command, args): Promise { - const invoke = requireInvoke(); // 认证桥未装:我们自己的失败关闭错误,原样抛出 - try { - return await invoke(command, args); - } catch (error) { - // 原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。 - throw new ClientAuthErrorWrapper(error); - } -} -``` - -- **不做运行时形状嗅探**:不再检查 `type` 存不存在。Rust 与 TS 同包发布,形状由 ts-rs 保证; - 出现别的形状属于 Tauri / Rust 侧的缺陷,`switch` 的 `default` 分支仍会把它抛出去上报,不会 - 静默吞掉——只是不再在包装层替 Tauri 兜底。 -- `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持 - 原样抛出(`需要在 Tauri App 内登录`)。 -- **不读任何变体字段、不拼用户文案**:`Error.message` 是构造时对整份载荷的序列化,不注入 - `source` / `action`;载体把原始拒绝值原样放进 `error`。展示文案与上报上下文都由 catch - 子句里拿到具名载荷的调用方决定。Tauri 缺陷抛出的真 `Error` 序列化后只有 `{}`,但上报链路 - 对真 `Error` 优先用其自身 message/stack。 -- 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 - -### 3. 判定只写在 catch 子句里,用具体变体 - -```ts -catch (error) { - if (!(error instanceof ClientAuthErrorWrapper)) throw error; // 超时 / 桥未装等我们自己的错误 - const failure = error.error as ClientAuthError; - switch (failure.type) { - case 'phoneNumberInvalid': { - // 无字段变体:文案由本 catch 给,不读任何字段。 - setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); - break; - } - case 'passwordLoginRejected': { - const payload = failure as PasswordLoginRejected; - // 前缀由本 catch 按当前操作提供;Rust 只给服务端原文(可能为 null)。 - setLoginStatus(`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`); - break; - } - // ... 每个业务 / 会话变体一个分支 - case 'authNetworkFailure': { - const payload = failure as AuthNetworkFailure; - // 可枚举的细分在类型化 reason 上再分流,仍然不碰文案。 - switch (payload.reason) { - case 'timeout': - case 'unreachable': - break; - default: - expectNever(payload.reason); - } - throw error; - } - // ... 其余系统变体逐个列出后原样抛出 - default: - expectNever(failure); - } -} -``` - -- **每个带载荷的业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,再读它自己的字段;字段是可枚举的 - 细分 `reason` 时,再 `switch (payload.reason)` 在类型化枚举上分流。无字段的 `case` 直接用本 catch 的 - 固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自当前 catch 的操作语义(登录、发码、 - 启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体先按载荷里的原始 - 事实(`reason` / `status` / `serverMessage`)给一行可见反馈,再原样抛出;带 `detail` 的本机失败是例外, - 只用固定文案,`detail` 只用于分流与诊断、不贴到界面上——变体名必须逐个列出,`default` 的 - `expectNever` 才成立。 -- 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给类型化 `reason` / - `serverMessage` / `status`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如 - 「服务器地址非法: 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 -- 系统变体:调用方处理不了,先给一行可见反馈(载荷原始事实,不建兜底文案层),再**原样 `throw`**。 - `onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 - handler 交给错误池。 -- `default: expectNever(failure)`(`expectNever(value: never)`)让"Rust 加了变体而这里 - 没接"变成**编译错误**。这是上一版"未识别变体上调是故意的"的替代方案:判据从运行时前移到 - 编译期。 -- 不把这段 switch 抽成 presenter / helper 函数:判定必须发生在 catch 里,包装函数只负责 - "把结构化拒绝转成 JS 错误"。 - -### 4. Rust 侧失败不再降级成字符串 - -结构化必须一路到底:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`, -`ClientAuthStateView` / `ClientAuthRefreshView` 不再有 `errorMessage` 字段,续期结果不再有 -`failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。 - -- `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由 - `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三种 - "unavailable 投影"。 -- `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - -## 后果与边界 - -- 新增认证命令或新增 `ClientAuthError` 变体,必须同时改所有 catch 的 switch,否则 `tsc` 失败。 -- `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理 - (`authoritative: false`),与旧 `failed` 分支语义一致。 -- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`; - 系统变体上报的 `source` 就是该 handler 的显式入参(`unhandledrejection`),载体不再携带 - `action`;结构化拒绝的 `Error.message` 是构造载体时生成的载荷序列化,`captureClientError` - 直接把它当作事件 `message`,事件指纹因此按变体区分,报告面板呈现的是机器事实。 - -## 验收 - -```text -npm run ai-game-creator-shell:typecheck -npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx apps/ai-game-creator-shell/tests/clientAuthHost.test.ts apps/ai-game-creator-shell/tests/platformSession.test.ts -cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error -npm run check:generated-bindings -npm run check:doc-index -npm run check:encoding -git diff --check -``` diff --git a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md b/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md deleted file mode 100644 index 3ff338113..000000000 --- a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md +++ /dev/null @@ -1,75 +0,0 @@ -# 【实施计划】导出产物面板与小红书小工具导出 - -| 字段 | 值 | -| --- | --- | -| Milestone | [导出产物面板与小红书小工具导出](./【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md) | -| Status | implemented-awaiting-runtime-acceptance(代码与定向验证已落,真实 vite 项目首轮适配待验) | -| Owner | Codex | - -## 修改边界 - -- Rust 模块:`src-tauri/src/export/mod.rs`(`EXPORT_WORK_RELATIVE_DIR` 与共用注册表读写、规范序列化 hash 助手)、`src-tauri/src/export/draft/mod.rs`、`src-tauri/src/export/draft/xhs_minitool/mod.rs`(表单 / 状态 / 结果 / 错误 DTO 与常量)、`src-tauri/src/export/draft/xhs_minitool/commands.rs`(小红书专属 `#[tauri::command]`)。 -- 现存草稿的归位:`export/command/`(空 `mod.rs`)是上一轮「共享命令目录」方案的残留,最终落点是 per-target `commands.rs`,实现时删除该目录与 `mod.rs` 里的 `pub mod command;`。同一轮把草稿常量改名落定:`xhx-minitool.zip` → `xhs-minitool.zip`、`xhx-minitool.json` → `xhs-minitool.json`、`build-xhx-minitool` → `build:xhs-minitool`;表单字段随 `#[serde(rename_all = "camelCase")]` 在 JSON / TS 侧为 `iconPath`(Rust 侧仍是 `icon_path`)。 -- Rust 命令(4 个,全部 per-target):`read_xhs_minitool_export` → `XHSMiniToolExportState { form, hasScript }`(内容,自动刷新反复调);`read_xhs_minitool_export_hash` → 注册表指纹字符串(写回时的 `baseHash`,只在内容被采纳时取一次);`save_xhs_minitool_export_form(project_path, form, base_hash)` → 写回后的新指纹;`run_xhs_minitool_export_build(project_path)` → `XHSMiniToolExportRunResult { outputTail, omittedCharacters }`(构建输出尾部**原文**与省略量两个事实,stdio 已合并;成功出口的退出码必然是 0,因此不带 `exitCode`,失败侧由 `CommandFailed.exitCode` 承担)。失败走 typed error `XHSMiniToolExportError`(ts-rs 导出):`buildScriptMissing` / `formInvalid` / `registryMalformed` / `saveConflict` / `commandDenied` / `commandFailed` / `artifactMissing` / `exportUnavailable`,前七类是预期拒绝,`exportUnavailable` 是宿主侧事实故障——两者在调用方要做的判断不同(留在面板 vs 原样抛出进错误池)。不新增 `GAME_CREATION_APP_COMMANDS` 条目,走 `command.exec` 的 typed `enforce_project_permission_policy_rejection`(不用那版拼好人话的 `enforce_project_permission_policy`,否则调用方分不出「策略拒绝」与「策略读不出来」)。内容与指纹分两条命令是刻意的:刷新这条高频路径在类型上就动不了写回基线。 -- 复用而非新造:运行脚本走 `command_exec` 的 `resolve_project_command_spec_at → prepare/stage/spawn`(或 `run_project_verification_with_commit_at`),cwd 沿用 `project/export.rs` 的 `resolve_publish_build_cwd` 口径;icon 与 zip 下载复用 `save_local_project_asset_file`;不拿项目写锁、不推进 revision。 -- 前端:`src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx`(表单 + 产物 + 冲突/失败卡片,只有「适配 / 导出」两颗显式按钮)、新增 `state/useXhsMinitoolExport.ts`(状态、防抖自动保存、按固定间隔自动重读内容;指纹只在 hook 内部的写回基线里,不进对外状态)、新增 `state/xhsMinitoolInstruction.ts`(适配与修复指令的纯函数 + 契约常量)、新增 `state/{xhsMinitoolApi,xhsMinitoolFailure,xhsMinitoolFields,xhsMinitoolDownload,xhsMinitoolOutputTail}.ts`、新增 `src/view/project-development/export/generated/`(ts-rs 产物)。宿主不预拼用户可见文案:输出尾部的「已省略前 N 个字符」与策略拒绝那句话都在前端拼(`xhsMinitoolOutputTail.ts` / `xhsMinitoolFailure.ts`)。失败分流按 ADR 两道口子:预期拒绝留在面板,宿主侧事实故障与认不出形状的拒绝先给现场再原样抛出(按指纹去重,面板 2 秒一轮重读不会把报告池的 `count` 刷成轮询次数)。 -- 快照排除:`src-tauri/src/project/filesystem.rs` 新增 `PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS = [".export"]`;**不能**并进通用排除列表(`agent/direct_patch.rs` 直接拿它拒绝路径,`agc_apply_patch` 会拒 `.export/`,首次适配无法落地)。 -- skill:`resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs`(新增 `--zip-out `,相对 cwd 解析、父目录自动创建;原 `--zip ` 行为不变;`--out-dir` 更名为 `--vite-built-dir`,并把原来的「就地删除白名单外文件」改成打包前预检报错、绝不动磁盘)、`scripts/vite.config.xhs-minitool.mjs` 注释点明 `build.outDir` 与 `--vite-built-dir` 必须一致、`SKILL.md` 补「被 AGC 导出面板调用时」三条硬契约;同步 `manifest.json` 指纹与 `skill_pack.rs` 的 `include_bytes!` 内容(文件数不变,仍 36 条)。 -- 文档:主规范一节、本里程碑与实施计划、`docs/README.md` 入口、`docs/project-memory/shared-memory/decision-log.md` 一条。 -- 契约常量的单一来源:Rust `export/mod.rs` 的 `EXPORT_WORK_RELATIVE_DIR` 与 `draft/xhs_minitool/layout.rs` 是权威,前端 `state/xhsMinitoolInstruction.ts` 只为插值留一份副本;这份副本由 `tests/xhsMinitoolContract.test.ts`(读上面两个 `.rs`)逐条钉住,两边任一侧改名都会红。前端指令用例只钉「正文有没有把契约写进去」,不再抄一份字面量。 -- 明确不动:`GAME_CREATION_APP_COMMANDS`、`shared-contracts`、`server-rs`、SpacetimeDB、OpenAPI、`api-server`,以及现有 `project/export.rs` 的发布链路。 - -## 实现顺序 - -1. Rust 基础:`export/mod.rs` 落 `EXPORT_WORK_RELATIVE_DIR`、注册表读写与规范序列化 hash;`xhs_minitool/mod.rs` 落 DTO(`#[serde(rename_all = "camelCase")]` + `#[ts(export, export_to = ...)]`)、常量与校验(首个错误即返回)。 -2. Rust 命令:`commands.rs` 三个命令。`read` 自动建空注册表;`save` 带 `base_hash` 做冲突判定;`run` 复用 `command_exec` 边界并解析 cwd,每次重新产出 zip。 -3. 前端:`state/xhsMinitoolInstruction.ts` 落指令正文与契约常量;`state/useXhsMinitoolExport.ts` 持状态与防抖自动保存;`ArtifactsPane.tsx` 落表单、复制、下载与字段级冲突面板;三颗显式按钮接线。 -4. skill:`pack.mjs` 新增 `--zip-out`、vite config 与 `SKILL.md` 写清 `.export/` 落点契约;`npm run agc:skill-pack:sync` 重算指纹并提版本。 -5. 快照排除:`.export/` 进 `PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS` 并补两侧口径的定向用例。 -6. 定向测试与真实项目首轮适配验证;回写主规范证据与未验证项。 - -## 验证命令与操作 - -- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell export::` -- 真实起进程跑宿主链路(默认 `#[ignore]`,要本机 node/npm 与可用命令沙箱):`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- --ignored executes_the_project_script` -- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`,随后 `npm run check:generated-bindings` -- `npx vitest run apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx` -- `npm run ai-game-creator-shell:typecheck` -- `npm run agc:skill-pack:sync` → `npm run agc:skill-pack:check` -- `node --test apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs`、同目录 `.validate.test.mjs` -- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check` -- 真实运行时:打开一个 vite 项目 → 点一键适配 → agent 跑通后不用点刷新(2 秒内导出按钮自己亮起来)→ 点导出 → 确认 `.export/xhs-minitool.zip` 可下载、内容可交给平台;再次导出确认零 agent 调用;手改坏注册表、删掉脚本各验证一次 rich error 文案。 - -## 风险与回滚点 - -- `pack.mjs` 在打包前预检 `--vite-built-dir`:白名单外的扩展名直接报错退出,不再就地删除;prompt 与 skill 都要求把它指向 vite 产物目录并与 `build.outDir` 一致,配错只会失败,不会误删项目源码。 -- `.export/` 排除出快照同步后适配成果不跟随恢复;prompt 与面板文案要说清「换机或恢复后需重新适配」,避免用户误以为已持久化。同机 checkpoint 仍含 `.export/`,是恢复适配脚本的兜底。 -- `.export/` 一旦被并进通用排除口径,`agc_apply_patch` 会以「不得修改受保护或排除路径」拒绝它,首次适配直接卡死;两侧口径差别由 `project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials` 钉住。 -- agent 写注册表是软约束(只靠 prompt 要求);宿主严格解析必须 fail closed,解析失败一律给 rich error 并引导重新适配,不做猜测性合并。 -- skill 指纹:改 `pack.mjs` / vite config 会让 `skill_pack.rs` 的 `include_bytes!` 内容与 manifest 不一致,必须同一次变更里跑 sync 并提版本,否则 release build 直接失败关闭。 -- 回滚点:三个命令与前端 tab 以新增文件为主(`main.rs` 仅多 `pub mod export;`),可整体摘除;skill 改动可回滚到上一版指纹。 - -## 当前状态 - -代码与文档已实现,提交按「Rust 基础 → Rust 命令 → 前端行为层 → 前端视图 → 前端测试 → skill → 快照排除」拆分: - -- `91741f9de` 共享注册表与 export 模块骨架;`2a70926a3` 小红书 DTO / 错误 / 布局 / 校验与 ts-rs 绑定;`b6bef5b10` 生成目录加入 prettier 忽略;`3a1999e11` 三个命令 + 构建 + desktop 接线。 -- `d694c183d` 前端行为层(命令入口、指令正文、失败说法、字段元数据、下载、状态 hook);`9a8d77dd9` 面板左半(表单 / 冲突 / 失败卡片与三颗按钮);`f8517abaa` 前端契约测试(10 例)。 -- `95323ce54` skill 的 `--zip-out` 与 AGC 接入契约(manifest `2026-08-26.41`);`3369f76b1` `.export` 快照同步排除;`faa93929b` 文档回写。 -- `5e2426c8a` 导出收尾判据抽成 `conclude(...)` 并补 5 例确定性用例(非零退出 / 超时 / 产物缺失 / 空产物 / 成功),同时去掉 `XHSMiniToolExportRunResult.exitCode` 这个恒为 0 的死字段;`463341c4b` 导出成功后把脚本输出尾部报给用户;`b3be3ee71` 证据口径修正;`5dd158ee1` 补默认 ignore 的真实起进程用例。 -- `1b7741ac6` 适配与修复指令补上「包根在 game/ 时落点写成 `../.export/xhs-minitool.zip`」,skill 契约同步(manifest `2026-08-26.42`);`f8ed4e898` 保存对话框的默认文件名收进下载助手并复用既有取文件名工具;`90f651ddf` 刷新时先撤掉待触发的自动保存(附回归用例;该行为已被下面的自动刷新取代——轮询不再撤掉待保存的输入,因为「磁盘为准」的刷新动作没有了)。 -- `ac666daad` 之后:未适配提示改成带入队按钮的卡片、失败卡片按变体分派适配/修/重试、提示只在注册表读回来之后出现(同一时刻只有一张卡片、一个入口)。 -- 导出面板的用例去掉恒真的存在性断言(`getBy*` 本来就抛,不再包 `expect(...).not.toBeNull()`;等待出现改用 `findBy*`,能写成行为断言的就点一下按钮看回调),并把这条口径写进 team-conventions。 -- layout 的用例重写:原来那条 `assert_eq!(artifact_relative_path(), format!(...))` 只是把实现抄了一遍,现改成 4 条只钉关系的用例(相对路径与宿主 `PathBuf` 指向同一文件、不许跑出 `.export/` 且不出现反斜杠、注册表与产物共用一个项目根锚定的 flat 目录、脚本名保住 `build:<后缀>` 形式);逐条反证过会红,`export::` 50 → 53 例。 -- `dffe4de55` 导出链路按 ADR 收口(策略拒绝与宿主故障拆成两个变体、输出尾部只回原文与省略量);`df3a6826b` 前端失败通道按 ADR 分流(宿主故障与未分类拒绝原样抛出并去重)与复制失败可见反馈。 -- 复制按钮改用 Tauri 剪贴板插件(原 `navigator.clipboard` 在 WebView 里可能根本没有,失败还会静默吞掉),失败态在按钮上可见。 -- 指令正文里的契约字符串全部从常量组合(`XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH` + 三个脚本落点),用例的期望值也从常量推;`9d3018a49` / `6dd0dc00c` 之后:去掉刷新按钮,改成按固定间隔自动重读内容;内容与注册表指纹拆成两条命令(自动刷新拿不到写回基线);同步细节(指纹、保存中、待保存)退出前端对外状态与界面。 - -已验证:Rust `export::` 53 例(`export::draft::xhs_minitool` 37 例)、`export_bindings` 116 例、`project_snapshot` 22 例、`check:generated-bindings`(118 个文件)、前端 vitest 21 例(新增 19 + 契约 2)、`ai-game-creator-shell:typecheck`、`ai-game-creator-shell:check:rust:shell`(本机 4 个 shard 都跑到了底,唯一失败是 4 个 `process_session` 真 PTY 用例——与下面的已知环境噪声同一批:沙箱里的 npm 由另一个 node 版本执行、`Cannot find module '../lib/cli.js'`;本次改动涉及的 `export::` 与 `project_snapshot` 全绿)、`agc:skill-pack:check`、`check:doc-index`、`check:encoding`、`git diff --check`。 - -未验证(唯一开口项):真实 vite 项目上的首轮适配闭环——agent 跑通 `build:xhs-minitool`、产出可上传 zip、二次导出零 agent 调用;以及 `.export/` 换机后重新适配的体感。宿主侧这一段已尽力自动化:`build.rs` 的 `executes_the_project_script_and_requires_a_non_empty_artifact_each_time`(`#[ignore]`)会真起进程执行项目脚本,覆盖「第一次产出 → 删掉产物再跑一次重新产出 → 脚本成功但没产出报 artifactMissing」。本机尝试运行被容器环境挡下,读日志时注意两点、别误判成 bug: -- 子进程输出里的 `running 1 test` 是**预期现象**:命令沙箱的垫片就是当前可执行文件本身(`desktop.rs` 按 `--command-sandbox-trampoline` 分流),编译到测试态时 `sandbox_trampoline_arguments()` 会改成 `--exact ...trampoline_child_fixture --ignored`,所以垫片进程是测试二进制在跑那一个 fixture 用例。 -- 真正失败的是沙箱里的 `npm`:解析到的是 fnm 包装脚本、却由另一个 node 版本执行(`Cannot find module '../lib/cli.js'`,`Node.js v26.10.0`)。同一脚本在普通 shell 里 `npm run build:xhs-minitool` 正常产出 zip,因此不是代码问题。 -该用例留给有健康沙箱与 node 环境的机器一键复跑。 - -已知环境噪声:本机全量 `cargo test --bin` 有 12 例与本变更无关的失败(`process_session` / `command_sandbox_trampoline` / `runner` 的 pty 与 GUI 锁、`resource_editor` 的外部 HTTP 连接被拒),失败用例均不经过本次改动的路径。 diff --git a/docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md b/docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md deleted file mode 100644 index 02fc06f28..000000000 --- a/docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md +++ /dev/null @@ -1,60 +0,0 @@ -# 【里程碑】导出产物面板与小红书小工具导出 - -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | implemented-awaiting-runtime-acceptance(代码与定向验证已落,真实 vite 项目首轮适配待验) | -| Date | 2026-10-05 | -| Parent Spec | [导出产物面板与小红书小工具导出](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-10-05-导出产物面板与小红书小工具导出) Version 1.0 | - -## 目标 - -把「导出到第三方平台」收敛成一条可复用链路:首次由 code agent 在用户项目里实验出能产出平台制品的脚本,之后每次导出由宿主直接运行该脚本产出 zip,用户从面板复制平台上传表单字段并下载 icon 与 zip 自行上传。本里程碑只交付小红书小工具(vite 项目)一个目标。 - -## 范围 - -- Rust 新增 `export` 模块与小红书专属命令:注册表自动创建与严格解析、表单校验、脚本存在判定、运行 `build:xhs-minitool`、返回产物路径与失败输出。 -- 注册表 `.export/xhs-minitool.json`(首次自动创建且字段全空)与 flat 工作目录 `.export/`;共享 icon 放 `.export/` 根,多目标可指同一文件。 -- 前端导出面板小红书 tab:表单展示/编辑/复制、icon 与 zip 下载、显式「适配 / 导出」两颗按钮(内容与脚本判据自动重读,没有刷新按钮)、冲突时逐字段二选一。 -- 前端组装首次适配指令,经会话写权限门入队到项目主 Direct 会话。 -- `vite-export-xhs-minitool` skill 的脚本与示例配置按 `.export/` 布局适配(`pack.mjs` 新增 `--zip-out ` 指定确定落点、`--out-dir` 更名为 `--vite-built-dir` 并在打包前预检该目录,vite config 示例注释点明 `build.outDir` 与它必须一致)。 -- 主规范一节、本里程碑、实施计划与决策记录同步。 - -## 不在范围内 - -- 平台自动上传(小红书不提供 API)、非 vite 项目、第二个导出目标、宿主侧 zip 结构校验与平台规范复刻、PNG/JPG 之外的 icon 格式转换(留 TODO)。 -- `GAME_CREATION_APP_COMMANDS`、`shared-contracts`、SpacetimeDB schema、OpenAPI、`api-server` 的任何改动;不新增权限命令、不推进全局 revision、不拿项目写锁。 -- `.export/` 进入项目快照或发布包的兼容;`.export/` 排除在快照同步之外(不进通用排除口径,否则 Agent 的 `agc_apply_patch` 写不了它),恢复或换机后需重新适配。 -- 导出进度流式推送、自动轮询或自动重试。 - -## 依赖与前置条件 - -- 用户已确认主规范与本里程碑。 -- `vite-export-xhs-minitool` skill 随包(`agc-skill-pack.v1` 十项之一),agent 可经 `agc_read_skill_resource` 读取 `scripts/*.mjs`。 -- 目标项目是 npm + vite 项目(`game/package.json`,`scripts.build` 为 `vite build`)。 -- `command.exec` 的 npm 参数白名单放行 `build:*`;脚本 cwd 解析可沿用 `resolve_publish_build_cwd` 的口径。 - -## 验收标准 - -- [x] 首次打开小红书 tab,`.export/xhs-minitool.json` 自动生成且 `name` / `introduction` / `iconPath` 全为空字符串;注册表损坏或字段不合法时给字段级错误,不静默吞掉。 -- [x] `name` / `introduction` 为空或超 14 个 Unicode 字符、icon 不存在/越界/符号链接/扩展名不在 `png|jpg|jpeg`/超 5 MiB 时被拒,首个错误即返回。 -- [x] `hasScript` 只由 npm 包 `package.json` 的 `scripts["build:xhs-minitool"]` 现算;注册表内没有 status、没有「已适配」、没有经验文本字段。 -- [x] 点「导出」每次都运行脚本并重新产出 `.export/xhs-minitool.zip`;脚本缺失、运行非零退出(带 exit code 与输出尾部,stdout 与 stderr 已合并)、产物缺失各有独立 rich error。输出尾部只装原文与省略量两个事实,漏掉多少字那句话由前端拼。 -- [x] 表单字段可复制;icon 与 zip 经原生保存对话框落盘;复制与下载不触发任何 agent 调用。 -- [x] agent 改过注册表后,用户保存旧值触发字段级冲突面板,可逐字段选「我的 / 文件里的」,纯格式化改动静默吸收,任何情况下不静默覆盖用户输入。 -- [x] 一键适配只入队一条指令且必须过会话写权限门;被拒时不落消息、不改注册表。 -- [x] 全链路不自动跑构建、不自动叫 agent(内容与脚本判据按固定间隔自动重读);`.export/` 不进发布包、不进快照。 -- [x] 失败按 ADR 的 typed 变体分流:权限策略拒绝(`commandDenied`)留在面板并给固定话术;宿主侧事实故障与认不出形状的拒绝先给现场、再原样抛出进错误池;面板每 2 秒重读,同一个故障只抛一次。 -- [x] ts-rs 绑定生成到前端目录且 `npm run check:generated-bindings` 通过。 - -## 证据要求 - -- 自动化(已落): - - Rust `cargo test ... --bin genarrative-ai-game-creator-shell export::` 53 例,其中 `export::draft::xhs_minitool` 37 例:注册表自动建空表单、格式漂移静默吸收、严格解析拒绝、表单首错、icon 越界/符号链接/扩展名、`hasScript` 现算、`contentHash` 冲突、产物路径守卫,以及构建收尾判据的逐条映射(脚本缺失、非零退出带退出码与输出尾部、超时无退出码、产物缺失、空产物不算、成功回输出尾部、截断只回省略量不回句子;layout 的 4 例只钉关系不抄实现——前端字符串相对路径与宿主 `PathBuf` 必须指向同一文件、相对路径不许跑出 `.export/`(含 Windows 反斜杠)、注册表与产物共用一个项目根锚定的 flat 目录、脚本名保住 `build:<后缀>` 形式且不许退化成裸 `build`)。 - - `export_bindings` 116 例 + `npm run check:generated-bindings`(118 个生成文件)通过。 - - 前端 `npx vitest run tests/xhsMinitoolExport.test.tsx`(19 例)与 `tests/xhsMinitoolContract.test.ts`(2 例,把前端契约常量的值逐条对到 Rust `layout.rs` / `export/mod.rs`)通过;面板外壳原先那份 `tests/artifactsPanel.test.tsx`(4 例)整份删除——三个目标的列表与切 tab 后的文案都是存在性断言,关闭按钮只是把 `onClose` 转发一次,而它唯一有内容的「挂载的是主聊天同一个 `.project-chat-surface`」已被 `chatDialogFrameLayout.test.ts` 覆盖;`npm run ai-game-creator-shell:typecheck` 通过。其中 19 例覆盖(期望值一律从契约常量推,不另抄字面量):指令正文三条硬契约与 game/ 落点、失败现场拼装、失败变体人话映射与三向 action 分派(适配 / 修 / 重试)、未适配卡片自带入队按钮且按钮行不重复、注册表读回来之前不下「还没适配」结论、表单复制、icon 与 zip 下载、冲突逐字段二选一、自动保存带回读到的 `baseHash`、自动重读只更新脚本判据而不动用户未保存的表单、agent 事后改注册表/加脚本不用点刷新就能带回来、导出把脚本输出尾部报给用户、失败变体的产出量与前端拼出的省略说明、认不出形状的拒绝原样抛出且不落成业务提示、宿主侧事实故障先给卡片再抛一次且同一故障只抛一次,复制失败在按钮上可见(不静默吞掉),以及「只有适配/修复才叫 agent」。 - - `.export/` 快照排除有定向用例:`project_snapshot::tests::project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials` 同时钉住「同步排除」与「通用口径不排除(Agent 仍可写)」。 - - `npm run agc:skill-pack:sync` → `agc:skill-pack:check` 通过(`manifest.json` 版本 `2026-08-26.43`,文件数不变,仍 36 条)。 - - `pack.mjs` 的 no-delete 预检有定向用例(`scripts/.pack.test.mjs` 5 例):`findUnsupportedFiles` 只报白名单外扩展名、存在不支持文件时整体失败且不删任何文件/不写 zip、干净目录仍能打包、CLI 拒绝旧名 `--out-dir` 不静默退回默认目录。 -- 运行时(**未验证**,本里程碑唯一开口项):在真实 vite 项目上由 agent 完成首轮适配、产出可人工上传的 zip、二次导出零 agent 调用;`.export/` 换机后需重新适配的体感也需一并确认。宿主侧这一段已有默认 `#[ignore]` 的真起进程用例(`executes_the_project_script_and_requires_a_non_empty_artifact_each_time`:第一次产出 → 删掉产物重跑必须重新产出 → 脚本成功但没产出报 artifactMissing),一键复跑命令见实施计划;本次尝试被容器环境的 npm/沙箱解析挡下,不是代码问题。 -- 边界(已覆盖):注册表损坏、脚本缺失、脚本非零退出、产物缺失、icon 越界与符号链接、目录遍历拒绝。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 631495a16..5cc080747 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9,21 +9,19 @@ - 决策(工作目录):`EXPORT_WORK_RELATIVE_DIR = ".export"` 锚在项目根并保持 flat;共享 icon 放 `.export/` 根,注册表存项目内相对路径,多目标复用同一文件;agent 拷贝的脚本按 `` 前缀命名避免第二目标撞名。`.export/` **只**排除在项目快照同步之外(`PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS`),恢复或换机后需重新适配(已知代价);**不得**并进通用排除口径——`agent/direct_patch.rs` 直接拿它拒绝路径,`agc_apply_patch` 会以「不得修改受保护或排除路径」拒绝 `.export/`,首次适配就落不了地;同机 checkpoint 仍含 `.export/`,可作恢复适配脚本的兜底。 - 决策(skill 落点):`pack.mjs` 新增 `--zip-out `(相对 cwd 解析、父目录自动创建),让「产物必须落在项目根 `.export/xhs-minitool.zip`」有确定写法;`--out-dir` 只允许指向 vite 构建输出目录(它会就地删掉该目录内非白名单扩展名的文件)。宿主只认结果:脚本名 + 非空产物路径,产物结构与平台规范仍归 skill 与 agent。 - 决策(权限与副作用):跑脚本复用 `command.exec` 的权限口径(只查 deny,UI 按钮即用户确认),不新增 `GAME_CREATION_APP_COMMANDS` 条目;导出链路不拿项目写锁、不推进全局 revision,`.export/` 不进 manifest、素材、UI State 或客户端投影;发布包白名单收集,`.export/` 不会进入。 -- 决策(prompt 归属):首次适配与失败修复的指令正文、契约常量与组装都放前端(`view/project-development/export/state/xhsMinitoolInstruction.ts` 纯函数;失败说法在 `xhsMinitoolFailure.ts`),宿主不提供 `enqueue_*` 命令;前端经 `useDirectProjectChatController` 的 `chat.submit` 入队,复用它既有的会话写权限门与 clientTurnId。Rust 侧因此只有 per-target 的四个命令(读内容 / 读指纹 / 存表单 / 跑构建),typed error 首个错误即返回,不带问题数组;内容与指纹为什么分成两条见下面 2026-10-05 的同步细节决策。 +- 决策(prompt 归属):首次适配与失败修复的指令正文、契约常量与组装都放前端(`view/project-development/export/state/xhsMinitoolInstruction.ts` 纯函数;失败说法在 `xhsMinitoolFailure.ts`),宿主不提供 `enqueue_*` 命令;前端经 `useDirectProjectChatController` 的 `chat.submit` 入队,复用它既有的会话写权限门与 clientTurnId。Rust 侧因此只有 per-target 的四个命令(读内容 / 读指纹 / 存表单 / 跑构建);内容与指纹为什么分成两条见下面 2026-10-05 的同步细节决策。 - 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/export/{mod,draft/mod,draft/xhs_minitool/{mod,commands}}`(`main.rs` 仅加 `pub mod export;`)、前端 `view/project-development/export/{tabs/xiaohongshu,state,generated}`、`resources/agc-skills/vite-export-xhs-minitool/scripts/*` 与其 `manifest.json` 指纹、`src-tauri/src/agent/skill_pack.rs` 的 `include_bytes!` 内容(文件数不变,仍 36 条)。不触碰 `GAME_CREATION_APP_COMMANDS`、`shared-contracts`、`server-rs`、SpacetimeDB、OpenAPI、`api-server` 与现有 `project/export.rs` 发布链路。 - 验证方式:`cargo test ... export::` 与 `export_bindings` + `npm run check:generated-bindings`、前端 vitest(复制 / 下载 / 冲突逐字段选择 / 适配指令组装与入队 mock)、`npm run ai-game-creator-shell:typecheck`、`npm run agc:skill-pack:sync` + `agc:skill-pack:check`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check`;真实 vite 项目上验证首轮适配产出可上传 zip 与二次导出零 agent 调用。 - 边界:本决策只覆盖小红书小工具这一个目标;第二个目标出现时再抽 target 描述符,不预留动态注册或通用表单引擎。 -## 2026-10-05 导出面板的同步细节与失败通道:指纹不进对外状态,真故障原样抛出 +## 2026-10-05 导出面板的同步细节与文案归属:指纹不进对外状态 -- 背景:导出面板(同上一条)落地后回头核 ADR([`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)、[`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)、[`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](../../adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md)),发现四件事与既有口径不一致:读状态把内容与指纹混在一条命令里、失败只有「显示一句话」一条出口、策略拒绝与宿主故障共用一个变体、宿主自己拼了「省略前 N 个字符」这句用户可见文案。四条都在这里收口。 +- 背景:导出面板(同上一条)落地后回头核 [`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](../../adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md),发现两件事与既有口径不一致:读状态把内容与指纹混在一条命令里、宿主自己拼了「省略前 N 个字符」这句用户可见文案。两条都在这里收口。 - 决策(同步细节不进对外状态):命令拆成 `read_xhs_minitool_export`(`form` + `hasScript`,自动刷新反复调的高频路径)与 `read_xhs_minitool_export_hash`(写回基线),后者只在进编辑会话、或新内容真的被采纳时取一次。`contentHash` 不进前端对外状态(hook 里只在 `baselineRef`),界面不显示任何同步状态。理由:刷新这条高频路径在**类型上**就动不了写回基线,否则「顺手刷新」会把两次刷新之间的外部改动认成自己的基线、把冲突吞掉。 - 决策(自动重读,取代「刷新按钮」,作废提交 `90f651ddf` 的「刷新取消未保存输入」):取消刷新按钮,按 2 秒固定间隔自动重读(窗口在后台时跳过)。`hasScript` 是宿主现算事实,任何时刻都采纳;表单只在用户手上没有未保存输入(也不在冲突/字段错误里)时才覆盖,否则绝不覆盖正在打的字。代价是「刷新会取消未保存输入」这条旧行为被有意作废,对应的回归用例同步替换。 -- 决策(失败通道):预期拒绝留在面板(一句话按变体写死 + 宿主现场),**宿主侧事实故障与认不出形状的拒绝先给现场再原样抛出**,由全局 `unhandledrejection` 进错误池——面板不再把裸 message 当成业务失败的解释糊给用户,也不再静默吞掉真故障。抛出按指纹去重:面板每 2 秒重读,同一个故障只抛一次、变了再抛,否则报告池的 `count` 统计的是「面板开了多久」。策略拒绝(`commandDenied`)与宿主故障(`exportUnavailable`)必须是两个变体:`enforce_project_permission_policy` 那版把「策略拒绝」与「策略读不出来」拼成同一句人话,调用方无从判该不该上报,导出链路因此改用 typed 的 `enforce_project_permission_policy_rejection`。 - 决策(文案归属的延伸):宿主不预拼用户可见文案这条也管载荷里的说明——构建输出尾部只回 `outputTail` + `omittedCharacters` 两个事实,「已省略前 N 个字符」由前端拼(`export/state/xhsMinitoolOutputTail.ts`,失败卡片与成功提示共用)。与上面那条 prompt 归属同源:机器事实在 Rust,句子在前端。 -- 刻意保留的差异:`xhsMinitoolFailure.ts` 这个「变体 → 一句话 + 下一步按钮」的纯映射没有按 ADR「判定只写在 catch 里、不抽 presenter」内联进 catch。那条规则的落点是只有一个消费方的认证 catch;这里有两个消费方(面板卡片与交给 agent 的修复指令),必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉。它不判「要不要上报」——上报判据在 hook 的 catch 里。 - 契约常量的单一来源:Rust `export/mod.rs` 与 `draft/xhs_minitool/layout.rs` 是契约值(`.export`、脚本名、注册表名、产物名)的唯一权威;前端在 `export/state/xhsMinitoolInstruction.ts` 保留一份用于拼指令,由 `tests/xhsMinitoolContract.test.ts` 直接读 Rust 源码把两份钉住(ts-rs 不能导出 `const`,所以钉法是测试而不是生成物)。改 Rust 常量而前端没跟,测试红。 -- 影响范围:`export/draft/xhs_minitool/{commands,error,build,dto}.rs`、`export/generated/{CommandDenied,CommandFailed,XHSMiniToolExportRunResult,XHSMiniToolExportError}.ts`、`export/state/{useXhsMinitoolExport,xhsMinitoolFailure,xhsMinitoolOutputTail,xhsMinitoolApi}.ts`、`export/tabs/xiaohongshu/*`。不新增命令、不碰权限位、不动发布链路。 +- 影响范围:`export/draft/xhs_minitool/{commands,error,build,dto}.rs`、`export/generated/*.ts`、`export/state/{useXhsMinitoolExport,xhsMinitoolFailure,xhsMinitoolOutputTail,xhsMinitoolApi}.ts`、`export/tabs/xiaohongshu/*`。不新增命令、不碰权限位、不动发布链路。 - 验证方式:`cargo test ... export::`(含 `export_bindings`)、`npm run check:generated-bindings`、`npx vitest run apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx apps/ai-game-creator-shell/tests/xhsMinitoolContract.test.ts`、`npm run ai-game-creator-shell:typecheck`、`npm run ai-game-creator-shell:check:rust:shell`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check`;真实 vite 项目上的首轮适配与二次导出仍是未验证项。 - 边界:只覆盖小红书小工具这一个目标;第二个目标出现时再抽 target 描述符,不预留动态注册或通用表单引擎。 @@ -164,23 +162,6 @@ - 影响范围:根开发脚本、AGC 开发启动编排、本地开发运维文档;不改变 API、schema、生产部署和独立 `npm run agc` 行为。 - 验证方式:参数/状态单测、开发栈健康端点 smoke、`.app/dev-stack.json` 身份复用检查、进程树收束检查。 -## 2026-10-01 AGC 命令错误结构化与错误报告口径 - -- 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端先按 `type` 选类别、可枚举细分再按类型化 `reason` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 -- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientAuthErrorWrapper` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 -- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 -- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 有字段变体的载荷文件)。 -- 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;可枚举的细分原因是类型化枚举字段(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`),不是字符串、也不各拆一个顶层变体。前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段,`reason` 是枚举时再 `switch (payload.reason)`(`default` 同样用 `expectNever`)。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 -- 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 -- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 -- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 -- 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 -- 追加(2026-10-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。 -- 追加(2026-10-02,401 归 phoneCodeLoginRejected):`/api/auth/phone/login` 的 401 只来自「用户不存在」(验证码错误/失效/过期在服务端都是 400,已由 `phoneCodeLoginRejected { serverMessage }` 带原文);顶层变体 `smsCodeRejected` 退役删除,前端三个 catch 去掉了它那个「验证码错误或已过期」的固定分支,`phoneCodeLoginRejected` 的文案统一为「验证码登录失败:<服务端原文>」。 -- 追加(2026-10-02,读 body 失败按已确认状态码归类):AGC 认证请求拿到 `status` 后 `response.text()` 失败,不再一律压成 `authNetworkFailure { unreachable }`;非 2xx 走既有分类(`serverMessage` 为 `None`,如 503 → `authServiceUnavailable { 503 }`),只有 2xx 响应没收完才算传输层故障。分类收敛在 `classify_unreadable_body`。 -- 追加(2026-10-02,系统类失败保留原始错误载荷):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` / `authClientInitFailed` 都带 `detail: string`(原始 error),既让调用方有机会分流处理,也让报告包带够诊断信息;原始 error 同时经 `app_log!`(落盘前过 `sanitize_diagnostic_message`)记一行本地日志。`detail` 不贴到界面上:三个 catch 用本操作的固定文案(登录检查 / 发码 / 登录各自不同)。取代上一版"本机 IO 失败不进载荷、原始 error 只进日志"。 -- 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 - ## 2026-10-02 launcher 页面高度契约:外壳分高度,页面不再自己算窗口高度 - 背景:PR #228(`6d2c275d3`)只给项目页补了「外壳纵向 flex + 页面 `flex: 1 1 auto`」的高度修复;其余页面仍各自算高度——帮助页没写高度也没有内层滚动容器,内容一长就被外壳 `overflow: hidden` 裁掉且无法滚动;首页用 `h-screen` / `h-[calc(100vh-32px)]`,模板库用 JS 量父级高度写内联 `height`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 20a8f87ba..874d626b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -31,7 +31,7 @@ - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 -- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。 - **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 - **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物保持 ts-rs 原始输出(不再经 prettier / eslint 二次改写,见本节「生成绑定」口径);ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。 - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index ba99888a7..a79183304 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,7 +8,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 ``。历史已经脱敏的归档不推测或补造原路由。 -- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型(`reason` 是枚举时再 `switch (payload.reason)`)、再用它自己的字段拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 406dc1996..3806ab765 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -10,12 +10,11 @@ - 构建契约:项目 npm 包里存在脚本 `build:xhs-minitool`(冒号形式是既有 `command.exec` 白名单 `build:*` 的要求),由宿主负责运行;跑完必须存在 `.export/xhs-minitool.zip`。脚本内部怎么 `vite build`、怎么调 `pack.mjs`、用什么中间目录由 agent 决定并写进脚本;**每次导出都重新构建 zip**,不做「产物已存在」的短路。 - 首次适配:用户点显式按钮 → 前端组装适配指令(正文在 `view/project-development/export/state/xhsMinitoolInstruction.ts`,纯函数 + 契约常量)→ 经 `useDirectProjectChatController` 的 `chat.submit` 入队(该链路自带会话写权限门与 clientTurnId),进的就是同一个项目主 Direct 会话,不另开「直接调 agent」的旁路。不自动跑构建、不自动叫 agent;内容与脚本判据按固定间隔(2 秒,窗口在后台时跳过)自动重读,所以没有刷新按钮——agent 回合结束后用户只需要点「导出」。重读时两条事实分开处理:`hasScript` 是宿主的现算事实,任何时刻都采纳;表单是用户的编辑对象,只有用户手上没有未保存输入(也不在冲突里)时才覆盖,否则只更新 `hasScript`,绝不覆盖正在打的字。没有构建脚本时,「还没适配」的结论只由注册表读回来之后下(`hasScript` 初值是 `false`,读取中不下结论),提示卡片自带那颗入队按钮,按钮行里就不再重复一颗;失败卡片按变体的 `action` 分派三颗按钮——`adapt` 发首次适配指令(还没适配,修复指令会引用一份还不存在的适配说明)、`repair` 发同一份契约加宿主失败现场、`retry` 只重试,同一时刻只有一张卡片、一个入口。 - 命令切分:`read_xhs_minitool_export` 只返回**内容**(`form` + `hasScript`),自动刷新反复调的就是这一条;**注册表指纹**(写回时的 `baseHash`)由 `read_xhs_minitool_export_hash` 单独给,只在进编辑会话、或重读到的新内容真的被采纳时取一次。两者分开是刻意的:刷新这条高频路径在类型上就动不了写回基线,否则「顺手刷新」会把两次刷新之间的外部改动认成自己的基线、把冲突吞掉。指纹是同步细节,不进前端对外状态(hook 里只在 `baselineRef`),界面也不显示任何同步状态(没有「保存中」「待保存」文案)。`save_xhs_minitool_export_form` 成功后回新的指纹。 -- 失败通道:命令失败是 typed 变体(`XHSMiniToolExportError`,ts-rs 导出到 `export/generated/`),前端只按 `type` 分流、`expectNever` 钉死穷尽性,**不对任何文案做判断**,也不把裸 message 当成业务失败的解释。分流沿用 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md) 的两道口子:预期拒绝(表单不合法、脚本缺失、冲突、权限策略拒绝 `commandDenied`、脚本非零退出、产物不在、注册表坏)留在面板里就地消化,一句话按变体写死、宿主现场照原样展示;**宿主侧事实故障(`exportUnavailable`)与认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败)先给现场再原样抛出**,由全局 `unhandledrejection` 送进错误池。抛出按指纹去重:面板每 2 秒重读,同一个故障只抛一次、变了再抛,否则报告池里的 `count` 统计的是「面板开了多久」。策略拒绝与宿主故障必须是两个变体:`enforce_project_permission_policy` 那版把「策略拒绝」与「策略读不出来」拼成同一句人话,调用方就分不出该不该上报,所以导出链路走 typed 的 `enforce_project_permission_policy_rejection`。宿主也不预拼用户可见文案:构建输出尾部只回原文 + `omittedCharacters` 两个事实,「已省略前 N 个字符」那句话由前端拼(`export/state/xhsMinitoolOutputTail.ts`,失败卡片与成功提示共用)。同一口径也管界面自己的失败:复制字段走 Tauri 剪贴板插件(`clipboard-manager:allow-write-text`,与 `UiEditorCopyPathButton` 等三处同一条路;`navigator.clipboard` 在 WebView 里可能根本没有、失败还静默),写失败时按钮显示「复制失败」,不假装成功。 - 冲突:注册表可被 agent(改文件)与用户(面板编辑)两方写入。宿主保存时带 `baseHash`;宿主重读比对,hash 不同再逐字段 diff,只要有字段真的不同就判冲突,**绝不覆盖**,返回两侧值让前端逐字段选择;纯格式化改动静默吸收。注册表内不存 revision / updatedAt 之类 token。 - 权限与副作用:跑脚本复用 `command.exec` 的权限口径(只查 deny,UI 按钮即用户确认),**不新增** `GAME_CREATION_APP_COMMANDS` 条目;导出链路**不拿项目写锁、不推进全局 revision**(`.export/` 不进 manifest、素材、UI State 或客户端投影)。发布包是白名单收集,`.export/` 不会进入。 - 非目标:平台自动上传(无 API)、PNG/JPG 之外的 icon 格式转换、宿主侧 zip 结构校验、非 vite 项目、第二个导出目标,以及 `GAME_CREATION_APP_COMMANDS` / `shared-contracts` / SpacetimeDB / OpenAPI 的任何改动。 -- 验收:注册表自动建空表单与严格解析拒绝、表单首错、`hasScript` 判定、内容与指纹两条命令的切分(自动刷新拿不到写回基线)、`contentHash` 冲突与逐字段选择、icon 越界与符号链接拒绝、产物路径;失败按 typed 变体分流(策略拒绝与宿主故障是两个变体、认不出形状的拒绝与宿主故障原样抛出、同一故障只抛一次、输出省略量由前端写进提示);自动重读不覆盖用户未保存的输入、agent 事后加上脚本不用点刷新就能亮起导出按钮;首次适配在真实 vite 项目上由 agent 跑通并产出可人工上传的 zip,二次导出零 agent 调用;变更 skill 脚本后指纹同步。证据与未验证项见[里程碑](../project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md)。 -- 落地情况:Rust(`export/{mod,registry,draft/xhs_minitool/*}` 四个命令)、前端(`export/{state,tabs/xiaohongshu,generated}`)、skill(`pack.mjs --zip-out`)与 `.export/` 快照同步排除均已实现;失败通道已按上面两条 ADR 收口(`commandDenied` 与 `exportUnavailable` 分开、宿主故障与未分类拒绝原样抛出并去重、输出尾部只回事实)。定向单测与前端 vitest 通过,**真实 vite 项目上的首轮适配仍是唯一未验证项**。 +- 验收:注册表自动建空表单与严格解析拒绝、表单首错、`hasScript` 判定、内容与指纹两条命令的切分(自动刷新拿不到写回基线)、`contentHash` 冲突与逐字段选择、icon 越界与符号链接拒绝、产物路径;自动重读不覆盖用户未保存的输入、agent 事后加上脚本不用点刷新就能亮起导出按钮;首次适配在真实 vite 项目上由 agent 跑通并产出可人工上传的 zip,二次导出零 agent 调用;变更 skill 脚本后指纹同步。 +- 落地情况:Rust(`export/{mod,registry,draft/xhs_minitool/*}` 四个命令)、前端(`export/{state,tabs/xiaohongshu,generated}`)、skill(`pack.mjs --zip-out`)与 `.export/` 快照同步排除均已实现。定向单测与前端 vitest 通过,**真实 vite 项目上的首轮适配仍是唯一未验证项**。 ## 2026-10-02 退役自建 Agent Runtime 与 CLI 执行面 From 6e33768b8ba9163fd1afd932e972646a83892b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 15:10:45 +0800 Subject: [PATCH 049/156] =?UTF-8?q?skill=EF=BC=9A=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=81=87=E8=AE=BE=E6=94=B9=E6=88=90=20game/=EF=BC=8Cpack.mjs?= =?UTF-8?q?=20=E7=9A=84=20--help=20=E8=A1=A5=E4=B8=8A=E5=B0=91=E5=86=99?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E4=B8=AA=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SKILL.md` 的 vite 项目目录假设写的是 `games/`,实际是 `game/`; - 默认参数都按「cwd 就是 vite 项目根」给,非标准目录结构要显式指 `--vite-built-dir`; - `pack.mjs --help` 里 `--skip-validate` / `--no-strict` 原先只在用法行里出现、没有说明,补上; - `SKILL.md` 新增「脚本参数」一节,列出三个脚本各自的参数(与 `--help` 里同一份); - 同步 manifest 指纹(version=2026-08-26.46)。 --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../agc-skills/vite-export-xhs-minitool/SKILL.md | 13 ++++++++++++- .../vite-export-xhs-minitool/scripts/pack.mjs | 4 +++- 3 files changed, 17 insertions(+), 4 deletions(-) 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 057c29d50..3bb654b3f 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.44", + "version": "2026-08-26.46", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "f7d1f7e05863b45e307fe9ef6b281513774b130af2631cc2001b55f40b752fc9" + "sha256": "e80b5c6a3f9a1619af1f789a74ea6e7577e58a4efa208d1d7b29e869f40b3725" } ] } 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 index 18bc23a25..5382393c3 100644 --- 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 @@ -17,7 +17,18 @@ metadata: * 不干扰正常的web构建 # 假设和默认项: -* vite项目目录: games/ ,各个工具(包括示例vite配置)的默认参数参照这个条件给出, 对于非标准的目录结构需要给出显式的参数来适应 +* vite项目目录: game/ ,各个工具(包括示例vite配置)的默认参数参照这个条件给出, 对于非标准的目录结构需要给出显式的参数来适应 + - 默认参数都按"cwd 就是 vite 项目根"给的: 示例配置的 `build.outDir` 是 `dist-xhs-minitool`, `pack.mjs --vite-built-dir` 的默认值也是 `dist-xhs-minitool`. + - 所以先 `cd` 到 vite 项目根再跑; 或者把路径写全 (`--vite-built-dir <项目根>/dist-xhs-minitool`). + +# 脚本参数: +每个脚本 `--help` 都能看参数, 这里只把三份摆在一起: + +| 脚本 | 参数 | +|---|---| +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | +| `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | +| `scripts/pack.mjs` | `--vite-built-dir `、`--zip-out `、`--zip `、`--skip-validate`、`--no-strict` | # 一些情况: * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 80d3f0e51..42c566927 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -39,7 +39,9 @@ const USAGE = `用法:node pack.mjs [--vite-built-dir ] [--zip ] [- 该目录出现白名单之外的文件类型时直接报错退出并列出文件,不会删除任何文件。 --zip 只给文件名时用:写到当前工作目录下。 --zip-out 需要指定落点(例如 AGC 导出面板要求的 .export/xhs-minitool.zip)时用: - 相对路径按当前工作目录解析,父目录不存在会自动建。`; + 相对路径按当前工作目录解析,父目录不存在会自动建。 +--skip-validate 写完 zip 后不再跑一遍静态校验。 +--no-strict 校验时不把 WARNING 当失败。`; /** * 打包前预检:产物目录里出现小红书白名单之外的文件类型时整体失败,列出路径但**不改磁盘**。 From aeec2f86bdb71c0ac0680beff626e74e036c1eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 15:25:31 +0800 Subject: [PATCH 050/156] =?UTF-8?q?skill=EF=BC=9A=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=81=87=E8=AE=BE=E6=94=B9=E6=88=90=20game/=EF=BC=9Bpack.mjs?= =?UTF-8?q?=20=E5=8E=BB=E6=8E=89=20--zip=20=E4=B8=8E=E4=BB=A3=E8=B7=91?= =?UTF-8?q?=E7=9A=84=E6=A0=A1=E9=AA=8C=EF=BC=8C=E4=BF=9D=E7=95=99=20--vite?= =?UTF-8?q?-built-dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SKILL.md` 的 vite 项目目录假设写的是 `games/`,实际是 `game/`;默认参数都按「cwd 就是 vite 项目根」给,非标准目录结构用 `--vite-built-dir` 指全路径; - `pack.mjs` 删掉打包后调用 `validate.mjs` 的那一步,连带 `--skip-validate` / `--no-strict`:校验归 `validate.mjs` 与项目构建脚本,pack 只管整理产物、写 zip、按体积门禁审计; - `pack.mjs` 删掉 `--zip` 与它专用的 `readPackageName()`(默认文件名):落点统一由 `--zip-out` 指定;`--vite-built-dir` 保留; - 参数解析改成「只认 `--vite-built-dir` / `--zip-out`」:不认识的参数、缺值、缺 `--zip-out` 都当场打印用法并退出 2,打包本身失败仍退出 1,不再有静默退回默认目录的路径; - `SKILL.md` 新增「脚本参数」一节,列出三个脚本各自的参数; - `pack.mjs` 的用例改成「CLI 拒绝不认识的参数,且不写出 zip」; - 同步 manifest 指纹(version=2026-08-26.49)。 --- .../resources/agc-skills/manifest.json | 4 +- .../vite-export-xhs-minitool/SKILL.md | 2 +- .../scripts/.pack.test.mjs | 12 +-- .../vite-export-xhs-minitool/scripts/pack.mjs | 99 ++++++++----------- 4 files changed, 50 insertions(+), 67 deletions(-) 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 3bb654b3f..83f28936b 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.46", + "version": "2026-08-26.49", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "e80b5c6a3f9a1619af1f789a74ea6e7577e58a4efa208d1d7b29e869f40b3725" + "sha256": "ad78485332009c5c72675a00ba525836b1ed94db34616256052ef9b30ca2d869" } ] } 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 index 5382393c3..9320bd375 100644 --- 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 @@ -28,7 +28,7 @@ metadata: |---|---| | `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | | `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | -| `scripts/pack.mjs` | `--vite-built-dir `、`--zip-out `、`--zip `、`--skip-validate`、`--no-strict` | +| `scripts/pack.mjs` | `--vite-built-dir `、`--zip-out ` | # 一些情况: * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 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 index 2c41324de..83f5596c5 100644 --- 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 @@ -61,7 +61,6 @@ test('存在不支持文件时打包整体失败,且不删除任何文件', () cwd: BASE, viteBuiltDir: 'no-delete', zipOut: 'no-delete-out/xhs-minitool.zip', - skipValidate: true, }), /没有删除任何文件/, ); @@ -77,19 +76,20 @@ test('干净产物目录仍能正常打包', () => { cwd: BASE, viteBuiltDir: 'clean', zipOut, - skipValidate: true, }); assert.equal(code, 0); assert.ok(existsSync(zipOut), 'zip 未生成'); }); -test('CLI 拒绝旧名 --out-dir,不再静默退回默认目录', () => { +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, '--skip-validate'], + [PACK, '--vite-built-dir', dir, '--out-dir', dir, '--zip-out', zipOut], { cwd: BASE, encoding: 'utf8' }, ); - assert.equal(result.status, 1); - assert.match(result.stderr, /--vite-built-dir/); + 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/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 42c566927..e52528f90 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -5,7 +5,6 @@ * - 校正 HTML 资源为相对路径、去掉 module 痕迹 * - 将脚本移到 body 末尾并保持顺序 * - 用 node:zlib 写出 zip(index.html 在 zip 根目录、不多套一层目录) - * - 再跑一遍静态校验 */ import { copyFileSync, @@ -22,7 +21,7 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { deflateRawSync } from 'node:zlib'; -import { findUnsupportedFiles, runCli } from './validate.mjs'; +import { findUnsupportedFiles } from './validate.mjs'; const MIB = 1024 * 1024; const ZIP_HARD_LIMIT = 10 * MIB; @@ -31,17 +30,14 @@ const ZIP_RECOMMENDED = 2 * MIB; const FORBIDDEN_ENTRY = /(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i; -const USAGE = `用法:node pack.mjs [--vite-built-dir ] [--zip ] [--zip-out ] [--skip-validate] [--no-strict] +const USAGE = `用法:node pack.mjs [--vite-built-dir ] --zip-out 把 vite 构建产物整理成符合小红书规范、根目录带 index.html 的 zip。 --vite-built-dir vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 dist-xhs-minitool。 该目录出现白名单之外的文件类型时直接报错退出并列出文件,不会删除任何文件。 ---zip 只给文件名时用:写到当前工作目录下。 ---zip-out 需要指定落点(例如 AGC 导出面板要求的 .export/xhs-minitool.zip)时用: - 相对路径按当前工作目录解析,父目录不存在会自动建。 ---skip-validate 写完 zip 后不再跑一遍静态校验。 ---no-strict 校验时不把 WARNING 当失败。`; +--zip-out 落点(例如 AGC 导出面板要求的 .export/xhs-minitool.zip): + 相对路径按当前工作目录解析,父目录不存在会自动建。`; /** * 打包前预检:产物目录里出现小红书白名单之外的文件类型时整体失败,列出路径但**不改磁盘**。 @@ -272,38 +268,22 @@ function makeZip(viteBuiltDir, zipPath) { console.log(`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB)`); } -function readPackageName(cwd) { - const pkgPath = join(cwd, 'package.json'); - if (!existsSync(pkgPath)) return 'minitool'; - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - return pkg.name || 'minitool'; - } catch { - return 'minitool'; - } -} - /** * @param {{ * cwd?: string, * viteBuiltDir?: string, - * zipName?: string, - * zipOut?: string, + * zipOut: string, * publicDir?: string, - * skipValidate?: boolean, - * strict?: boolean, - * }} [options] + * }} options */ export function packMinitool(options = {}) { + if (!options.zipOut) { + throw new Error('缺少 --zip-out:打包要给出 zip 的落点'); + } const cwd = resolve(options.cwd || process.cwd()); const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool'); - const zipName = options.zipName || `${readPackageName(cwd)}-xhs-tool.zip`; - // `zipOut` 优先:调用方(如 .export/ 下的项目拷贝)要的是确定的落点,而不是"当前目录下叫什么名字"。 - const zipPath = options.zipOut - ? resolve(cwd, options.zipOut) - : resolve(cwd, zipName); + const zipPath = resolve(cwd, options.zipOut); const publicDir = resolve(cwd, options.publicDir || 'public'); - const strict = options.strict !== false; if (!existsSync(viteBuiltDir)) { throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); @@ -328,16 +308,7 @@ export function packMinitool(options = {}) { makeZip(viteBuiltDir, zipPath); console.log(`\n小红书小工具包已整理:${viteBuiltDir}`); - - if (options.skipValidate) return 0; - - const args = [viteBuiltDir]; - if (strict) args.push('--strict'); - const code = runCli(args); - if (code !== 0) { - process.exitCode = code; - } - return code; + return 0; } export default packMinitool; @@ -359,33 +330,45 @@ function isMainModule() { if (isMainModule()) { const raw = process.argv.slice(2); - const flags = new Set(raw.filter((arg) => arg.startsWith('-'))); - if (flags.has('--help') || flags.has('-h')) { + if (raw.includes('--help') || raw.includes('-h')) { console.log(USAGE); process.exit(0); } - // 旧名不能静默退回默认目录:否则适配脚本里的 --out-dir 会被忽略,产物落到别处还查不出来。 - if (flags.has('--out-dir')) { - console.error('--out-dir 已更名为 --vite-built-dir,请改用新名字(`node pack.mjs --help`)。'); - process.exit(1); - } - - const getFlagValue = (name) => { + // 取走一个参数和它的值;剩下的非空参数说明调用方传了本脚本不认的东西。 + const takeFlag = (name) => { const idx = raw.indexOf(name); - return idx === -1 ? undefined : raw[idx + 1]; + if (idx === -1) return undefined; + const value = raw[idx + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${name} 后面要跟一个值`); + } + raw.splice(idx, 2); + return value; }; + let viteBuiltDir; + let zipOut; try { - const code = packMinitool({ - viteBuiltDir: getFlagValue('--vite-built-dir'), - zipName: getFlagValue('--zip'), - zipOut: getFlagValue('--zip-out'), - skipValidate: flags.has('--skip-validate'), - strict: !flags.has('--no-strict'), - }); - process.exit(code ?? 0); + viteBuiltDir = takeFlag('--vite-built-dir'); + zipOut = takeFlag('--zip-out'); + // 不认识的参数当场拒绝:否则调用方以为它生效了,产物落到别处还查不出来。 + if (raw.length > 0) { + throw new Error(`不认识的参数:${raw.join(' ')}`); + } + if (!zipOut) { + throw new Error('缺少 --zip-out'); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + console.error(USAGE); + process.exit(2); + } + + try { + packMinitool({ viteBuiltDir, zipOut }); + process.exit(0); } catch (err) { console.error(err instanceof Error ? err.message : err); process.exit(1); From 2cfc0d5bb4927752a3f7158710fbeb92fd430cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 15:28:42 +0800 Subject: [PATCH 051/156] =?UTF-8?q?xhs-minitool=20skill=EF=BC=9ASKILL.md?= =?UTF-8?q?=20=E7=9A=84=E5=81=87=E8=AE=BE=E4=B8=8E=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=A1=A8=E6=94=B6=E6=95=9B=EF=BC=8C=E5=90=8C=E6=AD=A5=E6=8C=87?= =?UTF-8?q?=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「假设和默认项」合并成一条:各工具(含示例 vite 配置)的默认参数都按「cwd 就是 vite 项目根」给,非标准目录结构用显式参数适应; - 「脚本参数」表去掉多余的引导句、对齐列宽,三个脚本的参数不变; - 同步 manifest 指纹(version=2026-08-26.50)。 --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../agc-skills/vite-export-xhs-minitool/SKILL.md | 15 ++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) 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 83f28936b..294866c32 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.49", + "version": "2026-08-26.50", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "ad78485332009c5c72675a00ba525836b1ed94db34616256052ef9b30ca2d869" + "sha256": "83f78c763407f749ffeb859843a111fe6922a77971cc84be35a6d618de4745b1" } ] } 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 index 9320bd375..f9dd9a1ff 100644 --- 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 @@ -17,18 +17,15 @@ metadata: * 不干扰正常的web构建 # 假设和默认项: -* vite项目目录: game/ ,各个工具(包括示例vite配置)的默认参数参照这个条件给出, 对于非标准的目录结构需要给出显式的参数来适应 - - 默认参数都按"cwd 就是 vite 项目根"给的: 示例配置的 `build.outDir` 是 `dist-xhs-minitool`, `pack.mjs --vite-built-dir` 的默认值也是 `dist-xhs-minitool`. - - 所以先 `cd` 到 vite 项目根再跑; 或者把路径写全 (`--vite-built-dir <项目根>/dist-xhs-minitool`). + +* vite项目目录: game/ ,各个工具(包括示例vite配置)默认cwd 就是 vite 项目根, 对于非标准的目录结构需要给出显式的参数来适应 # 脚本参数: -每个脚本 `--help` 都能看参数, 这里只把三份摆在一起: - -| 脚本 | 参数 | -|---|---| +| 脚本 | 参数 | +|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| | `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | -| `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | -| `scripts/pack.mjs` | `--vite-built-dir `、`--zip-out ` | +| `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | +| `scripts/pack.mjs` | `--vite-built-dir `、`--zip-out ` | # 一些情况: * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 From 6311bb5837085db0d57c3885bcd1c757938251a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 15:30:36 +0800 Subject: [PATCH 052/156] =?UTF-8?q?=E4=B8=BA=20sandbox=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=A4=B1=E8=B4=A5=E8=A1=A5=E4=BF=9D=E7=95=99=E6=BA=90?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=9A=84=E5=AE=BF=E4=B8=BB=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `command.exec` 在 sandbox-ready 失败时先取走子进程 stdout/stderr,再终止受控进程组,避免 `Child` 回收时丢掉 bwrap/trampoline 的真实报错; - 采集结果在有界时间(2s)和字节(8 KiB/流)内读空;新增 `sanitize_diagnostic_message_preserving_paths`、`append_application_log_line_preserving_paths` 与 `app_log_with_paths!`,只做凭据脱敏、保留绝对路径(bwrap `Can't find source path` 的路径就是唯一证据),写入宿主 `application.log`; - 默认 `sanitize_diagnostic_message` 仍整行脱敏绝对路径,新增项按 `target_os = "linux"` 门控;诊断只进宿主诊断包,不进入模型可见错误、command log、receipt 或 Agent DB; - 新增 `sandbox_launch_diagnostics_keep_source_paths_and_redact_secrets`,覆盖源路径保留、凭据脱敏与空白流不产出行; - `pitfalls.md` 记录 sandbox 启动失败丢弃子进程 stderr 的排障经验。 --- .../src-tauri/src/command_exec.rs | 131 +++++++++++++++++- .../src-tauri/src/main.rs | 44 +++++- docs/project-memory/shared-memory/pitfalls.md | 8 ++ 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 14a4e6096..2ab979478 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -1853,18 +1853,18 @@ where let (mut gate, ready) = match ready_task { Ok(result) => result, Err(error) => { - let termination = terminate_project_command_process_group(&mut child).await; + let cause = format!("等待 sandbox-ready 任务失败:{error}"); + let termination = + terminate_project_command_launch_and_log_diagnostics(&mut child, &cause).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, - project_command_launch_error_with_termination( - format!("等待 sandbox-ready 任务失败:{error}"), - termination, - ), + project_command_launch_error_with_termination(cause, termination), )); } }; if let Err(error) = ready { - let termination = terminate_project_command_process_group(&mut child).await; + let termination = + terminate_project_command_launch_and_log_diagnostics(&mut child, &error).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, project_command_launch_error_with_termination(error, termination), @@ -2085,6 +2085,103 @@ fn terminate_project_command_process_group_after_commit( )) } +/// sandbox 启动失败时,bwrap / trampoline 子进程的 stderr / stdout 是唯一能说明 setup +/// 失败原因的证据。启动失败路径原先直接 kill + wait,管道里的诊断随 `Child` 一起被丢掉, +/// 只剩 `Connection reset by peer` 这类不透明的 OS 错误。这里只做有界采集,凭据脱敏但保留 +/// 绝对路径(bwrap 的 `Can't find source path` 里路径就是唯一证据),写入宿主 +/// application.log 供诊断包使用;不进入模型可见错误、command log、receipt 或 Agent DB。 +#[cfg(target_os = "linux")] +const SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES: usize = 8 * 1024; +#[cfg(target_os = "linux")] +const SANDBOX_LAUNCH_DIAGNOSTIC_MAX_WAIT: Duration = Duration::from_secs(2); + +#[cfg(target_os = "linux")] +async fn read_bounded_sandbox_launch_stream(mut reader: R, budget: usize) -> Vec +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut collected = Vec::new(); + let mut buffer = [0_u8; 1024]; + while collected.len() < budget { + match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(count) => { + let remaining = budget - collected.len(); + collected.extend_from_slice(&buffer[..count.min(remaining)]); + } + } + } + collected +} + +#[cfg(target_os = "linux")] +async fn collect_sandbox_launch_streams( + stdout: Option, + stderr: Option, +) -> (Vec, Vec) { + let read_stdout = async { + match stdout { + Some(reader) => { + read_bounded_sandbox_launch_stream(reader, SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES) + .await + } + None => Vec::new(), + } + }; + let read_stderr = async { + match stderr { + Some(reader) => { + read_bounded_sandbox_launch_stream(reader, SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES) + .await + } + None => Vec::new(), + } + }; + tokio::join!(read_stdout, read_stderr) +} + +/// 把 sandbox 子进程的输出整理成一条宿主日志:非空、单行、凭据已脱敏。 +/// 绝对路径必须保留——bwrap `Can't find source path` 的路径就是唯一证据。 +#[cfg(target_os = "linux")] +fn format_sandbox_launch_diagnostics(stdout: &[u8], stderr: &[u8]) -> Option { + let mut sections = Vec::new(); + for (stream_name, bytes) in [("stdout", stdout), ("stderr", stderr)] { + let text = String::from_utf8_lossy(bytes); + let text = text.trim(); + if text.is_empty() { + continue; + } + let sanitized = crate::sanitize_diagnostic_message_preserving_paths(text, None); + let sanitized = sanitized.trim(); + if !sanitized.is_empty() { + sections.push(format!("{stream_name}={sanitized}")); + } + } + (!sections.is_empty()).then(|| sections.join(";")) +} + +/// 启动失败路径统一入口:先取走管道(否则 `wait()` 回收后诊断随 `Child` 一起被丢掉), +/// 再终止受控进程组,最后在有界时间内读空管道并写入宿主日志。 +#[cfg(target_os = "linux")] +async fn terminate_project_command_launch_and_log_diagnostics( + child: &mut tokio::process::Child, + cause: &str, +) -> Result { + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let termination = terminate_project_command_process_group(child).await; + let (stdout_bytes, stderr_bytes) = tokio::time::timeout( + SANDBOX_LAUNCH_DIAGNOSTIC_MAX_WAIT, + collect_sandbox_launch_streams(stdout, stderr), + ) + .await + .unwrap_or_default(); + let diagnostics = format_sandbox_launch_diagnostics(&stdout_bytes, &stderr_bytes) + .unwrap_or_else(|| "stdout/stderr 无输出".to_string()); + app_log_with_paths!("command.exec sandbox 启动失败诊断:cause={cause};{diagnostics}"); + termination +} + async fn read_bounded_project_command_output( mut reader: R, ) -> Result @@ -3567,4 +3664,26 @@ raise SystemExit(code)' assert!(output.text.contains("omitted")); assert!(output.truncated); } + + #[cfg(target_os = "linux")] + #[test] + fn sandbox_launch_diagnostics_keep_source_paths_and_redact_secrets() { + let diagnostics = format_sandbox_launch_diagnostics( + b"", + b"bwrap: Can't find source path /home/alice/project/game No such file or directory\n", + ) + .expect("non-empty stderr yields diagnostics"); + assert!(diagnostics.contains("bwrap"), "{diagnostics}"); + assert!( + diagnostics.contains("/home/alice/project/game"), + "source path is the diagnostic evidence and must survive: {diagnostics}" + ); + assert!( + format_sandbox_launch_diagnostics(b"", b" \n \t").is_none(), + "whitespace-only streams must not produce a diagnostic line" + ); + let secret = format_sandbox_launch_diagnostics(b"", b"authorization: Bearer abc123\n") + .expect("secret-bearing stderr still yields a redacted diagnostic"); + assert!(!secret.to_ascii_lowercase().contains("bearer"), "{secret}"); + } } 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 a56425d58..91c65c81b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -68,6 +68,17 @@ macro_rules! app_log { }}; } +/// 与 `app_log!` 相同,但保留绝对路径。只给「路径本身就是诊断证据」的宿主失败用 +/// (例如 bwrap `Can't find source path`);凭据标记仍整行脱敏。 +#[cfg(target_os = "linux")] +macro_rules! app_log_with_paths { + ($($arg:tt)*) => {{ + let message = format!($($arg)*); + let _ = $crate::append_application_log_line_preserving_paths(&format!("RUST {}: {}", module_path!(), message)); + std::eprintln!("{}", message); + }}; +} + // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); @@ -1645,6 +1656,17 @@ pub(crate) fn append_application_log_line(line: &str) -> std::io::Result<()> { append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized) } +/// 与 `append_application_log_line` 相同,但保留绝对路径。只给「路径本身就是诊断证据」的 +/// 宿主失败用;凭据标记仍由 `sanitize_diagnostic_message_preserving_paths` 整行脱敏。 +#[cfg(target_os = "linux")] +pub(crate) fn append_application_log_line_preserving_paths(line: &str) -> std::io::Result<()> { + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(()); + }; + let sanitized = sanitize_diagnostic_message_preserving_paths(line, Some(&config_dir)); + append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized) +} + fn redact_windows_absolute_paths(value: &str) -> String { let bytes = value.as_bytes(); let mut output = String::with_capacity(value.len()); @@ -1704,6 +1726,24 @@ fn redact_unix_absolute_paths(value: &str) -> String { } pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String { + sanitize_diagnostic_message_inner(value, private_root, true) +} + +/// 与 `sanitize_diagnostic_message` 相同,但保留绝对路径。路径本身就是诊断证据的宿主失败 +/// (例如 bwrap `Can't find source path`)必须走这一条;凭据标记仍然整行脱敏。 +#[cfg(target_os = "linux")] +pub(crate) fn sanitize_diagnostic_message_preserving_paths( + value: &str, + private_root: Option<&Path>, +) -> String { + sanitize_diagnostic_message_inner(value, private_root, false) +} + +fn sanitize_diagnostic_message_inner( + value: &str, + private_root: Option<&Path>, + redact_paths: bool, +) -> String { let mut sanitized = value.replace(['\r', '\n'], " "); if let Some(root) = private_root { let root = root.to_string_lossy(); @@ -1728,7 +1768,9 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat { return "".to_string(); } - sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); + if redact_paths { + sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); + } sanitized.chars().take(2_048).collect() } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 874d626b1..39086a3f5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4149,6 +4149,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:并发执行全部 project verification 测试;同时真实运行 bwrap staged marker 用例,确认 child-created、block、ready、commit、exec 和目标退出链均稳定,目标 argv/env/FD 不含 nonce 或控制 socket。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs`、`command_sandbox.rs`、`command_exec.rs`、`project.rs`。 +## sandbox 启动失败不能把子进程 stderr 一起丢掉 + +- 现象:`command.exec` 在 `wait_sandbox_ready` 阶段失败时,模型和日志只看到 `等待 command sandbox ready 失败:Connection reset by peer (os error 104)`;无法判断是 userns、AppArmor、mount 还是 trampoline 自身失败,真实 bwrap 报错完全不可见。 +- 原因:bwrap/trampoline 以 `Stdio::piped()` 启动,但启动失败路径只调用 `terminate_project_command_process_group`(`kill` + `wait`)后即返回;`Child` 被回收时管道里的 stderr/stdout 一并丢失。项目自己的真实 launcher 测试(`command_sandbox.rs` 的 opt-in staged gate 用例)反而会 `wait_with_output()` 并把 stderr 打进 panic。 +- 处理:启动失败统一走 `terminate_project_command_launch_and_log_diagnostics`:先 `take()` stdout/stderr,再 kill + wait,然后在有界时间(2s)和字节(8 KiB/流)内读空,经 `sanitize_diagnostic_message_preserving_paths` 只做凭据脱敏、保留绝对路径(bwrap 的 `Can't find source path` 里路径就是唯一证据),写入宿主 `application.log`;诊断只进宿主诊断包,不进入模型可见错误、command log、receipt 或 Agent DB。默认的 `sanitize_diagnostic_message` 仍然整行脱敏绝对路径,只有这条宿主失败日志走保留路径的变体。 +- 验证:`sandbox_launch_diagnostics_keep_source_paths_and_redact_secrets` 断言源路径保留、凭据整体脱敏、纯空白流不产出行;`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test ... real_linux_opt_in` 的真实 bwrap 握手用例保持通过。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_exec.rs`、`command_sandbox.rs`、`command_sandbox_trampoline.rs`。 + ## bwrap 的命令分隔符不能从目标 argv 末尾反查 - 现象:普通命令握手正常,但 `cargo test -- --nocapture`、npm forwarded args 等包含独立 `--` 的合法目标参数可能在 sandbox preflight 或 staged launch 期间提前执行原目标。 From eccaa5f79c4fc4334b1a55c7ab65e45921b12914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 15:41:58 +0800 Subject: [PATCH 053/156] =?UTF-8?q?=E5=9B=9E=E9=80=80=20sandbox=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5=E8=AF=8A=E6=96=AD=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=8C=E6=81=A2=E5=A4=8D=20application.log=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回退 6311bb583:移除 sandbox 启动失败时采集子进程 stdout/stderr 的宿主日志及保留绝对路径的变体; - application.log 恢复对所有行统一脱敏绝对路径的既有行为; - 真实 npm 启动故障改从宿主 Node 环境修复,不改日志代码。 --- .../src-tauri/src/command_exec.rs | 131 +----------------- .../src-tauri/src/main.rs | 44 +----- docs/project-memory/shared-memory/pitfalls.md | 8 -- 3 files changed, 7 insertions(+), 176 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 2ab979478..14a4e6096 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -1853,18 +1853,18 @@ where let (mut gate, ready) = match ready_task { Ok(result) => result, Err(error) => { - let cause = format!("等待 sandbox-ready 任务失败:{error}"); - let termination = - terminate_project_command_launch_and_log_diagnostics(&mut child, &cause).await; + let termination = terminate_project_command_process_group(&mut child).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, - project_command_launch_error_with_termination(cause, termination), + project_command_launch_error_with_termination( + format!("等待 sandbox-ready 任务失败:{error}"), + termination, + ), )); } }; if let Err(error) = ready { - let termination = - terminate_project_command_launch_and_log_diagnostics(&mut child, &error).await; + let termination = terminate_project_command_process_group(&mut child).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, project_command_launch_error_with_termination(error, termination), @@ -2085,103 +2085,6 @@ fn terminate_project_command_process_group_after_commit( )) } -/// sandbox 启动失败时,bwrap / trampoline 子进程的 stderr / stdout 是唯一能说明 setup -/// 失败原因的证据。启动失败路径原先直接 kill + wait,管道里的诊断随 `Child` 一起被丢掉, -/// 只剩 `Connection reset by peer` 这类不透明的 OS 错误。这里只做有界采集,凭据脱敏但保留 -/// 绝对路径(bwrap 的 `Can't find source path` 里路径就是唯一证据),写入宿主 -/// application.log 供诊断包使用;不进入模型可见错误、command log、receipt 或 Agent DB。 -#[cfg(target_os = "linux")] -const SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES: usize = 8 * 1024; -#[cfg(target_os = "linux")] -const SANDBOX_LAUNCH_DIAGNOSTIC_MAX_WAIT: Duration = Duration::from_secs(2); - -#[cfg(target_os = "linux")] -async fn read_bounded_sandbox_launch_stream(mut reader: R, budget: usize) -> Vec -where - R: tokio::io::AsyncRead + Unpin, -{ - let mut collected = Vec::new(); - let mut buffer = [0_u8; 1024]; - while collected.len() < budget { - match reader.read(&mut buffer).await { - Ok(0) | Err(_) => break, - Ok(count) => { - let remaining = budget - collected.len(); - collected.extend_from_slice(&buffer[..count.min(remaining)]); - } - } - } - collected -} - -#[cfg(target_os = "linux")] -async fn collect_sandbox_launch_streams( - stdout: Option, - stderr: Option, -) -> (Vec, Vec) { - let read_stdout = async { - match stdout { - Some(reader) => { - read_bounded_sandbox_launch_stream(reader, SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES) - .await - } - None => Vec::new(), - } - }; - let read_stderr = async { - match stderr { - Some(reader) => { - read_bounded_sandbox_launch_stream(reader, SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES) - .await - } - None => Vec::new(), - } - }; - tokio::join!(read_stdout, read_stderr) -} - -/// 把 sandbox 子进程的输出整理成一条宿主日志:非空、单行、凭据已脱敏。 -/// 绝对路径必须保留——bwrap `Can't find source path` 的路径就是唯一证据。 -#[cfg(target_os = "linux")] -fn format_sandbox_launch_diagnostics(stdout: &[u8], stderr: &[u8]) -> Option { - let mut sections = Vec::new(); - for (stream_name, bytes) in [("stdout", stdout), ("stderr", stderr)] { - let text = String::from_utf8_lossy(bytes); - let text = text.trim(); - if text.is_empty() { - continue; - } - let sanitized = crate::sanitize_diagnostic_message_preserving_paths(text, None); - let sanitized = sanitized.trim(); - if !sanitized.is_empty() { - sections.push(format!("{stream_name}={sanitized}")); - } - } - (!sections.is_empty()).then(|| sections.join(";")) -} - -/// 启动失败路径统一入口:先取走管道(否则 `wait()` 回收后诊断随 `Child` 一起被丢掉), -/// 再终止受控进程组,最后在有界时间内读空管道并写入宿主日志。 -#[cfg(target_os = "linux")] -async fn terminate_project_command_launch_and_log_diagnostics( - child: &mut tokio::process::Child, - cause: &str, -) -> Result { - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let termination = terminate_project_command_process_group(child).await; - let (stdout_bytes, stderr_bytes) = tokio::time::timeout( - SANDBOX_LAUNCH_DIAGNOSTIC_MAX_WAIT, - collect_sandbox_launch_streams(stdout, stderr), - ) - .await - .unwrap_or_default(); - let diagnostics = format_sandbox_launch_diagnostics(&stdout_bytes, &stderr_bytes) - .unwrap_or_else(|| "stdout/stderr 无输出".to_string()); - app_log_with_paths!("command.exec sandbox 启动失败诊断:cause={cause};{diagnostics}"); - termination -} - async fn read_bounded_project_command_output( mut reader: R, ) -> Result @@ -3664,26 +3567,4 @@ raise SystemExit(code)' assert!(output.text.contains("omitted")); assert!(output.truncated); } - - #[cfg(target_os = "linux")] - #[test] - fn sandbox_launch_diagnostics_keep_source_paths_and_redact_secrets() { - let diagnostics = format_sandbox_launch_diagnostics( - b"", - b"bwrap: Can't find source path /home/alice/project/game No such file or directory\n", - ) - .expect("non-empty stderr yields diagnostics"); - assert!(diagnostics.contains("bwrap"), "{diagnostics}"); - assert!( - diagnostics.contains("/home/alice/project/game"), - "source path is the diagnostic evidence and must survive: {diagnostics}" - ); - assert!( - format_sandbox_launch_diagnostics(b"", b" \n \t").is_none(), - "whitespace-only streams must not produce a diagnostic line" - ); - let secret = format_sandbox_launch_diagnostics(b"", b"authorization: Bearer abc123\n") - .expect("secret-bearing stderr still yields a redacted diagnostic"); - assert!(!secret.to_ascii_lowercase().contains("bearer"), "{secret}"); - } } 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 91c65c81b..a56425d58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -68,17 +68,6 @@ macro_rules! app_log { }}; } -/// 与 `app_log!` 相同,但保留绝对路径。只给「路径本身就是诊断证据」的宿主失败用 -/// (例如 bwrap `Can't find source path`);凭据标记仍整行脱敏。 -#[cfg(target_os = "linux")] -macro_rules! app_log_with_paths { - ($($arg:tt)*) => {{ - let message = format!($($arg)*); - let _ = $crate::append_application_log_line_preserving_paths(&format!("RUST {}: {}", module_path!(), message)); - std::eprintln!("{}", message); - }}; -} - // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); @@ -1656,17 +1645,6 @@ pub(crate) fn append_application_log_line(line: &str) -> std::io::Result<()> { append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized) } -/// 与 `append_application_log_line` 相同,但保留绝对路径。只给「路径本身就是诊断证据」的 -/// 宿主失败用;凭据标记仍由 `sanitize_diagnostic_message_preserving_paths` 整行脱敏。 -#[cfg(target_os = "linux")] -pub(crate) fn append_application_log_line_preserving_paths(line: &str) -> std::io::Result<()> { - let Some(config_dir) = game_creator_runtime_config_dir() else { - return Ok(()); - }; - let sanitized = sanitize_diagnostic_message_preserving_paths(line, Some(&config_dir)); - append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized) -} - fn redact_windows_absolute_paths(value: &str) -> String { let bytes = value.as_bytes(); let mut output = String::with_capacity(value.len()); @@ -1726,24 +1704,6 @@ fn redact_unix_absolute_paths(value: &str) -> String { } pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String { - sanitize_diagnostic_message_inner(value, private_root, true) -} - -/// 与 `sanitize_diagnostic_message` 相同,但保留绝对路径。路径本身就是诊断证据的宿主失败 -/// (例如 bwrap `Can't find source path`)必须走这一条;凭据标记仍然整行脱敏。 -#[cfg(target_os = "linux")] -pub(crate) fn sanitize_diagnostic_message_preserving_paths( - value: &str, - private_root: Option<&Path>, -) -> String { - sanitize_diagnostic_message_inner(value, private_root, false) -} - -fn sanitize_diagnostic_message_inner( - value: &str, - private_root: Option<&Path>, - redact_paths: bool, -) -> String { let mut sanitized = value.replace(['\r', '\n'], " "); if let Some(root) = private_root { let root = root.to_string_lossy(); @@ -1768,9 +1728,7 @@ fn sanitize_diagnostic_message_inner( { return "".to_string(); } - if redact_paths { - sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); - } + sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); sanitized.chars().take(2_048).collect() } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 39086a3f5..874d626b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4149,14 +4149,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:并发执行全部 project verification 测试;同时真实运行 bwrap staged marker 用例,确认 child-created、block、ready、commit、exec 和目标退出链均稳定,目标 argv/env/FD 不含 nonce 或控制 socket。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs`、`command_sandbox.rs`、`command_exec.rs`、`project.rs`。 -## sandbox 启动失败不能把子进程 stderr 一起丢掉 - -- 现象:`command.exec` 在 `wait_sandbox_ready` 阶段失败时,模型和日志只看到 `等待 command sandbox ready 失败:Connection reset by peer (os error 104)`;无法判断是 userns、AppArmor、mount 还是 trampoline 自身失败,真实 bwrap 报错完全不可见。 -- 原因:bwrap/trampoline 以 `Stdio::piped()` 启动,但启动失败路径只调用 `terminate_project_command_process_group`(`kill` + `wait`)后即返回;`Child` 被回收时管道里的 stderr/stdout 一并丢失。项目自己的真实 launcher 测试(`command_sandbox.rs` 的 opt-in staged gate 用例)反而会 `wait_with_output()` 并把 stderr 打进 panic。 -- 处理:启动失败统一走 `terminate_project_command_launch_and_log_diagnostics`:先 `take()` stdout/stderr,再 kill + wait,然后在有界时间(2s)和字节(8 KiB/流)内读空,经 `sanitize_diagnostic_message_preserving_paths` 只做凭据脱敏、保留绝对路径(bwrap 的 `Can't find source path` 里路径就是唯一证据),写入宿主 `application.log`;诊断只进宿主诊断包,不进入模型可见错误、command log、receipt 或 Agent DB。默认的 `sanitize_diagnostic_message` 仍然整行脱敏绝对路径,只有这条宿主失败日志走保留路径的变体。 -- 验证:`sandbox_launch_diagnostics_keep_source_paths_and_redact_secrets` 断言源路径保留、凭据整体脱敏、纯空白流不产出行;`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test ... real_linux_opt_in` 的真实 bwrap 握手用例保持通过。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_exec.rs`、`command_sandbox.rs`、`command_sandbox_trampoline.rs`。 - ## bwrap 的命令分隔符不能从目标 argv 末尾反查 - 现象:普通命令握手正常,但 `cargo test -- --nocapture`、npm forwarded args 等包含独立 `--` 的合法目标参数可能在 sandbox preflight 或 staged launch 期间提前执行原目标。 From 66ff1d20aa210dd024311904d9d2ef5007324a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 15:51:24 +0800 Subject: [PATCH 054/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=EF=BC=9A=E5=8E=BB=E6=8E=89=E3=80=8C=E5=B7=B2=E8=AE=A9=E9=99=B6?= =?UTF-8?q?=E6=B3=A5=E5=84=BF=E2=80=A6=E3=80=8D=E8=BF=99=E7=B1=BB=E5=A4=8D?= =?UTF-8?q?=E8=BF=B0=EF=BC=8C=E7=8A=B6=E6=80=81=E8=A1=8C=E5=8F=AA=E8=AF=B4?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 一键适配、帮我填不再铺一句「已让陶泥儿帮你调通导出 / 来填这三项」:这两个动作就是用户刚点的那颗按钮,右侧会话就摆在旁边,复述只是噪音; - 打包现算发现没有脚本时,只把适配弹窗重新武装起来,状态行不再重复「项目还没配置好导出…」; - 失败修复的回执只留出错原因,去掉「已让陶泥儿来修。」这句叙述(失败卡片收掉后,原因仍是唯一说明「刚才为什么开这一轮」的地方); - `notice` 的语义写进注释:读不到内容、打包结果、指令没发出去、失败修复的原因;用户刚点的动作不回执; - 用例跟着改:没脚本时 `notice` 为空。 --- .../export/state/useXhsMinitoolExport.ts | 35 +++++++------------ .../tests/xhsMinitoolExport.test.tsx | 5 ++- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 39f9450e0..bc1cf6086 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -89,6 +89,8 @@ export function useXhsMinitoolExport({ const [fieldError, setFieldError] = useState(null); const [conflict, setConflict] = useState(null); const [failure, setFailure] = useState(null); + // 界面上「有事要说」的那一句:读不到内容、打包结果、指令没发出去、失败修复的原因。 + // 用户刚点的动作本身不回执——「已交给陶泥儿」这类复述是噪音。 const [notice, setNotice] = useState(''); const [loading, setLoading] = useState(false); /** @@ -199,8 +201,8 @@ export function useXhsMinitoolExport({ setFieldError(null); setConflict(null); setFailure(null); - // 只撤掉「读不到」那一句:适配 / 打包的回执也是 `notice`,读到内容不等于用户读过了它, - // 每 2 秒重读一次就把回执擦掉会让回执根本来不及被看见。 + // 只撤掉「读不到」那一句:打包结果、失败修复的原因也是 `notice`,读到内容不等于用户读过了它, + // 每 2 秒重读一次就把它们擦掉会让这些话根本来不及被看见。 setNotice((previous) => previous === CONTENT_UNAVAILABLE_NOTICE ? '' : previous, ); @@ -392,12 +394,8 @@ export function useXhsMinitoolExport({ try { const hasScriptNow = await refresh(); if (hasScriptNow !== true) { + // 弹窗重新武装起来就是给用户的交代,状态行不再复述一遍。 setAdaptPromptDismissed(false); - if (hasScriptNow === false) { - setNotice( - '项目还没配置好导出,先让陶泥儿帮你调通,之后就能直接打包了', - ); - } return; } // 先 flush 自动保存:用户刚敲完就点打包时,注册表里可能还是上一份。 @@ -425,12 +423,12 @@ export function useXhsMinitoolExport({ /** * 把一条指令当用户消息发给项目会话。 * - * `sentNotice` 由调用方按「首次适配 / 失败修复」分别给:两条路径给 agent 的指令不同,给用户的 - * 回执也不该长一个样——失败修复那条必须把这次的失败原因复述出来,否则失败卡片收掉之后用户只剩 - * 一句「已交给 agent」。 + * 成功不回执:这几个动作都是用户刚点的那颗按钮,面板再复述一句「已交给陶泥儿」只是噪音。 + * 留在界面上的只有两种话——发不出去时的实话,和失败修复必须留下的出错原因(失败卡片收掉之后, + * 那是唯一还写着「刚才为什么开这一轮」的地方)。 */ const sendToAgent = useCallback( - async (instruction: string, sentNotice: string): Promise => { + async (instruction: string, sentNotice = ''): Promise => { const handedOver = await submitTurn([ { type: 'input_text', text: instruction }, ]); @@ -460,20 +458,14 @@ export function useXhsMinitoolExport({ if (dirty && !conflict && !fieldError) { await persist(form, baselineRef.current); } - await sendToAgent( - buildXhsMinitoolFormFillInstruction(), - '已让陶泥儿来填这三项,它改完会自动出现在上面', - ); + await sendToAgent(buildXhsMinitoolFormFillInstruction()); }, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]); /** 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 */ const adapt = useCallback((): void => { if (!projectPath) return; setAdaptPromptDismissed(true); - void sendToAgent( - buildXhsMinitoolAdaptationInstruction(), - '已让陶泥儿帮你调通导出,右侧会话里能看到它的进展', - ); + void sendToAgent(buildXhsMinitoolAdaptationInstruction()); }, [projectPath, sendToAgent]); /** @@ -496,10 +488,7 @@ export function useXhsMinitoolExport({ // 回执复述用户侧的说法;给 agent 的指令直接带原始错误,两者互不派生。 const reason = describeXhsMinitoolFailure(failure); setFailure(null); - void sendToAgent( - buildXhsMinitoolErrorFixInstruction(failure), - `已让陶泥儿来修。\n出错原因:${reason}`, - ); + void sendToAgent(buildXhsMinitoolErrorFixInstruction(failure), reason); }, [projectPath, failure, sendToAgent]); /** diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 5caf51eb3..48d772549 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -885,10 +885,9 @@ describe('状态 hook', () => { expect(calls).toContain('read_xhs_minitool_export'); expect(calls).not.toContain('run_xhs_minitool_export_build'); // 还没有脚本就把「让陶泥儿帮你调通」重新递出来,而不是报个错把人晾在原地。 + // 这一路只重新武装弹窗,状态行不再复述一遍。 expect(result.current.adaptPromptDismissed).toBe(false); - expect(result.current.notice).toBe( - '项目还没配置好导出,先让陶泥儿帮你调通,之后就能直接打包了', - ); + expect(result.current.notice).toBe(''); expect(result.current.running).toBe(false); }); From 7614f741e15140af642d671d53a3b75145ae610f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 16:09:58 +0800 Subject: [PATCH 055/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=EF=BC=9A=E5=A4=B1=E8=B4=A5=E4=B8=80=E5=BE=8B=E8=B5=B0=E9=98=BB?= =?UTF-8?q?=E6=96=AD=E5=BC=B9=E7=AA=97=EF=BC=8C=E5=8E=BB=E6=8E=89=E3=80=8C?= =?UTF-8?q?=E5=B7=B2=E4=BF=9D=E5=AD=98=E5=88=B0=20=E2=80=A6=E3=80=8D?= =?UTF-8?q?=E4=B8=8E=E6=95=B4=E6=9D=A1=E9=80=9A=E7=9F=A5=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败现场从就地卡片改成阻断弹窗(`FailureDialog` 取代 `FailureCard`):左半本来就矮,卡片会被滚出视野,而失败是用户必须看见的东西; - 弹窗按「谁有能力修」给按钮:项目还没适配给「让陶泥儿帮我调通」,适配过但这次没跑通给「让陶泥儿来修」,改不动的(权限策略拉黑 `command.exec`、宿主没跑起来)不给陶泥儿入口;每条都带「关闭」,关掉的现场记进 `dismissedFailure`,同一个现场不会因为下一次重读又跳回来——没有「重试」,关掉回面板点「打包」是同一件事; - 一键适配也清掉失败现场:从「还没配置好导出」那条弹窗进来的就是同一个 `buildScriptMissing`,不清它弹窗关不上; - 删掉通知行(那段 `role="status"`)与整个 `notice` 状态:保存产物、打包结果、入队结果都不再在导出面板里回执;读不到内容仍由那段占位文案交代,真故障仍走错误池; - 保存产物不再返回「已保存到 …」:落点与结果都是用户在原生对话框里亲手给的,失败只留一条控制台痕迹; - 输出尾部的拼句模块 `xhsMinitoolOutputTail.ts` 随之删除(唯一调用方就是那条通知); - 用例跟着改:删掉截断拼句那条,失败相关改称弹窗并断言 `role="dialog"`,补一条「关掉同一个现场不再弹」。 --- .../export/state/useXhsMinitoolExport.ts | 87 ++++++++---------- .../export/state/xhsMinitoolDownload.ts | 32 +++---- .../export/state/xhsMinitoolOutputTail.ts | 16 ---- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 47 ++++------ .../export/tabs/xiaohongshu/FailureCard.tsx | 59 ------------ .../export/tabs/xiaohongshu/FailureDialog.tsx | 74 +++++++++++++++ .../tests/xhsMinitoolExport.test.tsx | 91 ++++++++++--------- 7 files changed, 194 insertions(+), 212 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureCard.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index bc1cf6086..c6ebd6ebd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -36,7 +36,6 @@ import { import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure'; import { classifyXhsMinitoolFailure, - describeXhsMinitoolFailure, XhsMinitoolFailureRepairability, } from './xhsMinitoolFailure'; import { @@ -44,7 +43,6 @@ import { buildXhsMinitoolErrorFixInstruction, buildXhsMinitoolFormFillInstruction, } from './xhsMinitoolInstruction'; -import { describeOutputTail } from './xhsMinitoolOutputTail'; export type XhsMinitoolExportState = ReturnType; @@ -57,8 +55,6 @@ const AUTOSAVE_DELAY_MS = 600; * 每次只读内容(不含指纹),而且只在用户手上没有未保存输入时才采纳表单,所以轮询不会吃掉输入。 */ const CONTENT_REFRESH_INTERVAL_MS = 2000; -const NO_HOST_NOTICE = '要在客户端里打开导出面板,才能保存信息并生成代码包'; -const CONTENT_UNAVAILABLE_NOTICE = '没能读到导出信息,正在自动重试…'; /** 三个字段就是全部内容,逐字段比即可,不必引入深比较。 */ function sameForm( @@ -89,9 +85,11 @@ export function useXhsMinitoolExport({ const [fieldError, setFieldError] = useState(null); const [conflict, setConflict] = useState(null); const [failure, setFailure] = useState(null); - // 界面上「有事要说」的那一句:读不到内容、打包结果、指令没发出去、失败修复的原因。 - // 用户刚点的动作本身不回执——「已交给陶泥儿」这类复述是噪音。 - const [notice, setNotice] = useState(''); + /** + * 被用户关掉的失败现场指纹:内容读不回来时每 2 秒重读一次,同一个现场会一直被送回来, + * 关掉之后就不该再弹;换了一个新的现场(指纹不同)照弹。 + */ + const [dismissedFailure, setDismissedFailure] = useState(''); const [loading, setLoading] = useState(false); /** * 首次读取是否已经落定(无论成功、结构化失败还是认不出形状的拒绝)。 @@ -175,10 +173,7 @@ export function useXhsMinitoolExport({ const refresh = useCallback(async (): Promise => { if (!projectPath) return null; const invoke = resolveTauriInvoke(); - if (!invoke) { - setNotice(NO_HOST_NOTICE); - return null; - } + if (!invoke) return null; // 只有首次读取才翻 `loading`:自动重读每 2 秒一次,每次都翻一下这个状态,界面上的 // 按钮与卡片就会跟着一闪一闪(未适配提示、按钮行的「适配」都以它为判据之一)。 const isInitialRead = !initialReadSettledRef.current; @@ -201,11 +196,6 @@ export function useXhsMinitoolExport({ setFieldError(null); setConflict(null); setFailure(null); - // 只撤掉「读不到」那一句:打包结果、失败修复的原因也是 `notice`,读到内容不等于用户读过了它, - // 每 2 秒重读一次就把它们擦掉会让这些话根本来不及被看见。 - setNotice((previous) => - previous === CONTENT_UNAVAILABLE_NOTICE ? '' : previous, - ); baselineRef.current = await readXhsMinitoolExportHash( invoke, projectPath, @@ -214,9 +204,8 @@ export function useXhsMinitoolExport({ } catch (error) { const structured = asXhsMinitoolReadFailure(error); if (!structured) { - // 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):面板留一句实话, - // 现场原样抛给错误池结算。没有这一句,面板会停在「正在读取」上装死。 - setNotice(CONTENT_UNAVAILABLE_NOTICE); + // 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):现场原样抛给错误池 + // 结算;面板那一侧退回「没能读到导出信息,正在自动重试…」,不会停在「正在读取」上装死。 throwToErrorPool(String(error), error); return null; } @@ -257,10 +246,7 @@ export function useXhsMinitoolExport({ async (next: XHSMiniToolExportForm, baseHash: string): Promise => { if (!projectPath) return false; const invoke = resolveTauriInvoke(); - if (!invoke) { - setNotice(NO_HOST_NOTICE); - return false; - } + if (!invoke) return false; try { baselineRef.current = await saveXhsMinitoolExportForm(invoke, { projectPath, @@ -335,21 +321,11 @@ export function useXhsMinitoolExport({ const buildOnce = useCallback(async (): Promise => { if (!projectPath) return; const invoke = resolveTauriInvoke(); - if (!invoke) { - setNotice(NO_HOST_NOTICE); - return; - } - setNotice(''); + if (!invoke) return; setFailure(null); try { - const result = await runXhsMinitoolExportBuild(invoke, projectPath); + await runXhsMinitoolExportBuild(invoke, projectPath); setArtifactReady(true); - // 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。 - setNotice( - ['代码包已重新生成,点「下载」保存到本地。', describeOutputTail(result)] - .filter((line) => line.trim() !== '') - .join('\n'), - ); } catch (error) { setArtifactReady(false); const structured = asXhsMinitoolBuildFailure(error); @@ -423,16 +399,12 @@ export function useXhsMinitoolExport({ /** * 把一条指令当用户消息发给项目会话。 * - * 成功不回执:这几个动作都是用户刚点的那颗按钮,面板再复述一句「已交给陶泥儿」只是噪音。 - * 留在界面上的只有两种话——发不出去时的实话,和失败修复必须留下的出错原因(失败卡片收掉之后, - * 那是唯一还写着「刚才为什么开这一轮」的地方)。 + * 入队结果不在导出面板里复述:走的与会话输入框同一条 `submit`,忙态、队满、发不出去都由会话 + * 那一侧告知;用户刚点的动作再铺一句回执只是噪音。 */ const sendToAgent = useCallback( - async (instruction: string, sentNotice = ''): Promise => { - const handedOver = await submitTurn([ - { type: 'input_text', text: instruction }, - ]); - setNotice(handedOver ? sentNotice : '这次没能发出去,原因在右侧会话里'); + async (instruction: string): Promise => { + await submitTurn([{ type: 'input_text', text: instruction }]); }, [submitTurn], ); @@ -442,6 +414,13 @@ export function useXhsMinitoolExport({ setAdaptPromptDismissed(true); }, []); + /** 关掉失败弹窗:现场记进 `dismissedFailure`,同一个现场不会因为下一次重读又跳出来。 */ + const dismissFailure = useCallback((): void => { + if (!failure) return; + setDismissedFailure(JSON.stringify(failure)); + setFailure(null); + }, [failure]); + /** * 「帮我填」:把手上没保存的输入先落盘,再让 agent 直接改注册表把表单三项填好。 * @@ -461,10 +440,16 @@ export function useXhsMinitoolExport({ await sendToAgent(buildXhsMinitoolFormFillInstruction()); }, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]); - /** 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 */ + /** + * 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 + * + * 也清掉失败现场:入口可能来自失败弹窗(「还没配置好导出」那是同一个 `buildScriptMissing`), + * 留着它弹窗就关不上,而这一颗按钮已经把用户要交给陶泥儿的事交出去了。 + */ const adapt = useCallback((): void => { if (!projectPath) return; setAdaptPromptDismissed(true); + setFailure(null); void sendToAgent(buildXhsMinitoolAdaptationInstruction()); }, [projectPath, sendToAgent]); @@ -475,7 +460,7 @@ export function useXhsMinitoolExport({ * 项目权限策略拉黑了 `command.exec` 是用户自己的配置(agent 受同一条策略约束)、宿主没跑起来改代码没用—— * 这些叫 agent 进来它只会去改一份没问题可改的代码。 * - * 用户回执带失败原因:卡片随即被撤掉,这句提示是面板上唯一还留着「刚才为什么开这一轮」的地方。 + * 失败现场当场清掉:弹窗由它开关,指令本身已经带着原始错误交给陶泥儿了。 */ const repair = useCallback((): void => { if (!projectPath || !failure) return; @@ -485,10 +470,8 @@ export function useXhsMinitoolExport({ ) { return; } - // 回执复述用户侧的说法;给 agent 的指令直接带原始错误,两者互不派生。 - const reason = describeXhsMinitoolFailure(failure); setFailure(null); - void sendToAgent(buildXhsMinitoolErrorFixInstruction(failure), reason); + void sendToAgent(buildXhsMinitoolErrorFixInstruction(failure)); }, [projectPath, failure, sendToAgent]); /** @@ -497,13 +480,16 @@ export function useXhsMinitoolExport({ * 同步细节(写回基线、保存中、待保存)刻意不在这里:它们没有对应的界面,露出来只会诱使调用方 * 拿它做判断。刷新也不是对外动作——重读内容由自动刷新和「打包」之后的现算负责。 */ + /** 对外只给没被关掉的那个现场:关掉的还是同一个就不弹。 */ + const visibleFailure = + failure && JSON.stringify(failure) === dismissedFailure ? null : failure; + return { form, hasScript, fieldError, conflict, - failure, - notice, + failure: visibleFailure, loading, readSettled, running, @@ -517,5 +503,6 @@ export function useXhsMinitoolExport({ repair, fillForm, dismissAdaptPrompt, + dismissFailure, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts index 2527e7320..3f9bbb48e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts @@ -3,13 +3,15 @@ * * 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护 * 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。 + * + * 不回话也不抛:落点与结果都是用户在原生对话框里亲手给的,面板不再补一句「已保存到 …」; + * 失败只留一条控制台痕迹,不打断用户手上的事。 */ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { resolveTauriInvoke } from '../../../../app/tauri'; import { resourceFileNameFromPath } from '../../../../features/resource-canvas/resourceCanvasAssetTransferModel'; -/** 返回给用户看的一句话;用户取消对话框时返回空串(不打扰)。 */ export async function saveXhsMinitoolFileToDisk(input: { projectPath: string; /** 项目内相对路径,交给宿主的既有门禁复核。 */ @@ -17,9 +19,9 @@ export async function saveXhsMinitoolFileToDisk(input: { /** 路径里取不出文件名时用的兜底名。 */ fallbackFileName: string; title: string; -}): Promise { +}): Promise { const invoke = resolveTauriInvoke(); - if (!invoke) return '要在客户端里打开导出面板才能保存文件'; + if (!invoke) return; let destination: string | null = null; try { destination = await saveNativeFileDialog({ @@ -30,22 +32,20 @@ export async function saveXhsMinitoolFileToDisk(input: { title: input.title, }); } catch (error) { - return error instanceof Error ? error.message : String(error); + console.warn('导出产物保存对话框没能打开', error); + return; } - if (!destination) return ''; + // 用户取消对话框:什么都没发生,也没有话要说。 + if (!destination) return; try { - const result = await invoke<{ destinationPath: string }>( - 'save_local_project_asset_file', - { - input: { - projectPath: input.projectPath, - relativePath: input.relativePath, - destinationPath: destination, - }, + await invoke('save_local_project_asset_file', { + input: { + projectPath: input.projectPath, + relativePath: input.relativePath, + destinationPath: destination, }, - ); - return `已保存到 ${result.destinationPath}`; + }); } catch (error) { - return error instanceof Error ? error.message : String(error); + console.warn('导出产物没能写到你选的位置', error); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts deleted file mode 100644 index e7e1f2ca2..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。 - * - * 宿主不预拼用户可见文案:省略量是事实, - * 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。 - * 失败卡片与导出成功提示共用这一份,避免两处各写一句。 - */ -export function describeOutputTail(input: { - outputTail: string; - omittedCharacters: number; -}): string { - const tail = input.outputTail.trim(); - if (input.omittedCharacters <= 0) return tail; - const note = `(内容较长,前面省略了 ${input.omittedCharacters} 个字符)`; - return tail ? `${note}\n${tail}` : note; -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index e323992bc..414cb0e62 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -1,11 +1,9 @@ -import { useState } from 'react'; - import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction'; import { AdaptPromptDialog } from './AdaptPromptDialog'; import { ConflictCard } from './ConflictCard'; -import { FailureCard } from './FailureCard'; +import { FailureDialog } from './FailureDialog'; import { FormCard } from './FormCard'; import { PaneButton } from './PaneButton'; @@ -19,7 +17,9 @@ import { PaneButton } from './PaneButton'; * 内容与脚本判据按固定间隔重读(所以没有刷新按钮)。 * * 「还没配置好导出」不再是一张常驻卡片,而是阻断弹窗:重读每 2 秒一次,卡片会跟着重读闪,弹窗则 - * 只在用户真的需要做选择时出现一次。可见文案说用户的话,不出现脚本名、注册表、zip 这些实现词。 + * 只在用户真的需要做选择时出现一次。失败现场同理走阻断弹窗(`FailureDialog`):左半本来就矮, + * 卡片会被滚出视野,而失败是用户必须看见的东西。可见文案说用户的话,不出现脚本名、注册表、zip + * 这些实现词。 */ export function ArtifactsPane({ minitool, @@ -28,8 +28,7 @@ export function ArtifactsPane({ minitool: XhsMinitoolExportState; projectPath: string | null; }) { - const [downloadNotice, setDownloadNotice] = useState(''); - const { form, conflict } = minitool; + const { form, conflict, failure } = minitool; /** * 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下 * 「还没适配」的结论——那种情况只该显示读取中或失败现场。 @@ -47,6 +46,7 @@ export function ArtifactsPane({ */ const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed; + // 保存不打回执:用户刚在原生对话框里选完落点,磁盘上有没有那个文件是他自己看得见的事实。 const downloadIcon = (): void => { if (!projectPath || !form) return; void saveXhsMinitoolFileToDisk({ @@ -54,7 +54,7 @@ export function ArtifactsPane({ relativePath: form.iconPath.trim(), fallbackFileName: 'icon.png', title: '保存图标文件', - }).then(setDownloadNotice); + }); }; const downloadArtifact = (): void => { @@ -64,7 +64,7 @@ export function ArtifactsPane({ relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, fallbackFileName: 'xhs-minitool.zip', title: '保存代码包', - }).then(setDownloadNotice); + }); }; return ( @@ -84,10 +84,10 @@ export function ArtifactsPane({ /> ) : !minitool.readSettled ? (

正在读取导出信息…

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

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

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

- {downloadNotice || minitool.notice} -

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

- {describeXhsMinitoolFailure(failure)} -

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

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

+
+ 关闭 + {agentAction === null ? null : ( + + {agentAction} + + )} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 48d772549..ba30ec15d 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -4,7 +4,7 @@ * * 三个切面各钉一块: * - 交给 code agent 的指令正文(纯字符串组装,含三条硬契约与要拷的示例脚本); - * - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步); + * - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败弹窗的下一步); * - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。 */ import { writeText } from '@tauri-apps/plugin-clipboard-manager'; @@ -462,7 +462,7 @@ describe('面板左半', () => { ).toBeNull(); }); - it('读落定之后失败就不再假装还在读:上面交给失败卡片,不重复一句话', () => { + it('读落定之后失败就不再假装还在读:交给失败弹窗,不重复一句话', () => { renderPane( fakeMinitool({ form: null, @@ -472,7 +472,8 @@ describe('面板左半', () => { ); expect(screen.queryByText('正在读取导出信息…')).toBeNull(); - expect(screen.getByRole('alert')).toBeTruthy(); + // 失败一律走阻断弹窗:左半矮,就地卡片会被滚出视野。 + expect(screen.getByRole('dialog', { name: '导出失败' })).toBeTruthy(); }); it('认不出形状的拒绝给一句实话,不装死在「正在读取」上', () => { @@ -505,7 +506,7 @@ describe('面板左半', () => { }); }); - it('失败卡片按变体给出「让陶泥儿调通」或「让陶泥儿来修」', () => { + it('失败弹窗按变体给出「让陶泥儿调通」或「让陶泥儿来修」', () => { // 还没配置好导出:入队的必须是首次适配指令,修复指令会引用一份还不存在的适配说明。 const neverAdapted = fakeMinitool({ hasScript: false, @@ -517,7 +518,7 @@ describe('面板左半', () => { }); renderPane(neverAdapted); - // 卡片说用户看得懂的话,不把脚本名与宿主找过哪些目录抖出来。 + // 弹窗说用户看得懂的话,不把脚本名与宿主找过哪些目录抖出来。 screen.getByText( '这个项目还没有配置好导出。先让陶泥儿帮你调通一次,之后就能直接打包了。', ); @@ -526,6 +527,7 @@ describe('面板左半', () => { fireEvent.click(screen.getByRole('button', { name: '让陶泥儿帮我调通' })); expect(neverAdapted.adapt).toHaveBeenCalledTimes(1); expect(neverAdapted.repair).not.toHaveBeenCalled(); + expect(neverAdapted.dismissFailure).not.toHaveBeenCalled(); // 配置过但这次没跑通:入队修复指令,带上这次的现场。 cleanup(); @@ -544,9 +546,9 @@ describe('面板左半', () => { expect(broken.adapt).not.toHaveBeenCalled(); }); - it('只能人来改的失败,卡片不给任何「让陶泥儿」入口', () => { + it('只能人来改的失败,弹窗不给任何「让陶泥儿」入口,只给关闭', () => { // command.exec 被项目权限策略拉黑,改策略的是用户,agent 受同一条策略约束; - // 宿主没跑起来更是只能重试。`saveConflict` 到不了这里——它有冲突面板。 + // 宿主没跑起来也一样。`saveConflict` 到不了这里——它有冲突面板。 const humanOnlyFailures: XhsMinitoolCardFailure[] = [ { type: 'commandDenied', commandId: 'command.exec' }, { type: 'exportUnavailable', cause: '项目根不可用:找不到目录' }, @@ -560,13 +562,49 @@ describe('面板左半', () => { expect( screen.queryByRole('button', { name: '让陶泥儿帮我调通' }), ).toBeNull(); - // 用户能做的只有重试;现场留着,等用户去界面上改。 - fireEvent.click(screen.getByRole('button', { name: '重试' })); - expect(minitool.runExport).toHaveBeenCalledTimes(1); + // 没有「重试」这颗按钮:关掉弹窗回面板自己点「打包」是同一件事。 + expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + expect(minitool.dismissFailure).toHaveBeenCalledTimes(1); + expect(minitool.runExport).not.toHaveBeenCalled(); expect(minitool.repair).not.toHaveBeenCalled(); expect(minitool.adapt).not.toHaveBeenCalled(); } }); + + it('关掉失败弹窗后,下一次重读把同一个现场送回来也不再弹', async () => { + // 内容读不回来时每 2 秒重读一次,同一个现场会一直被送回来:关一次就该一直安静, + // 否则弹窗会自己跳出来跟用户抢界面。 + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + throw { type: 'registryMalformed', cause: 'bad json' }; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => + expect(result.current.failure).toEqual({ + type: 'registryMalformed', + cause: 'bad json', + }), + ); + + await act(async () => { + result.current.dismissFailure(); + }); + expect(result.current.failure).toBeNull(); + + // 「打包」会现算一次要不要构建,这条路上又读了一次内容——同一个现场不许再弹。 + await act(async () => { + await result.current.packageAll(); + }); + expect(result.current.failure).toBeNull(); + }); }); describe('命令失败各有一条表', () => { @@ -779,34 +817,6 @@ describe('状态 hook', () => { expect(result.current.artifactReady).toBe(false); }); - it('输出被宿主截断时,说明那句话由前端拼', async () => { - const invoke = vi.fn(async (command: string) => { - if (command === 'read_xhs_minitool_export') { - return { form: FORM, hasScript: true }; - } - if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; - return { outputTail: 'zip 已生成', omittedCharacters: 4096 }; - }); - installTauriRuntime({ core: { invoke } }); - const { result } = renderHook(() => - useXhsMinitoolExport({ - enabled: true, - projectPath: PROJECT_PATH, - submitTurn: vi.fn(async () => true), - }), - ); - await waitFor(() => expect(result.current.form).toEqual(FORM)); - - await act(async () => { - await result.current.runExport(); - }); - - // 宿主只回事实(原文 + 省略量),提示里那句「已省略」是这里拼的。 - expect(result.current.notice).toBe( - '代码包已重新生成,点「下载」保存到本地。\n(内容较长,前面省略了 4096 个字符)\nzip 已生成', - ); - }); - it('打包先把没保存的输入落盘,成功后再跑构建', async () => { const calls: string[] = []; const invoke = vi.fn(async (command: string) => { @@ -885,9 +895,7 @@ describe('状态 hook', () => { expect(calls).toContain('read_xhs_minitool_export'); expect(calls).not.toContain('run_xhs_minitool_export_build'); // 还没有脚本就把「让陶泥儿帮你调通」重新递出来,而不是报个错把人晾在原地。 - // 这一路只重新武装弹窗,状态行不再复述一遍。 expect(result.current.adaptPromptDismissed).toBe(false); - expect(result.current.notice).toBe(''); expect(result.current.running).toBe(false); }); @@ -981,7 +989,6 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await expect(result.current.runExport()).rejects.toBe(transport); - expect(result.current.notice).toBe(''); expect(result.current.failure).toBeNull(); // 同一个故障只抛一次:轮询每 2 秒重读一次,不能让错误池的 count 变成轮询次数。 await expect(result.current.runExport()).resolves.toBeUndefined(); @@ -1230,7 +1237,6 @@ function fakeMinitool( fieldError: null, conflict: null, failure: null, - notice: '', loading: false, readSettled: true, running: false, @@ -1244,6 +1250,7 @@ function fakeMinitool( repair: vi.fn(), fillForm: vi.fn(async () => undefined), dismissAdaptPrompt: vi.fn(), + dismissFailure: vi.fn(), ...overrides, }; } From 21a2856661c5e4e3b05be831fc914d91f4ff8aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 16:10:16 +0800 Subject: [PATCH 056/156] =?UTF-8?q?AGC=20=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20skill=EF=BC=9Adist=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=E7=A7=BB=E8=BF=9B=20vite=20=E6=8F=92=E4=BB=B6=EF=BC=8Cpack=20?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E4=B8=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `xhsMinitoolArtifactPlugin`(`apply: 'build'` + `enforce: 'post'` 的 `closeBundle`):相对路径改写、去 `type="module"` / `crossorigin`、脚本移到 body 末尾、清空空目录、`copyPublicDir: false` 时的 icon 兜底都在构建收尾做完,dist 出来即合规; - `pack.mjs` 只做白名单预检与打 zip,不再改写 dist:validate 看到的不再是「只有跑过 pack 才合规」的中间态; - 新增 `.vite-config.test.mjs`:真跑一遍 `vite build` 断言上述收尾,并覆盖 icon 兜底、构建失败不留半成品 dist、`pruneEmptyDirs` 只删空目录;`.pack.test.mjs` 补断言 pack 不改写 index.html; - SKILL.md 写清两段构建、以及「项目已有 vite.config 时要单独把插件加进 plugins」;`.selective_rule.txt` 记下这次职责归位的原因与验证口径; - manifest 指纹同步(version=2026-08-26.51)。 --- .../resources/agc-skills/manifest.json | 4 +- .../.selective_rule.txt | 6 + .../vite-export-xhs-minitool/SKILL.md | 7 +- .../scripts/.pack.test.mjs | 21 ++- .../scripts/.vite-config.test.mjs | 153 ++++++++++++++++++ .../vite-export-xhs-minitool/scripts/pack.mjs | 76 ++------- .../scripts/vite.config.xhs-minitool.mjs | 137 +++++++++++++++- 7 files changed, 330 insertions(+), 74 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs 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 294866c32..8b90908b4 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.50", + "version": "2026-08-26.51", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "83f78c763407f749ffeb859843a111fe6922a77971cc84be35a6d618de4745b1" + "sha256": "25e302b74418c81511f95684f4752f227018d89ab1a3bf4e53a73d1516740f19" } ] } 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 index a7d556b5a..cbfb8d2df 100644 --- 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 @@ -71,3 +71,9 @@ - scripts/vite.config.xhs-minitool.mjs(原 packages/vite-config-example/vite.config.xhs-minitool.mjs) - references/MANUAL_CHECKS.md(原 packages/validate-script/MANUAL_CHECKS.md) - pack.mjs 的 import 改为 './validate.mjs';performance-budget.md 的门禁链接改为 '../scripts/validate.mjs';xhs/packages 已空。 + +收尾职责归位(2026-10-05) +- 背景:pack.mjs 之前会就地改写 dist(把 index.html 改成相对路径 / 去 module 痕迹 / 脚本移到 body 末尾 / 删空目录 / 兜底复制 icon),再打 zip。dist 只有跑完 pack 才合规,validate 看到的是中间态。 +- 决策:dist 收尾移进 `scripts/vite.config.xhs-minitool.mjs` 的 `xhsMinitoolArtifactPlugin`(`closeBundle` 钩子,`apply: 'build'` + `enforce: 'post'`)。构建结束 dist 即合规;`pack.mjs` 只做白名单预检与打 zip,不再写盘。 +- 口径:编译约束留在 `minitoolBaseConfig`,收尾只在插件里做一次,禁止在 pack 或项目脚本里重复。 +- 验证:`scripts/.vite-config.test.mjs` 真跑一遍 `vite build`,断言 dist 无 `type="module"` / `crossorigin` / 根路径、脚本在 body 末尾、空目录被清、`copyPublicDir: false` 时 icon 兜底;`scripts/.pack.test.mjs` 断言 pack 不再改写 index.html。 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 index f9dd9a1ff..c693ee429 100644 --- 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 @@ -8,10 +8,10 @@ metadata: # 目标: * 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip - 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. + 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. 构建分两段: `minitoolBaseConfig` 管编译约束, 配置内置的 `xhsMinitoolArtifactPlugin` 在 build 收尾时把 dist 整理成可直接打 zip 的形状(相对路径 / 去 module 痕迹 / 脚本移到 body 末尾 / 清空空目录 / 兜底 icon). **不要**在 pack 或别处重复这些收尾. 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: `references/manual-checks.md` - 3. 使用 `scripts/pack.mjs` 打包成zip + 3. 使用 `scripts/pack.mjs` 打包成zip; 它只读 dist 打 zip, 不改写 dist 4. 跑通流程后请把需要的脚本配置复制到项目里(以免依赖skill), 并形成最终的打包脚本 * 不干扰正常的web构建 @@ -23,11 +23,12 @@ metadata: # 脚本参数: | 脚本 | 参数 | |----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| -| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并); `xhsMinitoolArtifactPlugin()`(已有 vite.config 时单独加插件做收尾) | | `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | | `scripts/pack.mjs` | `--vite-built-dir `、`--zip-out ` | # 一些情况: +* 项目已有自己的 vite.config 时, 用 `defineMinitoolConfig(overrides)` 合并, 或至少把 `xhsMinitoolArtifactPlugin()` 加进 `plugins`; 漏掉插件会让 dist 保留 `type="module"` / 绝对路径, validate 会直接报错. * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 * 如果使用了外部能力, 参考platform-abstract skill对项目先重构 * 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 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 index 83f5596c5..51f5585eb 100644 --- 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 @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -81,6 +81,25 @@ test('干净产物目录仍能正常打包', () => { assert.ok(existsSync(zipOut), 'zip 未生成'); }); +test('pack 只打 zip,不再改写产物目录', () => { + const root = writeBuiltDir('no-rewrite'); + // 故意留一份「构建后还没收尾」的 index.html:type=module / crossorigin / 根路径脚本。 + const unfinalized = '' + + '' + + '' + + '' + + ''; + 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('CLI 拒绝不认识的参数,不写出 zip', () => { const dir = writeBuiltDir('cli'); const zipOut = join(BASE, 'cli-out.zip'); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs new file mode 100644 index 000000000..ce88ab25d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs @@ -0,0 +1,153 @@ +#!/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); -} - -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 }); - } - } -} - /* ------------------------------------------------------------------ */ /* 纯 Node zip 写入(store / deflate,UTF-8 文件名,ZIP64 直接报错) */ /* ------------------------------------------------------------------ */ @@ -273,7 +231,6 @@ function makeZip(viteBuiltDir, zipPath) { * cwd?: string, * viteBuiltDir?: string, * zipOut: string, - * publicDir?: string, * }} options */ export function packMinitool(options = {}) { @@ -283,31 +240,18 @@ export function packMinitool(options = {}) { const cwd = resolve(options.cwd || process.cwd()); const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool'); const zipPath = resolve(cwd, options.zipOut); - const publicDir = resolve(cwd, options.publicDir || 'public'); if (!existsSync(viteBuiltDir)) { throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); } - // 先预检、后改动:白名单外的文件直接报错,不删除。指错目录时也不会误删源码。 + // 只预检、不改写:白名单外的文件直接报错。产物收尾已由 vite 插件做完,这里不应再动 dist。 assertNoUnsupportedFiles(viteBuiltDir); // 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。 mkdirSync(dirname(zipPath), { recursive: true }); - for (const icon of ['icon-192.svg', 'icon-512.svg']) { - const dest = join(viteBuiltDir, 'icons', icon); - const src = join(publicDir, 'icons', icon); - if (!existsSync(dest) && existsSync(src)) { - mkdirSync(dirname(dest), { recursive: true }); - copyFileSync(src, dest); - } - } - - pruneEmptyDirs(viteBuiltDir); - patchIndexHtml(viteBuiltDir); makeZip(viteBuiltDir, zipPath); - console.log(`\n小红书小工具包已整理:${viteBuiltDir}`); return 0; } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs index 85cbce8b6..037b5b5a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs @@ -8,12 +8,30 @@ * import { defineMinitoolConfig } from '<此文件路径>'; * export default defineMinitoolConfig({ plugins: [...] }); * + * 构建分两段,边界只有一条:**本配置负责把 dist 变成合规产物,pack.mjs 只负责打 zip。** + * + * - 编译段:`minitoolBaseConfig` 固定 base / target / 单入口 IIFE / 无 sourcemap 等约束。 + * - 收尾段:`xhsMinitoolArtifactPlugin` 在 bundle 写盘后原地整理 dist——相对路径、去掉 + * module 痕迹、脚本移到 前、清空目录、兜底 icon。这样 dist 在任何时刻都是 + * 「可校验、可直接打 zip」的状态,不需要 pack 再回头改它。 + * * 下面 build.outDir 的值与打包脚本的 --vite-built-dir 是同一个目录,两处必须一致: * pack.mjs 会在打包前预检该目录;出现白名单之外的文件类型会直接报错,不会删除任何文件。 * - * 约束来源:minitool-zip-builder/references/js-compatibility.md、css-compatibility.md、 - * zip-artifact-spec.md —— Chrome 61 / ES2017 基线、相对路径、IIFE 单入口、无 sourcemap。 + * 约束来源:references/js-compatibility.md、css-compatibility.md、zip-artifact-spec.md + * —— Chrome 61 / ES2017 基线、相对路径、IIFE 单入口、无 sourcemap。 */ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { defineConfig, mergeConfig } from 'vite'; /** JS 最低基线:Android 8.1 出厂 Chrome / WebView 61(完整支持 ES2017)。 */ @@ -21,6 +39,120 @@ export const MINITOOL_JS_TARGET = ['es2017', 'chrome61']; /** CSS 目标内核:Chrome 61;iOS 18.4 能力只走能力检测后的增强路径。 */ export const MINITOOL_CSS_TARGET = ['chrome61']; +/** 小红书要求包内自带的小工具 icon;publicDir 被关掉时由插件兜底复制。 */ +const FALLBACK_ICONS = ['icon-192.svg', 'icon-512.svg']; + +/** + * 把 vite 生成的绝对资源路径改为相对路径,并抹掉 module / 预加载痕迹。 + * + * 幂等:对已经处理过的 index.html 再跑一次不会重复改。缺失 index.html 时直接抛错, + * 因为那说明构建配置指错了目录,继续打 zip 只会交付一个坏包。 + * + * @param {string} outDir 已解析为绝对路径的构建输出目录 + */ +export function patchIndexHtml(outDir) { + const htmlPath = join(outDir, 'index.html'); + if (!existsSync(htmlPath)) { + throw new Error(`${htmlPath} 不存在,请先执行 vite build`); + } + + let html = readFileSync(htmlPath, 'utf8'); + + html = html.replace(/(href|src)="\/([^"]+)"/g, '$1="./$2"'); + html = html.replace(/\s+crossorigin(?:="[^"]*")?/g, ''); + html = html.replace(/\s+type="module"/gi, ''); + html = html.replace(/]+rel="(?:manifest|modulepreload)"[^>]*>/gi, ''); + + const scripts = []; + html = html.replace( + /]*)?\ssrc="(\.\/[^"]+)"(?:\s[^>]*)?>\s*<\/script>\s*/gi, + (_m, src) => { + scripts.push(``); + 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: 'xhs-minitool-artifact', + 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: { @@ -52,6 +184,7 @@ export const minitoolBaseConfig = { }, }, }, + plugins: [xhsMinitoolArtifactPlugin()], }; /** From 58f8c2065f91088f5d061fc9b6fb3e36b3f6d43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 16:21:19 +0800 Subject: [PATCH 057/156] =?UTF-8?q?AGC=20=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20skill=EF=BC=9A=E6=B5=8B=E8=AF=95=E6=8C=89?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=91=BD=E5=90=8D=EF=BC=8C=E5=8F=96=E8=88=8D?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=94=B9=E5=9B=9E=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 隐藏测试 .vite-config.test.mjs 重命名为 .vite.config.xhs-minitool.test.mjs,与 pack.mjs → .pack.test.mjs、validate.mjs → .validate.test.mjs 的命名口径一致; - .selective_rule.txt 去掉日期段落,把「二次清理」的条目并进 SKILL.md / zip-artifact-spec.md 对应条目,恢复为逐文件「删除与去处」清单; - decision-log 补记 dist 收尾归 Vite 插件、pack 只读打 zip 的决策、失败语义与验证口径; - skill-pack 指纹无需重算:本次改动均为隐藏文件与文档,manifest 仍为 2026-08-26.51。 --- .../.selective_rule.txt | 30 +++---------------- ...mjs => .vite.config.xhs-minitool.test.mjs} | 0 .../shared-memory/decision-log.md | 10 +++++++ 3 files changed, 14 insertions(+), 26 deletions(-) rename apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/{.vite-config.test.mjs => .vite.config.xhs-minitool.test.mjs} (100%) 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 index cbfb8d2df..90e92c189 100644 --- 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 @@ -1,5 +1,4 @@ 【取舍记录】minitool-zip-builder MUST-only 清理 -日期:2026-10-04 原则 - references/ 只保留 MUST(必须 / 须 / 禁止 / 不得),非 MUST 的建议、可选、工具用法全部删除。 @@ -15,12 +14,15 @@ - 步骤 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 已声明)。 - - 保留 §2 文件类型、§3 容器 CSP、§5 index.html 要求。 + - 删 §5 的「index.html 模板」整段 HTML 骨架与其中的 reset / 字体 CSS;标题改为「index.html 要求」,只留要求表与 说明。 + - 原因:模板是 pack 之后的 dist 形状,对 Vite 源 index.html 是错的(源需要 <script type="module"> 入口);reset / 字体 CSS 不是容器要求;doctype / charset / lang 由 validate 的 *_MISSING 兜。 + - 保留 §2 文件类型、§3 容器 CSP、§5 index.html 要求(viewport 含 width=device-width, initial-scale=1.0, viewport-fit=cover;不用 <base href>;不引用外部资源;不自建 CSP <meta>)。 - device-capabilities.md - 删 §6 能力扫描清单:validate 的禁用 API code 已全覆盖。 - 保留 §1–§5(可用能力、禁用能力与替代、常见交互写法)。 @@ -53,27 +55,3 @@ 口径说明(避免后续误判为缺失) - 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。 - -后续更新规则 -- references/ 只加 MUST。建议、经验、数值标定写进脚本(带检查码)或本文件,不写回 references。 - -二次清理(同日) -- zip-artifact-spec.md §5:删掉「index.html 模板」整段 HTML 骨架和其中的 reset / 字体 CSS;标题改为「index.html 要求」,只留要求表与 <title> 说明。 - - 原因:模板是 pack 之后的 dist 形状,对 Vite 源 index.html 是错的(源需要 <script type="module"> 入口);reset / 字体 CSS 不是容器要求;doctype / charset / lang 由 validate 的 *_MISSING 兜。 - - 保留的 4 条要求:viewport 含 width=device-width, initial-scale=1.0, viewport-fit=cover;不用 <base href>;不引用外部资源;不自建 CSP <meta>。 -- SKILL.md:被外部精简重写为 vite-export-xhs-minitool,只剩 Reference 表,原步骤 9 / 10 已不存在;Reference 表指向「index.html 要求」。 -- 本文件名由「取舍记录.txt」改为 selective_rule.txt。 - -目录调整(同日) -- packages/pack-script、packages/validate-script、packages/vite-config-example 已合并进本 skill: - - scripts/pack.mjs(原 packages/pack-script/pack.mjs) - - scripts/validate.mjs(原 packages/validate-script/scripts/validate-xhs-tool.mjs,已改名) - - scripts/vite.config.xhs-minitool.mjs(原 packages/vite-config-example/vite.config.xhs-minitool.mjs) - - references/MANUAL_CHECKS.md(原 packages/validate-script/MANUAL_CHECKS.md) -- pack.mjs 的 import 改为 './validate.mjs';performance-budget.md 的门禁链接改为 '../scripts/validate.mjs';xhs/packages 已空。 - -收尾职责归位(2026-10-05) -- 背景:pack.mjs 之前会就地改写 dist(把 index.html 改成相对路径 / 去 module 痕迹 / 脚本移到 body 末尾 / 删空目录 / 兜底复制 icon),再打 zip。dist 只有跑完 pack 才合规,validate 看到的是中间态。 -- 决策:dist 收尾移进 `scripts/vite.config.xhs-minitool.mjs` 的 `xhsMinitoolArtifactPlugin`(`closeBundle` 钩子,`apply: 'build'` + `enforce: 'post'`)。构建结束 dist 即合规;`pack.mjs` 只做白名单预检与打 zip,不再写盘。 -- 口径:编译约束留在 `minitoolBaseConfig`,收尾只在插件里做一次,禁止在 pack 或项目脚本里重复。 -- 验证:`scripts/.vite-config.test.mjs` 真跑一遍 `vite build`,断言 dist 无 `type="module"` / `crossorigin` / 根路径、脚本在 body 末尾、空目录被清、`copyPublicDir: false` 时 icon 兜底;`scripts/.pack.test.mjs` 断言 pack 不再改写 index.html。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs similarity index 100% rename from apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs rename to apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5cc080747..3aee2bb5a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-10-06 小红书导出 skill:dist 收尾归 Vite 插件,pack 只读打 zip + +- 背景:`vite-export-xhs-minitool` 的 `pack.mjs` 之前会就地改写 dist(相对路径 / 去 `type="module"` / `crossorigin` / 脚本移到 body 末尾 / 清空目录 / 兜底复制 icon)再打 zip;dist 只有在跑完 pack 后才合规,`validate.mjs` 看到的是中间态,打包脚本也因此同时承担「整理产物」与「打 zip」两件事。 +- 决策(职责边界):dist 收尾移进 `scripts/vite.config.xhs-minitool.mjs` 的 `xhsMinitoolArtifactPlugin`(`apply: 'build'` + `enforce: 'post'` 的 `closeBundle`);`minitoolBaseConfig` 只保留编译约束。构建结束 dist 即合规,`pack.mjs` 只做白名单预检与打 zip,不再写盘;`--vite-built-dir` / `--zip-out` 契约与产物落点不变,已适配项目无需改脚本。 +- 决策(失败语义):rollup 在构建失败时也会调 `closeBundle`,插件用 `buildEnd(error)` 记下失败并直接返回,避免留下只复制了 icon 的半成品 dist,也避免用插件的「index.html 不存在」盖掉真正的语法报错。 +- 注:本条同时取代 2026-10-05 条目里 `--out-dir` 与「就地删掉非白名单扩展名文件」的旧描述——现行参数是 `--vite-built-dir`,且只预检、不删除、不改写。 +- 影响范围:`resources/agc-skills/vite-export-xhs-minitool/{SKILL.md,.selective_rule.txt,scripts/{pack.mjs,vite.config.xhs-minitool.mjs}}` 与其 `resources/agc-skills/manifest.json` 指纹、`scripts/.pack.test.mjs`(补「pack 不改写 index.html」)、新增隐藏开发辅助 `scripts/.vite.config.xhs-minitool.test.mjs`(真跑 `vite build` 断言收尾、`copyPublicDir: false` 的 icon 兜底、失败不收尾、`pruneEmptyDirs` 只删空目录)。隐藏测试不进清单、不改 `skill_pack.rs` 的 `include_bytes!` 条目数(仍 36),manifest version=2026-08-26.51。 +- 验证方式:`node --test scripts/.vite.config.xhs-minitool.test.mjs scripts/.pack.test.mjs scripts/.validate.test.mjs`(30 passed)、`npm run agc:skill-pack:check`、`git diff --check`、`npm run check:encoding`。 +- 边界:仍是小红书小工具一个目标的适配脚本;宿主 `export/draft/xhs_minitool` 链路不变,第二目标出现时再抽 target 描述符。 + ## 2026-10-05 导出产物面板:适配归 code agent,宿主只校验并运行项目脚本 - 背景:AGC 需要把项目导出成第三方平台制品,首个目标是小红书小工具。平台不给上传 API,用户只能人工上传;而制品规范(zip 根目录、扩展名白名单、尺寸上限、`index.html` 要求)会随平台变化。把规范复刻进 Rust 等于在客户端养一份会过期的第二真源。 From 09825b13d15ec9d96bf59c901fcda3d48920c1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 16:40:52 +0800 Subject: [PATCH 058/156] =?UTF-8?q?AGC=20=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20skill=EF=BC=9ASKILL.md=20=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E6=8F=92=E4=BB=B6=E8=AF=B4=E6=98=8E=EF=BC=8C=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E6=8F=92=E4=BB=B6=E5=BC=BA=E5=88=B6=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md 回到精简版:删掉「构建分两段 / pack 只读 / 手动加 xhsMinitoolArtifactPlugin」这些正文说明,插件行为只写在 vite.config.xhs-minitool.mjs 注释里; - defineMinitoolConfig 在 mergeConfig 之后按插件名去重再追加 xhsMinitoolArtifactPlugin:展开 minitoolBaseConfig 再覆盖 plugins 也挤不掉它,收尾插件固定排最后且只跑一次; - 隐藏测试补一条:overrides 的插件与内置插件共存、不重复、收尾插件在最后; - manifest 指纹同步(2026-08-26.53 → 2026-08-26.54)。 --- .../resources/agc-skills/manifest.json | 4 ++-- .../vite-export-xhs-minitool/SKILL.md | 7 +++---- .../scripts/.vite.config.xhs-minitool.test.mjs | 16 ++++++++++++++++ .../scripts/vite.config.xhs-minitool.mjs | 18 ++++++++++++++++-- 4 files changed, 37 insertions(+), 8 deletions(-) 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 8b90908b4..9ec4a20a4 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.51", + "version": "2026-08-26.54", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "25e302b74418c81511f95684f4752f227018d89ab1a3bf4e53a73d1516740f19" + "sha256": "f775b0fb628341fed30aa4149c82311a640671d6cd81f78d9d80c2e5c8e47912" } ] } 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 index c693ee429..f9dd9a1ff 100644 --- 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 @@ -8,10 +8,10 @@ metadata: # 目标: * 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip - 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. 构建分两段: `minitoolBaseConfig` 管编译约束, 配置内置的 `xhsMinitoolArtifactPlugin` 在 build 收尾时把 dist 整理成可直接打 zip 的形状(相对路径 / 去 module 痕迹 / 脚本移到 body 末尾 / 清空空目录 / 兜底 icon). **不要**在 pack 或别处重复这些收尾. + 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: `references/manual-checks.md` - 3. 使用 `scripts/pack.mjs` 打包成zip; 它只读 dist 打 zip, 不改写 dist + 3. 使用 `scripts/pack.mjs` 打包成zip 4. 跑通流程后请把需要的脚本配置复制到项目里(以免依赖skill), 并形成最终的打包脚本 * 不干扰正常的web构建 @@ -23,12 +23,11 @@ metadata: # 脚本参数: | 脚本 | 参数 | |----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| -| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并); `xhsMinitoolArtifactPlugin()`(已有 vite.config 时单独加插件做收尾) | +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | | `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | | `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>` | # 一些情况: -* 项目已有自己的 vite.config 时, 用 `defineMinitoolConfig(overrides)` 合并, 或至少把 `xhsMinitoolArtifactPlugin()` 加进 `plugins`; 漏掉插件会让 dist 保留 `type="module"` / 绝对路径, validate 会直接报错. * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 * 如果使用了外部能力, 参考platform-abstract skill对项目先重构 * 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 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 index ce88ab25d..1ebc6451d 100644 --- 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 @@ -140,6 +140,22 @@ test('构建失败时不收尾:不覆盖真实报错,也不留下半成品 d assert.equal(existsSync(join(outDir, 'icons')), false, '失败的构建不该被插件写入 icon'); }); +test('defineMinitoolConfig 始终追加收尾插件,且只跑一次', () => { + const withExtra = defineMinitoolConfig({ plugins: [{ name: 'extra' }] }); + const names = withExtra.plugins.map((plugin) => plugin.name); + assert.ok(names.includes('xhs-minitool-artifact'), `缺少收尾插件: ${names}`); + assert.equal( + names.filter((name) => name === 'xhs-minitool-artifact').length, + 1, + `收尾插件重复: ${names}`, + ); + assert.ok(names.includes('extra'), 'overrides 的插件丢了'); + assert.equal(names.at(-1), 'xhs-minitool-artifact', `收尾插件不在最后: ${names}`); + + const noPlugins = defineMinitoolConfig({ plugins: [] }); + assert.deepEqual(noPlugins.plugins.map((plugin) => plugin.name), ['xhs-minitool-artifact']); +}); + test('pruneEmptyDirs 只删空目录,保留有内容的目录', () => { const root = join(BASE, 'prune'); mkdirSync(join(root, 'empty', 'nested'), { recursive: true }); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs index 037b5b5a2..6284dc01b 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs @@ -42,6 +42,9 @@ export const MINITOOL_CSS_TARGET = ['chrome61']; /** 小红书要求包内自带的小工具 icon;publicDir 被关掉时由插件兜底复制。 */ const FALLBACK_ICONS = ['icon-192.svg', 'icon-512.svg']; +/** 收尾插件名;defineMinitoolConfig 按它去重,保证插件只跑一次。 */ +const ARTIFACT_PLUGIN_NAME = 'xhs-minitool-artifact'; + /** * 把 vite 生成的绝对资源路径改为相对路径,并抹掉 module / 预加载痕迹。 * @@ -132,7 +135,7 @@ export function xhsMinitoolArtifactPlugin() { let buildFailed = false; return { - name: 'xhs-minitool-artifact', + name: ARTIFACT_PLUGIN_NAME, apply: 'build', enforce: 'post', configResolved(config) { @@ -188,11 +191,22 @@ export const minitoolBaseConfig = { }; /** + * 合并项目配置,并保证收尾插件一定在 `plugins` 里。 + * + * `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方 + * 若先展开 `minitoolBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后 + * 追加一个新实例:无论 overrides 怎么给,插件都在最后且只跑一次。 + * * @param {import('vite').UserConfig} [overrides] * @returns {import('vite').UserConfig} */ export function defineMinitoolConfig(overrides = {}) { - return mergeConfig(defineConfig(minitoolBaseConfig), overrides); + const merged = mergeConfig(defineConfig(minitoolBaseConfig), overrides); + merged.plugins = [ + ...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME), + xhsMinitoolArtifactPlugin(), + ]; + return merged; } export default defineMinitoolConfig(); From aafcd593e3a9ca9530ac07fc262ec2009463aa3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 16:42:43 +0800 Subject: [PATCH 059/156] =?UTF-8?q?AGC=20=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20skill=EF=BC=9A=E5=90=8C=E6=AD=A5=20manifes?= =?UTF-8?q?t=20=E6=8C=87=E7=BA=B9=E4=B8=8E=20SKILL.md=20=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest 指纹同步(2026-08-26.54 → 2026-08-26.55); - SKILL.md 追加脚本配置复制说明,明确引入 `agc_install_skill_resource` 的操作指引。 --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../resources/agc-skills/vite-export-xhs-minitool/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 9ec4a20a4..b6d8d33f7 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.54", + "version": "2026-08-26.55", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "f775b0fb628341fed30aa4149c82311a640671d6cd81f78d9d80c2e5c8e47912" + "sha256": "bcb8910018b5d98c17e616cd154146c08b0e5cb0d3c80d0b9e3635a711ffb6e6" } ] } 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 index f9dd9a1ff..2b0fc51be 100644 --- 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 @@ -12,7 +12,7 @@ metadata: 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: `references/manual-checks.md` 3. 使用 `scripts/pack.mjs` 打包成zip - 4. 跑通流程后请把需要的脚本配置复制到项目里(以免依赖skill), 并形成最终的打包脚本 + 4. 跑通流程后请把需要的脚本配置复制( agc_install_skill_resource )到项目里(以免依赖skill), 并形成最终的打包脚本 * 不干扰正常的web构建 From df744ae155458eed9cf13ac0b98c476f0863da54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 16:47:32 +0800 Subject: [PATCH 060/156] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20agc=5Finstall=5Fsk?= =?UTF-8?q?ill=5Fresource=EF=BC=9A=E5=AE=BF=E4=B8=BB=E7=9B=B4=E8=90=BD=20S?= =?UTF-8?q?kill=20=E9=99=84=E4=BB=B6=EF=BC=8C=E5=A4=8D=E7=94=A8=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=90=88=E5=90=8C=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增内置 MCP 工具 agc_install_skill_resource(skillName/relativePath/destinationPath),宿主直接读 AGC_SKILL_PACK_FILES 已审核字节 - 组装成 agc_write_file 的 {path,content} 转发客户端工具桥,复用同一份合同、lease、项目写锁与保护面校验 - read/install 共用 skill_resource_relative_path_is_unsafe;模型自带正文无旁路,正文不过模型上下文,不依赖原生 cp 审批 - direct-tools.json 增补工具描述与 destinationPath,direct.json engineering 指引改用本工具复制 Skill 文件 - 新增两条 install_skill_resource 测试并登记工具目录;同步技术方案与 decision-log --- .../prompts/runtime/texts/direct-tools.json | 2 + .../prompts/runtime/texts/direct.json | 2 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 121 +++++++++++++++++- .../shared-memory/decision-log.md | 8 ++ ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 5 files changed, 128 insertions(+), 7 deletions(-) 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 043ab9557..fdcefd217 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 a544d209e..0b7779844 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,仅支持artifact(path)、command(program/arguments/cwd/purpose)、visual或gameplay(scenario)。只登记用户要求的必要范围,登记后不能扩项。新Web游戏必须覆盖构建、双端视觉和玩法底线;跑酷选择runner-v1,俄罗斯方块选择tetris-v1,其余按真实能力选择固定场景。构建证据调用agc_run_validation,purpose=build、program=npm、arguments=[\"run\",\"build\"]、cwd=game或实际包目录;测试用purpose=test。现有文件的存在不等于本轮修改完成,必须给出真实修改与验证证据。不能提交passed、改写验证JSON或降低已登记要求。要求满足后停止新增润色或付费请求。需要诊断未满足项时读取agc_delivery_status。", "deliveryFeedback": "本轮验收尚未通过。读取agc_delivery_status,仅补齐已登记要求;未登记则先调用agc_register_delivery_contract。不得扩项、提交passed或改写证据。使用agc_run_validation purpose=build保存构建证明,再执行必要的定点测试和固定双端场景。原用户目标与本轮登记要求保持不变。\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/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 28aded7d3..60693ebe6 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"), @@ -1039,11 +1058,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}")) { @@ -1052,6 +1067,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<Value, String> { + 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); @@ -1536,6 +1585,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"))] @@ -2752,6 +2802,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", @@ -3259,6 +3310,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/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3aee2bb5a..d52084c5e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-06 新增 agc_install_skill_resource:Skill 附件由宿主直接落盘,不走模型正文 + +- 背景:`vite-export-xhs-minitool` 等审核 Skill 要把自带脚本(`scripts/validate.mjs` 约 40 KB、`pack.mjs`、`vite.config.*.mjs`)原样复制进用户项目。`agc_read_skill_resource` 的大文件正文会被模型上下文截断,重抄必然失真;原生 `cp` 在 DirectProject 只读沙箱下被审批闸门拒绝。两条路都走不通。 +- 决策:新增内置 MCP 工具 `agc_install_skill_resource`,入参 `skillName` / `relativePath` / `destinationPath`。宿主在 MCP 进程内直接读 `AGC_SKILL_PACK_FILES` 里已审核的内置字节(复用 `read_agc_skill_resource` 的清单校验与路径拒绝口径),组装成 `agc_write_file` 的 `{path, content}` 转发给客户端受控工具桥。写入因此走与 `agc_write_file` 相同的合同、lease、项目写锁与保护面校验;正文不经过模型上下文,没有截断,也不需要原生 `cp` 及其审批。 +- 边界:工具只暴露 `agc_write_file` 的两个入参,模型无法自带正文旁路审核;一次调用落一个文件,不打包、不改写、不建目录以外的副作用。DirectProject 的只读原生沙箱与 #439 审批口径不变,本工具是替代 `cp` 的受控通道,不是放开沙箱。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs`(工具目录、分发、`install_skill_resource_write_arguments`)、`prompts/runtime/texts/direct-tools.json`、`prompts/runtime/texts/direct.json`,权威说明同步到技术方案。 +- 验证:`cargo test --offline -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell agent::`(736 passed,含新增 `install_skill_resource_*` 两条与 `tool_catalog_preserves_reviewed_resource_contracts`)、`npm run check:encoding`、`git diff --check`。 + ## 2026-10-06 小红书导出 skill:dist 收尾归 Vite 插件,pack 只读打 zip - 背景:`vite-export-xhs-minitool` 的 `pack.mjs` 之前会就地改写 dist(相对路径 / 去 `type="module"` / `crossorigin` / 脚本移到 body 末尾 / 清空目录 / 兜底复制 icon)再打 zip;dist 只有在跑完 pack 后才合规,`validate.mjs` 看到的是中间态,打包脚本也因此同时承担「整理产物」与「打 zip」两件事。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3806ab765..6b2b95a25 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1522,7 +1522,7 @@ game-project/ - 首页提供“做游戏 / 做方案”两个创作类型(`game` / `doc`),默认“做游戏”;“做素材”入口已退役,素材生成在项目内按实际工作流触发。每次首页提交自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|doc` 作为受限结构化首轮上下文传给同一 Codex thread;持久草稿里遗留的 `art` 按 `game` 处理(`effectiveCreationType` 映射),不再产生第三种首轮上下文。 - 「策划补全」是“做游戏”专属的提交前勾选(2026-09-15 起):勾选时该档提交 `planning`,未勾选时提交 `direct-build`;“做方案”始终走立项策划链路,与本勾选无关。复选框只在“做游戏”档渲染;切换创作类型时首页表面整体重挂,勾选状态随之清除,切回来必须是未勾选——这条可观察契约由 `appSurface` 的「scopes the 策划补全 option to the game entry」与「submits the game entry with planning when 策划补全 is checked」两条用例钉住,实现侧不额外维护重置逻辑。 - 2026-09-23 清理了未注册的 DirectHome 用户对话命令及旧附件 sidecar 渲染链;首页仍先创建项目再进入 DirectProject,不恢复无项目对话。自动项目命名和提示润色仍调用内部 `direct_game_creator_home_codex_chat`,其 DirectHome 只读隔离通道与测试继续保留。附件作为 canonical `userItem.content` 中的 `agc_attachment_reference` 携带名称、媒体类型、大小、项目相对路径及状态,经现役 validation/wire 校验与投影;路径映射不等于灌入全文,也不按 GDD 特判。附件清洗与数量上限继续复用 `direct_codex_attachments.rs`,旧 sidecar DTO、header、专属 prompt key 和测试不再是保留合同。 -- `agc-skill-pack.v1` 包含完整游戏交付流程、项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影、Unity/Godot 编辑器常用操作、平台抽象收口和小红书小工具导出十项审核 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。Skill 目录里以 `.` 开头的文件和目录是本地开发辅助(例如 `vite-export-xhs-minitool/.selective_rule.txt`、`scripts/.pack.test.mjs`):不写进清单、不参与指纹、不安装,也不能经 `agc_read_skill_resource` 读取;JS 清单工具 `isHiddenSkillEntryName` 与 Rust `is_hidden_skill_entry_name` 共用同一拒绝口径,新增 Skill 时无需为它们补 `include_bytes!`。 +- `agc-skill-pack.v1` 包含完整游戏交付流程、项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影、Unity/Godot 编辑器常用操作、平台抽象收口和小红书小工具导出十项审核 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。同步统一运行 `npm run agc:skill-pack:sync`,只读校验由 AGC `typecheck` 和 release build 自动执行,发现漂移时直接列出 Skill 与实际摘要,不让失配内容进入构建产物。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown;把 Skill 自带脚本或配置原样复制进项目时用 `agc_install_skill_resource`(skillName/relativePath/destinationPath),宿主直接读内置审核字节并按 `agc_write_file` 的入参规则与合同 lease 写盘,正文不经过模型上下文,避免大文件截断和原生 `cp` 审批。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。Skill 目录里以 `.` 开头的文件和目录是本地开发辅助(例如 `vite-export-xhs-minitool/.selective_rule.txt`、`scripts/.pack.test.mjs`):不写进清单、不参与指纹、不安装,也不能经 `agc_read_skill_resource` 读取;JS 清单工具 `isHiddenSkillEntryName` 与 Rust `is_hidden_skill_entry_name` 共用同一拒绝口径,新增 Skill 时无需为它们补 `include_bytes!`。 - DirectProject 连接客户端内置的 `agc_tools` STDIO MCP,并在启动时接入客户端扩展仓库中用户已启用的独立第三方 STDIO/HTTP MCP 配置。内置工具包括审核引用读取、图片生成、标准陶泥儿美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive 语义生成、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`。内置 MCP 进程负责协议;真实浏览器、付费平台调用与受控搜索通过随机 loopback 地址回到客户端主进程,GUI 登录态、开发者 Key、项目路径、revision、operation 与幂等键由客户端持有并隔离于模型上下文。内置与用户启用的第三方 MCP 工具沿用 DirectProject 自动批准方式;付费资源工具由客户端绑定稳定回合身份、串行执行并优先恢复匹配账本。`llm.webSearchEnabled` 控制 DirectProject 的 AGC 受控搜索工具暴露与执行。原生工具与审批权限以下方“DirectProject Codex 完整访问覆盖”为准。 - 陶泥儿生成复用持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记;普通客户端使用当前 AGC 登录会话及账号路由,受控的 ExternalDeveloper 发布模式在客户端内部使用按服务器 origin 隔离的私有 Key。凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。 - 自定义 LLM API Key 路由在 DirectProject 及内部 DirectHome 辅助调用中经 loopback `/responses` 流式代理转发。代理使用请求自带的 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,按实际 API Provider 响应判断请求结果。 From a96c0e6209786288fad1431b3f8a82badbc5dac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 17:09:49 +0800 Subject: [PATCH 061/156] =?UTF-8?q?AGC=20=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=EF=BC=9Avalidate=20=E5=8E=BB=20strict=20?= =?UTF-8?q?=E4=B8=8E=E5=A2=9E=E5=BC=BA=E5=B1=82=E5=91=8A=E8=AD=A6=EF=BC=8C?= =?UTF-8?q?pack=20=E5=8A=A0=E8=90=BD=E7=82=B9=E9=A2=84=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate.mjs:scanJsSyntax 先 stripJsNoise 剥注释与字符串,`"#e8f4ff"` / `'#hit'` 不再误判为 ES2018 - validate.mjs:删除 MODERN_RUNTIME_API、CSS_MODERN_PATTERNS / CSS_MODERN_FEATURE 与 --strict,退出码只由 ERROR 决定 - pack.mjs:--zip-out 相对 cwd 且落在 <cwd>/.export/ 时打 workdir 警告,打印 cwd 与绝对落点 - SKILL.md:产物为项目根 .export/xhs-minitool.zip,game/ 子工程写 ../.export/...;参数表去掉 --strict - 测试:增强层放行、JS 噪音不误报、真私有字段仍报、warning 也退出 0、workdir 预警 - manifest:vite-export-xhs-minitool 指纹与 version 同步到 2026-08-26.56 - decision-log.md:记录本次 validate/pack 收敛 --- .../resources/agc-skills/manifest.json | 4 +- .../vite-export-xhs-minitool/SKILL.md | 6 +- .../scripts/.pack.test.mjs | 24 ++++ .../scripts/.validate.test.mjs | 31 ++++- .../vite-export-xhs-minitool/scripts/pack.mjs | 21 +++- .../scripts/validate.mjs | 106 ++++++++++++------ .../shared-memory/decision-log.md | 10 ++ 7 files changed, 157 insertions(+), 45 deletions(-) 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 b6d8d33f7..274dbbccd 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.55", + "version": "2026-08-26.56", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "bcb8910018b5d98c17e616cd154146c08b0e5cb0d3c80d0b9e3635a711ffb6e6" + "sha256": "19dd6324e2b5c5a40b7b2b8b2589189dc96bc9784047fc363ac5643d020965b6" } ] } 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 index 2b0fc51be..f91fc82df 100644 --- 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 @@ -11,7 +11,7 @@ metadata: 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: `references/manual-checks.md` - 3. 使用 `scripts/pack.mjs` 打包成zip + 3. 使用 `scripts/pack.mjs` 打包成zip; 产物必须是**项目根**的 `.export/xhs-minitool.zip`, 而 `--zip-out` 相对 cwd 解析: npm 脚本挂在 `game/` 子工程时写 `../.export/xhs-minitool.zip` 4. 跑通流程后请把需要的脚本配置复制( agc_install_skill_resource )到项目里(以免依赖skill), 并形成最终的打包脚本 * 不干扰正常的web构建 @@ -24,8 +24,8 @@ metadata: | 脚本 | 参数 | |----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| | `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | -| `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | -| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>` | +| `scripts/validate.mjs` | `[project]`、`--json` | +| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>`(相对 cwd 解析;宿主在项目根 `.export/xhs-minitool.zip` 找产物) | # 一些情况: * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 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 index 51f5585eb..d6af2ef9d 100644 --- 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 @@ -100,6 +100,30 @@ test('pack 只打 zip,不再改写产物目录', () => { ); }); +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'); 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 index cadd10726..baf799dd0 100644 --- 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 @@ -250,7 +250,7 @@ test('html template and classic-script requirements are enforced', () => { ); }); -test('modern CSS and runtime APIs are flagged as warnings', () => { +test('现代 CSS / 运行时 API 作为增强层放行,不再报警', () => { const root = join(BASE, 'modern_css'); writeProject(root, VALID_HTML, { ...VALID_FILES, @@ -258,8 +258,29 @@ test('modern CSS and runtime APIs are flagged as warnings', () => { 'app.js': "list.replaceAll('a', 'b');\nObject.hasOwn({}, 'x');\n", }); const actual = codes(root); - assert.ok(actual.has('CSS_MODERN_FEATURE'), `missing CSS_MODERN_FEATURE; actual=${[...actual].sort()}`); - assert.ok(actual.has('MODERN_RUNTIME_API'), `missing MODERN_RUNTIME_API; actual=${[...actual].sort()}`); + assert.ok(!actual.has('CSS_MODERN_FEATURE'), `unexpected CSS_MODERN_FEATURE; actual=${[...actual].sort()}`); + assert.ok(!actual.has('MODERN_RUNTIME_API'), `unexpected MODERN_RUNTIME_API; actual=${[...actual].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('真正的 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', () => { @@ -299,7 +320,7 @@ test('symlinks are rejected', (t) => { assert.ok(codes(root).has('SYMLINK')); }); -test('CLI exit codes: pass, strict warning and error', () => { +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'); @@ -311,8 +332,8 @@ test('CLI exit codes: pass, strict warning and error', () => { writeProject(warningRoot, '<!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><img src="data:image/png;base64,AA" alt=""></body></html>'); + // 有 WARNING 也照样通过:退出码只由 ERROR 决定,没有 --strict 这种「警告即失败」的模式。 assert.equal(runCli(warningRoot).status, 0); - assert.equal(runCli(warningRoot, '--strict').status, 2); const errorRoot = join(BASE, 'error_cli'); writeProject(errorRoot, '<!doctype html><script>bad()</script>'); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 61a1d942d..611f58365 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -17,7 +17,7 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { deflateRawSync } from 'node:zlib'; @@ -226,6 +226,23 @@ function makeZip(viteBuiltDir, zipPath) { console.log(`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB)`); } +/** + * cwd 歧义预警:宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析。 + * npm 脚本挂在 `game/` 子工程时 cwd 就是 `game/`,写 `.export/...` 会落到 `game/.export/...`, + * 导出判失败却没有任何提示。这里只在落点位于「当前 cwd 的 `.export/`」时提醒(`../.export/...` + * 或绝对路径不触发),把 cwd 与绝对落点打出来,让 workdir 问题不再静默。 + */ +function warnIfWorkdirRelative(cwd, zipPath, zipOut) { + if (typeof zipOut !== 'string' || isAbsolute(zipOut)) return; + const relativeOut = relative(cwd, zipPath); + if (relativeOut.startsWith('..') || !relativeOut.startsWith(`.export${sep}`)) return; + console.warn( + `[warn] --zip-out 相对 cwd 解析:cwd=${cwd},落点=${zipPath}。` + + '宿主在项目根 `.export/xhs-minitool.zip` 找产物;若 cwd 是 game/ 子工程,' + + '请写 `../.export/xhs-minitool.zip`,否则会落在 game/.export/ 导致导出判失败。', + ); +} + /** * @param {{ * cwd?: string, @@ -241,6 +258,8 @@ export function packMinitool(options = {}) { const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool'); const zipPath = resolve(cwd, options.zipOut); + warnIfWorkdirRelative(cwd, zipPath, options.zipOut); + if (!existsSync(viteBuiltDir)) { throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs index dcd50f979..48f38caec 100755 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs @@ -82,7 +82,6 @@ const CODE_PATTERNS = [ ['WARNING', 'DYNAMIC_IMPORT', '动态 import 可能产生运行时资源加载;确认目标为包内静态资源。', /\bimport\s*\(/g], ['WARNING', 'COOKIE_REVIEW', 'Cookie 只能作为本地存储;不要用于服务端登录态或鉴权。', /\bdocument\s*\.\s*cookie\b/g], ['WARNING', 'NAVIGATION_REVIEW', '检测到页面导航 API;确认不会打开外链、新窗口或其他小工具。', /\blocation\s*\.\s*href\s*=(?!=)|\b(?:location\s*\.\s*(?:assign|replace)|history\s*\.\s*(?:pushState|replaceState))\s*\(/g], - ['WARNING', 'MODERN_RUNTIME_API', '使用了较新的运行时 API;须做能力检测或局部 fallback,不能作为唯一实现路径。', /\bObject\s*\.\s*hasOwn\s*\(|\bstructuredClone\s*\(|\.replaceAll\s*\(|\.at\s*\(/g], ['WARNING', 'REGEX_LOOKBEHIND', '正则使用了 lookbehind;目标内核 Chrome 61 不支持,须改写。', /\(\?<[=!]/g], ]; @@ -97,36 +96,85 @@ const ES2018_SYNTAX_PATTERNS = [ [/\b\d[\d_]*n\b/g, 'BigInt 字面量'], ]; -/** 晚于 Chrome 61 的 CSS 能力;只能作为能力检测后的增强层,不能作为唯一实现。 */ -const CSS_MODERN_PATTERNS = [ - [/\baspect-ratio\s*:/gi, 'aspect-ratio'], - [/(?<![\w-])(?:row-|column-)?gap\s*:/gi, 'flex gap'], - [/\b(?:clamp|min|max)\s*\(/gi, 'min()/max()/clamp()'], - [/(?:^|[;{\s])(?:inset|margin-inline|margin-block|padding-inline|padding-block|border-inline|border-block)\s*[:(]/gi, '逻辑属性 / 简写'], - [/overflow\s*:\s*clip/gi, 'overflow: clip'], - [/:focus-visible/gi, ':focus-visible'], - [/:has\s*\(/gi, ':has()'], - [/@container\b/gi, '@container'], - [/\bsubgrid\b/gi, 'subgrid'], - [/@layer\b/gi, '@layer'], - [/@property\b/gi, '@property'], - [/\b\d+(?:\.\d+)?(?:dvh|svh|lvh)\b/gi, 'dvh / svh / lvh'], - [/\b(?:color-mix|oklab|oklch)\s*\(/gi, 'color-mix() / oklab() / oklch()'], - [/backdrop-filter\s*:/gi, 'backdrop-filter'], - [/text-wrap\s*:\s*balance/gi, 'text-wrap: balance'], -]; - const BASE64_RE = /data:[\w.+-]+\/[\w.+-]+;base64,([A-Za-z0-9+/=]+)/g; +/** + * 把 JS 注释与字符串字面量替换成等长空白(保留换行),行号偏移不变。 + * + * 语法扫描只看真正的代码:`"#e8f4ff"`、`document.querySelector('#hit')`、注释里写的 `fetch(` + * 都不该被判成语法 / API 命中。模板字面量里的 `${...}` 仍按代码处理。 + */ +export function stripJsNoise(text) { + const chars = text.split(''); + const blank = (index) => { + if (chars[index] !== '\n' && chars[index] !== '\r') chars[index] = ' '; + }; + const n = text.length; + let i = 0; + + while (i < n) { + const ch = text[i]; + const next = text[i + 1]; + + if (ch === '/' && next === '/') { + while (i < n && text[i] !== '\n') { blank(i); i += 1; } + continue; + } + if (ch === '/' && next === '*') { + blank(i); blank(i + 1); i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { blank(i); i += 1; } + if (i < n) { blank(i); blank(i + 1); i += 2; } + continue; + } + if (ch === "'" || ch === '"') { + const quote = ch; + blank(i); i += 1; + while (i < n && text[i] !== quote) { + if (text[i] === '\\') { blank(i); i += 1; if (i < n) { blank(i); i += 1; } continue; } + blank(i); i += 1; + } + if (i < n) { blank(i); i += 1; } + continue; + } + if (ch === '`') { + blank(i); i += 1; + while (i < n) { + if (text[i] === '\\') { blank(i); i += 1; if (i < n) { blank(i); i += 1; } continue; } + if (text[i] === '`') { blank(i); i += 1; break; } + if (text[i] === '$' && text[i + 1] === '{') { + // 进入插值:里面的内容是真代码,保留;按花括号配平跳到闭合的 `}`。 + i += 2; + let depth = 1; + while (i < n && depth > 0) { + if (text[i] === '{') depth += 1; + else if (text[i] === '}') depth -= 1; + if (depth === 0) break; + i += 1; + } + i += 1; + continue; + } + blank(i); i += 1; + } + continue; + } + i += 1; + } + + return chars.join(''); +} + /** JS 语法基线:ESM 语句直接判 ERROR,晚于 ES2017 的语法判 WARNING。 */ export function scanJsSyntax(path, text, collector) { const lineAt = createLineCounter(text); - for (const match of text.matchAll(/^[ \t]*(?:import|export)\b/gm)) { + // 只在剥掉字符串 / 注释后的代码上匹配,避免 `"#e8f4ff"`、`'#hit'` 这类字面量误判。 + const code = stripJsNoise(text); + for (const match of code.matchAll(/^[ \t]*(?:import|export)\b/gm)) { collector.add('ERROR', 'ESM_SYNTAX', path, lineAt(match.index), '最终包不得使用 ES Module 的 import / export;改为经典脚本并按依赖顺序 <script src> 引入。'); } for (const [pattern, label] of ES2018_SYNTAX_PATTERNS) { - for (const match of text.matchAll(pattern)) { + for (const match of code.matchAll(pattern)) { collector.add('ERROR', 'ES2018_PLUS_SYNTAX', path, lineAt(match.index), `最终产物出现晚于 ES2017 的语法(${label});语法不兼容会在解析阶段直接失败,必须由构建链转译到 es2017 / chrome61。`); } @@ -151,12 +199,11 @@ export function scanBase64(path, text, collector) { } } -const USAGE = `用法:validate.mjs [project] [--json] [--strict] +const USAGE = `用法:validate.mjs [project] [--json] 扫描小红书小工具项目的容器兼容性问题。 [project] 产物目录,默认为 dist-xhs-minitool - --json 以 JSON 输出结果 - --strict 存在 WARNING 时也返回非零状态`; + --json 以 JSON 输出结果`; /* ------------------------------------------------------------------ */ /* 工具函数 */ @@ -366,13 +413,6 @@ export function scanCssReferences(path, text, collector, baseLine = 1) { } checkLocalReference(value, path, collector, line, 'CSS url()'); } - - for (const [pattern, label] of CSS_MODERN_PATTERNS) { - for (const match of text.matchAll(pattern)) { - collector.add('WARNING', 'CSS_MODERN_FEATURE', path, baseLine + lineAt(match.index) - 1, - `使用了晚于 Chrome 61 的 CSS 能力(${label});需保留基线写法并用能力检测启用增强。`); - } - } } /* ------------------------------------------------------------------ */ @@ -956,7 +996,6 @@ export function runCli(argv, io = {}) { args: argv, options: { json: { type: 'boolean' }, - strict: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, }, allowPositionals: true, @@ -1005,7 +1044,6 @@ export function runCli(argv, io = {}) { } if (errors) return 1; - if (values.strict && warnings) return 2; return 0; } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d52084c5e..fe0de7923 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-10-06 小红书导出 validate/pack:去掉 strict 与增强层告警,扫 JS 先剥字符串/注释,zip 落点带 cwd 预警 + +- 背景:真实项目首轮适配反馈三处。① `validate.mjs` 的 `/#[A-Za-z_$][\w$]*/g`(class 私有字段)直接在原始文本上匹配,把 `"#e8f4ff"`、`document.querySelector('#hit')` 误判为 ES2018 语法并报 ERROR,把排查引向「构建链没转译」。② `--strict` 让 references 明确鼓励的「Chrome 61 基线 + 能力检测后的增强层」(`CSS_MODERN_FEATURE`、`MODERN_RUNTIME_API`)也判失败,想 strict 通过只能砍增强层。③ 宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析;npm 脚本挂在 `game/` 子工程时 `.export/...` 会落到 `game/.export/`,没有任何提示。 +- 决策(扫描先剥噪音):`scanJsSyntax` 先过 `stripJsNoise`——把注释与字符串字面量替换成等长空白(保留换行),只对真代码跑 ESM 与 ES2018 检测;模板 `${...}` 仍按代码处理。`"#e8f4ff"`、`'#hit'`、注释里的 `obj?.c` 不再命中,真 `#count` 私有字段仍报错。 +- 决策(退出码只由 ERROR 决定):删掉 `MODERN_RUNTIME_API` 与 `CSS_MODERN_PATTERNS` / `CSS_MODERN_FEATURE`,删掉 `--strict`。增强层写法交给 `references/css-compatibility.md`、`js-compatibility.md` 的能力检测要求约束,`validate.mjs` 不再把「能用但需检测」判失败。 +- 决策(产物落点有确定写法与预警):`SKILL.md` 写明产物是项目根的 `.export/xhs-minitool.zip`,`game/` 子工程写 `../.export/xhs-minitool.zip`;`pack.mjs` 在 `--zip-out` 为相对路径且落点位于 `<cwd>/.export/` 时打 `[warn]`,打印 cwd 与绝对落点(`../.export/...`、绝对路径或其他相对位置不触发)。 +- 影响范围:`resources/agc-skills/vite-export-xhs-minitool/{SKILL.md,scripts/{validate.mjs,pack.mjs}}`、其 `resources/agc-skills/manifest.json` 指纹(version=2026-08-26.56)、隐藏测试 `scripts/.validate.test.mjs`、`scripts/.pack.test.mjs`。宿主 `export/draft/xhs_minitool` 链路与 `xhsMinitoolInstruction.ts` 契约常量不变。 +- 验证方式:`node --test scripts/.validate.test.mjs scripts/.pack.test.mjs scripts/.vite.config.xhs-minitool.test.mjs`(34 passed)、`npm run agc:skill-pack:check`、`git diff --check`、`npm run check:encoding`。 +- 边界:「web 与小工具共用同一 `game/index.html`」是默认姿势,不需要另起 root;只有入口 HTML 确实不同才需要并存 recipe,本次未写,留待需要时补。 + ## 2026-10-06 新增 agc_install_skill_resource:Skill 附件由宿主直接落盘,不走模型正文 - 背景:`vite-export-xhs-minitool` 等审核 Skill 要把自带脚本(`scripts/validate.mjs` 约 40 KB、`pack.mjs`、`vite.config.*.mjs`)原样复制进用户项目。`agc_read_skill_resource` 的大文件正文会被模型上下文截断,重抄必然失真;原生 `cp` 在 DirectProject 只读沙箱下被审批闸门拒绝。两条路都走不通。 From 30a0f05c11ae15df1834161a7a4ed9e78473157d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 17:20:52 +0800 Subject: [PATCH 062/156] =?UTF-8?q?AGC=20=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20SKILL=EF=BC=9A=E5=8F=82=E6=95=B0=E8=A1=A8?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20defineMinitoolConfig=20=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md:vite 配置行只保留 build.outDir,删除「项目已有 vite.config 时用它合并」的误导说明(helper 仍留在配置代码里作为默认导出构造器) - SKILL.md:同步当前排版(步骤 3 换行、pack 行说明收进步骤 3) - manifest:vite-export-xhs-minitool 指纹与 version 同步到 2026-08-26.59 --- .../resources/agc-skills/manifest.json | 4 ++-- .../vite-export-xhs-minitool/SKILL.md | 18 +++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) 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 274dbbccd..4ff291bf8 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.56", + "version": "2026-08-26.59", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "19dd6324e2b5c5a40b7b2b8b2589189dc96bc9784047fc363ac5643d020965b6" + "sha256": "26412d79803b700b55cd5d7e1e8ea59886105d852eae139f3a9f8d4aa08cb19d" } ] } 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 index f91fc82df..faff6f479 100644 --- 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 @@ -7,30 +7,34 @@ metadata: --- # 目标: + * 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip - 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. + 1. 复制 (并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: `references/manual-checks.md` - 3. 使用 `scripts/pack.mjs` 打包成zip; 产物必须是**项目根**的 `.export/xhs-minitool.zip`, 而 `--zip-out` 相对 cwd 解析: npm 脚本挂在 `game/` 子工程时写 `../.export/xhs-minitool.zip` - 4. 跑通流程后请把需要的脚本配置复制( agc_install_skill_resource )到项目里(以免依赖skill), 并形成最终的打包脚本 + 3. 使用 `scripts/pack.mjs` 打包成zip; 产物必须是 **项目根**的 `.export/xhs-minitool.zip`, 而 `--zip-out` 相对 cwd + 解析:所以例如 npm 脚本挂在 `game/` 子工程时写 `../.export/xhs-minitool.zip` + 4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本 * 不干扰正常的web构建 # 假设和默认项: -* vite项目目录: game/ ,各个工具(包括示例vite配置)默认cwd 就是 vite 项目根, 对于非标准的目录结构需要给出显式的参数来适应 +* vite项目目录: game/ ,各个工具 (包括示例vite配置)默认cwd 就是 vite 项目根, 对于非标准的目录结构需要给出显式的参数来适应 # 脚本参数: + | 脚本 | 参数 | |----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| -| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录) | | `scripts/validate.mjs` | `[project]`、`--json` | -| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>`(相对 cwd 解析;宿主在项目根 `.export/xhs-minitool.zip` 找产物) | +| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>` | # 一些情况: + * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 * 如果使用了外部能力, 参考platform-abstract skill对项目先重构 -* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 +* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 以下是其他参考文档, 非必要不读 From c7076af548b025691ac04628b9a2fd709914d8d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 17:30:12 +0800 Subject: [PATCH 063/156] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20manifest=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E4=B8=8E=E6=8C=87=E7=BA=B9=E8=87=B3=202026-0?= =?UTF-8?q?8-26.60=EF=BC=8C=E4=BC=98=E5=8C=96=20SKILL.md=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E8=A1=A8=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../agc-skills/vite-export-xhs-minitool/SKILL.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) 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 4ff291bf8..0e705a7d9 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.59", + "version": "2026-08-26.60", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "26412d79803b700b55cd5d7e1e8ea59886105d852eae139f3a9f8d4aa08cb19d" + "sha256": "1488810b869df35f1d5283b09b57d1f73b7d56ca6fad74052d141414768a3150" } ] } 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 index faff6f479..a2ed38eaa 100644 --- 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 @@ -24,11 +24,11 @@ metadata: # 脚本参数: -| 脚本 | 参数 | -|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| -| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录) | -| `scripts/validate.mjs` | `[project]`、`--json` | -| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>` | +| 脚本 | 参数 | +|----------------------------------------|------------------------------------------------------------------| +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录) | +| `scripts/validate.mjs` | `[project]`、`--json` | +| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>` | # 一些情况: From b0c16d985da714846db0a8b0065827ee942596b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 17:44:59 +0800 Subject: [PATCH 064/156] =?UTF-8?q?AGC=20=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20pack=EF=BC=9A--zip-out=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=20../.export/xhs-minitool.zip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pack.mjs:--zip-out 默认 ../.export/xhs-minitool.zip(假设 cwd 是 game/ 子工程,落到项目根 .export/),仍可显式覆盖;USAGE 同步 - SKILL.md:步骤 3 写明默认落点与 game/ 假设 - .pack.test.mjs:补默认落点用例(cwd=game/ 时 zip 落在项目根 .export/) - manifest:vite-export-xhs-minitool 指纹与 version 同步到 2026-08-26.61 --- .../resources/agc-skills/manifest.json | 4 ++-- .../vite-export-xhs-minitool/SKILL.md | 3 +-- .../scripts/.pack.test.mjs | 10 ++++++++ .../vite-export-xhs-minitool/scripts/pack.mjs | 23 ++++++++++--------- 4 files changed, 25 insertions(+), 15 deletions(-) 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 0e705a7d9..89af00587 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.60", + "version": "2026-08-26.61", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "1488810b869df35f1d5283b09b57d1f73b7d56ca6fad74052d141414768a3150" + "sha256": "9a4ebd56d9fe30dacacec35ab2af941ea08cffc3764564a6d0331bb251cfc612" } ] } 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 index a2ed38eaa..976cd4881 100644 --- 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 @@ -12,8 +12,7 @@ metadata: 1. 复制 (并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: `references/manual-checks.md` - 3. 使用 `scripts/pack.mjs` 打包成zip; 产物必须是 **项目根**的 `.export/xhs-minitool.zip`, 而 `--zip-out` 相对 cwd - 解析:所以例如 npm 脚本挂在 `game/` 子工程时写 `../.export/xhs-minitool.zip` + 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构建 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 index d6af2ef9d..8ed0e67f7 100644 --- 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 @@ -81,6 +81,16 @@ test('干净产物目录仍能正常打包', () => { 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 / 根路径脚本。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 611f58365..47bdc093b 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -27,17 +27,24 @@ const MIB = 1024 * 1024; const ZIP_HARD_LIMIT = 10 * MIB; const ZIP_RECOMMENDED = 2 * MIB; +/** + * 默认落点:假设 npm 脚本挂在 `game/` 子工程(cwd = `game/`),宿主在**项目根** + * `.export/xhs-minitool.zip` 找产物,所以默认写 `../.export/xhs-minitool.zip`。 + * 脚本挂在项目根 `package.json`(cwd = 项目根)时用 `--zip-out .export/xhs-minitool.zip` 覆盖。 + */ +const DEFAULT_ZIP_OUT = join('..', '.export', 'xhs-minitool.zip'); + const FORBIDDEN_ENTRY = /(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i; -const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] --zip-out <path> +const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] [--zip-out <path>] 把 vite 构建产物打成根目录带 index.html 的 zip;产物应先由 vite.config.xhs-minitool.mjs 收尾。 --vite-built-dir <dir> vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 dist-xhs-minitool。 该目录出现白名单之外的文件类型时直接报错退出并列出文件,不会删除任何文件。 本脚本不改写该目录,只读它来打 zip。 ---zip-out <path> 落点(例如 AGC 导出面板要求的 .export/xhs-minitool.zip): +--zip-out <path> 落点。默认 ${DEFAULT_ZIP_OUT}(假设 cwd 是 game/ 子工程,产物落在项目根的 .export/xhs-minitool.zip); 相对路径按当前工作目录解析,父目录不存在会自动建。`; /** @@ -247,16 +254,13 @@ function warnIfWorkdirRelative(cwd, zipPath, zipOut) { * @param {{ * cwd?: string, * viteBuiltDir?: string, - * zipOut: string, + * zipOut?: string, * }} options */ export function packMinitool(options = {}) { - if (!options.zipOut) { - throw new Error('缺少 --zip-out:打包要给出 zip 的落点'); - } const cwd = resolve(options.cwd || process.cwd()); const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool'); - const zipPath = resolve(cwd, options.zipOut); + const zipPath = resolve(cwd, options.zipOut || DEFAULT_ZIP_OUT); warnIfWorkdirRelative(cwd, zipPath, options.zipOut); @@ -315,14 +319,11 @@ if (isMainModule()) { let zipOut; try { viteBuiltDir = takeFlag('--vite-built-dir'); - zipOut = takeFlag('--zip-out'); + zipOut = takeFlag('--zip-out') || DEFAULT_ZIP_OUT; // 不认识的参数当场拒绝:否则调用方以为它生效了,产物落到别处还查不出来。 if (raw.length > 0) { throw new Error(`不认识的参数:${raw.join(' ')}`); } - if (!zipOut) { - throw new Error('缺少 --zip-out'); - } } catch (err) { console.error(err instanceof Error ? err.message : err); console.error(USAGE); From d4797abb852ff120594f2bd2e3e2a4f3ba26c508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 17:48:35 +0800 Subject: [PATCH 065/156] =?UTF-8?q?AGC=20=E5=AF=BC=E5=87=BA=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=8A=BD=E5=87=BA=20common=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=B9=B6=E8=AE=A9=E5=B0=8F=E7=BA=A2=E4=B9=A6?= =?UTF-8?q?=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 把 PaneButton 从 tabs/xiaohongshu 移到 tabs/common,供小红书与陶泥儿共用 - 新增 common/ChatPane,统一第二个会话 chatbox 的挂载与状态投影 - 小红书 chat Pane 改为委托 common/ChatPane,五个卡片组件导入路径同步更新 --- .../export/tabs/common/ChatPane.tsx | 64 +++++++++++++++++++ .../{xiaohongshu => common}/PaneButton.tsx | 7 +- .../tabs/xiaohongshu/AdaptPromptDialog.tsx | 2 +- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 2 +- .../export/tabs/xiaohongshu/ConflictCard.tsx | 2 +- .../export/tabs/xiaohongshu/FailureDialog.tsx | 2 +- .../export/tabs/xiaohongshu/FormCard.tsx | 2 +- .../export/tabs/xiaohongshu/chat/Pane.tsx | 40 +++--------- 8 files changed, 83 insertions(+), 38 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/ChatPane.tsx rename apps/ai-game-creator-shell/src/view/project-development/export/tabs/{xiaohongshu => common}/PaneButton.tsx (79%) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/ChatPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/ChatPane.tsx new file mode 100644 index 000000000..b6e09186d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/ChatPane.tsx @@ -0,0 +1,64 @@ +import type { DirectProjectChatBoxProps } from '../../../chat/components/DirectProjectChatBox/DirectProjectChatBox'; +import { DirectProjectChatBox } from '../../../chat/components/DirectProjectChatBox/DirectProjectChatBox'; + +/** + * 导出工作台右半的 chatbox:原样复用主聊天的对话盒(`DirectProjectChatBox`)。 + * + * 外壳用 `.game-workbench-chat`:对话盒那套 `.project-chat-surface.is-direct-codex` 几何 + * 是按这个祖先写的,换成自造容器就会串版。会话真相不在这里——订阅、回合与草稿都在面板根的 + * tab 状态 hook 上,这里只是把第二个 subscriber 的投影喂给同一个对话盒。 + * + * 小红书小工具与陶泥儿发布共用这一份;差异只有 `aria` 文案与忙态兜底文案。两个 tab 的状态 + * hook 都提供同一组字段,`ExportChatPaneTab` 就是这份结构合同。 + */ +export type ExportChatPaneTab = { + chat: DirectProjectChatBoxProps['controller']; + assets: DirectProjectChatBoxProps['assets']; + versions?: DirectProjectChatBoxProps['versions']; + projectPath: DirectProjectChatBoxProps['projectPath']; + turns: DirectProjectChatBoxProps['turns']; + turnStatus: DirectProjectChatBoxProps['turnStatus']; + activeTurnStartedAt: DirectProjectChatBoxProps['activeTurnStartedAt']; + draft: DirectProjectChatBoxProps['draft']; + runtimeNotice: string; + setDraft: (draft: NonNullable<DirectProjectChatBoxProps['draft']>) => void; +}; + +export function ExportChatPane({ + tab, + ariaLabel, + chatAriaLabel, + fallbackStatusText, +}: { + tab: ExportChatPaneTab; + /** `aside` 的语义标签(这一栏)。 */ + ariaLabel: string; + /** 对话盒自己的语义标签。 */ + chatAriaLabel: string; + /** 没有运行提示、也没有控制器提示时的兜底文案。 */ + fallbackStatusText: string; +}) { + return ( + <aside + className="game-workbench-chat h-full min-h-0" + aria-label={ariaLabel} + > + <DirectProjectChatBox + ariaLabel={chatAriaLabel} + controller={tab.chat} + assets={tab.assets} + {...(tab.versions ? { versions: tab.versions } : {})} + projectPath={tab.projectPath} + turns={tab.turns} + turnStatus={tab.turnStatus} + activeTurnStartedAt={tab.activeTurnStartedAt} + statusText={ + tab.runtimeNotice || tab.chat.statusNotice || fallbackStatusText + } + referencesEnabled={false} + draft={tab.draft} + onDraftChange={(next) => tab.setDraft(next.content)} + /> + </aside> + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/PaneButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx similarity index 79% rename from apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/PaneButton.tsx rename to apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx index c82a61b2d..62dfaed32 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/PaneButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx @@ -3,9 +3,10 @@ import type { ReactNode } from 'react'; /** * 面板里的按钮。语气与尺寸收在这一处,别在每个卡片里各抄一遍 Tailwind 串。 * - * `brand` 是小红书红;`hero` 只给「打包」那颗主行动按钮——整行宽、字号更大,是这一页唯一一个 - * 「点下去就产出东西」的动作;其余是次要动作(复制、下载、重试),尺寸跟发布面板 - * (`GameDistributionPublishPanel`)对齐:10px/14px 内边距、10px 圆角、600 字重。 + * `brand` 是小红书红,`primary` 是陶泥儿橙(发布);`hero` 只给每个工作台那颗主行动按钮—— + * 整行宽、字号更大,是一页里唯一一个「点下去就产出东西」的动作;其余是次要动作(复制、下载、 + * 重试),尺寸跟发布面板(`GameDistributionPublishPanel`)对齐:10px/14px 内边距、10px 圆角、 + * 600 字重。 */ export function PaneButton({ children, diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx index c02fc48d3..644733f8f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx @@ -1,5 +1,5 @@ import { ThemedModal } from '../../../../../components/modal/ThemedModal'; -import { PaneButton } from './PaneButton'; +import { PaneButton } from '../common/PaneButton'; /** * 「还没配置好导出」的阻断提示。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 414cb0e62..dda088946 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -1,11 +1,11 @@ import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction'; +import { PaneButton } from '../common/PaneButton'; import { AdaptPromptDialog } from './AdaptPromptDialog'; import { ConflictCard } from './ConflictCard'; import { FailureDialog } from './FailureDialog'; import { FormCard } from './FormCard'; -import { PaneButton } from './PaneButton'; /** * 小红书工作台左半:平台上传表单 + 小工具代码包 + 「打包」。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx index bbbbb8ca8..d5c32e619 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import type { SaveConflict } from '../../generated/SaveConflict'; import type { XHSMiniToolExportForm } from '../../generated/XHSMiniToolExportForm'; import { XHS_MINITOOL_FORM_FIELDS } from '../../state/xhsMinitoolFields'; -import { PaneButton } from './PaneButton'; +import { PaneButton } from '../common/PaneButton'; type Side = 'mine' | 'file'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx index de983eca8..da6d7e4ba 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx @@ -5,7 +5,7 @@ import { describeXhsMinitoolFailure, XhsMinitoolFailureRepairability, } from '../../state/xhsMinitoolFailure'; -import { PaneButton } from './PaneButton'; +import { PaneButton } from '../common/PaneButton'; /** * 失败弹窗:一句用户看得懂的话 + 一颗按「谁有能力修」选出来的按钮。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index 37837296f..b3a1a9784 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -7,9 +7,9 @@ import { ICON_IMPORTER_SETTINGS } from '../../../../../components/AssetImporter/ import type { FormInvalid } from '../../generated/FormInvalid'; import type { XHSMiniToolExportForm } from '../../generated/XHSMiniToolExportForm'; import { XHS_MINITOOL_FORM_FIELDS } from '../../state/xhsMinitoolFields'; +import { PaneButton } from '../common/PaneButton'; import { CopyButton } from './CopyButton'; import { IconPreview } from './IconPreview'; -import { PaneButton } from './PaneButton'; /** * 小红书上传表单。名称与介绍可复制(它们是要粘贴到上传页的文字),图标只给「下载」——复制一条 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/chat/Pane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/chat/Pane.tsx index ce98ddfdc..0765df65c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/chat/Pane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/chat/Pane.tsx @@ -1,39 +1,19 @@ -import { DirectProjectChatBox } from '../../../../chat/components/DirectProjectChatBox/DirectProjectChatBox'; import type { XiaohongshuTabState } from '../../../state/useXiaohongshuTab'; +import { ExportChatPane } from '../../common/ChatPane'; /** - * 右半 chatbox:**原样复用**主聊天的对话盒(`DirectProjectChatBox`)。 + * 小红书工作台右半 chatbox:复用 `common` 的导出对话栏。 * - * 外壳用 `.game-workbench-chat`:对话盒那套 `.project-chat-surface.is-direct-codex` 几何 - * 是按这个祖先写的,换成自造容器就会串版。会话真相不在这里——订阅、回合与草稿都在面板根的 - * `useXiaohongshuTab` 上,这里只是把第二个 subscriber 的投影喂给同一个对话盒。 - * - * 与主聊天的差别只有一处:输入区关掉素材引用(`referencesEnabled={false}`), - * 草稿提升到面板层,Composer 随 tab 页卸载再挂回来也不丢字。 + * 会话真相在面板根的 `useXiaohongshuTab`:这里只是把第二个 subscriber 的投影交给共用组件, + * 与主聊天的差别只有输入区关掉素材引用、草稿提升到面板层。 */ export function ChatPane({ tab }: { tab: XiaohongshuTabState }) { - const { chat, turnStatus } = tab; return ( - <aside - className="game-workbench-chat h-full min-h-0" - aria-label="小红书小工具导出对话栏" - > - <DirectProjectChatBox - ariaLabel="小红书小工具导出对话" - controller={chat} - assets={tab.assets} - {...(tab.versions ? { versions: tab.versions } : {})} - projectPath={tab.projectPath} - turns={tab.turns} - turnStatus={turnStatus} - activeTurnStartedAt={tab.activeTurnStartedAt} - statusText={ - tab.runtimeNotice || chat.statusNotice || '小红书小工具导出会话' - } - referencesEnabled={false} - draft={tab.draft} - onDraftChange={(next) => tab.setDraft(next.content)} - /> - </aside> + <ExportChatPane + tab={tab} + ariaLabel="小红书小工具导出对话栏" + chatAriaLabel="小红书小工具导出对话" + fallbackStatusText="小红书小工具导出会话" + /> ); } From 727aae584424cf498b3ed07155b9979d086a2612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 17:48:58 +0800 Subject: [PATCH 066/156] =?UTF-8?q?AGC=20=E9=99=B6=E6=B3=A5=E5=84=BF?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=A1=A8=E5=8D=95=E5=86=85=E8=81=94=E8=BF=9B?= =?UTF-8?q?=E5=B7=A6=E6=A0=8F=E5=B9=B6=E6=8A=8A=E7=8A=B6=E6=80=81=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=88=B0=E9=9D=A2=E6=9D=BF=E6=A0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 陶泥儿 tab 改为左右两栏:左半内联发布资料表单,右半第二个会话 chatbox - 去掉「发布到游戏广场」二次跳转与弹窗表单,主行动改为内容末尾的橙色「打包并发布」 - 打包并发布一次点击先按需构建打包(沿用进度 / 阻断弹窗),成功后直接提交发布 - 抽出 useGameDistributionPublishForm 与 GameDistributionPublishFormView,弹窗与内联共用同一份表单 - 发布表单状态提升到面板根 useTaonierTab,切 tab 不丢已填资料;只在首次进入陶泥儿 tab 时初始化 - 同步更新发布反馈用例与玩法链路文档 --- .../GameDistributionPublishFormView.tsx | 513 ++++++++++ .../GameDistributionPublishPanel.tsx | 888 +----------------- .../useGameDistributionPublishForm.ts | 630 +++++++++++++ .../export/state/usePanel.ts | 10 + .../export/state/useTaonierTab.ts | 219 +++-- .../export/tabs/taonier/Page.tsx | 57 +- .../export/tabs/taonier/PublishPane.tsx | 42 + .../export/tabs/taonier/chat/Pane.tsx | 19 + .../tests/gamePublishFeedback.test.tsx | 18 +- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- 10 files changed, 1412 insertions(+), 986 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx create mode 100644 apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/chat/Pane.tsx 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..680203c86 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -0,0 +1,513 @@ +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 { + type GameDistributionPublishFormState, + PANEL_IMAGE_ACCEPT, +} from './useGameDistributionPublishForm'; + +/** + * 发布资料表单的表现层:弹窗与工作台内联共用同一份 DOM 结构。 + * + * 状态全部来自 `form`(`useGameDistributionPublishForm` 的返回值);内联形态把主行动换成 + * 内容末尾整行橙色的「打包并发布」。 + */ +export function GameDistributionPublishFormView({ + form, + inline = false, + open, + onClose, + packaging = false, +}: { + form: GameDistributionPublishFormState; + inline?: boolean; + open: boolean; + onClose: () => void; + packaging?: boolean; +}) { + const { + packageResult, + title, + setTitle, + summary, + setSummary, + category, + setCategory, + busy, + error, + result, + cover, + setCover, + screenshots, + previewScreenshot, + setPreviewScreenshot, + uploadingLabel, + metadataSuggestionPending, + metadataSuggestionReady, + coverConfirmOpen, + setCoverConfirmOpen, + generatingCover, + coverGenerationPrice, + coverGenerationDetail, + coverInputRef, + screenshotInputRef, + summaryTouchedRef, + categoryTouchedRef, + activeScreenshotCount, + screenshotsUploading, + coverPriceLabel, + handleCoverSelected, + handleGenerateCover, + handleScreenshotsSelected, + removeScreenshot, + handleSubmit, + handlePackageAndPublish, + } = form; + + const fieldsAndMedia = ( + <> + <div className="game-distribution-publish-panel__fields"> + <label> + 游戏名称 + <input + value={title} + maxLength={40} + onChange={(event) => setTitle(event.target.value)} + /> + </label> + <label> + 一句话简介 + <textarea + value={summary} + maxLength={120} + rows={2} + onChange={(event) => { + summaryTouchedRef.current = true; + setSummary(event.target.value); + }} + /> + </label> + <label> + 分类 + <select + value={category} + onChange={(event) => { + categoryTouchedRef.current = true; + setCategory( + event.target + .value as GameDistributionPublishMetadata['category'], + ); + }} + > + {GAME_DISTRIBUTION_CATEGORIES.map((item) => ( + <option key={item} value={item}> + {item} + </option> + ))} + </select> + </label> + <span + className="game-distribution-publish-panel__metadata-hint" + role="status" + > + {metadataSuggestionPending + ? '正在根据创作内容生成简介和分类…' + : metadataSuggestionReady + ? 'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。' + : '已根据创作内容预填简介和分类,免费;你可以直接修改。'} + </span> + </div> + <div + className="game-distribution-publish-panel__media" + aria-label="封面与截图" + > + <div className="game-distribution-publish-panel__cover"> + <div className="game-distribution-publish-panel__cover-preview"> + {cover?.previewUrl ? ( + <img src={cover.previewUrl} alt="游戏封面预览" /> + ) : ( + <span>{cover ? '封面已上传' : '还没有封面'}</span> + )} + </div> + <div className="game-distribution-publish-panel__cover-editor"> + <span className="game-distribution-publish-panel__cover-editor-label"> + 游戏封面(必需) + </span> + <div className="game-distribution-publish-panel__cover-actions"> + <label className="game-distribution-publish-panel__picker"> + <input + ref={coverInputRef} + type="file" + aria-label="游戏封面图片" + accept={PANEL_IMAGE_ACCEPT} + disabled={busy} + onChange={(event) => { + void handleCoverSelected(event.target.files?.[0] ?? null); + }} + /> + <span className="game-distribution-publish-panel__pick"> + {cover ? '更换封面图片' : '选择封面图片'} + </span> + </label> + <button + type="button" + className="game-distribution-publish-panel__generate-cover" + onClick={() => setCoverConfirmOpen(true)} + disabled={busy || generatingCover || Boolean(uploadingLabel)} + > + {generatingCover + ? '正在生成封面…' + : `AI 生成封面(${coverPriceLabel})`} + </button> + {cover ? ( + <button + type="button" + onClick={() => setCover(null)} + disabled={busy || generatingCover} + > + 移除封面 + </button> + ) : null} + </div> + <span className="game-distribution-publish-panel__hint"> + {cover + ? `已选择「${cover.name}」;重新选择即可替换封面。` + : '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'} + </span> + {coverGenerationDetail ? ( + <span + className="game-distribution-publish-panel__hint" + role={generatingCover ? 'status' : undefined} + > + {coverGenerationDetail} + </span> + ) : null} + </div> + </div> + <label> + {`游戏截图(可选,最多 ${MAX_AGC_GAME_SCREENSHOTS} 张)`} + <span className="game-distribution-publish-panel__picker"> + <input + ref={screenshotInputRef} + type="file" + accept={PANEL_IMAGE_ACCEPT} + multiple + disabled={ + busy || + screenshotsUploading || + activeScreenshotCount >= MAX_AGC_GAME_SCREENSHOTS + } + onChange={(event) => { + void handleScreenshotsSelected( + Array.from(event.target.files ?? []), + ); + }} + /> + <span className="game-distribution-publish-panel__pick"> + {activeScreenshotCount >= MAX_AGC_GAME_SCREENSHOTS + ? '截图已满 6 张' + : screenshotsUploading + ? '截图上传中…' + : '添加截图'} + </span> + </span> + <span className="game-distribution-publish-panel__hint"> + 每张截图独立上传,单张失败不会影响其它截图或发布;鼠标移到成功截图上可删除或预览。 + </span> + </label> + {screenshots.length > 0 ? ( + <ul className="game-distribution-publish-panel__shots"> + {screenshots.map((item, index) => ( + <li + key={item.id} + className={`game-distribution-publish-panel__shot is-${item.status}`} + > + <div className="game-distribution-publish-panel__shot-media"> + {item.previewUrl ? ( + <img src={item.previewUrl} alt={`截图 ${index + 1} 预览`} /> + ) : ( + <span>{`截图 ${index + 1}`}</span> + )} + {item.status === 'uploading' ? ( + <span className="game-distribution-publish-panel__shot-state"> + 上传中… + </span> + ) : null} + {item.status === 'failed' ? ( + <> + <span + className="game-distribution-publish-panel__shot-error" + role="alert" + > + {item.error || '截图上传失败'} + </span> + <div className="game-distribution-publish-panel__shot-actions is-failed"> + <button + type="button" + disabled={busy} + onClick={() => removeScreenshot(item.id)} + aria-label={`删除截图 ${index + 1}`} + title="删除截图" + > + <Trash2 size={15} aria-hidden="true" /> + </button> + </div> + </> + ) : null} + {item.status === 'ready' ? ( + <div className="game-distribution-publish-panel__shot-actions"> + <button + type="button" + disabled={busy} + onClick={() => removeScreenshot(item.id)} + aria-label={`删除截图 ${index + 1}`} + title="删除截图" + > + <Trash2 size={15} aria-hidden="true" /> + </button> + <span aria-hidden="true" /> + <button + type="button" + disabled={busy} + onClick={() => setPreviewScreenshot(item)} + aria-label={`预览截图 ${index + 1}`} + title="预览截图" + > + <Eye size={15} aria-hidden="true" /> + </button> + </div> + ) : null} + </div> + </li> + ))} + </ul> + ) : null} + {uploadingLabel ? ( + <span className="game-distribution-publish-panel__hint"> + {uploadingLabel} + </span> + ) : null} + </div> + </> + ); + + const coverConfirmDialog = ( + <ThemedModal + open={coverConfirmOpen} + ariaLabel="确认生成游戏封面" + onClose={() => setCoverConfirmOpen(false)} + panelClassName="game-publish-progress-dialog game-publish-cover-confirm-dialog" + panelStyle={{ background: '#fffaf7', color: '#4f362d' }} + > + <div className="game-publish-progress-icon" aria-hidden="true"> + ✦ + </div> + <h2>AI 生成游戏封面</h2> + <p> + 将根据当前项目上下文生成 16:9 游戏封面,并自动设为发布封面。 + {coverGenerationPrice === null + ? '本次生成会消耗泥点。' + : `本次生成预计消耗 ${coverGenerationPrice} 泥点。`} + </p> + <div className="game-distribution-publish-panel__actions"> + <button type="button" onClick={() => setCoverConfirmOpen(false)}> + 取消 + </button> + <button type="button" onClick={() => void handleGenerateCover()}> + 生成封面 + </button> + </div> + </ThemedModal> + ); + + const previewDialog = ( + <ThemedModal + open={previewScreenshot !== null} + ariaLabel="截图预览" + onClose={() => setPreviewScreenshot(null)} + panelClassName="game-distribution-publish-panel__preview-dialog" + panelStyle={{ background: '#1d1714', color: '#fffaf7' }} + > + {previewScreenshot?.previewUrl ? ( + <img + src={previewScreenshot.previewUrl} + alt={`${previewScreenshot.name} 预览`} + /> + ) : ( + <p>当前截图没有可用的预览图。</p> + )} + </ThemedModal> + ); + + if (inline) { + return ( + <div + className="flex h-full min-h-0 flex-col gap-3 overflow-auto p-3" + aria-label="游戏发布表单" + > + <section className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4"> + <span className="game-distribution-publish-panel__eyebrow"> + 发布到游戏广场 + </span> + <h2 className="mt-1 text-base font-semibold text-[#3d1f10]"> + 让玩家现在就能试玩 + </h2> + </section> + {result ? ( + <div + className="game-distribution-publish-panel__success" + role="status" + > + <strong>已提交审核</strong> + <p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p> + <p className="game-distribution-publish-panel__mono"> + 版本 {result.versionNumber} ·{' '} + {result.packageSizeBytes.toLocaleString()} B ·{' '} + {result.packageSha256.slice(0, 16)}… + </p> + </div> + ) : ( + <> + {fieldsAndMedia} + {error ? ( + <p + className="game-distribution-publish-panel__error" + role="alert" + > + {error} + </p> + ) : null} + <button + type="button" + disabled={ + busy || + generatingCover || + screenshotsUploading || + Boolean(uploadingLabel) || + Boolean(packaging) || + !title.trim() || + !summary.trim() + } + onClick={() => void handlePackageAndPublish()} + className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-6 py-4 text-lg font-bold tracking-wide text-white shadow-md transition enabled:hover:bg-[#b4562f] disabled:cursor-not-allowed disabled:opacity-45" + > + {packaging + ? '正在打包…' + : busy + ? '上传中…' + : generatingCover + ? '封面生成中…' + : screenshotsUploading + ? '截图上传中…' + : uploadingLabel + ? '素材上传中…' + : '打包并发布'} + </button> + </> + )} + {coverConfirmDialog} + {previewDialog} + </div> + ); + } + + return ( + <> + <ThemedModal + open={open} + ariaLabel="发布到游戏广场" + onClose={busy || generatingCover ? () => undefined : onClose} + panelClassName="game-distribution-publish-panel" + closeOnBackdrop={!busy && !generatingCover} + closeOnEscape={!busy && !generatingCover} + > + <header className="game-distribution-publish-panel__header"> + <div> + <span className="game-distribution-publish-panel__eyebrow"> + 发布到游戏广场 + </span> + <h2>让玩家现在就能试玩</h2> + </div> + <button + type="button" + onClick={onClose} + disabled={busy || generatingCover} + aria-label="关闭发布面板" + > + × + </button> + </header> + {result ? ( + <div + className="game-distribution-publish-panel__success" + role="status" + > + <strong>已提交审核</strong> + <p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p> + <p className="game-distribution-publish-panel__mono"> + 版本 {result.versionNumber} ·{' '} + {result.packageSizeBytes.toLocaleString()} B ·{' '} + {result.packageSha256.slice(0, 16)}… + </p> + <div className="game-distribution-publish-panel__actions"> + <button type="button" onClick={onClose}> + 完成 + </button> + </div> + </div> + ) : ( + <> + {fieldsAndMedia} + {error ? ( + <p + className="game-distribution-publish-panel__error" + role="alert" + > + {error} + </p> + ) : null} + <div className="game-distribution-publish-panel__actions"> + <button + type="button" + onClick={onClose} + disabled={busy || generatingCover || screenshotsUploading} + > + 稍后再说 + </button> + <button + type="button" + onClick={() => void handleSubmit()} + disabled={ + busy || + generatingCover || + screenshotsUploading || + Boolean(uploadingLabel) || + !title.trim() || + !summary.trim() || + !packageResult + } + > + {busy + ? '上传中…' + : generatingCover + ? '封面生成中…' + : screenshotsUploading + ? '截图上传中…' + : uploadingLabel + ? '素材上传中…' + : '发布游戏'} + </button> + </div> + </> + )} + </ThemedModal> + {coverConfirmDialog} + {previewDialog} + </> + ); +} diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 23524532f..56b954f8b 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -1,149 +1,15 @@ -import { Eye, Trash2 } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; - import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution'; -import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; -import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectExportPackageResult } from '../../app/types'; -import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; -import { - createGameDistributionPublishKey, - type GameDistributionPublishMetadata, - type GameDistributionPublishResult, - generateGameDistributionCover, - MAX_AGC_GAME_SCREENSHOTS, - publishLocalProjectGame, - readGameCoverGenerationPrice, - suggestGameDistributionPublishMetadata, -} from '../../services/gameDistributionPublish'; -import { ThemedModal } from '../modal/ThemedModal'; - -/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */ -const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024; -const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; - -type PanelImageKind = 'cover' | 'screenshot'; - -type PanelImageAsset = { - /** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */ - signature: string; - name: string; - assetObjectId: string; - previewUrl: string; -}; - -type PanelScreenshotEntry = { - id: string; - name: string; - previewUrl: string; - assetObjectId: string | null; - status: 'uploading' | 'ready' | 'failed'; - error: string | null; - file: File; -}; - -function resolvePanelImageLabel(kind: PanelImageKind) { - return kind === 'cover' ? '游戏封面' : '游戏截图'; -} - -/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */ -function resolvePanelImageFileError(file: File, kind: PanelImageKind) { - const label = resolvePanelImageLabel(kind); - if (file.size <= 0) return `${label}文件为空,请重新选择`; - if (file.size > PANEL_IMAGE_MAX_BYTES) { - return `${label}过大,请压缩后再上传(最多 6MB)`; - } - const contentType = file.type.trim(); - if (contentType && !contentType.startsWith('image/')) { - return `${label}必须是图片文件`; - } - return ''; -} - -function buildPanelImageSignature(file: File, kind: PanelImageKind) { - return `${kind}:${file.name}:${file.size}:${file.lastModified}`; -} - -/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */ -function buildPanelImagePreviewUrl(file: File) { - if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { - return ''; - } - try { - return URL.createObjectURL(file); - } catch { - return ''; - } -} - -function createPanelScreenshotId() { - const randomUuid = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `screenshot-${randomUuid}`; -} - -const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; -const COVER_GENERATION_ASPECT_RATIO = '16:9'; -const COVER_GENERATION_IMAGE_SIZE = '2K'; - -function buildPublishProjectContext(manifest: GameCreationAppManifest) { - const taskSummary = (manifest.tasks ?? []) - .slice(0, 16) - .map((task) => `${task.title}(${task.status})`) - .join('、'); - const assetSummary = (manifest.assets ?? []) - .slice(0, 24) - .map((asset) => `${asset.kind}:${contextSafeAssetLabel(asset.localPath)}`) - .join('、'); - const versionSummary = (manifest.versions ?? []) - .slice(-3) - .map((version) => version.editPrompt?.trim()) - .filter(Boolean) - .join(';'); - return [ - `当前任务与状态:${taskSummary || '暂无'}`, - `现有素材:${assetSummary || '暂无'}`, - manifest.preview?.status - ? `运行视图:${manifest.preview.status}` - : '运行视图:未运行', - versionSummary ? `最近编辑:${versionSummary}` : '', - ] - .filter(Boolean) - .join('\n') - .slice(0, 6_000); -} +import type { GameDistributionPublishResult } from '../../services/gameDistributionPublish'; +import { GameDistributionPublishFormView } from './GameDistributionPublishFormView'; +import { useGameDistributionPublishForm } from './useGameDistributionPublishForm'; /** - * 送进模型的项目上下文必须脱敏:素材只带文件名,不带目录片段。 + * 发布资料表单的弹窗外壳:点按钮弹出的独立面板。 * - * 素材清单里的 `localPath` 正常是项目内相对路径,但登记来源不受本地约束时也可能是 - * 绝对路径(含盘符或用户目录)——`C:\Users\<用户名>\...\assets\x.png` 这种一旦进提示词 - * 就等于把本地目录结构发给了平台模型。这里统一取最后一段并限长。 + * 表单状态在 `useGameDistributionPublishForm`;工作台内联形态把它提升到导出面板根 + * (`useTaonierTab`),切 tab 不丢。这里保留「弹窗 + 关闭」的入口形态。 */ -function contextSafeAssetLabel(value: string) { - const normalized = value.trim().replaceAll('\\', '/'); - const base = normalized.split('/').filter(Boolean).pop() ?? ''; - return base.slice(0, 80); -} - -function buildCoverGenerationPrompt( - manifest: GameCreationAppManifest, - summary: string, -) { - const goal = manifest.goal?.trim() || summary.trim(); - return [ - `为游戏《${manifest.name.trim() || '未命名游戏'}》生成一张游戏广场发行封面。`, - goal ? `创作目标与核心体验:${goal}` : '', - '主体画面需要体现游戏的核心玩法、题材氛围与代表性元素,构图清晰,适合作为游戏卡片和详情页首图。', - '16:9 横版构图,主体居中偏上,边缘保留安全区;不要生成文字、水印、平台标识、边框或界面截图。', - ] - .filter(Boolean) - .join('\n'); -} - export function GameDistributionPublishPanel({ open, projectPath, @@ -159,735 +25,19 @@ export function GameDistributionPublishPanel({ onClose: () => void; onPublished?: (result: GameDistributionPublishResult) => void; }) { - const [title, setTitle] = useState(''); - const [summary, setSummary] = useState(''); - const [category, setCategory] = - useState<GameDistributionPublishMetadata['category']>('其他'); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); - const [result, setResult] = useState<GameDistributionPublishResult | null>( - null, - ); - const [cover, setCover] = useState<PanelImageAsset | null>(null); - const [screenshots, setScreenshots] = useState<PanelScreenshotEntry[]>([]); - const [previewScreenshot, setPreviewScreenshot] = - useState<PanelScreenshotEntry | null>(null); - const [uploadingLabel, setUploadingLabel] = useState(''); - const [metadataSuggestionPending, setMetadataSuggestionPending] = - useState(false); - const [metadataSuggestionReady, setMetadataSuggestionReady] = useState(false); - const [coverConfirmOpen, setCoverConfirmOpen] = useState(false); - const [generatingCover, setGeneratingCover] = useState(false); - const [coverGenerationPrice, setCoverGenerationPrice] = useState< - number | null - >(null); - const [coverGenerationDetail, setCoverGenerationDetail] = useState(''); - const publishIdempotencyKeyRef = useRef(''); - const metadataSuggestionRequestRef = useRef(0); - const summaryTouchedRef = useRef(false); - const categoryTouchedRef = useRef(false); - const coverInputRef = useRef<HTMLInputElement>(null); - const screenshotInputRef = useRef<HTMLInputElement>(null); - // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 - const uploadedAssetCacheRef = useRef(new Map<string, string>()); - const objectUrlsRef = useRef(new Set<string>()); - - useEffect(() => { - if (!open) return; - const fallbackSummary = (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏') - .trim() - .slice(0, 120); - setTitle(manifest.name.trim()); - setSummary(fallbackSummary); - setCategory('其他'); - setBusy(false); - setError(''); - setResult(null); - setMetadataSuggestionPending(false); - setMetadataSuggestionReady(false); - setCoverConfirmOpen(false); - setGeneratingCover(false); - setCoverGenerationPrice(null); - setCoverGenerationDetail(''); - setPreviewScreenshot(null); - summaryTouchedRef.current = false; - categoryTouchedRef.current = false; - - const requestId = ++metadataSuggestionRequestRef.current; - setMetadataSuggestionPending(true); - void suggestGameDistributionPublishMetadata({ - name: manifest.name, - goal: manifest.goal, - context: buildPublishProjectContext(manifest), - }) - .then((suggestion) => { - if (requestId !== metadataSuggestionRequestRef.current) return; - if (!summaryTouchedRef.current && suggestion.summary.trim()) { - setSummary(suggestion.summary.trim().slice(0, 120)); - } - if (!categoryTouchedRef.current && suggestion.category) { - setCategory(suggestion.category); - } - setMetadataSuggestionReady(true); - }) - .catch(() => { - // 免费增强能力失败时保留本地兜底,不阻断发布。 - }) - .finally(() => { - if (requestId === metadataSuggestionRequestRef.current) { - setMetadataSuggestionPending(false); - } - }); - }, [manifest, open, packageResult?.packageRelativePath]); - - useEffect(() => { - if (open) { - publishIdempotencyKeyRef.current = createGameDistributionPublishKey(); - } - }, [open, packageResult?.packageRelativePath]); - - useEffect(() => { - if (!open) return; - let cancelled = false; - void readGameCoverGenerationPrice({ - model: COVER_GENERATION_MODEL, - imageSize: COVER_GENERATION_IMAGE_SIZE, - }) - .then((price) => { - if (!cancelled) setCoverGenerationPrice(price); - }) - .catch(() => { - if (!cancelled) setCoverGenerationPrice(null); - }); - return () => { - cancelled = true; - }; - }, [open]); - - useEffect(() => { - const objectUrls = objectUrlsRef.current; - return () => { - objectUrls.forEach((url) => { - try { - URL.revokeObjectURL(url); - } catch { - // 预览地址释放失败不影响关闭面板。 - } - }); - objectUrls.clear(); - }; - }, []); - - async function resolveAssetObjectId(file: File, kind: PanelImageKind) { - const signature = buildPanelImageSignature(file, kind); - const cached = uploadedAssetCacheRef.current.get(signature); - if (cached) return cached; - const uploaded = await uploadPlatformMediaAsset({ - file, - assetKind: - kind === 'cover' - ? 'game_distribution_cover' - : 'game_distribution_screenshot', - pathSegments: ['game-distribution', kind, `${Date.now()}`], - entityId: `game-distribution-${kind}`, - metadata: { game_distribution_media: kind }, - }); - uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId); - return uploaded.assetObjectId; - } - - function buildPanelImageAsset( - file: File, - kind: PanelImageKind, - assetObjectId: string, - ) { - const previewUrl = buildPanelImagePreviewUrl(file); - if (previewUrl) objectUrlsRef.current.add(previewUrl); - return { - signature: buildPanelImageSignature(file, kind), - name: file.name.trim() || resolvePanelImageLabel(kind), - assetObjectId, - previewUrl, - }; - } - - async function handleCoverSelected(file: File | null) { - if (!file) return; - setError(''); - const fileError = resolvePanelImageFileError(file, 'cover'); - if (fileError) { - setError(fileError); - return; - } - setUploadingLabel('正在上传封面…'); - try { - const assetObjectId = await resolveAssetObjectId(file, 'cover'); - setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); - } catch (uploadError) { - setError( - uploadError instanceof Error - ? uploadError.message - : '封面上传失败,请重试', - ); - } finally { - setUploadingLabel(''); - if (coverInputRef.current) coverInputRef.current.value = ''; - } - } - - async function handleGenerateCover() { - setCoverConfirmOpen(false); - setError(''); - setCoverGenerationDetail('正在根据项目内容生成封面…'); - setGeneratingCover(true); - try { - const generated = await generateGameDistributionCover({ - prompt: buildCoverGenerationPrompt(manifest, summary), - model: COVER_GENERATION_MODEL, - aspectRatio: COVER_GENERATION_ASPECT_RATIO, - imageSize: COVER_GENERATION_IMAGE_SIZE, - assetLabel: `${manifest.name.trim() || '游戏'}封面`, - }); - setCover({ - signature: generated.taskId - ? `generated:${generated.taskId}` - : `generated:${generated.assetObjectId}`, - name: 'AI 生成封面', - assetObjectId: generated.assetObjectId, - previewUrl: generated.previewUrl, - }); - setCoverGenerationDetail( - '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。', - ); - } catch (generationError) { - setCoverGenerationDetail(''); - setError( - generationError instanceof Error - ? generationError.message - : '生成游戏封面失败,请重试', - ); - } finally { - setGeneratingCover(false); - } - } - - function createPanelScreenshotEntry(file: File): PanelScreenshotEntry { - const fileError = resolvePanelImageFileError(file, 'screenshot'); - return { - id: createPanelScreenshotId(), - name: file.name.trim() || '游戏截图', - previewUrl: buildPanelImagePreviewUrl(file), - assetObjectId: null, - status: fileError ? 'failed' : 'uploading', - error: fileError || null, - file, - }; - } - - function removeScreenshot(id: string) { - setScreenshots((current) => { - const target = current.find((item) => item.id === id); - if (target?.previewUrl && objectUrlsRef.current.has(target.previewUrl)) { - try { - URL.revokeObjectURL(target.previewUrl); - } catch { - // 预览地址释放失败不影响移除截图。 - } - objectUrlsRef.current.delete(target.previewUrl); - } - return current.filter((item) => item.id !== id); - }); - setPreviewScreenshot((current) => (current?.id === id ? null : current)); - } - - async function handleScreenshotsSelected(files: File[]) { - if (files.length === 0) return; - const counted = screenshots.filter( - (item) => item.status !== 'failed', - ).length; - const remaining = MAX_AGC_GAME_SCREENSHOTS - counted; - if (files.length > remaining) { - setError( - remaining > 0 - ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` - : '游戏截图最多 6 张', - ); - if (screenshotInputRef.current) screenshotInputRef.current.value = ''; - return; - } - setError(''); - const entries = files.map(createPanelScreenshotEntry); - setScreenshots((current) => [...current, ...entries]); - - // 每张截图独立上传、独立失败;某一张失败只标记该张,不打断同批其它截图。 - await Promise.all( - entries.map(async (entry) => { - if (entry.status === 'failed') return; - try { - const assetObjectId = await resolveAssetObjectId( - entry.file, - 'screenshot', - ); - setScreenshots((current) => - current.map((item) => - item.id === entry.id - ? { - ...item, - status: 'ready', - assetObjectId, - error: null, - } - : item, - ), - ); - } catch (uploadError) { - setScreenshots((current) => - current.map((item) => - item.id === entry.id - ? { - ...item, - status: 'failed', - assetObjectId: null, - error: - uploadError instanceof Error - ? uploadError.message - : '截图上传失败,请删除后重试', - } - : item, - ), - ); - } - }), - ); - if (screenshotInputRef.current) screenshotInputRef.current.value = ''; - } - - async function handleSubmit() { - if (!packageResult || !projectPath.trim()) { - setError('请先导出有效的试玩包'); - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setError('需要在 Tauri App 内发布'); - return; - } - if (!cover) { - setError('请先选择游戏封面(JPG/PNG/WebP)'); - return; - } - if ( - uploadingLabel || - screenshots.some((item) => item.status === 'uploading') - ) { - setError('素材还在上传中,请稍候再发布'); - return; - } - setBusy(true); - setError(''); - try { - const next = await publishLocalProjectGame({ - invoke, - projectPath, - packageRelativePath: packageResult.packageRelativePath, - manifest, - metadata: { - title, - summary, - category, - coverAssetId: cover.assetObjectId, - screenshots: screenshots - .filter( - ( - item, - ): item is PanelScreenshotEntry & { - assetObjectId: string; - } => item.status === 'ready' && Boolean(item.assetObjectId), - ) - .map((item) => item.assetObjectId), - }, - idempotencyKey: publishIdempotencyKeyRef.current, - }); - setResult(next); - onPublished?.(next); - } catch (nextError) { - setError( - nextError instanceof Error ? nextError.message : String(nextError), - ); - } finally { - setBusy(false); - } - } - - const activeScreenshotCount = screenshots.filter( - (item) => item.status !== 'failed', - ).length; - const screenshotsUploading = screenshots.some( - (item) => item.status === 'uploading', - ); - const coverPriceLabel = - coverGenerationPrice === null ? '消耗泥点' : `${coverGenerationPrice} 泥点`; - + const form = useGameDistributionPublishForm({ + open, + inline: false, + projectPath, + manifest, + packageResult, + onPublished, + }); return ( - <> - <ThemedModal - open={open} - ariaLabel="发布到游戏广场" - onClose={busy || generatingCover ? () => undefined : onClose} - panelClassName="game-distribution-publish-panel" - closeOnBackdrop={!busy && !generatingCover} - closeOnEscape={!busy && !generatingCover} - > - <header className="game-distribution-publish-panel__header"> - <div> - <span className="game-distribution-publish-panel__eyebrow"> - 发布到游戏广场 - </span> - <h2>让玩家现在就能试玩</h2> - </div> - <button - type="button" - onClick={onClose} - disabled={busy || generatingCover} - aria-label="关闭发布面板" - > - × - </button> - </header> - {result ? ( - <div - className="game-distribution-publish-panel__success" - role="status" - > - <strong>已提交审核</strong> - <p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p> - <p className="game-distribution-publish-panel__mono"> - 版本 {result.versionNumber} ·{' '} - {result.packageSizeBytes.toLocaleString()} B ·{' '} - {result.packageSha256.slice(0, 16)}… - </p> - <div className="game-distribution-publish-panel__actions"> - <button type="button" onClick={onClose}> - 完成 - </button> - </div> - </div> - ) : ( - <> - <div className="game-distribution-publish-panel__fields"> - <label> - 游戏名称 - <input - value={title} - maxLength={40} - onChange={(event) => setTitle(event.target.value)} - /> - </label> - <label> - 一句话简介 - <textarea - value={summary} - maxLength={120} - rows={2} - onChange={(event) => { - summaryTouchedRef.current = true; - setSummary(event.target.value); - }} - /> - </label> - <label> - 分类 - <select - value={category} - onChange={(event) => { - categoryTouchedRef.current = true; - setCategory( - event.target - .value as GameDistributionPublishMetadata['category'], - ); - }} - > - {GAME_DISTRIBUTION_CATEGORIES.map((item) => ( - <option key={item} value={item}> - {item} - </option> - ))} - </select> - </label> - <span - className="game-distribution-publish-panel__metadata-hint" - role="status" - > - {metadataSuggestionPending - ? '正在根据创作内容生成简介和分类…' - : metadataSuggestionReady - ? 'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。' - : '已根据创作内容预填简介和分类,免费;你可以直接修改。'} - </span> - </div> - <div - className="game-distribution-publish-panel__media" - aria-label="封面与截图" - > - <div className="game-distribution-publish-panel__cover"> - <div className="game-distribution-publish-panel__cover-preview"> - {cover?.previewUrl ? ( - <img src={cover.previewUrl} alt="游戏封面预览" /> - ) : ( - <span>{cover ? '封面已上传' : '还没有封面'}</span> - )} - </div> - <div className="game-distribution-publish-panel__cover-editor"> - <span className="game-distribution-publish-panel__cover-editor-label"> - 游戏封面(必需) - </span> - <div className="game-distribution-publish-panel__cover-actions"> - <label className="game-distribution-publish-panel__picker"> - <input - ref={coverInputRef} - type="file" - aria-label="游戏封面图片" - accept={PANEL_IMAGE_ACCEPT} - disabled={busy} - onChange={(event) => { - void handleCoverSelected( - event.target.files?.[0] ?? null, - ); - }} - /> - <span className="game-distribution-publish-panel__pick"> - {cover ? '更换封面图片' : '选择封面图片'} - </span> - </label> - <button - type="button" - className="game-distribution-publish-panel__generate-cover" - onClick={() => setCoverConfirmOpen(true)} - disabled={ - busy || generatingCover || Boolean(uploadingLabel) - } - > - {generatingCover - ? '正在生成封面…' - : `AI 生成封面(${coverPriceLabel})`} - </button> - {cover ? ( - <button - type="button" - onClick={() => setCover(null)} - disabled={busy || generatingCover} - > - 移除封面 - </button> - ) : null} - </div> - <span className="game-distribution-publish-panel__hint"> - {cover - ? `已选择「${cover.name}」;重新选择即可替换封面。` - : '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'} - </span> - {coverGenerationDetail ? ( - <span - className="game-distribution-publish-panel__hint" - role={generatingCover ? 'status' : undefined} - > - {coverGenerationDetail} - </span> - ) : null} - </div> - </div> - <label> - {`游戏截图(可选,最多 ${MAX_AGC_GAME_SCREENSHOTS} 张)`} - <span className="game-distribution-publish-panel__picker"> - <input - ref={screenshotInputRef} - type="file" - accept={PANEL_IMAGE_ACCEPT} - multiple - disabled={ - busy || - screenshotsUploading || - activeScreenshotCount >= MAX_AGC_GAME_SCREENSHOTS - } - onChange={(event) => { - void handleScreenshotsSelected( - Array.from(event.target.files ?? []), - ); - }} - /> - <span className="game-distribution-publish-panel__pick"> - {activeScreenshotCount >= MAX_AGC_GAME_SCREENSHOTS - ? '截图已满 6 张' - : screenshotsUploading - ? '截图上传中…' - : '添加截图'} - </span> - </span> - <span className="game-distribution-publish-panel__hint"> - 每张截图独立上传,单张失败不会影响其它截图或发布;鼠标移到成功截图上可删除或预览。 - </span> - </label> - {screenshots.length > 0 ? ( - <ul className="game-distribution-publish-panel__shots"> - {screenshots.map((item, index) => ( - <li - key={item.id} - className={`game-distribution-publish-panel__shot is-${item.status}`} - > - <div className="game-distribution-publish-panel__shot-media"> - {item.previewUrl ? ( - <img - src={item.previewUrl} - alt={`截图 ${index + 1} 预览`} - /> - ) : ( - <span>{`截图 ${index + 1}`}</span> - )} - {item.status === 'uploading' ? ( - <span className="game-distribution-publish-panel__shot-state"> - 上传中… - </span> - ) : null} - {item.status === 'failed' ? ( - <> - <span - className="game-distribution-publish-panel__shot-error" - role="alert" - > - {item.error || '截图上传失败'} - </span> - <div className="game-distribution-publish-panel__shot-actions is-failed"> - <button - type="button" - disabled={busy} - onClick={() => removeScreenshot(item.id)} - aria-label={`删除截图 ${index + 1}`} - title="删除截图" - > - <Trash2 size={15} aria-hidden="true" /> - </button> - </div> - </> - ) : null} - {item.status === 'ready' ? ( - <div className="game-distribution-publish-panel__shot-actions"> - <button - type="button" - disabled={busy} - onClick={() => removeScreenshot(item.id)} - aria-label={`删除截图 ${index + 1}`} - title="删除截图" - > - <Trash2 size={15} aria-hidden="true" /> - </button> - <span aria-hidden="true" /> - <button - type="button" - disabled={busy} - onClick={() => setPreviewScreenshot(item)} - aria-label={`预览截图 ${index + 1}`} - title="预览截图" - > - <Eye size={15} aria-hidden="true" /> - </button> - </div> - ) : null} - </div> - </li> - ))} - </ul> - ) : null} - {uploadingLabel ? ( - <span className="game-distribution-publish-panel__hint"> - {uploadingLabel} - </span> - ) : null} - </div> - {error ? ( - <p - className="game-distribution-publish-panel__error" - role="alert" - > - {error} - </p> - ) : null} - <div className="game-distribution-publish-panel__actions"> - <button - type="button" - onClick={onClose} - disabled={busy || generatingCover || screenshotsUploading} - > - 稍后再说 - </button> - <button - type="button" - onClick={() => void handleSubmit()} - disabled={ - busy || - generatingCover || - screenshotsUploading || - Boolean(uploadingLabel) || - !title.trim() || - !summary.trim() || - !packageResult - } - > - {busy - ? '上传中…' - : generatingCover - ? '封面生成中…' - : screenshotsUploading - ? '截图上传中…' - : uploadingLabel - ? '素材上传中…' - : '发布游戏'} - </button> - </div> - </> - )} - </ThemedModal> - <ThemedModal - open={coverConfirmOpen} - ariaLabel="确认生成游戏封面" - onClose={() => setCoverConfirmOpen(false)} - panelClassName="game-publish-progress-dialog game-publish-cover-confirm-dialog" - panelStyle={{ background: '#fffaf7', color: '#4f362d' }} - > - <div className="game-publish-progress-icon" aria-hidden="true"> - ✦ - </div> - <h2>AI 生成游戏封面</h2> - <p> - 将根据当前项目上下文生成 16:9 游戏封面,并自动设为发布封面。 - {coverGenerationPrice === null - ? '本次生成会消耗泥点。' - : `本次生成预计消耗 ${coverGenerationPrice} 泥点。`} - </p> - <div className="game-distribution-publish-panel__actions"> - <button type="button" onClick={() => setCoverConfirmOpen(false)}> - 取消 - </button> - <button type="button" onClick={() => void handleGenerateCover()}> - 生成封面 - </button> - </div> - </ThemedModal> - <ThemedModal - open={previewScreenshot !== null} - ariaLabel="截图预览" - onClose={() => setPreviewScreenshot(null)} - panelClassName="game-distribution-publish-panel__preview-dialog" - panelStyle={{ background: '#1d1714', color: '#fffaf7' }} - > - {previewScreenshot?.previewUrl ? ( - <img - src={previewScreenshot.previewUrl} - alt={`${previewScreenshot.name} 预览`} - /> - ) : ( - <p>当前截图没有可用的预览图。</p> - )} - </ThemedModal> - </> + <GameDistributionPublishFormView + form={form} + open={open} + onClose={onClose} + /> ); } diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts new file mode 100644 index 000000000..2c9090b31 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -0,0 +1,630 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { LocalProjectExportPackageResult } from '../../app/types'; +import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; +import { + createGameDistributionPublishKey, + type GameDistributionPublishMetadata, + type GameDistributionPublishResult, + generateGameDistributionCover, + MAX_AGC_GAME_SCREENSHOTS, + publishLocalProjectGame, + readGameCoverGenerationPrice, + suggestGameDistributionPublishMetadata, +} from '../../services/gameDistributionPublish'; + +/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */ +const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024; +export const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; + +type PanelImageKind = 'cover' | 'screenshot'; + +type PanelImageAsset = { + /** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */ + signature: string; + name: string; + assetObjectId: string; + previewUrl: string; +}; + +type PanelScreenshotEntry = { + id: string; + name: string; + previewUrl: string; + assetObjectId: string | null; + status: 'uploading' | 'ready' | 'failed'; + error: string | null; + file: File; +}; + +function resolvePanelImageLabel(kind: PanelImageKind) { + return kind === 'cover' ? '游戏封面' : '游戏截图'; +} + +/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */ +function resolvePanelImageFileError(file: File, kind: PanelImageKind) { + const label = resolvePanelImageLabel(kind); + if (file.size <= 0) return `${label}文件为空,请重新选择`; + if (file.size > PANEL_IMAGE_MAX_BYTES) { + return `${label}过大,请压缩后再上传(最多 6MB)`; + } + const contentType = file.type.trim(); + if (contentType && !contentType.startsWith('image/')) { + return `${label}必须是图片文件`; + } + return ''; +} + +function buildPanelImageSignature(file: File, kind: PanelImageKind) { + return `${kind}:${file.name}:${file.size}:${file.lastModified}`; +} + +/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */ +function buildPanelImagePreviewUrl(file: File) { + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + return ''; + } + try { + return URL.createObjectURL(file); + } catch { + return ''; + } +} + +function createPanelScreenshotId() { + const randomUuid = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `screenshot-${randomUuid}`; +} + +const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; +const COVER_GENERATION_ASPECT_RATIO = '16:9'; +const COVER_GENERATION_IMAGE_SIZE = '2K'; + +function buildPublishProjectContext(manifest: GameCreationAppManifest) { + const taskSummary = (manifest.tasks ?? []) + .slice(0, 16) + .map((task) => `${task.title}(${task.status})`) + .join('、'); + const assetSummary = (manifest.assets ?? []) + .slice(0, 24) + .map((asset) => `${asset.kind}:${contextSafeAssetLabel(asset.localPath)}`) + .join('、'); + const versionSummary = (manifest.versions ?? []) + .slice(-3) + .map((version) => version.editPrompt?.trim()) + .filter(Boolean) + .join(';'); + return [ + `当前任务与状态:${taskSummary || '暂无'}`, + `现有素材:${assetSummary || '暂无'}`, + manifest.preview?.status + ? `运行视图:${manifest.preview.status}` + : '运行视图:未运行', + versionSummary ? `最近编辑:${versionSummary}` : '', + ] + .filter(Boolean) + .join('\n') + .slice(0, 6_000); +} + +/** + * 送进模型的项目上下文必须脱敏:素材只带文件名,不带目录片段。 + * + * 素材清单里的 `localPath` 正常是项目内相对路径,但登记来源不受本地约束时也可能是 + * 绝对路径(含盘符或用户目录)——`C:\Users\<用户名>\...\assets\x.png` 这种一旦进提示词 + * 就等于把本地目录结构发给了平台模型。这里统一取最后一段并限长。 + */ +function contextSafeAssetLabel(value: string) { + const normalized = value.trim().replaceAll('\\', '/'); + const base = normalized.split('/').filter(Boolean).pop() ?? ''; + return base.slice(0, 80); +} + +function buildCoverGenerationPrompt( + manifest: GameCreationAppManifest, + summary: string, +) { + const goal = manifest.goal?.trim() || summary.trim(); + return [ + `为游戏《${manifest.name.trim() || '未命名游戏'}》生成一张游戏广场发行封面。`, + goal ? `创作目标与核心体验:${goal}` : '', + '主体画面需要体现游戏的核心玩法、题材氛围与代表性元素,构图清晰,适合作为游戏卡片和详情页首图。', + '16:9 横版构图,主体居中偏上,边缘保留安全区;不要生成文字、水印、平台标识、边框或界面截图。', + ] + .filter(Boolean) + .join('\n'); +} + +export type GameDistributionPublishFormState = ReturnType< + typeof useGameDistributionPublishForm +>; + +/** + * 发布资料表单的全部状态与动作。 + * + * 弹窗形态由 `GameDistributionPublishPanel` 自己调用;工作台内联形态把它提升到导出面板根 + * (`useTaonierTab`),这样切走 tab、页面组件卸载也不会丢掉已经填好的发布资料。 + * + * `inline` 决定初始化开关:弹窗每次带着新试玩包打开都重来;内联常驻左栏,只在换项目时重来。 + */ +export function useGameDistributionPublishForm({ + open, + inline, + projectPath, + manifest, + packageResult, + onPublished, + onEnsurePackage, +}: { + open: boolean; + inline: boolean; + projectPath: string; + manifest: GameCreationAppManifest | null; + packageResult: LocalProjectExportPackageResult | null; + onPublished?: (result: GameDistributionPublishResult) => void; + onEnsurePackage?: () => Promise<LocalProjectExportPackageResult | null>; +}) { + const manifestRef = useRef(manifest); + manifestRef.current = manifest; + const [title, setTitle] = useState(''); + const [summary, setSummary] = useState(''); + const [category, setCategory] = + useState<GameDistributionPublishMetadata['category']>('其他'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState<GameDistributionPublishResult | null>( + null, + ); + const [cover, setCover] = useState<PanelImageAsset | null>(null); + const [screenshots, setScreenshots] = useState<PanelScreenshotEntry[]>([]); + const [previewScreenshot, setPreviewScreenshot] = + useState<PanelScreenshotEntry | null>(null); + const [uploadingLabel, setUploadingLabel] = useState(''); + const [metadataSuggestionPending, setMetadataSuggestionPending] = + useState(false); + const [metadataSuggestionReady, setMetadataSuggestionReady] = useState(false); + const [coverConfirmOpen, setCoverConfirmOpen] = useState(false); + const [generatingCover, setGeneratingCover] = useState(false); + const [coverGenerationPrice, setCoverGenerationPrice] = useState< + number | null + >(null); + const [coverGenerationDetail, setCoverGenerationDetail] = useState(''); + const publishIdempotencyKeyRef = useRef(''); + const metadataSuggestionRequestRef = useRef(0); + const summaryTouchedRef = useRef(false); + const categoryTouchedRef = useRef(false); + const coverInputRef = useRef<HTMLInputElement>(null); + const screenshotInputRef = useRef<HTMLInputElement>(null); + // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 + const uploadedAssetCacheRef = useRef(new Map<string, string>()); + const objectUrlsRef = useRef(new Set<string>()); + + /** + * 表单初始化开关:弹窗每次带着新试玩包打开都要重来;内联常驻左栏,只在换项目时重来, + * 不能因为「打包完包结果变化」就把用户已经填好的资料清掉。 + */ + const resetKey = inline + ? 'inline' + : (packageResult?.packageRelativePath ?? ''); + + useEffect(() => { + if (!open) return; + const currentManifest = manifestRef.current; + if (!currentManifest) return; + const fallbackSummary = ( + currentManifest.goal ?? '由陶泥儿创作的可在线游玩游戏' + ) + .trim() + .slice(0, 120); + setTitle(currentManifest.name.trim()); + setSummary(fallbackSummary); + setCategory('其他'); + setBusy(false); + setError(''); + setResult(null); + setMetadataSuggestionPending(false); + setMetadataSuggestionReady(false); + setCoverConfirmOpen(false); + setGeneratingCover(false); + setCoverGenerationPrice(null); + setCoverGenerationDetail(''); + setPreviewScreenshot(null); + summaryTouchedRef.current = false; + categoryTouchedRef.current = false; + + const requestId = ++metadataSuggestionRequestRef.current; + setMetadataSuggestionPending(true); + void suggestGameDistributionPublishMetadata({ + name: currentManifest.name, + goal: currentManifest.goal, + context: buildPublishProjectContext(currentManifest), + }) + .then((suggestion) => { + if (requestId !== metadataSuggestionRequestRef.current) return; + if (!summaryTouchedRef.current && suggestion.summary.trim()) { + setSummary(suggestion.summary.trim().slice(0, 120)); + } + if (!categoryTouchedRef.current && suggestion.category) { + setCategory(suggestion.category); + } + setMetadataSuggestionReady(true); + }) + .catch(() => { + // 免费增强能力失败时保留本地兜底,不阻断发布。 + }) + .finally(() => { + if (requestId === metadataSuggestionRequestRef.current) { + setMetadataSuggestionPending(false); + } + }); + }, [manifest?.projectId, open, resetKey]); + + useEffect(() => { + if (open) { + publishIdempotencyKeyRef.current = createGameDistributionPublishKey(); + } + }, [open, packageResult?.packageRelativePath]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + void readGameCoverGenerationPrice({ + model: COVER_GENERATION_MODEL, + imageSize: COVER_GENERATION_IMAGE_SIZE, + }) + .then((price) => { + if (!cancelled) setCoverGenerationPrice(price); + }) + .catch(() => { + if (!cancelled) setCoverGenerationPrice(null); + }); + return () => { + cancelled = true; + }; + }, [open]); + + useEffect(() => { + const objectUrls = objectUrlsRef.current; + return () => { + objectUrls.forEach((url) => { + try { + URL.revokeObjectURL(url); + } catch { + // 预览地址释放失败不影响关闭面板。 + } + }); + objectUrls.clear(); + }; + }, []); + + async function resolveAssetObjectId(file: File, kind: PanelImageKind) { + const signature = buildPanelImageSignature(file, kind); + const cached = uploadedAssetCacheRef.current.get(signature); + if (cached) return cached; + const uploaded = await uploadPlatformMediaAsset({ + file, + assetKind: + kind === 'cover' + ? 'game_distribution_cover' + : 'game_distribution_screenshot', + pathSegments: ['game-distribution', kind, `${Date.now()}`], + entityId: `game-distribution-${kind}`, + metadata: { game_distribution_media: kind }, + }); + uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId); + return uploaded.assetObjectId; + } + + function buildPanelImageAsset( + file: File, + kind: PanelImageKind, + assetObjectId: string, + ) { + const previewUrl = buildPanelImagePreviewUrl(file); + if (previewUrl) objectUrlsRef.current.add(previewUrl); + return { + signature: buildPanelImageSignature(file, kind), + name: file.name.trim() || resolvePanelImageLabel(kind), + assetObjectId, + previewUrl, + }; + } + + async function handleCoverSelected(file: File | null) { + if (!file) return; + setError(''); + const fileError = resolvePanelImageFileError(file, 'cover'); + if (fileError) { + setError(fileError); + return; + } + setUploadingLabel('正在上传封面…'); + try { + const assetObjectId = await resolveAssetObjectId(file, 'cover'); + setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '封面上传失败,请重试', + ); + } finally { + setUploadingLabel(''); + if (coverInputRef.current) coverInputRef.current.value = ''; + } + } + + async function handleGenerateCover() { + if (!manifest) { + setError('项目清单尚未就绪,请稍后重试'); + return; + } + setCoverConfirmOpen(false); + setError(''); + setCoverGenerationDetail('正在根据项目内容生成封面…'); + setGeneratingCover(true); + try { + const generated = await generateGameDistributionCover({ + prompt: buildCoverGenerationPrompt(manifest, summary), + model: COVER_GENERATION_MODEL, + aspectRatio: COVER_GENERATION_ASPECT_RATIO, + imageSize: COVER_GENERATION_IMAGE_SIZE, + assetLabel: `${manifest.name.trim() || '游戏'}封面`, + }); + setCover({ + signature: generated.taskId + ? `generated:${generated.taskId}` + : `generated:${generated.assetObjectId}`, + name: 'AI 生成封面', + assetObjectId: generated.assetObjectId, + previewUrl: generated.previewUrl, + }); + setCoverGenerationDetail( + '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。', + ); + } catch (generationError) { + setCoverGenerationDetail(''); + setError( + generationError instanceof Error + ? generationError.message + : '生成游戏封面失败,请重试', + ); + } finally { + setGeneratingCover(false); + } + } + + function createPanelScreenshotEntry(file: File): PanelScreenshotEntry { + const fileError = resolvePanelImageFileError(file, 'screenshot'); + return { + id: createPanelScreenshotId(), + name: file.name.trim() || '游戏截图', + previewUrl: buildPanelImagePreviewUrl(file), + assetObjectId: null, + status: fileError ? 'failed' : 'uploading', + error: fileError || null, + file, + }; + } + + function removeScreenshot(id: string) { + setScreenshots((current) => { + const target = current.find((item) => item.id === id); + if (target?.previewUrl && objectUrlsRef.current.has(target.previewUrl)) { + try { + URL.revokeObjectURL(target.previewUrl); + } catch { + // 预览地址释放失败不影响移除截图。 + } + objectUrlsRef.current.delete(target.previewUrl); + } + return current.filter((item) => item.id !== id); + }); + setPreviewScreenshot((current) => (current?.id === id ? null : current)); + } + + async function handleScreenshotsSelected(files: File[]) { + if (files.length === 0) return; + const counted = screenshots.filter( + (item) => item.status !== 'failed', + ).length; + const remaining = MAX_AGC_GAME_SCREENSHOTS - counted; + if (files.length > remaining) { + setError( + remaining > 0 + ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` + : '游戏截图最多 6 张', + ); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + return; + } + setError(''); + const entries = files.map(createPanelScreenshotEntry); + setScreenshots((current) => [...current, ...entries]); + + // 每张截图独立上传、独立失败;某一张失败只标记该张,不打断同批其它截图。 + await Promise.all( + entries.map(async (entry) => { + if (entry.status === 'failed') return; + try { + const assetObjectId = await resolveAssetObjectId( + entry.file, + 'screenshot', + ); + setScreenshots((current) => + current.map((item) => + item.id === entry.id + ? { + ...item, + status: 'ready', + assetObjectId, + error: null, + } + : item, + ), + ); + } catch (uploadError) { + setScreenshots((current) => + current.map((item) => + item.id === entry.id + ? { + ...item, + status: 'failed', + assetObjectId: null, + error: + uploadError instanceof Error + ? uploadError.message + : '截图上传失败,请删除后重试', + } + : item, + ), + ); + } + }), + ); + if (screenshotInputRef.current) screenshotInputRef.current.value = ''; + } + + /** 真正提交发布:包由调用方传入,不依赖 prop 更新时机。 */ + async function submitPackage(pkg: LocalProjectExportPackageResult) { + if (!projectPath.trim()) { + setError('请先导出有效的试玩包'); + return; + } + if (!manifest) { + setError('项目清单尚未就绪,请稍后重试'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setError('需要在 Tauri App 内发布'); + return; + } + if (!cover) { + setError('请先选择游戏封面(JPG/PNG/WebP)'); + return; + } + if ( + uploadingLabel || + screenshots.some((item) => item.status === 'uploading') + ) { + setError('素材还在上传中,请稍候再发布'); + return; + } + setBusy(true); + setError(''); + try { + const next = await publishLocalProjectGame({ + invoke, + projectPath, + packageRelativePath: pkg.packageRelativePath, + manifest, + metadata: { + title, + summary, + category, + coverAssetId: cover.assetObjectId, + screenshots: screenshots + .filter( + ( + item, + ): item is PanelScreenshotEntry & { + assetObjectId: string; + } => item.status === 'ready' && Boolean(item.assetObjectId), + ) + .map((item) => item.assetObjectId), + }, + idempotencyKey: publishIdempotencyKeyRef.current, + }); + setResult(next); + onPublished?.(next); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : String(nextError), + ); + } finally { + setBusy(false); + } + } + + async function handleSubmit() { + if (!packageResult) { + setError('请先导出有效的试玩包'); + return; + } + await submitPackage(packageResult); + } + + /** + * 内联主行动:一次点击完成「打包 → 发布」。 + * + * 顺序沿用旧入口的「点击即构建打包」:先按需构建试玩包(进度 / 阻断由父层弹窗承载), + * 打包成功后再走提交前的表单门禁(封面、必填项、在途上传),失败只把错误留在表单里。 + */ + async function handlePackageAndPublish() { + let pkg = packageResult; + if (!pkg) { + if (!onEnsurePackage) { + setError('请先导出有效的试玩包'); + return; + } + pkg = await onEnsurePackage(); + if (!pkg) return; + } + await submitPackage(pkg); + } + + const activeScreenshotCount = screenshots.filter( + (item) => item.status !== 'failed', + ).length; + const screenshotsUploading = screenshots.some( + (item) => item.status === 'uploading', + ); + const coverPriceLabel = + coverGenerationPrice === null ? '消耗泥点' : `${coverGenerationPrice} 泥点`; + + return { + packageResult, + title, + setTitle, + summary, + setSummary, + category, + setCategory, + busy, + error, + result, + cover, + setCover, + screenshots, + previewScreenshot, + setPreviewScreenshot, + uploadingLabel, + metadataSuggestionPending, + metadataSuggestionReady, + coverConfirmOpen, + setCoverConfirmOpen, + generatingCover, + coverGenerationPrice, + coverGenerationDetail, + coverInputRef, + screenshotInputRef, + summaryTouchedRef, + categoryTouchedRef, + activeScreenshotCount, + screenshotsUploading, + coverPriceLabel, + handleCoverSelected, + handleGenerateCover, + handleScreenshotsSelected, + removeScreenshot, + handleSubmit, + handlePackageAndPublish, + }; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts index d9642eb8a..866d5336b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts @@ -52,11 +52,18 @@ export function usePanel({ const [xiaohongshuVisited, setXiaohongshuVisited] = useState(false); const xiaohongshuEntered = xiaohongshuVisited || activeTarget === Target.Xiaohongshu; + /** + * 陶泥儿发布表单的初始化开关同样比 `enabled` 窄:只有真的到过陶泥儿 tab 才初始化 + * (进入即触发表单预填与 AI 简介建议)。状态留在面板根,离开后再回来不重来。 + */ + const [taonierVisited, setTaonierVisited] = useState(false); + const taonierEntered = taonierVisited || activeTarget === Target.Taonier; /** 切到某个目标时的共同动作:两条入口(点卡片、点 tab 条)都必须经过它。 */ const enterTarget = useCallback((target: Target) => { setActiveTarget(target); if (target === Target.Xiaohongshu) setXiaohongshuVisited(true); + if (target === Target.Taonier) setTaonierVisited(true); }, []); const openTarget = useCallback( @@ -72,7 +79,10 @@ export function usePanel({ const taonier: TaonierTabState = useTaonierTab({ enabled: workspaceEntered && Boolean(projectPath), + formEnabled: workspaceEntered && taonierEntered && Boolean(projectPath), projectPath, + ensureConversationReadAllowed, + ensureConversationWriteAllowed, }); const xiaohongshu: XiaohongshuTabState = useXiaohongshuTab({ diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index ec37b87a2..c4f9e777a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -1,11 +1,20 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../../../app/tauri'; import type { LocalProjectExportPackageResult } from '../../../../app/types'; import type { GamePublishProgressState } from '../../../../components/game-distribution/GamePublishProgressDialog'; +import { useGameDistributionPublishForm } from '../../../../components/game-distribution/useGameDistributionPublishForm'; import { readGamePublishAvailability } from '../../../../services/gameDistributionPublish'; +import { + type DirectProjectConversationReadGate, + type DirectProjectConversationWriteGate, + useDirectProjectChatController, +} from '../../chat/controller/useDirectProjectChatController'; import { useDirectProjectManifest } from '../../chat/controller/useDirectProjectManifest'; +import { useDirectProjectTurnStatus } from '../../chat/controller/useDirectProjectTurnStatus'; +import { buildDirectChatTurns } from '../../chat/conversation/directTurnPresentation'; +import type { UserContentPart } from '../../chat/generated/UserContentPart'; export type TaonierTabState = ReturnType<typeof useTaonierTab>; @@ -42,29 +51,46 @@ function appendPublishPermissionLog(projectPath: string) { } /** - * 陶泥儿导出 tab 的全部状态。 + * 陶泥儿导出 tab 的全部状态:左半的发布编排 + 右半的第二个会话。 * - * 发布编排(构建 → 打包 → 发布资料面板)从工作台壳搬到这里:tab 页只渲染,状态挂在 - * **面板根**(`usePanel`)上,所以切到别的 tab 再切回来时试玩包结果、封面与幂等键都还在。 - * 关闭导出面板会卸载这个 hook,在途的 `export_local_project_package` 回来后由 - * `mountedRef` 丢弃,不再向已卸载的组件写状态。 + * 这个 hook 挂在**面板根**(`usePanel`)上,不挂在 tab 页里:切到别的 tab 时页面组件会卸载, + * 但发布灰度、试玩包结果、进度与右半的订阅、回合、草稿必须留在面板上。 + * + * 右半会话走 `useDirectProjectChatController` 内部的 `useDirectThreadChatSubscription`: + * 那是同一 thread 的**第二个 subscriber**,有自己的 `subscriptionId` 与游标,不与主聊天互相推进; + * 输入草稿同样提升到面板层,Composer 随 tab 页卸载再挂回来也不丢字。 + * + * 关闭导出面板会卸载这个 hook,在途的 `export_local_project_package` 回来后由 `mountedRef` 丢弃, + * 不再向已卸载的组件写状态。 */ export function useTaonierTab({ enabled, + formEnabled, projectPath, + ensureConversationReadAllowed, + ensureConversationWriteAllowed, }: { - /** 进过一次工作台且项目路径就绪后才读灰度:进入前不产生任何后端访问。 */ + /** 进过一次工作台且项目路径就绪后才读灰度与订阅:进入前不产生任何后端访问。 */ enabled: boolean; + /** + * 发布资料表单的初始化开关,比 `enabled` 更窄:只有真的到过陶泥儿 tab 才初始化。 + * 表单状态仍留在面板根,切走再切回来不重来、也不丢已填内容。 + */ + formEnabled: boolean; projectPath: string | null; + /** 项目对话读 / 写权限门:第二订阅读历史、以及从这里发送回合时都必经这两道门。 */ + ensureConversationReadAllowed: DirectProjectConversationReadGate; + ensureConversationWriteAllowed: DirectProjectConversationWriteGate; }) { - const { manifest } = useDirectProjectManifest(projectPath); + const { assets, manifest, projectId, refresh, versions } = + useDirectProjectManifest(projectPath); const manifestRef = useRef<GameCreationAppManifest | null>(manifest); manifestRef.current = manifest; const [allowed, setAllowed] = useState(false); + const [availabilityPending, setAvailabilityPending] = useState(true); const [packageResult, setPackageResult] = useState<LocalProjectExportPackageResult | null>(null); - const [panelOpen, setPanelOpen] = useState(false); const [progress, setProgress] = useState<GamePublishProgressState | null>( null, ); @@ -82,79 +108,156 @@ export function useTaonierTab({ if (!enabled) return; let cancelled = false; setAllowed(false); + setAvailabilityPending(true); void readGamePublishAvailability() .then((value) => { if (!cancelled) setAllowed(value); }) .catch(() => { if (!cancelled) setAllowed(false); + }) + .finally(() => { + if (!cancelled) setAvailabilityPending(false); }); return () => { cancelled = true; }; }, [enabled, projectPath]); - const requestPublish = useCallback(async () => { - const currentProjectPath = projectPath?.trim() ?? ''; - if (!currentProjectPath) { - setBlockedMessage('先打开一个项目再发布'); - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setBlockedMessage('需要在 Tauri App 内发布'); - return; - } - const currentManifest = manifestRef.current; - if (!currentManifest) { - setBlockedMessage('项目清单尚未就绪,请稍后重试'); - return; - } - if (!hasRunnablePrototype(currentManifest)) { - setBlockedMessage( - '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', - ); - return; - } - setProgress({ - phase: 'running', - message: '正在构建并打包试玩包,请稍候…', - }); - try { - const result = await invoke<LocalProjectExportPackageResult>( - 'export_local_project_package', - { projectPath: currentProjectPath }, - ); - if (!mountedRef.current) return; - setProgress(null); - setPackageResult(result); - setPanelOpen(true); - appendPublishPermissionLog(currentProjectPath); - } catch (error) { - if (!mountedRef.current) return; - setProgress({ - phase: 'failed', - message: '发布失败', - detail: error instanceof Error ? error.message : String(error), - }); - } - }, [projectPath]); + const [runtimeNotice, setRuntimeNotice] = useState(''); + /** + * 输入草稿提升到面板层:Composer 随 tab 页卸载后,正文不再随组件一起丢。 + * 提交成功时 Composer 内部的 `clear()` 会经 `onDraftChange` 把空草稿回执上来。 + */ + const [draft, setDraft] = useState<UserContentPart[]>([]); + const chat = useDirectProjectChatController({ + enabled, + ensureConversationReadAllowed, + ensureConversationWriteAllowed, + onRuntimeError: setRuntimeNotice, + projectId, + projectPath, + refreshManifest: refresh, + }); + const turns = useMemo( + () => + buildDirectChatTurns({ + entries: chat.directEntries, + localMessages: chat.localMessages, + turnRunning: chat.directTurnRunning, + // 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。 + turnStartedAt: chat.directTurnStartedAt, + }), + [ + chat.directEntries, + chat.localMessages, + chat.directTurnRunning, + chat.directTurnStartedAt, + ], + ); + const turnStatus = useDirectProjectTurnStatus({ + turnRunning: chat.directTurnRunning, + commandInFlight: chat.commandInFlight, + pendingTurns: chat.pendingTurns, + turns, + }); + // 运行中回合的读秒起点:只有最新一轮且还没结束才拿实时值,历史回合是 0(不读秒)。 + const latestTurn = turns.at(-1) ?? null; + const activeTurnStartedAt = + latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0; + + /** + * 构建并打包试玩包,返回结果供同一次点击继续发布。 + * + * 灰度未命中、缺项目、宿主缺失、原型未完成或构建失败都返回 `null`:阻断 / 失败反馈由本 hook + * 的 `blockedMessage` / `progress` 承载,调用方只负责不再往下走。 + */ + const ensurePackage = + useCallback(async (): Promise<LocalProjectExportPackageResult | null> => { + const currentProjectPath = projectPath?.trim() ?? ''; + if (!currentProjectPath) { + setBlockedMessage('先打开一个项目再发布'); + return null; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setBlockedMessage('需要在 Tauri App 内发布'); + return null; + } + const currentManifest = manifestRef.current; + if (!currentManifest) { + setBlockedMessage('项目清单尚未就绪,请稍后重试'); + return null; + } + if (!hasRunnablePrototype(currentManifest)) { + setBlockedMessage( + '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', + ); + return null; + } + setProgress({ + phase: 'running', + message: '正在构建并打包试玩包,请稍候…', + }); + try { + const result = await invoke<LocalProjectExportPackageResult>( + 'export_local_project_package', + { projectPath: currentProjectPath }, + ); + if (!mountedRef.current) return null; + setProgress(null); + setPackageResult(result); + appendPublishPermissionLog(currentProjectPath); + return result; + } catch (error) { + if (!mountedRef.current) return null; + setProgress({ + phase: 'failed', + message: '发布失败', + detail: error instanceof Error ? error.message : String(error), + }); + return null; + } + }, [projectPath]); - const closePanel = useCallback(() => setPanelOpen(false), []); const closeProgress = useCallback(() => setProgress(null), []); const closeBlocked = useCallback(() => setBlockedMessage(null), []); + /** + * 发布资料表单的状态提升到面板根:切走 tab、页面组件卸载后已填的资料仍在。 + * + * 内联形态(`inline: true`)与「打包并发布」共用同一个 `ensurePackage`:一次点击先按需 + * 构建打包,再用返回的包继续提交发布。 + */ + const publishForm = useGameDistributionPublishForm({ + open: formEnabled, + inline: true, + projectPath: projectPath ?? '', + manifest, + packageResult, + onEnsurePackage: ensurePackage, + }); + return { enabled, allowed, + availabilityPending, manifest, + assets, + versions, projectPath, packageResult, - panelOpen, + publishForm, progress, blockedMessage, - requestPublish, - closePanel, + chat, + draft, + setDraft, + runtimeNotice, + turns, + turnStatus, + activeTurnStartedAt, + ensurePackage, closeProgress, closeBlocked, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx index 9fb97a5c7..3a78260e8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx @@ -1,61 +1,20 @@ -import { Rocket } from 'lucide-react'; - -import { GameDistributionPublishPanel } from '../../../../../components/game-distribution/GameDistributionPublishPanel'; import { GamePublishBlockedDialog } from '../../../../../components/game-distribution/GamePublishBlockedDialog'; import { GamePublishProgressDialog } from '../../../../../components/game-distribution/GamePublishProgressDialog'; import type { TaonierTabState } from '../../state/useTaonierTab'; +import { ChatPane } from './chat/Pane'; +import { PublishPane } from './PublishPane'; /** - * 陶泥儿导出 tab:这里是「发布到游戏广场」的唯一入口。 + * 陶泥儿发布工作台:中缝一分为二,左半是内联发布表单与「打包并发布」,右半是另一个 chatbox。 * - * 状态全部在面板根的 `useTaonierTab`(见 `usePanel`),这一页只负责渲染入口、挂发布资料 - * 面板与两条反馈弹窗;构建、打包、灰度与幂等都由状态 hook 持有。 + * 左右两侧的状态都在面板根的 `useTaonierTab`:这一页跟着 tab 切走时可以卸载,发布灰度、 + * 试玩包结果、订阅、回合与草稿都留在面板上。两条发布反馈弹窗仍由面板根的状态驱动。 */ export function TaonierPage({ tab }: { tab: TaonierTabState }) { - const manifest = tab.manifest; - const busy = tab.progress?.phase === 'running'; - const canPublish = tab.allowed && Boolean(manifest) && !busy; - return ( - <div - className="grid h-full min-h-0 place-items-center p-6" - aria-label="陶泥儿导出页" - > - {manifest ? ( - <div className="w-full max-w-md rounded-3xl border border-[#e2cbb8] bg-[#fffaf5] p-8 text-center shadow-[0_18px_40px_rgba(112,57,30,0.08)]"> - <h2 className="text-lg font-semibold text-[#3d1f10]"> - 发布到游戏广场 - </h2> - <p className="mt-3 text-sm text-[#6f5848]"> - 先构建并打包当前项目的试玩包,再填写发行资料提交审核。 - </p> - {tab.allowed ? null : ( - <p className="mt-2 text-xs text-[#988476]"> - 当前账号暂未开放发布入口。 - </p> - )} - <button - type="button" - disabled={!canPublish} - onClick={() => void tab.requestPublish()} - className="mt-5 inline-flex items-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-4 py-2 text-sm font-medium text-white transition enabled:hover:bg-[#b3572f] disabled:cursor-not-allowed disabled:border-[#e2cbb8] disabled:bg-[#f4e6da] disabled:text-[#b9a08f]" - > - <Rocket size={16} aria-hidden="true" /> - {busy ? '正在构建并打包…' : '发布到游戏广场'} - </button> - </div> - ) : ( - <p className="text-sm text-[#988476]">正在读取项目清单…</p> - )} - {manifest ? ( - <GameDistributionPublishPanel - open={tab.panelOpen} - projectPath={tab.projectPath ?? ''} - manifest={manifest} - packageResult={tab.packageResult} - onClose={tab.closePanel} - /> - ) : null} + <div className="grid h-full min-h-0 min-w-0 grid-cols-1 overflow-hidden md:grid-cols-2"> + <PublishPane tab={tab} /> + <ChatPane tab={tab} /> <GamePublishProgressDialog progress={tab.progress} onClose={tab.closeProgress} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx new file mode 100644 index 000000000..5162fba49 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx @@ -0,0 +1,42 @@ +import { GameDistributionPublishFormView } from '../../../../../components/game-distribution/GameDistributionPublishFormView'; +import type { TaonierTabState } from '../../state/useTaonierTab'; + +/** + * 陶泥儿工作台左半:内联的发布资料表单 + 内容末尾整行橙色的「打包并发布」。 + * + * 表单不再以弹窗出现(没有第二跳):状态在面板根的 `useTaonierTab`(`publishForm`), + * 这一页跟着 tab 切走时可以卸载,已填的资料不丢。灰度未命中时只挡发布,不挡看表单。 + */ +export function PublishPane({ tab }: { tab: TaonierTabState }) { + const manifest = tab.manifest; + if (!manifest || tab.availabilityPending) { + return ( + <div className="flex h-full min-h-0 items-center justify-center border-[#e2cbb8] bg-[#fffdf9]/40 p-6 md:border-r"> + <p className="text-sm text-[#988476]"> + {tab.availabilityPending ? '正在读取发布权限…' : '正在读取项目清单…'} + </p> + </div> + ); + } + if (!tab.allowed) { + return ( + <div className="flex h-full min-h-0 items-center justify-center border-[#e2cbb8] bg-[#fffdf9]/40 p-6 md:border-r"> + <p className="text-sm text-[#988476]">当前账号暂未开放发布入口。</p> + </div> + ); + } + return ( + <div + className="flex h-full min-h-0 min-w-0 flex-col border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r" + aria-label="游戏发布表单区" + > + <GameDistributionPublishFormView + form={tab.publishForm} + inline + open + onClose={() => undefined} + packaging={tab.progress?.phase === 'running'} + /> + </div> + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/chat/Pane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/chat/Pane.tsx new file mode 100644 index 000000000..6b97f4a24 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/chat/Pane.tsx @@ -0,0 +1,19 @@ +import type { TaonierTabState } from '../../../state/useTaonierTab'; +import { ExportChatPane } from '../../common/ChatPane'; + +/** + * 陶泥儿发布工作台右半 chatbox:与左侧发布表单并列的第二个订阅会话。 + * + * 会话真相在面板根的 `useTaonierTab`:订阅、回合与草稿都留在面板上,切走再切回来还是同一条 + * 会话、同一份草稿。 + */ +export function ChatPane({ tab }: { tab: TaonierTabState }) { + return ( + <ExportChatPane + tab={tab} + ariaLabel="游戏发布对话栏" + chatAriaLabel="游戏发布对话" + fallbackStatusText="游戏发布对话" + /> + ); +} diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 58ad289d2..f7192fba6 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -2,8 +2,8 @@ /** * 客户端「发布」入口的可见反馈。 * - * 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片; - * 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。 + * 陶泥儿 tab 左半是内联的发布资料表单,内容末尾的橙色「打包并发布」一次点击先构建打包、 + * 再提交发布。打包用独立的全屏进度弹窗(失败留在弹窗内),成功后不再弹出发布面板。 */ import { readFileSync } from 'node:fs'; @@ -129,16 +129,14 @@ function renderPublishProject( async function openTaonierPublishEntry() { fireEvent.click(await screen.findByRole('button', { name: '导出产物' })); fireEvent.click(await screen.findByRole('button', { name: '陶泥儿' })); - const publish = await screen.findByRole('button', { - name: '发布到游戏广场', - }); + const publish = await screen.findByRole('button', { name: '打包并发布' }); await waitFor(() => expect(publish).toHaveProperty('disabled', false)); return publish; } async function clickPublish() { fireEvent.click(await openTaonierPublishEntry()); - return screen.findByLabelText('陶泥儿项目对话'); + return screen.findByLabelText('游戏发布对话栏'); } beforeEach(() => { @@ -204,7 +202,7 @@ describe('客户端发布入口的可见反馈', () => { }); }); - it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => { + it('打包时显示全屏进度遮罩,成功后直接进入发布提交(不再弹发布面板)', async () => { const deferred = createDeferred<ReturnType<typeof createExportPackageResult>>(); const exportPackage = vi.fn(() => deferred.promise); @@ -232,12 +230,14 @@ describe('客户端发布入口的可见反馈', () => { await waitFor(() => { expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull(); }); + // 打包成功后直接提交发布;还没有封面时把错误留在表单里,而不是再弹一个发布面板。 expect( - await screen.findByRole('dialog', { name: '发布到游戏广场' }), + await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'), ).not.toBeNull(); + expect(screen.queryByRole('dialog', { name: '发布到游戏广场' })).toBeNull(); }); - it('发布失败时在进度弹窗里显示错误并允许关闭', async () => { + it('打包失败时在进度弹窗里显示错误并允许关闭', async () => { installTauri({ exportPackage: () => { throw new Error('构建可玩版本失败:缺少入口'); diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 640cb8c0c..dcafa87e6 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -86,7 +86,7 @@ 2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。 3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。 4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。 -5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 导出产物面板陶泥儿 tab 的「发布到游戏广场」入口据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。 +5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 导出产物面板陶泥儿 tab 的左半发布工作台(内联资料表单 + 内容末尾的橙色「打包并发布」按钮,与右半第二个会话 chatbox 左右并列)据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布表单不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。 6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。 7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。 8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 From 779857342e91ee67eeedd81c4c3390129d63c16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 17:52:23 +0800 Subject: [PATCH 067/156] =?UTF-8?q?=E7=B2=BE=E7=AE=80=20XHS=5FMINITOOL=5FR?= =?UTF-8?q?EGISTRY=5FSHAPE=5FHINT=20=E5=86=85=E6=96=87=E6=A1=A3=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../export/state/xhsMinitoolInstruction.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts index 641f9b48d..f3a596c25 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts @@ -37,10 +37,9 @@ export const XHS_MINITOOL_PACKAGE_SKILL_NAME = 'vite-export-xhs-minitool'; */ const XHS_MINITOOL_REGISTRY_SHAPE_HINT: XHSMiniToolExportRegistry = { form: { - name: '小工具名称,最多 14 个字,写用户在小红书里看到的名字', - introduction: '一句话介绍,最多 14 个字,说清这个小工具是做什么的', - iconPath: - '项目里真实存在的图片文件路径(相对项目根),优先用 assets/ 下已有的图', + name: '最多 14 个字', + introduction: '最多 14 个字', + iconPath: 'png、jpg、jpeg;大小不超过 5 M;推荐比例为1:1', }, }; From 163564a811a6c6bf79d93f7da393792fe90482fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:02:08 +0800 Subject: [PATCH 068/156] =?UTF-8?q?AGC=20=E6=89=93=E5=8C=85=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E4=B8=8D=E5=86=8D=E6=8C=89=E5=BF=85=E5=A1=AB?= =?UTF-8?q?=E9=A1=B9=E5=8F=98=E7=81=B0=EF=BC=8C=E6=94=B9=E4=B8=BA=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=A0=A1=E9=AA=8C=E7=BB=99=E5=8F=AF=E8=AF=BB=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「打包并发布」只在打包 / 上传进行中禁用,不再因为名称或简介为空而变灰 - 文案必填项在打包前就地校验,封面与在途上传在打包成功后校验,错误都留在表单里 - 简介兜底改用 ||,项目 goal 为空时不再留下空简介把唯一入口卡死 - 用户一改名称 / 简介即清除上一次的校验错误 - 补「项目还没起名时按钮仍可点并给出必填提示」的回归用例 --- .../GameDistributionPublishFormView.tsx | 11 +++-- .../useGameDistributionPublishForm.ts | 47 +++++++++++++++---- .../tests/gamePublishFeedback.test.tsx | 20 ++++++++ 3 files changed, 64 insertions(+), 14 deletions(-) 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 index 680203c86..077341612 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -40,6 +40,7 @@ export function GameDistributionPublishFormView({ setCategory, busy, error, + clearError, result, cover, setCover, @@ -77,7 +78,10 @@ export function GameDistributionPublishFormView({ <input value={title} maxLength={40} - onChange={(event) => setTitle(event.target.value)} + onChange={(event) => { + setTitle(event.target.value); + clearError(); + }} /> </label> <label> @@ -89,6 +93,7 @@ export function GameDistributionPublishFormView({ onChange={(event) => { summaryTouchedRef.current = true; setSummary(event.target.value); + clearError(); }} /> </label> @@ -389,9 +394,7 @@ export function GameDistributionPublishFormView({ generatingCover || screenshotsUploading || Boolean(uploadingLabel) || - Boolean(packaging) || - !title.trim() || - !summary.trim() + Boolean(packaging) } onClick={() => void handlePackageAndPublish()} className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-6 py-4 text-lg font-bold tracking-wide text-white shadow-md transition enabled:hover:bg-[#b4562f] disabled:cursor-not-allowed disabled:opacity-45" diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 2c9090b31..aa0387c38 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -218,10 +218,8 @@ export function useGameDistributionPublishForm({ const currentManifest = manifestRef.current; if (!currentManifest) return; const fallbackSummary = ( - currentManifest.goal ?? '由陶泥儿创作的可在线游玩游戏' - ) - .trim() - .slice(0, 120); + currentManifest.goal?.trim() || '由陶泥儿创作的可在线游玩游戏' + ).slice(0, 120); setTitle(currentManifest.name.trim()); setSummary(fallbackSummary); setCategory('其他'); @@ -491,6 +489,27 @@ export function useGameDistributionPublishForm({ if (screenshotInputRef.current) screenshotInputRef.current.value = ''; } + /** + * 提交前的本地门禁。按钮不因为表单没填好就变灰(同小红书「打包」):点下去当场校验、 + * 当场给可读错误。封面与在途上传都在这一刻判定,不再把「能不能点」的判断推给用户。 + */ + function resolvePublishFormError(): string { + if (!title.trim()) return '请先填写游戏名称'; + if (!summary.trim()) return '请先填写一句话简介'; + if ( + uploadingLabel || + screenshots.some((item) => item.status === 'uploading') + ) { + return '素材还在上传中,请稍候再发布'; + } + return ''; + } + + /** 用户一改字段就把上一次的校验错误收起来,别让已经修好的字段继续挂着红字。 */ + function clearError() { + setError(''); + } + /** 真正提交发布:包由调用方传入,不依赖 prop 更新时机。 */ async function submitPackage(pkg: LocalProjectExportPackageResult) { if (!projectPath.trim()) { @@ -510,11 +529,9 @@ export function useGameDistributionPublishForm({ setError('请先选择游戏封面(JPG/PNG/WebP)'); return; } - if ( - uploadingLabel || - screenshots.some((item) => item.status === 'uploading') - ) { - setError('素材还在上传中,请稍候再发布'); + const formError = resolvePublishFormError(); + if (formError) { + setError(formError); return; } setBusy(true); @@ -565,9 +582,18 @@ export function useGameDistributionPublishForm({ * 内联主行动:一次点击完成「打包 → 发布」。 * * 顺序沿用旧入口的「点击即构建打包」:先按需构建试玩包(进度 / 阻断由父层弹窗承载), - * 打包成功后再走提交前的表单门禁(封面、必填项、在途上传),失败只把错误留在表单里。 + * 打包成功后再走提交前的表单门禁,失败只把错误留在表单里。文案必填项在打包之前就拦, + * 免得为了一份注定提交不了的资料白跑一次构建;封面 / 在途上传留到打包成功后判定。 */ async function handlePackageAndPublish() { + if (!title.trim()) { + setError('请先填写游戏名称'); + return; + } + if (!summary.trim()) { + setError('请先填写一句话简介'); + return; + } let pkg = packageResult; if (!pkg) { if (!onEnsurePackage) { @@ -599,6 +625,7 @@ export function useGameDistributionPublishForm({ setCategory, busy, error, + clearError, result, cover, setCover, diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index f7192fba6..350e5a482 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -295,4 +295,24 @@ describe('客户端发布入口的可见反馈', () => { expect(screen.queryByText('project.export_package')).toBeNull(); expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull(); }); + + it('项目还没起名时「打包并发布」不变灰,点击后给出可读的必填提示', async () => { + const exportPackage = vi.fn(createExportPackageResult); + const manifest: GameCreationAppManifest = { + ...createFixtureManifest(), + name: '', + goal: '', + }; + installTauri({ exportPackage, manifest }); + renderPublishProject(manifest); + + const publish = await openTaonierPublishEntry(); + expect(publish).toHaveProperty('disabled', false); + + fireEvent.click(publish); + + // 按钮始终可点,校验在前端当场给出;必填文案不通过时不为一次注定失败的发布白跑构建。 + expect(await screen.findByText('请先填写游戏名称')).not.toBeNull(); + expect(exportPackage).not.toHaveBeenCalled(); + }); }); From f3f52dc5e0fea9742631307c2c13e1476c2782a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:03:00 +0800 Subject: [PATCH 069/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8A=80=E8=83=BD=EF=BC=9AChrome=2061=20=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=94=B9=E4=B8=BA=E7=A1=AC=E6=80=A7=20ERROR=EF=BC=8Cp?= =?UTF-8?q?ack=20=E8=87=AA=E5=B8=A6=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate.mjs 恢复 CSS_UNSUPPORTED_PATTERNS / MODERN_RUNTIME_API,命中判 ERROR - 删除「基线层 + 增强层」写法,references 明确 Chrome 61 为硬基线 - pack.mjs 复制白名单与遍历实现,不再 import validate.mjs - SKILL.md 参数表补 validate / pack / vite.config 的默认值 - 修正 decision-log 中与本次相反的旧结论 --- .../resources/agc-skills/manifest.json | 4 +- .../vite-export-xhs-minitool/SKILL.md | 34 ++-- .../references/css-compatibility.md | 162 ++++-------------- .../references/js-compatibility.md | 8 +- .../references/manual-checks.md | 6 +- .../references/zip-artifact-spec.md | 2 +- .../scripts/.validate.test.mjs | 24 ++- .../vite-export-xhs-minitool/scripts/pack.mjs | 47 ++++- .../scripts/validate.mjs | 32 ++++ .../scripts/vite.config.xhs-minitool.mjs | 2 +- .../shared-memory/decision-log.md | 13 +- 11 files changed, 162 insertions(+), 172 deletions(-) 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 89af00587..f9006428f 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.61", + "version": "2026-08-26.62", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "9a4ebd56d9fe30dacacec35ab2af941ea08cffc3764564a6d0331bb251cfc612" + "sha256": "5c7cd2098061be1203b4055c806b0cb40f2f54efdf4efc34066c98a086a26250" } ] } 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 index 976cd4881..f5a46b3e8 100644 --- 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 @@ -12,22 +12,24 @@ metadata: 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 解析) + 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 项目根, 对于非标准的目录结构需要给出显式的参数来适应 +* vite项目目录: game/ +* 各个工具 (包括示例vite配置) 默认cwd 就是 vite 项目根(所以对于非标准的目录结构需要给出显式的参数来适应) # 脚本参数: -| 脚本 | 参数 | -|----------------------------------------|------------------------------------------------------------------| -| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录) | -| `scripts/validate.mjs` | `[project]`、`--json` | -| `scripts/pack.mjs` | `--vite-built-dir <dir>`、`--zip-out <path>` | +| 脚本 | 参数 | +|----------------------------------------|----------------------------------------------------------------------------------------------------------------| +| `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`) | # 一些情况: @@ -39,12 +41,12 @@ metadata: ## Reference -| 文档 | 何时读 | -|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------| -| [js-api.md](references/js-api.md) | 本地 API 快照; | -| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写 HTML 文件类型、容器 CSP、`index.html` 要求 | -| [device-capabilities.md](references/device-capabilities.md) | 处理端能力时:哪些 Web 能力可用 / 不可用及替代写法、如何实现常见交互(手势、拍照、选图等) | -| [js-compatibility.md](references/js-compatibility.md) | 写 JS / 选择构建产物时:Android 8.1 出场 Chrome / WebView 61 最低基线、Web API 检测与局部降级 | -| [css-compatibility.md](references/css-compatibility.md) | 写 CSS / 选择构建产物时:Chrome 61 基线、能力检测、现代 CSS 增强与局部回退 | -| [cross-platform-h5.md](references/cross-platform-h5.md) | 适配多端时:触摸、滚动、安全区、PC 模拟器与真机差异 | -| [performance-budget.md](references/performance-budget.md) | 包体、静态数据、Base64、媒体、长列表与 WebGL 资源控制和降级 | +| 文档 | 何时读 | +|-------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| [js-api.md](references/js-api.md) | 本地 API 快照; | +| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写 HTML 文件类型、容器 CSP、`index.html` 要求 | +| [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/css-compatibility.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md index 2b19208c2..8b1e192ab 100644 --- 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 @@ -1,145 +1,52 @@ # CSS 兼容性规范 -> 小工具 CSS 的最低兼容基线是 **Android 8.1 出场 Chrome / WebView 61**。这不等于只能写旧 CSS:交付物采用“Chrome 61 可用的基线层 + 能力检测后的增强层”,在新内核上使用更合适的 CSS 能力。 +> 目标内核固定为 **Android 8.1 出场 Chrome / WebView 61**。晚于 61 的 CSS 能力一律不得出现在最终产物里:浏览器会静默忽略,`validate.mjs` 判为 `CSS_UNSUPPORTED_FEATURE`(ERROR)。 -## 1. 基线原则 +## 1. 硬性要求 -- 基线层保证布局、文字、按钮和核心交互在 Chrome 61 可用;增强层可以使用新 CSS 改善布局、视觉或交互。 -- 按功能点提供回退与增强,不维护两套完整页面或两份完整样式表,避免双份样式逐渐漂移。 -- 根据特性选择检测方法:能用声明级回退的按层叠覆盖,能准确查询的使用 `@supports`,语法检测无法证明实际布局行为时使用 JS 做最小行为检测。 -- 浏览器会静默丢弃无法解析的选择器、声明或整条 at-rule。不能像 JS 一样靠异常发现 CSS 不兼容,必须检查最终产物和实际布局。 -- 兼容性以最终 zip 内的 CSS 为准。源码使用了构建工具,不代表产物已经兼容。 +- 最终 zip 内的 CSS 必须能被 Chrome 61 完整解析;不得保留任何晚于 61 的选择器、声明或 at-rule。 +- 浏览器静默丢弃无法解析的 CSS,不像 JS 会抛异常;兼容性以最终产物为准,源码用了构建工具不代表产物已兼容。 +- 不用 UA、Android 版本或机型字符串决定样式能力。 -## 2. Chrome 61 不可作为唯一实现的能力 +## 2. Chrome 61 不支持的能力与替代写法 -以下能力晚于 Chrome 61,不能作为唯一实现。先提供基线路径,再用适合该能力的检测方式启用增强。 - -| 能力 | 基线写法 / 降级方式 | -|-------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| Flexbox `gap` / `row-gap` / `column-gap` | 基线用子项单边 `margin`;增强用 JS 实际测量 Flex 布局后切换 class,不能只检测 `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 Queries(`@container`) | 使用 viewport 媒体查询,或由 JS 按容器尺寸切换 class | -| Subgrid | 使用普通 Grid、Flex 或显式轨道尺寸 | -| CSS Nesting、Cascade Layers(`@layer`)、`@property` | 已有构建链能可靠展开时才在源码使用;最终产物不得保留为核心规则 | -| `dvh` / `svh` / `lvh` | 先用 `%` / `100vh`;受软键盘影响的全屏高度用 JS 维护 CSS 变量并保留静态兜底 | -| `color-mix()`、`oklab()`、`oklch()` 等现代颜色 | 先写 `#hex`、`rgb()`、`rgba()` 或 `hsl()` 颜色 | -| `backdrop-filter` | 先给不依赖模糊的实色 / 半透明背景;模糊仅作增强,并同时考虑 `-webkit-backdrop-filter` | -| `text-wrap: balance` 等现代排版属性 | 保留普通换行;不要让其决定关键区域高度 | +| 能力 | 必须改用的写法 | +|-------------------------------------------------------------|---------------------------------------------------------------------------------------| +| 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` 形成基线;需要时通过行为检测启用 `gap` 并清除 margin。动态增删子项时确认首尾间距仍正确。 -- Grid 只使用基础显式 / 隐式轨道;间距使用 Chrome 61 可解析的 `grid-gap`。不要使用 Subgrid、Masonry 或依赖新语法的自动布局。 +- 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)` 增加鼠标悬停效果。 +- 关键操作不能只在 `:hover` 出现;触摸端默认可见,鼠标悬停效果可放 `@media (hover: hover)`。 -## 4. 基线层与增强层 +## 4. 视口与安全区 -### 声明回退和 `@supports` +- 页面宽度使用 `%`、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 兼容性未实测”。 -先写 Chrome 61 可用的完整基线,再覆盖增强: +## 5. 构建链目标 -```css -.panel { - background: rgba(255, 255, 255, 0.96); -} - -@supports ((-webkit-backdrop-filter: blur(12px)) or (backdrop-filter: blur(12px))) { - .panel { - background: rgba(255, 255, 255, 0.72); - -webkit-backdrop-filter: blur(12px); - backdrop-filter: blur(12px); - } -} -``` - -同一属性使用“旧值在前、新值在后”的回退顺序: - -```css -.page { - min-height: 100vh; - min-height: var(--app-height, 100vh); -} -``` - -安全区须先保留普通值,再使用容器注入变量与 `env()`: - -```css -.bottom-bar { - padding-bottom: 0; - padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); -} -``` - -`@supports` 适合检测 `aspect-ratio`、`backdrop-filter`、动态视口单位等能由单个声明准确表达的能力。需要由 JS 切换 class 时,可以使用 `CSS.supports()` 检测同类声明。任何检测路径都必须保留基础样式。 - -### Flex gap 必须检测布局行为 - -`CSS.supports('gap', '1px')` 和 `@supports (gap: 1px)` 只能证明浏览器认识该属性和值,不能证明 `gap` 在 Flexbox 中生效;支持 Grid gap、但不支持 Flex gap 的内核也可能通过语法检测。需要启用 Flex gap 时,实际创建一次 Flex 容器并测量: - -```js -function supportsFlexGap() { - var flex = document.createElement('div'); - flex.style.position = 'absolute'; - flex.style.visibility = 'hidden'; - flex.style.display = 'flex'; - flex.style.flexDirection = 'column'; - flex.style.rowGap = '1px'; - flex.appendChild(document.createElement('div')); - flex.appendChild(document.createElement('div')); - document.body.appendChild(flex); - var supported = flex.scrollHeight === 1; - flex.parentNode.removeChild(flex); - return supported; -} - -if (supportsFlexGap()) { - document.documentElement.classList.add('supports-flex-gap'); -} -``` - -对应 CSS 只维护一套组件规则,其中 margin 是基线,gap 是增强: - -```css -.actions { - display: flex; -} - -.actions > * + * { - margin-left: 12px; -} - -.supports-flex-gap .actions { - column-gap: 12px; -} - -.supports-flex-gap .actions > * + * { - margin-left: 0; -} -``` - -行为检测放在包内经典脚本中,并在 `document.body` 存在后、核心页面渲染前执行。检测一次即可,不要在 resize 或渲染循环中反复测量。 - -不要用 UA、Android 版本或机型字符串决定样式能力。能力检测决定是否启用增强,Chrome 61 基线层始终保留。 - -## 5. 有构建链与无构建链 - -### 直接交付静态文件 - -没有现成构建链时,不为 CSS 兼容性临时引入 PostCSS、Autoprefixer 或新的 npm 依赖;直接按 Chrome 61 基线编写。 - -### 项目已有构建链 - -将目标浏览器至少设置为: +项目已有构建链时,浏览器目标至少设置为: ```text Chrome >= 61 @@ -148,10 +55,3 @@ ios_saf >= 18.4 - 压缩器也须使用相同浏览器目标,避免把兼容写法重新合并成 Chrome 61 无法解析的现代语法。 - 交付前检查构建后的 CSS;zip 中只保留最终静态产物,不带 source map 和构建配置。 - -## 6. 视口、安全区与实测 - -- 页面宽度使用 `%`、Flex 或基础 Grid,不写死 `375px` 等单一机型宽度。 -- `100vh` 在移动端地址栏、容器高度变化和软键盘出现时可能不等于可视高度。必须跟随可视高度时,由 JS 监听尺寸变化并维护 `--app-height`,同时保留 `100vh` 回退。 -- 安全区规则须配合 `viewport-fit=cover`,具体组合见 [cross-platform-h5.md](./cross-platform-h5.md)。 -- 现代桌面浏览器或 PC 模拟器通过不等于 Chrome 61 通过。能够运行旧内核时,至少检查首屏、滚动区、弹层、表单、横竖屏 / 尺寸变化和核心交互;无法运行时在交付说明中标记“Chrome 61 CSS 兼容性未实测”。 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 index a1ad3db4b..e1173f30d 100644 --- 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 @@ -1,12 +1,12 @@ # JavaScript 兼容性规范 -> 小工具 JavaScript 的最低兼容基线是 **Android 8.1 出场 Chrome / WebView 61**。最终代码必须能在 Chrome 61 解析和运行;iOS 18.4+ 支持的额外能力只能用于能力检测后的增强路径。Chrome 61 完整支持 ES2017,最终代码以 ES2017 为构建目标。 +> 目标内核固定为 **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;更新的运行时 API 仍须做能力检测或提供必要的局部实现。 +- ES2018+ 语法须由构建链转译,例如对象 spread、异步迭代、可选链、空值合并、逻辑赋值、class 私有字段、static block、BigInt 字面量和 top-level await;晚于 ES2017 的运行时 API 须改用基线写法。 语法不兼容会在脚本解析阶段直接失败,无法通过运行时 `if` 兜底。 @@ -30,8 +30,8 @@ ## 3. 运行时 API - ES2017 内置 API 和基础 DOM API 可直接使用。 -- `String.prototype.replaceAll`、`Array.prototype.at`、`Object.hasOwn`、`structuredClone` 等更新 API 不应直接作为唯一实现路径。 -- 使用非基础 Web 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) 为准。 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 index 166a65992..c3e3c9eed 100644 --- 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 @@ -2,11 +2,11 @@ - 相机 / 麦克风由用户手势触发,并处理系统弹窗授权。 ## JS 兼容 -- 使用非基础 Web API 前先做能力检测;不可用时隐藏增强功能、使用简单替代实现或给出清晰提示;能力检测基于对象 / 方法是否存在,不按 UA、机型或系统版本字符串分支。 +- 除 `replaceAll` / `.at` / `Object.hasOwn` / `structuredClone` 这类晚于 Chrome 61 的 API(须用基线写法替代)外,使用非基础 Web API 前先做能力检测;不可用时降级为简单替代实现或给出清晰提示;不按 UA、机型或系统版本字符串分支。 ## CSS 兼容 -- Chrome 61 基线层能够独立完成核心布局和交互,新内核通过能力检测启用增强层。 -- Flex gap 使用布局行为检测,或仅使用 margin 基线;未把 `@supports (gap: …)` / `CSS.supports('gap', …)` 当作 Flex gap 检测。 +- 最终产物只使用 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`。 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 index 20537d139..abe2929de 100644 --- 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 @@ -44,7 +44,7 @@ zip 内仅允许以下类型: - **脚本必须是经典脚本**:只用 `<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 增强;只做功能点级回退,不维护两套完整 CSS,见 [css-compatibility.md](./css-compatibility.md)。 +- **样式须兼容目标 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 一律加载不到,所有资源全部打包进小工具。 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 index baf799dd0..bd2077b40 100644 --- 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 @@ -250,16 +250,30 @@ test('html template and classic-script requirements are enforced', () => { ); }); -test('现代 CSS / 运行时 API 作为增强层放行,不再报警', () => { +test('Chrome 61 不支持的 CSS 与运行时 API 直接报 ERROR', () => { const root = join(BASE, 'modern_css'); writeProject(root, VALID_HTML, { ...VALID_FILES, - 'styles.css': '.a { aspect-ratio: 1; gap: 8px; color: oklch(0.5 0.1 20); }\n', + '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 actual = codes(root); - assert.ok(!actual.has('CSS_MODERN_FEATURE'), `unexpected CSS_MODERN_FEATURE; actual=${[...actual].sort()}`); - assert.ok(!actual.has('MODERN_RUNTIME_API'), `unexpected MODERN_RUNTIME_API; actual=${[...actual].sort()}`); + 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 / #选择器不算命中', () => { diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 47bdc093b..98ebd432a 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -9,6 +9,7 @@ */ import { existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, @@ -17,12 +18,10 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { deflateRawSync } from 'node:zlib'; -import { findUnsupportedFiles } from './validate.mjs'; - const MIB = 1024 * 1024; const ZIP_HARD_LIMIT = 10 * MIB; const ZIP_RECOMMENDED = 2 * MIB; @@ -47,6 +46,48 @@ const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] [--zip-out <path> --zip-out <path> 落点。默认 ${DEFAULT_ZIP_OUT}(假设 cwd 是 game/ 子工程,产物落在项目根的 .export/xhs-minitool.zip); 相对路径按当前工作目录解析,父目录不存在会自动建。`; +/** + * 产物白名单与遍历逻辑刻意在 pack 内自存一份:pack.mjs 要能单独复制进项目,不 import validate.mjs。 + * 改动时须与 validate.mjs 的 ALLOWED_EXTENSIONS / SKIP_DIRS 同步。 + */ +const ALLOWED_EXTENSIONS = new Set([ + '.html', '.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.webp', + '.svg', '.woff', '.woff2', '.json', +]); +const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.cache']); + +function* walkArtifactFiles(dir) { + for (const name of readdirSync(dir).sort()) { + const full = join(dir, name); + let stats; + try { + stats = lstatSync(full); + } catch { + continue; + } + if (stats.isSymbolicLink()) continue; + if (stats.isDirectory()) { + if (SKIP_DIRS.has(name)) continue; + yield* walkArtifactFiles(full); + } else if (stats.isFile()) { + yield full; + } + } +} + +/** 列出产物目录里白名单外的文件;path 用 `/` 分隔,方便报错展示。 */ +function findUnsupportedFiles(root) { + const base = resolve(root); + const unsupported = []; + for (const path of walkArtifactFiles(base)) { + const ext = extname(path).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + unsupported.push({ path: relative(base, path).split(sep).join('/'), ext }); + } + } + return unsupported; +} + /** * 打包前预检:产物目录里出现小红书白名单之外的文件类型时整体失败,列出路径但**不改磁盘**。 * diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs index 48f38caec..38d7e9108 100755 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs @@ -82,6 +82,7 @@ const CODE_PATTERNS = [ ['WARNING', 'DYNAMIC_IMPORT', '动态 import 可能产生运行时资源加载;确认目标为包内静态资源。', /\bimport\s*\(/g], ['WARNING', 'COOKIE_REVIEW', 'Cookie 只能作为本地存储;不要用于服务端登录态或鉴权。', /\bdocument\s*\.\s*cookie\b/g], ['WARNING', 'NAVIGATION_REVIEW', '检测到页面导航 API;确认不会打开外链、新窗口或其他小工具。', /\blocation\s*\.\s*href\s*=(?!=)|\b(?:location\s*\.\s*(?:assign|replace)|history\s*\.\s*(?:pushState|replaceState))\s*\(/g], + ['ERROR', 'MODERN_RUNTIME_API', '使用了 Chrome 61 不支持的运行时 API;目标内核会直接报错,必须改用基线写法。', /\bObject\s*\.\s*hasOwn\s*\(|\bstructuredClone\s*\(|\.replaceAll\s*\(|\.at\s*\(/g], ['WARNING', 'REGEX_LOOKBEHIND', '正则使用了 lookbehind;目标内核 Chrome 61 不支持,须改写。', /\(\?<[=!]/g], ]; @@ -96,6 +97,30 @@ const ES2018_SYNTAX_PATTERNS = [ [/\b\d[\d_]*n\b/g, 'BigInt 字面量'], ]; +/** + * Chrome 61 不支持的 CSS 能力;目标是硬性要求,命中即 ERROR。 + * + * gap / row-gap / column-gap 的裸写法都晚于 Chrome 61(Grid 66、Flex 84);唯一可用的是 + * `grid-gap`(以及 `-webkit-*` 前缀写法),所以这里用负向后顾把它们排除,不做上下文区分。 + */ +const CSS_UNSUPPORTED_PATTERNS = [ + [/(?<![\w-])(?:row-|column-)?gap\s*:/gi, 'gap / row-gap / column-gap(Grid 用 grid-gap)'], + [/\baspect-ratio\s*:/gi, 'aspect-ratio'], + [/\b(?:clamp|min|max)\s*\(/gi, 'min()/max()/clamp()'], + [/(?:^|[;{\s])(?:inset|margin-inline|margin-block|padding-inline|padding-block|border-inline|border-block)\s*[:(]/gi, '逻辑属性 / 简写'], + [/overflow\s*:\s*clip/gi, 'overflow: clip'], + [/:focus-visible/gi, ':focus-visible'], + [/:has\s*\(/gi, ':has()'], + [/@container\b/gi, '@container'], + [/\bsubgrid\b/gi, 'subgrid'], + [/@layer\b/gi, '@layer'], + [/@property\b/gi, '@property'], + [/\b\d+(?:\.\d+)?(?:dvh|svh|lvh)\b/gi, 'dvh / svh / lvh'], + [/\b(?:color-mix|oklab|oklch)\s*\(/gi, 'color-mix() / oklab() / oklch()'], + [/backdrop-filter\s*:/gi, 'backdrop-filter'], + [/text-wrap\s*:\s*balance/gi, 'text-wrap: balance'], +]; + const BASE64_RE = /data:[\w.+-]+\/[\w.+-]+;base64,([A-Za-z0-9+/=]+)/g; /** @@ -413,6 +438,13 @@ export function scanCssReferences(path, text, collector, baseLine = 1) { } checkLocalReference(value, path, collector, line, 'CSS url()'); } + + for (const [pattern, label] of CSS_UNSUPPORTED_PATTERNS) { + for (const match of text.matchAll(pattern)) { + collector.add('ERROR', 'CSS_UNSUPPORTED_FEATURE', path, baseLine + lineAt(match.index) - 1, + `使用了 Chrome 61 不支持的 CSS 能力(${label});目标内核会静默忽略该声明,必须改用 Chrome 61 可解析的写法。`); + } + } } /* ------------------------------------------------------------------ */ diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs index 6284dc01b..10e061864 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs @@ -36,7 +36,7 @@ import { defineConfig, mergeConfig } from 'vite'; /** JS 最低基线:Android 8.1 出厂 Chrome / WebView 61(完整支持 ES2017)。 */ export const MINITOOL_JS_TARGET = ['es2017', 'chrome61']; -/** CSS 目标内核:Chrome 61;iOS 18.4 能力只走能力检测后的增强路径。 */ +/** CSS 目标内核:Chrome 61。 */ export const MINITOOL_CSS_TARGET = ['chrome61']; /** 小红书要求包内自带的小工具 icon;publicDir 被关掉时由插件兜底复制。 */ diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fe0de7923..95d502d14 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,13 +1,14 @@ # 决策记录 -## 2026-10-06 小红书导出 validate/pack:去掉 strict 与增强层告警,扫 JS 先剥字符串/注释,zip 落点带 cwd 预警 +## 2026-10-06 小红书导出 validate/pack:Chrome 61 能力按硬性 ERROR 拦下,pack 自带白名单不再共享 -- 背景:真实项目首轮适配反馈三处。① `validate.mjs` 的 `/#[A-Za-z_$][\w$]*/g`(class 私有字段)直接在原始文本上匹配,把 `"#e8f4ff"`、`document.querySelector('#hit')` 误判为 ES2018 语法并报 ERROR,把排查引向「构建链没转译」。② `--strict` 让 references 明确鼓励的「Chrome 61 基线 + 能力检测后的增强层」(`CSS_MODERN_FEATURE`、`MODERN_RUNTIME_API`)也判失败,想 strict 通过只能砍增强层。③ 宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析;npm 脚本挂在 `game/` 子工程时 `.export/...` 会落到 `game/.export/`,没有任何提示。 +- 背景:真实项目适配反馈。① `validate.mjs` 的 `/#[A-Za-z_$][\w$]*/g`(class 私有字段)直接在原始文本上匹配,把 `"#e8f4ff"`、`document.querySelector('#hit')` 误判为 ES2018 语法并报 ERROR,把排查引向「构建链没转译」。② `CSS_MODERN_PATTERNS` / `MODERN_RUNTIME_API` 告警被移除后,`flex gap`(Chrome 84+)、`min()/max()/clamp()`(Chrome 79+)等晚于 Chrome 61 的写法被静默忽略却无人拦截,`references/manual-checks.md` 只有人读清单没有工具兜底。③ 宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析;npm 脚本挂在 `game/` 子工程时 `.export/...` 会落到 `game/.export/`,没有任何提示。④ `pack.mjs` 直接 `import './validate.mjs'`,只复制 pack.mjs 会 `ERR_MODULE_NOT_FOUND`。⑤ `validate.mjs` 的 `[project]` 默认值只在源码 USAGE 里,SKILL 参数表没写默认值。 - 决策(扫描先剥噪音):`scanJsSyntax` 先过 `stripJsNoise`——把注释与字符串字面量替换成等长空白(保留换行),只对真代码跑 ESM 与 ES2018 检测;模板 `${...}` 仍按代码处理。`"#e8f4ff"`、`'#hit'`、注释里的 `obj?.c` 不再命中,真 `#count` 私有字段仍报错。 -- 决策(退出码只由 ERROR 决定):删掉 `MODERN_RUNTIME_API` 与 `CSS_MODERN_PATTERNS` / `CSS_MODERN_FEATURE`,删掉 `--strict`。增强层写法交给 `references/css-compatibility.md`、`js-compatibility.md` 的能力检测要求约束,`validate.mjs` 不再把「能用但需检测」判失败。 -- 决策(产物落点有确定写法与预警):`SKILL.md` 写明产物是项目根的 `.export/xhs-minitool.zip`,`game/` 子工程写 `../.export/xhs-minitool.zip`;`pack.mjs` 在 `--zip-out` 为相对路径且落点位于 `<cwd>/.export/` 时打 `[warn]`,打印 cwd 与绝对落点(`../.export/...`、绝对路径或其他相对位置不触发)。 -- 影响范围:`resources/agc-skills/vite-export-xhs-minitool/{SKILL.md,scripts/{validate.mjs,pack.mjs}}`、其 `resources/agc-skills/manifest.json` 指纹(version=2026-08-26.56)、隐藏测试 `scripts/.validate.test.mjs`、`scripts/.pack.test.mjs`。宿主 `export/draft/xhs_minitool` 链路与 `xhsMinitoolInstruction.ts` 契约常量不变。 -- 验证方式:`node --test scripts/.validate.test.mjs scripts/.pack.test.mjs scripts/.vite.config.xhs-minitool.test.mjs`(34 passed)、`npm run agc:skill-pack:check`、`git diff --check`、`npm run check:encoding`。 +- 决策(Chrome 61 是硬性要求,不是「增强层」):恢复 `CSS_UNSUPPORTED_PATTERNS` / `MODERN_RUNTIME_API` 并判 **ERROR**(`CSS_UNSUPPORTED_FEATURE`),覆盖 `gap`/`row-gap`/`column-gap`(仅 `grid-gap` 与 `-webkit-` 前缀写法豁免)、`aspect-ratio`、`min()/max()/clamp()`、逻辑属性、`overflow: clip`、`:focus-visible`、`:has()`、`@container`、`subgrid`、`@layer`/`@property`、`dvh/svh/lvh`、现代颜色、`backdrop-filter`、`text-wrap: balance`,以及 `Object.hasOwn`/`structuredClone`/`replaceAll`/`.at`。`--strict` 保持移除:退出码只由 ERROR 决定。references 同步删除「基线层 + 增强层」与能力检测启用现代 CSS / 新 API 的写法;`flex gap` 一律用子项 `margin`。`:hover` 关键操作与安全区仍需人读清单。 +- 决策(产物落点有确定写法与预警):`SKILL.md` 写明产物是项目根的 `.export/xhs-minitool.zip`;`--zip-out` 默认 `../.export/xhs-minitool.zip`(假定 cwd 是 `game/`),相对路径落点位于 `<cwd>/.export/` 时打 `[warn]`,打印 cwd 与绝对落点。 +- 决策(pack 自带实现 + 参数表补默认值):`pack.mjs` 不再 `import './validate.mjs'`,把 `ALLOWED_EXTENSIONS` / `SKIP_DIRS` / 遍历与 `findUnsupportedFiles` 复制进 pack(改动时两处需同步);`SKILL.md` 参数表补 `validate.mjs [project]` 默认 `dist-xhs-minitool`、`pack.mjs --vite-built-dir` 默认 `dist-xhs-minitool`、`--zip-out` 默认 `../.export/xhs-minitool.zip`、`vite.config` `build.outDir` 默认 `dist-xhs-minitool`。 +- 影响范围:`resources/agc-skills/vite-export-xhs-minitool/{SKILL.md,references/{css-compatibility.md,js-compatibility.md,manual-checks.md,zip-artifact-spec.md},scripts/{validate.mjs,pack.mjs,vite.config.xhs-minitool.mjs}}`、`resources/agc-skills/manifest.json` 指纹(version=2026-08-26.62)、隐藏测试 `scripts/.validate.test.mjs`、`scripts/.pack.test.mjs`。 +- 验证方式:`node --test scripts/.validate.test.mjs scripts/.pack.test.mjs scripts/.vite.config.xhs-minitool.test.mjs`(36 passed)、`npm run agc:skill-pack:check`、`git diff --check`、`npm run check:encoding`。 - 边界:「web 与小工具共用同一 `game/index.html`」是默认姿势,不需要另起 root;只有入口 HTML 确实不同才需要并存 recipe,本次未写,留待需要时补。 ## 2026-10-06 新增 agc_install_skill_resource:Skill 附件由宿主直接落盘,不走模型正文 From b0bde75fcbb4e4fe85c429b2545dd1e178cd42e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:04:19 +0800 Subject: [PATCH 070/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8A=80=E8=83=BD=EF=BC=9A=E5=8F=82=E8=80=83=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=8D=E5=86=8D=E6=A0=87=E6=B3=A8=E3=80=8C=E9=9D=9E?= =?UTF-8?q?=E5=BF=85=E8=A6=81=E4=B8=8D=E8=AF=BB=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md 改为「何时读命中时必须读」 - zip-artifact-spec 行点名改源码硬约束:viewport-fit=cover、不用 base、不自建 CSP meta - 流程里加一条:写/改 index.html 前先读 zip-artifact-spec §3/§5 - 同步 manifest 指纹(version=2026-08-26.63) --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../resources/agc-skills/vite-export-xhs-minitool/SKILL.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 f9006428f..b4db8ccbc 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.62", + "version": "2026-08-26.63", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "5c7cd2098061be1203b4055c806b0cb40f2f54efdf4efc34066c98a086a26250" + "sha256": "a6bbe8c4a8175486a5dfd9a0ec35d31d576a5a279700bddb883dbfee54f40796" } ] } 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 index f5a46b3e8..9a3930656 100644 --- 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 @@ -16,6 +16,7 @@ metadata: 产物落在 **项目根** `.export/xhs-minitool.zip`; 相对 cwd 解析) 4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本 +* 写/改 `index.html` 前先读 `references/zip-artifact-spec.md` §3/§5:viewport-fit=cover、不用 `<base>`、不自建 CSP meta 等是改源码时的硬约束(`validate.mjs` 都会判 ERROR),不要等打包才看 * 不干扰正常的web构建 # 假设和默认项: @@ -37,14 +38,14 @@ metadata: * 如果使用了外部能力, 参考platform-abstract skill对项目先重构 * 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 -以下是其他参考文档, 非必要不读 +以下是参考文档;「何时读」命中时必须读: ## Reference | 文档 | 何时读 | |-------------------------------------------------------------|--------------------------------------------------------------------------------------------| | [js-api.md](references/js-api.md) | 本地 API 快照; | -| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写 HTML 文件类型、容器 CSP、`index.html` 要求 | +| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写/改 `index.html` 或处理文件类型、脚本外置、容器 CSP 时:viewport-fit=cover、不用 `<base>`、不自建 CSP meta(validate 均判 ERROR) | | [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 硬基线、不支持的能力与替代写法 | From a01891bdf09cdaaa436bec3682af7549a40da4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:08:32 +0800 Subject: [PATCH 071/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8A=80=E8=83=BD=EF=BC=9Aindex.html=20=E7=A1=AE?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E7=BA=A6=E6=9D=9F=E5=8F=AA=E7=95=99=E5=9C=A8?= =?UTF-8?q?=20validate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zip-artifact-spec.md §5 删 viewport / <base> / CSP meta 三行(validate 已判 ERROR) - SKILL.md 去掉对应的枚举,只保留「何时读命中时必须读」措辞 - .selective_rule.txt 记录删除项与对应的 validate 检查码 - 同步 manifest 指纹(version=2026-08-26.64) --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../vite-export-xhs-minitool/.selective_rule.txt | 4 +++- .../agc-skills/vite-export-xhs-minitool/SKILL.md | 3 +-- .../references/zip-artifact-spec.md | 9 +++------ 4 files changed, 9 insertions(+), 11 deletions(-) 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 b4db8ccbc..eb8808432 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.63", + "version": "2026-08-26.64", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "a6bbe8c4a8175486a5dfd9a0ec35d31d576a5a279700bddb883dbfee54f40796" + "sha256": "727e519f2f60d0fc999faad994b2e7e32656bf47aabb3b7be6d45e9811333412" } ] } 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 index 90e92c189..359267b98 100644 --- 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 @@ -22,7 +22,9 @@ - 删 §5 表格里「资源全为相对路径」「脚本外置」两行(前者 Vite 保证,后者 §3 CSP 已声明)。 - 删 §5 的「index.html 模板」整段 HTML 骨架与其中的 reset / 字体 CSS;标题改为「index.html 要求」,只留要求表与 <title> 说明。 - 原因:模板是 pack 之后的 dist 形状,对 Vite 源 index.html 是错的(源需要 <script type="module"> 入口);reset / 字体 CSS 不是容器要求;doctype / charset / lang 由 validate 的 *_MISSING 兜。 - - 保留 §2 文件类型、§3 容器 CSP、§5 index.html 要求(viewport 含 width=device-width, initial-scale=1.0, viewport-fit=cover;不用 <base href>;不引用外部资源;不自建 CSP <meta>)。 + - 删 §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(可用能力、禁用能力与替代、常见交互写法)。 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 index 9a3930656..a426c07dc 100644 --- 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 @@ -16,7 +16,6 @@ metadata: 产物落在 **项目根** `.export/xhs-minitool.zip`; 相对 cwd 解析) 4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本 -* 写/改 `index.html` 前先读 `references/zip-artifact-spec.md` §3/§5:viewport-fit=cover、不用 `<base>`、不自建 CSP meta 等是改源码时的硬约束(`validate.mjs` 都会判 ERROR),不要等打包才看 * 不干扰正常的web构建 # 假设和默认项: @@ -45,7 +44,7 @@ metadata: | 文档 | 何时读 | |-------------------------------------------------------------|--------------------------------------------------------------------------------------------| | [js-api.md](references/js-api.md) | 本地 API 快照; | -| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写/改 `index.html` 或处理文件类型、脚本外置、容器 CSP 时:viewport-fit=cover、不用 `<base>`、不自建 CSP meta(validate 均判 ERROR) | +| [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 硬基线、不支持的能力与替代写法 | 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 index abe2929de..5113cd724 100644 --- 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 @@ -52,11 +52,8 @@ zip 内仅允许以下类型: ## 5. index.html 要求 -| 规则 | 原因 | -|-------------------------------------------------------------------------|----------------------------------| -| viewport 含 `width=device-width, initial-scale=1.0, viewport-fit=cover` | 真机 + 模拟器布局与安全区 | -| 不用 `<base href>` | 破坏真机路径 | -| 不引用任何外部资源(图片 / CSS / JS / 字体) | 外部资源加载不到,须全部打进 zip | -| 不自建 CSP `<meta>` | 安全策略由容器统一管理 | +| 规则 | 原因 | +|-------------------------------------------------|----------------------------------| +| 不引用任何外部资源(图片 / CSS / JS / 字体) | 外部资源加载不到,须全部打进 zip | - `<title>` 仅影响文档标题;导航栏标题由容器 UI 配置。 From 8a2c09f24a176e00cc0d213408431e38515b53c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:10:02 +0800 Subject: [PATCH 072/156] =?UTF-8?q?AGC=20=E6=89=93=E5=8C=85=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=A0=A1=E9=AA=8C=E5=89=8D=E7=BD=AE=E5=88=B0?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E6=97=B6=E5=B9=B6=E6=94=B9=E7=94=A8=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=8F=90=E7=A4=BA=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 表单必填项(名称 / 简介 / 封面 / 在途上传)在点击「打包并发布」时先于构建校验,不通过直接弹「发布提示」,不再先弹一次打包进度 - 表单错误统一经 onError 交给面板根已有的 GamePublishBlockedDialog,左栏不再铺内联红字 - 简介兜底改用默认文案,项目 goal 为空不再把入口卡死 - 补齐封面后打包的回归用例,新增「未选封面不打包、不出进度弹窗」用例 --- .../GameDistributionPublishFormView.tsx | 8 --- .../useGameDistributionPublishForm.ts | 70 +++++++++++-------- .../export/state/useTaonierTab.ts | 2 + .../tests/gamePublishFeedback.test.tsx | 66 +++++++++++++---- 4 files changed, 97 insertions(+), 49 deletions(-) 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 index 077341612..0f7ed1194 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -379,14 +379,6 @@ export function GameDistributionPublishFormView({ ) : ( <> {fieldsAndMedia} - {error ? ( - <p - className="game-distribution-publish-panel__error" - role="alert" - > - {error} - </p> - ) : null} <button type="button" disabled={ diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index aa0387c38..64875aab6 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -161,6 +161,7 @@ export function useGameDistributionPublishForm({ packageResult, onPublished, onEnsurePackage, + onError, }: { open: boolean; inline: boolean; @@ -169,6 +170,11 @@ export function useGameDistributionPublishForm({ packageResult: LocalProjectExportPackageResult | null; onPublished?: (result: GameDistributionPublishResult) => void; onEnsurePackage?: () => Promise<LocalProjectExportPackageResult | null>; + /** + * 错误出口:内联工作台把表单校验 / 提交错误交给面板根的 `GamePublishBlockedDialog` + * (发布提示弹窗)展示;弹窗形态不传,退回表单里的内联文案。`null` 表示清掉弹窗。 + */ + onError?: (message: string | null) => void; }) { const manifestRef = useRef(manifest); manifestRef.current = manifest; @@ -225,6 +231,7 @@ export function useGameDistributionPublishForm({ setCategory('其他'); setBusy(false); setError(''); + onError?.(null); setResult(null); setMetadataSuggestionPending(false); setMetadataSuggestionReady(false); @@ -261,7 +268,7 @@ export function useGameDistributionPublishForm({ setMetadataSuggestionPending(false); } }); - }, [manifest?.projectId, open, resetKey]); + }, [manifest?.projectId, open, resetKey, onError]); useEffect(() => { if (open) { @@ -336,10 +343,10 @@ export function useGameDistributionPublishForm({ async function handleCoverSelected(file: File | null) { if (!file) return; - setError(''); + reportError(''); const fileError = resolvePanelImageFileError(file, 'cover'); if (fileError) { - setError(fileError); + reportError(fileError); return; } setUploadingLabel('正在上传封面…'); @@ -347,7 +354,7 @@ export function useGameDistributionPublishForm({ const assetObjectId = await resolveAssetObjectId(file, 'cover'); setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); } catch (uploadError) { - setError( + reportError( uploadError instanceof Error ? uploadError.message : '封面上传失败,请重试', @@ -360,11 +367,11 @@ export function useGameDistributionPublishForm({ async function handleGenerateCover() { if (!manifest) { - setError('项目清单尚未就绪,请稍后重试'); + reportError('项目清单尚未就绪,请稍后重试'); return; } setCoverConfirmOpen(false); - setError(''); + reportError(''); setCoverGenerationDetail('正在根据项目内容生成封面…'); setGeneratingCover(true); try { @@ -388,7 +395,7 @@ export function useGameDistributionPublishForm({ ); } catch (generationError) { setCoverGenerationDetail(''); - setError( + reportError( generationError instanceof Error ? generationError.message : '生成游戏封面失败,请重试', @@ -434,7 +441,7 @@ export function useGameDistributionPublishForm({ ).length; const remaining = MAX_AGC_GAME_SCREENSHOTS - counted; if (files.length > remaining) { - setError( + reportError( remaining > 0 ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` : '游戏截图最多 6 张', @@ -442,7 +449,7 @@ export function useGameDistributionPublishForm({ if (screenshotInputRef.current) screenshotInputRef.current.value = ''; return; } - setError(''); + reportError(''); const entries = files.map(createPanelScreenshotEntry); setScreenshots((current) => [...current, ...entries]); @@ -496,6 +503,7 @@ export function useGameDistributionPublishForm({ function resolvePublishFormError(): string { if (!title.trim()) return '请先填写游戏名称'; if (!summary.trim()) return '请先填写一句话简介'; + if (!cover) return '请先选择游戏封面(JPG/PNG/WebP)'; if ( uploadingLabel || screenshots.some((item) => item.status === 'uploading') @@ -505,37 +513,46 @@ export function useGameDistributionPublishForm({ return ''; } + /** + * 表单错误统一出口:内联工作台交给面板根的「发布提示」弹窗(`GamePublishBlockedDialog`), + * 弹窗形态没有 `onError` 时仍退回表单内联文案。空串等价于清掉弹窗。 + */ + function reportError(message: string) { + setError(message); + onError?.(message.trim() ? message : null); + } + /** 用户一改字段就把上一次的校验错误收起来,别让已经修好的字段继续挂着红字。 */ function clearError() { - setError(''); + reportError(''); } /** 真正提交发布:包由调用方传入,不依赖 prop 更新时机。 */ async function submitPackage(pkg: LocalProjectExportPackageResult) { if (!projectPath.trim()) { - setError('请先导出有效的试玩包'); + reportError('请先导出有效的试玩包'); return; } if (!manifest) { - setError('项目清单尚未就绪,请稍后重试'); + reportError('项目清单尚未就绪,请稍后重试'); return; } const invoke = resolveTauriInvoke(); if (!invoke) { - setError('需要在 Tauri App 内发布'); + reportError('需要在 Tauri App 内发布'); return; } if (!cover) { - setError('请先选择游戏封面(JPG/PNG/WebP)'); + reportError('请先选择游戏封面(JPG/PNG/WebP)'); return; } const formError = resolvePublishFormError(); if (formError) { - setError(formError); + reportError(formError); return; } setBusy(true); - setError(''); + reportError(''); try { const next = await publishLocalProjectGame({ invoke, @@ -562,7 +579,7 @@ export function useGameDistributionPublishForm({ setResult(next); onPublished?.(next); } catch (nextError) { - setError( + reportError( nextError instanceof Error ? nextError.message : String(nextError), ); } finally { @@ -572,7 +589,7 @@ export function useGameDistributionPublishForm({ async function handleSubmit() { if (!packageResult) { - setError('请先导出有效的试玩包'); + reportError('请先导出有效的试玩包'); return; } await submitPackage(packageResult); @@ -581,23 +598,20 @@ export function useGameDistributionPublishForm({ /** * 内联主行动:一次点击完成「打包 → 发布」。 * - * 顺序沿用旧入口的「点击即构建打包」:先按需构建试玩包(进度 / 阻断由父层弹窗承载), - * 打包成功后再走提交前的表单门禁,失败只把错误留在表单里。文案必填项在打包之前就拦, - * 免得为了一份注定提交不了的资料白跑一次构建;封面 / 在途上传留到打包成功后判定。 + * 与小红书「打包」同一口径:表单必填项(名称、简介、封面、在途上传)全部先在**点击那一刻** + * 于前端校验,不通过就把错误交给「发布提示」弹窗——既不灰按钮,也不会为一个注定提交不了的 + * 资料先弹一次打包进度。校验通过后才按需构建试玩包(进度 / 阻断弹窗照旧),再提交发布。 */ async function handlePackageAndPublish() { - if (!title.trim()) { - setError('请先填写游戏名称'); - return; - } - if (!summary.trim()) { - setError('请先填写一句话简介'); + const formError = resolvePublishFormError(); + if (formError) { + reportError(formError); return; } let pkg = packageResult; if (!pkg) { if (!onEnsurePackage) { - setError('请先导出有效的试玩包'); + reportError('请先导出有效的试玩包'); return; } pkg = await onEnsurePackage(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index c4f9e777a..efa4f0faa 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -236,6 +236,8 @@ export function useTaonierTab({ manifest, packageResult, onEnsurePackage: ensurePackage, + // 校验 / 提交错误走面板根已有的「发布提示」弹窗,不再在左栏里铺一行红字。 + onError: setBlockedMessage, }); return { diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 350e5a482..7d2498b68 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -46,6 +46,14 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { }; }); +// 表单只负责选图并调用上传;这里替换掉真实直传,让内联表单能在测试里拿到封面 ID。 +vi.mock('../src/services/assetDirectUpload', () => ({ + uploadPlatformMediaAsset: vi.fn(async () => ({ + assetObjectId: 'asset_cover', + objectKey: 'game-distribution/cover/cover.png', + })), +})); + const PROJECT_PATH = '/tmp/game-publish-feedback-project'; const PROJECT_ID = 'game-publish-feedback-project'; @@ -134,9 +142,14 @@ async function openTaonierPublishEntry() { return publish; } -async function clickPublish() { - fireEvent.click(await openTaonierPublishEntry()); - return screen.findByLabelText('游戏发布对话栏'); +/** 内联表单只有拿到封面素材 ID 才算校验通过;用假上传把封面补齐后再点主行动。 */ +async function attachCover() { + fireEvent.change(await screen.findByLabelText('游戏封面图片'), { + target: { + files: [new File(['cover-bytes'], 'cover.png', { type: 'image/png' })], + }, + }); + await screen.findByText(/已选择「cover\.png」/u); } beforeEach(() => { @@ -183,7 +196,11 @@ describe('客户端发布入口的可见反馈', () => { renderPublishProject(manifest); - const surface = await clickPublish(); + const publish = await openTaonierPublishEntry(); + // 表单先过本地门禁,原型阻断才会接管;先补齐封面让校验通过。 + await attachCover(); + fireEvent.click(publish); + const surface = await screen.findByLabelText('游戏发布对话栏'); const noticeDialog = await screen.findByRole('dialog', { name: '发布提示', }); @@ -209,7 +226,9 @@ describe('客户端发布入口的可见反馈', () => { installTauri({ exportPackage }); renderPublishProject(); - fireEvent.click(await openTaonierPublishEntry()); + const publish = await openTaonierPublishEntry(); + await attachCover(); + fireEvent.click(publish); const progressDialog = await screen.findByRole('dialog', { name: '发布进度', @@ -230,10 +249,7 @@ describe('客户端发布入口的可见反馈', () => { await waitFor(() => { expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull(); }); - // 打包成功后直接提交发布;还没有封面时把错误留在表单里,而不是再弹一个发布面板。 - expect( - await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'), - ).not.toBeNull(); + // 校验通过才会走到打包;打包成功后直接提交发布,不再弹旧的发布面板。 expect(screen.queryByRole('dialog', { name: '发布到游戏广场' })).toBeNull(); }); @@ -245,7 +261,9 @@ describe('客户端发布入口的可见反馈', () => { }); renderPublishProject(); - fireEvent.click(await openTaonierPublishEntry()); + const publish = await openTaonierPublishEntry(); + await attachCover(); + fireEvent.click(publish); const failureDialog = await screen.findByRole('dialog', { name: '发布失败', @@ -289,7 +307,9 @@ describe('客户端发布入口的可见反馈', () => { installTauri({ exportPackage, policy: createConfirmPolicy() }); renderPublishProject(); - fireEvent.click(await openTaonierPublishEntry()); + const publish = await openTaonierPublishEntry(); + await attachCover(); + fireEvent.click(publish); await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); expect(screen.queryByText('project.export_package')).toBeNull(); @@ -311,8 +331,28 @@ describe('客户端发布入口的可见反馈', () => { fireEvent.click(publish); - // 按钮始终可点,校验在前端当场给出;必填文案不通过时不为一次注定失败的发布白跑构建。 - expect(await screen.findByText('请先填写游戏名称')).not.toBeNull(); + // 按钮始终可点,校验在前端当场给出、由「发布提示」弹窗承载;不通过时不为一次注定失败的发布白跑构建。 + const noticeDialog = await screen.findByRole('dialog', { + name: '发布提示', + }); + expect(noticeDialog.textContent ?? '').toContain('请先填写游戏名称'); expect(exportPackage).not.toHaveBeenCalled(); }); + + it('没选封面点「打包并发布」:直接弹发布提示,不打包也不出进度弹窗', async () => { + const exportPackage = vi.fn(createExportPackageResult); + installTauri({ exportPackage }); + renderPublishProject(); + + fireEvent.click(await openTaonierPublishEntry()); + + const noticeDialog = await screen.findByRole('dialog', { + name: '发布提示', + }); + expect(noticeDialog.textContent ?? '').toContain( + '请先选择游戏封面(JPG/PNG/WebP)', + ); + expect(exportPackage).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull(); + }); }); From c66d1586f40aab6987de96131a3ed015a8ed6895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:15:35 +0800 Subject: [PATCH 073/156] =?UTF-8?q?AGC=20=E9=80=80=E5=BD=B9=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=A1=A8=E5=8D=95=E5=BC=B9=E7=AA=97=E5=A4=96=E5=A3=B3?= =?UTF-8?q?=EF=BC=8C=E9=94=99=E8=AF=AF=E7=BB=9F=E4=B8=80=E8=B5=B0=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=8F=90=E7=A4=BA=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除已无现役调用方的 GameDistributionPublishPanel,发布资料表单只保留内联形态 - useGameDistributionPublishForm 去掉 inline 开关与 handleSubmit,onError 改为必填 - GameDistributionPublishFormView 去掉弹窗分支与 open / onClose 入参,只渲染内联布局 - 原专属测试迁移到内联表单并改名为 gameDistributionPublishForm.test.tsx --- .../GameDistributionPublishFormView.tsx | 211 +++++------------- .../GameDistributionPublishPanel.tsx | 43 ---- .../useGameDistributionPublishForm.ts | 38 +--- .../export/state/useTaonierTab.ts | 1 - .../export/tabs/taonier/PublishPane.tsx | 3 - ...x => gameDistributionPublishForm.test.tsx} | 79 +++++-- 6 files changed, 119 insertions(+), 256 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx rename apps/ai-game-creator-shell/tests/{gameDistributionPublishPanel.test.tsx => gameDistributionPublishForm.test.tsx} (86%) 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 index 0f7ed1194..0193cd116 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -12,26 +12,19 @@ import { } from './useGameDistributionPublishForm'; /** - * 发布资料表单的表现层:弹窗与工作台内联共用同一份 DOM 结构。 + * 发布资料表单的表现层(工作台内联形态)。 * - * 状态全部来自 `form`(`useGameDistributionPublishForm` 的返回值);内联形态把主行动换成 - * 内容末尾整行橙色的「打包并发布」。 + * 状态全部来自 `form`(`useGameDistributionPublishForm` 的返回值);主行动是内容末尾整行 + * 橙色的「打包并发布」,校验 / 提交错误由面板根的「发布提示」弹窗承载。 */ export function GameDistributionPublishFormView({ form, - inline = false, - open, - onClose, packaging = false, }: { form: GameDistributionPublishFormState; - inline?: boolean; - open: boolean; - onClose: () => void; packaging?: boolean; }) { const { - packageResult, title, setTitle, summary, @@ -39,7 +32,6 @@ export function GameDistributionPublishFormView({ category, setCategory, busy, - error, clearError, result, cover, @@ -66,7 +58,6 @@ export function GameDistributionPublishFormView({ handleGenerateCover, handleScreenshotsSelected, removeScreenshot, - handleSubmit, handlePackageAndPublish, } = form; @@ -349,160 +340,60 @@ export function GameDistributionPublishFormView({ </ThemedModal> ); - if (inline) { - return ( - <div - className="flex h-full min-h-0 flex-col gap-3 overflow-auto p-3" - aria-label="游戏发布表单" - > - <section className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4"> - <span className="game-distribution-publish-panel__eyebrow"> - 发布到游戏广场 - </span> - <h2 className="mt-1 text-base font-semibold text-[#3d1f10]"> - 让玩家现在就能试玩 - </h2> - </section> - {result ? ( - <div - className="game-distribution-publish-panel__success" - role="status" - > - <strong>已提交审核</strong> - <p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p> - <p className="game-distribution-publish-panel__mono"> - 版本 {result.versionNumber} ·{' '} - {result.packageSizeBytes.toLocaleString()} B ·{' '} - {result.packageSha256.slice(0, 16)}… - </p> - </div> - ) : ( - <> - {fieldsAndMedia} - <button - type="button" - disabled={ - busy || - generatingCover || - screenshotsUploading || - Boolean(uploadingLabel) || - Boolean(packaging) - } - onClick={() => void handlePackageAndPublish()} - className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-6 py-4 text-lg font-bold tracking-wide text-white shadow-md transition enabled:hover:bg-[#b4562f] disabled:cursor-not-allowed disabled:opacity-45" - > - {packaging - ? '正在打包…' - : busy - ? '上传中…' - : generatingCover - ? '封面生成中…' - : screenshotsUploading - ? '截图上传中…' - : uploadingLabel - ? '素材上传中…' - : '打包并发布'} - </button> - </> - )} - {coverConfirmDialog} - {previewDialog} - </div> - ); - } - return ( - <> - <ThemedModal - open={open} - ariaLabel="发布到游戏广场" - onClose={busy || generatingCover ? () => undefined : onClose} - panelClassName="game-distribution-publish-panel" - closeOnBackdrop={!busy && !generatingCover} - closeOnEscape={!busy && !generatingCover} - > - <header className="game-distribution-publish-panel__header"> - <div> - <span className="game-distribution-publish-panel__eyebrow"> - 发布到游戏广场 - </span> - <h2>让玩家现在就能试玩</h2> - </div> + <div + className="flex h-full min-h-0 flex-col gap-3 overflow-auto p-3" + aria-label="游戏发布表单" + > + <section className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4"> + <span className="game-distribution-publish-panel__eyebrow"> + 发布到游戏广场 + </span> + <h2 className="mt-1 text-base font-semibold text-[#3d1f10]"> + 让玩家现在就能试玩 + </h2> + </section> + {result ? ( + <div className="game-distribution-publish-panel__success" role="status"> + <strong>已提交审核</strong> + <p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p> + <p className="game-distribution-publish-panel__mono"> + 版本 {result.versionNumber} ·{' '} + {result.packageSizeBytes.toLocaleString()} B ·{' '} + {result.packageSha256.slice(0, 16)}… + </p> + </div> + ) : ( + <> + {fieldsAndMedia} <button type="button" - onClick={onClose} - disabled={busy || generatingCover} - aria-label="关闭发布面板" + disabled={ + busy || + generatingCover || + screenshotsUploading || + Boolean(uploadingLabel) || + Boolean(packaging) + } + onClick={() => void handlePackageAndPublish()} + className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-6 py-4 text-lg font-bold tracking-wide text-white shadow-md transition enabled:hover:bg-[#b4562f] disabled:cursor-not-allowed disabled:opacity-45" > - × + {packaging + ? '正在打包…' + : busy + ? '上传中…' + : generatingCover + ? '封面生成中…' + : screenshotsUploading + ? '截图上传中…' + : uploadingLabel + ? '素材上传中…' + : '打包并发布'} </button> - </header> - {result ? ( - <div - className="game-distribution-publish-panel__success" - role="status" - > - <strong>已提交审核</strong> - <p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p> - <p className="game-distribution-publish-panel__mono"> - 版本 {result.versionNumber} ·{' '} - {result.packageSizeBytes.toLocaleString()} B ·{' '} - {result.packageSha256.slice(0, 16)}… - </p> - <div className="game-distribution-publish-panel__actions"> - <button type="button" onClick={onClose}> - 完成 - </button> - </div> - </div> - ) : ( - <> - {fieldsAndMedia} - {error ? ( - <p - className="game-distribution-publish-panel__error" - role="alert" - > - {error} - </p> - ) : null} - <div className="game-distribution-publish-panel__actions"> - <button - type="button" - onClick={onClose} - disabled={busy || generatingCover || screenshotsUploading} - > - 稍后再说 - </button> - <button - type="button" - onClick={() => void handleSubmit()} - disabled={ - busy || - generatingCover || - screenshotsUploading || - Boolean(uploadingLabel) || - !title.trim() || - !summary.trim() || - !packageResult - } - > - {busy - ? '上传中…' - : generatingCover - ? '封面生成中…' - : screenshotsUploading - ? '截图上传中…' - : uploadingLabel - ? '素材上传中…' - : '发布游戏'} - </button> - </div> - </> - )} - </ThemedModal> + </> + )} {coverConfirmDialog} {previewDialog} - </> + </div> ); } diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx deleted file mode 100644 index 56b954f8b..000000000 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { LocalProjectExportPackageResult } from '../../app/types'; -import type { GameDistributionPublishResult } from '../../services/gameDistributionPublish'; -import { GameDistributionPublishFormView } from './GameDistributionPublishFormView'; -import { useGameDistributionPublishForm } from './useGameDistributionPublishForm'; - -/** - * 发布资料表单的弹窗外壳:点按钮弹出的独立面板。 - * - * 表单状态在 `useGameDistributionPublishForm`;工作台内联形态把它提升到导出面板根 - * (`useTaonierTab`),切 tab 不丢。这里保留「弹窗 + 关闭」的入口形态。 - */ -export function GameDistributionPublishPanel({ - open, - projectPath, - manifest, - packageResult, - onClose, - onPublished, -}: { - open: boolean; - projectPath: string; - manifest: GameCreationAppManifest; - packageResult: LocalProjectExportPackageResult | null; - onClose: () => void; - onPublished?: (result: GameDistributionPublishResult) => void; -}) { - const form = useGameDistributionPublishForm({ - open, - inline: false, - projectPath, - manifest, - packageResult, - onPublished, - }); - return ( - <GameDistributionPublishFormView - form={form} - open={open} - onClose={onClose} - /> - ); -} diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 64875aab6..2723cfc0c 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -148,14 +148,11 @@ export type GameDistributionPublishFormState = ReturnType< /** * 发布资料表单的全部状态与动作。 * - * 弹窗形态由 `GameDistributionPublishPanel` 自己调用;工作台内联形态把它提升到导出面板根 - * (`useTaonierTab`),这样切走 tab、页面组件卸载也不会丢掉已经填好的发布资料。 - * - * `inline` 决定初始化开关:弹窗每次带着新试玩包打开都重来;内联常驻左栏,只在换项目时重来。 + * 由导出面板根(`useTaonierTab`)持有,这样切走 tab、页面组件卸载也不会丢掉已经填好的发布资料; + * 初始化只在换项目 / 打开时重来,不因为「打包结果变化」清掉用户填好的资料。 */ export function useGameDistributionPublishForm({ open, - inline, projectPath, manifest, packageResult, @@ -164,17 +161,13 @@ export function useGameDistributionPublishForm({ onError, }: { open: boolean; - inline: boolean; projectPath: string; manifest: GameCreationAppManifest | null; packageResult: LocalProjectExportPackageResult | null; onPublished?: (result: GameDistributionPublishResult) => void; onEnsurePackage?: () => Promise<LocalProjectExportPackageResult | null>; - /** - * 错误出口:内联工作台把表单校验 / 提交错误交给面板根的 `GamePublishBlockedDialog` - * (发布提示弹窗)展示;弹窗形态不传,退回表单里的内联文案。`null` 表示清掉弹窗。 - */ - onError?: (message: string | null) => void; + /** 表单校验 / 提交错误的出口:面板根用它驱动「发布提示」弹窗(`GamePublishBlockedDialog`)。 */ + onError: (message: string | null) => void; }) { const manifestRef = useRef(manifest); manifestRef.current = manifest; @@ -211,14 +204,6 @@ export function useGameDistributionPublishForm({ const uploadedAssetCacheRef = useRef(new Map<string, string>()); const objectUrlsRef = useRef(new Set<string>()); - /** - * 表单初始化开关:弹窗每次带着新试玩包打开都要重来;内联常驻左栏,只在换项目时重来, - * 不能因为「打包完包结果变化」就把用户已经填好的资料清掉。 - */ - const resetKey = inline - ? 'inline' - : (packageResult?.packageRelativePath ?? ''); - useEffect(() => { if (!open) return; const currentManifest = manifestRef.current; @@ -231,7 +216,7 @@ export function useGameDistributionPublishForm({ setCategory('其他'); setBusy(false); setError(''); - onError?.(null); + onError(null); setResult(null); setMetadataSuggestionPending(false); setMetadataSuggestionReady(false); @@ -268,7 +253,7 @@ export function useGameDistributionPublishForm({ setMetadataSuggestionPending(false); } }); - }, [manifest?.projectId, open, resetKey, onError]); + }, [manifest?.projectId, open, onError]); useEffect(() => { if (open) { @@ -519,7 +504,7 @@ export function useGameDistributionPublishForm({ */ function reportError(message: string) { setError(message); - onError?.(message.trim() ? message : null); + onError(message.trim() ? message : null); } /** 用户一改字段就把上一次的校验错误收起来,别让已经修好的字段继续挂着红字。 */ @@ -587,14 +572,6 @@ export function useGameDistributionPublishForm({ } } - async function handleSubmit() { - if (!packageResult) { - reportError('请先导出有效的试玩包'); - return; - } - await submitPackage(packageResult); - } - /** * 内联主行动:一次点击完成「打包 → 发布」。 * @@ -665,7 +642,6 @@ export function useGameDistributionPublishForm({ handleGenerateCover, handleScreenshotsSelected, removeScreenshot, - handleSubmit, handlePackageAndPublish, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index efa4f0faa..e5b9b2a85 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -231,7 +231,6 @@ export function useTaonierTab({ */ const publishForm = useGameDistributionPublishForm({ open: formEnabled, - inline: true, projectPath: projectPath ?? '', manifest, packageResult, diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx index 5162fba49..837c95d89 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx @@ -32,9 +32,6 @@ export function PublishPane({ tab }: { tab: TaonierTabState }) { > <GameDistributionPublishFormView form={tab.publishForm} - inline - open - onClose={() => undefined} packaging={tab.progress?.phase === 'running'} /> </div> diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishForm.test.tsx similarity index 86% rename from apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx rename to apps/ai-game-creator-shell/tests/gameDistributionPublishForm.test.tsx index 98de312bf..790641d41 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishForm.test.tsx @@ -13,13 +13,16 @@ import { waitFor, within, } from '@testing-library/react'; +import { useState } from 'react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectExportPackageResult } from '../src/app/types'; -import { GameDistributionPublishPanel } from '../src/components/game-distribution/GameDistributionPublishPanel'; +import { GameDistributionPublishFormView } from '../src/components/game-distribution/GameDistributionPublishFormView'; +import { useGameDistributionPublishForm } from '../src/components/game-distribution/useGameDistributionPublishForm'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { + type GameDistributionPublishResult, generateGameDistributionCover, publishLocalProjectGame, readGameCoverGenerationPrice, @@ -94,13 +97,49 @@ async function selectCover( await screen.findByText(new RegExp(`已选择「${file.name}」`, 'u')); } +/** + * 内联化之后,原弹窗外壳已退役;测试直接挂在现役的内联表单上(去掉面板根那层状态提升), + * 继续覆盖预填、门禁、上传与成功 / 失败边界。 + */ +function PublishFormHarness({ + open, + projectPath, + manifest, + packageResult, + onPublished, +}: { + open: boolean; + projectPath: string; + manifest: GameCreationAppManifest; + packageResult: LocalProjectExportPackageResult | null; + onClose: () => void; + onPublished?: (result: GameDistributionPublishResult) => void; +}) { + const [notice, setNotice] = useState<string | null>(null); + const form = useGameDistributionPublishForm({ + open, + projectPath, + manifest, + packageResult, + onPublished, + // 内联形态在面板根用「发布提示」弹窗承载错误;测试里用一个等价的 alert 节点接住。 + onError: setNotice, + }); + return ( + <> + {notice ? <p role="alert">{notice}</p> : null} + <GameDistributionPublishFormView form={form} /> + </> + ); +} + function renderPanel( - overrides: Partial<Parameters<typeof GameDistributionPublishPanel>[0]> = {}, + overrides: Partial<Parameters<typeof PublishFormHarness>[0]> = {}, ) { const onClose = vi.fn(); const onPublished = vi.fn(); render( - <GameDistributionPublishPanel + <PublishFormHarness open projectPath="/tmp/authorized-game" manifest={MANIFEST} @@ -136,7 +175,7 @@ afterEach(() => { (window.localStorage as Storage | undefined)?.clear(); }); -describe('GameDistributionPublishPanel', () => { +describe('AGC 发布资料表单(内联形态)', () => { test('打开时预填游戏资料且不展示发行包技术摘要', async () => { installTauriInvoke(async () => undefined); renderPanel(); @@ -260,7 +299,7 @@ describe('GameDistributionPublishPanel', () => { ), ).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); @@ -298,7 +337,7 @@ describe('GameDistributionPublishPanel', () => { target: { files: [buildImageFile('shot-1.png')] }, }); await screen.findByRole('button', { name: '删除截图 1' }); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), @@ -335,7 +374,7 @@ describe('GameDistributionPublishPanel', () => { renderPanel(); await selectCover(); - const submit = screen.getByRole('button', { name: '发布游戏' }); + const submit = screen.getByRole('button', { name: '打包并发布' }); fireEvent.click(submit); fireEvent.click(submit); @@ -362,7 +401,7 @@ describe('GameDistributionPublishPanel', () => { renderPanel(); await selectCover(); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); expect( await screen.findByText( @@ -376,7 +415,7 @@ describe('GameDistributionPublishPanel', () => { installTauriInvoke(async () => undefined); renderPanel(); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); expect( await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'), @@ -398,7 +437,7 @@ describe('GameDistributionPublishPanel', () => { }), ); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); @@ -499,7 +538,7 @@ describe('GameDistributionPublishPanel', () => { expect(screen.queryByRole('dialog', { name: '截图预览' })).toBeNull(); }); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); @@ -541,19 +580,23 @@ describe('GameDistributionPublishPanel', () => { expect(publishLocalProjectGame).not.toHaveBeenCalled(); }); - test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => { + test('缺少封面或试玩包时失败关闭且不调用发布接口', async () => { renderPanel(); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); - expect(await screen.findByText('需要在 Tauri App 内发布')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); + // 校验前置:没有封面时在点击当下就给出提示,不进入打包也不提交发布。 + expect( + await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'), + ).not.toBeNull(); expect(publishLocalProjectGame).not.toHaveBeenCalled(); cleanup(); installTauriInvoke(async () => undefined); renderPanel({ packageResult: null }); - expect(screen.getByRole('button', { name: '发布游戏' })).toHaveProperty( - 'disabled', - true, - ); + await selectCover(); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); + // 没有试玩包、也没有打包回调时,同样在点击当下就地给出原因,而不是灰掉按钮。 + expect(await screen.findByText('请先导出有效的试玩包')).not.toBeNull(); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); expect(screen.queryByLabelText('发行包摘要')).toBeNull(); }); }); From 122f8a2c1922d005baf38d6d10f1d972da66344b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:42:24 +0800 Subject: [PATCH 074/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8A=80=E8=83=BD=EF=BC=9A=E6=9B=B4=E6=96=B0=20manife?= =?UTF-8?q?st=20=E6=8C=87=E7=BA=B9=20(version=3D2026-08-26.65)=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=96=87=E6=A1=A3=E6=A0=BC=E5=BC=8F=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../resources/agc-skills/vite-export-xhs-minitool/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 eb8808432..49dbb869f 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.64", + "version": "2026-08-26.65", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "727e519f2f60d0fc999faad994b2e7e32656bf47aabb3b7be6d45e9811333412" + "sha256": "8a096ba5ac398ee45276e47d955691f834ecb0af8facadb43d52186f90458313" } ] } 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 index a426c07dc..769fff1fc 100644 --- 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 @@ -44,7 +44,7 @@ metadata: | 文档 | 何时读 | |-------------------------------------------------------------|--------------------------------------------------------------------------------------------| | [js-api.md](references/js-api.md) | 本地 API 快照; | -| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写/改 `index.html`、选择文件类型或处理脚本外置 / 容器 CSP 时 | +| [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 硬基线、不支持的能力与替代写法 | From 677c477f367ffa80a4eb2de01ce218fa3aaedffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:48:24 +0800 Subject: [PATCH 075/156] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E6=A0=A1=E9=AA=8C=E8=84=9A=E6=9C=AC=E5=A4=B4?= =?UTF-8?q?=E9=83=A8=E8=BF=87=E6=9C=9F=E7=9A=84=E7=9B=AE=E5=BD=95=E6=B8=85?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check-generated-bindings.mjs 文件头从「两套 crate、四个目录」更新为「五个目录」,补上 AGC export/generated --- scripts/check-generated-bindings.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/check-generated-bindings.mjs b/scripts/check-generated-bindings.mjs index 6f89ce888..cc589c469 100644 --- a/scripts/check-generated-bindings.mjs +++ b/scripts/check-generated-bindings.mjs @@ -6,9 +6,10 @@ * 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接—— * 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。 * - * 覆盖两套 crate、四个目录: + * 覆盖两套 crate、五个目录: * - `shared-contracts` → `packages/shared/src/contracts/generated`; - * - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types`。 + * - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types` + + * `export/generated`。 * * 生成文件一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写:这些目录 * 同时登记在 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,所以这里直接逐字节 From 411771af2fb372993b1ed4d72dd97799d0938de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:48:36 +0800 Subject: [PATCH 076/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=E9=9D=A2=E6=9D=BF=E7=BB=9F=E4=B8=80=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E5=90=8E=E7=9A=84=E9=A1=B9=E7=9B=AE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx 新增 exportProjectPath,对 localProject/projectPath 做一次 trim,空白即视作无项目 - onRequestExport 开关与 ArtifactsPanel 的 projectPath 都改用该值,避免纯空白路径照开门 --- apps/ai-game-creator-shell/src/App.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9c4b04cf0..858856187 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1754,6 +1754,12 @@ export function App({ if (directProjectMode) { // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 + // + // 导出入口与面板共用**同一个已裁剪**的路径:`localProject?.projectPath || projectPath` + // 只判真值,纯空白的路径会照开门;面板下游又都用 `Boolean(projectPath)` 当有效判据。 + // 这里统一裁一次,空白即视作「没有项目」。 + const exportProjectPath = + (localProject?.projectPath || projectPath || '').trim() || null; return ( <> <DirectProjectChatView @@ -1761,9 +1767,7 @@ export function App({ ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed} initialTurn={initialDirectTurn} onRequestExport={ - localProject?.projectPath || projectPath - ? () => setExportPanelOpen(true) - : undefined + exportProjectPath ? () => setExportPanelOpen(true) : undefined } onCancelConfirmation={cancelUiCommandConfirmation} onConfirmConfirmation={confirmUiCommand} @@ -1773,7 +1777,7 @@ export function App({ /> {exportPanelOpen ? ( <ArtifactsPanel - projectPath={localProject?.projectPath ?? projectPath ?? null} + projectPath={exportProjectPath} ensureConversationReadAllowed={ensureDirectHistoryReadAllowed} ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed} onClose={() => setExportPanelOpen(false)} From 32d77a0ccda6b9155e95dffbaf3c90e65825b1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:49:00 +0800 Subject: [PATCH 077/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E5=90=8D=E6=8C=89?= =?UTF-8?q?=E5=86=99=E5=85=A5=E5=BA=8F=E5=8F=B7=E5=94=AF=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.rs 的 temp_path 在 pid 之外加入进程内原子自增序号,同一注册表路径的并发写不再共用同一个 .tmp 文件 --- .../src-tauri/src/export/registry.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 7033e8c9c..569758eb9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -8,6 +8,7 @@ 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; @@ -150,9 +151,18 @@ fn temp_path(path: &Path) -> PathBuf { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "registry.json".to_string()); - path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id())) + // 临时文件名必须**每次写都不同**:只用 pid 的话,同一进程里两个并发写共用同一个临时文件, + // 一个会截断另一个正在写的内容,rename 也会互相抢。进程内原子计数器保证不重名。 + let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + path.with_file_name(format!( + ".{file_name}.{}.{sequence}.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::*; From c9d2ed041bfef87024e11383cec32acc69d5970d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:49:28 +0800 Subject: [PATCH 078/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E6=8A=8A=E9=9D=9E=20UTF-8=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=BD=92=E5=85=A5=20malformed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.rs 的 load_or_create 单独处理 ErrorKind::InvalidData,返回 RegistryError::Malformed,不再落进通用 Io 被报成路径/权限故障 - 新增 rejects_non_utf8_content_as_malformed 用例 --- .../src-tauri/src/export/registry.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 index 569758eb9..d7de77b46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -72,6 +72,14 @@ where 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: format!("注册表不是有效 UTF-8:{error}"), + }); + } Err(error) => { return Err(RegistryError::Io { path: path.to_path_buf(), @@ -219,6 +227,21 @@ mod tests { 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"); From 9c48db76b25f53fa6790dc03d31eb7514040c155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:49:45 +0800 Subject: [PATCH 079/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E5=9C=A8=20rename=20=E5=90=8E=20fsync=20=E7=88=B6?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.rs 的 write_atomically 在原子替换后同步父目录,崩溃时目录条目不再可能回退到旧文件 --- .../src-tauri/src/export/registry.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index d7de77b46..656f22d8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -151,7 +151,13 @@ fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> { path: path.to_path_buf(), cause: error.to_string(), } - }) + })?; + // rename 只改目录条目:不 fsync 父目录的话,崩溃后「哪个文件才是新的」这件事可能没落盘, + // 数据虽然在磁盘上,目录项仍是旧的(甚至没有)。Windows 打不开目录句柄,打不开就跳过。 + if let Ok(directory) = fs::File::open(parent) { + let _ = directory.sync_all(); + } + Ok(()) } fn temp_path(path: &Path) -> PathBuf { From 07cc0aa5fdebe1c006c4119f05d05166b95d8b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:49:56 +0800 Subject: [PATCH 080/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20create=5Fnew=20=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.rs 的 write_atomically 不再用会跟随符号链接的 File::create,改用 O_EXCL 打开临时文件,已存在的链接/残留文件直接失败 --- .../ai-game-creator-shell/src-tauri/src/export/registry.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 656f22d8b..cbf66fa52 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -133,7 +133,12 @@ fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> { })?; let temp = temp_path(path); let write = || -> std::io::Result<()> { - let mut file = fs::File::create(&temp)?; + // `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() From dc639d571a988e6d8c676ddb8febb9e820fccdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:50:21 +0800 Subject: [PATCH 081/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=9E=84=E5=BB=BA=E5=89=8D=E6=B8=85=E6=8E=89=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E7=9A=84=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build.rs 的 run 在启动项目脚本前删除 .export/xhs-minitool.zip(不存在则跳过,删不掉则 fail closed) - 避免 no-op 脚本靠上一轮残留的 zip 被判成功 --- .../src/export/draft/xhs_minitool/build.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index ce40ae903..6393efc00 100644 --- 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 @@ -42,6 +42,23 @@ pub(crate) async fn run( } })?; + // 产物判据只看「文件非空」,所以先清掉上一次的 zip:脚本这次什么都没写时,残留的旧 zip 会 + // 让 no-op 脚本被判成功,用户上传的其实是上一轮的产物。删不掉(权限等)就 fail closed—— + // 留着旧产物继续判成功,比这次构建失败更糟。 + 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() + )), + )); + } + } + let result = run_project_verification_with_commit_at( root, SCRIPT_NAME, From eee12b28099b229eeb42a2d4c1e3ce28d450f5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:50:44 +0800 Subject: [PATCH 082/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=BA=A7=E7=89=A9=E5=88=A4=E6=8D=AE=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20symlink=5Fmetadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build.rs 的 conclude 不再跟随符号链接,链接与非普通文件不计入「已产出」 - 新增 a_symlinked_artifact_does_not_count 用例(仅 unix) --- .../src/export/draft/xhs_minitool/build.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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 index 6393efc00..060af0d67 100644 --- 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 @@ -95,8 +95,13 @@ fn conclude( let path = artifact_path(root); let produced = matches!( - std::fs::metadata(&path), - Ok(metadata) if metadata.is_file() && metadata.len() > 0 + 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 的参数没对齐。 @@ -232,6 +237,25 @@ mod tests { )); } + #[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, "构建完成"), + Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) + ), + "指向项目外非空文件的链接不能算产出成功" + ); + } + #[test] fn a_successful_script_with_a_non_empty_artifact_returns_the_output_tail() { let root = fixture_root("ok"); From 9722b42692e7e8e538e9ede8ec5cdd715ff0e79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:51:02 +0800 Subject: [PATCH 083/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=A4=B1=E8=B4=A5=E6=97=A5=E5=BF=97=E6=8C=89=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E6=95=B0=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commands.rs 的 log_failure 落盘前经 bounded_for_log 截到 512 字符,CommandFailed 的 4KB 脚本输出不再整段写进 AppData 日志与去重表 - 新增 bounded_for_log 的短文本与中文边界用例 --- .../src/export/draft/xhs_minitool/commands.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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 index beb4b8257..9e17d5972 100644 --- 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 @@ -151,8 +151,15 @@ fn report<T, E: std::fmt::Debug>(command: &'static str, project_path: &str, resu } } +/// 一条失败日志最多记多少个字符。 +/// +/// `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 = format!("{error:?}"); + let message = bounded_for_log(&format!("{error:?}"), MAX_LOGGED_FAILURE_CHARS); if let Ok(mut last) = LAST_FAILURE.lock() { if last.get(command).is_some_and(|seen| *seen == message) { return; @@ -162,6 +169,15 @@ fn log_failure(command: &'static str, project_path: &str, error: &impl std::fmt: 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) { if let Ok(mut last) = LAST_FAILURE.lock() { last.remove(command); @@ -176,7 +192,7 @@ fn short(hash: &str) -> &str { #[cfg(test)] mod tests { - use super::short; + use super::{bounded_for_log, short}; #[test] fn short_keeps_first_eight_characters() { @@ -184,4 +200,11 @@ mod tests { 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), "一二三四…"); + } } From e96d38a017fde8ed58e75e716c602b0af6772b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:51:16 +0800 Subject: [PATCH 084/156] =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=80=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E7=BB=99=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=8A=A0=E8=B6=85=E6=97=B6=E5=9B=B4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthenticatedClient 的 hydrateClientServerSelection 改走 withAuthCheckTimeout,IPC 卡住时不再让界面无限停在「刷新登录状态」 --- .../ai-game-creator-shell/src/app/AuthenticatedClient.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index dcb003db5..31c4e82b4 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -226,7 +226,13 @@ export function AuthenticatedClient({ try { // 服务器选择先于认证读取落盘值:Rust 按 `expectedApiBaseUrl` 判定凭据归属,竞态下用 // 渠道默认值会被当成「凭据属于另一个 origin」而误报未登录,自定义服务器每次启动都要重登。 - const selection = await hydrateClientServerSelection(); + // 读服务器偏好同样是一次 IPC:不设围栏时,它卡住会让界面永远停在「刷新登录状态」, + // 既没有超时提示也没有重试路径(`hydrateClientServerSelection` 自己吞错、不会 reject)。 + const selection = await withAuthCheckTimeout( + hydrateClientServerSelection(), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取服务器偏好超时,请重试或重启客户端', + ); if (!isActiveRun()) return; setServerSelection(selection); setCustomServerUrl(selection.customBaseUrl); From 3061762aaa94fe6553fe4d1749488705d9d185e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:52:01 +0800 Subject: [PATCH 085/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E6=8D=A2=E9=A1=B9=E7=9B=AE=E6=97=B6=E4=B8=80=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E5=B0=81=E9=9D=A2=E4=B8=8E=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGameDistributionPublishForm 的重置 effect 现在清空 cover / screenshots / 上传缓存,并回收已登记的预览 blob 地址 - 抽出 revokeTrackedObjectUrls 供重置与卸载共用 --- .../useGameDistributionPublishForm.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 2723cfc0c..2347ff221 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -86,6 +86,18 @@ const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; const COVER_GENERATION_ASPECT_RATIO = '16:9'; const COVER_GENERATION_IMAGE_SIZE = '2K'; +/** 释放一组已登记的预览 blob 地址;重置与卸载共用,避免两处各写一遍。 */ +function revokeTrackedObjectUrls(objectUrls: Set<string>) { + objectUrls.forEach((url) => { + try { + URL.revokeObjectURL(url); + } catch { + // 预览地址释放失败不影响重置 / 卸载。 + } + }); + objectUrls.clear(); +} + function buildPublishProjectContext(manifest: GameCreationAppManifest) { const taskSummary = (manifest.tasks ?? []) .slice(0, 16) @@ -224,7 +236,13 @@ export function useGameDistributionPublishForm({ setGeneratingCover(false); setCoverGenerationPrice(null); setCoverGenerationDetail(''); + // 表单在换项目时重来,封面与截图也必须跟着清:素材是「项目 A 的公开展示物料」, + // 留着会被当成项目 B 的封面 / 截图提交。缓存与预览地址一并回收。 + setCover(null); + setScreenshots([]); setPreviewScreenshot(null); + uploadedAssetCacheRef.current.clear(); + revokeTrackedObjectUrls(objectUrlsRef.current); summaryTouchedRef.current = false; categoryTouchedRef.current = false; @@ -282,14 +300,7 @@ export function useGameDistributionPublishForm({ useEffect(() => { const objectUrls = objectUrlsRef.current; return () => { - objectUrls.forEach((url) => { - try { - URL.revokeObjectURL(url); - } catch { - // 预览地址释放失败不影响关闭面板。 - } - }); - objectUrls.clear(); + revokeTrackedObjectUrls(objectUrls); }; }, []); From f4b1a6ad99916347df24d1e6ede0191f1854c6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:52:09 +0800 Subject: [PATCH 086/156] =?UTF-8?q?=E6=89=93=E5=8C=85=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=8E=A5=E4=BD=8F=20onEnsurePackage=20=E7=9A=84=20rej?= =?UTF-8?q?ect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGameDistributionPublishForm 的 handlePackageAndPublish 用 try/catch 包住 onEnsurePackage,异常经 reportError 走发布提示弹窗,不再变成未处理的 promise rejection --- .../useGameDistributionPublishForm.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 2347ff221..f890bb928 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -602,7 +602,18 @@ export function useGameDistributionPublishForm({ reportError('请先导出有效的试玩包'); return; } - pkg = await onEnsurePackage(); + // 现有实现自己吞错并返回 null,但契约没保证回调一定不 reject;reject 时不接住会变成 + // 未处理的 promise rejection——按钮弹回、用户看不到任何失败说明。 + try { + pkg = await onEnsurePackage(); + } catch (ensureError) { + reportError( + ensureError instanceof Error + ? ensureError.message + : String(ensureError), + ); + return; + } if (!pkg) return; } await submitPackage(pkg); From 10ca2341a18039ebf02dfd6effa2e4d094a9c349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:52:39 +0800 Subject: [PATCH 087/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E8=A2=AB=E6=9B=BF=E6=8D=A2=E6=88=96=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E7=9A=84=E5=B0=81=E9=9D=A2=E9=A2=84=E8=A7=88=E5=9C=B0?= =?UTF-8?q?=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGameDistributionPublishForm 新增 previousCoverRef 与跟随 cover 的 effect,换封面 / 移除封面 / AI 生成覆盖时释放上一张的 blob 地址 --- .../useGameDistributionPublishForm.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index f890bb928..3584d74f1 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -215,6 +215,31 @@ export function useGameDistributionPublishForm({ // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 const uploadedAssetCacheRef = useRef(new Map<string, string>()); const objectUrlsRef = useRef(new Set<string>()); + /** 上一张封面:替换 / 移除 / AI 生成覆盖时用来回收它的预览 blob 地址。 */ + const previousCoverRef = useRef<PanelImageAsset | null>(null); + + /** + * 封面预览地址跟着封面走:换封面、移除封面、AI 生成覆盖都会丢掉上一张的 blob 地址, + * 不在这里统一回收就会一路泄漏到面板卸载(卸载清理只认 `objectUrlsRef`)。 + */ + useEffect(() => { + const previous = previousCoverRef.current; + previousCoverRef.current = cover; + if ( + !previous || + previous === cover || + !previous.previewUrl || + !objectUrlsRef.current.has(previous.previewUrl) + ) { + return; + } + try { + URL.revokeObjectURL(previous.previewUrl); + } catch { + // 预览地址释放失败不影响换封面 / 移除封面。 + } + objectUrlsRef.current.delete(previous.previewUrl); + }, [cover]); useEffect(() => { if (!open) return; From 49d6f8db2f8d460636e22383cba69b6b39210899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:52:50 +0800 Subject: [PATCH 088/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E6=88=AA=E5=9B=BE=E9=A2=84=E8=A7=88=E5=9C=B0?= =?UTF-8?q?=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGameDistributionPublishForm 的 createPanelScreenshotEntry 把生成的 blob 地址加入 objectUrlsRef,删除截图与卸载时的回收分支终于能命中 --- .../game-distribution/useGameDistributionPublishForm.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 3584d74f1..d419eed5e 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -428,10 +428,14 @@ export function useGameDistributionPublishForm({ function createPanelScreenshotEntry(file: File): PanelScreenshotEntry { const fileError = resolvePanelImageFileError(file, 'screenshot'); + const previewUrl = buildPanelImagePreviewUrl(file); + // 与封面走同一条登记:不登记的话 `removeScreenshot` 的 revoke 分支永远命中不了, + // 卸载清理也遍历不到这些地址,截图预览会一直泄漏到页面关闭。 + if (previewUrl) objectUrlsRef.current.add(previewUrl); return { id: createPanelScreenshotId(), name: file.name.trim() || '游戏截图', - previewUrl: buildPanelImagePreviewUrl(file), + previewUrl, assetObjectId: null, status: fileError ? 'failed' : 'uploading', error: fileError || null, From a3af8a60a4707aa1d174dd3e691c94c6d274fe90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:53:10 +0800 Subject: [PATCH 089/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E7=AE=80=E4=BB=8B=E6=8F=90=E7=A4=BA=E8=AF=AD=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E7=BA=AF=E5=87=BD=E6=95=B0=E9=80=89=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameDistributionPublishFormView 抽出 resolveMetadataHint(生成中 / AI 已生成 / 本地兜底),替换原来的三层嵌套三元 --- .../GameDistributionPublishFormView.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 index 0193cd116..91d42eed8 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -11,6 +11,17 @@ import { PANEL_IMAGE_ACCEPT, } from './useGameDistributionPublishForm'; +/** + * 简介 / 分类提示语:生成中、AI 已生成、本地兜底三种状态各一句话。 + * + * 拆成纯函数是为了让这段文案选路不埋在三层嵌套三元里——读的人要的是「现在处于哪种状态」。 + */ +function resolveMetadataHint(pending: boolean, ready: boolean) { + if (pending) return '正在根据创作内容生成简介和分类…'; + if (ready) return 'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。'; + return '已根据创作内容预填简介和分类,免费;你可以直接修改。'; +} + /** * 发布资料表单的表现层(工作台内联形态)。 * @@ -111,11 +122,10 @@ export function GameDistributionPublishFormView({ className="game-distribution-publish-panel__metadata-hint" role="status" > - {metadataSuggestionPending - ? '正在根据创作内容生成简介和分类…' - : metadataSuggestionReady - ? 'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。' - : '已根据创作内容预填简介和分类,免费;你可以直接修改。'} + {resolveMetadataHint( + metadataSuggestionPending, + metadataSuggestionReady, + )} </span> </div> <div From 2b8bcd68c5b939aabef73f6d17279ba0e340336c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:53:32 +0800 Subject: [PATCH 090/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E4=B8=BB=E8=A1=8C?= =?UTF-8?q?=E5=8A=A8=E6=8C=89=E9=92=AE=E6=96=87=E6=A1=88=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E7=BA=AF=E5=87=BD=E6=95=B0=E9=80=89=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameDistributionPublishFormView 抽出 resolvePublishActionLabel(打包 / 上传 / 生成封面 / 上传截图 / 上传素材 / 常态),替换原来的五层嵌套三元 --- .../GameDistributionPublishFormView.tsx | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) 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 index 91d42eed8..2f0c5bcb3 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -22,6 +22,24 @@ function resolveMetadataHint(pending: boolean, ready: boolean) { return '已根据创作内容预填简介和分类,免费;你可以直接修改。'; } +/** + * 主行动按钮文案:打包 / 上传 / 生成封面 / 上传截图 / 上传素材 依次优先,都没有才是常态。 + */ +function resolvePublishActionLabel(state: { + 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 '素材上传中…'; + return '打包并发布'; +} + /** * 发布资料表单的表现层(工作台内联形态)。 * @@ -388,17 +406,13 @@ export function GameDistributionPublishFormView({ onClick={() => void handlePackageAndPublish()} className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-6 py-4 text-lg font-bold tracking-wide text-white shadow-md transition enabled:hover:bg-[#b4562f] disabled:cursor-not-allowed disabled:opacity-45" > - {packaging - ? '正在打包…' - : busy - ? '上传中…' - : generatingCover - ? '封面生成中…' - : screenshotsUploading - ? '截图上传中…' - : uploadingLabel - ? '素材上传中…' - : '打包并发布'} + {resolvePublishActionLabel({ + packaging, + busy, + generatingCover, + screenshotsUploading, + uploadingLabel, + })} </button> </> )} From 761dced565252953a6550f407a36603abb90ad1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:53:48 +0800 Subject: [PATCH 091/156] =?UTF-8?q?PaneButton=20=E8=AF=AD=E6=B0=94?= =?UTF-8?q?=E7=B1=BB=E5=90=8D=E6=94=B9=E7=94=A8=E6=9F=A5=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export/tabs/common/PaneButton.tsx 用 TONE_CLASS 映射替换 tone 的嵌套三元 --- .../export/tabs/common/PaneButton.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx index 62dfaed32..aa350a7b6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx @@ -8,6 +8,16 @@ import type { ReactNode } from 'react'; * 重试),尺寸跟发布面板(`GameDistributionPublishPanel`)对齐:10px/14px 内边距、10px 圆角、 * 600 字重。 */ +/** + * 语气 → 类名。用查表而不是嵌套三元:加第四种语气时只加一行,也不用再读一层分支。 + */ +const TONE_CLASS: Record<'primary' | 'ghost' | 'brand', string> = { + brand: 'border-[#ff2442] bg-[#ff2442] text-white hover:bg-[#e01e3a]', + primary: 'border-[#c7653d] bg-[#c7653d] text-white hover:bg-[#b4562f]', + ghost: + 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]', +}; + export function PaneButton({ children, variant = 'ghost', @@ -27,12 +37,7 @@ export function PaneButton({ className?: string; onClick: () => void; }) { - const tone = - variant === 'brand' - ? 'border-[#ff2442] bg-[#ff2442] text-white hover:bg-[#e01e3a]' - : variant === 'primary' - ? 'border-[#c7653d] bg-[#c7653d] text-white hover:bg-[#b4562f]' - : 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'; + const tone = TONE_CLASS[variant]; const sizing = size === 'hero' ? 'w-full justify-center gap-2 rounded-xl px-6 py-4 text-lg font-bold tracking-wide shadow-md' From b77ebbc1b2f8ba69c76baf23466bd127944daebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:54:12 +0800 Subject: [PATCH 092/156] =?UTF-8?q?PaneButton=20=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E7=B1=BB=E5=90=8D=E6=94=B9=E7=94=A8=E6=9F=A5=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export/tabs/common/PaneButton.tsx 用 SIZING_CLASS 映射替换 sizing 的嵌套三元 --- .../export/tabs/common/PaneButton.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx index aa350a7b6..2003bc7d5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx @@ -18,6 +18,13 @@ const TONE_CLASS: Record<'primary' | 'ghost' | 'brand', string> = { 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]', }; +/** 尺寸 → 类名,理由同 [`TONE_CLASS`]。 */ +const SIZING_CLASS: Record<'sm' | 'lg' | 'hero', string> = { + hero: 'w-full justify-center gap-2 rounded-xl px-6 py-4 text-lg font-bold tracking-wide shadow-md', + lg: 'justify-center gap-2 rounded-xl px-6 py-3 text-base font-semibold shadow-sm', + sm: 'gap-1.5 rounded-[10px] px-3.5 py-2 text-xs font-semibold', +}; + export function PaneButton({ children, variant = 'ghost', @@ -38,12 +45,7 @@ export function PaneButton({ onClick: () => void; }) { const tone = TONE_CLASS[variant]; - const sizing = - size === 'hero' - ? 'w-full justify-center gap-2 rounded-xl px-6 py-4 text-lg font-bold tracking-wide shadow-md' - : size === 'lg' - ? 'justify-center gap-2 rounded-xl px-6 py-3 text-base font-semibold shadow-sm' - : 'gap-1.5 rounded-[10px] px-3.5 py-2 text-xs font-semibold'; + const sizing = SIZING_CLASS[size]; return ( <button type="button" From 6c540e067132feb8f07124f8ab7dc0ba1ed39311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:54:51 +0800 Subject: [PATCH 093/156] =?UTF-8?q?=E9=99=B6=E6=B3=A5=E5=84=BF=E6=89=93?= =?UTF-8?q?=E5=8C=85=E5=9B=9E=E6=9D=A5=E5=90=8E=E5=AF=B9=E8=B4=A6=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTaonierTab 新增 projectPathRef 与 isCurrentProject 判据,ensurePackage 的 await 返回后只有仍是当前项目才写 progress / packageResult,失败路径同样对账 --- .../export/state/useTaonierTab.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index e5b9b2a85..a8059685f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -86,6 +86,12 @@ export function useTaonierTab({ useDirectProjectManifest(projectPath); const manifestRef = useRef<GameCreationAppManifest | null>(manifest); manifestRef.current = manifest; + /** + * 当前项目路径。`ensurePackage` 在 await 之后要对账「这次打包还是不是当前项目的」, + * 但它闭包里的 `projectPath` 可能已经过期(面板跨项目切换时不会卸载)。 + */ + const projectPathRef = useRef(projectPath); + projectPathRef.current = projectPath; const [allowed, setAllowed] = useState(false); const [availabilityPending, setAvailabilityPending] = useState(true); @@ -175,6 +181,13 @@ export function useTaonierTab({ const ensurePackage = useCallback(async (): Promise<LocalProjectExportPackageResult | null> => { const currentProjectPath = projectPath?.trim() ?? ''; + /** + * 这次打包发起时的项目是不是还是当前项目:await 回来后 `setPackageResult` 只能写给 + * 发起的那个项目,否则旧项目的包会落进新项目的面板,被发布流程当成新项目的产物。 + */ + const isCurrentProject = () => + mountedRef.current && + (projectPathRef.current?.trim() ?? '') === currentProjectPath; if (!currentProjectPath) { setBlockedMessage('先打开一个项目再发布'); return null; @@ -204,13 +217,13 @@ export function useTaonierTab({ 'export_local_project_package', { projectPath: currentProjectPath }, ); - if (!mountedRef.current) return null; + if (!isCurrentProject()) return null; setProgress(null); setPackageResult(result); appendPublishPermissionLog(currentProjectPath); return result; } catch (error) { - if (!mountedRef.current) return null; + if (!isCurrentProject()) return null; setProgress({ phase: 'failed', message: '发布失败', From abd27834d9df2d6384ebed89f7a3a4f6ee4b3bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:55:08 +0800 Subject: [PATCH 094/156] =?UTF-8?q?=E9=99=B6=E6=B3=A5=E5=84=BF=20tab=20?= =?UTF-8?q?=E6=8D=A2=E9=A1=B9=E7=9B=AE=E6=97=B6=E9=87=8D=E7=BD=AE=E6=89=93?= =?UTF-8?q?=E5=8C=85=E7=BB=93=E6=9E=9C=E4=B8=8E=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTaonierTab 新增以 projectPath 为依赖的 effect,清空 packageResult / progress / blockedMessage,新项目不再复用旧项目的试玩包 --- .../export/state/useTaonierTab.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index a8059685f..d386e1fef 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -130,6 +130,19 @@ export function useTaonierTab({ }; }, [enabled, projectPath]); + /** + * 换项目时清掉上一次项目的打包结果、进度与阻断提示。 + * + * 面板跨项目切换时不会卸载(同一份 `usePanel` 状态继续用),而发布表单在 + * `manifest?.projectId` 变化时会重新初始化并复用现成的 `packageResult`——不清的话, + * 新项目会带着旧项目的 `packageRelativePath` 直接被发布出去。与上面的灰度重置同一口径。 + */ + useEffect(() => { + setPackageResult(null); + setProgress(null); + setBlockedMessage(null); + }, [projectPath]); + const [runtimeNotice, setRuntimeNotice] = useState(''); /** * 输入草稿提升到面板层:Composer 随 tab 页卸载后,正文不再随组件一起丢。 From 5a202b091a577f236444e13270b182fd00fd3446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:55:30 +0800 Subject: [PATCH 095/156] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E7=9A=84=20XHS=5FMINITOOL=5FFIELD=5FLABELS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export/state/xhsMinitoolFields.ts 移除与 XHS_MINITOOL_FORM_FIELDS 重复且全仓无引用的标签表 --- .../project-development/export/state/xhsMinitoolFields.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts index 76f3f1d9e..5900874de 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts @@ -43,10 +43,3 @@ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ copyable: false, }, ]; - -export const XHS_MINITOOL_FIELD_LABELS: Record<XHSMiniToolExportField, string> = - { - name: '小工具名称', - introduction: '一句话介绍', - iconPath: '图标', - }; From ee8ad27c2fb9413c48155c1b9ff2bec20a59e86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:55:41 +0800 Subject: [PATCH 096/156] =?UTF-8?q?=E4=BF=AE=E6=8E=89=E3=80=8C=E5=B8=AE?= =?UTF-8?q?=E6=88=91=E5=A1=AB=E3=80=8D=E6=8C=87=E4=BB=A4=E9=87=8C=20JSON?= =?UTF-8?q?=20=E5=9D=97=E5=90=8E=E7=9A=84=E5=A4=9A=E4=BD=99=E9=80=97?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export/state/xhsMinitoolInstruction.ts 的 buildXhsMinitoolFormFillInstruction 去掉形状示意后的尾随逗号,不再在 JSON 与下一条要点之间留一行 ',' --- .../project-development/export/state/xhsMinitoolInstruction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts index f3a596c25..ffcea1290 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts @@ -88,7 +88,7 @@ export function buildXhsMinitoolFormFillInstruction(): string { * 直接修改文件,改完不必重述. * 结构和限制: -${JSON.stringify(XHS_MINITOOL_REGISTRY_SHAPE_HINT, null, 2)}, +${JSON.stringify(XHS_MINITOOL_REGISTRY_SHAPE_HINT, null, 2)} * 对于图标示例图等字段可以看看目前是否已经有合适的, 没有的话可以(征询用户意见后)生图/留给用户自己选择 `; From 01b068decf422ef796d1ea71218cffa0bc4c0e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:55:56 +0800 Subject: [PATCH 097/156] =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E6=8A=8A=E6=8C=89=E9=92=AE=E6=96=87=E6=A1=88=E4=B8=8E=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=A4=84=E7=90=86=E7=BB=91=E5=9C=A8=E4=B8=80=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FailureDialog 的 agentAction 改为 { label, onClick },不再按中文文案反查 onAdapt / onRepair --- .../export/tabs/xiaohongshu/FailureDialog.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx index da6d7e4ba..009cfe802 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx @@ -38,13 +38,14 @@ export function FailureDialog({ onDismiss: () => void; }) { const repairability = failure ? classifyXhsMinitoolFailure(failure) : null; + // 文案与点击处理一起定:只存一句中文再按它反查 handler 的话,改文案就会静默接错动作。 const agentAction = !failure || repairability === XhsMinitoolFailureRepairability.OnlyHumanRepairable ? null : failure.type === 'buildScriptMissing' - ? '让陶泥儿帮我调通' - : '让陶泥儿来修'; + ? { label: '让陶泥儿帮我调通', onClick: onAdapt } + : { label: '让陶泥儿来修', onClick: onRepair }; return ( <ThemedModal @@ -61,11 +62,8 @@ export function FailureDialog({ <div className="mt-4 flex flex-wrap justify-end gap-2"> <PaneButton onClick={onDismiss}>关闭</PaneButton> {agentAction === null ? null : ( - <PaneButton - variant="primary" - onClick={agentAction === '让陶泥儿帮我调通' ? onAdapt : onRepair} - > - {agentAction} + <PaneButton variant="primary" onClick={agentAction.onClick}> + {agentAction.label} </PaneButton> )} </div> From 433baadb4b9176ab6d070b9902566719488311e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:56:16 +0800 Subject: [PATCH 098/156] =?UTF-8?q?=E5=86=B2=E7=AA=81=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E7=9A=84=E5=8D=95=E9=80=89=E9=A1=B9=E8=A1=A5=E4=B8=8A=E5=90=8C?= =?UTF-8?q?=E7=BB=84=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConflictCard 的 Choice 接收 name(取字段身份),同一字段的两个 radio 成为同一原生单选组,恢复方向键切换与读屏分组播报 --- .../export/tabs/xiaohongshu/ConflictCard.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx index d5c32e619..8c2e6c556 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx @@ -59,6 +59,7 @@ export function ConflictCard({ <Choice label={meta.label} caption="我的" + name={meta.field} selected={(choice[meta.field] ?? 'mine') === 'mine'} value={mine[meta.field]} onSelect={() => @@ -71,6 +72,7 @@ export function ConflictCard({ <Choice label={meta.label} caption="项目里的" + name={meta.field} selected={choice[meta.field] === 'file'} value={conflict.current[meta.field]} onSelect={() => @@ -96,12 +98,15 @@ export function ConflictCard({ function Choice({ label, caption, + name, selected, value, onSelect, }: { label: string; caption: string; + /** 同一字段的两个选项共用一个 name,方向键才能在同一组单选里移动。 */ + name: string; selected: boolean; value: string; onSelect: () => void; @@ -115,6 +120,7 @@ function Choice({ <span className="flex items-center gap-1 text-[#988476]"> <input type="radio" + name={name} aria-label={`${label}:${caption}`} checked={selected} className="accent-[#c7653d]" From f27aface7a03fa97767886db09f9e04baa9f90a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:56:44 +0800 Subject: [PATCH 099/156] =?UTF-8?q?=E5=88=A0=E9=99=A4=20FormCard=20?= =?UTF-8?q?=E6=97=A0=E4=BA=BA=E4=BC=A0=E5=85=A5=E7=9A=84=20action=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export/tabs/xiaohongshu/FormCard.tsx 的 FieldRow 移除 action prop 及其渲染分支与 ReactNode 导入 --- .../project-development/export/tabs/xiaohongshu/FormCard.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index b3a1a9784..564948380 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -1,5 +1,4 @@ import { Download, Images, Sparkles } from 'lucide-react'; -import type { ReactNode } from 'react'; import { useId, useState } from 'react'; import { AssetImporter } from '../../../../../components/AssetImporter'; @@ -112,7 +111,6 @@ function FieldRow({ copyable, value, error, - action, onChange, }: { label: string; @@ -122,7 +120,6 @@ function FieldRow({ copyable: boolean; value: string; error: string | null; - action?: ReactNode; onChange: (value: string) => void; }) { const id = useId(); @@ -168,7 +165,6 @@ function FieldRow({ onChange={(event) => onChange(event.target.value)} /> )} - {action ? <div className="flex">{action}</div> : null} {error ? ( <p role="alert" className="text-xs font-medium text-[#c7653d]"> {error} From b9c0cd369ab612026123bdf75d0f8f0d103c9ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 18:58:03 +0800 Subject: [PATCH 100/156] =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=9A=84=E6=97=A0=E9=9A=9C=E7=A2=8D=E5=90=8D=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export/tabs/xiaohongshu/CopyButton.tsx 的 aria-label 在状态词与字段名之间补空格,不再读成「已复制小工具名称」这类连读短语 --- .../export/tabs/xiaohongshu/CopyButton.tsx | 2 +- .../tests/xhsMinitoolExport.test.tsx | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx index 9c0d55fc7..faa8435bd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx @@ -27,7 +27,7 @@ export function CopyButton({ value, label }: { value: string; label: string }) { return ( <button type="button" - aria-label={`${text}${label}`} + aria-label={`${text} ${label}`} title={ state === 'failed' ? `复制${label}失败,请手动复制` : `复制${label}` } diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index ba30ec15d..2e9c6c3f3 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -214,32 +214,34 @@ describe('面板左半', () => { it('复制按钮把字段值写进剪贴板', async () => { renderPane(); - fireEvent.click(screen.getByRole('button', { name: '复制小工具名称' })); + fireEvent.click(screen.getByRole('button', { name: '复制 小工具名称' })); await waitFor(() => expect(writeText).toHaveBeenCalledWith('小工具')); // 成功态也写进无障碍名,用户与读屏都看得到「已复制」。 - await screen.findByRole('button', { name: '已复制小工具名称' }); + await screen.findByRole('button', { name: '已复制 小工具名称' }); }); it('复制失败不静默吞掉,按钮自己说一声', async () => { vi.mocked(writeText).mockRejectedValue(new Error('剪贴板不可用')); renderPane(); - fireEvent.click(screen.getByRole('button', { name: '复制小工具名称' })); + fireEvent.click(screen.getByRole('button', { name: '复制 小工具名称' })); - await screen.findByRole('button', { name: '复制失败小工具名称' }); + await screen.findByRole('button', { name: '复制失败 小工具名称' }); }); it('路径不给复制入口:图标与代码包都只给下载', () => { renderPane(fakeMinitool({ artifactReady: true })); // 图标是文件、代码包是压缩包,把项目内路径复制出去没有意义。 - expect(screen.queryByRole('button', { name: '复制图标' })).toBeNull(); - expect(screen.queryByRole('button', { name: '复制代码包位置' })).toBeNull(); + expect(screen.queryByRole('button', { name: '复制 图标' })).toBeNull(); + expect( + screen.queryByRole('button', { name: '复制 代码包位置' }), + ).toBeNull(); expect(screen.queryByText(XHS_MINITOOL_ARTIFACT_RELATIVE_PATH)).toBeNull(); // 名称与介绍仍可复制:它们是要粘贴到小红书上传页的文字。 - screen.getByRole('button', { name: '复制小工具名称' }); - screen.getByRole('button', { name: '复制一句话介绍' }); + screen.getByRole('button', { name: '复制 小工具名称' }); + screen.getByRole('button', { name: '复制 一句话介绍' }); // 两个下载入口都还在。 screen.getByRole('button', { name: '下载图标文件' }); screen.getByRole('button', { name: '下载代码包' }); From 499b247233753b04629384008ee85a5e7e9e28f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:02:11 +0800 Subject: [PATCH 101/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E5=B7=A6=E5=8D=8A=E8=A1=A5=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=A4=96=E9=93=BE=E5=B9=B6=E5=B1=85=E4=B8=AD=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E8=A1=8C=E5=8A=A8=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export/tabs/xiaohongshu/ArtifactsPane.tsx 新增「前往小红书发布/更新」外链,点击经 opener 插件的 openUrl 交给系统默认浏览器打开小红书创作平台,失败时留 console 痕迹 - 该外链改用主题陶泥儿橙 #c7653d 与常驻下划线,并补齐 hover、active 与键盘焦点态,按下有反馈且不再用蓝色 - 「下载代码包」标签与发布外链各自居中,下载按钮仍与「打包」同宽 --- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index dda088946..9f5fa8166 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -1,3 +1,5 @@ +import { openUrl } from '@tauri-apps/plugin-opener'; + import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction'; @@ -119,7 +121,7 @@ export function ArtifactsPane({ {minitool.artifactReady ? ( <PaneButton ariaLabel="下载代码包" - className="self-start" + className="justify-center" onClick={downloadArtifact} > 下载代码包 @@ -136,6 +138,25 @@ export function ArtifactsPane({ onResolve={minitool.resolveConflict} /> ) : null} + + {/* + 发布入口是外链:登录态和发布 / 更新流程都在小红书创作平台上,交给系统默认浏览器打开, + 不在应用内嵌 WebView 里做;与顶栏「在浏览器打开」同属用户主动动作。样式按超链接做: + 陶泥儿橙、常驻下划线,hover / active / 焦点态都跟着主题色走。 + */} + <button + type="button" + className="cursor-pointer self-center border-0 bg-transparent p-0 text-xs font-semibold text-[#c7653d] underline underline-offset-2 transition-colors hover:text-[#b4562f] active:text-[#3d1f10] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c7653d]" + onClick={() => { + void openUrl('https://creator.xiaohongshu.com/new/red-app').catch( + (error) => { + console.error('[agc] 打开小红书发布页失败', error); + }, + ); + }} + > + 前往小红书发布/更新 + </button> </div> <AdaptPromptDialog From 1897bf54d0991b12716031719d2a21dbe5aff7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:05:22 +0800 Subject: [PATCH 102/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E9=A1=B6=E9=83=A8=E6=A7=BD=E4=BD=8D=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20if/else=20=E6=91=8A=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArtifactsPane 把表单 / 读取中 / 读失败留白 / 自动重试的四路链式三元抽成 formSlot,JSX 只剩一个插槽 --- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 9f5fa8166..9ed57f0ac 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -1,4 +1,5 @@ import { openUrl } from '@tauri-apps/plugin-opener'; +import type { ReactNode } from 'react'; import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; @@ -69,29 +70,38 @@ export function ArtifactsPane({ }); }; + /** + * 左半顶部槽位:整张表单 / 一行读取中 / 什么都不放(读失败由弹窗拿着现场)/ 一行自动重试兜底。 + * 四种形状差得远,用 if/else 摊开算好,JSX 里只留一个 `{formSlot}`,别把控制流埋进链式三元。 + */ + let formSlot: ReactNode = null; + if (form) { + formSlot = ( + <FormCard + form={form} + fieldError={minitool.fieldError} + projectPath={projectPath} + onChange={minitool.updateField} + onDownloadIcon={downloadIcon} + onAskAgentToFill={() => void minitool.fillForm()} + /> + ); + } else if (!minitool.readSettled) { + formSlot = <p className="text-xs text-[#988476]">正在读取导出信息…</p>; + } else if (!failure) { + // 读失败时上面不重复一句话:失败弹窗拿着宿主的现场和下一步。 + formSlot = ( + <p className="text-xs text-[#988476]">没能读到导出信息,正在自动重试…</p> + ); + } + return ( <div className="flex h-full min-h-0 min-w-0 flex-col border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r" aria-label="导出内容区" > <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3"> - {form ? ( - <FormCard - form={form} - fieldError={minitool.fieldError} - projectPath={projectPath} - onChange={minitool.updateField} - onDownloadIcon={downloadIcon} - onAskAgentToFill={() => void minitool.fillForm()} - /> - ) : !minitool.readSettled ? ( - <p className="text-xs text-[#988476]">正在读取导出信息…</p> - ) : // 读失败时上面不重复一句话:失败弹窗拿着宿主的现场和下一步。 - failure ? null : ( - <p className="text-xs text-[#988476]"> - 没能读到导出信息,正在自动重试… - </p> - )} + {formSlot} <section className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4" From 065a193e4e6d65b4d31ee27f4dbb9dae3b55951d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:23:14 +0800 Subject: [PATCH 103/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=BB=8E=E7=B4=A2=E5=BC=95=E3=80=81checkpoin?= =?UTF-8?q?t=20=E4=B8=8E=20git=20=E6=A3=80=E6=9F=A5=E4=B8=AD=E6=8E=92?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filesystem 新增 should_skip_project_derived_path(快照口径 + .export),索引改用它 - checkpoint 恢复与清单过滤改用新口径,.export 不再进 checkpoint - git_inspect 的提交路径校验与工作区路径过滤改用新口径,.export 不再算未跟踪/可提交 - 常量改名 PROJECT_DERIVED_EXCLUDED_COMPONENTS,project_snapshot 测试补双向断言 --- .../src-tauri/src/git_inspect.rs | 6 +-- .../src-tauri/src/project/checkpoint.rs | 4 +- .../src-tauri/src/project/filesystem.rs | 43 ++++++++++++++----- .../src-tauri/src/project_snapshot/tests.rs | 29 ++++++++++++- 4 files changed, 65 insertions(+), 17 deletions(-) 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<Vec<String>, 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<String>, Vec<String>, Vec<Str fn safe_git_path(root: &Path, path: &str) -> Option<String> { 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/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 d7b6ddac4..cf759c0e9 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,37 @@ 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` 的写入门禁,并进去会让首次适配直接卡死。 +pub(crate) fn should_skip_project_derived_path(relative_path: &str) -> bool { + project_snapshot_path_is_excluded(relative_path, false) + || relative_path + .split('/') + .filter(|component| !component.is_empty()) + .any(|component| { + let component = component.to_ascii_lowercase(); + PROJECT_DERIVED_EXCLUDED_COMPONENTS + .iter() + .any(|candidate| *candidate == component) + }) +} + /** * 项目快照同步(上传)的排除口径。 * @@ -492,16 +514,17 @@ 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` 回「不得修改受保护或排除路径」,首次适配直接卡死。 * - * `.export` 是本机派生的导出工作目录:注册表、脚本拷贝、icon 与 zip 产物都能从项目源码重跑 - * 出来,zip 动辄数 MB,因此不进快照同步;换机或恢复后重新适配即可。 + * 也不能只排快照同步:索引会把多 MB 的 zip 整份读进内存算 checksum,checkpoint 会把它复制进 + * `.agent/checkpoints`,git 检查会把它当未跟踪文件放出去,允许提交。所以「把项目内容当数据 + * 来读」的消费者(索引、checkpoint、git 检查)都走 `should_skip_project_derived_path`。 */ -const PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS: &[&str] = &[".export"]; +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 @@ -513,9 +536,9 @@ fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: b PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str()) && !(include_agent_state && component == ".agent") }) || (include_agent_state - && components.iter().any(|component| { - PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS.contains(&component.as_str()) - })) + && components + .iter() + .any(|component| PROJECT_DERIVED_EXCLUDED_COMPONENTS.contains(&component.as_str()))) { return true; } 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 ae318018f..7008d2eee 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 @@ -232,14 +232,39 @@ fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() 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 写不了适配脚本。 + // `.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] From 158fae5879fea955a0339de1f6d8a7906882098f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:23:29 +0800 Subject: [PATCH 104/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E4=BF=9D=E5=AD=98=E8=A1=A5=E4=B8=8A=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=AA=97=E5=8F=A3=E7=9A=84=E6=8E=A5=E5=8F=97=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry::save 在读与写之间加注释:保存由用户手动触发、明知 code agent 在改,接受后写覆盖先写 - 说明不做 CAS 的原因:把 expected_hash 下沉进共享 save 会改所有导出目标的写契约 --- .../src-tauri/src/export/draft/xhs_minitool/registry.rs | 7 +++++++ 1 file changed, 7 insertions(+) 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 index 6453ccd13..9a00a6027 100644 --- 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 @@ -44,6 +44,13 @@ pub(crate) fn save( base_hash: &str, ) -> Result<RegistrySnapshot, XHSMiniToolExportSaveError> { 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 { From 63b7ef366563bd985a9668fc36e4ea1391b9bcd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:23:36 +0800 Subject: [PATCH 105/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=89=E6=9D=A1=E8=AF=BB=E5=86=99=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E6=8C=AA=E5=88=B0=E9=98=BB=E5=A1=9E=E7=BA=BF=E7=A8=8B=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commands 新增 offload:用 tokio::task::spawn_blocking 包住同步文件 IO - read / readHash / save 三条命令改走 offload,JoinError 收口成 ExportUnavailable --- .../src/export/draft/xhs_minitool/commands.rs | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) 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 index 9e17d5972..425a44d40 100644 --- 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 @@ -31,7 +31,11 @@ use super::{build, script}; pub(crate) async fn read_xhs_minitool_export( project_path: String, ) -> Result<XHSMiniToolExportState, XHSMiniToolExportReadError> { - let result = read_content(&project_path); + let result = offload({ + let project_path = project_path.clone(); + move || read_content(&project_path) + }) + .await; report("read", &project_path, &result); result } @@ -43,7 +47,11 @@ pub(crate) async fn read_xhs_minitool_export( pub(crate) async fn read_xhs_minitool_export_hash( project_path: String, ) -> Result<String, XHSMiniToolExportReadError> { - let result = read_content_hash(&project_path); + let result = offload({ + let project_path = project_path.clone(); + move || read_content_hash(&project_path) + }) + .await; report("readHash", &project_path, &result); result } @@ -58,7 +66,13 @@ pub(crate) async fn save_xhs_minitool_export_form( form: XHSMiniToolExportForm, base_hash: String, ) -> Result<String, XHSMiniToolExportSaveError> { - let result = save_form(&project_path, &form, &base_hash); + 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!( @@ -109,6 +123,25 @@ fn project_root(project_path: &str) -> Result<PathBuf, ExportUnavailable> { Ok(root) } +/// 把小块同步 IO 挪到 Tokio 的阻塞线程池。 +/// +/// 读命令每 2 秒被轮询一次,注册表 + `package.json` 探测虽然小,但直接在 `async fn` 里做同步 +/// 文件 IO 会占着 worker 线程,拖慢同一 runtime 上的其它异步任务(构建子进程、SpacetimeDB +/// 客户端)。任务 panic 时 `JoinError` 收口成 `ExportUnavailable`,走各命令既有的失败表。 +async fn offload<T, E, F>(job: F) -> Result<T, E> +where + T: Send + 'static, + E: From<ExportUnavailable> + Send + 'static, + F: FnOnce() -> Result<T, E> + 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<XHSMiniToolExportState, XHSMiniToolExportReadError> { let root = project_root(project_path)?; Ok(content( From c7629606180ade7fa8832b3ed5876e92be27a16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:23:46 +0800 Subject: [PATCH 106/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E7=9B=AE=E6=A0=87=E6=9D=A1=E6=94=B9=E5=9B=9E=E4=B8=80?= =?UTF-8?q?=E7=BB=84=E6=99=AE=E9=80=9A=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TargetTabs 去掉没有配套 tabpanel 的 tablist/tab 角色,容器改 role=group - 选中态改用 aria-current 表达,Tab/Enter 操作不变 --- .../project-development/export/components/TargetTabs.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx index e2e88df34..0881d7ef8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx @@ -6,6 +6,10 @@ import { TargetIcon } from './TargetIcon'; * * 形态是经典下划线 tab(不是按钮胶囊):小字号、等宽三列、选中项用主色 + 底部指示条, * 视觉重量压在 header 里不抢标题。等宽靠三列 grid,不随标题长短伸缩。 + * + * 无障碍上按「一组单选按钮」而不是 ARIA tab 模式:本组件没有配套的 `tabpanel` / + * `aria-controls` / 方向键处理,硬套 `role="tab"` 只会让读屏播报一个找不到面板的 tab。 + * 这里保留 `role="group"` + `aria-current` 表达「这组里当前选中的是哪个」,Tab/Enter 本来就能用。 */ export function TargetTabs({ activeTarget, @@ -17,7 +21,7 @@ export function TargetTabs({ return ( <div className="mx-auto grid w-full max-w-md grid-cols-3" - role="tablist" + role="group" aria-label="导出目标" > {TARGET_ORDER.map((target) => { @@ -26,8 +30,7 @@ export function TargetTabs({ <button key={target} type="button" - role="tab" - aria-selected={active} + aria-current={active ? 'true' : undefined} className={`relative flex min-w-0 items-center justify-center gap-1.5 px-2 pb-2 pt-1 text-xs font-medium transition-colors after:absolute after:inset-x-3 after:bottom-0 after:h-[2px] after:rounded-full after:transition-colors after:content-[''] ${ active ? 'text-[#c7653d] after:bg-[#c7653d]' From 226a339ced5afdd0e895d6bddf015759e86f373e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:23:51 +0800 Subject: [PATCH 107/156] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=A4=B4=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=85=A5=E5=8F=A3=E5=9C=A8=E5=9B=9E=E5=90=88=E5=9C=A8?= =?UTF-8?q?=E9=80=94=E6=97=B6=E7=A6=81=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectProjectChatHeader 给导出按钮恢复 disabled={busy},与旧发布入口一致 - 设置入口不受影响,禁用样式沿用现有 .project-chat-publish-trigger:disabled --- .../DirectProjectChatHeader/DirectProjectChatHeader.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatHeader/DirectProjectChatHeader.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatHeader/DirectProjectChatHeader.tsx index ccf19df43..6d05f6f64 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatHeader/DirectProjectChatHeader.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectChatHeader/DirectProjectChatHeader.tsx @@ -5,6 +5,9 @@ import { Package, Settings } from 'lucide-react'; * * 状态文案由容器决定(忙态优先),这里只负责渲染;DirectProject 不再把工作台壳的 * 全局状态文案搬进来。发布入口归导出产物面板的陶泥儿 tab,不在聊天头。 + * + * 导出入口在回合在途(`busy`)时禁用:导出会读写项目文件,和 DirectProject 回合的写入并发 + * 会互相踩;设置入口不受影响。 */ export function DirectProjectChatHeader({ busy, @@ -34,6 +37,7 @@ export function DirectProjectChatHeader({ className="project-chat-publish-trigger" aria-label="导出产物" title="导出产物" + disabled={busy} onClick={onRequestExport} > <Package size={16} aria-hidden="true" /> From 91b664b66f2a00be1915b01ddf806363e0b76a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:24:14 +0800 Subject: [PATCH 108/156] =?UTF-8?q?=E5=88=A0=E6=8E=89=E5=B0=8F=E7=BA=A2?= =?UTF-8?q?=E4=B9=A6=E5=AF=BC=E5=87=BA=20hook=20=E9=87=8C=E6=97=A0?= =?UTF-8?q?=E4=BA=BA=E8=B0=83=E7=94=A8=E7=9A=84=20runExport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useXhsMinitoolExport 移除 runExport 与返回项,构建只经 packageAll 到达 buildOnce - 测试改用 packageAll 驱动构建路径,删掉 runExport 的 mock 与断言 --- .../export/state/useXhsMinitoolExport.ts | 14 +------------- .../tests/xhsMinitoolExport.test.tsx | 14 ++++++-------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index c6ebd6ebd..1b1279c6a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -316,7 +316,7 @@ export function useXhsMinitoolExport({ /** * 跑一次构建:每次都重新跑脚本、重新产出 zip,没有"上次的产物还能用"这条捷径。 * - * 不碰 `running`:调用方决定这次构建是一次独立导出,还是「打包」里保存之后的收尾。 + * 不碰 `running`:由调用方(`packageAll`)决定这次构建在哪一层持锁。 */ const buildOnce = useCallback(async (): Promise<void> => { if (!projectPath) return; @@ -340,17 +340,6 @@ export function useXhsMinitoolExport({ } }, [projectPath, throwToErrorPool]); - /** 只重新构建:失败卡片上的「重试」走这里,不夹带保存。 */ - const runExport = useCallback(async (): Promise<void> => { - if (!projectPath) return; - setRunning(true); - try { - await buildOnce(); - } finally { - setRunning(false); - } - }, [projectPath, buildOnce]); - /** * 打包:先**现场复算**「有没有脚本」,再冲掉没保存的输入,最后跑构建。 * @@ -497,7 +486,6 @@ export function useXhsMinitoolExport({ adaptPromptDismissed, updateField, resolveConflict, - runExport, packageAll, adapt, repair, diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 2e9c6c3f3..8b419473e 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -568,7 +568,6 @@ describe('面板左半', () => { expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '关闭' })); expect(minitool.dismissFailure).toHaveBeenCalledTimes(1); - expect(minitool.runExport).not.toHaveBeenCalled(); expect(minitool.repair).not.toHaveBeenCalled(); expect(minitool.adapt).not.toHaveBeenCalled(); } @@ -990,10 +989,10 @@ describe('状态 hook', () => { ); await waitFor(() => expect(result.current.form).toEqual(FORM)); - await expect(result.current.runExport()).rejects.toBe(transport); + await expect(result.current.packageAll()).rejects.toBe(transport); expect(result.current.failure).toBeNull(); // 同一个故障只抛一次:轮询每 2 秒重读一次,不能让错误池的 count 变成轮询次数。 - await expect(result.current.runExport()).resolves.toBeUndefined(); + await expect(result.current.packageAll()).resolves.toBeUndefined(); }); it('宿主侧事实故障先给卡片、再把同一个故障抛一次', async () => { @@ -1017,7 +1016,7 @@ describe('状态 hook', () => { // 卡片照常给现场,同时原样抛出(真实 WebView 里由全局 handler 结算进错误池)。 await act(async () => { - await expect(result.current.runExport()).rejects.toBeInstanceOf(Error); + await expect(result.current.packageAll()).rejects.toBeInstanceOf(Error); }); // 面板留下的就是宿主那个原始错误,用户那句话是渲染时从变体推的。 expect(result.current.failure).toEqual({ @@ -1025,7 +1024,7 @@ describe('状态 hook', () => { cause: '项目根不可用:找不到目录', }); await act(async () => { - await expect(result.current.runExport()).resolves.toBeUndefined(); + await expect(result.current.packageAll()).resolves.toBeUndefined(); }); }); @@ -1165,7 +1164,7 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await act(async () => { - await result.current.runExport(); + await result.current.packageAll(); }); expect(result.current.failure).toEqual({ type: 'commandFailed', @@ -1206,7 +1205,7 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await act(async () => { - await result.current.runExport(); + await result.current.packageAll(); }); expect(result.current.failure).toEqual({ type: 'saveConflict', @@ -1246,7 +1245,6 @@ function fakeMinitool( adaptPromptDismissed: false, updateField: vi.fn(), resolveConflict: vi.fn(), - runExport: vi.fn(async () => undefined), packageAll: vi.fn(async () => undefined), adapt: vi.fn(), repair: vi.fn(), From 7f9c686b34f72a333e7c75a00ac06289b3424c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:24:26 +0800 Subject: [PATCH 109/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E5=8F=AA?= =?UTF-8?q?=E7=95=99=E6=8E=A7=E5=88=B6=E5=8F=B0=E7=97=95=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xhsMinitoolDownload 返回 saved/cancelled/failed,调用方能区分用户取消与落盘失败 - ArtifactsPane 失败时在表单上方给一行重试提示,成功与取消保持静默 - 补一条测试:宿主拒绝写盘时提示可见 --- .../export/state/xhsMinitoolDownload.ts | 18 ++++++++++----- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 22 +++++++++++++++++-- .../tests/xhsMinitoolExport.test.tsx | 22 +++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts index 3f9bbb48e..f350ff7c7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts @@ -4,14 +4,18 @@ * 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护 * 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。 * - * 不回话也不抛:落点与结果都是用户在原生对话框里亲手给的,面板不再补一句「已保存到 …」; - * 失败只留一条控制台痕迹,不打断用户手上的事。 + * 返回值说明这次到底存没存下来:`saved`(宿主写完)、`cancelled`(用户关掉对话框)、`failed` + * (对话框打不开 / 宿主拒绝写)。成功不必在面板上复述——落点是用户亲手选的,原生对话框就是回执; + * 失败则由调用方决定怎么告知,不能像以前那样只留一条控制台痕迹。 */ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { resolveTauriInvoke } from '../../../../app/tauri'; import { resourceFileNameFromPath } from '../../../../features/resource-canvas/resourceCanvasAssetTransferModel'; +/** 一次导出产物保存的结局。 */ +export type XhsMinitoolDownloadResult = 'saved' | 'cancelled' | 'failed'; + export async function saveXhsMinitoolFileToDisk(input: { projectPath: string; /** 项目内相对路径,交给宿主的既有门禁复核。 */ @@ -19,9 +23,9 @@ export async function saveXhsMinitoolFileToDisk(input: { /** 路径里取不出文件名时用的兜底名。 */ fallbackFileName: string; title: string; -}): Promise<void> { +}): Promise<XhsMinitoolDownloadResult> { const invoke = resolveTauriInvoke(); - if (!invoke) return; + if (!invoke) return 'failed'; let destination: string | null = null; try { destination = await saveNativeFileDialog({ @@ -33,10 +37,10 @@ export async function saveXhsMinitoolFileToDisk(input: { }); } catch (error) { console.warn('导出产物保存对话框没能打开', error); - return; + return 'failed'; } // 用户取消对话框:什么都没发生,也没有话要说。 - if (!destination) return; + if (!destination) return 'cancelled'; try { await invoke('save_local_project_asset_file', { input: { @@ -45,7 +49,9 @@ export async function saveXhsMinitoolFileToDisk(input: { destinationPath: destination, }, }); + return 'saved'; } catch (error) { console.warn('导出产物没能写到你选的位置', error); + return 'failed'; } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 9ed57f0ac..d28ffc470 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -1,5 +1,5 @@ import { openUrl } from '@tauri-apps/plugin-opener'; -import type { ReactNode } from 'react'; +import { type ReactNode, useState } from 'react'; import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; @@ -32,6 +32,8 @@ export function ArtifactsPane({ projectPath: string | null; }) { const { form, conflict, failure } = minitool; + /** 下载失败的一句话现场:成功 / 用户取消都不说,只有宿主真的没写下来才占这一行。 */ + const [downloadNotice, setDownloadNotice] = useState(''); /** * 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下 * 「还没适配」的结论——那种情况只该显示读取中或失败现场。 @@ -49,24 +51,35 @@ export function ArtifactsPane({ */ const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed; - // 保存不打回执:用户刚在原生对话框里选完落点,磁盘上有没有那个文件是他自己看得见的事实。 + // 成功不打回执:用户刚在原生对话框里选完落点,磁盘上有没有那个文件是他自己看得见的事实。 + // 失败则必须说一句话——宿主拒绝落盘不能只留一条控制台痕迹。 const downloadIcon = (): void => { if (!projectPath || !form) return; + setDownloadNotice(''); void saveXhsMinitoolFileToDisk({ projectPath, relativePath: form.iconPath.trim(), fallbackFileName: 'icon.png', title: '保存图标文件', + }).then((result) => { + if (result === 'failed') { + setDownloadNotice('没能把图标保存到所选位置,请重试'); + } }); }; const downloadArtifact = (): void => { if (!projectPath) return; + setDownloadNotice(''); void saveXhsMinitoolFileToDisk({ projectPath, relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, fallbackFileName: 'xhs-minitool.zip', title: '保存代码包', + }).then((result) => { + if (result === 'failed') { + setDownloadNotice('没能把代码包保存到所选位置,请重试'); + } }); }; @@ -101,6 +114,11 @@ export function ArtifactsPane({ aria-label="导出内容区" > <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3"> + {downloadNotice ? ( + <p role="status" className="text-xs text-[#b4562f]"> + {downloadNotice} + </p> + ) : null} {formSlot} <section diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 8b419473e..506c9e450 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -298,6 +298,28 @@ describe('面板左半', () => { ); }); + it('宿主拒绝落盘时不静默:面板给出重试提示', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_image_preview') { + return { dataUrl: 'data:image/png;base64,icon' }; + } + if (command === 'save_local_project_asset_file') { + throw new Error('目标位置不可写'); + } + return {}; + }); + installTauriRuntime({ core: { invoke } }); + renderPane(fakeMinitool({ artifactReady: true })); + + fireEvent.click(screen.getByRole('button', { name: '下载代码包' })); + + await waitFor(() => + expect( + screen.getByText('没能把代码包保存到所选位置,请重试'), + ).toBeTruthy(), + ); + }); + it('图标先出缩略图,选择图标走公共素材导入器并把路径写回表单', async () => { const invoke = vi.fn( async (command: string, args?: Record<string, unknown>) => { From fba3805af88db1a5355d46e80db869dd9f803f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 19:32:35 +0800 Subject: [PATCH 110/156] =?UTF-8?q?=E4=BF=AE=E6=8E=89=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E9=87=8D=E8=AF=BB=E9=87=8C=E6=8C=87=E7=BA=B9=E4=B8=A2=E5=A4=B1?= =?UTF-8?q?=E4=B8=8E=E8=AF=BB=E5=A4=B1=E8=B4=A5=E4=B8=8D=E6=B8=85=E7=90=86?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useXhsMinitoolExport 加 hashPendingRef:指纹读失败后表单已换、基线还欠着,下一次重读跳过早返回把配套指纹补上,不再把旧 baseHash 留给新表单 - 保存命令的返回值直接落定基线并清掉 pending,不必再补读 - 每次内容读成功都在早返回之前清掉读来源的失败(registryMalformed / exportUnavailable),用函数式 setFailure 避免闭包过期;构建类失败不受影响 - 补两条回归测试:指纹失败后重读补上并带回新 baseHash;读恢复后失败就地清掉 --- .../export/state/useXhsMinitoolExport.ts | 44 ++++++++- .../tests/xhsMinitoolExport.test.tsx | 98 +++++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 1b1279c6a..f47257810 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -119,6 +119,14 @@ export function useXhsMinitoolExport({ * 读它。 */ const baselineRef = useRef(''); + /** + * 表单已经换成新的,但配套指纹还没取到(上一次指纹读失败了)。 + * + * 指纹与内容必须配套:内容读成功、指纹读失败时,绝不能把旧基线留给新表单,否则下一次保存会 + * 拿旧指纹撞出假冲突、或字段恰好相同被静默吸收。置真后轮询不再早返回,直到指纹补上;一次 + * 成功保存也会用返回值直接把它落定。 + */ + const hashPendingRef = useRef(false); /** 首次读取是否已经落定。`loading` 只覆盖这一段,自动重读不许再翻它。 */ const initialReadSettledRef = useRef(false); /** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */ @@ -164,7 +172,8 @@ export function useXhsMinitoolExport({ * - 表单是用户的编辑对象,只有用户手上没有未保存输入(也不在冲突里)时才覆盖,否则只更新 * `hasScript`,绝不覆盖正在打的字。 * - * 内容真的变了才去取指纹:指纹与内容必须配套,取早了会把外部改动认成自己的基线。 + * 内容真的变了、或上一次指纹读失败还欠着(`hashPendingRef`)时才去取指纹:指纹与内容必须 + * 配套,取早了会把外部改动认成自己的基线,欠着不取则会让下一次保存带着旧基线撞出假冲突。 * * 返回值是**这次现算出来的**「有没有脚本」:`true` / `false` 是宿主的当场结论,`null` 是这次 * 没读到(没有项目路径、没有宿主、注册表坏、认不出形状的拒绝)。「打包」要用这个当场结论决定 @@ -184,10 +193,25 @@ export function useXhsMinitoolExport({ projectPath, ); setHasScript(content.hasScript); + // 内容读回来了,说明宿主与注册表现在读得动:读来源的失败(注册表坏了 / 宿主不可用)就地 + // 清掉,别让弹窗卡在已经好了的现场上。用函数式更新拿最新 `failure`,既不把 `failure` 加进 + // `refresh` 的依赖里,也不怕闭包读到旧值。构建类失败不在这里清:它是构建的现场,交给下一次 + // 构建处理。 + setFailure((previous) => + previous && + (previous.type === 'registryMalformed' || + previous.type === 'exportUnavailable') + ? null + : previous, + ); // 首次读取无条件采纳:那时候用户不可能有未保存输入,采纳规则拦在这里只会让面板永远 // 停在「正在读取」(`adoptableRef` 的初值是 false,它是给轮询用的)。 + // 上一次指纹读失败时(`hashPendingRef`)也不能早返回:表单虽然没变,但基线还没配套,必须 + // 再走一次下面的指纹读;用户正在打字(`adoptableRef` 为 false)时依旧不动表单。 + const needsHashRetry = hashPendingRef.current && adoptableRef.current; if ( !isInitialRead && + !needsHashRetry && (!adoptableRef.current || sameForm(formRef.current, content.form)) ) { return content.hasScript; @@ -196,10 +220,18 @@ export function useXhsMinitoolExport({ setFieldError(null); setConflict(null); setFailure(null); - baselineRef.current = await readXhsMinitoolExportHash( - invoke, - projectPath, - ); + try { + baselineRef.current = await readXhsMinitoolExportHash( + invoke, + projectPath, + ); + hashPendingRef.current = false; + } catch (error) { + // 指纹没取到:表单已经采纳、基线却还停在旧值。标成 pending,下一次轮询跳过上面的早返回 + // 把指纹补上;失败本身照旧交给外层 catch 报给用户。 + hashPendingRef.current = true; + throw error; + } return content.hasScript; } catch (error) { const structured = asXhsMinitoolReadFailure(error); @@ -253,6 +285,8 @@ export function useXhsMinitoolExport({ form: next, baseHash, }); + // 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。 + hashPendingRef.current = false; setFieldError(null); setConflict(null); setFailure(null); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 506c9e450..3876b4a29 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -1243,6 +1243,104 @@ describe('状态 hook', () => { expect(submitTurn).not.toHaveBeenCalled(); expect(result.current.failure).not.toBeNull(); }); + + it('指纹读失败后,下一次重读把配套指纹补上,保存不再带陈旧 baseHash', async () => { + // 内容读成功、紧接着的指纹读失败:旧实现会把旧基线留给新表单;又因为表单没变,之后每次 + // 轮询都走早返回,指纹永远补不上,保存就会带着空 / 旧 baseHash 撞出假冲突。 + let hashCount = 0; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') { + hashCount += 1; + if (hashCount === 1) { + throw { type: 'registryMalformed', cause: '指纹暂时读不到' }; + } + return 'hash-2'; + } + if (command === 'save_xhs_minitool_export_form') return 'hash-3'; + return undefined; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + // 指纹没读到,基线还欠着:失败先报出来。 + expect(result.current.failure).toEqual({ + type: 'registryMalformed', + cause: '指纹暂时读不到', + }); + expect(hashCount).toBe(1); + + // 再走一次读:内容没变,但基线欠着,必须补读指纹而不是早返回。 + await act(async () => { + await result.current.packageAll(); + }); + expect(hashCount).toBe(2); + + await act(async () => { + result.current.updateField('name', '新名字'); + }); + await act(async () => { + await result.current.fillForm(); + }); + + // 基线是补读回来的 `hash-2`,不是空字符串、也不是任何旧值。 + expect(invoke).toHaveBeenCalledWith('save_xhs_minitool_export_form', { + projectPath: PROJECT_PATH, + form: { ...FORM, name: '新名字' }, + baseHash: 'hash-2', + }); + }); + + it('读来源的失败在现场恢复后就地清掉,不必等表单变化', async () => { + // 注册表先坏一次再恢复,而表单值没变:旧实现走早返回、跳过 setFailure(null),弹窗一直卡着。 + let readCount = 0; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + readCount += 1; + if (readCount === 2) { + throw { type: 'registryMalformed', cause: 'bad json' }; + } + // 第 3 次读恢复,但脚本还没出现:让这次「打包」在读失败清除后就返回,不进入构建 + // (构建会自己 setFailure(null),那就分不清失败到底是哪条路清掉的)。 + return { form: FORM, hasScript: readCount !== 3 }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + return undefined; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + expect(result.current.failure).toBeNull(); + + // 一次读失败:现场挂上。 + await act(async () => { + await result.current.packageAll(); + }); + expect(result.current.failure).toEqual({ + type: 'registryMalformed', + cause: 'bad json', + }); + + // 再读一次就恢复了、表单值一模一样:失败必须在早返回之前清掉。 + await act(async () => { + await result.current.packageAll(); + }); + expect(result.current.failure).toBeNull(); + }); }); function renderPane(minitool: XhsMinitoolExportState = fakeMinitool()) { From 709b57313d16e498fe8ae75d6e8ead13bf244477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:02:01 +0800 Subject: [PATCH 111/156] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=8E=92=E9=99=A4=E9=87=8C=E7=9A=84=E9=80=90?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=B0=8F=E5=86=99=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project/filesystem.rs:派生目录判定改用 eq_ignore_ascii_case 原地比较,不再为每个路径组件分配一个新 String。 --- apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 cf759c0e9..03757e283 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 @@ -421,10 +421,9 @@ pub(crate) fn should_skip_project_derived_path(relative_path: &str) -> bool { .split('/') .filter(|component| !component.is_empty()) .any(|component| { - let component = component.to_ascii_lowercase(); PROJECT_DERIVED_EXCLUDED_COMPONENTS .iter() - .any(|candidate| *candidate == component) + .any(|candidate| component.eq_ignore_ascii_case(candidate)) }) } From 22d1efdbb48bf62fee8045fa61fa9542f38764a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:02:09 +0800 Subject: [PATCH 112/156] =?UTF-8?q?=E4=BF=AE=E6=8E=89=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E6=B3=A8=E9=87=8A=E9=87=8C=E5=86=99=E9=94=99?= =?UTF-8?q?=E7=9A=84=E5=B8=B8=E9=87=8F=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project/filesystem.rs:文档注释把 PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS 改成实际使用的 PROJECT_DERIVED_EXCLUDED_COMPONENTS。 --- apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 03757e283..8c140b94b 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 @@ -438,7 +438,7 @@ pub(crate) fn should_skip_project_derived_path(relative_path: &str) -> bool { * 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、 * `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。 * - * 另外,同步还多认 `PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS`(`.export`):本机派生 + * 另外,同步还多认 `PROJECT_DERIVED_EXCLUDED_COMPONENTS`(`.export`):本机派生 * 的导出工作目录不进同步,但 Agent 仍要能读写它。 */ pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool { From 3a2a3ec5abc97139869fb7080687a1e3d4bb559f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:02:14 +0800 Subject: [PATCH 113/156] =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=97=B6=E5=85=B3=E6=8E=89=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.tsx:resetChatState 一并重置 exportPanelOpen,避免面板跨项目切换后重绑到新项目。 --- apps/ai-game-creator-shell/src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 858856187..05e7bdca4 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -979,12 +979,14 @@ export function App({ * 切换项目作用域时把这条链路自己的聊天状态清干净。 * * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 + * 导出面板同样按项目打开,切换项目时必须一起关掉:它不带 `key`,留着会直接重绑到新项目。 */ function resetChatState() { setChatAgentBusy(false); setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); + setExportPanelOpen(false); designReplyAnimation.reset(); setDesignAgentStatus(''); setDesignAgentReasoning(''); From 50ee440f792d5bd00eaf53cf17011b9bde84546e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:02:46 +0800 Subject: [PATCH 114/156] =?UTF-8?q?=E6=8D=A2=E9=A1=B9=E7=9B=AE=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=8F=91=E5=B8=83=E7=BB=91=E5=AE=9A=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useGameDistributionPublishForm.ts:重置效果一并清掉 publicationState/publicationBinding/publicationRefreshed/publicationMessage/latestVersionNumber/publishPhase,堵住旧项目绑定在新项目回读落定前可提交的窗口。 --- .../game-distribution/useGameDistributionPublishForm.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index a7a395366..8b05cb783 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -364,6 +364,14 @@ export function useGameDistributionPublishForm({ setError(''); onError(null); setResult(null); + // 发布绑定状态同样按项目重来:面板跨项目不卸载,旧项目的 `publicationState` 会在新项目第一次 + // 回读落定前继续显示(甚至还是可提交的「更新」),这中间就存在拿旧 gameId / 线上版本提交的窗口。 + setPublishPhase(null); + setPublicationState('checking'); + setPublicationBinding(null); + setPublicationRefreshed(false); + setPublicationMessage(''); + setLatestVersionNumber(0); setCoverConfirmOpen(false); setGeneratingCover(false); setCoverGenerationPrice(null); From 03a65e95fc4f4c7520dd4a53302c0fc25192f974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:02:53 +0800 Subject: [PATCH 115/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=89=8D=E7=BD=AE?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=94=B9=E8=B5=B0=E7=BB=9F=E4=B8=80=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=87=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useGameDistributionPublishForm.ts:发布状态未就绪与封面缺少素材三处 setError 改成 reportError,让面板根的发布提示弹窗能显示出来。 --- .../game-distribution/useGameDistributionPublishForm.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 8b05cb783..abee79220 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -760,11 +760,11 @@ export function useGameDistributionPublishForm({ return; } if (publicationState === 'checking') { - setError('正在确认项目发布状态,请稍候再试'); + reportError('正在确认项目发布状态,请稍候再试'); return; } if (publicationState === 'unavailable') { - setError( + reportError( publicationMessage || '无法确认该项目的线上发布状态,请稍后重试', ); return; @@ -774,7 +774,7 @@ export function useGameDistributionPublishForm({ return; } if (!cover.assetObjectId.trim()) { - setError('当前发布封面缺少平台素材,请重新选择封面后再发布'); + reportError('当前发布封面缺少平台素材,请重新选择封面后再发布'); return; } const formError = resolvePublishFormError(); From 3be99f55b109aa9743a91d1f1ff95082632db0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:03:00 +0800 Subject: [PATCH 116/156] =?UTF-8?q?=E6=8D=A2=E9=A1=B9=E7=9B=AE=E6=97=B6?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E6=88=AA=E5=9B=BE=E9=A2=84=E8=A7=88=E5=9C=B0?= =?UTF-8?q?=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useGameDistributionPublishForm.ts:重置效果调用 revokeTrackedObjectUrls,清掉 objectUrlsRef 里累计的截图预览 blob 地址。 --- .../game-distribution/useGameDistributionPublishForm.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index abee79220..3723f2953 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -386,6 +386,8 @@ export function useGameDistributionPublishForm({ publicationDraftAppliedRef.current = false; restoredDeclarationsRef.current = null; uploadedAssetCacheRef.current.clear(); + // 截图预览地址只登记在这里,没有「跟着某个 state 收」的效应;换项目时不清就会一直攒到卸载。 + revokeTrackedObjectUrls(objectUrlsRef.current); const suggestionRequestId = ++metadataSuggestionRequestRef.current; void suggestGameDistributionPublishMetadata({ From d8057ab796be7ed10a845ccc0cfc232cacd1a72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:05:30 +0800 Subject: [PATCH 117/156] =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E9=94=AE=E5=B8=A6=E4=B8=8A=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/draft/xhs_minitool/commands.rs:LAST_FAILURE 的键从命令名改为(命令名, 项目路径),避免不同项目的同一句失败被静默吞掉。 --- .../src/export/draft/xhs_minitool/commands.rs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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 index 425a44d40..920d93f7e 100644 --- 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 @@ -96,7 +96,7 @@ pub(crate) async fn run_xhs_minitool_export_build( let result = run_build(&project_path).await; match &result { Ok(run) => { - clear_failure("build"); + clear_failure("build", &project_path); app_log!( "export.build.ok project={project_path} outputChars={} omittedCharacters={}", run.output_tail.chars().count(), @@ -172,14 +172,17 @@ async fn run_build( build::run(&root).await } -/// 上一次记过的失败(按命令名)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。 -static LAST_FAILURE: LazyLock<Mutex<HashMap<&'static str, String>>> = +/// 上一次记过的失败(按命令名 + 项目路径)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。 +/// +/// 去重键必须带上项目:`registryMalformed` 之类的现场文案不含文件路径,两个项目可能给出同一句 +/// `Debug`,只按命令名去重会把项目 B 的失败当成项目 A 的重复现场静默吞掉。 +static LAST_FAILURE: LazyLock<Mutex<HashMap<(&'static str, String), String>>> = LazyLock::new(|| Mutex::new(HashMap::new())); /// 命令边界的统一收口:成功清掉去重记录,失败记一条。 fn report<T, E: std::fmt::Debug>(command: &'static str, project_path: &str, result: &Result<T, E>) { match result { - Ok(_) => clear_failure(command), + Ok(_) => clear_failure(command, project_path), Err(error) => log_failure(command, project_path, error), } } @@ -193,11 +196,12 @@ 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(command).is_some_and(|seen| *seen == message) { + if last.get(&key).is_some_and(|seen| *seen == message) { return; } - last.insert(command, message.clone()); + last.insert(key, message.clone()); } app_log!("export.{command}.failed project={project_path}: {message}"); } @@ -211,9 +215,9 @@ fn bounded_for_log(message: &str, max_chars: usize) -> String { format!("{head}…") } -fn clear_failure(command: &'static str) { +fn clear_failure(command: &'static str, project_path: &str) { if let Ok(mut last) = LAST_FAILURE.lock() { - last.remove(command); + last.remove(&(command, project_path.trim().to_string())); } } From 1094e4f862c7852c3159e053ccaf301e73b75a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:05:32 +0800 Subject: [PATCH 118/156] =?UTF-8?q?=E6=88=AA=E5=9B=BE=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E6=96=87=E6=A1=88=E6=94=B9=E7=94=A8=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useGameDistributionPublishForm.ts:超限报错里的 6 改用 MAX_AGC_GAME_SCREENSHOTS,避免与常量脱节。 --- .../game-distribution/useGameDistributionPublishForm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 3723f2953..44e32bfca 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -662,8 +662,8 @@ export function useGameDistributionPublishForm({ if (files.length > remaining) { reportError( remaining > 0 - ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` - : '游戏截图最多 6 张', + ? `游戏截图最多 ${MAX_AGC_GAME_SCREENSHOTS} 张,还可以再选 ${remaining} 张` + : `游戏截图最多 ${MAX_AGC_GAME_SCREENSHOTS} 张`, ); if (screenshotInputRef.current) screenshotInputRef.current.value = ''; return; From 9908e09614c2570db185d37e1a326cac3432191a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:05:49 +0800 Subject: [PATCH 119/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=8A=BD=E6=88=90=E5=8D=95=E4=B8=80=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameDistributionPublishFormView.tsx:新增 resolvePublicationStatusLabel,替掉四层嵌套三元。 --- .../GameDistributionPublishFormView.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) 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 index 96bc144d9..a4ed3a66a 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -35,6 +35,26 @@ function resolvePublishActionLabel(state: { return '打包并发布'; } +/** + * 发布绑定状态 → 状态行标题。 + * + * 与 [`resolvePublishActionLabel`] 分开:那颗按钮的文案还要叠加打包 / 上传阶段,状态标题只认绑定。 + */ +function resolvePublicationStatusLabel( + state: GameDistributionPublishFormState['publicationState'], +) { + switch (state) { + case 'update': + return '更新游戏'; + case 'unavailable': + return '无法确认发布状态'; + case 'checking': + return '正在检查发布绑定…'; + case 'first-publish': + return '首次发布'; + } +} + /** * 发布资料表单的表现层(工作台内联形态)。 * @@ -96,15 +116,7 @@ export function GameDistributionPublishFormView({ className={`game-distribution-publish-panel__publication is-${publicationState}`} role="status" > - <strong> - {publicationState === 'update' - ? '更新游戏' - : publicationState === 'unavailable' - ? '无法确认发布状态' - : publicationState === 'checking' - ? '正在检查发布绑定…' - : '首次发布'} - </strong> + <strong>{resolvePublicationStatusLabel(publicationState)}</strong> {publicationState === 'update' && publicationBinding ? ( <span> 线上最近提交{' '} From 62947a836f06fc67edb6c60def02b9d00542bc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:05:59 +0800 Subject: [PATCH 120/156] =?UTF-8?q?=E6=88=AA=E5=9B=BE=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=96=87=E6=A1=88=E6=8A=BD=E6=88=90=E5=87=BD=E6=95=B0=E5=B9=B6?= =?UTF-8?q?=E8=B7=9F=E5=B8=B8=E9=87=8F=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameDistributionPublishFormView.tsx:新增 resolveScreenshotPickLabel,替掉嵌套三元并把「截图已满 6 张」改成 MAX_AGC_GAME_SCREENSHOTS。 --- .../GameDistributionPublishFormView.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 index a4ed3a66a..13b3143c2 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -55,6 +55,18 @@ function resolvePublicationStatusLabel( } } +/** 截图选择按钮的文案:满 / 上传中 / 可添加三态,上限跟着常量走。 */ +function resolveScreenshotPickLabel(state: { + activeScreenshotCount: number; + screenshotsUploading: boolean; +}) { + if (state.activeScreenshotCount >= MAX_AGC_GAME_SCREENSHOTS) { + return `截图已满 ${MAX_AGC_GAME_SCREENSHOTS} 张`; + } + if (state.screenshotsUploading) return '截图上传中…'; + return '添加截图'; +} + /** * 发布资料表单的表现层(工作台内联形态)。 * @@ -289,11 +301,10 @@ export function GameDistributionPublishFormView({ }} /> <span className="game-distribution-publish-panel__pick"> - {activeScreenshotCount >= MAX_AGC_GAME_SCREENSHOTS - ? '截图已满 6 张' - : screenshotsUploading - ? '截图上传中…' - : '添加截图'} + {resolveScreenshotPickLabel({ + activeScreenshotCount, + screenshotsUploading, + })} </span> </span> <span className="game-distribution-publish-panel__hint"> From 30c08272c68fa9c2367678e7200f56024dab7bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:06:08 +0800 Subject: [PATCH 121/156] =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=96=87=E6=A1=88=E6=94=B9=E6=88=90=E7=8A=B6=E6=80=81=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xiaohongshu/CopyButton.tsx:用 COPY_STATE_TEXT 查表替掉嵌套三元。 --- .../export/tabs/xiaohongshu/CopyButton.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx index faa8435bd..59696e181 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx @@ -4,6 +4,13 @@ import { useEffect, useState } from 'react'; type CopyState = 'idle' | 'copied' | 'failed'; +/** 状态 → 按钮文案;加状态时只在这里补一条。 */ +const COPY_STATE_TEXT: Record<CopyState, string> = { + idle: '复制', + copied: '已复制', + failed: '复制失败', +}; + /** * 复制按钮:成功后显示一小会儿「已复制」,失败也**必须**说一声。 * @@ -21,8 +28,7 @@ export function CopyButton({ value, label }: { value: string; label: string }) { return () => clearTimeout(timer); }, [state]); - const text = - state === 'copied' ? '已复制' : state === 'failed' ? '复制失败' : '复制'; + const text = COPY_STATE_TEXT[state]; return ( <button From 99e6bfa139bcd0f931abf744a7a2122ee0acde35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:06:15 +0800 Subject: [PATCH 122/156] =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E7=9A=84=20agent=20=E6=8C=89=E9=92=AE=E6=94=B9=E6=88=90?= =?UTF-8?q?=E6=97=A9=E8=BF=94=E5=9B=9E=E5=BC=8F=E8=B5=8B=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xiaohongshu/FailureDialog.tsx:嵌套三元改成 if 块,保持「文案与 handler 一起定」不变。 --- .../export/tabs/xiaohongshu/FailureDialog.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx index 009cfe802..4075b370a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx @@ -39,13 +39,16 @@ export function FailureDialog({ }) { const repairability = failure ? classifyXhsMinitoolFailure(failure) : null; // 文案与点击处理一起定:只存一句中文再按它反查 handler 的话,改文案就会静默接错动作。 - const agentAction = - !failure || - repairability === XhsMinitoolFailureRepairability.OnlyHumanRepairable - ? null - : failure.type === 'buildScriptMissing' + let agentAction: { label: string; onClick: () => void } | null = null; + if ( + failure && + repairability !== XhsMinitoolFailureRepairability.OnlyHumanRepairable + ) { + agentAction = + failure.type === 'buildScriptMissing' ? { label: '让陶泥儿帮我调通', onClick: onAdapt } : { label: '让陶泥儿来修', onClick: onRepair }; + } return ( <ThemedModal From c5188d468276e269f927e555ee5e77bbf9e3c36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:06:24 +0800 Subject: [PATCH 123/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=85=A5=E5=8F=A3=E5=9C=B0=E5=9D=80=E6=94=B6=E6=88=90?= =?UTF-8?q?=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xiaohongshu/ArtifactsPane.tsx:把创作平台发布页 URL 提到模块常量 XHS_PUBLISH_URL。 --- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index d28ffc470..0cb15f7a1 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -10,6 +10,9 @@ import { ConflictCard } from './ConflictCard'; import { FailureDialog } from './FailureDialog'; import { FormCard } from './FormCard'; +/** 小红书创作平台的发布 / 更新入口;业务地址收一处,改路径只动这里。 */ +const XHS_PUBLISH_URL = 'https://creator.xiaohongshu.com/new/red-app'; + /** * 小红书工作台左半:平台上传表单 + 小工具代码包 + 「打包」。 * @@ -176,11 +179,9 @@ export function ArtifactsPane({ type="button" className="cursor-pointer self-center border-0 bg-transparent p-0 text-xs font-semibold text-[#c7653d] underline underline-offset-2 transition-colors hover:text-[#b4562f] active:text-[#3d1f10] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c7653d]" onClick={() => { - void openUrl('https://creator.xiaohongshu.com/new/red-app').catch( - (error) => { - console.error('[agc] 打开小红书发布页失败', error); - }, - ); + void openUrl(XHS_PUBLISH_URL).catch((error) => { + console.error('[agc] 打开小红书发布页失败', error); + }); }} > 前往小红书发布/更新 From 4edbf62c812ecad9b522418d2fdd61ac6a4b7288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:06:31 +0800 Subject: [PATCH 124/156] =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=B2=A1=E6=9C=89=20?= =?UTF-8?q?type=20=E7=9A=84=E4=BC=AA=E7=BB=93=E6=9E=84=E5=8C=96=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/state/xhsMinitoolApi.ts:guardStructuredFailure 在装 wrapper 前校验对象至少带字符串 type,否则原样抛出交给错误池。 --- .../view/project-development/export/state/xhsMinitoolApi.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts index f4d97c9e3..5a95878e1 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolApi.ts @@ -146,6 +146,9 @@ async function guardStructuredFailure<T, Payload>( // 非对象同理(命令没注册、参数序列化失败时 Tauri 会拒绝一个裸字符串):装进 wrapper 只会 // 得到一份没有 `type` 的假变体,调用方按变体分流时会走到不该到的分支。 if (typeof error !== 'object' || error === null) throw error; + // 非对象之外,还要确认它长得像生成的判别联合:缺字符串 `type` 的普通对象装进 wrapper 只会变成 + // 「没有任何变体」的失败,分流落到 default,真正的拒绝原因反而被当成业务现场糊过去。 + if (typeof (error as { type?: unknown }).type !== 'string') throw error; throw new FailureClass(error as Payload); } } From 6af4957fa25868e5ee8d87cf23a077add2e51d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:06:45 +0800 Subject: [PATCH 125/156] =?UTF-8?q?=E9=99=B6=E6=B3=A5=E5=84=BF=20tab=20?= =?UTF-8?q?=E6=8D=A2=E9=A1=B9=E7=9B=AE=E6=97=B6=E6=B8=85=E6=8E=89=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/state/useTaonierTab.ts:换项目重置效果补上 setDraft([]) 与 setRuntimeNotice(''),旧项目没发出去的输入不再出现在新项目输入框。 --- .../export/state/useTaonierTab.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index 3c50950e6..ee256d00b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -107,25 +107,29 @@ export function useTaonierTab({ }; }, []); - /** - * 换项目时清掉上一次项目的打包结果、进度与阻断提示。 - * - * 面板跨项目切换时不会卸载(同一份 `usePanel` 状态继续用),而发布表单在 - * `manifest?.projectId` 变化时会重新初始化并复用现成的 `packageResult`——不清的话, - * 新项目会带着旧项目的 `packageRelativePath` 直接被发布出去。 - */ - useEffect(() => { - setPackageResult(null); - setProgress(null); - setBlockedMessage(null); - }, [projectPath]); - const [runtimeNotice, setRuntimeNotice] = useState(''); /** * 输入草稿提升到面板层:Composer 随 tab 页卸载后,正文不再随组件一起丢。 * 提交成功时 Composer 内部的 `clear()` 会经 `onDraftChange` 把空草稿回执上来。 */ const [draft, setDraft] = useState<UserContentPart[]>([]); + + /** + * 换项目时清掉上一次项目的打包结果、进度、阻断提示与输入草稿。 + * + * 面板跨项目切换时不会卸载(同一份 `usePanel` 状态继续用),而发布表单在 + * `manifest?.projectId` 变化时会重新初始化并复用现成的 `packageResult`——不清的话, + * 新项目会带着旧项目的 `packageRelativePath` 直接被发布出去。草稿与运行提示同理: + * 两个项目不是同一条会话,旧项目没发出去的字不该出现在新项目的输入框里。 + */ + useEffect(() => { + setPackageResult(null); + setProgress(null); + setBlockedMessage(null); + setDraft([]); + setRuntimeNotice(''); + }, [projectPath]); + const chat = useDirectProjectChatController({ enabled, ensureConversationReadAllowed, From f5ec14966c6f89ff55dfddb122561db4a5dcb5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:06:55 +0800 Subject: [PATCH 126/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=20tab=20?= =?UTF-8?q?=E6=8D=A2=E9=A1=B9=E7=9B=AE=E6=97=B6=E6=B8=85=E6=8E=89=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/state/useXiaohongshuTab.ts:新增按 projectPath 的重置效果,清空 draft 与 runtimeNotice。 --- .../export/state/useXiaohongshuTab.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts index a5838a5b5..b54b43fe6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { type DirectProjectConversationReadGate, @@ -48,6 +48,14 @@ export function useXiaohongshuTab({ * 提交成功时 Composer 内部的 `clear()` 会经 `onDraftChange` 把空草稿回执上来。 */ const [draft, setDraft] = useState<UserContentPart[]>([]); + /** + * 换项目时清掉输入草稿与运行提示:面板根跨项目不卸载,两个项目又不是同一条会话, + * 旧项目没发出去的字不该出现在新项目的输入框里。 + */ + useEffect(() => { + setDraft([]); + setRuntimeNotice(''); + }, [projectPath]); const chat = useDirectProjectChatController({ enabled, ensureConversationReadAllowed, From 3a8415398a3b752df4c7500097fa1e5665562aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:07:02 +0800 Subject: [PATCH 127/156] =?UTF-8?q?=E8=A1=A8=E5=8D=95=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E6=94=B9=E6=88=90=E6=8C=89=E7=A0=81=E7=82=B9=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=A0=A1=E9=AA=8C=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xiaohongshu/FormCard.tsx:used 改用 Array.from(...).length,避免补充平面字符被按 UTF-16 码元多算。 --- .../project-development/export/tabs/xiaohongshu/FormCard.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index 564948380..e53cf0cb8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -123,7 +123,9 @@ function FieldRow({ onChange: (value: string) => void; }) { const id = useId(); - const used = value.trim().length; + // 按码点计数,与宿主 `validate_text` 的 `chars().count()` 对齐:`String.length` 数的是 UTF-16 + // 码元,一个 emoji 会被这里算成 2、宿主算成 1,计数徽标会比宿主更早报超限。 + const used = Array.from(value.trim()).length; const overLimit = maxChars !== undefined && used > maxChars; return ( From c02f499d245a96bc7648b21a5cf243629d1b0d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:07:11 +0800 Subject: [PATCH 128/156] =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=AD=97=E6=95=B0?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E6=94=B6=E6=88=90=E5=8D=95=E4=B8=80=E5=B8=B8?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/state/xhsMinitoolFields.ts:14 提成 TEXT_MAX_CHARS,占位文案与 maxChars 共用。 --- .../export/state/xhsMinitoolFields.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts index 5900874de..345f12d80 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts @@ -18,21 +18,24 @@ export type XhsMinitoolFieldMeta = { copyable: boolean; }; +/** 文本字段的字数上限:与 Rust 权威 `validation.rs` 的 NAME_MAX_CHARS / INTRODUCTION_MAX_CHARS 一致。 */ +const TEXT_MAX_CHARS = 14; + /** 表单展示顺序。加字段时只改这里,保存与复制都按字段身份走。 */ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ { field: 'name', label: '小工具名称', - placeholder: '小红书里显示的名字,最多 14 个字', - maxChars: 14, + placeholder: `小红书里显示的名字,最多 ${TEXT_MAX_CHARS} 个字`, + maxChars: TEXT_MAX_CHARS, multiline: false, copyable: true, }, { field: 'introduction', label: '一句话介绍', - placeholder: '小红书里显示的一句话,最多 14 个字', - maxChars: 14, + placeholder: `小红书里显示的一句话,最多 ${TEXT_MAX_CHARS} 个字`, + maxChars: TEXT_MAX_CHARS, multiline: true, copyable: true, }, From f7cfad459223767d085ee74e26443bc034a17cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:07:27 +0800 Subject: [PATCH 129/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E5=90=8D=E5=8A=A0?= =?UTF-8?q?=E4=B8=8A=E6=97=B6=E9=97=B4=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/registry.rs:temp_path 在 pid 与序号之外再带纳秒时间戳,避免崩溃残骸被同 pid 新进程撞上导致 create_new 误失败。 --- .../src-tauri/src/export/registry.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index cbf66fa52..275146002 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -172,9 +172,16 @@ fn temp_path(path: &Path) -> PathBuf { .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}.tmp", + ".{file_name}.{}.{sequence}.{stamp}.tmp", std::process::id() )) } From 24dfbd4a550f2956ff9100eeba013b3013629095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:32:22 +0800 Subject: [PATCH 130/156] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA=E7=9A=84=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/registry.rs:在 load_or_create 注释里写明 NotFound 到原子写入之间被并发覆盖的窗口属于明确接受,与 xhs_minitool::registry::save 的口径一致。 --- apps/ai-game-creator-shell/src-tauri/src/export/registry.rs | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 275146002..2a777f71a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -61,6 +61,12 @@ pub(crate) fn content_hash(text: &str) -> String { /// 读注册表;文件不存在时先用 `empty` 的规范序列化补一份空注册表,再读回来。 /// /// 「自动创建」是产品要求:面板一打开就要能显示空表单,而不是先报「文件不存在」。 +/// +/// 这里明确接受一个并发窗口:`read_to_string` 返回 `NotFound` 到下面 `write_atomically` 之间 +/// 没有锁,如果这期间别的写者(code agent 的脚本、并发的保存命令)抢先建好了同一个文件,本次 +/// 写入会用空注册表把它盖掉。不为此加锁或改成「原子重读 + 比较」:注册表本来就是用户与 agent +/// 共写的本机文件,真撞上时重新读一次就能看到最新内容,写入门禁级别的并发保证成本高于收益 +///(目标模块 `xhs_minitool::registry::save` 对同类窗口也是明确接受)。 pub(crate) fn load_or_create<T>(path: &Path, empty: &T) -> Result<Loaded<T>, RegistryError> where T: Serialize + DeserializeOwned, From 00b8d12d98d644ab67b72e883f2961094a47bd20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:33:58 +0800 Subject: [PATCH 131/156] =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=A1=A8=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=B8=A6=E4=B8=8A=E5=BA=95=E5=B1=82=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=B9=B6=E5=AE=9E=E7=8E=B0=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/registry.rs:新增 RegistryErrorCause,io/serde 失败保留原始错误类型(拿不到类型时用 Message 兜底),RegistryError 与 Cause 都实现 std::error::Error::source,Display 文案不变。 export/draft/xhs_minitool/error.rs:只在命令边界把 cause 转成 String,配合新增测试锁定 io::ErrorKind 可透出。 --- .../src/export/draft/xhs_minitool/error.rs | 4 +- .../src-tauri/src/export/registry.rs | 130 ++++++++++++++++-- 2 files changed, 122 insertions(+), 12 deletions(-) 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 index 8106613e5..c0caad70e 100644 --- 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 @@ -192,7 +192,9 @@ macro_rules! impl_from_registry_error { fn from(error: crate::export::RegistryError) -> Self { match error { crate::export::RegistryError::Malformed { cause, .. } => { - Self::RegistryMalformed(RegistryMalformed { cause }) + Self::RegistryMalformed(RegistryMalformed { + cause: cause.to_string(), + }) } other => Self::ExportUnavailable(ExportUnavailable::new(other.to_string())), } 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 index 2a777f71a..96460f7d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -24,15 +24,103 @@ pub(crate) struct Loaded<T> { 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<String>) -> 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: String }, + Io { + path: PathBuf, + cause: RegistryErrorCause, + }, /// 文件内容不是本目标期望的形状。fail closed:不猜测、不修补。 - Malformed { path: PathBuf, cause: String }, + Malformed { + path: PathBuf, + cause: RegistryErrorCause, + }, /// 宿主自己的 DTO 序列化失败,属于编程错误。 - Encode { cause: String }, + 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 { @@ -83,13 +171,13 @@ where // 被报成「路径 / 权限故障」,把用户和 agent 往修权限的方向引。 return Err(RegistryError::Malformed { path: path.to_path_buf(), - cause: format!("注册表不是有效 UTF-8:{error}"), + cause: RegistryErrorCause::io_with_context("注册表不是有效 UTF-8", error), }); } Err(error) => { return Err(RegistryError::Io { path: path.to_path_buf(), - cause: error.to_string(), + cause: RegistryErrorCause::io(error), }); } }; @@ -111,7 +199,7 @@ pub(crate) fn save<T: Serialize>(path: &Path, value: &T) -> Result<String, Regis /// 规范形式:两空格缩进 + 结尾换行。agent 会手改这个文件,人类可读比紧凑更重要。 fn encode<T: Serialize>(value: &T) -> Result<String, RegistryError> { let mut text = serde_json::to_string_pretty(value).map_err(|error| RegistryError::Encode { - cause: error.to_string(), + cause: RegistryErrorCause::serde(error), })?; text.push('\n'); Ok(text) @@ -120,7 +208,7 @@ fn encode<T: Serialize>(value: &T) -> Result<String, RegistryError> { fn decode<T: DeserializeOwned>(path: &Path, text: &str) -> Result<T, RegistryError> { serde_json::from_str(text).map_err(|error| RegistryError::Malformed { path: path.to_path_buf(), - cause: error.to_string(), + cause: RegistryErrorCause::serde(error), }) } @@ -131,11 +219,11 @@ fn decode<T: DeserializeOwned>(path: &Path, text: &str) -> Result<T, RegistryErr fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> { let parent = path.parent().ok_or_else(|| RegistryError::Io { path: path.to_path_buf(), - cause: "注册表路径缺少父目录".to_string(), + cause: RegistryErrorCause::message("注册表路径缺少父目录"), })?; fs::create_dir_all(parent).map_err(|error| RegistryError::Io { path: path.to_path_buf(), - cause: format!("创建导出目录失败:{error}"), + cause: RegistryErrorCause::io_with_context("创建导出目录失败", error), })?; let temp = temp_path(path); let write = || -> std::io::Result<()> { @@ -153,14 +241,14 @@ fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> { let _ = fs::remove_file(&temp); return Err(RegistryError::Io { path: path.to_path_buf(), - cause: error.to_string(), + cause: RegistryErrorCause::io(error), }); } fs::rename(&temp, path).map_err(|error| { let _ = fs::remove_file(&temp); RegistryError::Io { path: path.to_path_buf(), - cause: error.to_string(), + cause: RegistryErrorCause::io(error), } })?; // rename 只改目录条目:不 fsync 父目录的话,崩溃后「哪个文件才是新的」这件事可能没落盘, @@ -292,4 +380,24 @@ mod tests { 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::<std::io::Error>()) + .expect("io source"); + assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied); + } } From 22ad218dc772ee5ac3d01093e374f6148dc4a258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:35:21 +0800 Subject: [PATCH 132/156] =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E7=9A=84=E5=89=8D=E5=90=8E=E7=BD=AE=E5=90=8C=E6=AD=A5=20IO=20?= =?UTF-8?q?=E6=8C=AA=E5=88=B0=E9=98=BB=E5=A1=9E=E7=BA=BF=E7=A8=8B=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/draft/xhs_minitool/build.rs:把找脚本、判权限、清旧产物抽成同步的 prepare,连同收尾判产物的 symlink_metadata 一起走 offload。 export/draft/xhs_minitool/commands.rs:offload 放宽到 pub(super),供同级 build 模块复用。 --- .../src/export/draft/xhs_minitool/build.rs | 59 +++++++++++++------ .../src/export/draft/xhs_minitool/commands.rs | 4 +- 2 files changed, 45 insertions(+), 18 deletions(-) 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 index 060af0d67..011748199 100644 --- 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 @@ -10,12 +10,13 @@ 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; +use super::script::{self, NpmScript}; /// 权限点位复用既有命令口径:这就是在跑项目命令,不新增 `GAME_CREATION_APP_COMMANDS` 条目。 const PERMISSION_COMMAND_ID: &str = "command.exec"; @@ -27,6 +28,45 @@ const OUTPUT_TAIL_MAX_CHARS: usize = 4_000; pub(crate) async fn run( root: &Path, ) -> Result<XHSMiniToolExportRunResult, XHSMiniToolExportBuildError> { + // 找脚本、读权限策略、清上一次产物都是同步文件 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)?; + + // 收尾判产物同样是同步 IO(symlink_metadata),一样搬去阻塞线程池。 + offload(move || { + conclude( + &root, + result.exit_code, + result.timed_out, + &result.output, + ) + }) + .await +} + +/// 构建前置(同步):找到脚本、确认权限点位没被拒、清掉上一次的产物。 +/// +/// 产物判据只看「文件非空」,所以先清掉上一次的 zip:脚本这次什么都没写时,残留的旧 zip 会 +/// 让 no-op 脚本被判成功,用户上传的其实是上一轮的产物。删不掉(权限等)就 fail closed—— +/// 留着旧产物继续判成功,比这次构建失败更糟。 +fn prepare(root: &Path) -> Result<NpmScript, XHSMiniToolExportBuildError> { let Some(script) = script::find(root) else { return Err(script::missing_error()); }; @@ -42,9 +82,6 @@ pub(crate) async fn run( } })?; - // 产物判据只看「文件非空」,所以先清掉上一次的 zip:脚本这次什么都没写时,残留的旧 zip 会 - // 让 no-op 脚本被判成功,用户上传的其实是上一轮的产物。删不掉(权限等)就 fail closed—— - // 留着旧产物继续判成功,比这次构建失败更糟。 let artifact = artifact_path(root); match std::fs::remove_file(&artifact) { Ok(()) => {} @@ -59,19 +96,7 @@ pub(crate) async fn run( } } - 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)?; - - conclude(root, result.exit_code, result.timed_out, &result.output) + Ok(script) } /// 把宿主的构建结果收成导出结果:先判成功,再确认产物真的在。 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 index 920d93f7e..c3daf01df 100644 --- 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 @@ -128,7 +128,9 @@ fn project_root(project_path: &str) -> Result<PathBuf, ExportUnavailable> { /// 读命令每 2 秒被轮询一次,注册表 + `package.json` 探测虽然小,但直接在 `async fn` 里做同步 /// 文件 IO 会占着 worker 线程,拖慢同一 runtime 上的其它异步任务(构建子进程、SpacetimeDB /// 客户端)。任务 panic 时 `JoinError` 收口成 `ExportUnavailable`,走各命令既有的失败表。 -async fn offload<T, E, F>(job: F) -> Result<T, E> +/// +/// 构建命令(`build::run`)的前后置同步 IO 也复用这里,所以对同级的 `build` 模块可见。 +pub(super) async fn offload<T, E, F>(job: F) -> Result<T, E> where T: Send + 'static, E: From<ExportUnavailable> + Send + 'static, From d7ca19c59851ebc3d5b1d9dc39234b16eb55f71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:36:45 +0800 Subject: [PATCH 133/156] =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=20.export=20=E5=8F=AA=E8=AE=A4=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=A0=B9=E9=82=A3=E4=B8=80=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project/filesystem.rs:should_skip_project_derived_path 与快照同步判定改为只看第一个路径组件,源码里的嵌套 game/.export 不再被误排。 project_snapshot/tests.rs:补测试区分根 .export 与嵌套 .export。 --- .../src-tauri/src/project/filesystem.rs | 14 +++++--- .../src-tauri/src/project_snapshot/tests.rs | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) 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 8c140b94b..566f41128 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 @@ -415,12 +415,15 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { /// 这些文件能从项目源码重跑出来,不该进索引、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('/') - .filter(|component| !component.is_empty()) - .any(|component| { + .find(|component| !component.is_empty()) + .is_some_and(|component| { PROJECT_DERIVED_EXCLUDED_COMPONENTS .iter() .any(|candidate| component.eq_ignore_ascii_case(candidate)) @@ -439,7 +442,8 @@ pub(crate) fn should_skip_project_derived_path(relative_path: &str) -> bool { * `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。 * * 另外,同步还多认 `PROJECT_DERIVED_EXCLUDED_COMPONENTS`(`.export`):本机派生 - * 的导出工作目录不进同步,但 Agent 仍要能读写它。 + * 的导出工作目录不进同步,但 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) @@ -536,8 +540,8 @@ fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: b && !(include_agent_state && component == ".agent") }) || (include_agent_state && components - .iter() - .any(|component| PROJECT_DERIVED_EXCLUDED_COMPONENTS.contains(&component.as_str()))) + .first() + .is_some_and(|component| PROJECT_DERIVED_EXCLUDED_COMPONENTS.contains(&component.as_str()))) { return true; } 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 7008d2eee..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 @@ -267,6 +267,41 @@ fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() 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] fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() { let root = fixture_root(); From 6487b52b31e1da9e2352cb67403273a4f3afb33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:37:28 +0800 Subject: [PATCH 134/156] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E5=AE=9A=E8=89=B2=E6=8F=90=E5=88=B0=E6=A8=A1=E5=9D=97=E5=B8=B8?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameDistributionPublishFormView.tsx:封面确认与截图预览两个 panelStyle 提取为模块级常量,不再每次渲染新建对象;按仓库约定保持内联样式,不新建 CSS 文件。 --- .../GameDistributionPublishFormView.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 index 13b3143c2..ee9d6ac0a 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx @@ -13,6 +13,23 @@ import { 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; + /** * 主行动按钮文案:先按发布绑定状态(检查中 / 不可用 / 更新)选路,再按打包与上传阶段选路。 */ @@ -393,7 +410,7 @@ export function GameDistributionPublishFormView({ ariaLabel="确认生成游戏封面" onClose={() => setCoverConfirmOpen(false)} panelClassName="game-publish-progress-dialog game-publish-cover-confirm-dialog" - panelStyle={{ background: '#fffaf7', color: '#4f362d' }} + panelStyle={COVER_CONFIRM_PANEL_STYLE} > <div className="game-publish-progress-icon" aria-hidden="true"> ✦ @@ -422,7 +439,7 @@ export function GameDistributionPublishFormView({ ariaLabel="截图预览" onClose={() => setPreviewScreenshot(null)} panelClassName="game-distribution-publish-panel__preview-dialog" - panelStyle={{ background: '#1d1714', color: '#fffaf7' }} + panelStyle={SCREENSHOT_PREVIEW_PANEL_STYLE} > {previewScreenshot?.previewUrl ? ( <img From 230c9a2d28dba01b43a6459582b8c639d23c95cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:39:21 +0800 Subject: [PATCH 135/156] =?UTF-8?q?=E6=89=93=E5=8C=85=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=85=88=E6=A0=A1=E9=AA=8C=E5=90=8E=E8=90=BD=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite-export-xhs-minitool/scripts/pack.mjs:抽出 encodeZip,makeZip 先把条目与字节算完并通过 index.html / 禁止条目 / 体积校验,最后才写 zip,失败时不再留半成品。 scripts/.pack.test.mjs:补测试覆盖 makeZip 内部校验失败时不留 zip。 resources/agc-skills/manifest.json:同步该 Skill 的内容指纹与 pack 版本。 --- .../resources/agc-skills/manifest.json | 4 +-- .../scripts/.pack.test.mjs | 25 +++++++++++++++ .../vite-export-xhs-minitool/scripts/pack.mjs | 32 +++++++++++++------ 3 files changed, 50 insertions(+), 11 deletions(-) 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 392096126..8d329e435 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.66", + "version": "2026-08-26.67", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "8a096ba5ac398ee45276e47d955691f834ecb0af8facadb43d52186f90458313" + "sha256": "ad9b5a6dc1b71e0413a1a71780c60d17076d13fbf67517a939c57565e6d0a477" } ] } 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 index 8ed0e67f7..2804d7bb4 100644 --- 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 @@ -69,6 +69,31 @@ test('存在不支持文件时打包整体失败,且不删除任何文件', () 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'); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 98ebd432a..9d69fd6e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -150,8 +150,8 @@ function collectZipEntries(rootDir) { return entries; } -export function writeZip(rootDir, zipPath) { - const entries = collectZipEntries(rootDir); +/** 把已收集的条目编码成完整 zip 字节。只算内存,不落盘——校验没过时磁盘上不能留半成品。 */ +function encodeZip(entries) { const now = new Date(); const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; @@ -235,8 +235,15 @@ export function writeZip(rootDir, zipPath) { eocd.writeUInt32LE(localBuf.length, 16); eocd.writeUInt16LE(0, 20); + return Buffer.concat([localBuf, centralBuf, eocd]); +} + +/** 落盘版:写 zip 并返回条目名列表(保持既有导出名,供只想要 zip 的调用方使用)。 */ +export function writeZip(rootDir, zipPath) { + const entries = collectZipEntries(rootDir); + const buffer = encodeZip(entries); rmSync(zipPath, { force: true }); - writeFileSync(zipPath, Buffer.concat([localBuf, centralBuf, eocd])); + writeFileSync(zipPath, buffer); return entries.map((entry) => entry.name); } @@ -258,20 +265,27 @@ export function auditZipFile(file, size) { } function makeZip(viteBuiltDir, zipPath) { - const entries = writeZip(viteBuiltDir, zipPath); - if (!entries.includes('index.html')) { + // 先把条目和字节都算完、校验通过,最后才落盘:中途失败时预期输出路径上不能留一份半成品, + // 更不能让宿主把半成品当成本次构建的产物。 + const entries = collectZipEntries(viteBuiltDir); + const names = entries.map((entry) => entry.name); + if (!names.includes('index.html')) { throw new Error('zip 根目录缺少 index.html(可能多套了一层目录)'); } - const bad = entries.find((entry) => FORBIDDEN_ENTRY.test(entry)); + const bad = names.find((entry) => FORBIDDEN_ENTRY.test(entry)); if (bad) { throw new Error(`zip 内含禁止文件:${bad}`); } - const size = statSync(zipPath).size; - for (const item of auditZipFile(zipPath, size)) { + const buffer = encodeZip(entries); + for (const item of auditZipFile(zipPath, buffer.length)) { if (item.severity === 'ERROR') throw new Error(item.message); console.warn(`[warn] ${item.message}`); } - console.log(`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB)`); + rmSync(zipPath, { force: true }); + writeFileSync(zipPath, buffer); + console.log( + `zip 已生成:${zipPath}(${entries.length} 个条目,${(buffer.length / MIB).toFixed(2)} MiB)`, + ); } /** From 1ae7ae41c90bfe7252f4a194475d487d515e692b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:41:52 +0800 Subject: [PATCH 136/156] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=E5=AD=90=E9=A1=B5=E5=A4=96=E5=A3=B3=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components/modal/FullscreenSubpage.tsx:新增共享全屏子页,portal + 焦点陷阱 + role=dialog/aria-modal,Esc 不关闭、只能由内容自己的按钮退出。 components/modal/windowChrome.ts:把标题栏放行判据从 ThemedModal 抽出,供两种模态复用。 components/modal/ThemedModal.tsx:改为引用抽出的判据。 view/project-development/export/index.tsx:导出面板改用 FullscreenSubpage,删掉自写的 portal 包裹。 --- .../components/modal/FullscreenSubpage.tsx | 65 +++++++++++++++++++ .../src/components/modal/ThemedModal.tsx | 19 +----- .../src/components/modal/windowChrome.ts | 19 ++++++ .../view/project-development/export/index.tsx | 16 +---- 4 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx create mode 100644 apps/ai-game-creator-shell/src/components/modal/windowChrome.ts diff --git a/apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx b/apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx new file mode 100644 index 000000000..f8787896d --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx @@ -0,0 +1,65 @@ +import { FocusTrap } from 'focus-trap-react'; +import { type ReactNode, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import { isWindowChromeBarTarget } from './windowChrome'; + +/** + * 全屏子页外壳:portal 到 `document.body`,整屏接管,并把键盘焦点锁在里面。 + * + * 与 [`ThemedModal`] 的分工:弹窗是页面上盖一层卡片,子页是整屏换页(导出产物面板、UI 编辑器 + * 这类)。两者都要求焦点留在模态内,但子页**没有关闭语义**: + * - 只有内容自己提供关闭按钮,**Esc 永远不关闭**——编辑 / 填表到一半误按 Esc 就丢工作不可接受; + * - 背景也不接收点击,没有「点遮罩关闭」。 + * + * `platform-theme--light` 只为共享组件提供 `--platform-*` 变量;子页自己的版式与配色由调用方 + * 用 Tailwind 内联值决定。 + */ +export function FullscreenSubpage({ + ariaLabel, + children, + className, +}: { + ariaLabel: string; + children: ReactNode; + /** 追加到固定覆盖层上的类名,例如 UI 编辑器要的 `!bg-transparent !p-0`。 */ + className?: string; +}) { + const panelRef = useRef<HTMLElement | null>(null); + + if (typeof document === 'undefined') return <>{children}</>; + + return createPortal( + <FocusTrap + focusTrapOptions={{ + // 子页没有「按 Esc 取消」的语义:整屏接管期间按 Esc 不能把用户弹出去。 + escapeDeactivates: false, + // 与 ThemedModal 一致:只放行窗口标题栏的最小化 / 最大化 / 关闭,其余外部点击仍被挡。 + allowOutsideClick: (event) => isWindowChromeBarTarget(event.target), + fallbackFocus: () => panelRef.current!, + returnFocusOnDeactivate: true, + }} + > + <div + className={[ + 'platform-theme platform-theme--light fixed inset-0 z-50', + className, + ] + .filter(Boolean) + .join(' ')} + > + <section + ref={panelRef} + className="h-full w-full rounded-none p-0 outline-none focus:outline-none" + role="dialog" + aria-modal="true" + aria-label={ariaLabel} + tabIndex={-1} + > + {children} + </section> + </div> + </FocusTrap>, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx index ec75bfca4..79cb127ba 100644 --- a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx +++ b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx @@ -2,25 +2,10 @@ import { FocusTrap } from 'focus-trap-react'; import { type CSSProperties, type ReactNode, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; +import { isWindowChromeBarTarget } from './windowChrome'; + type ThemedModalTheme = 'light' | 'dark'; -/** - * 自绘标题栏的标记:它是窗口边框,不属于模态内容。 - * - * focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接 - * `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏 - * 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱 - * 挡在模态之外,点空白处不会误触底层界面。 - */ -const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]'; - -function isWindowChromeBarTarget(target: EventTarget | null) { - return ( - target instanceof Element && - target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null - ); -} - export type ThemedModalProps = { open: boolean; ariaLabel: string; diff --git a/apps/ai-game-creator-shell/src/components/modal/windowChrome.ts b/apps/ai-game-creator-shell/src/components/modal/windowChrome.ts new file mode 100644 index 000000000..090120dc6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/windowChrome.ts @@ -0,0 +1,19 @@ +/** + * 自绘标题栏的标记:它是窗口边框,不属于模态内容。 + * + * focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接 + * `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏 + * 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱 + * 挡在模态之外,点空白处不会误触底层界面。 + * + * 单独成文件(而不是从 `ThemedModal` 导出):那边是纯组件文件,多导出一个函数会触发 + * `react-refresh/only-export-components`。 + */ +const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]'; + +export function isWindowChromeBarTarget(target: EventTarget | null) { + return ( + target instanceof Element && + target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx index 0276fc6de..d6cf75ca8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx @@ -1,5 +1,4 @@ -import { createPortal } from 'react-dom'; - +import { FullscreenSubpage } from '../../../components/modal/FullscreenSubpage'; import type { DirectProjectConversationReadGate, DirectProjectConversationWriteGate, @@ -66,16 +65,7 @@ export default function ArtifactsPanel({ </Shell> ); - if (typeof document === 'undefined') return content; - - return createPortal( - // `platform-theme--light` 只为共享组件(工具卡 / 思考块 / 输入区)提供 `--platform-*` - // 变量;本面板自己的版式与配色仍全部走 Tailwind 内联值。 - <div className="platform-theme platform-theme--light fixed inset-0 z-50"> - <section className="h-full w-full rounded-none p-0 outline-none focus:outline-none"> - {content} - </section> - </div>, - document.body, + return ( + <FullscreenSubpage ariaLabel="导出产物面板">{content}</FullscreenSubpage> ); } From 7703dcc1f48c41df006efb34be0a067097902e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:44:17 +0800 Subject: [PATCH 137/156] =?UTF-8?q?=E8=84=9A=E6=9C=AC=E6=B2=A1=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=BD=92=E4=B8=BA=E5=AE=BF=E4=B8=BB=E4=BE=A7=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E8=80=8C=E4=B8=8D=E6=98=AF=E5=91=BD=E4=BB=A4=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/draft/xhs_minitool/build.rs:conclude 增加 script_launched 判据,用 target_exec != established 区分「根本没跑起来」与「跑了被信号杀掉」;前者报 exportUnavailable,由前端既有路径抛进错误池上报,后者维持 commandFailed。补对应单测。 --- .../src/export/draft/xhs_minitool/build.rs | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) 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 index 011748199..fbcaf793a 100644 --- 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 @@ -49,16 +49,16 @@ pub(crate) async fn run( .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, - result.exit_code, - result.timed_out, - &result.output, - ) - }) - .await + offload(move || conclude(&root, exit_code, timed_out, script_launched, &output)).await } /// 构建前置(同步):找到脚本、确认权限点位没被拒、清掉上一次的产物。 @@ -99,16 +99,28 @@ fn prepare(root: &Path) -> Result<NpmScript, XHSMiniToolExportBuildError> { Ok(script) } -/// 把宿主的构建结果收成导出结果:先判成功,再确认产物真的在。 +/// 把宿主的构建结果收成导出结果:先判脚本有没有真的跑起来,再判成功,最后确认产物真的在。 /// -/// 只吃三个字段而不是整份 `ProjectVerificationResult`:这条判据是导出链路里唯一"脚本跑完之后 -/// 算不算数"的规则,抽成纯函数才能脱离"真的跑一遍 npm"逐条测(非零退出、超时、产物缺失、成功)。 +/// 只吃四个字段而不是整份 `ProjectVerificationResult`:这条判据是导出链路里唯一"脚本跑完之后 +/// 算不算数"的规则,抽成纯函数才能脱离"真的跑一遍 npm"逐条测(未启动、非零退出、超时、产物缺失、 +/// 成功)。 fn conclude( root: &Path, exit_code: Option<i32>, timed_out: bool, + script_launched: bool, output: &str, ) -> Result<XHSMiniToolExportRunResult, XHSMiniToolExportBuildError> { + 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 { @@ -216,7 +228,7 @@ mod tests { 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, "vite 构建失败\n入口不存在") + 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"); @@ -230,7 +242,7 @@ mod tests { fn a_timeout_still_reports_command_failed_without_an_exit_code() { let root = fixture_root("timeout"); - let error = conclude(&root, None, true, "已取消") + 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"); @@ -244,7 +256,7 @@ mod tests { fn a_successful_script_without_the_artifact_is_reported_as_missing() { let root = fixture_root("no-artifact"); - let error = conclude(&root, Some(0), false, "构建完成").expect_err("产物缺失必须失败"); + let error = conclude(&root, Some(0), false, true, "构建完成").expect_err("产物缺失必须失败"); let XHSMiniToolExportBuildError::ArtifactMissing(missing) = error else { panic!("没有 zip 必须映射成 artifactMissing"); }; @@ -257,7 +269,7 @@ mod tests { write_artifact(&root, b""); assert!(matches!( - conclude(&root, Some(0), false, "构建完成"), + conclude(&root, Some(0), false, true, "构建完成"), Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) )); } @@ -274,7 +286,7 @@ mod tests { assert!( matches!( - conclude(&root, Some(0), false, "构建完成"), + conclude(&root, Some(0), false, true, "构建完成"), Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) ), "指向项目外非空文件的链接不能算产出成功" @@ -290,6 +302,7 @@ mod tests { &root, Some(0), false, + true, "zip 已生成:.export/xhs-minitool.zip(3 个条目)", ) .expect("产物在就应当成功"); @@ -297,6 +310,20 @@ mod tests { 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` 与可用的命令沙箱,慢且依赖宿主环境,不进常规单测。 From 11b424cebac862f204cdb86cde081af53f357358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:46:05 +0800 Subject: [PATCH 138/156] =?UTF-8?q?=E5=B0=81=E9=9D=A2=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E4=B8=8E=E7=94=9F=E6=88=90=E5=8A=A0=E8=BF=87=E6=97=B6=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components/game-distribution/useGameDistributionPublishForm.ts:新增 coverRequestRef,选择封面与 AI 生成在 await 回来后先比对请求序号再写封面 state,收尾也只由最新一次请求执行;换项目重置时作废在途请求并清掉上传中提示。 tests/gameDistributionPublishForm.test.tsx:补「换项目后旧项目在途的封面上传不再写回新项目」用例。 --- .../useGameDistributionPublishForm.ts | 26 ++++++++++-- .../gameDistributionPublishForm.test.tsx | 41 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts index 44e32bfca..c380d1eed 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts +++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts @@ -310,6 +310,13 @@ export function useGameDistributionPublishForm({ ); const metadataSuggestionRequestRef = useRef(0); const publicationRequestRef = useRef(0); + /** + * 封面选择 / AI 生成的在途请求序号。 + * + * 面板跨项目不卸载,上传 / 生成都要等网络;换项目后旧请求回来时不能把上一张封面写进新项目, + * 也不能清掉新项目正在进行的封面操作的状态。 + */ + const coverRequestRef = useRef(0); const publicationDraftAppliedRef = useRef(false); /** 回读冻结资料里的设备/输入/方向声明:面板不编辑,但更新发布必须原样沿用。 */ const restoredDeclarationsRef = useRef<{ @@ -376,6 +383,9 @@ export function useGameDistributionPublishForm({ setGeneratingCover(false); setCoverGenerationPrice(null); setCoverGenerationDetail(''); + setUploadingLabel(''); + // 换项目即在途的封面上传 / 生成作废:旧请求回来时不许再写封面,也不许清掉新项目的上传状态。 + coverRequestRef.current += 1; // 表单在换项目时重来,封面与截图也必须跟着清:素材是「项目 A 的公开展示物料」, // 留着会被当成项目 B 的封面 / 截图提交。缓存与预览地址一并回收。 setCover(null); @@ -565,18 +575,24 @@ export function useGameDistributionPublishForm({ return; } setUploadingLabel('正在上传封面…'); + const requestId = ++coverRequestRef.current; try { const assetObjectId = await resolveAssetObjectId(file, 'cover'); + if (requestId !== coverRequestRef.current) return; setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); } catch (uploadError) { + if (requestId !== coverRequestRef.current) return; reportError( uploadError instanceof Error ? uploadError.message : '封面上传失败,请重试', ); } finally { - setUploadingLabel(''); - if (coverInputRef.current) coverInputRef.current.value = ''; + // 只有仍然是「这一次」上传才收尾,避免旧请求清掉新请求刚点亮的「正在上传封面…」。 + if (requestId === coverRequestRef.current) { + setUploadingLabel(''); + if (coverInputRef.current) coverInputRef.current.value = ''; + } } } @@ -589,6 +605,7 @@ export function useGameDistributionPublishForm({ reportError(''); setCoverGenerationDetail('正在根据项目内容生成封面…'); setGeneratingCover(true); + const requestId = ++coverRequestRef.current; try { const generated = await generateGameDistributionCover({ prompt: buildCoverGenerationPrompt(manifest, summary), @@ -597,6 +614,7 @@ export function useGameDistributionPublishForm({ imageSize: COVER_GENERATION_IMAGE_SIZE, assetLabel: `${manifest.name.trim() || '游戏'}封面`, }); + if (requestId !== coverRequestRef.current) return; setCover({ signature: generated.taskId ? `generated:${generated.taskId}` @@ -609,6 +627,7 @@ export function useGameDistributionPublishForm({ '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。', ); } catch (generationError) { + if (requestId !== coverRequestRef.current) return; setCoverGenerationDetail(''); reportError( generationError instanceof Error @@ -616,7 +635,8 @@ export function useGameDistributionPublishForm({ : '生成游戏封面失败,请重试', ); } finally { - setGeneratingCover(false); + // 旧请求回来时不收尾:换项目重置或新一次生成已经接管了 `generatingCover`。 + if (requestId === coverRequestRef.current) setGeneratingCover(false); } } diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishForm.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishForm.test.tsx index bd15114e1..78a833e53 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishForm.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishForm.test.tsx @@ -707,6 +707,47 @@ describe('AGC 发布资料表单(内联形态)', () => { expect(publishLocalProjectGame).not.toHaveBeenCalled(); }); + test('换项目后旧项目在途的封面上传不再写回新项目', async () => { + let resolveUpload!: ( + value: Awaited<ReturnType<typeof uploadPlatformMediaAsset>>, + ) => void; + vi.mocked(uploadPlatformMediaAsset).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); + const panelProps = { + open: true, + manifest: MANIFEST, + packageResult: PACKAGE_RESULT, + onClose: vi.fn(), + }; + const { rerender } = render( + <PublishFormHarness {...panelProps} projectPath="/tmp/project-a" />, + ); + + fireEvent.change(screen.getByLabelText(/游戏封面/u), { + target: { files: [buildImageFile('cover-a.png')] }, + }); + // 上传还没回来就换项目:面板跨项目不卸载,旧请求只能作废。 + rerender( + <PublishFormHarness {...panelProps} projectPath="/tmp/project-b" />, + ); + await waitFor(() => expect(uploadPlatformMediaAsset).toHaveBeenCalled()); + + await act(async () => { + resolveUpload({ + assetObjectId: 'asset_cover_stale', + objectKey: 'game-distribution/cover/cover-a.png', + }); + }); + + // 旧项目那一张不能变成新项目的封面;新项目仍然是「还没有封面」。 + expect(screen.getByText('还没有封面')).not.toBeNull(); + expect(screen.queryByText('更换封面图片')).toBeNull(); + }); + test('缺少封面或试玩包时失败关闭且不调用发布接口', async () => { renderPanel(); fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); From a94d8c6cff9b081718887dc820b38cc04d2e0082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:58:02 +0800 Subject: [PATCH 139/156] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=89=93=E5=8C=85?= =?UTF-8?q?=E9=81=8D=E5=8E=86=E4=B8=8E=E9=A2=84=E6=A3=80=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84=E5=8F=96=E8=88=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs:collectZipEntries 补注释,说明它用 statSync 且不跳 SKIP_DIRS、与预检 walkArtifactFiles 的 lstatSync/跳过目录不一致,属于已评审并决定暂不统一的取舍。 resources/agc-skills/manifest.json:同步 xhs 技能指纹并递增版本。 --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../vite-export-xhs-minitool/scripts/pack.mjs | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) 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 8d329e435..0168cf51c 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.67", + "version": "2026-08-26.68", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "ad9b5a6dc1b71e0413a1a71780c60d17076d13fbf67517a939c57565e6d0a477" + "sha256": "016423b1f673b402d8775bad34b78e0e3166cd8021e623bc9ff103fd5d6c4dbd" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 9d69fd6e8..9dbdb3d09 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -130,7 +130,15 @@ function crc32(buffer) { return (c ^ 0xffffffff) >>> 0; } -/** 收集目录下所有条目(目录条目在前,文件名统一 / 分隔,保持排序以稳定产物)。 */ +/** + * 收集目录下所有条目(目录条目在前,文件名统一 / 分隔,保持排序以稳定产物)。 + * + * 已知取舍(评审提出、当前决定不改):本函数用 `statSync`(跟随符号链接)且**不**跳 `SKIP_DIRS`, + * 而校验脚本预检用的 `walkArtifactFiles` 是 `lstatSync`(跳过链接)且会跳 `SKIP_DIRS`。两者口径 + * 不一致:链接目标的内容会被按链接名打进包,`venv` / `.cache` 这类目录也可能绕过扩展名白名单。 + * 不统一是因为「链接算不算合法产物」还没有定论,改任一侧都会动到现有可能依赖链接的包。真要统一 + * 时必须两份实现一起改,并补一条「预检与打包看到同一批文件」的用例。 + */ function collectZipEntries(rootDir) { const entries = []; const visit = (dir) => { From 87c0b2a01f0f879c7e89b025ea9192fc99780b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:58:59 +0800 Subject: [PATCH 140/156] =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=8F=AA=E5=9C=A8=20JS=20=E4=B8=8A=E5=89=A5=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E4=B8=8E=E5=AD=97=E7=AC=A6=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs:scanCode 对 .js 用剥掉注释/字符串的副本匹配 API 名规则,避免注释里的 fetch( 误报;三条要匹配字符串参数的规则标记为扫原文;外部 URL 始终扫原文,CSS 的 url(https://…) 不再被 // 当行注释抹掉。 resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs:补注释/字符串不误报、字符串参数规则仍生效、CSS 与 JS 外部 URL 仍报错三个用例。 resources/agc-skills/manifest.json:同步 xhs 技能指纹并递增版本。 --- .../resources/agc-skills/manifest.json | 4 +- .../scripts/.validate.test.mjs | 45 +++++++++++++++++++ .../scripts/validate.mjs | 27 ++++++++--- 3 files changed, 68 insertions(+), 8 deletions(-) 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 0168cf51c..87ba2cf8a 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.68", + "version": "2026-08-26.69", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "016423b1f673b402d8775bad34b78e0e3166cd8021e623bc9ff103fd5d6c4dbd" + "sha256": "1c2323f06e1b278f910c9aa48ae9c3c27ec351349c0c6a485fdc0603023d27ca" } ] } 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 index bd2077b40..6ef18d7b4 100644 --- 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 @@ -288,6 +288,51 @@ test('字符串与注释里的现代语法 / #hex / #选择器不算命中', () 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, { diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs index 38d7e9108..71208eeb5 100755 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs @@ -45,6 +45,15 @@ const NAMED_ENTITIES = new Map([ ]); const ENTITY_RE = /&(?:#([0-9]+)|#x([0-9a-fA-F]+)|([a-zA-Z][a-zA-Z0-9]*));?/g; +/** + * 「代码里不该出现的 API」清单。每项是 + * `[级别, 代码, 说明, 正则, 是否必须扫原文]`。 + * + * 绝大多数规则匹配 API 名(`fetch(`、`new Function(`、`WebSocket`),必须在 JS 里先剥掉注释与 + * 字符串再扫,否则注释里写一句 `fetch(` 就会被误判。少数规则要匹配的正是字符串参数 + * (`setAttribute('download')`、`execCommand('copy')`、`addEventListener('devicemotion')`), + * 剥掉字符串会让它们彻底失效,所以标 `true` 表示这条扫原文。 + */ const CODE_PATTERNS = [ ['ERROR', 'NETWORK_FETCH', '禁止 fetch 网络请求。', /\bfetch\s*\(/g], ['ERROR', 'NETWORK_XHR', '禁止 XMLHttpRequest 网络请求。', /\bXMLHttpRequest\b/g], @@ -60,9 +69,9 @@ const CODE_PATTERNS = [ ['ERROR', 'SERVICE_WORKER', '禁止 Service Worker。', /\bserviceWorker\b/g], ['ERROR', 'SHARED_ARRAY_BUFFER', '禁止 SharedArrayBuffer 多线程。', /\bSharedArrayBuffer\b/g], ['ERROR', 'GEOLOCATION', '禁止地理定位。', /\b(?:navigator\s*\.\s*)?geolocation\b/g], - ['ERROR', 'CLIPBOARD', '禁止剪贴板 API。', /\bnavigator\s*\.\s*clipboard\b|execCommand\s*\(\s*['"](?:copy|cut|paste)['"]/g], + ['ERROR', 'CLIPBOARD', '禁止剪贴板 API。', /\bnavigator\s*\.\s*clipboard\b|execCommand\s*\(\s*['"](?:copy|cut|paste)['"]/g, true], ['ERROR', 'HARDWARE_API', '禁止蓝牙、USB、HID 或串口 API。', /\bnavigator\s*\.\s*(?:bluetooth|usb|hid|serial)\b/g], - ['ERROR', 'SENSOR_API', '禁止传感器和设备运动/朝向 API。', /\b(?:Accelerometer|Gyroscope|Magnetometer|AmbientLightSensor|DeviceMotionEvent|DeviceOrientationEvent)\b|addEventListener\s*\(\s*['"](?:devicemotion|deviceorientation)['"]/g], + ['ERROR', 'SENSOR_API', '禁止传感器和设备运动/朝向 API。', /\b(?:Accelerometer|Gyroscope|Magnetometer|AmbientLightSensor|DeviceMotionEvent|DeviceOrientationEvent)\b|addEventListener\s*\(\s*['"](?:devicemotion|deviceorientation)['"]/g, true], ['ERROR', 'SCREEN_API', '禁止屏幕共享或 requestFullscreen。', /\b(?:getDisplayMedia|(?:webkit)?[rR]equestFullscreen)\b/g], ['ERROR', 'DEVICE_INFO', '禁止电池、网络信息或媒体设备枚举。', /\b(?:getBattery|enumerateDevices)\s*\(|navigator\s*\.\s*connection\b/g], ['ERROR', 'PERSISTENT_STORAGE', '禁止请求持久化存储或跨域存储访问。', /\bnavigator\s*\.\s*storage\s*\.\s*(?:persist|persisted|getDirectory)\s*\(|\bdocument\s*\.\s*requestStorageAccess\s*\(/g], @@ -78,7 +87,7 @@ const CODE_PATTERNS = [ ['ERROR', 'BACKGROUND_SYNC', '移动端容器不支持后台同步。', /\b(?:SyncManager|PeriodicSyncManager)\b/g], ['ERROR', 'POINTER_KEYBOARD_LOCK', '移动端容器不支持指针或键盘锁定。', /\brequestPointerLock\b|\bnavigator\s*\.\s*keyboard\s*\.\s*lock\b/g], ['ERROR', 'WINDOW_MANAGEMENT', '移动端容器不支持窗口管理 API。', /\bgetScreenDetails\b/g], - ['ERROR', 'PROGRAMMATIC_DOWNLOAD', '容器禁止程序化文件下载。', /\.download\s*=|setAttribute\s*\(\s*['"]download['"]/g], + ['ERROR', 'PROGRAMMATIC_DOWNLOAD', '容器禁止程序化文件下载。', /\.download\s*=|setAttribute\s*\(\s*['"]download['"]/g, true], ['WARNING', 'DYNAMIC_IMPORT', '动态 import 可能产生运行时资源加载;确认目标为包内静态资源。', /\bimport\s*\(/g], ['WARNING', 'COOKIE_REVIEW', 'Cookie 只能作为本地存储;不要用于服务端登录态或鉴权。', /\bdocument\s*\.\s*cookie\b/g], ['WARNING', 'NAVIGATION_REVIEW', '检测到页面导航 API;确认不会打开外链、新窗口或其他小工具。', /\blocation\s*\.\s*href\s*=(?!=)|\b(?:location\s*\.\s*(?:assign|replace)|history\s*\.\s*(?:pushState|replaceState))\s*\(/g], @@ -453,13 +462,19 @@ export function scanCssReferences(path, text, collector, baseLine = 1) { export function scanCode(path, text, collector) { const lineAt = createLineCounter(text); + const isJs = extname(path).toLowerCase() === '.js'; + // JS 先剥掉注释与字符串再扫 API 名,避免把注释里的 `fetch(` 当成真实调用;CSS / HTML 没有这层 + // 噪声可剥,原样扫。标了「必须扫原文」的规则仍用 `text`(见 CODE_PATTERNS 的说明)。 + const apiText = isJs ? stripJsNoise(text) : text; - for (const [severity, code, message, pattern] of CODE_PATTERNS) { - for (const match of text.matchAll(pattern)) { + for (const [severity, code, message, pattern, needsRawText] of CODE_PATTERNS) { + for (const match of (needsRawText === true ? text : apiText).matchAll(pattern)) { collector.add(severity, code, path, lineAt(match.index), message); } } + // 外部 URL 必须在原文上扫:真实的 `fetch('https://...')` 就在字符串里,剥掉字符串会漏检; + // 另外 `stripJsNoise` 把 `//` 当行注释,CSS 的 `url(https://...)` 会被从 `//` 起整段抹掉。 for (const match of text.matchAll(EXTERNAL_URL_RE)) { const value = match[0]; if (NONNETWORK_NAMESPACE_URLS.has(value)) continue; @@ -467,7 +482,7 @@ export function scanCode(path, text, collector) { `检测到外部 URL:${value}。所有资源和数据必须本地打包。`); } - if (extname(path).toLowerCase() === '.js') { + if (isJs) { scanJsSyntax(path, text, collector); } From afe5785f87cfee35297cd84f3a1b62561f40f797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:59:05 +0800 Subject: [PATCH 141/156] =?UTF-8?q?=E4=BF=AE=E9=94=99=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A=E4=B8=8E=E5=AE=9E=E7=8E=B0=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view/project-development/export/state/xhsMinitoolInstruction.ts:buildXhsMinitoolErrorFixInstruction 的文档注释不再承诺「要求 agent 先复现再修」,改成与实现一致——正文只有 error 现场,怎么修由 agent 自行判断。 --- .../export/state/xhsMinitoolInstruction.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts index ffcea1290..2c377b688 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts @@ -55,13 +55,14 @@ export function buildXhsMinitoolAdaptationInstruction(): string { /** * 按失败现场修复(error fix)指令:项目已有打包脚本、但构建 / 产物 / 注册表出错时走这条。 * - * 与 bootstrap 的分工:这条**不重讲一遍适配**,而是把宿主报的原始错误摆在最前面,要求 agent 先 - * 复现同一个失败再定位修掉。没有脚本(`buildScriptMissing`)时不要走这里——那是首次适配,走 + * 与 bootstrap 的分工:这条**不重讲一遍适配**,只把宿主报的原始错误摆在最前面。没有脚本 + * (`buildScriptMissing`)时不要走这里——那是首次适配,走 * [buildXhsMinitoolAdaptationInstruction]。 * - * 现场就是宿主那个结构化错误本身:这里只 `JSON.stringify`,不翻译、不摘要、不另抽一份「给 agent 的 - * 说法」。用户侧的句子由 `xhsMinitoolFailure.ts` 独家负责,两边不共享文案。也不要项目根:agent 就 - * 工作在项目根里。 + * 正文只有 `error: {...}`,不附带复现 / 定位步骤:怎么修由 agent 按 skill 自行判断,这里只负责 + * 把现场原样交给它。现场就是宿主那个结构化错误本身:这里只 `JSON.stringify`,不翻译、不摘要、不另抽 + * 一份「给 agent 的说法」。用户侧的句子由 `xhsMinitoolFailure.ts` 独家负责,两边不共享文案。也不要 + * 项目根:agent 就工作在项目根里。 */ export function buildXhsMinitoolErrorFixInstruction( error: XhsMinitoolCardFailure, From 28d1a97730e388db037d77bf99a42111ebadd50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:59:11 +0800 Subject: [PATCH 142/156] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=A4=E4=B8=AA=20?= =?UTF-8?q?tab=20=E5=9B=9E=E5=90=88=E6=B4=BE=E7=94=9F=E6=9A=82=E4=B8=8D?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=9A=84=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view/project-development/export/state/useXiaohongshuTab.ts 与 useTaonierTab.ts:在 turns 派生处补注释,说明这段与另一 tab 的重复是有意保留——两个 tab 以后可能各自订阅不同会话/线程,等形状真分叉再抽公共 helper。 --- .../src/view/project-development/export/state/useTaonierTab.ts | 2 ++ .../view/project-development/export/state/useXiaohongshuTab.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index ee256d00b..35b2ec831 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -139,6 +139,8 @@ export function useTaonierTab({ projectPath, refreshManifest: refresh, }); + // 同 `useXiaohongshuTab`:这段派生与那边目前几乎逐字相同,但有意不抽公共 helper——两个 tab + // 以后可能各自订阅不同的会话 / 线程,形状会分叉,等真分叉了再抽。 const turns = useMemo( () => buildDirectChatTurns({ diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts index b54b43fe6..04c0856bf 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXiaohongshuTab.ts @@ -65,6 +65,9 @@ export function useXiaohongshuTab({ projectPath, refreshManifest: refresh, }); + // 这三段派生(turns / turnStatus / activeTurnStartedAt)与 `useTaonierTab` 里的写法目前几乎逐字 + // 相同。**有意不抽公共 helper**:两个 tab 现在接的是同一条会话,但以后很可能各自订阅不同的会话 / + // 线程,届时入参就会分叉。等它们真的分叉时再按各自需要抽,比现在先猜一个公共形状更便宜。 const turns = useMemo( () => buildDirectChatTurns({ From 3a4b58e8345d9fe75bad60d88b37a515af5cba8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 21:59:17 +0800 Subject: [PATCH 143/156] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E8=AF=BB=E5=86=99=E4=B8=8D=E9=A2=9D=E5=A4=96=E6=8B=92?= =?UTF-8?q?=E9=93=BE=E7=9A=84=E5=8F=96=E8=88=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export/draft/xhs_minitool/registry.rs:模块注释补一段已知取舍,说明这里读写注册表不检查符号链接、与产物/脚本/图标的拒链口径不同,属于已评审并决定保留的行为。 --- .../src-tauri/src/export/draft/xhs_minitool/registry.rs | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 9a00a6027..6dc886e2a 100644 --- 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 @@ -2,6 +2,12 @@ //! //! 文件级细节(自动创建、严格解析、原子写入、内容指纹)都在 [`crate::export::registry`];这里 //! 只管本目标的事:注册表 ↔ 表单的映射,以及冲突判定。 +//! +//! 已知取舍(评审提出、当前决定不改):这里用共享的 `load_or_create` / `save`,**不**额外检查 +//! 注册表文件与 `.export` 目录是不是符号链接。也就是说,如果 `.export/xhs-minitool.json` 被换成 +//! 链接,读写会落到项目外。这与产物(`build::conclude`)、脚本(`script::find`)、图标 +//! (`validation`)三处的拒链口径不同,是有意为之:那三处是「把项目内容交给打包链」的入口, +//! 这里读写的是用户自己项目里的草稿文件,按「项目文件由用户掌控」的前提接受这个重定向。 use std::path::Path; From 26120af5ac9e21f303a2d40f48298749d03e0a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:00:23 +0800 Subject: [PATCH 144/156] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E4=BE=A7=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E4=B8=8D=E5=86=8D=E8=90=BD=E9=9D=A2=E6=9D=BF=E5=8D=A1?= =?UTF-8?q?=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view/project-development/export/state/useXhsMinitoolExport.ts:refresh 与 buildOnce 遇到 exportUnavailable 不再 setFailure,直接抛给全局错误池上报;persist 同样只上报不落卡片。 tests/xhsMinitoolExport.test.tsx:更新为断言 failure 始终为空、同一个故障只抛一次。 --- .../export/state/useXhsMinitoolExport.ts | 19 ++++++++++--------- .../tests/xhsMinitoolExport.test.tsx | 12 +++++------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index f47257810..05938b2bc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -241,11 +241,12 @@ export function useXhsMinitoolExport({ throwToErrorPool(String(error), error); return null; } - setFailure(structured); - // 宿主侧事实故障:先让面板显示现场,再抛进错误池(策略拒绝等预期拒绝不走这一步)。 if (structured.type === 'exportUnavailable') { + // 宿主侧事实故障:面板不接管(不留失败卡片),直接抛给全局错误池上报;下一次轮询会自然重试。 throwToErrorPool(JSON.stringify(structured), error); + return null; } + setFailure(structured); return null; } finally { if (isInitialRead) { @@ -303,12 +304,10 @@ export function useXhsMinitoolExport({ } if (structured.type === 'formInvalid') setFieldError(structured); else if (structured.type === 'saveConflict') setConflict(structured); - else { - setFailure(structured); - if (structured.type === 'exportUnavailable') { - throwToErrorPool(JSON.stringify(structured), error); - } - } + else if (structured.type === 'exportUnavailable') { + // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 + throwToErrorPool(JSON.stringify(structured), error); + } else setFailure(structured); return false; } }, @@ -367,10 +366,12 @@ export function useXhsMinitoolExport({ throwToErrorPool(String(error), error); return; } - setFailure(structured); if (structured.type === 'exportUnavailable') { + // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 throwToErrorPool(JSON.stringify(structured), error); + return; } + setFailure(structured); } }, [projectPath, throwToErrorPool]); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 3876b4a29..56c5315ee 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -1017,7 +1017,7 @@ describe('状态 hook', () => { await expect(result.current.packageAll()).resolves.toBeUndefined(); }); - it('宿主侧事实故障先给卡片、再把同一个故障抛一次', async () => { + it('宿主侧事实故障原样抛出进错误池,面板不留卡片', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_xhs_minitool_export') { return { form: FORM, hasScript: true }; @@ -1036,18 +1036,16 @@ describe('状态 hook', () => { ); await waitFor(() => expect(result.current.form).toEqual(FORM)); - // 卡片照常给现场,同时原样抛出(真实 WebView 里由全局 handler 结算进错误池)。 + // 宿主故障交给全局错误池上报,面板不接管:原样抛出,failure 保持为空。 await act(async () => { await expect(result.current.packageAll()).rejects.toBeInstanceOf(Error); }); - // 面板留下的就是宿主那个原始错误,用户那句话是渲染时从变体推的。 - expect(result.current.failure).toEqual({ - type: 'exportUnavailable', - cause: '项目根不可用:找不到目录', - }); + expect(result.current.failure).toBeNull(); + // 同一个故障只抛一次(轮询会反复读到它,不能让错误池的 count 变成轮询次数)。 await act(async () => { await expect(result.current.packageAll()).resolves.toBeUndefined(); }); + expect(result.current.failure).toBeNull(); }); it('帮我填:先把没保存的输入落盘,再把改注册表这件事交给同一个会话', async () => { From 90da09f961c8650b02cdb351b4b9a18eb731fc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:04:07 +0800 Subject: [PATCH 145/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=20hook=20=E6=8D=A2?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=97=B6=E6=95=B4=E5=A5=97=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E4=B8=A2=E5=BC=83=E8=BF=87=E6=97=B6=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view/project-development/export/state/useXhsMinitoolExport.ts:新增 projectPath 重置效果,把表单/判据/冲突/产物状态与全部本地基线清回初值;读内容、取指纹、保存、构建各处在 await 回来后用 projectPathRef 校验结果仍属于当前项目,过时就丢弃,不再写回新项目。 tests/xhsMinitoolExport.test.tsx:补换项目后表单与产物判据清空、并读回新项目内容的用例。 --- .../export/state/useXhsMinitoolExport.ts | 64 +++++++++++++++++-- .../tests/xhsMinitoolExport.test.tsx | 43 +++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 05938b2bc..67f404d05 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -134,6 +134,11 @@ export function useXhsMinitoolExport({ /** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */ const reportedFailureRef = useRef(''); const formRef = useRef<XHSMiniToolExportForm | null>(null); + /** + * 当前项目路径的即时值。异步结果(读内容、取指纹、保存、构建)回来时用它判断结果还属不属于 + * 当前项目:面板根跨项目不卸载,旧请求回来时绝不能把上一个项目的表单 / 基线写进新项目。 + */ + const projectPathRef = useRef(projectPath); useEffect(() => { formRef.current = form; @@ -143,6 +148,36 @@ export function useXhsMinitoolExport({ adoptableRef.current = !dirty && !conflict && !fieldError; }, [dirty, conflict, fieldError]); + /** + * 换项目时把整套状态与本地判据清回初值。 + * + * 面板根跨项目不卸载,而这个 hook 的状态默认都属于「当前项目」:不清就会拿 A 的表单、写回基线 + * 与产物判据去操作 B。清完之后,下面依赖 `projectPath` 的轮询效果会立刻为新项目重读一次。 + * + * `initialReadSettledRef` 必须一起复位:它决定 `loading` 与首次采纳,留着 true 会让新项目的 + * 第一次读取被当成「非首次轮询」而直接早返回,旧表单就会继续留在界面上。 + */ + useEffect(() => { + projectPathRef.current = projectPath; + setForm(null); + setHasScript(false); + setFieldError(null); + setConflict(null); + setFailure(null); + setDismissedFailure(''); + setLoading(false); + setReadSettled(false); + setRunning(false); + setArtifactReady(false); + setDirty(false); + setAdaptPromptDismissed(false); + baselineRef.current = ''; + hashPendingRef.current = false; + initialReadSettledRef.current = false; + reportedFailureRef.current = ''; + formRef.current = null; + }, [projectPath]); + // 脚本出现之后就把提示重新武装:它要是又被删掉,用户该再看到一次阻断提示。 useEffect(() => { if (hasScript) setAdaptPromptDismissed(false); @@ -192,6 +227,8 @@ export function useXhsMinitoolExport({ invoke, projectPath, ); + // 读回来之前换了项目:这份内容属于上一个项目,不能写进新项目的状态。 + if (projectPathRef.current !== projectPath) return null; setHasScript(content.hasScript); // 内容读回来了,说明宿主与注册表现在读得动:读来源的失败(注册表坏了 / 宿主不可用)就地 // 清掉,别让弹窗卡在已经好了的现场上。用函数式更新拿最新 `failure`,既不把 `failure` 加进 @@ -221,19 +258,25 @@ export function useXhsMinitoolExport({ setConflict(null); setFailure(null); try { - baselineRef.current = await readXhsMinitoolExportHash( + const contentHash = await readXhsMinitoolExportHash( invoke, projectPath, ); + // 基线属于项目:换过项目就不能拿这份指纹去当新项目的写回基线。 + if (projectPathRef.current !== projectPath) return null; + baselineRef.current = contentHash; hashPendingRef.current = false; } catch (error) { // 指纹没取到:表单已经采纳、基线却还停在旧值。标成 pending,下一次轮询跳过上面的早返回 // 把指纹补上;失败本身照旧交给外层 catch 报给用户。 + if (projectPathRef.current !== projectPath) return null; hashPendingRef.current = true; throw error; } return content.hasScript; } catch (error) { + // 旧项目的失败不该记到新项目头上(也不该为新项目弹卡片)。 + if (projectPathRef.current !== projectPath) return null; const structured = asXhsMinitoolReadFailure(error); if (!structured) { // 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):现场原样抛给错误池 @@ -249,7 +292,8 @@ export function useXhsMinitoolExport({ setFailure(structured); return null; } finally { - if (isInitialRead) { + // 旧项目的收尾不翻新项目的「首次读取」判据:新项目那次 refresh 会自己收尾。 + if (isInitialRead && projectPathRef.current === projectPath) { initialReadSettledRef.current = true; setLoading(false); setReadSettled(true); @@ -281,12 +325,15 @@ export function useXhsMinitoolExport({ const invoke = resolveTauriInvoke(); if (!invoke) return false; try { - baselineRef.current = await saveXhsMinitoolExportForm(invoke, { + const nextBaseline = await saveXhsMinitoolExportForm(invoke, { projectPath, form: next, baseHash, }); + // 保存期间换了项目:这份返回值属于上一个项目,不能拿来当新项目的写回基线。 + if (projectPathRef.current !== projectPath) return false; // 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。 + baselineRef.current = nextBaseline; hashPendingRef.current = false; setFieldError(null); setConflict(null); @@ -294,6 +341,8 @@ export function useXhsMinitoolExport({ setDirty(false); return true; } catch (error) { + // 旧项目的失败不该记到新项目头上。 + if (projectPathRef.current !== projectPath) return false; // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 setDirty(false); @@ -358,8 +407,12 @@ export function useXhsMinitoolExport({ setFailure(null); try { await runXhsMinitoolExportBuild(invoke, projectPath); + // 构建期间换了项目:这次产物属于上一个项目,不能让新项目出现可下载的按钮。 + if (projectPathRef.current !== projectPath) return; setArtifactReady(true); } catch (error) { + // 旧项目的构建失败不该记到新项目头上。 + if (projectPathRef.current !== projectPath) return; setArtifactReady(false); const structured = asXhsMinitoolBuildFailure(error); if (!structured) { @@ -393,6 +446,8 @@ export function useXhsMinitoolExport({ setRunning(true); try { const hasScriptNow = await refresh(); + // 读期间换了项目:这次「有没有脚本」的结论属于上一个项目。 + if (projectPathRef.current !== projectPath) return; if (hasScriptNow !== true) { // 弹窗重新武装起来就是给用户的交代,状态行不再复述一遍。 setAdaptPromptDismissed(false); @@ -407,7 +462,8 @@ export function useXhsMinitoolExport({ if (conflict || fieldError) return; await buildOnce(); } finally { - setRunning(false); + // 旧项目那一次打包的收尾不解新项目的锁。 + if (projectPathRef.current === projectPath) setRunning(false); } }, [ projectPath, diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 56c5315ee..19abffa35 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -798,6 +798,49 @@ describe('状态 hook', () => { expect(result.current.form).toEqual(FORM); }); + it('换项目时清掉上一份表单与产物判据,并读回新项目的内容', async () => { + const OTHER_PATH = '/tmp/export-panel-project-2'; + const OTHER_FORM = { + name: '另一个小工具', + introduction: '另一个简介', + iconPath: 'assets/other.png', + }; + const invoke = vi.fn( + async (command: string, args?: { projectPath?: string }) => { + if (command === 'read_xhs_minitool_export') { + return args?.projectPath === PROJECT_PATH + ? { form: FORM, hasScript: true } + : { form: OTHER_FORM, hasScript: false }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + return { outputTail: 'zip 已生成', omittedCharacters: 0 }; + }, + ); + installTauriRuntime({ core: { invoke } }); + const { result, rerender } = renderHook( + ({ projectPath }: { projectPath: string }) => + useXhsMinitoolExport({ + enabled: true, + projectPath, + submitTurn: vi.fn(async () => true), + }), + { initialProps: { projectPath: PROJECT_PATH } }, + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + await act(async () => { + await result.current.packageAll(); + }); + expect(result.current.artifactReady).toBe(true); + + rerender({ projectPath: OTHER_PATH }); + // 新项目第一次读取落定之前,界面不能还显示上一个项目的表单与可下载产物。 + expect(result.current.form).toBeNull(); + expect(result.current.artifactReady).toBe(false); + + await waitFor(() => expect(result.current.form).toEqual(OTHER_FORM)); + expect(result.current.hasScript).toBe(false); + }); + it('打包成功才有下载入口,失败会收回去', async () => { let fail = false; const invoke = vi.fn(async (command: string) => { From b9d0276a467cb3d783733b228d4c5e8062274eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:08:30 +0800 Subject: [PATCH 146/156] =?UTF-8?q?=E8=A1=A8=E5=8D=95=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E4=B8=B2=E8=A1=8C=E5=86=99=E7=9B=98=E9=94=81?= =?UTF-8?q?=EF=BC=8C=E5=86=99=E7=9B=98=E6=9C=9F=E9=97=B4=E7=9A=84=E6=96=B0?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E4=B8=8D=E4=BC=9A=E8=A2=AB=E6=BC=8F=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view/project-development/export/state/formSaveLock.ts:新增写盘锁,串行执行保存,并按进入时的编辑版本判断这次写的是不是最后一版。 view/project-development/export/state/useXhsMinitoolExport.ts:persist 走锁,写盘期间又有编辑时不清 dirty,让已排队的自动保存接着写最新一版;updateField 与 resolveConflict 记编辑版本。 tests/formSaveLock.test.ts:覆盖串行、被后来的编辑超过、最后一版、失败不卡队列。 tests/xhsMinitoolExport.test.tsx:补「保存期间继续输入,最后一版仍会落盘」的回归用例。 --- .../export/state/formSaveLock.ts | 51 ++++++++++++ .../export/state/useXhsMinitoolExport.ts | 81 +++++++++++-------- .../tests/formSaveLock.test.ts | 73 +++++++++++++++++ .../tests/xhsMinitoolExport.test.tsx | 56 +++++++++++++ 4 files changed, 228 insertions(+), 33 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/state/formSaveLock.ts create mode 100644 apps/ai-game-creator-shell/tests/formSaveLock.test.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/formSaveLock.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/formSaveLock.ts new file mode 100644 index 000000000..39de0e89b --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/formSaveLock.ts @@ -0,0 +1,51 @@ +/** + * 表单写盘的串行锁。 + * + * 为什么需要它:写盘要等网络。等待期间用户可能又敲了一版表单,那一版会在 `AUTOSAVE_DELAY_MS` 之后 + * 再排一次写盘。如果先回来的那次写盘无条件清掉 `dirty`,自动保存效果会认为「已经存好了」而把后 + * 一次的定时器取消掉,最后一版输入就永远不落盘。 + * + * 锁管两件事: + * 1. **串行**:同一时刻只跑一次写盘,后来的调用排队,不会并发打宿主; + * 2. **是否被后来的编辑超过**:每次 `run` 记下进入时的编辑版本,写盘回来时再比一次。版本变了, + * 说明这次写的不是最后一版,调用方就不该清 `dirty`,排队的下一次写盘会接着写最新一版。 + * + * 编辑版本由 `markEdited()` 推进:只有用户真的改了表单才加,读回来的内容不在此列。 + */ +export type FormSaveLock = { + /** 记录一次表单编辑(用户在界面上改了字段、或逐字段解冲突)。 */ + markEdited: () => void; + /** + * 串行执行一次写盘。 + * + * `saved` 是任务本身的结果;`superseded` 表示这次写盘期间表单又被编辑过——此时 `saved` 虽然为 + * 真,写的却不是最后一版,调用方不能清 `dirty`。 + */ + run: ( + save: () => Promise<boolean>, + ) => Promise<{ saved: boolean; superseded: boolean }>; +}; + +export function createFormSaveLock(): FormSaveLock { + let chain: Promise<unknown> = Promise.resolve(); + let editVersion = 0; + + return { + markEdited() { + editVersion += 1; + }, + run(save) { + const versionAtStart = editVersion; + const task = chain.then(async () => { + const saved = await save(); + return { saved, superseded: editVersion !== versionAtStart }; + }); + // 队列不能因为一次失败就断掉:这次任务的结果照旧返回给调用方,链本身只吞掉拒绝。 + chain = task.then( + () => undefined, + () => undefined, + ); + return task; + }, + }; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 67f404d05..0d071d68c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -24,6 +24,7 @@ import type { FormInvalid } from '../generated/FormInvalid'; import type { SaveConflict } from '../generated/SaveConflict'; import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState'; +import { createFormSaveLock } from './formSaveLock'; import { asXhsMinitoolBuildFailure, asXhsMinitoolReadFailure, @@ -139,6 +140,8 @@ export function useXhsMinitoolExport({ * 当前项目:面板根跨项目不卸载,旧请求回来时绝不能把上一个项目的表单 / 基线写进新项目。 */ const projectPathRef = useRef(projectPath); + /** 表单写盘的串行锁:一次只写一版,并告诉调用方这次写的是不是最后一版。 */ + const saveLockRef = useRef(createFormSaveLock()); useEffect(() => { formRef.current = form; @@ -318,47 +321,56 @@ export function useXhsMinitoolExport({ * 这是**唯一**做表单硬校验的地方(`validate_form` 在保存命令里):自动保存到点就调它,「打包」 * 要落盘手上没保存的输入也调它。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等 * 用户处理,不拿一份没通过校验的表单去构建。 + * + * 写盘经由 [FormSaveLock]:同一时刻只写一版。写盘期间用户又改了表单时,这次的结果虽然成功,也 + * **不能**清 `dirty`——否则自动保存效果会把后一版的定时器当成「已经存好了」取消,最后一版输入 + * 就永远不落盘。 */ const persist = useCallback( async (next: XHSMiniToolExportForm, baseHash: string): Promise<boolean> => { if (!projectPath) return false; const invoke = resolveTauriInvoke(); if (!invoke) return false; - try { - const nextBaseline = await saveXhsMinitoolExportForm(invoke, { - projectPath, - form: next, - baseHash, - }); - // 保存期间换了项目:这份返回值属于上一个项目,不能拿来当新项目的写回基线。 - if (projectPathRef.current !== projectPath) return false; - // 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。 - baselineRef.current = nextBaseline; - hashPendingRef.current = false; - setFieldError(null); - setConflict(null); - setFailure(null); - setDirty(false); - return true; - } catch (error) { - // 旧项目的失败不该记到新项目头上。 - if (projectPathRef.current !== projectPath) return false; - // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 - // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 - setDirty(false); - const structured = asXhsMinitoolSaveFailure(error); - if (!structured) { - throwToErrorPool(String(error), error); + const { saved, superseded } = await saveLockRef.current.run(async () => { + try { + const nextBaseline = await saveXhsMinitoolExportForm(invoke, { + projectPath, + form: next, + baseHash, + }); + // 保存期间换了项目:这份返回值属于上一个项目,不能拿来当新项目的写回基线。 + if (projectPathRef.current !== projectPath) return false; + // 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。 + baselineRef.current = nextBaseline; + hashPendingRef.current = false; + setFieldError(null); + setConflict(null); + setFailure(null); + return true; + } catch (error) { + // 旧项目的失败不该记到新项目头上。 + if (projectPathRef.current !== projectPath) return false; + // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 + // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 + setDirty(false); + const structured = asXhsMinitoolSaveFailure(error); + if (!structured) { + throwToErrorPool(String(error), error); + return false; + } + if (structured.type === 'formInvalid') setFieldError(structured); + else if (structured.type === 'saveConflict') setConflict(structured); + else if (structured.type === 'exportUnavailable') { + // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 + throwToErrorPool(JSON.stringify(structured), error); + } else setFailure(structured); return false; } - if (structured.type === 'formInvalid') setFieldError(structured); - else if (structured.type === 'saveConflict') setConflict(structured); - else if (structured.type === 'exportUnavailable') { - // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 - throwToErrorPool(JSON.stringify(structured), error); - } else setFailure(structured); - return false; - } + }); + // 成功且期间没有新编辑,才说明「最后一版已经落盘」;被后来的编辑超过时留着 `dirty`, + // 让已经排上的下一次自动保存接着写。 + if (saved && !superseded) setDirty(false); + return saved; }, [projectPath, throwToErrorPool], ); @@ -374,6 +386,8 @@ export function useXhsMinitoolExport({ /** 改一个字段。改哪个字段就清哪个字段的旧报错——别让已经改好的红字继续挂着。 */ const updateField = useCallback( (field: keyof XHSMiniToolExportForm, value: string): void => { + // 记进写盘锁:正在写的那一版回来时会看到「已被超过」,从而不清 `dirty`。 + saveLockRef.current.markEdited(); setForm((previous) => previous ? { ...previous, [field]: value } : previous, ); @@ -389,6 +403,7 @@ export function useXhsMinitoolExport({ const resolveConflict = useCallback( (resolved: XHSMiniToolExportForm): void => { if (!conflict) return; + saveLockRef.current.markEdited(); setForm(resolved); void persist(resolved, conflict.contentHash); }, diff --git a/apps/ai-game-creator-shell/tests/formSaveLock.test.ts b/apps/ai-game-creator-shell/tests/formSaveLock.test.ts new file mode 100644 index 000000000..308f5a5ad --- /dev/null +++ b/apps/ai-game-creator-shell/tests/formSaveLock.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +/** + * 表单写盘锁的行为边界:串行、以及「这次写的是不是最后一版」。 + */ +import { describe, expect, it } from 'vitest'; + +import { createFormSaveLock } from '../src/view/project-development/export/state/formSaveLock'; + +describe('表单写盘锁', () => { + it('串行执行:前一次结束前不会开始下一次', async () => { + const lock = createFormSaveLock(); + const order: string[] = []; + let releaseFirst!: () => void; + const first = lock.run(async () => { + order.push('first:start'); + await new Promise<void>((resolve) => { + releaseFirst = resolve; + }); + order.push('first:end'); + return true; + }); + const second = lock.run(async () => { + order.push('second:start'); + return true; + }); + + // 让第一个任务跑起来并挂住:第二个还没轮到。 + await Promise.resolve(); + expect(order).toEqual(['first:start']); + + releaseFirst(); + await first; + await second; + expect(order).toEqual(['first:start', 'first:end', 'second:start']); + }); + + it('写盘期间又编辑过:这次结果标记为已被超过', async () => { + const lock = createFormSaveLock(); + let release!: () => void; + const task = lock.run(async () => { + await new Promise<void>((resolve) => { + release = resolve; + }); + return true; + }); + await Promise.resolve(); + lock.markEdited(); + release(); + + await expect(task).resolves.toEqual({ saved: true, superseded: true }); + }); + + it('写完没有再编辑:就是最后一版', async () => { + const lock = createFormSaveLock(); + await expect(lock.run(async () => true)).resolves.toEqual({ + saved: true, + superseded: false, + }); + }); + + it('一次任务失败不会卡住后面的写盘', async () => { + const lock = createFormSaveLock(); + await expect( + lock.run(async () => { + throw new Error('写盘失败'); + }), + ).rejects.toThrow('写盘失败'); + await expect(lock.run(async () => true)).resolves.toEqual({ + saved: true, + superseded: false, + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 19abffa35..ad32a5855 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -732,6 +732,62 @@ describe('状态 hook', () => { expect(Object.keys(result.current)).not.toContain('dirty'); }); + it('保存期间继续输入时,先回来的那次不清待保存状态,最后一版仍会落盘', async () => { + const savedNames: string[] = []; + let releaseFirstSave!: () => void; + let saveCount = 0; + const invoke = vi.fn( + async (command: string, args?: { form?: { name: string } }) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + if (command === 'save_xhs_minitool_export_form') { + saveCount += 1; + savedNames.push(args?.form?.name ?? ''); + if (saveCount === 1) { + await new Promise<void>((resolve) => { + releaseFirstSave = resolve; + }); + } + return `hash-${saveCount}`; + } + return undefined; + }, + ); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + // 第一版输入:自动保存发出后挂在宿主那一步。 + await act(async () => { + result.current.updateField('name', '第一版'); + }); + await waitFor(() => expect(saveCount).toBe(1), { timeout: 3000 }); + + // 第一次保存还没回来,用户又敲了一版:这一版也必须被写下去,不能因为第一次回来 + // 清掉 `dirty` 就让它的定时器被取消。 + await act(async () => { + result.current.updateField('name', '第二版'); + }); + await act(async () => { + releaseFirstSave(); + }); + // 先让 React 把第一次保存的结果与效果清理跑完,再等第二版的自动保存定时器。 + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + }); + + expect(savedNames).toEqual(['第一版', '第二版']); + expect(result.current.form?.name).toBe('第二版'); + }); + it('用户手上有未保存输入时,自动重读只更新脚本判据、不动表单', async () => { let diskForm = { ...FORM }; const invoke = vi.fn(async (command: string) => { From ab8a6d13210892e16d825b64c045b4322794533d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:13:10 +0800 Subject: [PATCH 147/156] =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=BC=B9=E7=AA=97=E4=BA=A4=E4=BB=A3=E5=B9=B6?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E5=9C=A8=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8=E4=B8=AD=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src-tauri/src/project/asset_export.rs:新增按保存同一条路径门禁解析产物位置的能力,供文件管理器定位复用。 src-tauri/src/commands/desktop.rs:新增 reveal_local_project_asset_file 命令,用系统文件管理器定位项目内产物。 src-tauri/src/desktop.rs:注册新命令。 view/project-development/export/state/xhsMinitoolDownload.ts:保存结果改为带失败原因,新增在文件管理器里定位原件的兜底调用。 view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx:新增下载失败弹窗,给「关闭」与「在文件管理器中打开」两条路。 view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx:下载失败改用弹窗与兜底出口,去掉会被滚出视野的一行小字。 tests/xhsMinitoolExport.test.tsx:覆盖失败弹窗、定位命令参数与定位失败留在弹窗。 --- .../src-tauri/src/commands/desktop.rs | 16 ++++ .../src-tauri/src/desktop.rs | 1 + .../src-tauri/src/project/asset_export.rs | 32 +++++++ .../export/state/xhsMinitoolDownload.ts | 76 +++++++++++++--- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 86 ++++++++++++++----- .../xiaohongshu/DownloadFailureDialog.tsx | 71 +++++++++++++++ .../tests/xhsMinitoolExport.test.tsx | 43 ++++++++-- 7 files changed, 288 insertions(+), 37 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx 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 e2e7080a1..7f4e1aadf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -668,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, 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<PathBuf, String> { + 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/view/project-development/export/state/xhsMinitoolDownload.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts index f350ff7c7..e7879114b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts @@ -4,17 +4,29 @@ * 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护 * 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。 * - * 返回值说明这次到底存没存下来:`saved`(宿主写完)、`cancelled`(用户关掉对话框)、`failed` - * (对话框打不开 / 宿主拒绝写)。成功不必在面板上复述——落点是用户亲手选的,原生对话框就是回执; - * 失败则由调用方决定怎么告知,不能像以前那样只留一条控制台痕迹。 + * 返回值不只说明这次存没存下来,失败还带一句原因:`saved`(宿主写完)、`cancelled`(用户关掉 + * 对话框)、`failed`(对话框打不开 / 宿主拒绝写)。成功不必在面板上复述——落点是用户亲手选的, + * 原生对话框就是回执;失败则由调用方决定怎么告知,不能像以前那样只留一条控制台痕迹,也不能只给 + * 一句没有下文的「请重试」。 + * + * 同一条保存链路上还有 [`revealXhsMinitoolFileInManager`]:保存失败时用系统文件管理器打开原件 + * 所在位置,作为用户自己把文件拿出去的兜底出口。 */ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { resolveTauriInvoke } from '../../../../app/tauri'; import { resourceFileNameFromPath } from '../../../../features/resource-canvas/resourceCanvasAssetTransferModel'; -/** 一次导出产物保存的结局。 */ -export type XhsMinitoolDownloadResult = 'saved' | 'cancelled' | 'failed'; +/** 一次导出产物保存的结局。失败带一句可展示的原因(宿主给的是中文人话)。 */ +export type XhsMinitoolDownloadResult = + | { status: 'saved' } + | { status: 'cancelled' } + | { status: 'failed'; reason: string }; + +/** 在系统文件管理器里定位一份导出产物的结局。 */ +export type XhsMinitoolRevealResult = + | { status: 'revealed' } + | { status: 'failed'; reason: string }; export async function saveXhsMinitoolFileToDisk(input: { projectPath: string; @@ -25,7 +37,9 @@ export async function saveXhsMinitoolFileToDisk(input: { title: string; }): Promise<XhsMinitoolDownloadResult> { const invoke = resolveTauriInvoke(); - if (!invoke) return 'failed'; + if (!invoke) { + return { status: 'failed', reason: '需要在应用内运行才能保存文件' }; + } let destination: string | null = null; try { destination = await saveNativeFileDialog({ @@ -37,10 +51,10 @@ export async function saveXhsMinitoolFileToDisk(input: { }); } catch (error) { console.warn('导出产物保存对话框没能打开', error); - return 'failed'; + return { status: 'failed', reason: '保存对话框没能打开' }; } // 用户取消对话框:什么都没发生,也没有话要说。 - if (!destination) return 'cancelled'; + if (!destination) return { status: 'cancelled' }; try { await invoke('save_local_project_asset_file', { input: { @@ -49,9 +63,51 @@ export async function saveXhsMinitoolFileToDisk(input: { destinationPath: destination, }, }); - return 'saved'; + return { status: 'saved' }; } catch (error) { console.warn('导出产物没能写到你选的位置', error); - return 'failed'; + return { status: 'failed', reason: exportFailureReason(error) }; } } + +/** + * 在系统文件管理器里定位这份导出产物。 + * + * 保存失败后的兜底出口:不去替用户挑落点,只把原件所在位置打开,让他自己复制。与保存共用同一条 + * 宿主路径门禁——能保存的原件才能被定位,打开的一定是项目里真实存在的那份文件。 + */ +export async function revealXhsMinitoolFileInManager(input: { + projectPath: string; + relativePath: string; +}): Promise<XhsMinitoolRevealResult> { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return { status: 'failed', reason: '需要在应用内运行才能打开文件管理器' }; + } + try { + await invoke('reveal_local_project_asset_file', { + projectPath: input.projectPath, + relativePath: input.relativePath, + }); + return { status: 'revealed' }; + } catch (error) { + console.warn('导出产物没能在文件管理器里打开', error); + return { status: 'failed', reason: exportFailureReason(error) }; + } +} + +/** + * 把宿主或对话框抛出的原因收成一句能给用户看的话。 + * + * 宿主这条链路的拒绝都是中文人话(`Result<_, String>`),原样带出去;认不出形状时退回一句通用 + * 说明,不把 `[object Object]` 之类的内部形状糊到界面上。 + */ +function exportFailureReason(error: unknown): string { + if (typeof error === 'string') { + return error.trim() || '写入所选位置失败'; + } + if (error instanceof Error) { + return error.message.trim() || '写入所选位置失败'; + } + return '写入所选位置失败'; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 0cb15f7a1..0583e9fbe 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -2,11 +2,18 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import { type ReactNode, useState } from 'react'; import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; -import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; +import { + revealXhsMinitoolFileInManager, + saveXhsMinitoolFileToDisk, +} from '../../state/xhsMinitoolDownload'; import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction'; import { PaneButton } from '../common/PaneButton'; import { AdaptPromptDialog } from './AdaptPromptDialog'; import { ConflictCard } from './ConflictCard'; +import { + DownloadFailureDialog, + type XhsMinitoolDownloadFailure, +} from './DownloadFailureDialog'; import { FailureDialog } from './FailureDialog'; import { FormCard } from './FormCard'; @@ -23,9 +30,10 @@ const XHS_PUBLISH_URL = 'https://creator.xiaohongshu.com/new/red-app'; * 内容与脚本判据按固定间隔重读(所以没有刷新按钮)。 * * 「还没配置好导出」不再是一张常驻卡片,而是阻断弹窗:重读每 2 秒一次,卡片会跟着重读闪,弹窗则 - * 只在用户真的需要做选择时出现一次。失败现场同理走阻断弹窗(`FailureDialog`):左半本来就矮, - * 卡片会被滚出视野,而失败是用户必须看见的东西。可见文案说用户的话,不出现脚本名、注册表、zip - * 这些实现词。 + * 只在用户真的需要做选择时出现一次。失败现场同理走阻断弹窗:构建 / 读取失败走 `FailureDialog`, + * 下载失败走 `DownloadFailureDialog`(后者多给一条「在文件管理器中打开原件」的自助出口)。左半 + * 本来就矮,卡片会被滚出视野,而失败是用户必须看见的东西。可见文案说用户的话,不出现脚本名、 + * 注册表、zip 这些实现词。 */ export function ArtifactsPane({ minitool, @@ -35,8 +43,14 @@ export function ArtifactsPane({ projectPath: string | null; }) { const { form, conflict, failure } = minitool; - /** 下载失败的一句话现场:成功 / 用户取消都不说,只有宿主真的没写下来才占这一行。 */ - const [downloadNotice, setDownloadNotice] = useState(''); + /** + * 下载失败现场:宿主拒绝写盘或对话框打不开时,用弹窗把「哪一份、为什么、下一步」一次说清, + * 并给出「在文件管理器中打开原件」这条兜底出口。 + */ + const [downloadFailure, setDownloadFailure] = + useState<XhsMinitoolDownloadFailure | null>(null); + /** 「在文件管理器中打开」自身失败时的一句话;没失败时为空。 */ + const [revealFailure, setRevealFailure] = useState(''); /** * 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下 * 「还没适配」的结论——那种情况只该显示读取中或失败现场。 @@ -55,37 +69,63 @@ export function ArtifactsPane({ const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed; // 成功不打回执:用户刚在原生对话框里选完落点,磁盘上有没有那个文件是他自己看得见的事实。 - // 失败则必须说一句话——宿主拒绝落盘不能只留一条控制台痕迹。 + // 失败则必须让用户看见——宿主拒绝落盘不能只留一条控制台痕迹,也不能只给一句「请重试」。 const downloadIcon = (): void => { if (!projectPath || !form) return; - setDownloadNotice(''); + const relativePath = form.iconPath.trim(); void saveXhsMinitoolFileToDisk({ projectPath, - relativePath: form.iconPath.trim(), + relativePath, fallbackFileName: 'icon.png', title: '保存图标文件', }).then((result) => { - if (result === 'failed') { - setDownloadNotice('没能把图标保存到所选位置,请重试'); - } + if (result.status !== 'failed') return; + setRevealFailure(''); + setDownloadFailure({ kind: 'icon', relativePath, reason: result.reason }); }); }; const downloadArtifact = (): void => { if (!projectPath) return; - setDownloadNotice(''); + const relativePath = XHS_MINITOOL_ARTIFACT_RELATIVE_PATH; void saveXhsMinitoolFileToDisk({ projectPath, - relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + relativePath, fallbackFileName: 'xhs-minitool.zip', title: '保存代码包', }).then((result) => { - if (result === 'failed') { - setDownloadNotice('没能把代码包保存到所选位置,请重试'); - } + if (result.status !== 'failed') return; + setRevealFailure(''); + setDownloadFailure({ + kind: 'artifact', + relativePath, + reason: result.reason, + }); }); }; + /** 兜底出口:让系统文件管理器定位原件;打不开就把原因留在弹窗里。 */ + const revealDownloadedFile = (): void => { + if (!projectPath || !downloadFailure) return; + void revealXhsMinitoolFileInManager({ + projectPath, + relativePath: downloadFailure.relativePath, + }).then((result) => { + if (result.status === 'failed') { + setRevealFailure(result.reason); + return; + } + // 文件管理器已经弹出:收起弹窗,别挡着用户去看。 + setDownloadFailure(null); + setRevealFailure(''); + }); + }; + + const dismissDownloadFailure = (): void => { + setDownloadFailure(null); + setRevealFailure(''); + }; + /** * 左半顶部槽位:整张表单 / 一行读取中 / 什么都不放(读失败由弹窗拿着现场)/ 一行自动重试兜底。 * 四种形状差得远,用 if/else 摊开算好,JSX 里只留一个 `{formSlot}`,别把控制流埋进链式三元。 @@ -117,11 +157,6 @@ export function ArtifactsPane({ aria-label="导出内容区" > <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3"> - {downloadNotice ? ( - <p role="status" className="text-xs text-[#b4562f]"> - {downloadNotice} - </p> - ) : null} {formSlot} <section @@ -200,6 +235,13 @@ export function ArtifactsPane({ onRepair={minitool.repair} onDismiss={minitool.dismissFailure} /> + + <DownloadFailureDialog + failure={downloadFailure} + revealFailure={revealFailure} + onReveal={revealDownloadedFile} + onDismiss={dismissDownloadFailure} + /> </div> ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx new file mode 100644 index 000000000..d1eb2ff01 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx @@ -0,0 +1,71 @@ +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; +import { PaneButton } from '../common/PaneButton'; + +/** 哪一份产物没存下来:文案与「在文件管理器中打开」的目标都跟着它走。 */ +export type XhsMinitoolDownloadFailure = { + kind: 'icon' | 'artifact'; + /** 项目内相对路径:用户要在文件管理器里打开的就是它。 */ + relativePath: string; + /** 保存失败的原因(宿主给的中文说明,或一句通用说明)。 */ + reason: string; +}; + +/** + * 下载失败弹窗:一句「哪一份没存下来」+ 原因 + 兜底出口。 + * + * 保存失败不再只留一行会被滚出视野的小字:左半本来就矮,而这是用户点过下载之后必须看见的结果。 + * 弹窗给两条路:关掉,或者让系统文件管理器打开原件所在位置自己复制——宿主拒绝写盘时(权限、 + * 目标不存在、路径被门禁拦下),用户至少还能把项目里那份原件拿出去。 + * + * 与失败弹窗(`FailureDialog`)同一种阻断层:背景点击与 Esc 不生效。宿主的原始错误不在这里展开, + * `reason` 只用调用方收好的那一句人话。 + */ +export function DownloadFailureDialog({ + failure, + revealFailure, + onReveal, + onDismiss, +}: { + /** 没有下载失败现场时为 `null`,弹窗关着。 */ + failure: XhsMinitoolDownloadFailure | null; + /** 「在文件管理器中打开」自身失败时的原因;空串表示没有。 */ + revealFailure: string; + onReveal: () => void; + onDismiss: () => void; +}) { + return ( + <ThemedModal + open={failure !== null} + ariaLabel="下载失败" + onClose={onDismiss} + closeOnBackdrop={false} + closeOnEscape={false} + panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4" + > + <p className="text-sm leading-relaxed text-[#3d1f10]"> + {failure + ? `没能把${failure.kind === 'icon' ? '图标' : '代码包'}保存到你选的位置。` + : null} + </p> + {failure ? ( + <p className="mt-2 text-xs leading-relaxed text-[#6f5848]"> + {failure.reason} + </p> + ) : null} + <p className="mt-2 text-xs leading-relaxed text-[#6f5848]"> + 也可以让系统在文件管理器里打开它原来的位置,自己复制过去。 + </p> + {revealFailure ? ( + <p role="status" className="mt-2 text-xs text-[#b4562f]"> + {revealFailure} + </p> + ) : null} + <div className="mt-4 flex flex-wrap justify-end gap-2"> + <PaneButton onClick={onDismiss}>关闭</PaneButton> + <PaneButton variant="primary" onClick={onReveal}> + 在文件管理器中打开 + </PaneButton> + </div> + </ThemedModal> + ); +} diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index ad32a5855..363bc9446 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -298,13 +298,13 @@ describe('面板左半', () => { ); }); - it('宿主拒绝落盘时不静默:面板给出重试提示', async () => { + it('宿主拒绝落盘时弹出失败面板,并给出「在文件管理器中打开」的兜底出口', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_local_project_image_preview') { return { dataUrl: 'data:image/png;base64,icon' }; } if (command === 'save_local_project_asset_file') { - throw new Error('目标位置不可写'); + throw '目标位置不可写'; } return {}; }); @@ -313,13 +313,46 @@ describe('面板左半', () => { fireEvent.click(screen.getByRole('button', { name: '下载代码包' })); + // 弹窗:哪一份没存下来、为什么,以及用户自己把原件拿出去的出口。 + await screen.findByText('没能把代码包保存到你选的位置。'); + screen.getByText('目标位置不可写'); + screen.getByText(/在文件管理器里打开它原来的位置/u); + + // 兜底出口走宿主与保存同一条门禁的定位命令。 + fireEvent.click(screen.getByRole('button', { name: '在文件管理器中打开' })); await waitFor(() => - expect( - screen.getByText('没能把代码包保存到所选位置,请重试'), - ).toBeTruthy(), + expect(invoke).toHaveBeenCalledWith('reveal_local_project_asset_file', { + projectPath: PROJECT_PATH, + relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + }), ); }); + it('文件管理器也打不开时,弹窗留在原地并说清原因', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_image_preview') { + return { dataUrl: 'data:image/png;base64,icon' }; + } + if (command === 'save_local_project_asset_file') { + throw '目标位置不可写'; + } + if (command === 'reveal_local_project_asset_file') { + throw '系统没有可用的文件管理器'; + } + return {}; + }); + installTauriRuntime({ core: { invoke } }); + renderPane(fakeMinitool({ artifactReady: true })); + + fireEvent.click(screen.getByRole('button', { name: '下载代码包' })); + await screen.findByText('没能把代码包保存到你选的位置。'); + fireEvent.click(screen.getByRole('button', { name: '在文件管理器中打开' })); + + await screen.findByText('系统没有可用的文件管理器'); + // 弹窗还在:用户可以改关弹窗、或在文件管理器恢复后重试。 + screen.getByRole('button', { name: '在文件管理器中打开' }); + }); + it('图标先出缩略图,选择图标走公共素材导入器并把路径写回表单', async () => { const invoke = vi.fn( async (command: string, args?: Record<string, unknown>) => { From a6974f3ca49fcdd15504e9e7a3c36d9b298a81c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:25:05 +0800 Subject: [PATCH 148/156] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E6=A0=A1=E9=AA=8C=E5=8F=AA=E7=95=99=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存命令不再校验表单:`save_form` 只判 baseHash 冲突并写盘,删掉 `validation` 模块与它的必填 / 字数 / icon 约束 - `FormInvalid` 与 `XHSMiniToolExportField` 从 Rust 失败表和 DTO 退役,ts-rs 同步删掉两个生成类型 - 前端新增 `validateXhsMinitoolForm`(必填 + 14 字上限,按码点算),`persist` 先过它再落盘 - `fieldError` 改用本地 `XhsMinitoolFieldError`,`save` 守卫与失败卡片分流同步移除 `formInvalid` - 测试:新增 `xhsMinitoolFields.test.ts`,改写两处依赖宿主 `formInvalid` 的用例为前端校验 --- .../src/export/draft/xhs_minitool/commands.rs | 9 +- .../src/export/draft/xhs_minitool/dto.rs | 14 +- .../src/export/draft/xhs_minitool/error.rs | 26 +- .../src/export/draft/xhs_minitool/mod.rs | 7 +- .../src/export/draft/xhs_minitool/registry.rs | 6 +- .../export/draft/xhs_minitool/validation.rs | 231 ------------------ .../export/generated/FormInvalid.ts | 4 - .../generated/XHSMiniToolExportField.ts | 6 - .../generated/XHSMiniToolExportSaveError.ts | 3 +- .../export/state/useXhsMinitoolExport.ts | 37 ++- .../export/state/xhsMinitoolFailure.ts | 14 +- .../export/state/xhsMinitoolFields.ts | 64 ++++- .../export/tabs/xiaohongshu/FormCard.tsx | 17 +- .../tests/xhsMinitoolExport.test.tsx | 29 ++- .../tests/xhsMinitoolFields.test.ts | 58 +++++ 15 files changed, 190 insertions(+), 335 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts create mode 100644 apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts 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 index c3daf01df..6bf86b987 100644 --- 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 @@ -23,7 +23,6 @@ use super::error::{ XHSMiniToolExportSaveError, }; use super::registry::{self, RegistrySnapshot}; -use super::validation::validate_form; use super::{build, script}; /// 读内容:注册表(缺则补一份空表单)+ 脚本是否存在。自动刷新反复调的就是这一条。 @@ -56,10 +55,11 @@ pub(crate) async fn read_xhs_minitool_export_hash( result } -/// 保存表单:先硬校验(首个错误即返回),再按 `base_hash` 判冲突;成功后回新的指纹。 +/// 保存表单:只按 `base_hash` 判冲突,成功后回新的指纹。 /// -/// 校验挡在保存之前是刻意的——平台字数与 icon 限制是硬约束,让它在用户输入时就被看见,比等到 -/// 人工上传被平台打回便宜。 +/// **不校验表单内容**——必填与字数上限由界面在提交前挡住(用户最终面对的是界面,这份表单也只是 +/// 一份草稿),宿主只负责把它原样写进注册表。`iconPath` 这类路径事实在真正读它的那条路(预览、 +/// 下载)由统一的路径门禁复核,不在这里预支。 #[tauri::command] pub(crate) async fn save_xhs_minitool_export_form( project_path: String, @@ -163,7 +163,6 @@ fn save_form( base_hash: &str, ) -> Result<String, XHSMiniToolExportSaveError> { let root = project_root(project_path)?; - validate_form(&root, form)?; Ok(registry::save(&root, form, base_hash)?.content_hash) } 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 index 1ed418fbc..f9bb9da18 100644 --- 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 @@ -21,8 +21,8 @@ pub(crate) struct XHSMiniToolExportForm { impl XHSMiniToolExportForm { /// 落盘形态:三个字段都去掉首尾空白。 /// - /// 校验按去空白后的文本算(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白,否则 - /// 「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。 + /// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白, + /// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。 pub(crate) fn normalized(&self) -> Self { Self { name: self.name.trim().to_string(), @@ -46,16 +46,6 @@ pub(crate) struct XHSMiniToolExportRegistry { pub(crate) form: XHSMiniToolExportForm, } -/// 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。 -#[derive(Clone, Copy, 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) enum XHSMiniToolExportField { - Name, - Introduction, - IconPath, -} - /// 面板读到的现场内容。 /// /// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 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 index c0caad70e..7c86b6869 100644 --- 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 @@ -6,8 +6,9 @@ //! - [`XHSMiniToolExportReadError`]:读注册表(`read_xhs_minitool_export` / //! `read_xhs_minitool_export_hash`); //! - [`XHSMiniToolExportSaveError`]:写表单(`save_xhs_minitool_export_form`,自动保存与「打包」 -//! 前的 flush 走同一条)。`formInvalid` 与 `saveConflict` **只**出现在这里——它们是「这份输入 -//! 还没落盘」的两种结果,不是构建失败; +//! 前的 flush 走同一条)。`saveConflict` 只出现在这里——它是「这份输入还没落盘」的结果,不是 +//! 构建失败。表单内容的必填与字数由界面在提交前校验,宿主这一层不做业务校验,所以这张表里没有 +//! 「字段不合法」这一类; //! - [`XHSMiniToolExportBuildError`]:跑构建(`run_xhs_minitool_export_build`)。构建不是表单的 //! 消费者,所以这张表里没有表单类变体。 //! @@ -20,7 +21,7 @@ use serde::Serialize; use ts_rs::TS; -use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; +use super::dto::XHSMiniToolExportForm; /// 读注册表可能怎么失败。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] @@ -48,8 +49,6 @@ pub(crate) enum XHSMiniToolExportReadError { pub(crate) enum XHSMiniToolExportSaveError { /// 注册表存在但不是本目标期望的形状。 RegistryMalformed(RegistryMalformed), - /// 表单字段不合法。只回**首个**出错字段。 - FormInvalid(FormInvalid), /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 SaveConflict(SaveConflict), /// 项目根不可用或其它宿主侧事实故障。 @@ -85,14 +84,6 @@ 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 FormInvalid { - pub(crate) field: XHSMiniToolExportField, - pub(crate) reason: 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/"))] @@ -157,15 +148,6 @@ impl ExportUnavailable { } } -impl XHSMiniToolExportSaveError { - pub(crate) fn form_invalid(field: XHSMiniToolExportField, reason: impl Into<String>) -> Self { - Self::FormInvalid(FormInvalid { - field, - reason: reason.into(), - }) - } -} - /// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。 impl From<ExportUnavailable> for XHSMiniToolExportReadError { fn from(payload: ExportUnavailable) -> Self { 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 index 6d43c4951..3c58814d4 100644 --- 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 @@ -6,8 +6,10 @@ //! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 //! //! 分层:`dto` 是线上形状,`error` 是命令边界失败表,`layout` 是契约常量,`registry` 管注册表, -//! `script` 管 npm 脚本发现,`validation` 管表单硬约束,`build` 跑脚本并确认产物, -//! `commands` 是唯一对外的命令面。 +//! `script` 管 npm 脚本发现,`build` 跑脚本并确认产物,`commands` 是唯一对外的命令面。 +//! +//! 表单**不在宿主侧做业务校验**:命令只把用户那份草稿原样读出来、写回去,必填与字数由界面在 +//! 提交前挡住。宿主要挡的只有「这份注册表文件写得对不对」这类 IO 事实(见 `registry`)。 pub mod commands; @@ -17,4 +19,3 @@ mod error; mod layout; mod registry; mod script; -mod validation; 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 index 6dc886e2a..ac8739bcf 100644 --- 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 @@ -5,9 +5,9 @@ //! //! 已知取舍(评审提出、当前决定不改):这里用共享的 `load_or_create` / `save`,**不**额外检查 //! 注册表文件与 `.export` 目录是不是符号链接。也就是说,如果 `.export/xhs-minitool.json` 被换成 -//! 链接,读写会落到项目外。这与产物(`build::conclude`)、脚本(`script::find`)、图标 -//! (`validation`)三处的拒链口径不同,是有意为之:那三处是「把项目内容交给打包链」的入口, -//! 这里读写的是用户自己项目里的草稿文件,按「项目文件由用户掌控」的前提接受这个重定向。 +//! 链接,读写会落到项目外。这与产物(`build::conclude`)、脚本(`script::find`)不同,也与图标在 +//! 预览 / 下载时走的路径门禁不同,是有意为之:那几处是「把项目内容交给打包链」的入口,这里读写 +//! 的是用户自己项目里的草稿文件,按「项目文件由用户掌控」的前提接受这个重定向。 use std::path::Path; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs deleted file mode 100644 index 997d96c13..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! 小红书上传表单的校验:**首个错误即返回**。 -//! -//! 字数上限来自小红书平台的上传表单,所以是宿主硬校验而不是软提示——写错了用户会被平台打回 -//! 去,代价比早一点拦下来大。icon 的扩展名与大小是本地文件事实,同样硬校验;非 png/jpg/jpeg -//! 目前直接拒绝,格式转换工具是后续事项(见下方 TODO)。 - -use std::path::Path; - -use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; -use super::error::XHSMiniToolExportSaveError; - -/// 小红书平台对名称的字数上限(Unicode 字符数,不是字节数)。 -pub(crate) const NAME_MAX_CHARS: usize = 14; -/// 简介与名称同档。 -pub(crate) const INTRODUCTION_MAX_CHARS: usize = 14; -pub(crate) const ICON_MAX_BYTES: u64 = 5 * 1024 * 1024; -const ICON_EXTENSIONS: [&str; 3] = ["png", "jpg", "jpeg"]; - -pub(crate) fn validate_form( - root: &Path, - form: &XHSMiniToolExportForm, -) -> Result<(), XHSMiniToolExportSaveError> { - validate_text( - XHSMiniToolExportField::Name, - "名称", - &form.name, - NAME_MAX_CHARS, - )?; - validate_text( - XHSMiniToolExportField::Introduction, - "简介", - &form.introduction, - INTRODUCTION_MAX_CHARS, - )?; - validate_icon(root, form) -} - -fn validate_text( - field: XHSMiniToolExportField, - label: &str, - value: &str, - max_chars: usize, -) -> Result<(), XHSMiniToolExportSaveError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!("{label}不能为空"), - )); - } - let chars = trimmed.chars().count(); - if chars > max_chars { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!("{label}最多 {max_chars} 个字,当前 {chars} 个字"), - )); - } - Ok(()) -} - -fn validate_icon( - root: &Path, - form: &XHSMiniToolExportForm, -) -> Result<(), XHSMiniToolExportSaveError> { - let field = XHSMiniToolExportField::IconPath; - let relative = form.icon_path.trim(); - if relative.is_empty() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - "icon 不能为空", - )); - } - // 与通用项目读取同一套拒绝口径:越界、符号链接、Windows reparse point 都在这里挡掉。 - let path = crate::project::resolve_local_project_path(root, relative) - .map_err(|cause| XHSMiniToolExportSaveError::form_invalid(field, cause))?; - let metadata = std::fs::symlink_metadata(&path).map_err(|error| { - XHSMiniToolExportSaveError::form_invalid(field, format!("icon 不存在或不可读:{error}")) - })?; - if metadata.file_type().is_symlink() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - "icon 不能是符号链接", - )); - } - if !metadata.is_file() { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - "icon 必须是普通文件", - )); - } - let extension = path - .extension() - .and_then(|value| value.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - if !ICON_EXTENSIONS.contains(&extension.as_str()) { - // TODO: 提供格式转换工具,把平台不认的格式(webp、heic 等)转成 png/jpg/jpeg。 - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!("icon 只支持 png、jpg、jpeg,当前是 .{extension}"), - )); - } - if metadata.len() > ICON_MAX_BYTES { - return Err(XHSMiniToolExportSaveError::form_invalid( - field, - format!( - "icon 不能超过 5 MiB,当前 {:.1} MiB", - metadata.len() as f64 / (1024.0 * 1024.0) - ), - )); - } - Ok(()) -} - -#[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-validation-{prefix}-{}-{}", - std::process::id(), - NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - fs::create_dir_all(root.join("assets")).expect("create fixture project"); - root - } - - fn form_with_icon(icon_path: &str) -> XHSMiniToolExportForm { - XHSMiniToolExportForm { - name: "小工具".to_string(), - introduction: "一句话简介".to_string(), - icon_path: icon_path.to_string(), - } - } - - #[test] - fn accepts_a_well_formed_form() { - let root = fixture_project("ok"); - fs::write(root.join("assets/icon.png"), b"png").unwrap(); - - validate_form(&root, &form_with_icon("assets/icon.png")).expect("valid form"); - } - - #[test] - fn counts_unicode_characters_not_bytes() { - let root = fixture_project("unicode"); - fs::write(root.join("assets/icon.png"), b"png").unwrap(); - let mut form = form_with_icon("assets/icon.png"); - form.name = "小".repeat(NAME_MAX_CHARS); - - validate_form(&root, &form).expect("exactly at the limit"); - form.name.push('多'); - let error = validate_form(&root, &form).expect_err("over the limit"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::Name - )); - } - - #[test] - fn reports_the_first_invalid_field_only() { - let root = fixture_project("first"); - let form = XHSMiniToolExportForm { - name: String::new(), - introduction: String::new(), - icon_path: String::new(), - }; - - let error = validate_form(&root, &form).expect_err("empty form"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::Name - )); - } - - #[test] - fn rejects_paths_escaping_the_project_and_symlinks() { - let root = fixture_project("escape"); - let outside = root.parent().unwrap().join("outside.png"); - fs::write(&outside, b"png").unwrap(); - - let error = validate_form(&root, &form_with_icon("../outside.png")) - .expect_err("escaping path must be rejected"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - - #[cfg(unix)] - { - std::os::unix::fs::symlink(&outside, root.join("assets/link.png")).unwrap(); - let error = validate_form(&root, &form_with_icon("assets/link.png")) - .expect_err("symlink must be rejected"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - } - } - - #[test] - fn rejects_unsupported_extension_and_oversized_icon() { - let root = fixture_project("icon"); - fs::write(root.join("assets/icon.webp"), b"webp").unwrap(); - let error = validate_form(&root, &form_with_icon("assets/icon.webp")) - .expect_err("webp is not accepted yet"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - - let big = root.join("assets/big.png"); - fs::write(&big, vec![0_u8; (ICON_MAX_BYTES + 1) as usize]).unwrap(); - let error = validate_form(&root, &form_with_icon("assets/big.png")).expect_err("too large"); - assert!(matches!( - error, - XHSMiniToolExportSaveError::FormInvalid(payload) - if payload.field == XHSMiniToolExportField::IconPath - )); - } -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts deleted file mode 100644 index 3898f1c1d..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { XHSMiniToolExportField } from "./XHSMiniToolExportField"; - -export type FormInvalid = { field: XHSMiniToolExportField, reason: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts deleted file mode 100644 index 8e2c17544..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。 - */ -export type XHSMiniToolExportField = "name" | "introduction" | "iconPath"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts index e2e09da22..565ad0901 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts @@ -1,10 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExportUnavailable } from "./ExportUnavailable"; -import type { FormInvalid } from "./FormInvalid"; import type { RegistryMalformed } from "./RegistryMalformed"; import type { SaveConflict } from "./SaveConflict"; /** * 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 */ -export type XHSMiniToolExportSaveError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "formInvalid" } & FormInvalid | { "type": "saveConflict" } & SaveConflict | { "type": "exportUnavailable" } & ExportUnavailable; +export type XHSMiniToolExportSaveError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "saveConflict" } & SaveConflict | { "type": "exportUnavailable" } & ExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 0d071d68c..322eda813 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -20,7 +20,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../../../app/tauri'; import type { UserContentPart } from '../../chat/generated/UserContentPart'; -import type { FormInvalid } from '../generated/FormInvalid'; import type { SaveConflict } from '../generated/SaveConflict'; import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState'; @@ -39,6 +38,10 @@ import { classifyXhsMinitoolFailure, XhsMinitoolFailureRepairability, } from './xhsMinitoolFailure'; +import { + validateXhsMinitoolForm, + type XhsMinitoolFieldError, +} from './xhsMinitoolFields'; import { buildXhsMinitoolAdaptationInstruction, buildXhsMinitoolErrorFixInstruction, @@ -83,7 +86,9 @@ export function useXhsMinitoolExport({ }) { const [form, setForm] = useState<XHSMiniToolExportForm | null>(null); const [hasScript, setHasScript] = useState(false); - const [fieldError, setFieldError] = useState<FormInvalid | null>(null); + const [fieldError, setFieldError] = useState<XhsMinitoolFieldError | null>( + null, + ); const [conflict, setConflict] = useState<SaveConflict | null>(null); const [failure, setFailure] = useState<XhsMinitoolCardFailure | null>(null); /** @@ -318,9 +323,10 @@ export function useXhsMinitoolExport({ /** * 落盘一份表单,返回是否真的写成功。 * - * 这是**唯一**做表单硬校验的地方(`validate_form` 在保存命令里):自动保存到点就调它,「打包」 - * 要落盘手上没保存的输入也调它。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等 - * 用户处理,不拿一份没通过校验的表单去构建。 + * 这是**唯一**做表单硬校验的地方:自动保存到点就调它,「打包」要落盘手上没保存的输入也调它。 + * 校验只挡必填与字数([`validateXhsMinitoolForm`]),宿主那侧不再挡——表单只是草稿,用户最终 + * 面对的是界面。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等用户处理,不拿一份 + * 没通过校验的表单去构建。 * * 写盘经由 [FormSaveLock]:同一时刻只写一版。写盘期间用户又改了表单时,这次的结果虽然成功,也 * **不能**清 `dirty`——否则自动保存效果会把后一版的定时器当成「已经存好了」取消,最后一版输入 @@ -331,6 +337,14 @@ export function useXhsMinitoolExport({ if (!projectPath) return false; const invoke = resolveTauriInvoke(); if (!invoke) return false; + // 首个字段错误即返回:红字挂到对应输入框,等用户改完下一次输入再存。`dirty` 归零,同一份 + // 不合法的输入不会反复排队。 + const invalid = validateXhsMinitoolForm(next); + if (invalid) { + setFieldError(invalid); + setDirty(false); + return false; + } const { saved, superseded } = await saveLockRef.current.run(async () => { try { const nextBaseline = await saveXhsMinitoolExportForm(invoke, { @@ -350,16 +364,15 @@ export function useXhsMinitoolExport({ } catch (error) { // 旧项目的失败不该记到新项目头上。 if (projectPathRef.current !== projectPath) return false; - // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 - // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 + // 剩下的结果都要停下重试,等用户或下一次输入推着走:冲突等用户选、其余按 typed 失败给 + // 建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 setDirty(false); const structured = asXhsMinitoolSaveFailure(error); if (!structured) { throwToErrorPool(String(error), error); return false; } - if (structured.type === 'formInvalid') setFieldError(structured); - else if (structured.type === 'saveConflict') setConflict(structured); + if (structured.type === 'saveConflict') setConflict(structured); else if (structured.type === 'exportUnavailable') { // 宿主侧事实故障:不落面板,直接抛给全局错误池上报。 throwToErrorPool(JSON.stringify(structured), error); @@ -452,9 +465,9 @@ export function useXhsMinitoolExport({ * 没有脚本(或这次读不到)时不报错了事,而是把阻断弹窗重新武装起来——用户点「打包」的本意就是 * 「我要一份代码包」,这条路走不通时该当场把「让陶泥儿帮你调通」这个出口递给他。 * - * 这里不做任何表单校验——校验只发生在保存命令里(`persist`)。打包只关心「盘上的注册表是不是 - * 用户现在这份」:有未保存输入就先 flush,flush 回 `false`(字段不合法、撞上冲突、宿主故障) - * 就停在表单上让用户处理;冲突或字段红字还没解决也一样停住。构建本身只认盘上的文件。 + * 这里不做任何表单校验——校验只在 `persist` 落盘前做一次(界面这一层)。打包只关心「盘上的 + * 注册表是不是用户现在这份」:有未保存输入就先 flush,flush 回 `false`(字段不合法、撞上冲突、 + * 宿主故障)就停在表单上让用户处理;冲突或字段红字还没解决也一样停住。构建本身只认盘上的文件。 */ const packageAll = useCallback(async (): Promise<void> => { if (!projectPath) return; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts index 1f215e9a7..8f5196327 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts @@ -2,8 +2,7 @@ * 结构化失败 → 两件互不派生的事:给用户看的一句话,和这次该由谁修。 * * 输入是**会出现在失败卡片上的失败**([`XhsMinitoolCardFailure`])——它按命令把三条失败表拼起来, - * 但不含 `formInvalid`(字段红字)与 `saveConflict`(冲突面板):那两种有专属 UI,到不了卡片, - * 也就不该在这张表里再分一次类。 + * 但不含 `saveConflict`(冲突面板):它有专属 UI,到不了卡片,也就不该在这张表里再分一次类。 * * 拆成两个纯函数,各答一件事: * - `describeXhsMinitoolFailure` 只产用户侧的说法(一个字符串),句子按变体写死,**不从载荷里 @@ -22,18 +21,15 @@ import type { XHSMiniToolExportReadError } from '../generated/XHSMiniToolExportR import type { XHSMiniToolExportSaveError } from '../generated/XHSMiniToolExportSaveError'; /** - * 会出现在失败卡片上的失败:读注册表、保存表单、跑构建三条命令里,除去有专属 UI 的两种。 + * 会出现在失败卡片上的失败:读注册表、保存表单、跑构建三条命令里,除去有专属 UI 的冲突。 * - * `formInvalid` 由保存命令在写盘前挡下,走 `FormCard` 的字段红字;`saveConflict` 走冲突面板。 - * 两者都不是「失败卡片」的现场,所以不在这张表里。 + * 字段红字(必填 / 字数)在界面这一层拦下,根本不会走到保存命令,也就不在这张表里;`saveConflict` + * 走冲突面板。两者都不是「失败卡片」的现场。 */ export type XhsMinitoolCardFailure = | XHSMiniToolExportReadError | XHSMiniToolExportBuildError - | Exclude< - XHSMiniToolExportSaveError, - { type: 'formInvalid' } | { type: 'saveConflict' } - >; + | Exclude<XHSMiniToolExportSaveError, { type: 'saveConflict' }>; /** * 谁有能力修这次失败。判据只有一条:**要改的东西在不在用户项目的代码里**。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts index 345f12d80..dfd06ee4e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts @@ -1,25 +1,36 @@ /** - * 三个表单字段的展示元数据:标签、占位文案、字数上限。 + * 三个表单字段的展示元数据与本地校验:标签、占位文案、字数上限,以及保存前那道必填 / 字数关。 * - * 字段身份直接用 ts-rs 生成的 `XHSMiniToolExportField`(`"name" | "introduction" | "iconPath"`): - * 它与表单对象的键一一对应,所以 `form[field]` 天然成立,不需要再维护一份字段映射。 + * 字段身份用本文件定义的 `XhsMinitoolField`:它与表单对象的键一一对应,所以 `form[field]` 天然 + * 成立,不需要再维护一份字段映射。宿主那侧不再做表单业务校验,必填与上限都在这里挡。 */ -import type { XHSMiniToolExportField } from '../generated/XHSMiniToolExportField'; +import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; + +/** 表单里的三个字段,与 [`XHSMiniToolExportForm`] 的键一一对应。 */ +export type XhsMinitoolField = 'name' | 'introduction' | 'iconPath'; + +/** 字段红字:一次只挂一个出错字段,用户改完再报下一个。 */ +export type XhsMinitoolFieldError = { + field: XhsMinitoolField; + reason: string; +}; export type XhsMinitoolFieldMeta = { - field: XHSMiniToolExportField; + field: XhsMinitoolField; label: string; /** 输入框的占位提示。图标不给输入框,所以这一项只有文本字段有。 */ placeholder?: string; - /** 宿主硬校验的字数上限;icon 不是文本字段,没有这一项。 */ + /** 字数上限;icon 不是文本字段,没有这一项。 */ maxChars?: number; multiline: boolean; /** 是否给一颗「复制」按钮。图标是文件、不是要粘贴到平台的文字,复制路径没有意义。 */ copyable: boolean; }; -/** 文本字段的字数上限:与 Rust 权威 `validation.rs` 的 NAME_MAX_CHARS / INTRODUCTION_MAX_CHARS 一致。 */ -const TEXT_MAX_CHARS = 14; +/** 文本字段的字数上限,与小红书上传页一致。 */ +export const XHS_MINITOOL_TEXT_MAX_CHARS = 14; + +const TEXT_MAX_CHARS = XHS_MINITOOL_TEXT_MAX_CHARS; /** 表单展示顺序。加字段时只改这里,保存与复制都按字段身份走。 */ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ @@ -46,3 +57,40 @@ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ copyable: false, }, ]; + +/** + * 保存前的本地校验:只挡「平台一定不认」的三件事——三个字段必填、两个文本字段不超字数。 + * + * 校验放在界面这一层是刻意的:用户最终面对的就是界面,这份表单也只是一份草稿,不值得让宿主再挡 + * 一次。文件是不是真的存在、是不是符号链接、扩展名与大小都不在这里判——那些是「真的去读它」时 + * 才成立的事实,由预览 / 下载那条路各自的路径门禁负责。首个错误即返回,与表单的提交顺序一致。 + */ +export function validateXhsMinitoolForm( + form: XHSMiniToolExportForm, +): XhsMinitoolFieldError | null { + return ( + validateText('name', '名称', form.name) ?? + validateText('introduction', '简介', form.introduction) ?? + (form.iconPath.trim() + ? null + : { field: 'iconPath', reason: 'icon 不能为空' }) + ); +} + +function validateText( + field: 'name' | 'introduction', + label: string, + value: string, +): XhsMinitoolFieldError | null { + const trimmed = value.trim(); + if (!trimmed) return { field, reason: `${label}不能为空` }; + // 按码点计数:`String.length` 数的是 UTF-16 码元,一个 emoji 会被算成 2、平台算成 1。 + const chars = Array.from(trimmed).length; + if (chars > XHS_MINITOOL_TEXT_MAX_CHARS) { + return { + field, + reason: `${label}最多 ${XHS_MINITOOL_TEXT_MAX_CHARS} 个字,当前 ${chars} 个字`, + }; + } + return null; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index e53cf0cb8..f413a9205 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -3,9 +3,11 @@ import { useId, useState } from 'react'; import { AssetImporter } from '../../../../../components/AssetImporter'; import { ICON_IMPORTER_SETTINGS } from '../../../../../components/AssetImporter/settings'; -import type { FormInvalid } from '../../generated/FormInvalid'; import type { XHSMiniToolExportForm } from '../../generated/XHSMiniToolExportForm'; -import { XHS_MINITOOL_FORM_FIELDS } from '../../state/xhsMinitoolFields'; +import { + XHS_MINITOOL_FORM_FIELDS, + type XhsMinitoolFieldError, +} from '../../state/xhsMinitoolFields'; import { PaneButton } from '../common/PaneButton'; import { CopyButton } from './CopyButton'; import { IconPreview } from './IconPreview'; @@ -14,7 +16,8 @@ import { IconPreview } from './IconPreview'; * 小红书上传表单。名称与介绍可复制(它们是要粘贴到上传页的文字),图标只给「下载」——复制一条 * 项目内路径没有意义。 * - * 表单只收集值:校验在宿主(保存时硬校验),这里的红字是宿主回话的投影,不是前端自己判的。 + * 表单只收集值:校验在界面这一层(保存前,见 `validateXhsMinitoolForm`),红字是本地校验的 + * 结果;宿主那侧只按原样读写草稿。 * 文案一律说「上传到小红书要填什么」,不暴露脚本名、注册表与 zip 这些实现词。 * * 版式对齐发布面板(`GameDistributionPublishPanel`):标签在上、输入独占整行、字段之间留够呼吸, @@ -30,7 +33,7 @@ export function FormCard({ onAskAgentToFill, }: { form: XHSMiniToolExportForm; - fieldError: FormInvalid | null; + fieldError: XhsMinitoolFieldError | null; projectPath: string | null; onChange: (field: keyof XHSMiniToolExportForm, value: string) => void; onDownloadIcon: () => void; @@ -179,9 +182,9 @@ function FieldRow({ /** * 图标一格:左边缩略图、右边文件名与两颗按钮。**没有输入框**——图标位置不接受手填。 * - * 位置必须是一条已登记、可预览的项目内路径:手打出来的路径宿主预览不到、校验也会拒,给一个能打字 - * 的框等于请用户去犯一个必然犯的错。唯一入口是「选择图标」——它开公共 `AssetImporter`(左边是 - * 本地项目、右边是云端素材库),选中后把图登记到 `assets/uploads/` 再回一条项目内相对路径。 + * 位置必须是一条已登记、可预览的项目内路径:手打出来的路径宿主预览不到,给一个能打字的框等于请用户 + * 去犯一个必然犯的错。唯一入口是「选择图标」——它开公共 `AssetImporter`(左边是本地项目、右边是云端 + * 素材库),选中后把图登记到 `assets/uploads/` 再回一条项目内相对路径。 * * 连那条路径也不显示:用户要认的是那张图,不是它在磁盘上的位置。文件名只用来在几张图之间认人。 */ diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 363bc9446..055cc1290 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -206,7 +206,8 @@ describe('交给 code agent 的指令', () => { cause: '项目根不可用:找不到目录', }), ).toBe(XhsMinitoolFailureRepairability.OnlyHumanRepairable); - // `formInvalid` / `saveConflict` 不在这张表里:它们只出现在保存命令的失败表上,各有专属 UI。 + // `saveConflict` 不在这张表里:它只出现在保存命令的失败表上,有专属的冲突面板;字段红字则 + // 在界面这一层就被拦下,根本走不到保存命令。 }); }); @@ -671,7 +672,11 @@ describe('命令失败各有一条表', () => { throw { type: 'registryMalformed', cause: 'JSON 解析失败' }; } if (command === 'save_xhs_minitool_export_form') { - throw { type: 'formInvalid', field: 'name', reason: '名称不能为空' }; + throw { + type: 'saveConflict', + current: FORM, + contentHash: CONTENT_HASH, + }; } throw { type: 'commandFailed', @@ -700,7 +705,7 @@ describe('命令失败各有一条表', () => { baseHash: CONTENT_HASH, }).catch((thrown: unknown) => thrown); - expect(asXhsMinitoolSaveFailure(error)?.type).toBe('formInvalid'); + expect(asXhsMinitoolSaveFailure(error)?.type).toBe('saveConflict'); expect(asXhsMinitoolReadFailure(error)).toBeNull(); expect(asXhsMinitoolBuildFailure(error)).toBeNull(); }); @@ -1086,15 +1091,13 @@ describe('状态 hook', () => { expect(result.current.artifactReady).toBe(true); }); - it('打包时表单校验不过就停在表单上,不跑构建', async () => { + it('打包时表单校验不过就停在表单上,不碰宿主也不跑构建', async () => { + // 校验已经挪到界面这一层:宿主不再有「字段不合法」这张拒绝,`save` 根本不该被调到。 const invoke = vi.fn(async (command: string) => { if (command === 'read_xhs_minitool_export') { return { form: FORM, hasScript: true }; } if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; - if (command === 'save_xhs_minitool_export_form') { - throw { type: 'formInvalid', field: 'name', reason: '名称太长' }; - } return { outputTail: 'zip 已生成', omittedCharacters: 0 }; }); installTauriRuntime({ core: { invoke } }); @@ -1108,13 +1111,17 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await act(async () => { - result.current.updateField('name', '新名字'); + result.current.updateField('name', '这个名字已经超过十四个字了平台会拒'); }); await act(async () => { await result.current.packageAll(); }); expect(result.current.fieldError?.field).toBe('name'); + expect(invoke).not.toHaveBeenCalledWith( + 'save_xhs_minitool_export_form', + expect.anything(), + ); expect(invoke).not.toHaveBeenCalledWith('run_xhs_minitool_export_build', { projectPath: PROJECT_PATH, }); @@ -1236,7 +1243,7 @@ describe('状态 hook', () => { return { form: FORM, hasScript: true }; } if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; - throw { type: 'formInvalid', field: 'name', reason: '名称太长' }; + return { outputTail: '', omittedCharacters: 0 }; }); installTauriRuntime({ core: { invoke } }); const submitTurn = vi.fn(async () => true); @@ -1250,13 +1257,13 @@ describe('状态 hook', () => { await waitFor(() => expect(result.current.form).toEqual(FORM)); await act(async () => { - result.current.updateField('name', '新名字'); + result.current.updateField('name', '这个名字已经超过十四个字了平台会拒'); }); await act(async () => { await result.current.fillForm(); }); - // 保存被宿主拒了,红字留在表单上,但这条消息已经发出去了——拦住它只剩用户自己改。 + // 保存被界面校验拦下,红字留在表单上,但这条消息已经发出去了——拦住它只剩用户自己改。 expect(result.current.fieldError?.field).toBe('name'); expect(submitTurn).toHaveBeenCalledTimes(1); }); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts new file mode 100644 index 000000000..8ccb0dce8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts @@ -0,0 +1,58 @@ +/** + * 表单本地校验:必填、字数边界(按去空白后的码点数),以及「首个错误即返回」。 + * + * 宿主那侧不再校验表单,这份纯函数就是唯一的表单关;`xhsMinitoolExport.test.tsx` 只钉它接到 + * `persist` 上的那一步,边界都在这里覆盖。 + */ +import { describe, expect, it } from 'vitest'; + +import type { XHSMiniToolExportForm } from '../src/view/project-development/export/generated/XHSMiniToolExportForm'; +import { + validateXhsMinitoolForm, + XHS_MINITOOL_TEXT_MAX_CHARS, +} from '../src/view/project-development/export/state/xhsMinitoolFields'; + +const VALID: XHSMiniToolExportForm = { + name: '小工具', + introduction: '一句话介绍', + iconPath: 'assets/icon.png', +}; + +describe('validateXhsMinitoolForm', () => { + it('三个字段都合法时通过', () => { + expect(validateXhsMinitoolForm(VALID)).toBeNull(); + }); + + it('按去空白后的码点数判字数:正好到上限通过,多一个码点报错', () => { + const atLimit = '字'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); + expect(validateXhsMinitoolForm({ ...VALID, name: atLimit })).toBeNull(); + + const over = `${atLimit}字`; + const error = validateXhsMinitoolForm({ ...VALID, name: over }); + expect(error?.field).toBe('name'); + expect(error?.reason).toContain(`${XHS_MINITOOL_TEXT_MAX_CHARS}`); + }); + + it('一个 emoji 也算一个字:`String.length` 数的是码元,这里按码点算', () => { + const emojis = '🙂'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); + expect(validateXhsMinitoolForm({ ...VALID, name: emojis })).toBeNull(); + expect( + validateXhsMinitoolForm({ ...VALID, name: `${emojis}🙂` })?.field, + ).toBe('name'); + }); + + it('只挡首个错误:先报名称,名称修好后才轮到简介', () => { + const empty = { name: ' ', introduction: ' ', iconPath: '' }; + expect(validateXhsMinitoolForm(empty)?.field).toBe('name'); + expect(validateXhsMinitoolForm({ ...empty, name: '有名字了' })?.field).toBe( + 'introduction', + ); + expect( + validateXhsMinitoolForm({ + ...empty, + name: '有名字了', + introduction: '有简介了', + })?.field, + ).toBe('iconPath'); + }); +}); From fafc83f9e6e516e1a5cfd887bb41cf6788b250cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:28:11 +0800 Subject: [PATCH 149/156] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=B9=B3=E5=8F=B0=E4=B8=BB=E9=A2=98=E5=8F=98?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `Shell` 的米色页面渐变改用 `bg-(image:--platform-body-fill)` - 文案 / 强调 / 描边换成 `--platform-text-strong` / `-base` / `-soft` / `-muted`、`--platform-accent`、`--platform-surface-border` 与 `--platform-surface-hover-border` - 卡片与输入底色换成 `--platform-panel-fill` / `--platform-input-fill` / `--platform-neutral-bg`,冲突卡底换成 `--platform-warm-bg` - 硬编码阴影换成 `--platform-panel-shadow` 与 `--platform-profile-action-shadow` - 小红书品牌红 `#ff2442` 不在 `--platform-*` 调色板里,按原值保留并加注释 --- .../export/components/EmptyTab.tsx | 2 +- .../export/components/Shell.tsx | 6 ++-- .../export/components/TargetCard.tsx | 6 ++-- .../export/components/TargetTabs.tsx | 4 +-- .../export/tabs/common/PaneButton.tsx | 9 +++-- .../export/tabs/taonier/PublishPane.tsx | 6 ++-- .../tabs/xiaohongshu/AdaptPromptDialog.tsx | 6 ++-- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 18 ++++++---- .../export/tabs/xiaohongshu/ConflictCard.tsx | 22 ++++++++----- .../export/tabs/xiaohongshu/CopyButton.tsx | 2 +- .../xiaohongshu/DownloadFailureDialog.tsx | 10 +++--- .../export/tabs/xiaohongshu/FailureDialog.tsx | 4 +-- .../export/tabs/xiaohongshu/FormCard.tsx | 33 +++++++++++++------ .../export/tabs/xiaohongshu/IconPreview.tsx | 6 ++-- 14 files changed, 82 insertions(+), 52 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/components/EmptyTab.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/components/EmptyTab.tsx index 28f93968e..58441ea15 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/components/EmptyTab.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/components/EmptyTab.tsx @@ -9,7 +9,7 @@ import { type Target, TARGET_META } from '../model'; export function EmptyTab({ target }: { target: Target }) { return ( <div - className="grid h-full min-h-0 place-items-center p-6 text-sm text-[#988476]" + className="grid h-full min-h-0 place-items-center p-6 text-sm text-(--platform-text-soft)" aria-label={`${TARGET_META[target].label}导出页`} > {`${TARGET_META[target].label}导出页尚未接入`} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/components/Shell.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/components/Shell.tsx index 19cb9bb68..c3d6f9174 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/components/Shell.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/components/Shell.tsx @@ -22,8 +22,8 @@ export function Shell({ onClose: () => void; }) { return ( - <main className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-gradient-to-b from-[#fffdf9] via-[#fdf9f5] to-[#f8efe7] text-[#3d1f10]"> - <header className="flex shrink-0 items-stretch gap-3 border-b border-[#e2cbb8] px-4"> + <main className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(image:--platform-body-fill) text-(--platform-text-strong)"> + <header className="flex shrink-0 items-stretch gap-3 border-b border-(--platform-surface-border) px-4"> <div className="flex items-center py-2"> <strong className="text-base font-semibold">导出到</strong> </div> @@ -35,7 +35,7 @@ export function Shell({ type="button" aria-label="关闭导出面板" title="关闭" - className="grid size-8 shrink-0 place-items-center rounded-lg border border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] transition hover:border-[#c7653d] hover:text-[#c7653d]" + className="grid size-8 shrink-0 place-items-center rounded-lg border border-(--platform-surface-border) bg-(image:--platform-panel-fill) text-(--platform-text-base) transition hover:border-(--platform-surface-hover-border) hover:text-(--platform-accent)" onClick={onClose} > <X size={16} aria-hidden="true" /> diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetCard.tsx index de93b1682..b6d495e65 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetCard.tsx @@ -18,7 +18,7 @@ export function TargetCard({ return ( <button type="button" - className="group flex aspect-square w-full flex-col items-center justify-center gap-4 rounded-3xl border border-[#e2cbb8] bg-[#fffaf5] p-6 text-center shadow-[0_18px_40px_rgba(112,57,30,0.08)] transition hover:-translate-y-0.5 hover:border-[#c7653d] hover:shadow-[0_22px_48px_rgba(112,57,30,0.14)]" + className="group flex aspect-square w-full flex-col items-center justify-center gap-4 rounded-3xl border border-(--platform-surface-border) bg-(image:--platform-panel-fill) p-6 text-center shadow-(--platform-panel-shadow) transition hover:-translate-y-0.5 hover:border-(--platform-surface-hover-border) hover:shadow-(--platform-profile-action-shadow)" onClick={() => onOpen(target)} > {/* 图标盒恒为正方形:只许按比例缩放,不许拉成矩形。 */} @@ -29,7 +29,9 @@ export function TargetCard({ className="h-full w-full object-contain transition group-hover:scale-105" /> </span> - <span className="text-lg font-semibold text-[#3d1f10]">{meta.label}</span> + <span className="text-lg font-semibold text-(--platform-text-strong)"> + {meta.label} + </span> </button> ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx index 0881d7ef8..d259de2cb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/components/TargetTabs.tsx @@ -33,8 +33,8 @@ export function TargetTabs({ aria-current={active ? 'true' : undefined} className={`relative flex min-w-0 items-center justify-center gap-1.5 px-2 pb-2 pt-1 text-xs font-medium transition-colors after:absolute after:inset-x-3 after:bottom-0 after:h-[2px] after:rounded-full after:transition-colors after:content-[''] ${ active - ? 'text-[#c7653d] after:bg-[#c7653d]' - : 'text-[#988476] after:bg-transparent hover:text-[#6f5848]' + ? 'text-(--platform-accent) after:bg-(--platform-accent)' + : 'text-(--platform-text-soft) after:bg-transparent hover:text-(--platform-text-base)' }`} onClick={() => onSelect(target)} > diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx index 2003bc7d5..7d81723d2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/common/PaneButton.tsx @@ -12,10 +12,13 @@ import type { ReactNode } from 'react'; * 语气 → 类名。用查表而不是嵌套三元:加第四种语气时只加一行,也不用再读一层分支。 */ const TONE_CLASS: Record<'primary' | 'ghost' | 'brand', string> = { - brand: 'border-[#ff2442] bg-[#ff2442] text-white hover:bg-[#e01e3a]', - primary: 'border-[#c7653d] bg-[#c7653d] text-white hover:bg-[#b4562f]', + // 小红书红是平台自己的品牌色,不在 `--platform-*` 调色板里,按原值保留。 + brand: + 'border-[#ff2442] bg-[#ff2442] text-(--platform-button-primary-text) hover:bg-[#e01e3a]', + primary: + 'border-(--platform-accent) bg-(--platform-accent) text-(--platform-button-primary-text) hover:bg-(--platform-warm-text)', ghost: - 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]', + 'border-(--platform-surface-border) bg-(--platform-input-fill) text-(--platform-text-base) hover:border-(--platform-surface-hover-border) hover:text-(--platform-accent)', }; /** 尺寸 → 类名,理由同 [`TONE_CLASS`]。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx index 4a548c159..90aef81bb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/PublishPane.tsx @@ -12,14 +12,14 @@ export function PublishPane({ tab }: { tab: TaonierTabState }) { const manifest = tab.manifest; if (!manifest) { return ( - <div className="flex h-full min-h-0 items-center justify-center border-[#e2cbb8] bg-[#fffdf9]/40 p-6 md:border-r"> - <p className="text-sm text-[#988476]">正在读取项目清单…</p> + <div className="flex h-full min-h-0 items-center justify-center border-(--platform-surface-border) bg-(--platform-neutral-bg) p-6 md:border-r"> + <p className="text-sm text-(--platform-text-soft)">正在读取项目清单…</p> </div> ); } return ( <div - className="flex h-full min-h-0 min-w-0 flex-col border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r" + className="flex h-full min-h-0 min-w-0 flex-col border-(--platform-surface-border) bg-(--platform-neutral-bg) md:border-r" aria-label="游戏发布表单区" > <GameDistributionPublishFormView diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx index 644733f8f..f1f3dee64 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/AdaptPromptDialog.tsx @@ -28,12 +28,12 @@ export function AdaptPromptDialog({ onClose={onDoItMyself} closeOnBackdrop={false} closeOnEscape={false} - panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4" + panelClassName="w-full max-w-md rounded-2xl border border-(--platform-surface-border) p-4" > - <h2 className="text-sm font-semibold text-[#3d1f10]"> + <h2 className="text-sm font-semibold text-(--platform-text-strong)"> 这个项目还没有配置好导出 </h2> - <p className="mt-2 text-xs leading-relaxed text-[#6f5848]"> + <p className="mt-2 text-xs leading-relaxed text-(--platform-text-base)"> 要让陶泥儿先在项目里帮你调通一次导出流程(用哪套配置、入口在哪、怎么打包都由它试出来)。 调通之后,每次点「打包」就能直接生成代码包。 </p> diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 0583e9fbe..76e61a529 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -143,27 +143,33 @@ export function ArtifactsPane({ /> ); } else if (!minitool.readSettled) { - formSlot = <p className="text-xs text-[#988476]">正在读取导出信息…</p>; + formSlot = ( + <p className="text-xs text-(--platform-text-soft)">正在读取导出信息…</p> + ); } else if (!failure) { // 读失败时上面不重复一句话:失败弹窗拿着宿主的现场和下一步。 formSlot = ( - <p className="text-xs text-[#988476]">没能读到导出信息,正在自动重试…</p> + <p className="text-xs text-(--platform-text-soft)"> + 没能读到导出信息,正在自动重试… + </p> ); } return ( <div - className="flex h-full min-h-0 min-w-0 flex-col border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r" + className="flex h-full min-h-0 min-w-0 flex-col border-(--platform-surface-border) bg-(--platform-neutral-bg) md:border-r" aria-label="导出内容区" > <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3"> {formSlot} <section - className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4" + className="rounded-2xl border border-(--platform-surface-border) bg-(image:--platform-panel-fill) p-4" aria-label="小工具代码包" > - <h3 className="text-sm font-bold text-[#3d1f10]">小工具代码包</h3> + <h3 className="text-sm font-bold text-(--platform-text-strong)"> + 小工具代码包 + </h3> <div className="mt-4 flex flex-col gap-2"> {/* 「打包」不因为有脚本判据没到位就变灰:有没有脚本是**点下去那一刻**才成立的事实 @@ -212,7 +218,7 @@ export function ArtifactsPane({ */} <button type="button" - className="cursor-pointer self-center border-0 bg-transparent p-0 text-xs font-semibold text-[#c7653d] underline underline-offset-2 transition-colors hover:text-[#b4562f] active:text-[#3d1f10] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c7653d]" + className="cursor-pointer self-center border-0 bg-transparent p-0 text-xs font-semibold text-(--platform-accent) underline underline-offset-2 transition-colors hover:text-(--platform-warm-text) active:text-(--platform-text-strong) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--platform-accent)" onClick={() => { void openUrl(XHS_PUBLISH_URL).catch((error) => { console.error('[agc] 打开小红书发布页失败', error); diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx index 8c2e6c556..dd362f874 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ConflictCard.tsx @@ -41,18 +41,18 @@ export function ConflictCard({ <section role="alertdialog" aria-label="导出信息冲突" - className="rounded-2xl border border-[#c7653d] bg-[#fff5ef] p-4" + className="rounded-2xl border border-(--platform-accent) bg-(--platform-warm-bg) p-4" > - <h3 className="text-sm font-semibold text-[#3d1f10]"> + <h3 className="text-sm font-semibold text-(--platform-text-strong)"> 这份导出信息被改过 </h3> - <p className="mt-1 text-xs text-[#6f5848]"> + <p className="mt-1 text-xs text-(--platform-text-base)"> {`下面 ${differing.length} 项两边不一样,各选一个再保存;没列出来的两边一致。`} </p> <div className="mt-3 flex flex-col gap-3"> {differing.map((meta) => ( <div key={meta.field} className="flex flex-col gap-1"> - <span className="text-xs font-medium text-[#6f5848]"> + <span className="text-xs font-medium text-(--platform-text-base)"> {meta.label} </span> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> @@ -113,22 +113,26 @@ function Choice({ }) { return ( <label - className={`flex cursor-pointer flex-col gap-0.5 rounded-lg border bg-white px-2 py-1.5 text-xs transition ${ - selected ? 'border-[#c7653d]' : 'border-[#e2cbb8]' + className={`flex cursor-pointer flex-col gap-0.5 rounded-lg border bg-(--platform-input-fill) px-2 py-1.5 text-xs transition ${ + selected + ? 'border-(--platform-accent)' + : 'border-(--platform-surface-border)' }`} > - <span className="flex items-center gap-1 text-[#988476]"> + <span className="flex items-center gap-1 text-(--platform-text-soft)"> <input type="radio" name={name} aria-label={`${label}:${caption}`} checked={selected} - className="accent-[#c7653d]" + className="accent-(--platform-accent)" onChange={onSelect} /> {caption} </span> - <span className="break-all text-[#3d1f10]">{value || '(空)'}</span> + <span className="break-all text-(--platform-text-strong)"> + {value || '(空)'} + </span> </label> ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx index 59696e181..664400921 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx @@ -41,7 +41,7 @@ export function CopyButton({ value, label }: { value: string; label: string }) { className={`inline-flex shrink-0 items-center gap-1 rounded-[10px] border px-2.5 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-45 ${ state === 'failed' ? 'border-red-300 bg-red-50 text-red-600' - : 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]' + : 'border-(--platform-surface-border) bg-(--platform-input-fill) text-(--platform-text-base) hover:border-(--platform-surface-hover-border) hover:text-(--platform-accent)' }`} onClick={() => { setState('idle'); diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx index d1eb2ff01..e43ce5917 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx @@ -40,23 +40,23 @@ export function DownloadFailureDialog({ onClose={onDismiss} closeOnBackdrop={false} closeOnEscape={false} - panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4" + panelClassName="w-full max-w-md rounded-2xl border border-(--platform-surface-border) p-4" > - <p className="text-sm leading-relaxed text-[#3d1f10]"> + <p className="text-sm leading-relaxed text-(--platform-text-strong)"> {failure ? `没能把${failure.kind === 'icon' ? '图标' : '代码包'}保存到你选的位置。` : null} </p> {failure ? ( - <p className="mt-2 text-xs leading-relaxed text-[#6f5848]"> + <p className="mt-2 text-xs leading-relaxed text-(--platform-text-base)"> {failure.reason} </p> ) : null} - <p className="mt-2 text-xs leading-relaxed text-[#6f5848]"> + <p className="mt-2 text-xs leading-relaxed text-(--platform-text-base)"> 也可以让系统在文件管理器里打开它原来的位置,自己复制过去。 </p> {revealFailure ? ( - <p role="status" className="mt-2 text-xs text-[#b4562f]"> + <p role="status" className="mt-2 text-xs text-(--platform-warm-text)"> {revealFailure} </p> ) : null} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx index 4075b370a..234010944 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FailureDialog.tsx @@ -57,9 +57,9 @@ export function FailureDialog({ onClose={onDismiss} closeOnBackdrop={false} closeOnEscape={false} - panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4" + panelClassName="w-full max-w-md rounded-2xl border border-(--platform-surface-border) p-4" > - <p className="text-sm leading-relaxed text-[#3d1f10]"> + <p className="text-sm leading-relaxed text-(--platform-text-strong)"> {failure ? describeXhsMinitoolFailure(failure) : null} </p> <div className="mt-4 flex flex-wrap justify-end gap-2"> diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index f413a9205..f8e13e4db 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -41,7 +41,7 @@ export function FormCard({ }) { return ( <section - className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4" + className="rounded-2xl border border-(--platform-surface-border) bg-(image:--platform-panel-fill) p-4" aria-label="上传到小红书时要填的信息" > {/* @@ -50,7 +50,7 @@ export function FormCard({ 的自动重读再把值带回来,用户自己打的字先由自动保存落盘。 */} <div className="flex items-start justify-between gap-3"> - <h3 className="text-sm font-bold text-[#3d1f10]"> + <h3 className="text-sm font-bold text-(--platform-text-strong)"> 上传到小红书时要填的信息 </h3> <PaneButton @@ -101,8 +101,8 @@ export function FormCard({ /** 表单控件的公共皮:与发布面板的输入同宽、同圆角、同暖色焦点环。 */ function fieldInputClass(error: string | null) { - return `box-border w-full rounded-[10px] border bg-white/80 px-3 py-2.5 text-sm leading-[1.45] text-[#3d1f10] outline-none transition placeholder:text-[#b9a494] focus-visible:border-[#c7653d] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#c7653d]/25 ${ - error ? 'border-[#c7653d]' : 'border-[#e2cbb8]' + return `box-border w-full rounded-[10px] border bg-(--platform-input-fill) px-3 py-2.5 text-sm leading-[1.45] text-(--platform-text-strong) outline-none transition placeholder:text-(--platform-text-muted) focus-visible:border-(--platform-accent) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--platform-warm-border) ${ + error ? 'border-(--platform-accent)' : 'border-(--platform-surface-border)' }`; } @@ -134,14 +134,19 @@ function FieldRow({ return ( <div className="grid gap-1.5"> <div className="flex items-center justify-between gap-2"> - <label htmlFor={id} className="text-xs font-bold text-[#3d1f10]"> + <label + htmlFor={id} + className="text-xs font-bold text-(--platform-text-strong)" + > {label} </label> <span className="flex items-center gap-2"> {maxChars !== undefined ? ( <span className={`text-[11px] font-medium tabular-nums ${ - overLimit ? 'text-[#c7653d]' : 'text-[#988476]' + overLimit + ? 'text-(--platform-accent)' + : 'text-(--platform-text-soft)' }`} > {used}/{maxChars} @@ -171,7 +176,10 @@ function FieldRow({ /> )} {error ? ( - <p role="alert" className="text-xs font-medium text-[#c7653d]"> + <p + role="alert" + className="text-xs font-medium text-(--platform-accent)" + > {error} </p> ) : null} @@ -206,13 +214,15 @@ function IconField({ return ( <div className="grid gap-1.5"> - <span className="text-xs font-bold text-[#3d1f10]">图标</span> + <span className="text-xs font-bold text-(--platform-text-strong)"> + 图标 + </span> <div className="flex items-start gap-3"> <IconPreview projectPath={projectPath} relativePath={value} /> <div className="grid min-w-0 flex-1 gap-2"> {/* 没选图标时不摆一行字:缩略图里的占位符已经说了这件事,右侧直接给唯一那颗按钮。 */} {fileName ? ( - <p className="truncate text-sm leading-[1.45] text-[#6f5848]"> + <p className="truncate text-sm leading-[1.45] text-(--platform-text-base)"> 已选:{fileName} </p> ) : null} @@ -241,7 +251,10 @@ function IconField({ </div> </div> {error ? ( - <p role="alert" className="text-xs font-medium text-[#c7653d]"> + <p + role="alert" + className="text-xs font-medium text-(--platform-accent)" + > {error} </p> ) : null} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/IconPreview.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/IconPreview.tsx index 06c8aca21..ef1d96f4d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/IconPreview.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/IconPreview.tsx @@ -69,7 +69,7 @@ export function IconPreview({ return ( <span - className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-xl border border-[#e2cbb8] bg-white/80" + className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-xl border border-(--platform-surface-border) bg-(--platform-input-fill)" title={ failed ? '读不到这张图,用「选择图标」导入一张就能预览' : undefined } @@ -85,7 +85,9 @@ export function IconPreview({ ) : ( <ImageOff size={18} - className={failed ? 'text-[#c7653d]' : 'text-[#b9a494]'} + className={ + failed ? 'text-(--platform-accent)' : 'text-(--platform-text-muted)' + } aria-hidden="true" /> )} From 3162b04ee42d3fe729e45554bcb7ff5c192a52cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:56:32 +0800 Subject: [PATCH 150/156] =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=B0=8F=E7=BA=A2?= =?UTF-8?q?=E4=B9=A6=E6=B3=A8=E5=86=8C=E8=A1=A8=E4=B8=8D=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E7=AC=A6=E5=8F=B7=E9=93=BE=E6=8E=A5=E7=9A=84=E5=8F=96=E8=88=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.rs 模块注释写明决定不改的理由:注册表是用户项目里的草稿,要挡的是把项目内容交给打包链的入口 --- .../src/export/draft/xhs_minitool/registry.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 index ac8739bcf..d5ed12aa5 100644 --- 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 @@ -3,11 +3,15 @@ //! 文件级细节(自动创建、严格解析、原子写入、内容指纹)都在 [`crate::export::registry`];这里 //! 只管本目标的事:注册表 ↔ 表单的映射,以及冲突判定。 //! -//! 已知取舍(评审提出、当前决定不改):这里用共享的 `load_or_create` / `save`,**不**额外检查 -//! 注册表文件与 `.export` 目录是不是符号链接。也就是说,如果 `.export/xhs-minitool.json` 被换成 -//! 链接,读写会落到项目外。这与产物(`build::conclude`)、脚本(`script::find`)不同,也与图标在 -//! 预览 / 下载时走的路径门禁不同,是有意为之:那几处是「把项目内容交给打包链」的入口,这里读写 -//! 的是用户自己项目里的草稿文件,按「项目文件由用户掌控」的前提接受这个重定向。 +//! 已知取舍(评审提出):这里用共享的 `load_or_create` / `save`,**不**额外检查注册表文件与 +//! `.export` 目录是不是符号链接。也就是说,如果 `.export/xhs-minitool.json` 被换成链接,读写会 +//! 落到项目外。 +//! +//! **决定不改,理由**:要挡的是「把项目内容交给打包链」的入口——产物(`build::conclude`)、脚本 +//! (`script::find`)和图标在预览 / 下载时走的路径门禁都拒链,因为它们把项目内容送出项目边界。 +//! 注册表读写的是用户自己项目里的一份草稿,用户既然能在磁盘上把它换成链接,就按「项目文件由用户 +//! 掌控」接受这个重定向;为它单独加守卫会改掉共享读写的契约,收益与成本不成比例。若以后注册表要 +//! 承载跨项目共享的内容,再按与那三处同口径补 `symlink_metadata` 检查。 use std::path::Path; From e6190f080e2c399319324f492e2972e0b4a97ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:56:35 +0800 Subject: [PATCH 151/156] =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=B0=8F=E7=BA=A2?= =?UTF-8?q?=E4=B9=A6=E6=89=93=E5=8C=85=E8=84=9A=E6=9C=AC=E9=A2=84=E6=A3=80?= =?UTF-8?q?=E4=B8=8E=E6=89=93=E5=8C=85=E5=8F=A3=E5=BE=84=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E5=8F=96=E8=88=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pack.mjs 的 collectZipEntries 注释写明决定不改的理由:链接算不算合法产物还没定论,预检对链接是静默跳过 - manifest.json 同步 vite-export-xhs-minitool 的 sha256 与版本 --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- .../vite-export-xhs-minitool/scripts/pack.mjs | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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 87ba2cf8a..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.69", + "version": "2026-08-26.70", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "1c2323f06e1b278f910c9aa48ae9c3c27ec351349c0c6a485fdc0603023d27ca" + "sha256": "fd869242fdd40bb46fc3fbf30d1509c79d306e769e87dad3430b2ab0e5449b12" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs index 9dbdb3d09..84b85c9c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs @@ -133,11 +133,13 @@ function crc32(buffer) { /** * 收集目录下所有条目(目录条目在前,文件名统一 / 分隔,保持排序以稳定产物)。 * - * 已知取舍(评审提出、当前决定不改):本函数用 `statSync`(跟随符号链接)且**不**跳 `SKIP_DIRS`, - * 而校验脚本预检用的 `walkArtifactFiles` 是 `lstatSync`(跳过链接)且会跳 `SKIP_DIRS`。两者口径 - * 不一致:链接目标的内容会被按链接名打进包,`venv` / `.cache` 这类目录也可能绕过扩展名白名单。 - * 不统一是因为「链接算不算合法产物」还没有定论,改任一侧都会动到现有可能依赖链接的包。真要统一 - * 时必须两份实现一起改,并补一条「预检与打包看到同一批文件」的用例。 + * 已知取舍(评审提出):本函数用 `statSync`(跟随符号链接)且**不**跳 `SKIP_DIRS`,而校验脚本 + * 预检用的 `walkArtifactFiles` 是 `lstatSync`(跳过链接)且会跳 `SKIP_DIRS`。两者口径不一致: + * 链接目标的内容会被按链接名打进包,`venv` / `.cache` 这类目录也可能绕过扩展名白名单。 + * + * **决定不改,理由**:「链接算不算合法产物」还没有定论,而预检对链接是**静默跳过**、不是拒绝; + * 照评审建议在打包侧对链接直接抛错,会和预检的口径打架,也会动到现有可能依赖链接的包。真要统一 + * 时必须两份实现一起改成同一口径,并补一条「预检与打包看到同一批文件」的用例,那是独立的一次改动。 */ function collectZipEntries(rootDir) { const entries = []; From ac0b08b2ace1dbd9bd303319b51129fdad0b9e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:56:41 +0800 Subject: [PATCH 152/156] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=A2=AB=E4=BF=A1=E5=8F=B7=E7=BB=88=E6=AD=A2=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xhsMinitoolFailure.ts 的 commandFailed 在 exitCode 为 null 时改说「导出中途被中断了」:脚本没启动已归到 exportUnavailable - xhsMinitoolExport.test.tsx 同步这两条措辞断言 --- .../project-development/export/state/xhsMinitoolFailure.ts | 4 +++- apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts index 8f5196327..88f694346 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts @@ -75,8 +75,10 @@ export function describeXhsMinitoolFailure( case 'commandDenied': return '当前项目的权限设置不允许执行这次导出。'; case 'commandFailed': + // 「脚本根本没启动」已在宿主侧归到 `exportUnavailable`(见 build.rs 的 `script_launched`), + // 所以这里的 `exitCode === null` 只剩「脚本跑起来后被信号终止」:说用户能接住的话,不猜启动。 return error.exitCode === null - ? '导出没能启动,可能是运行环境的问题。' + ? '导出中途被中断了,没能生成代码包。' : '导出中途出错了,没能生成代码包。'; case 'artifactMissing': return '导出跑完了,但没有生成代码包。可以让陶泥儿看看。'; diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 055cc1290..6be4d79ad 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -126,7 +126,7 @@ describe('交给 code agent 的指令', () => { omittedCharacters: 0, }), ).toBe('导出中途出错了,没能生成代码包。'); - // 没启动起来(宿主没给退出码)与跑到一半失败是两句不同的话:用户能做的处置不一样。 + // 没启动起来已经归到 `exportUnavailable`(宿主保证);这里没退出码只剩「被信号终止」这一种。 expect( describeXhsMinitoolFailure({ type: 'commandFailed', @@ -134,7 +134,7 @@ describe('交给 code agent 的指令', () => { outputTail: '', omittedCharacters: 0, }), - ).toBe('导出没能启动,可能是运行环境的问题。'); + ).toBe('导出中途被中断了,没能生成代码包。'); // 策略拒绝是预期拒绝:宿主只给点位,句子在前端,且不进错误池。 expect( describeXhsMinitoolFailure({ From 157b4eb861b48e9c37b222f9544f900334eba8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 22:56:51 +0800 Subject: [PATCH 153/156] =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=80=90=E9=A1=B9=E6=8A=A5=E9=94=99=E5=B9=B6=E5=8F=AF=E4=BA=A4?= =?UTF-8?q?=E7=BB=99=E9=99=B6=E6=B3=A5=E5=84=BF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xhsMinitoolFields.ts 的 validateXhsMinitoolForm 一次返回全部出错字段 - xhsMinitoolInstruction.ts 新增字段红字指令,示意里的字数上限改用 XHS_MINITOOL_TEXT_MAX_CHARS - useXhsMinitoolExport.ts 的 fieldError 改为 fieldErrors,新增 handFieldErrorsToAgent:交接后清红字与 dirty,让自动重读能采纳 agent 的修正 - FormCard.tsx 每个出错字段各挂红框与红字,标题下新增「交给陶泥儿」入口 - ArtifactsPane.tsx 接上新字段与新动作 - 补 xhsMinitoolFields / xhsMinitoolExport 测试覆盖多字段报错与交接 --- .../export/state/useXhsMinitoolExport.ts | 61 +++++++----- .../export/state/xhsMinitoolFields.ts | 20 ++-- .../export/state/xhsMinitoolInstruction.ts | 43 +++++++- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 3 +- .../export/tabs/xiaohongshu/FormCard.tsx | 37 +++++-- .../tests/xhsMinitoolExport.test.tsx | 97 ++++++++++++++++++- .../tests/xhsMinitoolFields.test.ts | 51 ++++++---- 7 files changed, 249 insertions(+), 63 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 322eda813..54af46afe 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -45,6 +45,7 @@ import { import { buildXhsMinitoolAdaptationInstruction, buildXhsMinitoolErrorFixInstruction, + buildXhsMinitoolFormErrorInstruction, buildXhsMinitoolFormFillInstruction, } from './xhsMinitoolInstruction'; @@ -86,9 +87,7 @@ export function useXhsMinitoolExport({ }) { const [form, setForm] = useState<XHSMiniToolExportForm | null>(null); const [hasScript, setHasScript] = useState(false); - const [fieldError, setFieldError] = useState<XhsMinitoolFieldError | null>( - null, - ); + const [fieldErrors, setFieldErrors] = useState<XhsMinitoolFieldError[]>([]); const [conflict, setConflict] = useState<SaveConflict | null>(null); const [failure, setFailure] = useState<XhsMinitoolCardFailure | null>(null); /** @@ -153,8 +152,8 @@ export function useXhsMinitoolExport({ }, [form]); useEffect(() => { - adoptableRef.current = !dirty && !conflict && !fieldError; - }, [dirty, conflict, fieldError]); + adoptableRef.current = !dirty && !conflict && fieldErrors.length === 0; + }, [dirty, conflict, fieldErrors]); /** * 换项目时把整套状态与本地判据清回初值。 @@ -169,7 +168,7 @@ export function useXhsMinitoolExport({ projectPathRef.current = projectPath; setForm(null); setHasScript(false); - setFieldError(null); + setFieldErrors([]); setConflict(null); setFailure(null); setDismissedFailure(''); @@ -262,7 +261,7 @@ export function useXhsMinitoolExport({ return content.hasScript; } setForm(content.form); - setFieldError(null); + setFieldErrors([]); setConflict(null); setFailure(null); try { @@ -337,11 +336,11 @@ export function useXhsMinitoolExport({ if (!projectPath) return false; const invoke = resolveTauriInvoke(); if (!invoke) return false; - // 首个字段错误即返回:红字挂到对应输入框,等用户改完下一次输入再存。`dirty` 归零,同一份 - // 不合法的输入不会反复排队。 + // 一次把**所有**出错字段都挂出来:用户能同时看到每个字段各自的问题,不必改一个才知道下一个。 + // `dirty` 归零,同一份不合法的输入不会反复排队。 const invalid = validateXhsMinitoolForm(next); - if (invalid) { - setFieldError(invalid); + if (invalid.length > 0) { + setFieldErrors(invalid); setDirty(false); return false; } @@ -357,7 +356,7 @@ export function useXhsMinitoolExport({ // 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。 baselineRef.current = nextBaseline; hashPendingRef.current = false; - setFieldError(null); + setFieldErrors([]); setConflict(null); setFailure(null); return true; @@ -389,12 +388,14 @@ export function useXhsMinitoolExport({ ); useEffect(() => { - if (!enabled || !form || !dirty || conflict || fieldError) return; + if (!enabled || !form || !dirty || conflict || fieldErrors.length > 0) { + return; + } const timer = setTimeout(() => { void persist(form, baselineRef.current); }, AUTOSAVE_DELAY_MS); return () => clearTimeout(timer); - }, [enabled, form, dirty, conflict, fieldError, persist]); + }, [enabled, form, dirty, conflict, fieldErrors, persist]); /** 改一个字段。改哪个字段就清哪个字段的旧报错——别让已经改好的红字继续挂着。 */ const updateField = useCallback( @@ -404,8 +405,8 @@ export function useXhsMinitoolExport({ setForm((previous) => previous ? { ...previous, [field]: value } : previous, ); - setFieldError((previous) => - previous && previous.field === field ? null : previous, + setFieldErrors((previous) => + previous.filter((error) => error.field !== field), ); setDirty(true); }, @@ -482,12 +483,12 @@ export function useXhsMinitoolExport({ return; } // 先 flush 自动保存:用户刚敲完就点打包时,注册表里可能还是上一份。 - if (dirty && form && !conflict && !fieldError) { + if (dirty && form && !conflict && fieldErrors.length === 0) { const flushed = await persist(form, baselineRef.current); if (!flushed) return; } // 没解决的冲突 / 字段红字:盘上的内容和用户看到的不一致,先别打包。 - if (conflict || fieldError) return; + if (conflict || fieldErrors.length > 0) return; await buildOnce(); } finally { // 旧项目那一次打包的收尾不解新项目的锁。 @@ -499,7 +500,7 @@ export function useXhsMinitoolExport({ dirty, form, conflict, - fieldError, + fieldErrors, persist, buildOnce, ]); @@ -542,11 +543,26 @@ export function useXhsMinitoolExport({ const fillForm = useCallback(async (): Promise<void> => { if (!projectPath || !form) return; // 冲突或字段红字挂着时不重复撞一次保存(自动保存此时也是停的),直接把现场交给 agent。 - if (dirty && !conflict && !fieldError) { + if (dirty && !conflict && fieldErrors.length === 0) { await persist(form, baselineRef.current); } await sendToAgent(buildXhsMinitoolFormFillInstruction()); - }, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]); + }, [projectPath, form, dirty, conflict, fieldErrors, persist, sendToAgent]); + + /** + * 把手上的字段限制问题交给 agent:指令带上界面本地校验给用户的那些原话,agent 直接改注册表把值改成 + * 合规的。 + * + * 交出去之后清掉本地红字与 `dirty`:红字留着会让自动重读一直不肯采纳 agent 写回来的新值 + * (`adoptableRef` 要求手上没有待处理现场),用户就会卡在「点了按钮却什么都没变」。用户那份不合法 + * 的输入随指令一起带过去,agent 不必回界面猜。 + */ + const handFieldErrorsToAgent = useCallback((): void => { + if (!projectPath || !form || fieldErrors.length === 0) return; + setFieldErrors([]); + setDirty(false); + void sendToAgent(buildXhsMinitoolFormErrorInstruction(form, fieldErrors)); + }, [projectPath, form, fieldErrors, sendToAgent]); /** * 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 @@ -595,7 +611,7 @@ export function useXhsMinitoolExport({ return { form, hasScript, - fieldError, + fieldErrors, conflict, failure: visibleFailure, loading, @@ -609,6 +625,7 @@ export function useXhsMinitoolExport({ adapt, repair, fillForm, + handFieldErrorsToAgent, dismissAdaptPrompt, dismissFailure, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts index dfd06ee4e..bb2ee0ed8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts @@ -9,7 +9,7 @@ import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; /** 表单里的三个字段,与 [`XHSMiniToolExportForm`] 的键一一对应。 */ export type XhsMinitoolField = 'name' | 'introduction' | 'iconPath'; -/** 字段红字:一次只挂一个出错字段,用户改完再报下一个。 */ +/** 字段红字:一个出错字段一条,界面按 `field` 挂到对应输入框下面。 */ export type XhsMinitoolFieldError = { field: XhsMinitoolField; reason: string; @@ -63,18 +63,18 @@ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ * * 校验放在界面这一层是刻意的:用户最终面对的就是界面,这份表单也只是一份草稿,不值得让宿主再挡 * 一次。文件是不是真的存在、是不是符号链接、扩展名与大小都不在这里判——那些是「真的去读它」时 - * 才成立的事实,由预览 / 下载那条路各自的路径门禁负责。首个错误即返回,与表单的提交顺序一致。 + * 才成立的事实,由预览 / 下载那条路各自的路径门禁负责。 + * + * 返回**全部**出错字段(按表单顺序),让界面一次把每个字段的红字都挂上;调用方只需判空数组。 */ export function validateXhsMinitoolForm( form: XHSMiniToolExportForm, -): XhsMinitoolFieldError | null { - return ( - validateText('name', '名称', form.name) ?? - validateText('introduction', '简介', form.introduction) ?? - (form.iconPath.trim() - ? null - : { field: 'iconPath', reason: 'icon 不能为空' }) - ); +): XhsMinitoolFieldError[] { + return [ + validateText('name', '名称', form.name), + validateText('introduction', '简介', form.introduction), + form.iconPath.trim() ? null : { field: 'iconPath', reason: '图标不能为空' }, + ].filter((error): error is XhsMinitoolFieldError => error !== null); } function validateText( diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts index 2c377b688..e67c836c7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts @@ -9,8 +9,14 @@ * `tests/xhsMinitoolContract.test.ts` 对着 Rust 权威 `src-tauri/src/export/draft/xhs_minitool/layout.rs` * 逐条钉住。 */ +import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; import type { XHSMiniToolExportRegistry } from '../generated/XHSMiniToolExportRegistry'; import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure'; +import { + XHS_MINITOOL_FORM_FIELDS, + XHS_MINITOOL_TEXT_MAX_CHARS, + type XhsMinitoolFieldError, +} from './xhsMinitoolFields'; /** 目标名,与 `model.ts` 的 `TARGET_META` 人话一致。 */ export const XHS_MINITOOL_TARGET_LABEL = '小红书小工具'; @@ -37,8 +43,8 @@ export const XHS_MINITOOL_PACKAGE_SKILL_NAME = 'vite-export-xhs-minitool'; */ const XHS_MINITOOL_REGISTRY_SHAPE_HINT: XHSMiniToolExportRegistry = { form: { - name: '最多 14 个字', - introduction: '最多 14 个字', + name: `最多 ${XHS_MINITOOL_TEXT_MAX_CHARS} 个字`, + introduction: `最多 ${XHS_MINITOOL_TEXT_MAX_CHARS} 个字`, iconPath: 'png、jpg、jpeg;大小不超过 5 M;推荐比例为1:1', }, }; @@ -94,3 +100,36 @@ ${JSON.stringify(XHS_MINITOOL_REGISTRY_SHAPE_HINT, null, 2)} * 对于图标示例图等字段可以看看目前是否已经有合适的, 没有的话可以(征询用户意见后)生图/留给用户自己选择 `; } + +/** + * 「字段不合限制」指令:界面本地校验挡下表单后,让 agent 直接改注册表把值改成合规的。 + * + * 与「帮我填」同一条链(落点都是那份文件、宿主自动重读带回界面),区别是这条把**界面上那些原话** + * 带上:用户已经被限制挡下,agent 不必回界面猜他卡在哪。当前值也照抄进去——不合法的值没落盘, + * agent 光读文件看不到。 + */ +export function buildXhsMinitoolFormErrorInstruction( + form: XHSMiniToolExportForm, + errors: readonly XhsMinitoolFieldError[], +): string { + const lines = errors + .map((error) => { + const label = + XHS_MINITOOL_FORM_FIELDS.find((meta) => meta.field === error.field) + ?.label ?? error.field; + return ` - ${label}(${error.field}):${error.reason};当前值 ${JSON.stringify( + form[error.field], + )}`; + }) + .join('\n'); + return ` +帮我处理用于发布 ${XHS_MINITOOL_TARGET_LABEL} 的表单 ${XHS_MINITOOL_REGISTRY_RELATIVE_PATH} 里不合限制的字段。 + +* 被限制挡下的字段: +${lines} + +* 请按限制改成合适的值, 直接修改文件, 改完不必重述. +* 结构和限制: +${JSON.stringify(XHS_MINITOOL_REGISTRY_SHAPE_HINT, null, 2)} + `; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 76e61a529..5399cb211 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -135,11 +135,12 @@ export function ArtifactsPane({ formSlot = ( <FormCard form={form} - fieldError={minitool.fieldError} + fieldErrors={minitool.fieldErrors} projectPath={projectPath} onChange={minitool.updateField} onDownloadIcon={downloadIcon} onAskAgentToFill={() => void minitool.fillForm()} + onAskAgentAboutErrors={minitool.handFieldErrorsToAgent} /> ); } else if (!minitool.readSettled) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index f8e13e4db..d09d415fd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -16,8 +16,11 @@ import { IconPreview } from './IconPreview'; * 小红书上传表单。名称与介绍可复制(它们是要粘贴到上传页的文字),图标只给「下载」——复制一条 * 项目内路径没有意义。 * - * 表单只收集值:校验在界面这一层(保存前,见 `validateXhsMinitoolForm`),红字是本地校验的 - * 结果;宿主那侧只按原样读写草稿。 + * 表单只收集值:校验在界面这一层(保存前,见 `validateXhsMinitoolForm`),**每个**出错字段各自挂 + * 红框与红字;宿主那侧只按原样读写草稿。 + * + * 有字段被限制挡下时,标题下多一行说明 +「交给陶泥儿」:用户自己改不动就让 agent 直接改注册表, + * 指令带上这些红字的原话。 * 文案一律说「上传到小红书要填什么」,不暴露脚本名、注册表与 zip 这些实现词。 * * 版式对齐发布面板(`GameDistributionPublishPanel`):标签在上、输入独占整行、字段之间留够呼吸, @@ -26,18 +29,20 @@ import { IconPreview } from './IconPreview'; */ export function FormCard({ form, - fieldError, + fieldErrors, projectPath, onChange, onDownloadIcon, onAskAgentToFill, + onAskAgentAboutErrors, }: { form: XHSMiniToolExportForm; - fieldError: XhsMinitoolFieldError | null; + fieldErrors: XhsMinitoolFieldError[]; projectPath: string | null; onChange: (field: keyof XHSMiniToolExportForm, value: string) => void; onDownloadIcon: () => void; onAskAgentToFill: () => void; + onAskAgentAboutErrors: () => void; }) { return ( <section @@ -64,10 +69,28 @@ export function FormCard({ <span>帮我填</span> </PaneButton> </div> + {fieldErrors.length > 0 ? ( + <div className="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-(--platform-warm-border) bg-(--platform-warm-bg) px-3 py-2"> + <p className="text-xs leading-relaxed text-(--platform-text-base)"> + 有 {fieldErrors.length} 个字段不合平台限制。 + </p> + <PaneButton + variant="primary" + ariaLabel="交给陶泥儿处理这些字段" + title="把这些字段的问题与当前值原样发给陶泥儿,让它直接改注册表" + disabled={!projectPath} + onClick={onAskAgentAboutErrors} + > + <Sparkles size={13} aria-hidden="true" /> + <span>交给陶泥儿</span> + </PaneButton> + </div> + ) : null} <div className="mt-4 grid gap-4"> {XHS_MINITOOL_FORM_FIELDS.map((meta) => { const error = - fieldError?.field === meta.field ? fieldError.reason : null; + fieldErrors.find((item) => item.field === meta.field)?.reason ?? + null; if (meta.field === 'iconPath') { return ( <IconField @@ -126,8 +149,8 @@ function FieldRow({ onChange: (value: string) => void; }) { const id = useId(); - // 按码点计数,与宿主 `validate_text` 的 `chars().count()` 对齐:`String.length` 数的是 UTF-16 - // 码元,一个 emoji 会被这里算成 2、宿主算成 1,计数徽标会比宿主更早报超限。 + // 按码点计数:`String.length` 数的是 UTF-16 码元,一个 emoji 会被这里算成 2、平台算成 1, + // 计数徽标会比实际更早报超限。与 `validateXhsMinitoolForm` 用的是同一套计数。 const used = Array.from(value.trim()).length; const overLimit = maxChars !== undefined && used > maxChars; diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 6be4d79ad..69e554bc5 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -38,7 +38,9 @@ import { XhsMinitoolFailureRepairability, } from '../src/view/project-development/export/state/xhsMinitoolFailure'; import { + buildXhsMinitoolFormErrorInstruction, XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + XHS_MINITOOL_REGISTRY_RELATIVE_PATH, XHS_MINITOOL_SCRIPT_NAME, } from '../src/view/project-development/export/state/xhsMinitoolInstruction'; import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane'; @@ -209,6 +211,24 @@ describe('交给 code agent 的指令', () => { // `saveConflict` 不在这张表里:它只出现在保存命令的失败表上,有专属的冲突面板;字段红字则 // 在界面这一层就被拦下,根本走不到保存命令。 }); + + it('字段红字交给 agent 的指令带上红字原话、当前值与落点', () => { + const text = buildXhsMinitoolFormErrorInstruction( + { name: '太长的名字', introduction: '', iconPath: '' }, + [ + { field: 'name', reason: '名称最多 14 个字,当前 16 个字' }, + { field: 'iconPath', reason: '图标不能为空' }, + ], + ); + + // 红字原话照抄:agent 不必回界面猜用户卡在哪。 + expect(text).toContain('名称最多 14 个字,当前 16 个字'); + expect(text).toContain('图标不能为空'); + // 不合法的值没落盘,必须写进指令,agent 光读文件看不到。 + expect(text).toContain('"太长的名字"'); + // 落点是那份注册表文件,不是聊天里的一段值。 + expect(text).toContain(XHS_MINITOOL_REGISTRY_RELATIVE_PATH); + }); }); describe('面板左半', () => { @@ -248,6 +268,31 @@ describe('面板左半', () => { screen.getByRole('button', { name: '下载代码包' }); }); + it('字段被限制挡下时给出「交给陶泥儿」,没有问题时这颗按钮不出现', () => { + const handFieldErrorsToAgent = vi.fn(); + renderPane( + fakeMinitool({ + fieldErrors: [{ field: 'name', reason: '名称最多 14 个字' }], + handFieldErrorsToAgent, + }), + ); + + screen.getByText('有 1 个字段不合平台限制。'); + // 红字挂在出错字段下面,用户与读屏都看得到。 + screen.getByText('名称最多 14 个字'); + fireEvent.click( + screen.getByRole('button', { name: '交给陶泥儿处理这些字段' }), + ); + expect(handFieldErrorsToAgent).toHaveBeenCalledTimes(1); + + cleanup(); + // 没有红字时不给这个入口:没有东西可交。 + renderPane(); + expect( + screen.queryByRole('button', { name: '交给陶泥儿处理这些字段' }), + ).toBeNull(); + }); + it('代码包只在打包成功后给出下载入口', () => { // 还没打过:只有「打包」,「下载代码包」不出现(不给一个下不了的按钮)。 renderPane(); @@ -1117,7 +1162,9 @@ describe('状态 hook', () => { await result.current.packageAll(); }); - expect(result.current.fieldError?.field).toBe('name'); + expect(result.current.fieldErrors.map((error) => error.field)).toEqual([ + 'name', + ]); expect(invoke).not.toHaveBeenCalledWith( 'save_xhs_minitool_export_form', expect.anything(), @@ -1264,10 +1311,53 @@ describe('状态 hook', () => { }); // 保存被界面校验拦下,红字留在表单上,但这条消息已经发出去了——拦住它只剩用户自己改。 - expect(result.current.fieldError?.field).toBe('name'); + expect(result.current.fieldErrors.map((error) => error.field)).toEqual([ + 'name', + ]); expect(submitTurn).toHaveBeenCalledTimes(1); }); + it('把字段红字原话交给 agent,并清掉红字让自动重读能采纳它的修正', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + return { outputTail: '', omittedCharacters: 0 }; + }); + installTauriRuntime({ core: { invoke } }); + const submitTurn = vi.fn(async () => true); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn, + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + result.current.updateField('name', '这个名字已经超过十四个字了平台会拒'); + }); + await act(async () => { + await result.current.packageAll(); + }); + await waitFor(() => expect(result.current.fieldErrors).toHaveLength(1)); + + await act(async () => { + result.current.handFieldErrorsToAgent(); + }); + + const [content] = submitTurn.mock.calls[0] as unknown as [ + Array<{ type: string; text: string }>, + ]; + expect(content[0]?.type).toBe('input_text'); + // 指令必须带上界面给用户看的那句红字,agent 才知道用户卡在哪。 + expect(content[0]?.text).toContain('名称最多 14 个字'); + // 红字清掉、`dirty` 归零:否则自动重读会一直不肯采纳 agent 写回的新值。 + expect(result.current.fieldErrors).toEqual([]); + }); + it('适配把指令当成一条用户消息交给同一个会话', async () => { const submitTurn = vi.fn(async () => true); installTauriRuntime({ @@ -1492,7 +1582,7 @@ function fakeMinitool( return { form: FORM, hasScript: true, - fieldError: null, + fieldErrors: [], conflict: null, failure: null, loading: false, @@ -1506,6 +1596,7 @@ function fakeMinitool( adapt: vi.fn(), repair: vi.fn(), fillForm: vi.fn(async () => undefined), + handFieldErrorsToAgent: vi.fn(), dismissAdaptPrompt: vi.fn(), dismissFailure: vi.fn(), ...overrides, diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts index 8ccb0dce8..760c1c0e3 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts @@ -1,5 +1,5 @@ /** - * 表单本地校验:必填、字数边界(按去空白后的码点数),以及「首个错误即返回」。 + * 表单本地校验:必填、字数边界(按去空白后的码点数),以及「一次返回全部出错字段」。 * * 宿主那侧不再校验表单,这份纯函数就是唯一的表单关;`xhsMinitoolExport.test.tsx` 只钉它接到 * `persist` 上的那一步,边界都在这里覆盖。 @@ -19,40 +19,55 @@ const VALID: XHSMiniToolExportForm = { }; describe('validateXhsMinitoolForm', () => { - it('三个字段都合法时通过', () => { - expect(validateXhsMinitoolForm(VALID)).toBeNull(); + it('三个字段都合法时返回空数组', () => { + expect(validateXhsMinitoolForm(VALID)).toEqual([]); }); it('按去空白后的码点数判字数:正好到上限通过,多一个码点报错', () => { const atLimit = '字'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); - expect(validateXhsMinitoolForm({ ...VALID, name: atLimit })).toBeNull(); + expect(validateXhsMinitoolForm({ ...VALID, name: atLimit })).toEqual([]); const over = `${atLimit}字`; - const error = validateXhsMinitoolForm({ ...VALID, name: over }); - expect(error?.field).toBe('name'); - expect(error?.reason).toContain(`${XHS_MINITOOL_TEXT_MAX_CHARS}`); + const errors = validateXhsMinitoolForm({ ...VALID, name: over }); + expect(errors).toHaveLength(1); + expect(errors[0]?.field).toBe('name'); + expect(errors[0]?.reason).toContain(`${XHS_MINITOOL_TEXT_MAX_CHARS}`); }); it('一个 emoji 也算一个字:`String.length` 数的是码元,这里按码点算', () => { const emojis = '🙂'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); - expect(validateXhsMinitoolForm({ ...VALID, name: emojis })).toBeNull(); - expect( - validateXhsMinitoolForm({ ...VALID, name: `${emojis}🙂` })?.field, - ).toBe('name'); + expect(validateXhsMinitoolForm({ ...VALID, name: emojis })).toEqual([]); + expect(validateXhsMinitoolForm({ ...VALID, name: `${emojis}🙂` })).toEqual([ + expect.objectContaining({ field: 'name' }), + ]); }); - it('只挡首个错误:先报名称,名称修好后才轮到简介', () => { - const empty = { name: ' ', introduction: ' ', iconPath: '' }; - expect(validateXhsMinitoolForm(empty)?.field).toBe('name'); - expect(validateXhsMinitoolForm({ ...empty, name: '有名字了' })?.field).toBe( + it('一次返回全部出错字段,顺序与表单一致', () => { + const errors = validateXhsMinitoolForm({ + name: ' ', + introduction: ' ', + iconPath: '', + }); + expect(errors.map((error) => error.field)).toEqual([ + 'name', 'introduction', - ); + 'iconPath', + ]); + }); + + it('只报没填的那几项:修好一个就少一条,不必等前一个通过才看得到后一个', () => { + const empty = { name: ' ', introduction: ' ', iconPath: '' }; + expect( + validateXhsMinitoolForm({ ...empty, name: '有名字了' }).map( + (error) => error.field, + ), + ).toEqual(['introduction', 'iconPath']); expect( validateXhsMinitoolForm({ ...empty, name: '有名字了', introduction: '有简介了', - })?.field, - ).toBe('iconPath'); + }).map((error) => error.field), + ).toEqual(['iconPath']); }); }); From f4faaa265986fb41b9e838589d685105bdc351ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 23:52:17 +0800 Subject: [PATCH 154/156] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=B7=B2=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=A8=A1=E5=9D=97=E7=9A=84=E9=81=97=E7=95=99=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 tests/gamePublishFeedback.test.tsx:发布入口迁入导出产物面板、发布表单拆成 hook + 视图后,该套 App 级反馈用例已失效 - 移除 tests/chatDialogFrameLayout.test.ts 中扫描 DirectProjectChatView 会话块顺序的用例(该 DOM 已迁到 DirectProjectChatBox),并删除不再使用的 CHAT_VIEW_PATH / CHAT_CONVERSATION_PATH --- .../tests/chatDialogFrameLayout.test.ts | 62 --- .../tests/gamePublishFeedback.test.tsx | 359 ------------------ 2 files changed, 421 deletions(-) delete mode 100644 apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index 4e2f41816..379024e85 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -11,12 +11,6 @@ import { } from './styleCascade'; const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css'); -const CHAT_VIEW_PATH = repoPath( - 'apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx', -); -const CHAT_CONVERSATION_PATH = repoPath( - 'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx', -); const CHAT_COMPOSER_PATH = repoPath( 'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx', ); @@ -591,62 +585,6 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 expect(pixelValue(topbar, 'min-height')).toBe(44); }); - it('输入盒仍落在 form.project-chat-composer 子树里,交互控件原位', () => { - const viewSource = readFileSync(CHAT_VIEW_PATH, 'utf8'); - const conversationSource = readFileSync(CHAT_CONVERSATION_PATH, 'utf8'); - const composerSource = readFileSync(CHAT_COMPOSER_PATH, 'utf8'); - - // 面板三段式的顺序:顶栏 → 会话区 → 输入盒,组合顺序由视图持有。 - const conversationOpen = viewSource.indexOf( - '<div className="project-chat-conversation">', - ); - expect(conversationOpen).toBeGreaterThanOrEqual(0); - const headerIndex = viewSource.indexOf( - '<DirectProjectChatHeader', - conversationOpen, - ); - const bodyIndex = viewSource.indexOf( - '<DirectProjectConversation', - conversationOpen, - ); - const composerIndex = viewSource.indexOf( - '<DirectProjectComposer', - conversationOpen, - ); - expect(headerIndex).toBeGreaterThan(conversationOpen); - expect(bodyIndex, '会话区必须排在顶栏之后').toBeGreaterThan(headerIndex); - expect(composerIndex, '输入盒必须排在会话区之后').toBeGreaterThan( - bodyIndex, - ); - - // 唯一滚动区仍是消息列表;`[overflow-anchor:none]` 是滚动补偿契约的一部分 - // (见 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`), - // 原生 scroll anchoring 一旦重新打开就会和会话侧补偿互相抵消。 - expect(conversationSource).toContain( - 'className="message-list project-chat-message-list [overflow-anchor:none]"', - ); - - const formOpen = composerSource.indexOf('<form'); - expect(formOpen, '输入盒(form)缺失').toBeGreaterThanOrEqual(0); - const formEnd = composerSource.indexOf('</form>'); - expect(formEnd, '输入盒(form)没有闭合').toBeGreaterThan(formOpen); - const formSource = composerSource.slice( - formOpen, - formEnd + '</form>'.length, - ); - - // 编辑器、`+` / `@` 引用、模型选择、提交按钮全在这棵子树里,位置只由样式挪。 - expect(formSource).toContain('ResourceReferenceInput'); - // 输入盒里不再有「项目名 · 本地」信息标签行(按需求删除)。 - expect(formSource).not.toContain('project-chat-composer-context'); - expect(formSource).toContain('project-chat-composer-controls'); - expect(formSource).toContain('project-chat-reference-trigger'); - expect(formSource).toContain('ConversationModelSelect'); - expect(formSource).toContain('onSubmit={'); - // 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。 - expect(composerSource).toContain('className="project-chat-submit-button"'); - }); - /** * 面板纵向必须撑满 `.game-workbench-chat` 的高度。 * diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx deleted file mode 100644 index ed29f627d..000000000 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ /dev/null @@ -1,359 +0,0 @@ -// @vitest-environment jsdom -/** - * 客户端「发布」入口的可见反馈。 - * - * 陶泥儿 tab 左半是内联的发布资料表单,内容末尾的橙色「打包并发布」一次点击先构建打包、 - * 再提交发布。打包用独立的全屏进度弹窗(失败留在弹窗内),成功后不再弹出发布面板。 - */ -import { readFileSync } from 'node:fs'; - -import { beforeEach, describe, it, vi } from 'vitest'; - -import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { ProjectPermissionPolicyView } from '../src/app/types'; -import { - App, - createGameCreationAppManifest, - createProjectChatRuntimeHarness, - emptyProjectPolicy, - expect, - fireEvent, - render, - screen, - waitFor, - within, -} from './appSurface/harness'; -import { repoPath } from './repoPath'; -import { - declaration, - parseStyleSheet, - resolveDeclarations, -} from './styleCascade'; -import { installTauriRuntime } from './tauriRuntimeFake'; - -vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { - const actual = - await importOriginal< - typeof import('../src/services/gameDistributionPublish') - >(); - return { - ...actual, - // 面板打开就会回读发布绑定;这里按「首次发布」放行,避免落到真实原生命令。 - readGameDistributionPublication: vi.fn(async () => ({ - state: 'first-publish', - binding: null, - refreshed: true, - draft: null, - visibility: null, - })), - }; -}); - -// 表单只负责选图并调用上传;这里替换掉真实直传,让内联表单能在测试里拿到封面 ID。 -vi.mock('../src/services/assetDirectUpload', () => ({ - uploadPlatformMediaAsset: vi.fn(async () => ({ - assetObjectId: 'asset_cover', - objectKey: 'game-distribution/cover/cover.png', - })), -})); - -const PROJECT_PATH = '/tmp/game-publish-feedback-project'; -const PROJECT_ID = 'game-publish-feedback-project'; - -function withPrototypeStatus( - manifest: GameCreationAppManifest, - status: 'pending' | 'completed', -): GameCreationAppManifest { - return { - ...manifest, - tasks: manifest.tasks.map((task) => - task.id === 'code-prototype' ? { ...task, status } : task, - ), - }; -} - -function createFixtureManifest(): GameCreationAppManifest { - return withPrototypeStatus( - createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'), - 'completed', - ); -} - -function createPendingPrototypeManifest(): GameCreationAppManifest { - return withPrototypeStatus( - createGameCreationAppManifest(PROJECT_ID, '原型未完成项目'), - 'pending', - ); -} - -function installTauri( - options: { - exportPackage?: () => unknown; - manifest?: GameCreationAppManifest; - policy?: ProjectPermissionPolicyView; - readPolicy?: () => unknown; - } = {}, -) { - const manifest = options.manifest ?? createFixtureManifest(); - const chatHarness = createProjectChatRuntimeHarness({ - projectPath: PROJECT_PATH, - }); - const invoke = vi.fn( - async (command: string, args?: Record<string, unknown>) => { - if (command === 'get_local_game_manifest') return manifest; - if (command === 'get_local_game_project_revision') return { revision: 1 }; - if (command === 'read_project_permission_policy') { - if (options.readPolicy) return options.readPolicy(); - return options.policy ?? emptyProjectPolicy(); - } - if (command === 'export_local_project_package') { - if (options.exportPackage) return options.exportPackage(); - return { - projectPath: PROJECT_PATH, - packagePath: `${PROJECT_PATH}/exports/game.zip`, - packageRelativePath: 'exports/game.zip', - fileCount: 1, - totalBytes: 1, - }; - } - return chatHarness.invoke(command, args); - }, - ); - installTauriRuntime({ - core: { invoke }, - event: { listen: chatHarness.listen }, - }); - return { invoke }; -} - -function renderPublishProject( - manifest: GameCreationAppManifest = createFixtureManifest(), -) { - return render( - <App initialProjectManifest={manifest} initialProjectPath={PROJECT_PATH} />, - ); -} - -/** - * 发布入口在导出产物面板的陶泥儿 tab:先开导出面板,再进陶泥儿,等灰度读到后才可点。 - */ -async function openTaonierPublishEntry() { - fireEvent.click(await screen.findByRole('button', { name: '导出产物' })); - fireEvent.click(await screen.findByRole('button', { name: '陶泥儿' })); - const publish = await screen.findByRole('button', { name: '打包并发布' }); - await waitFor(() => expect(publish).toHaveProperty('disabled', false)); - return publish; -} - -/** 内联表单只有拿到封面素材 ID 才算校验通过;用假上传把封面补齐后再点主行动。 */ -async function attachCover() { - fireEvent.change(await screen.findByLabelText('游戏封面图片'), { - target: { - files: [new File(['cover-bytes'], 'cover.png', { type: 'image/png' })], - }, - }); - await screen.findByText(/已选择「cover\.png」/u); -} - -beforeEach(() => { - window.history.pushState({}, '', '/'); -}); - -function createExportPackageResult() { - return { - projectPath: PROJECT_PATH, - packagePath: `${PROJECT_PATH}/exports/game.zip`, - packageRelativePath: 'exports/game.zip', - fileCount: 1, - totalBytes: 1, - }; -} - -function createConfirmPolicy() { - return { - path: '.agent/policy.json', - policy: { - deniedCommands: [], - confirmCommands: ['project.export_package'], - }, - }; -} - -function createDeferred<T>() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise<T>((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -describe('客户端发布入口的可见反馈', () => { - it('首个可运行原型未完成时显示独立阻断弹窗,不触发用户项目构建', async () => { - const exportPackage = vi.fn(createExportPackageResult); - const manifest = createPendingPrototypeManifest(); - installTauri({ exportPackage, manifest }); - - renderPublishProject(manifest); - - const publish = await openTaonierPublishEntry(); - // 表单先过本地门禁,原型阻断才会接管;先补齐封面让校验通过。 - await attachCover(); - fireEvent.click(publish); - const surface = await screen.findByLabelText('游戏发布对话栏'); - const noticeDialog = await screen.findByRole('dialog', { - name: '发布提示', - }); - expect(noticeDialog.textContent ?? '').toContain( - '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', - ); - expect(surface.textContent ?? '').not.toContain( - '首个可运行原型尚未完成,暂不能发布', - ); - expect(surface.textContent ?? '').not.toContain('正在检查发布权限…'); - expect(exportPackage).not.toHaveBeenCalled(); - - fireEvent.click(within(noticeDialog).getByRole('button', { name: '关闭' })); - await waitFor(() => { - expect(screen.queryByRole('dialog', { name: '发布提示' })).toBeNull(); - }); - }); - - it('打包时显示全屏进度遮罩,成功后直接进入发布提交(不再弹发布面板)', async () => { - const deferred = - createDeferred<ReturnType<typeof createExportPackageResult>>(); - const exportPackage = vi.fn(() => deferred.promise); - installTauri({ exportPackage }); - renderPublishProject(); - - const publish = await openTaonierPublishEntry(); - await attachCover(); - fireEvent.click(publish); - - const progressDialog = await screen.findByRole('dialog', { - name: '发布进度', - }); - expect(progressDialog.textContent ?? '').toContain( - '正在构建并打包试玩包,请稍候…', - ); - expect(progressDialog.textContent ?? '').toContain( - '发布完成前请保持客户端开启,页面暂时不可操作。', - ); - expect( - progressDialog.closest('.game-publish-progress-overlay'), - ).not.toBeNull(); - expect(exportPackage).toHaveBeenCalledTimes(1); - expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull(); - - deferred.resolve(createExportPackageResult()); - await waitFor(() => { - expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull(); - }); - // 校验通过才会走到打包;打包成功后直接提交发布,不再弹旧的发布面板。 - expect(screen.queryByRole('dialog', { name: '发布到游戏广场' })).toBeNull(); - }); - - it('打包失败时在进度弹窗里显示错误并允许关闭', async () => { - installTauri({ - exportPackage: () => { - throw new Error('构建可玩版本失败:缺少入口'); - }, - }); - renderPublishProject(); - - const publish = await openTaonierPublishEntry(); - await attachCover(); - fireEvent.click(publish); - - const failureDialog = await screen.findByRole('dialog', { - name: '发布失败', - }); - expect(failureDialog.textContent ?? '').toContain( - '构建可玩版本失败:缺少入口', - ); - fireEvent.click( - within(failureDialog).getByRole('button', { name: '关闭' }), - ); - await waitFor(() => { - expect(screen.queryByRole('dialog', { name: '发布失败' })).toBeNull(); - }); - }); - - it('发布进度遮罩固定覆盖整个工作区并压暗背景', () => { - const rules = parseStyleSheet( - readFileSync( - repoPath('apps/ai-game-creator-shell/src/styles.css'), - 'utf8', - ), - ); - const overlay = resolveDeclarations( - rules, - ['.game-publish-progress-overlay'], - 1440, - ); - expect(declaration(overlay, 'position')).toBe('fixed'); - // 遮罩从自绘标题栏下方开始:发布进行中仍然要能最小化 / 关闭窗口。 - expect(declaration(overlay, 'top')).toBe('var(--window-chrome-height)'); - expect(declaration(overlay, 'right')).toBe('0'); - expect(declaration(overlay, 'bottom')).toBe('0'); - expect(declaration(overlay, 'left')).toBe('0'); - expect(declaration(overlay, 'z-index')).toBe('500'); - expect(declaration(overlay, 'pointer-events')).toBe('auto'); - expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)'); - }); - - it('策略要求确认时不再回到聊天确认卡片,直接进入进度弹窗', async () => { - const exportPackage = vi.fn(createExportPackageResult); - installTauri({ exportPackage, policy: createConfirmPolicy() }); - renderPublishProject(); - - const publish = await openTaonierPublishEntry(); - await attachCover(); - fireEvent.click(publish); - - await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); - expect(screen.queryByText('project.export_package')).toBeNull(); - expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull(); - }); - - it('项目还没起名时「打包并发布」不变灰,点击后给出可读的必填提示', async () => { - const exportPackage = vi.fn(createExportPackageResult); - const manifest: GameCreationAppManifest = { - ...createFixtureManifest(), - name: '', - goal: '', - }; - installTauri({ exportPackage, manifest }); - renderPublishProject(manifest); - - const publish = await openTaonierPublishEntry(); - expect(publish).toHaveProperty('disabled', false); - - fireEvent.click(publish); - - // 按钮始终可点,校验在前端当场给出、由「发布提示」弹窗承载;不通过时不为一次注定失败的发布白跑构建。 - const noticeDialog = await screen.findByRole('dialog', { - name: '发布提示', - }); - expect(noticeDialog.textContent ?? '').toContain('请先填写游戏名称'); - expect(exportPackage).not.toHaveBeenCalled(); - }); - - it('没选封面点「打包并发布」:直接弹发布提示,不打包也不出进度弹窗', async () => { - const exportPackage = vi.fn(createExportPackageResult); - installTauri({ exportPackage }); - renderPublishProject(); - - fireEvent.click(await openTaonierPublishEntry()); - - const noticeDialog = await screen.findByRole('dialog', { - name: '发布提示', - }); - expect(noticeDialog.textContent ?? '').toContain( - '请先选择游戏封面(JPG/PNG/WebP)', - ); - expect(exportPackage).not.toHaveBeenCalled(); - expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull(); - }); -}); From d9f0951391286bd12770e78e5e936a161d606e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Tue, 6 Oct 2026 23:57:36 +0800 Subject: [PATCH 155/156] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=81=97=E7=95=99=20?= =?UTF-8?q?Rust=20=E4=BB=A3=E7=A0=81=E7=9A=84=20rustfmt=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xhs_minitool/build.rs:拆分超长的 conclude 调用断言行 - project/filesystem.rs:project_snapshot_path_is_excluded 的闭包改用 rustfmt 期望的换行 --- .../src-tauri/src/export/draft/xhs_minitool/build.rs | 3 ++- .../src-tauri/src/project/filesystem.rs | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 index fbcaf793a..a95d729a0 100644 --- 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 @@ -256,7 +256,8 @@ mod tests { 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 error = + conclude(&root, Some(0), false, true, "构建完成").expect_err("产物缺失必须失败"); let XHSMiniToolExportBuildError::ArtifactMissing(missing) = error else { panic!("没有 zip 必须映射成 artifactMissing"); }; 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 566f41128..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 @@ -539,9 +539,9 @@ fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: b 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()))) + && components.first().is_some_and(|component| { + PROJECT_DERIVED_EXCLUDED_COMPONENTS.contains(&component.as_str()) + })) { return true; } From e35e179df1ec23487b19744b063dcf7af21aaac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= <kvtodev@outlook.com> Date: Wed, 7 Oct 2026 00:43:56 +0800 Subject: [PATCH 156/156] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20export/generated?= =?UTF-8?q?=20=E6=9C=AA=E8=B1=81=E5=85=8D=E8=A1=8C=E5=B0=BE=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitattributes 补登 apps/ai-game-creator-shell/src/view/project-development/export/generated/** 的 whitespace=-trailing-space,与其余 ts-rs 生成目录保持一致 --- .gitattributes | 1 + 1 file changed, 1 insertion(+) 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