Compare commits

..

20 Commits

Author SHA1 Message Date
lhk229 9169ae0dad Merge pull request '修复前端测试状态泄漏与异步断言竞态' (#397) from codex/ci-frontend-stability-20260917 into master
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m15s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m59s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / AI game creator shell Rust crates (push) Successful in 2m21s
Project CI / Frontend tests (push) Successful in 5m22s
Project CI / Repository checks (push) Successful in 4m26s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / AI game creator shell web tests (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 9m49s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/397
2026-09-17 12:29:26 +08:00
lhk229 a68a9621a7 修复前端测试状态泄漏与异步断言竞态
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m4s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m43s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m7s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Successful in 4m50s
Project CI / Frontend tests (pull_request) Successful in 5m52s
Project CI / Backend tests (pull_request) Successful in 9m13s
Project CI / Native shell tests (pull_request) Successful in 8m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m0s
隔离创作主页换签网络请求并使用独立响应

恢复快捷键选择状态、持久化路由和UI编辑页宿主mock

修正虚拟树尺寸与选择时序并纳入换签Hook测试

记录全量与随机顺序测试验证方式
2026-09-17 11:56:17 +08:00
lhk229 d9431b51da 隔离创作主页资源预览测试缓存
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m6s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m16s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m38s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m56s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m6s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Frontend tests (push) Failing after 4m59s
Project CI / Repository checks (push) Successful in 4m53s
Project CI / Native shell tests (push) Successful in 9m52s
Project CI / Backend tests (push) Successful in 10m33s
Project CI / AI game creator shell web tests (push) Successful in 4m13s
每个用例前后清理签名资源地址缓存,避免共享调度状态影响活动图片断言
2026-09-17 10:05:22 +08:00
lhk229 9beb16f203 稳定CI测试并发与工作流 (#393)
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m25s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m46s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 8m8s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m51s
Project CI / AI game creator shell Rust crates (push) Successful in 2m8s
Project CI / Frontend tests (push) Failing after 4m23s
Project CI / Repository checks (push) Successful in 4m31s
Project CI / Backend tests (push) Successful in 9m14s
Project CI / Native shell tests (push) Successful in 9m5s
Project CI / AI game creator shell web tests (push) Successful in 5m19s
为Project CI增加同分支并发取消,避免旧运行覆盖新结果

将Vitest默认线程数固定为1,降低异步UI测试竞态

Reviewed-on: #393
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-17 09:18:39 +08:00
lhk229 9b6ed8a4cf 整理抠图测试导入并明确对外说明边界
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m13s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m52s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m48s
Project CI / AI game creator shell Rust crates (push) Successful in 2m41s
Project CI / Frontend tests (push) Successful in 5m30s
Project CI / Repository checks (push) Successful in 4m22s
Project CI / Native shell tests (push) Successful in 9m3s
Project CI / Backend tests (push) Successful in 9m19s
Project CI / AI game creator shell web tests (push) Successful in 5m12s
将主站与客户端抠图测试模块的导入统一放到模块顶部
明确 provider 选择与兜底策略仅保留在内部技术方案中
2026-09-17 00:54:28 +08:00
lhk229 16deb0ad61 补齐抠图参数透传与验收
修复flat自动识别门禁并严格校验模式和颜色

保持旧请求幂等指纹并区分客户端抠图意图

同步工具说明和Skill契约并补充定向测试

记录真实BgFilter验证及本地数据库阻塞的待验收项
2026-09-17 00:54:28 +08:00
lhk229 13f56e644d 补充抠图模式调用说明
说明complex语义分割与flat纯色背景抠图

明确纯色背景优先使用flat并移除内部实现术语
2026-09-17 00:54:28 +08:00
lhk229 e08b031761 调整抠图模式参数说明 2026-09-17 00:54:28 +08:00
lhk229 70ad794b28 扩展AGC抠图客户端参数
支持complex与flat模式及auto背景色

保留旧客户端参数调用兼容性
2026-09-17 00:54:28 +08:00
lhk229 ca761e093c 接通抠图模式到BgFilter队列
透传backgroundMode和screenColor到内部worker

保留auto及省略颜色的下游识别语义
2026-09-17 00:54:28 +08:00
lhk229 a17367112f 增加主站抠图参数归一化
缺省模式兼容为complex并校验颜色格式

将auto颜色原样保留给BgFilter
2026-09-17 00:54:28 +08:00
lhk229 d7a0c3cffd 扩展主站抠图请求契约
增加backgroundMode和screenColor可选字段

同步External v1 OpenAPI抠图模式说明
2026-09-17 00:54:28 +08:00
lhk229 fa64819b6e 冻结BgFilter抠图服务契约
记录flat与complex模式及auto背景色行为

补充服务器认证头和当前运行版本
2026-09-17 00:54:28 +08:00
lhk229 f748d36d21 补充AGC抠图模式透传方案
新增主站与BgFilter模式及背景色契约

补充AGC客户端同步任务与兼容性规则

记录联调验收和发布回滚边界
2026-09-17 00:54:28 +08:00
lhk229 79b153e3fa 修复Direct活动回合重试类型错误
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m53s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m5s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m51s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust crates (push) Successful in 2m23s
Project CI / Frontend tests (push) Failing after 4m42s
Project CI / Repository checks (push) Successful in 4m44s
Project CI / Backend tests (push) Successful in 9m27s
Project CI / Native shell tests (push) Successful in 9m17s
Project CI / AI game creator shell web tests (push) Failing after 5m2s
重试等待 Promise 显式声明为 void,修复 shell typecheck 与原生构建失败。

补充卸载后取消读取重试的回归测试。
2026-09-16 21:53:19 +08:00
lhk229 362edcc49d 修复前端测试偶发超时与异步清理
Direct 活动回合读取失败后的重试定时器随组件卸载清理,避免测试环境销毁后访问 window。

后台素材查询大量缩略图测试合并可见性触发,并补充独立超时预算。

创作首页活动图与资源卡预览调度重载用例分别补充独立超时预算。

清单快照测试补齐 Direct 活动回合 IPC mock,避免未预期命令进入重试。
2026-09-16 21:53:19 +08:00
kdletters 1e434e0cb5 文档记录 CI 宿主 CPU 上限:Jenkins 16 核 / Gitea Actions runner 12 核
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m57s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m10s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m56s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / AI game creator shell Rust crates (push) Successful in 1m47s
Project CI / Repository checks (push) Successful in 3m34s
Project CI / Frontend tests (push) Successful in 4m25s
Project CI / Native shell tests (push) Successful in 7m56s
Project CI / AI game creator shell web tests (push) Successful in 4m32s
Project CI / Backend tests (push) Successful in 10m1s
- 在【开发运维】本地开发验证与生产运维新增「CI 宿主 CPU 上限」小节,写明 jenkins.service CPUQuota=1600% 与 gitea-stack runner cpus=12.0 的生效位置、核验命令和回滚方式
- 在 shared-memory/decision-log.md 补记该资源上限决策的背景、边界与验证证据
2026-09-16 21:17:30 +08:00
lhk229 cec971c438 修复局部修改工具的缺陷和偶发锁问题 (#386)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m49s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m16s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m1s
Project CI / AI game creator shell Rust crates (push) Successful in 2m14s
Project CI / Frontend tests (push) Failing after 4m6s
Project CI / Repository checks (push) Successful in 4m33s
Project CI / Native shell tests (push) Successful in 6m33s
Project CI / Backend tests (push) Successful in 8m26s
Project CI / AI game creator shell web tests (push) Successful in 3m43s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/386
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-16 19:53:23 +08:00
lhk229 70157673b6 Merge branch 'master' into fix/ci
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m27s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m46s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m12s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m41s
Project CI / AI game creator shell Rust crates (push) Successful in 2m48s
Project CI / Frontend tests (push) Successful in 4m49s
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Native shell tests (push) Successful in 6m41s
Project CI / Backend tests (push) Successful in 7m17s
Project CI / AI game creator shell web tests (push) Successful in 3m26s
2026-09-16 19:07:53 +08:00
lhk229 57fae7037a 修复 Agent 异步测试等待
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m24s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m52s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m42s
Project CI / Frontend tests (pull_request) Successful in 4m28s
Project CI / Repository checks (pull_request) Successful in 3m39s
Project CI / Native shell tests (pull_request) Successful in 6m59s
Project CI / Backend tests (pull_request) Successful in 7m37s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m55s
移除不稳定的项目页文案前置

保留刷新 Agent 与拆解任务按钮就绪检查
2026-09-16 10:52:25 +00:00
114 changed files with 1215 additions and 5122 deletions
+4
View File
@@ -7,6 +7,10 @@ on:
pull_request:
workflow_dispatch:
concurrency:
group: project-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({
interface MockIntersectionObserverController {
enter: (target: Element) => void;
enterAll: (targets: Element[]) => void;
isObserved: (target: Element) => boolean;
}
@@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
);
});
},
enterAll(targets) {
act(() => {
for (const target of targets) {
const record = observed.get(target);
if (!record) {
throw new Error('目标缩略图尚未进入 IntersectionObserver');
}
record.callback(
[
{
isIntersecting: true,
target,
} as IntersectionObserverEntry,
],
record.observer,
);
}
});
},
isObserved(target) {
return observed.has(target);
},
@@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
const thumbnails = entries.map((entry) =>
thumbnailElementForLabel(entry.label),
);
thumbnails.forEach((thumbnail) => {
for (const thumbnail of thumbnails) {
expect(observer.isObserved(thumbnail)).toBe(true);
observer.enter(thumbnail);
});
}
observer.enterAll(thumbnails);
await act(async () => {
await Promise.resolve();
});
@@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
await vi.advanceTimersByTimeAsync(200);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
});
}, 10_000);
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
const observer = installIntersectionObserverMock();
@@ -2,7 +2,7 @@
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
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.
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.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
@@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.16",
"version": "2026-08-26.17",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
}
]
}
@@ -527,10 +527,6 @@ fn process_design_batch(
let result = if uncertain {
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
} else {
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"design.tool",
)?;
execute_design_tool(root, resources, session, &call)
};
let error = result
@@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at(
finish_design_command(root, resources, session, active, run, emit).await
}
async fn recover_uncertain_design_batch(
root: &Path,
resources: &DesignResources,
session: DesignSession,
active: File,
) -> Result<DesignView, String> {
finish_design_command(root, resources, session, active, true, |_| {}).await
}
pub(crate) async fn decide_design_phase_at(
root: &Path,
resources: &DesignResources,
@@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
}
#[tauri::command]
pub(crate) fn hydrate_design_agent_session(
pub(crate) async fn hydrate_design_agent_session(
app: tauri::AppHandle,
project_path: String,
) -> Result<Option<DesignView>, String> {
let root = Path::new(project_path.trim());
@@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session(
if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into());
}
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
Ok(Some(design_view(&session, active.is_none())))
let Some(active) =
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
else {
return Ok(Some(design_view(&session, true)));
};
if design_session_has_uncertain_batch(&session) {
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
return Ok(Some(view));
}
drop(active);
Ok(Some(design_view(&session, false)))
}
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
let Some(batch) = session.pending_batch.as_ref() else {
return false;
};
if !batch.executing || batch.cursor >= batch.calls.len() {
return false;
}
let call_id = batch.calls[batch.cursor].id.as_str();
session.turn.as_ref().is_some_and(|turn| turn.pending)
&& !session.history.iter().any(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
})
}
fn design_session_error_is_recoverable(error: &str) -> bool {
@@ -1958,4 +1989,94 @@ mod tests {
.any(|message| message.text.contains("重试后继续")));
assert!(next.session.last_error.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
let (_temp, root, resources) = init_design_project();
execute_design_file_tool(
&root,
"write_file",
&json!({"path":"project/00_concept/design.md","content":"概念"}),
)
.expect("write concept");
let mut session = new_design_session("design-fake", "quality");
let call = platform_llm::LlmToolCall {
id: "interrupted-call".into(),
name: "patch_file".into(),
arguments: json!({
"path":"project/00_concept/design.md",
"old_text":"概念",
"new_text":"概念设计"
})
.to_string(),
};
session.history.push(json!({
"type":"function_call",
"call_id":call.id,
"name":call.name,
"arguments":call.arguments,
}));
session.messages = vec![DesignMessage {
id: "turn:user".into(),
role: "user".into(),
text: "继续".into(),
}];
session.turn = Some(DesignTurn {
id: "turn-recovery".into(),
pending: true,
request_index: 0,
attempt: 0,
});
session.pending_batch = Some(DesignToolBatch {
calls: vec![call],
cursor: 0,
executing: true,
});
assert!(design_session_has_uncertain_batch(&session));
write_design_session(&root, &session).expect("write interrupted session");
let _fake = fake_provider::install(
vec![Ok(fake_response(
"recovered-after-uncertain-tool",
"已读取文件并确认。",
Vec::new(),
))],
0,
);
let view = recover_uncertain_design_batch(&root, &resources, session, {
try_open_game_creator_agent_runtime_task_lock_file(
&root,
".agent/design-agent/active.lock",
)
.expect("open active lock")
.expect("active lock is free")
})
.await
.expect("recover uncertain batch");
assert!(!view.running);
assert!(view.session.last_error.is_none());
let restored = read_design_session(&root)
.expect("read restored")
.expect("session");
assert!(restored.pending_batch.is_none());
assert!(!restored.turn.expect("turn").pending);
assert!(restored.history.iter().any(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
&& item
.get("output")
.and_then(Value::as_str)
.is_some_and(|output| output.contains("执行结果未保存"))
}));
assert!(restored.history.iter().any(|item| {
item.get("role").and_then(Value::as_str) == Some("assistant")
&& item.get("content").is_some()
}));
assert!(
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
.expect("read target")
== "概念"
);
}
}
@@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool(
})
.collect::<Vec<_>>();
let mut matches = Vec::new();
let mut edit_errors = Vec::new();
let mut valid_edits = 0;
for (index, (old, new)) in normalized.iter().enumerate() {
if old == new {
edit_errors.push(format!(
"edits[{index}] new_text 与 old_text 相同,不会产生修改"
));
continue;
}
let count = content.matches(old).count();
if count == 0 {
return Err(format!("edits[{index}] 原文未找到:{display}"));
edit_errors.push(format!(
"edits[{index}] 原文未找到:{}{}",
display,
design_patch_location_hint(&content, old)
));
continue;
}
if count != 1 {
return Err(format!(
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
let start = content.find(old).expect("count checked");
let line = design_patch_line_number(&content, start);
edit_errors.push(format!(
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line}"
));
continue;
}
let start = content.find(old).expect("count checked");
let end = start + old.len();
@@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool(
.iter()
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
{
return Err(format!(
"edits[{index}] 与 edits[{other_index}] 修改范围重叠{display}"
edit_errors.push(format!(
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
));
continue;
}
matches.push((index, start, end));
valid_edits += 1;
let _ = new;
}
if !edit_errors.is_empty() {
let shown = edit_errors.len().min(4);
let mut details = edit_errors[..shown].to_vec();
if shown < edit_errors.len() {
details.push(format!(
"另有 {} 个 edit 校验失败(详情省略)",
edit_errors.len() - shown
));
}
if valid_edits > 0 {
details.push(format!(
"其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件"
));
} else {
details.push("本次未写入文件".to_string());
}
return Err(details.join("\n"));
}
let mut updated = content.clone();
for (index, start, end) in matches.into_iter().rev() {
let (_, new) = &normalized[index];
@@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool(
}
}
fn design_patch_line_number(content: &str, start: usize) -> usize {
1 + content[..start]
.bytes()
.filter(|byte| *byte == b'\n')
.count()
}
fn design_patch_visible_line(line: &str) -> String {
line.replace('\t', "\\t").chars().take(180).collect()
}
fn design_patch_location_hint(content: &str, old: &str) -> String {
let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else {
return String::new();
};
let mut candidates = content
.lines()
.enumerate()
.filter(|(_, line)| line.trim() == anchor)
.map(|(index, line)| (index + 1, line))
.collect::<Vec<_>>();
if candidates.is_empty() {
let token = anchor.split_whitespace().find(|token| token.len() >= 3);
if let Some(token) = token {
candidates = content
.lines()
.enumerate()
.filter(|(_, line)| line.trim().contains(token))
.map(|(index, line)| (index + 1, line))
.collect();
}
}
if candidates.is_empty() {
return format!(
";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)",
content.lines().count()
);
}
let details = candidates
.iter()
.take(2)
.map(|(line, text)| format!("{line} 行:{}", design_patch_visible_line(text)))
.collect::<Vec<_>>()
.join("");
let suffix = if candidates.len() > 2 {
format!("{}", candidates.len())
} else {
String::new()
};
format!(";old_text 首个非空行可能对应 {details}{suffix}tab 显示为 \\t")
}
pub(crate) fn list_design_workspace_files(
root: &Path,
) -> Result<Vec<DesignWorkspaceEntry>, String> {
@@ -693,6 +783,22 @@ mod tests {
)
.expect_err("escape");
assert!(escaped.contains("路径"));
let mismatch = execute_design_file_tool(
root,
"patch_file",
&json!({
"path":"notes/design.md",
"edits":[
{"old_text":" 游戏设计","new_text":"游戏概念"},
{"old_text":"设计","new_text":"方案"}
]
}),
)
.expect_err("report all patch failures");
assert!(mismatch.contains("edits[0] 原文未找到"));
assert!(mismatch.contains("第 1 行:游戏设计"));
assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配"));
assert!(mismatch.contains("本次未写入文件"));
let patched = execute_design_file_tool(
root,
"patch_file",
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&state.root, "asset.register")?;
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
@@ -1896,6 +1896,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
"assetName",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
)?;
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
let manifest = read_existing_manifest_for_project(&state.root)?;
let source_asset = manifest
.assets
@@ -1920,22 +1922,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
.map_err(|_| "创建抠图服务连接失败".to_string())?;
let context =
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
let fingerprint = background_removal_request_fingerprint(
&source_asset_id,
&asset_name,
background_mode,
screen_color,
);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals";
let mut request_body = json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
});
if background_mode == Some("flat") {
request_body["backgroundMode"] = json!("flat");
}
if let Some(color) = screen_color {
request_body["screenColor"] = json!(color);
}
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
})),
.json(&request_body),
)
.send()
.await
@@ -1972,6 +1986,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
}
}
fn background_removal_request_fingerprint(
source: &str,
name: &str,
mode: Option<&str>,
color: Option<&str>,
) -> String {
let mode = mode.unwrap_or("complex");
if mode == "complex" && color.is_none() {
format!("{source}\0{name}")
} else {
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
}
}
fn bridge_safe_queue_state(value: Value) -> Value {
let object = value.as_object();
json!({
@@ -2703,6 +2731,29 @@ pub(crate) async fn start_direct_tool_bridge(
#[cfg(test)]
mod tests {
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", None, None),
legacy
);
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
legacy
);
let mut identities = std::collections::HashSet::new();
identities.insert(legacy.to_string());
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
let id =
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
assert_eq!(
id,
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
);
assert!(identities.insert(id));
}
}
use super::*;
use std::io::{Cursor, Read, Write};
@@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
}),
json!({
"name": "agc_remove_background",
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
"description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。",
"inputSchema": {
"type": "object",
"properties": {
@@ -449,6 +449,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string",
"minLength": 1,
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
},
"backgroundMode": {
"type": "string",
"enum": ["complex", "flat"],
"description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex"
},
"screenColor": {
"type": "string",
"pattern": "^(auto|#[0-9A-Fa-f]{6})$",
"description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测"
}
},
"required": ["sourceLocalAssetId", "assetName"],
@@ -881,14 +891,46 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
}
}
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
validate_tool_object_fields(
arguments,
&[
"sourceLocalAssetId",
"assetName",
"backgroundMode",
"screenColor",
],
)?;
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
bounded_tool_string(
arguments,
"assetName",
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
)?;
if let Some(mode) = arguments.get("backgroundMode") {
let mode = mode
.as_str()
.ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?;
if mode != "complex" && mode != "flat" {
return Err("backgroundMode 必须是 complex 或 flat".to_string());
}
}
if let Some(color) = arguments.get("screenColor") {
let color = color
.as_str()
.ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?;
let valid_hex = color.len() == 7
&& color.starts_with('#')
&& color[1..]
.chars()
.all(|character| character.is_ascii_hexdigit());
if color != "auto" && !valid_hex {
return Err("screenColor 必须是 auto 或 #RRGGBB".to_string());
}
if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") {
return Err("complex 模式不能传 screenColor".to_string());
}
}
Ok(())
}
@@ -1849,6 +1891,51 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn remove_background_arguments_enforce_mode_color_contract() {
for fields in [
json!({}),
json!({"backgroundMode":"complex"}),
json!({"backgroundMode":"flat"}),
json!({"backgroundMode":"flat","screenColor":"auto"}),
json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}),
] {
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
arguments
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
assert!(
validate_remove_background_arguments(&arguments).is_ok(),
"{fields}"
);
}
for fields in [
json!({"screenColor":"auto"}),
json!({"backgroundMode":"complex","screenColor":"auto"}),
json!({"backgroundMode":"flat","screenColor":""}),
json!({"backgroundMode":"flat","screenColor":" auto "}),
json!({"backgroundMode":"flat","screenColor":"AUTO"}),
json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}),
json!({"backgroundMode":"flat","screenColor":null}),
json!({"backgroundMode":"flat","screenColor":12}),
json!({"backgroundMode":""}),
json!({"backgroundMode":"FLAT"}),
json!({"backgroundMode":" flat "}),
json!({"backgroundMode":null}),
] {
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
arguments
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
assert!(
validate_remove_background_arguments(&arguments).is_err(),
"{fields}"
);
}
}
#[cfg(all(windows, feature = "cocos-editor-execute"))]
#[test]
fn builtin_mcp_process_probe() {
@@ -284,7 +284,6 @@ mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod template_library;
mod tool_plan_handoff;
mod user_input;
mod windows;
@@ -324,7 +323,6 @@ use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use template_library::*;
use user_input::*;
use windows::*;
#[tauri::command]
@@ -2662,10 +2660,7 @@ fn main() {
start_game_creator_external_mcp,
stop_game_creator_external_mcp,
create_automatic_local_game_project,
create_automatic_local_game_project_from_template,
init_local_game_project,
fetch_game_template_library,
download_game_template,
import_local_godot_project,
import_local_cocos_project,
is_local_project_directory_non_empty,
File diff suppressed because it is too large Load Diff
@@ -24,8 +24,8 @@
}
],
"security": {
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
}
},
"bundle": {
@@ -1,133 +0,0 @@
{
"schemaVersion": "agc-template-library.v1",
"library": "agc-game-templates",
"libraryVersion": 1,
"updatedAt": "2026-09-17T03:22:43Z",
"templates": [
{
"id": "blank-2d-canvas",
"title": "空白二维画布工程",
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
"tags": [
"空白",
"起步工程",
"2d",
"canvas"
],
"runtime": "html",
"engine": "canvas",
"engineVersion": "",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-2d-canvas/template.zip",
"zipSizeBytes": 1534,
"zipSha256": "ff8f84e4793941acaf161738c2795f65c5d5390de8614f51aa9e3a5771767134",
"coverKey": "templates/v1/blank-2d-canvas/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "afb753dc6d3de0f9fb6e03ec94f2be7221dd04c9d4ab3cf92311879f0af25192",
"metadataKey": "templates/v1/blank-2d-canvas/template.json"
},
{
"id": "blank-3d-scene",
"title": "空白三维场景工程",
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
"tags": [
"空白",
"起步工程",
"3d",
"three.js"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-3d-scene/template.zip",
"zipSizeBytes": 1644,
"zipSha256": "f3f295f4e5adcf1445d75229dc1b583376a9bc96d3f27a257d69ee3b7cace892",
"coverKey": "templates/v1/blank-3d-scene/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "1429232adaf6df4457e45b4fc8d7ee9fab2e6bffce9f3f0016e91b81bb66c6a7",
"metadataKey": "templates/v1/blank-3d-scene/template.json"
},
{
"id": "blank-web",
"title": "空白网页工程",
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
"tags": [
"空白",
"起步工程",
"网页",
"原生"
],
"runtime": "html",
"engine": "none",
"engineVersion": "",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-web/template.zip",
"zipSizeBytes": 1212,
"zipSha256": "6fa4391f30342e8dcbdcf735f990d2534ea50405f119e4fa5879b83e8f00119e",
"coverKey": "templates/v1/blank-web/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
"metadataKey": "templates/v1/blank-web/template.json"
},
{
"id": "phaser-2d-starter",
"title": "Phaser 2D 起步工程",
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
"tags": [
"起步工程",
"2d",
"phaser",
"像素"
],
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/phaser-2d-starter/template.zip",
"zipSizeBytes": 8770,
"zipSha256": "9026856c3c0b3a42401172e36ce8b450a65e9f11eb9096624d8990d51449d8ce",
"coverKey": "templates/v1/phaser-2d-starter/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "fdb422027bf54bf755b2b91cd21fa10fdd7ce3f5c7e3ccd9ac3ffba602b12b96",
"metadataKey": "templates/v1/phaser-2d-starter/template.json"
},
{
"id": "threejs-3d-starter",
"title": "Three.js 3D 起步工程",
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
"tags": [
"起步工程",
"3d",
"three.js",
"网页"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/threejs-3d-starter/template.zip",
"zipSizeBytes": 1697,
"zipSha256": "03096152b17cd6d55e7f6ccd518485136133cb54fd2a5a5d0ac8e0974149155c",
"coverKey": "templates/v1/threejs-3d-starter/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "7ba013e8a8b515d7aff7146fe401afe5ba3416b9c69db181179705e1bce01beb",
"metadataKey": "templates/v1/threejs-3d-starter/template.json"
}
]
}
@@ -38,11 +38,16 @@ export function useDirectActiveTurns({
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
const mountedRef = useRef(true);
const inFlightRef = useRef<Promise<void> | null>(null);
const retryTimerRef = useRef<number | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (retryTimerRef.current !== null) {
window.clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
}, []);
@@ -73,12 +78,12 @@ export function useDirectActiveTurns({
return;
} catch {
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
await new Promise((resolve) =>
window.setTimeout(
resolve,
DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt,
),
);
await new Promise<void>((resolve) => {
retryTimerRef.current = window.setTimeout(() => {
retryTimerRef.current = null;
resolve();
}, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt);
});
}
}
}
@@ -25,10 +25,8 @@ import {
type ProjectManifestSnapshotSource,
rereadAuthoritativeProjectManifestSnapshot,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import TemplateLibraryView from '../../view/template-library';
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
DeveloperAgentDialogs,
@@ -85,11 +83,6 @@ export function WorkspaceLauncherShell({
setAgentChatProjectPath: developerAgent.setAgentChatProjectPath,
rememberRecentWorkspace,
});
const templateLibrary = useTemplateLibrary({
onProjectCreated: async (result) => {
await homeProject.enterCreatedTemplateProject(result);
},
});
const {
projectPath,
setProjectPath,
@@ -574,13 +567,6 @@ export function WorkspaceLauncherShell({
void openProject(path, 'open');
}}
onProjectPick={() => void homeProject.pickAndOpenProject()}
templateRecommendations={templateLibrary.templates}
templateLibraryLoading={
templateLibrary.status === 'loading' ||
templateLibrary.status === 'idle'
}
templateLibraryError={templateLibrary.error}
onTemplateLibraryOpen={() => setLauncherView('template-library')}
/>
) : launcherView === 'projects' ? (
<ProjectsPage
@@ -588,11 +574,6 @@ export function WorkspaceLauncherShell({
homeProject={homeProject}
recentProjects={recentProjects}
/>
) : launcherView === 'template-library' ? (
<TemplateLibraryView
controller={templateLibrary}
onBack={() => setLauncherView('home')}
/>
) : launcherView === 'agent-chat' ? (
<DeveloperAgentPanel
controller={developerAgent}
@@ -555,35 +555,6 @@ export function useHomeProjectCreation({
}
}
/**
* 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘,
* 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。
*/
async function enterCreatedTemplateProject(result: InitLocalProjectResult) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
});
}
async function openProject(nextProjectPath: string, mode: 'open' | 'create') {
if (mode === 'create') {
await createProjectFromProjectPage(nextProjectPath);
@@ -1034,7 +1005,6 @@ export function useHomeProjectCreation({
renameProject,
pickAndOpenProject,
pickAndCreateProject,
enterCreatedTemplateProject,
confirmCreateInNonEmptyFolder,
cancelCreateInNonEmptyFolder,
};
@@ -1,208 +0,0 @@
/**
* AGC 模板库的前端模型:清单类型、搜索与筛选的纯函数。
*
* 真源在 OSS 清单与 Rust 侧(`fetch_game_template_library`);这里只做展示层派生,
* 不缓存业务真相,也不拼远端地址(URL 由 Rust 侧按受信任 OSS 前缀给出)。
*/
export type GameTemplateLibrarySource = 'network' | 'cache';
export type GameTemplateEntry = {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
updatedAt: string;
entry: string;
zipUrl: string;
zipSizeBytes: number;
zipSha256: string;
coverUrl: string;
coverWidth: number;
coverHeight: number;
installed: boolean;
installedVersion: string | null;
installedAtMillis: number | null;
};
export type GameTemplateLibrarySnapshot = {
schemaVersion: string;
library: string;
libraryVersion: number;
updatedAt: string;
fetchedAtMillis: number;
source: GameTemplateLibrarySource;
templates: GameTemplateEntry[];
};
export type InstalledGameTemplate = {
templateId: string;
templateVersion: string;
installedAtMillis: number;
zipSha256: string;
fileCount: number;
projectDir: string;
};
export type TemplateLibraryFilters = {
query: string;
tags: readonly string[];
runtime: string;
installedOnly: boolean;
};
export const EMPTY_TEMPLATE_LIBRARY_FILTERS: TemplateLibraryFilters = {
query: '',
tags: [],
runtime: '',
installedOnly: false,
};
const RUNTIME_LABELS: Record<string, string> = {
html: '网页',
unity: 'Unity',
godot: 'Godot',
cocos: 'Cocos',
};
export function templateRuntimeLabel(runtime: string): string {
const normalized = runtime.trim().toLowerCase();
if (!normalized) return '未标注运行时';
return RUNTIME_LABELS[normalized] ?? runtime.trim();
}
/**
* 空白分隔的多个关键词之间是「与」关系:每个词都必须命中标题、简介、标签或引擎,
* 这样「三消 像素」不会退化成命中任意一个就出现的宽泛搜索。
*/
export function templateMatchesQuery(
template: GameTemplateEntry,
query: string,
): boolean {
const terms = query
.toLowerCase()
.split(/\s+/u)
.filter((term) => term.length > 0);
if (terms.length === 0) {
return true;
}
const haystack = [
template.title,
template.summary,
template.engine,
template.runtime,
template.tags.join(' '),
]
.join(' ')
.toLowerCase();
return terms.every((term) => haystack.includes(term));
}
export function filterGameTemplates(
templates: readonly GameTemplateEntry[],
filters: TemplateLibraryFilters,
): GameTemplateEntry[] {
const selectedTags = filters.tags
.map((tag) => tag.trim().toLowerCase())
.filter((tag) => tag.length > 0);
const runtime = filters.runtime.trim().toLowerCase();
return templates.filter((template) => {
if (filters.installedOnly && !template.installed) {
return false;
}
if (runtime && template.runtime.trim().toLowerCase() !== runtime) {
return false;
}
if (selectedTags.length > 0) {
const templateTags = template.tags.map((tag) => tag.toLowerCase());
if (!selectedTags.some((tag) => templateTags.includes(tag))) {
return false;
}
}
return templateMatchesQuery(template, filters.query);
});
}
/** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */
export function collectGameTemplateTags(
templates: readonly GameTemplateEntry[],
): string[] {
const counts = new Map<string, number>();
for (const template of templates) {
for (const tag of template.tags) {
const trimmed = tag.trim();
if (!trimmed) continue;
counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
}
}
return [...counts.entries()]
.sort(
([leftTag, leftCount], [rightTag, rightCount]) =>
rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'),
)
.map(([tag]) => tag);
}
export function collectGameTemplateRuntimes(
templates: readonly GameTemplateEntry[],
): string[] {
const runtimes = new Set<string>();
for (const template of templates) {
const runtime = template.runtime.trim().toLowerCase();
if (runtime) runtimes.add(runtime);
}
return [...runtimes].sort((left, right) =>
left.localeCompare(right, 'zh-CN'),
);
}
export function formatGameTemplateSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '--';
}
if (bytes < 1024) {
return `${Math.round(bytes)} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function isTemplateLibraryFiltersEmpty(
filters: TemplateLibraryFilters,
): boolean {
return (
!filters.query.trim() &&
filters.tags.length === 0 &&
!filters.runtime.trim() &&
!filters.installedOnly
);
}
export function toggleGameTemplateTag(
filters: TemplateLibraryFilters,
tag: string,
): TemplateLibraryFilters {
const exists = filters.tags.includes(tag);
return {
...filters,
tags: exists
? filters.tags.filter((value) => value !== tag)
: [...filters.tags, tag],
};
}
/**
* 已安装版本低于清单版本时必须重新下载;已安装且版本一致才算可直接使用。
*/
export function needsTemplateDownload(template: GameTemplateEntry): boolean {
return (
!template.installed ||
template.installedVersion !== template.templateVersion
);
}
@@ -1,243 +0,0 @@
/**
* 模板库状态链路:拉取清单、下载模板、用模板建项目。
*
* 远端真相全在 Rust 侧命令里(受信任 OSS 前缀 + 摘要校验 + 本机安装记录);
* 这里只维护界面状态,并在下载成功后把对应条目的安装状态就地更新,
* 避免为了一个"已下载"徽标再打一次清单请求。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { InitLocalProjectResult } from '../../app/types';
import {
collectGameTemplateRuntimes,
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
type GameTemplateEntry,
type GameTemplateLibrarySnapshot,
type InstalledGameTemplate,
isTemplateLibraryFiltersEmpty,
needsTemplateDownload,
type TemplateLibraryFilters,
toggleGameTemplateTag,
} from './templateLibraryModel';
export type TemplateLibraryStatus = 'idle' | 'loading' | 'ready' | 'error';
export type TemplateLibraryBusyKind = 'download' | 'create';
type UseTemplateLibraryOptions = {
/** 项目已建好:由调用方负责进入项目工作区(模板库不碰工作区状态)。 */
onProjectCreated: (result: InitLocalProjectResult) => Promise<void> | void;
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function useTemplateLibrary({
onProjectCreated,
}: UseTemplateLibraryOptions) {
const [snapshot, setSnapshot] = useState<GameTemplateLibrarySnapshot | null>(
null,
);
const [status, setStatus] = useState<TemplateLibraryStatus>('idle');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [filters, setFilters] = useState<TemplateLibraryFilters>(
EMPTY_TEMPLATE_LIBRARY_FILTERS,
);
const [busyTemplateId, setBusyTemplateId] = useState<string | null>(null);
const [busyKind, setBusyKind] = useState<TemplateLibraryBusyKind | null>(
null,
);
const loadingRef = useRef(false);
const refresh = useCallback(async () => {
if (loadingRef.current) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('error');
setError('需要在陶泥儿客户端内运行');
return;
}
loadingRef.current = true;
setStatus('loading');
setError('');
try {
const next = await invoke<GameTemplateLibrarySnapshot>(
'fetch_game_template_library',
);
setSnapshot(next);
setStatus('ready');
setNotice(
next.source === 'cache' ? '远端清单暂时读不到,当前展示本机缓存' : '',
);
} catch (nextError) {
setStatus('error');
setError(errorMessage(nextError));
} finally {
loadingRef.current = false;
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const downloadTemplate = useCallback(async (template: GameTemplateEntry) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
setBusyTemplateId(template.id);
setBusyKind('download');
setError('');
try {
const installed = await invoke<InstalledGameTemplate>(
'download_game_template',
{
templateId: template.id,
templateVersion: template.templateVersion,
},
);
setSnapshot((current) =>
current
? {
...current,
templates: current.templates.map((entry) =>
entry.id === template.id
? {
...entry,
installed: true,
installedVersion: installed.templateVersion,
installedAtMillis: installed.installedAtMillis,
}
: entry,
),
}
: current,
);
setNotice(`已下载模板「${template.title}`);
return installed;
} catch (nextError) {
setError(errorMessage(nextError));
throw nextError;
} finally {
setBusyTemplateId(null);
setBusyKind(null);
}
}, []);
const createProjectFromTemplate = useCallback(
async (template: GameTemplateEntry) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
try {
if (needsTemplateDownload(template)) {
await downloadTemplate(template);
}
setBusyTemplateId(template.id);
setBusyKind('create');
setError('');
setNotice(`正在用模板「${template.title}」创建项目`);
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project_from_template',
{
templateId: template.id,
templateVersion: template.templateVersion,
name: null,
planning: false,
},
);
await onProjectCreated(result);
setNotice(`已用模板「${template.title}」创建项目`);
return result;
} catch (nextError) {
setError(errorMessage(nextError));
throw nextError;
} finally {
setBusyTemplateId(null);
setBusyKind(null);
}
},
[downloadTemplate, onProjectCreated],
);
const templates = useMemo(
() => snapshot?.templates ?? [],
[snapshot?.templates],
);
const visibleTemplates = useMemo(
() => filterGameTemplates(templates, filters),
[templates, filters],
);
const tagOptions = useMemo(
() => collectGameTemplateTags(templates),
[templates],
);
const runtimeOptions = useMemo(
() => collectGameTemplateRuntimes(templates),
[templates],
);
const installedCount = useMemo(
() => templates.filter((template) => template.installed).length,
[templates],
);
const filtersActive = !isTemplateLibraryFiltersEmpty(filters);
const setQuery = useCallback((query: string) => {
setFilters((current) => ({ ...current, query }));
}, []);
const selectRuntime = useCallback((runtime: string) => {
setFilters((current) => ({
...current,
runtime: current.runtime === runtime ? '' : runtime,
}));
}, []);
const toggleTag = useCallback((tag: string) => {
setFilters((current) => toggleGameTemplateTag(current, tag));
}, []);
const setInstalledOnly = useCallback((installedOnly: boolean) => {
setFilters((current) => ({ ...current, installedOnly }));
}, []);
const clearFilters = useCallback(() => {
setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS);
}, []);
return {
snapshot,
status,
error,
notice,
templates,
visibleTemplates,
tagOptions,
runtimeOptions,
installedCount,
filters,
filtersActive,
setQuery,
selectRuntime,
toggleTag,
setInstalledOnly,
clearFilters,
busyTemplateId,
busyKind,
refresh,
downloadTemplate,
createProjectFromTemplate,
clearNotice: useCallback(() => setNotice(''), []),
};
}
export type TemplateLibraryController = ReturnType<typeof useTemplateLibrary>;
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
// TODO: 后续改为由服务器下发灵感资源;届时保留此组件的展示与预览交互,移除本地目录扫描。
const INSPIRATION_IMAGES = Object.entries(
import.meta.glob('./assets/inspiration/*.{webp,png,jpg,jpeg}', {
eager: true,
import: 'default',
query: '?url',
}),
)
.sort(([left], [right]) =>
left.localeCompare(right, undefined, { numeric: true }),
)
.map(([, image]) => image as string);
export default function InspirationGallery() {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
useEffect(() => {
if (!selectedImage) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setSelectedImage(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [selectedImage]);
return (
<>
<div className="columns-3 gap-3 max-[760px]:columns-2 max-[460px]:columns-1">
{INSPIRATION_IMAGES.map((image, index) => (
<button
className="mb-3 block w-full cursor-zoom-in break-inside-avoid overflow-hidden rounded-lg border-0 bg-transparent p-0 shadow-(--platform-panel-shadow) transition duration-200 hover:-translate-y-0.5 hover:shadow-(--platform-profile-action-shadow) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--platform-warm-text)"
type="button"
key={image}
aria-label={`查看灵感图片 ${index + 1}`}
onClick={() => setSelectedImage(image)}
>
<img
className="block h-auto w-full"
src={image}
alt=""
loading="lazy"
decoding="async"
/>
</button>
))}
</div>
{selectedImage
? createPortal(
<div
className="fixed inset-0 z-[1000] grid cursor-zoom-out place-items-center bg-black/80 p-6"
role="dialog"
aria-modal="true"
aria-label="查看灵感图片"
onClick={() => setSelectedImage(null)}
>
<img
className="block max-h-[92vh] max-w-[92vw] cursor-default object-contain shadow-2xl"
src={selectedImage}
alt="放大的灵感图片"
onClick={(event) => event.stopPropagation()}
/>
</div>,
document.body,
)
: null}
</>
);
}
@@ -1,96 +0,0 @@
import { BadgeCheck, Loader2, Package } from 'lucide-react';
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel';
type TemplateRecommendationsProps = {
templates: readonly GameTemplateEntry[];
loading: boolean;
error: string;
onOpenLibrary: () => void;
};
const RECOMMENDATION_LIMIT = 6;
/**
* 首页模板推荐:只做展示与跳转,下载与建项目都在模板库页面里完成,
* 避免首页的卡片点击直接产生项目副作用。
*/
export default function TemplateRecommendations({
templates,
loading,
error,
onOpenLibrary,
}: TemplateRecommendationsProps) {
if (loading && templates.length === 0) {
return (
<div className="flex items-center gap-2 py-6 text-[12px] text-(--platform-text-soft)">
<Loader2 className="animate-spin" size={14} aria-hidden="true" />
</div>
);
}
if (templates.length === 0) {
return (
<div className="grid justify-items-center gap-2 py-6">
<Package
className="text-(--platform-icon-text)"
size={22}
aria-hidden="true"
/>
<span className="text-[12px] text-(--platform-text-soft)">
{error || '模板库暂时没有可用的模板'}
</span>
<button
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
type="button"
onClick={onOpenLibrary}
>
</button>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-3 max-[760px]:grid-cols-2 max-[460px]:grid-cols-1">
{templates.slice(0, RECOMMENDATION_LIMIT).map((template) => (
<button
className="grid cursor-pointer content-start overflow-hidden rounded-lg border border-(--platform-subpanel-border) bg-transparent p-0 text-left shadow-(--platform-panel-shadow) transition duration-200 hover:-translate-y-0.5 hover:shadow-(--platform-profile-action-shadow) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--platform-warm-text)"
type="button"
key={template.id}
aria-label={`查看模板 ${template.title}`}
data-template-id={template.id}
onClick={onOpenLibrary}
>
<span className="relative block aspect-video w-full overflow-hidden bg-black/20">
<img
className="block h-full w-full object-cover"
src={template.coverUrl}
alt=""
loading="lazy"
decoding="async"
/>
{template.installed ? (
<span className="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] text-white">
<BadgeCheck size={11} aria-hidden="true" />
</span>
) : null}
</span>
<span className="grid gap-1 p-2.5">
<strong className="truncate text-[12px] text-(--platform-text-strong)">
{template.title}
</strong>
<span className="truncate text-[10px] text-(--platform-text-soft)">
{[templateRuntimeLabel(template.runtime), template.engine]
.filter((value) => value.trim())
.join(' · ')}
</span>
</span>
</button>
))}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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