Compare commits

..

7 Commits

Author SHA1 Message Date
kdletters d5743cd4b8 资源画布支持引擎资源预览与模型交互预览 (#413)
Project CI / AI game creator shell Rust shard 1/4 (push) Waiting to run
Project CI / AI game creator shell Rust shard 2/4 (push) Waiting to run
Project CI / AI game creator shell Rust shard 3/4 (push) Waiting to run
Project CI / AI game creator shell Rust shard 4/4 (push) Waiting to run
Project CI / AI game creator shell Rust smoke (push) Waiting to run
Project CI / AI game creator shell Rust crates (push) Waiting to run
Project CI / Backend tests (push) Waiting to run
Project CI / Native shell tests (push) Waiting to run
Project CI / Frontend tests (push) Waiting to run
Project CI / AI game creator shell web tests (push) Waiting to run
Project CI / Repository checks (push) Waiting to run
## 目的

资源画布支持引擎(Cocos Creator)资源的**只读预览**:能被发现、登记、进入画布,并按类型出预览。

## 主要改动

- 发现层识别 Cocos 资源并区分 `model` / `binary` 两个发现类别;`.meta` 等导入侧车文件仍只可发现
- 登记层按扩展名写入**既有** canonical kind(模型/场景/预制体 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`),不新增 manifest 契约字段
- 引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进入发现结果(仅当目录确实是 Cocos 工程),同名目录在非引擎工程里照常列出
- 资源画布新增三个卡面分支:模型缩略图(整页共用一个 WebGL 上下文)、结构摘要(Cocos 序列化资源)、类型卡(客户端解不了的容器)
- 新增模型放大预览浮层:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角
- 模型预览上限 32 MiB;缩略图按布局尺寸 2 倍超采样,缓存键改用稳定身份
- 引擎图像容器(tga/tif/tiff/hdr)在原生侧转码成 PNG 后复用既有图片预览链路
- 修复资源卡状态边框吃掉内容盒导致的卡面与角标位移
- 同步 Agent 提示词与 AGC 技能文档,并补里程碑与踩坑文档

## 验证

- `cargo check`、`cargo fmt --check`
- Rust 定向:`cargo test … cocos` 9 条、`resource_inspect::tests` 7 条、`agent_asset_import_tests` 9 条、生成目录过滤用例
- 前端:`resource` + `project` 套件 52 文件 / 546 用例;`appSurface` 450 通过 / 20 跳过;app `tsc --noEmit`
- `npm run check:encoding`、`git diff --check`、`npm run check:doc-index`
- 真机:在真实客户端内打开本地 Cocos 夹具工程,逐栏核对模型缩略图 / 结构摘要 / TGA 转码 / 类型卡,并量过卡片在指针移开、悬停、选中三态下几何完全一致

## 未验证

- 模型缩略图与放大预览的真机视觉只覆盖自带夹具工程;多文件 glTF(外部 .bin/贴图)与超大模型仍是类型卡

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/413
2026-09-17 20:28:39 +08:00
kdletters 724d94c95c 更新摘要降级为不阻断发布
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m22s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m20s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 6m42s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 6m1s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m4s
Project CI / Native shell tests (push) Failing after 1m29s
Project CI / AI game creator shell Rust crates (push) Successful in 2m15s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 摘要锚点读取失败时降级为无锚点并告警,不再让整个发布失败
- 无锚点时列出最近 8 条客户端相关提交,并注明可能与上一版重复
- 新增锚点读取失败降级与最近提交兜底的定向用例
- 技术方案补充摘要不阻断发布的约束
2026-09-17 20:00:50 +08:00
kdletters e146859ea2 更新摘要锚点支持 CI 兜底
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 摘要锚点优先取渠道清单 commit,缺失时回退 CI 传入的上一次成功构建 COMMIT_HASH
- Jenkins 构建阶段解析上一次成功构建的 COMMIT_HASH 并注入 AGC_UPDATE_PREVIOUS_COMMIT,读取失败保持为空
- 新增锚点优先级与非法值忽略的定向用例
- 技术方案补充锚点兜底说明
2026-09-17 19:42:10 +08:00
kdletters 5ccc46ac1a 调度管线按路径过滤 Full Build 并回填升级证据
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 调度管线同时判定 AGC 与 Full Build 两条下游的发布范围,并分别决定是否触发
- Full Build 采用与线上站点/后端无关的路径黑名单,改动落在黑名单外照常部署,避免漏发
- 两条同时跳过时只推进 revision 状态、不触发任何发布,构建描述写明实际触发的下游
- 新增 Full Build 跳过模式守卫用例,并补齐开发运维与技术方案说明
- 主规范回填真实更新闭环证据:0.1.47 客户端完成提示、下载、安装与重启
2026-09-17 19:31:19 +08:00
kdletters ba089980b1 AGC 发布自动生成更新摘要
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
- 发布脚本读取渠道清单的 commit 字段,把上一次发布到本次之间客户端相关路径的提交标题写进清单 notes
- 更新摘要同步写入旧协议清单 releaseNotes,并落盘 release-notes.txt 供 Jenkins 归档
- AGC_UPDATE_RELEASE_NOTES 非空时以手动文案为准,缺少上一次 commit 时不写摘要
- 提交取数固定在仓库根执行,避免 pathspec 相对应用目录解析导致漏取
- 新增摘要格式、真实临时仓库取数与调度管线路径表一致性用例
- 同步开发运维文档与技术方案的构建发布条款
2026-09-17 19:26:11 +08:00
kdletters 6017d46088 AGC 定时发布增加变更路径过滤
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m29s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m37s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 7m46s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m17s
Project CI / AI game creator shell Rust crates (push) Successful in 2m43s
Project CI / Native shell tests (push) Failing after 1m10s
Project CI / Frontend tests (push) Failing after 3m37s
Project CI / AI game creator shell web tests (push) Failing after 3m25s
Project CI / Repository checks (push) Successful in 4m19s
Project CI / Backend tests (push) Successful in 8m48s
- 调度管线新增 AGC 发布范围判定:比较上一轮 revision 与本次 revision 的变更路径
- 只有客户端、共享包、server-rs/crates、AGC 插件、桌面壳图标或根依赖清单变化时才触发 AGC Windows Build
- 纯文档与流水线提交只触发 Full Build,不再推高客户端版本号
- 判定失败或浅取窗口取不到该 revision 时按需要发布处理,FORCE_TRIGGER 仍强制两条都触发
- 同步开发运维文档、技术方案与调度 job 配置描述
2026-09-17 19:00:45 +08:00
kdletters 55af6014ba 修复渠道版本链回退并记录高水位规则
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
- 发布脚本版本来源改为渠道清单与旧协议迁移指针取较大值,避免渠道启用初期把版本链改小
- 旧迁移指针只服务 dev-win,其它渠道不参与比较,旧指针 404 后自动只剩渠道清单
- 补充三条定向用例:旧指针高水位、两者取高、非 Windows 渠道不读旧指针
- 主规范记录版本高水位规则与 2026-09-17 首次渠道发布 0.1.57 退回 0.1.48、后以 0.1.60 纠偏的事实
2026-09-17 18:47:53 +08:00
100 changed files with 7825 additions and 5948 deletions
-16
View File
@@ -172,20 +172,6 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships ## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发** - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -220,5 +206,3 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
+2
View File
@@ -61,6 +61,7 @@
"react-window": "^1.8.11", "react-window": "^1.8.11",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"three": "^0.184.0",
"vite": "^6.2.0", "vite": "^6.2.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
@@ -72,6 +73,7 @@
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.1",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "~5.8.2", "typescript": "~5.8.2",
@@ -1,4 +1,4 @@
import { spawnSync } from 'node:child_process'; import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
@@ -11,6 +11,8 @@ import {
} from './cargo-features.mjs'; } from './cargo-features.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url)); const appRoot = fileURLToPath(new URL('..', import.meta.url));
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
const repoRoot = path.resolve(appRoot, '..', '..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
const releaseTarget = const releaseTarget =
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
@@ -39,6 +41,20 @@ const releaseChannels = {
'dev-mac': 'darwin', 'dev-mac': 'darwin',
}; };
/**
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
* 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
*/
export const agcReleasePathPatterns = [
'apps/ai-game-creator-shell/',
'packages/',
'server-rs/crates/',
'plugins/agc-cocos-editor/',
'apps/desktop-shell/src-tauri/icons/',
'package.json',
'package-lock.json',
];
function ossBaseUrl() { function ossBaseUrl() {
return ( return (
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
@@ -143,27 +159,96 @@ export function resolveManifestPlatformKeys(target = releaseTarget) {
throw new Error(`不支持的发布目标:${target}`); throw new Error(`不支持的发布目标:${target}`);
} }
async function readRemoteVersion(channel = resolveReleaseChannel()) { /** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */
const manifestUrl = updateManifestUrl(channel); function legacyBridgeManifestUrl() {
return `${ossBaseUrl()}/latest.json`;
}
async function fetchManifest(manifestUrl, label) {
let response; let response;
try { try {
response = await fetch(manifestUrl, { response = await fetch(manifestUrl, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
} catch (error) { } catch (error) {
throw new Error(`读取 OSS 渠道清单失败:${error.message}`); throw new Error(`读取 ${label} 失败:${error.message}`);
} }
if (response.status === 404) return null; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`); throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
} }
let manifest;
try { try {
manifest = await response.json(); return await response.json();
} catch (error) { } catch (error) {
throw new Error(`OSS 渠道清单不是有效 JSON${error.message}`); throw new Error(`${label} 不是有效 JSON${error.message}`);
} }
return parseVersion(manifest?.version, 'OSS渠道清单 version'); }
async function readManifestVersion(manifestUrl, label) {
const manifest = await fetchManifest(manifestUrl, label);
return manifest == null
? null
: parseVersion(manifest?.version, `${label} version`);
}
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
}
/**
* 摘要锚点:上次发布对应的提交。
*
* 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
* 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
* —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
*/
export async function resolvePreviousReleaseCommit(
channel = resolveReleaseChannel(),
{ override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
) {
const explicit = override?.trim();
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
return explicit;
}
try {
const manifest = await readRemoteChannelManifest(channel);
const commit =
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
} catch (error) {
// 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。
console.warn(
`[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`,
);
return null;
}
}
/**
* 版本高水位:渠道清单与旧协议迁移指针取较大值。
*
* 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 ——
* 2026-09-17 首次渠道发布就是这样把 0.1.57 退回 0.1.48 的。旧指针只服务
* Windows 渠道,其它渠道不参与比较;旧指针 404(迁移窗口结束)后自动只剩渠道清单。
*/
export async function resolveRemoteHighWaterVersion(
channel = resolveReleaseChannel(),
) {
const channelVersion = await readManifestVersion(
updateManifestUrl(channel),
'OSS 渠道清单',
);
if (channel !== 'dev-win') return channelVersion;
const legacyVersion = await readManifestVersion(
legacyBridgeManifestUrl(),
'OSS 迁移指针',
);
if (channelVersion == null) return legacyVersion;
if (legacyVersion == null) return channelVersion;
return compareVersions(channelVersion, legacyVersion) >= 0
? channelVersion
: legacyVersion;
} }
function replaceVersionLine(source, version, pattern, label) { function replaceVersionLine(source, version, pattern, label) {
@@ -174,7 +259,7 @@ function replaceVersionLine(source, version, pattern, label) {
export async function prepareReleaseVersion() { export async function prepareReleaseVersion() {
const channel = resolveReleaseChannel(); const channel = resolveReleaseChannel();
const localVersion = parseVersion(readPackageJson().version, '本地版本'); const localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await readRemoteVersion(channel); const remoteVersion = await resolveRemoteHighWaterVersion(channel);
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
const nextVersion = requestedVersion const nextVersion = requestedVersion
? parseVersion(requestedVersion, '指定版本') ? parseVersion(requestedVersion, '指定版本')
@@ -368,6 +453,8 @@ export function createUpdateManifest(
channel = resolveReleaseChannel(), channel = resolveReleaseChannel(),
target = releaseTarget, target = releaseTarget,
publishedAt = new Date().toISOString(), publishedAt = new Date().toISOString(),
notes = readReleaseNotes(),
commit = readHeadCommit(),
} = {}, } = {},
) { ) {
const signature = readUpdaterSignature(artifactPath); const signature = readUpdaterSignature(artifactPath);
@@ -378,24 +465,136 @@ export function createUpdateManifest(
for (const key of resolveManifestPlatformKeys(target)) { for (const key of resolveManifestPlatformKeys(target)) {
platforms[key] = { signature, url }; platforms[key] = { signature, url };
} }
const notes = readReleaseNotes();
return { return {
version, version,
...(notes ? { notes } : {}), ...(notes ? { notes } : {}),
pub_date: publishedAt, pub_date: publishedAt,
platforms, platforms,
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
...(commit ? { commit } : {}),
}; };
} }
function readHeadCommit() {
try {
return execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim();
} catch {
return '';
}
}
/**
* 上一次发布到本次之间的客户端相关提交。
*
* 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。
*/
export function collectReleaseCommits(
previousCommit,
headCommit = 'HEAD',
{ cwd = repoRoot, paths = agcReleasePathPatterns } = {},
) {
if (!previousCommit) return null;
try {
for (const revision of [previousCommit, headCommit]) {
execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], {
cwd,
stdio: 'pipe',
});
}
} catch {
return null;
}
let output;
try {
output = execFileSync(
'git',
[
'log',
'--no-merges',
'--format=%h%x09%s',
`${previousCommit}..${headCommit}`,
'--',
...paths,
],
{ cwd, encoding: 'utf8' },
);
} catch {
return null;
}
return output
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sha = '', ...subject] = line.split('\t');
return { sha, subject: subject.join('\t') };
});
}
/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
export function formatReleaseNotes(
commits,
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
) {
if (!commits || commits.length === 0) return '';
const lines = commits.slice(0, limit).map(({ sha, subject }) => {
const trimmed =
subject.length > subjectLength
? `${subject.slice(0, subjectLength - 1)}`
: subject;
return `- ${trimmed}${sha}`;
});
if (commits.length > limit) {
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
}
const text = lines.join('\n');
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}` : text;
}
/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */
export function collectRecentReleaseCommits({
cwd = repoRoot,
paths = agcReleasePathPatterns,
limit = 8,
} = {}) {
let output;
try {
output = execFileSync(
'git',
['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
{ cwd, encoding: 'utf8' },
);
} catch {
return null;
}
const commits = output
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sha = '', ...subject] = line.split('\t');
return { sha, subject: subject.join('\t') };
});
return commits.length > 0 ? commits : null;
}
export function formatRecentReleaseNotes(commits) {
const notes = formatReleaseNotes(commits, { limit: 8 });
if (!notes) return '';
return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`;
}
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
export function createLegacyUpdateManifest( export function createLegacyUpdateManifest(
artifactPath, artifactPath,
{ channel = resolveReleaseChannel() } = {}, { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
) { ) {
const bytes = fs.readFileSync(artifactPath); const bytes = fs.readFileSync(artifactPath);
const version = readPackageJson().version; const version = readPackageJson().version;
const fileName = path.basename(artifactPath); const fileName = path.basename(artifactPath);
const notes = readReleaseNotes();
return { return {
version, version,
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
@@ -405,18 +604,36 @@ export function createLegacyUpdateManifest(
}; };
} }
export function generateUpdateManifest() { export async function generateUpdateManifest() {
const channel = resolveReleaseChannel(); const channel = resolveReleaseChannel();
const artifact = selectReleaseArtifact(listFiles(bundleRoot)); const artifact = selectReleaseArtifact(listFiles(bundleRoot));
if (!artifact) { if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
} }
const manifest = createUpdateManifest(artifact, { channel }); const manualNotes = readReleaseNotes();
const previousCommit = await resolvePreviousReleaseCommit(channel);
const commits = collectReleaseCommits(previousCommit);
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
const notes =
manualNotes ||
formatReleaseNotes(commits) ||
formatRecentReleaseNotes(recentCommits);
if (!manualNotes && !notes) {
console.log(
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`,
);
}
const manifest = createUpdateManifest(artifact, { channel, notes });
const manifestPath = path.join(bundleRoot, 'latest.json'); const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const notesPath = path.join(bundleRoot, 'release-notes.txt');
fs.writeFileSync(
notesPath,
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
);
const legacyManifest = const legacyManifest =
channel === 'dev-win' channel === 'dev-win'
? createLegacyUpdateManifest(artifact, { channel }) ? createLegacyUpdateManifest(artifact, { channel, notes })
: null; : null;
const legacyManifestPath = legacyManifest const legacyManifestPath = legacyManifest
? path.join(bundleRoot, 'legacy-latest.json') ? path.join(bundleRoot, 'legacy-latest.json')
@@ -431,6 +648,14 @@ export function generateUpdateManifest() {
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
); );
console.log(`[ai-game-creator-shell] 安装包:${artifact}`); console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
console.log(
manualNotes
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
: notes && !previousCommit
? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交`
: `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'}`,
);
console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`);
if (legacyManifestPath) { if (legacyManifestPath) {
console.log( console.log(
`[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`, `[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
@@ -441,6 +666,10 @@ export function generateUpdateManifest() {
artifact, artifact,
manifest, manifest,
manifestPath, manifestPath,
notes,
notesPath,
previousCommit,
commits,
legacyManifest, legacyManifest,
legacyManifestPath, legacyManifestPath,
}; };
@@ -453,5 +682,5 @@ if (
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (!args.includes('--no-bundle')) await prepareReleaseVersion(); if (!args.includes('--no-bundle')) await prepareReleaseVersion();
runTauriBuild(args); runTauriBuild(args);
if (!args.includes('--no-bundle')) generateUpdateManifest(); if (!args.includes('--no-bundle')) await generateUpdateManifest();
} }
@@ -1,18 +1,32 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { test } from 'node:test'; import { test } from 'node:test';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
agcReleasePathPatterns,
collectRecentReleaseCommits,
collectReleaseCommits,
compareVersions, compareVersions,
createChannelConfig, createChannelConfig,
createLegacyUpdateManifest, createLegacyUpdateManifest,
createUpdateManifest, createUpdateManifest,
formatRecentReleaseNotes,
formatReleaseNotes,
nextPatchVersion, nextPatchVersion,
resolveManifestPlatformKeys, resolveManifestPlatformKeys,
resolvePreviousReleaseCommit,
resolveReleaseChannel, resolveReleaseChannel,
resolveRemoteHighWaterVersion,
selectReleaseArtifact, selectReleaseArtifact,
updateManifestUrl, updateManifestUrl,
} from './build-release.mjs'; } from './build-release.mjs';
@@ -49,6 +63,22 @@ function withSignedArtifact(fileName, run) {
} }
} }
function jsonResponse(body, status = 200) {
return {
status,
ok: status >= 200 && status < 300,
json: async () => body,
};
}
function withStubbedFetch(handler, run) {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => handler(String(url));
return Promise.resolve(run()).finally(() => {
globalThis.fetch = originalFetch;
});
}
test('selects an explicit release artifact when configured', () => { test('selects an explicit release artifact when configured', () => {
const artifactPath = fileURLToPath( const artifactPath = fileURLToPath(
new URL('../package.json', import.meta.url), new URL('../package.json', import.meta.url),
@@ -173,6 +203,137 @@ test('next release version follows the higher local or channel version', () => {
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
}); });
test('version high water keeps the legacy pointer during the migration window', async () => {
await withStubbedFetch(
(url) =>
url.endsWith('/agc/dev-win/latest.json')
? jsonResponse({}, 404)
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57');
// 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
},
);
});
test('version high water takes the higher of channel and legacy pointer', async () => {
await withStubbedFetch(
(url) =>
url.endsWith('/agc/dev-win/latest.json')
? jsonResponse({ version: '0.1.60' })
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60');
},
);
});
test('version high water ignores the windows migration pointer for other channels', async () => {
await withStubbedFetch(
(url) => {
assert.ok(
!url.endsWith('/agc/latest.json'),
'non-windows channel must not read the windows migration pointer',
);
return jsonResponse({ version: '0.1.12' });
},
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12');
},
);
});
test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => {
await withStubbedFetch(
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', {
override: '6017d46088c04199e99cf89f347b12d67591475e',
}),
'6017d46088c04199e99cf89f347b12d67591475e',
);
// 覆盖值非法时忽略,继续用清单里的 commit。
assert.equal(
await resolvePreviousReleaseCommit('dev-win', {
override: 'not-a-sha',
}),
'abcdef1234567890',
);
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
'abcdef1234567890',
);
},
);
await withStubbedFetch(
() => jsonResponse({ version: '0.1.61' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
null,
);
},
);
});
test('release notes anchor degrades to null when the manifest cannot be read', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error('fetch failed');
};
try {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
null,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('recent commit fallback marks that entries may repeat the previous release', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-'));
const git = (...args) =>
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
try {
git('init', '--quiet');
git('config', 'user.email', 'release@example.test');
git('config', 'user.name', 'release test');
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
recursive: true,
});
for (const name of ['one', 'two']) {
writeFileSync(
path.join(directory, `apps/ai-game-creator-shell/${name}.rs`),
`fn ${name}() {}\n`,
);
git('add', '.');
git('commit', '--quiet', '-m', `客户端:${name}`);
}
writeFileSync(path.join(directory, 'README.md'), '# 文档\n');
git('add', '.');
git('commit', '--quiet', '-m', '文档:说明');
const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 });
assert.deepEqual(
recent.map((entry) => entry.subject),
['客户端:two', '客户端:one'],
);
const notes = formatRecentReleaseNotes(recent);
assert.match(
notes,
/^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u,
);
assert.equal(formatRecentReleaseNotes([]), '');
assert.equal(formatRecentReleaseNotes(null), '');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('release upload forces overwrite for artifact, signature and channel pointers', () => { test('release upload forces overwrite for artifact, signature and channel pointers', () => {
const source = readFileSync( const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url), new URL('./release-upload.mjs', import.meta.url),
@@ -185,3 +346,141 @@ test('release upload forces overwrite for artifact, signature and channel pointe
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
assert.match(source, /agc\/latest\.json/u); assert.match(source, /agc\/latest\.json/u);
}); });
test('release notes list client commits with short sha and bound their size', () => {
const notes = formatReleaseNotes([
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
{ sha: '55af6014', subject: '修'.repeat(120) },
]);
const lines = notes.split('\n');
assert.equal(lines.length, 2);
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
const truncatedSubject = lines[1]
.replace(/^- /u, '')
.replace(/55af6014$/u, '');
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
assert.match(truncatedSubject, /…$/u);
assert.match(lines[1], /55af6014$/u);
const many = formatReleaseNotes(
Array.from({ length: 20 }, (_, index) => ({
sha: `sha${index}`,
subject: `改动 ${index}`,
})),
);
assert.match(many, /- 其余 8 项客户端改动省略$/u);
assert.equal(formatReleaseNotes([]), '');
assert.equal(formatReleaseNotes(null), '');
});
test('release commits cover only client paths and skip merge commits', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-'));
const git = (...args) =>
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
try {
git('init', '--quiet');
git('config', 'user.email', 'release@example.test');
git('config', 'user.name', 'release test');
git('commit', '--allow-empty', '--quiet', '-m', '基点');
const base = git('rev-parse', 'HEAD').trim();
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
recursive: true,
});
mkdirSync(path.join(directory, 'docs'), { recursive: true });
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
'fn main() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
git('add', '.');
git('commit', '--quiet', '-m', '文档:补充说明');
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/other.rs'),
'fn other() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:修复版本回退');
git('checkout', '--quiet', '-b', 'side');
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/side.rs'),
'fn side() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:侧分支改动');
git('checkout', '--quiet', 'master');
git('merge', '--quiet', '--no-ff', '--no-edit', 'side');
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
assert.ok(commits, '应能在临时仓库里收集提交');
const subjects = commits.map((entry) => entry.subject);
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
assert.deepEqual(subjects, [
'客户端:侧分支改动',
'客户端:修复版本回退',
'客户端:新增更新插件接入',
]);
assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha)));
assert.equal(
collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }),
null,
);
assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('scheduler path filter stays in sync with client release paths', () => {
const jenkinsfile = readFileSync(
new URL(
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
import.meta.url,
),
'utf8',
);
const filterLine = jenkinsfile
.split('\n')
.find((line) => line.includes('apps/ai-game-creator-shell/*|'));
assert.ok(filterLine, '调度管线里应存在发布范围过滤模式');
for (const pattern of agcReleasePathPatterns) {
const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern;
assert.ok(
filterLine.includes(bashPattern),
`调度管线过滤缺少 ${bashPattern}`,
);
}
});
test('scheduler skips the full build only for non-deploy paths', () => {
const jenkinsfile = readFileSync(
new URL(
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
import.meta.url,
),
'utf8',
);
const skipLine = jenkinsfile
.split('\n')
.find((line) => line.includes('docs/*|.codex/*|jenkins/*'));
assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式');
for (const pattern of [
'docs/*',
'.codex/*',
'jenkins/*',
'apps/ai-game-creator-shell/*',
'apps/mobile-shell/*',
'apps/desktop-shell/*',
'apps/preview-deployer-web/*',
'tools/*',
'*.md',
]) {
assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`);
}
});
@@ -54,7 +54,7 @@ function runOssutil(args) {
await prepareReleaseVersion(); await prepareReleaseVersion();
runTauriBuild([]); runTauriBuild([]);
const { artifact, channel, legacyManifestPath, manifest, manifestPath } = const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
generateUpdateManifest(); await generateUpdateManifest();
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
@@ -33,7 +33,11 @@ chromiumoxide = "0.9.1"
futures = "0.3" futures = "0.3"
getrandom = "0.3" getrandom = "0.3"
http = "1" http = "1"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } # `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的
# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。
# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让
# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] }
jsonschema = { version = "0.49.3", default-features = false } jsonschema = { version = "0.49.3", default-features = false }
oxc_allocator = "0.143.0" oxc_allocator = "0.143.0"
oxc_ast = "0.143.0" oxc_ast = "0.143.0"
@@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result
## Workflow ## Workflow
1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media. 1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media.
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. 2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
@@ -8,7 +8,7 @@ The client projects three distinct facts:
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance. Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. `agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`. Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`.
@@ -1,6 +1,6 @@
{ {
"schemaVersion": "agc-skill-pack.v1", "schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.19", "version": "2026-08-26.20",
"skills": [ "skills": [
{ {
"name": "agc-game-production-workflow", "name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml", "agents/openai.yaml",
"references/projection-contract.md" "references/projection-contract.md"
], ],
"sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1" "sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe"
} }
] ]
} }
@@ -21,7 +21,6 @@ mod direct_project_history;
mod direct_project_turn_history; mod direct_project_turn_history;
mod direct_runtime; mod direct_runtime;
mod direct_thread_manager; mod direct_thread_manager;
mod direct_thread_wire;
mod direct_tool_bridge; mod direct_tool_bridge;
mod direct_tool_calls; mod direct_tool_calls;
mod direct_tools_mcp; mod direct_tools_mcp;
@@ -41,8 +40,7 @@ use codex_app_server::*;
pub(crate) use codex_app_server::{ pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at, cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity, direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
direct_thread_id_for_project, DirectTurnCancelView,
}; };
use codex_cli::*; use codex_cli::*;
pub(crate) use codex_cli::{ pub(crate) use codex_cli::{
@@ -57,7 +55,6 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*; pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*; pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*; pub(crate) use direct_thread_manager::*;
pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*; pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*; pub(crate) use direct_tools_mcp::*;
@@ -20,29 +20,6 @@ pub(crate) fn direct_codex_canonical_project_identity(
)) ))
} }
/// 项目根目录在 Thread Manager 里的线程身份。
///
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
///
/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
return root.to_string_lossy().into_owned();
};
canonical_root
.to_str()
.and_then(|value| value.strip_prefix(r"\\?\"))
.map(std::path::Path::new)
.unwrap_or(canonical_root.as_path())
.to_string_lossy()
.into_owned()
}
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> { pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
#[cfg(unix)] #[cfg(unix)]
{ {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5305,7 +5305,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?; })?;
if prepare_art { if prepare_art {
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
} else { } else {
system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。"); system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1380,6 +1380,33 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
"gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp"
| "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql"
| "gql" | "vue" | "svelte" => ("code", Some("text/plain")), | "gql" | "vue" | "svelte" => ("code", Some("text/plain")),
/*
* Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。
*
* 这里只做**发现分类**`model` / `binary` 是发现层新词,与 manifest 资产 `kind`
* 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。
* 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层
* 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的
* 第三份投影,同样要跟改。
*/
"glb" => ("model", Some("model/gltf-binary")),
"gltf" => ("model", Some("model/gltf+json")),
"fbx" => ("model", Some("application/octet-stream")),
"mesh" | "skeleton" => ("model", Some("application/json")),
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => {
("document", Some("application/json"))
}
"tmx" | "plist" => ("document", Some("application/xml")),
"effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")),
"tga" => ("image", Some("image/x-tga")),
"tif" | "tiff" => ("image", Some("image/tiff")),
"hdr" => ("image", Some("image/vnd.radiance")),
"exr" => ("image", Some("image/x-exr")),
"dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
("binary", Some("application/octet-stream"))
}
"pcm" => ("audio", Some("audio/pcm")),
_ => ("other", None), _ => ("other", None),
} }
} }
@@ -1421,8 +1448,10 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
.map(|value| value.to_lowercase()); .map(|value| value.to_lowercase());
let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)? let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)?
.unwrap_or_else(|| "all".to_string()); .unwrap_or_else(|| "all".to_string());
if !["all", "image", "font", "audio", "video", "document", "code"] if ![
.contains(&requested_kind.as_str()) "all", "image", "font", "audio", "video", "document", "code", "model", "binary",
]
.contains(&requested_kind.as_str())
{ {
return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
} }
@@ -3181,7 +3210,9 @@ mod tests {
"supported raster image should be importable: {path}" "supported raster image should be importable: {path}"
); );
} }
for path in ["assets/theme.bin", "assets/unknown.xyz"] { // `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler),
// 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。
for path in ["assets/theme.dat", "assets/unknown.xyz"] {
assert!( assert!(
!bridge_project_file_is_asset_importable(path), !bridge_project_file_is_asset_importable(path),
"unsupported project file must not be advertised as importable: {path}" "unsupported project file must not be advertised as importable: {path}"
@@ -3201,6 +3232,64 @@ mod tests {
} }
} }
/// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType`
/// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。
///
/// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。
#[test]
fn bridge_project_file_class_covers_cocos_creator_assets() {
for (path, expected_class, expected_media_type) in [
("assets/model/hero.glb", "model", "model/gltf-binary"),
("assets/model/hero.gltf", "model", "model/gltf+json"),
("assets/model/hero.fbx", "model", "application/octet-stream"),
("assets/model/hero.mesh", "model", "application/json"),
("assets/model/hero.skeleton", "model", "application/json"),
("assets/scene/main.scene", "document", "application/json"),
("assets/scene/enemy.prefab", "document", "application/json"),
("assets/anim/walk.anim", "document", "application/json"),
(
"assets/anim/graph.animgraph",
"document",
"application/json",
),
("assets/mtl/hero.mtl", "document", "application/json"),
("assets/shader/glow.effect", "document", "text/plain"),
("assets/atlas/hero.plist", "document", "application/xml"),
("assets/map/level.tmx", "document", "application/xml"),
("assets/font/bitmap.fnt", "document", "text/plain"),
("assets/atlas/auto.pac", "document", "application/json"),
("assets/tex/grass.tga", "image", "image/x-tga"),
("assets/tex/height.hdr", "image", "image/vnd.radiance"),
(
"assets/tex/hero.texture",
"binary",
"application/octet-stream",
),
(
"assets/spine/hero.skel",
"binary",
"application/octet-stream",
),
("assets/audio/voice.pcm", "audio", "audio/pcm"),
] {
let (class, media_type) = bridge_project_file_class(path);
assert_eq!(class, expected_class, "{path}");
assert_eq!(media_type, Some(expected_media_type), "{path}");
assert!(
bridge_project_file_is_asset_importable(path),
"Cocos 资源必须可登记:{path}"
);
}
// 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。
for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] {
assert_eq!(bridge_project_file_class(path).0, "other", "{path}");
assert!(
!bridge_project_file_is_asset_importable(path),
"非资源文件不得可登记:{path}"
);
}
}
#[test] #[test]
fn bridge_project_file_listing_projects_importability_per_file() { fn bridge_project_file_listing_projects_importability_per_file() {
let temporary = tempfile::tempdir().expect("create project file listing root"); let temporary = tempfile::tempdir().expect("create project file listing root");
@@ -1,4 +1,4 @@
//! GameAgent 对话「工具调用卡片」的采集持久化。 //! GameAgent 对话「工具调用卡片」的采集持久化与回读
//! //!
//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md` //! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 //! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更
@@ -8,9 +8,11 @@
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
use super::direct_thread_wire::sanitize_detail_text; use crate::agent::redact_secret_tokens;
use crate::config::write_game_creator_private_file; use crate::agent::sanitize_error_context;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -22,7 +24,7 @@ use std::path::{Path, PathBuf};
pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item";
/// 条目 schema 版本。 /// 条目 schema 版本。
pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1";
/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 /// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200;
/// `detail.command` / `detail.output` 的字符上限。 /// `detail.command` / `detail.output` 的字符上限。
const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000;
@@ -84,6 +86,134 @@ fn tool_calls_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/tool-calls.jsonl") root.join(".agent/conversations/tool-calls.jsonl")
} }
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
let mut index = start;
let mut relative = String::new();
while index < value.len() {
let character = value[index..].chars().next().unwrap_or_default();
if matches!(character, '/' | '\\') {
if !relative.is_empty() {
relative.push('/');
}
index += character.len_utf8();
continue;
}
if character.is_whitespace()
|| matches!(
character,
'\'' | '"'
| '`'
| ','
| ';'
| '|'
| '&'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| ':'
)
{
break;
}
relative.push(character);
index += character.len_utf8();
}
while relative.ends_with('/') {
relative.pop();
}
(index, relative)
}
/// 把项目根目录前缀换成**项目相对路径**`<root>/game/src/x.ts` → `game/src/x.ts`)。
///
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
let root_text = root.to_string_lossy();
let root_text = root_text.trim_end_matches(['/', '\\']);
if root_text.is_empty() {
return value.to_string();
}
let mut needles = [
root_text.to_string(),
root_text.replace('\\', "/"),
root_text.replace('/', "\\"),
]
.into_iter()
.map(|needle| needle.to_ascii_lowercase())
.filter(|needle| !needle.is_empty())
.collect::<Vec<_>>();
needles.sort();
needles.dedup();
let lower = value.to_ascii_lowercase();
let mut output = String::with_capacity(value.len());
let mut cursor = 0usize;
while cursor < value.len() {
let mut hit: Option<(usize, usize)> = None;
for needle in &needles {
let mut search = cursor;
while let Some(relative) = lower[search..].find(needle.as_str()) {
let start = search + relative;
let end = start + needle.len();
let left_is_boundary = start == 0
|| lower[..start].chars().next_back().is_some_and(|character| {
!character.is_alphanumeric() && character != '_' && character != '-'
});
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
if hit.is_none_or(|(best_start, _)| start < best_start) {
hit = Some((start, end));
}
break;
}
search = end;
}
}
let Some((start, end)) = hit else {
break;
};
output.push_str(&value[cursor..start]);
let (consumed, relative) = project_relative_path_segment(value, end);
if relative.is_empty() {
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
output.push_str("<absolute-path>");
} else {
output.push_str(&relative);
}
cursor = consumed;
}
output.push_str(&value[cursor..]);
output
}
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
/// 错误上下文脱敏。
///
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
///
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
///
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
let without_project_root = relativize_project_root_paths(root, value);
let without_absolute = redact_absolute_path_tokens(&without_project_root);
let without_secret = redact_secret_tokens(&without_absolute);
sanitize_error_context(&without_secret)
}
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
fn bounded_chars(value: &str, max_chars: usize) -> String { fn bounded_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars { if value.chars().count() <= max_chars {
@@ -397,6 +527,15 @@ fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
normalized normalized
} }
/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。
pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCall>, String> {
let path = tool_calls_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? {
return Ok(Vec::new());
}
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
}
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 /// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
fn status_certainty(status: &str) -> u8 { fn status_certainty(status: &str) -> u8 {
match status { match status {
@@ -545,22 +684,10 @@ mod tests {
use super::{ use super::{
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path, read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall,
DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION,
DIRECT_TOOL_CALL_SCHEMA_VERSION,
}; };
use serde_json::json; use serde_json::json;
use std::path::Path;
/// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪,
/// 断言因此落在「磁盘上到底写了什么」这一层。
fn persisted_tool_calls(root: &Path) -> Vec<DirectToolCall> {
std::fs::read_to_string(tool_calls_path(root))
.unwrap_or_default()
.lines()
.filter_map(tool_call_from_line)
.collect()
}
/// 一行合法的落盘信封(回读用例的夹具)。 /// 一行合法的落盘信封(回读用例的夹具)。
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
@@ -685,7 +812,7 @@ mod tests {
.expect("completed tool call"); .expect("completed tool call");
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); persist_direct_tool_call_at(root.path(), &completed).expect("persist completed");
let calls = persisted_tool_calls(root.path()); let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls");
assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!(calls[0].status, "completed"); assert_eq!(calls[0].status, "completed");
assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖");
@@ -785,6 +912,85 @@ mod tests {
); );
} }
/// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。
#[test]
fn tool_call_read_skips_corrupted_lines() {
let root = init_tool_call_project("tool-call-corrupt");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let good = serde_json::to_string(&json!({
"type": "tool_call_item",
"payload": {
"schemaVersion": "agc-tool-call.v1",
"id": "item-good",
"turnId": "turn-1",
"kind": "command",
"title": "执行命令",
"summary": "npm run build",
"status": "completed",
"detail": {"command": "npm run build"},
"startedAt": 1,
"updatedAt": 2
}
}))
.expect("serialize good row");
std::fs::write(
&path,
format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"),
)
.expect("write fixture");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_direct_tool_calls_at(missing.path())
.expect("missing file is empty")
.is_empty(),
"历史文件缺失必须返回空数组"
);
let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines");
assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条");
assert_eq!(calls[0].id, "item-good");
}
/// 判据:回读按时间正序,且超出上限时保留最新。
#[test]
fn tool_call_read_is_ordered_and_capped() {
let root = init_tool_call_project("tool-call-cap");
let total = DIRECT_TOOL_CALL_LIMIT + 5;
let calls = (0..total)
.map(|index| {
direct_tool_call_from_item(
root.path(),
&json!({
"id": format!("item-{index:04}"),
"type": "commandExecution",
"command": format!("run {index}"),
"startedAtMs": 1000 + index as u64,
}),
"turn-1",
false,
1000 + index as u64,
)
.expect("tool call")
})
.collect::<Vec<_>>();
persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch");
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条");
assert_eq!(
read.first().expect("first").id,
format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT),
"最早被裁掉的是最旧的条目"
);
assert!(
read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"回读必须按时间正序"
);
}
/// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。
#[test] #[test]
fn tool_call_file_change_title_counts_unique_paths() { fn tool_call_file_change_title_counts_unique_paths() {
@@ -983,7 +1189,7 @@ mod tests {
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
let calls = persisted_tool_calls(root.path()); let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write");
assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!( assert_eq!(
calls[0].status, "completed", calls[0].status, "completed",
@@ -995,7 +1201,7 @@ mod tests {
// 回合末整批落盘那条路径同样不得回退。 // 回合末整批落盘那条路径同样不得回退。
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
.expect("persist stale running batch"); .expect("persist stale running batch");
let calls = persisted_tool_calls(root.path()); let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write");
assert_eq!( assert_eq!(
calls[0].status, "completed", calls[0].status, "completed",
"整批落盘路径同样不得把 completed 打回 running" "整批落盘路径同样不得把 completed 打回 running"
@@ -1003,6 +1209,28 @@ mod tests {
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
} }
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
#[test]
fn tool_call_read_merges_duplicate_rows_monotonically() {
let root = init_tool_call_project("tool-call-read-monotonic");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let completed = tool_call_row("item-1", 1000, 2000);
let stale_running = tool_call_row("item-1", 1000, 1000)
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
assert!(stale_running.contains("\"status\":\"running\""));
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
assert_eq!(calls.len(), 1, "同 id 归并成一条");
assert_eq!(
calls[0].status, "completed",
"磁盘上更旧的快照不得把状态打回 running"
);
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
assert_eq!(calls[0].started_at, 1000);
}
/// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。
#[test] #[test]
fn tool_call_paths_become_project_relative() { fn tool_call_paths_become_project_relative() {
@@ -1055,10 +1283,9 @@ mod tests {
); );
} }
/// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回 /// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回
/// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。
#[test] #[test]
fn tool_call_pre_read_skips_invalid_utf8_line() { fn tool_call_read_skips_invalid_utf8_line() {
let root = init_tool_call_project("tool-call-invalid-utf8"); let root = init_tool_call_project("tool-call-invalid-utf8");
let path = tool_calls_path(root.path()); let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
@@ -1071,7 +1298,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes());
bytes.push(b'\n'); bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); std::fs::write(&path, &bytes).expect("write invalid utf8 fixture");
let calls = read_tool_call_lines(&path); let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8");
assert_eq!( assert_eq!(
calls.len(), calls.len(),
2, 2,
@@ -1091,7 +1318,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes());
bytes.push(b'\n'); bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); std::fs::write(&path, &bytes).expect("write truncated utf8 fixture");
let calls = read_tool_call_lines(&path); let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line");
assert_eq!( assert_eq!(
calls.len(), calls.len(),
2, 2,
@@ -1099,16 +1326,10 @@ mod tests {
); );
assert_eq!(calls[0].id, "item-a"); assert_eq!(calls[0].id, "item-a");
assert_eq!(calls[1].id, "item-c"); assert_eq!(calls[1].id, "item-c");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(),
"历史文件缺失时写前读取必须返回空表"
);
} }
/// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的 /// 判据:200 条上限「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃
/// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
#[test] #[test]
fn tool_call_cap_drops_oldest_turn_cards() { fn tool_call_cap_drops_oldest_turn_cards() {
let root = init_tool_call_project("tool-call-cap-oldest"); let root = init_tool_call_project("tool-call-cap-oldest");
@@ -1145,7 +1366,7 @@ mod tests {
.expect("newest tool call"); .expect("newest tool call");
persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); persist_direct_tool_call_at(root.path(), &newest).expect("persist newest");
let read = persisted_tool_calls(root.path()); let read = read_direct_tool_calls_at(root.path()).expect("read capped");
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条");
assert_eq!( assert_eq!(
read.last().expect("last").id, read.last().expect("last").id,
@@ -1160,7 +1381,7 @@ mod tests {
assert!( assert!(
read.windows(2) read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()), .all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"落盘顺序必须按时间正序" "回读必须按时间正序"
); );
} }
@@ -369,7 +369,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
}, },
"kind": { "kind": {
"type": "string", "type": "string",
"enum": ["all", "image", "font", "audio", "video", "document", "code"] "enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"],
"description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源"
}, },
"offset": { "type": "integer", "minimum": 0, "maximum": 500 }, "offset": { "type": "integer", "minimum": 0, "maximum": 500 },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 } "limit": { "type": "integer", "minimum": 1, "maximum": 100 }
@@ -771,7 +772,10 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String>
} }
if arguments.get("kind").is_some() { if arguments.get("kind").is_some() {
let kind = bounded_tool_string(arguments, "kind", 16)?; let kind = bounded_tool_string(arguments, "kind", 16)?;
if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str()) if ![
"all", "image", "font", "audio", "video", "document", "code", "model", "binary",
]
.contains(&kind.as_str())
{ {
return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
} }
@@ -1,4 +1,4 @@
//! GameAgent 对话「回合流」的采集持久化。 //! GameAgent 对话「回合流」的采集持久化与回读
//! //!
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序** //! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 //! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
@@ -11,7 +11,7 @@
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 //! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
use crate::agent::sanitize_detail_text; use crate::agent::sanitize_detail_text;
use crate::config::write_game_creator_private_file; use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -339,6 +339,15 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
}) })
} }
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
let path = turn_stream_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
return Ok(Vec::new());
}
Ok(normalize_stream_items(read_stream_lines(&path)))
}
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 /// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
/// ///
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 /// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
@@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str( output.push_str(
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\ "# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\ - 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档代码文件可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n", - 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等)可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
); );
for (path, size, media_type) in unregistered.iter().take(48) { for (path, size, media_type) in unregistered.iter().take(48) {
let asset_importable = !media_type.is_empty(); let asset_importable = !media_type.is_empty();
@@ -228,6 +228,27 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
"js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" "js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts"
| "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh"
| "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"), | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"),
/*
* Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与
* `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
* `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名,
* Agent 的提示词就会把「其实可以登记」的资源说成只能发现。
*/
"glb" => Some("model/gltf-binary"),
"gltf" => Some("model/gltf+json"),
"fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
Some("application/octet-stream")
}
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh"
| "skeleton" => Some("application/json"),
"tmx" | "plist" => Some("application/xml"),
"effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"),
"tga" => Some("image/x-tga"),
"tif" | "tiff" => Some("image/tiff"),
"hdr" => Some("image/vnd.radiance"),
"exr" => Some("image/x-exr"),
"pcm" => Some("audio/pcm"),
_ => None, _ => None,
} }
} }
@@ -246,10 +267,21 @@ mod tests {
("assets/data.json", "application/json"), ("assets/data.json", "application/json"),
("game/index.html", "text/html"), ("game/index.html", "text/html"),
("game/main.rs", "text/plain"), ("game/main.rs", "text/plain"),
// 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名,
// Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。
("assets/model/hero.glb", "model/gltf-binary"),
("assets/model/hero.fbx", "application/octet-stream"),
("assets/anim/walk.anim", "application/json"),
("assets/scene/main.scene", "application/json"),
("assets/shader/glow.effect", "text/plain"),
("assets/atlas/hero.plist", "application/xml"),
("assets/tex/grass.tga", "image/x-tga"),
("assets/audio/voice.pcm", "audio/pcm"),
] { ] {
assert_eq!(prompt_context_media_type(path), Some(expected), "{path}"); assert_eq!(prompt_context_media_type(path), Some(expected), "{path}");
} }
assert_eq!(prompt_context_media_type("assets/unknown.bin"), None); // `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。
assert_eq!(prompt_context_media_type("assets/unknown.dat"), None);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -2630,6 +2630,7 @@ fn main() {
read_local_project_image_preview, read_local_project_image_preview,
save_local_project_asset_file, save_local_project_asset_file,
read_local_project_text_preview, read_local_project_text_preview,
read_local_project_structured_preview,
read_local_project_media_preview, read_local_project_media_preview,
cancel_local_project_resource_preview_scope, cancel_local_project_resource_preview_scope,
write_local_project_file, write_local_project_file,
@@ -2646,6 +2647,8 @@ fn main() {
archive_game_creator_agent_session, archive_game_creator_agent_session,
read_local_conversation, read_local_conversation,
read_direct_project_conversation, read_direct_project_conversation,
read_direct_tool_calls,
read_direct_turn_stream,
read_agent_runtime_error_detail, read_agent_runtime_error_detail,
list_game_creator_direct_active_turns, list_game_creator_direct_active_turns,
subscribe_direct_project_thread, subscribe_direct_project_thread,
@@ -1,5 +1,21 @@
use super::*; use super::*;
/**
* Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。
*
* 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源
* 目录里的普通文件夹,不属于这里。
*/
fn is_engine_generated_root_directory(relative_path: &str) -> bool {
if relative_path.contains('/') {
return false;
}
matches!(
relative_path.to_ascii_lowercase().as_str(),
"library" | "temp" | "profiles" | "local"
)
}
pub(crate) fn list_local_project_files_at( pub(crate) fn list_local_project_files_at(
root: &Path, root: &Path,
) -> Result<ListLocalProjectFilesResult, String> { ) -> Result<ListLocalProjectFilesResult, String> {
@@ -11,6 +27,15 @@ pub(crate) fn list_local_project_files_at(
}); });
} }
/*
* 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。
*
* 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在
* 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的
* `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让
* 前端资源树加载一堆永远用不上的条目。
*/
let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some();
let mut files = Vec::new(); let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()]; let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() { while let Some(dir) = dirs.pop() {
@@ -41,6 +66,11 @@ pub(crate) fn list_local_project_files_at(
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0); .unwrap_or(0);
if file_type.is_dir() { if file_type.is_dir() {
if skip_engine_generated_directories
&& is_engine_generated_root_directory(&relative_path)
{
continue;
}
files.push(LocalProjectFileEntry { files.push(LocalProjectFileEntry {
path: relative_path, path: relative_path,
kind: "directory".to_string(), kind: "directory".to_string(),
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More