Compare commits

..

39 Commits

Author SHA1 Message Date
k88936 edfd1f9143 修正 Godot 直接回合用例对 canonical content 的断言
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 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 4/4 (pull_request) Has been cancelled
- appSurface Godot 用例不再断言被裁剪过的纯文本,改为断言编辑器实际提交的 canonical content(Shift+Enter 与组合态 Enter 插入的换行原样保留)
2026-09-16 18:51:08 +08:00
k88936 423f2d7184 修复独立总控聊天窗口未发送草稿的恢复
- App.tsx 增加 supervisorChatDraftHydratedRef 守卫的挂载水合:独立总控聊天窗口挂载走 loadProjectConversation,不经过 openWorkspace,此前 sessionStorage 里的草稿只写不读,重载后为空
- 仅在 supervisorChatOnly、存在持久化草稿且输入框已挂载时回填一次,避免覆盖用户后续输入
2026-09-16 18:50:55 +08:00
k88936 aa9076c95c 删除 DirectProject 图片专用 content part
- 移除 DirectCodexUserContentPart 的 agc_image_reference 分支与对应 ts-rs 绑定
- Rust 校验与 Codex wire 投影只保留 agc_attachment_reference
- 更新附件顺序投影测试的用例名、输入与断言
- 同步聊天素材功能说明、用户 Response item 里程碑与实施计划
- 在决策日志记录上传文件统一走附件 part、不保留图片兼容分支
2026-09-16 18:29:24 +08:00
k88936 41366dd71d 修复DirectProject canonical内容边界(review 第 8 条)
- 移除 chatComposerDraftToDirectCodexUserItem 对纯空白 input_text 的预过滤,原样传递 draft.content
- ChatComposerDraft 只保留 canonical content,删除 text / references 旧字段
- 新增 directCodexContentToPromptText、hasMeaningfulDirectCodexContent、directCodexUserItemFromContent 供 caller 与展示派生
- 删除 executeChatAgentReply 的 userItem 兜底分支,首页首轮、队列出队、普通提交与策略重试显式构造 canonical user item
- QueuedChatTurn 只保存 clientTurnId 与 userItem,队列 chip 文案由 content 派生
- 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO
- 资源输入区对外只暴露 canonical content,内部文本草稿改为文本 → content 重建
- 迁移草稿、润色、队列与 appSurface 测试 fixture 到 content-only
- 更新 canonical content 里程碑与实施计划文档结论
2026-09-16 17:46:41 +08:00
k88936 f4b496caca 补充DirectProject canonical内容严格边界计划
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
- 明确 content-only 输入与最终空内容校验

- 规划删除 userItem 兜底并修正 Direct caller
2026-09-16 15:28:25 +08:00
k88936 fc0fd846f9 避免快速编辑润色回填重复更新
- 回填前比较编辑器当前文本

- 防止输入区 onChange 与宿主回调形成循环
2026-09-16 13:22:57 +08:00
k88936 fa99d9ad62 避免总控草稿初始化误删
- 编辑器尚未恢复时跳过空草稿持久化

- 保留已有会话草稿等待输入区完成恢复
2026-09-16 13:20:47 +08:00
k88936 64a52005f8 修复清空草稿后润色状态残留
- 在编辑器发出空草稿时重置润色结果

- 清除本轮发送提醒确认标记
2026-09-16 13:20:01 +08:00
k88936 645d806fed 修复空初始草稿吞掉首次编辑
- 仅在初始内容实际非空时跳过初始化回调

- 保证快速编辑首次输入同步到宿主状态
2026-09-16 13:19:13 +08:00
k88936 0b9d16e8f1 修复润色按钮无法感知输入
- 用 React 状态同步编辑器当前文本

- 让按钮禁用态随用户输入即时更新
2026-09-16 13:18:31 +08:00
k88936 d5c4e0e780 修复快速编辑润色结果未回填
- 为快速编辑输入区绑定编辑器句柄

- 润色回调同时更新面板状态与编辑器文本
2026-09-16 13:17:50 +08:00
k88936 30e27abc60 修复建议命令草稿残留引用
- 准备命令草稿前先清空编辑器

- 避免提交用户上一轮选择的素材引用
2026-09-16 13:16:07 +08:00
k88936 b607501916 修复总控草稿恢复后被清空
- 仅在非纯聊天模式清空聊天编辑器

- 保留纯聊天模式恢复的会话草稿
2026-09-16 13:15:08 +08:00
k88936 46e3cd9744 内联 DirectProject 附件与图片内容
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m30s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m54s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m1s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m41s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m41s
Project CI / Native shell tests (pull_request) Successful in 6m9s
新增附件与图片 canonical content part 及 ts-rs 绑定

移除 DirectProject Tauri 附件 sidecar 追加路径

修正前端引用字段与生成类型适配

补充附件图片顺序投影测试并同步里程碑文档
2026-09-16 13:03:11 +08:00
k88936 5fb3db662b 合并主线并保留 DirectProject 输入行为
合并主线回合队列、工具流展示与活动回合管理

保留 Lexical 单一输入状态及附件图片 sidecar 投影

收敛 DirectProject user item 校验、历史恢复与图片预算

同步输入链路里程碑和历史异常恢复文档
2026-09-16 11:27:09 +08:00
k88936 87b798322e 收敛输入编辑器单一状态并补齐附件投影
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m6s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 1m49s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 1m56s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m2s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m13s
Project CI / Native shell tests (pull_request) Successful in 7m14s
聊天与快速编辑输入统一由 Lexical 编辑器持有唯一当前状态

App 通过输入句柄读取草稿,移除跨视图 EditorState 和字符串镜像

DirectProject 将附件与图片 sidecar 投影纳入 prompt,支持附件-only 输入

迁移润色测试并更新 DirectProject 里程碑验收记录
2026-09-15 22:13:25 +08:00
k88936 ec286b3480 抽取通用富文本输入组件
新增可复用的 RichTextInput Lexical 输入外壳。

让首页输入与资源引用输入共享编辑器、回车提交和禁用状态。

保留首页附件与图片粘贴节点能力,并移除 DirectProject 草稿 legacy fallback。

统一聊天草稿 canonical content 状态并更新资源引用测试。
2026-09-15 19:42:37 +08:00
k88936 35733f0e33 过滤 canonical 草稿中的空白输入 part
在统一 user item 转换入口移除纯空白 input_text,覆盖恢复草稿和所有提交路径。
2026-09-15 17:54:00 +08:00
k88936 3c7b02b9f8 过滤 DirectProject 编辑器的空白内容 part
跳过纯空白文本节点并合并相邻文本,避免引用后的分隔空格和换行触发 Rust 空输入校验。
2026-09-15 17:53:33 +08:00
k88936 f2030a616f 复用 DirectProject 用户输入校验读取的 manifest
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m17s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m17s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / Frontend tests (pull_request) Failing after 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m51s
Project CI / Native shell tests (pull_request) Successful in 6m21s
让校验返回已读取的 manifest,wire 投影复用同一份数据,避免一次输入重复读取项目清单。
2026-09-15 17:33:49 +08:00
k88936 b3b5d77990 按运行模式创建 DirectProject 回合输入
仅在 DirectCodex 产品运行时生成 direct clientTurnId 和 user item,避免旧 Supervisor 与策划链路复用错误身份。
2026-09-15 17:32:46 +08:00
k88936 1da106af7d 避免重复校验 DirectProject 用户输入
复用 canonical 到 prompt 投影函数中的校验,删除不可达空 prompt 分支。
2026-09-15 17:29:08 +08:00
k88936 cd2edd6966 移除未使用的 DirectProject 输入信封类型
删除未被调用且未生成到前端绑定的 DirectCodexUserMessageEnvelope,收窄内部 API。
2026-09-15 17:27:42 +08:00
k88936 22e830ff93 避免 DirectProject 回合输入的无必要克隆
在互斥的观察器分支中移动 optional user item,避免深层 JSON 克隆。
2026-09-15 17:26:34 +08:00
k88936 c266ae7b50 清理策划模式提交后的聊天草稿
规划 V2 提交时同步清空资源引用和 canonical content,避免切回 DirectProject 后复用旧输入。
2026-09-15 17:25:15 +08:00
k88936 a543b75cf7 拆开聊天草稿内容分支判断
将资源草稿转换的三种回退路径改为显式分支,保持现有优先级并提升可读性。
2026-09-15 17:24:05 +08:00
k88936 f213987f9a 复用资源引用的统一内容映射
让 Lexical 草稿读取复用 chatReferenceToContentPart,避免引用字段映射分叉。
2026-09-15 17:22:55 +08:00
k88936 2c623bb577 移除 DirectProject 回合输入的重复赋值
删除已在对象初始化时设置的 userItem 重复赋值。
2026-09-15 17:19:17 +08:00
k88936 3353906e6f 修正 Response item content 类型投影
区分 turn/start 的 text 与历史 message 的 input_text

将 AGC 私有引用转换为标准 input_text

新增回归测试避免未知 variant text
2026-09-15 16:57:04 +08:00
k88936 17716347e2 收紧历史测试与注释口径
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m17s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m43s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 7m29s
移除 legacy 投影描述

将历史测试统一为 Response item 合同
2026-09-15 16:16:03 +08:00
k88936 9ad66a67a3 接入前端 canonical 用户 Response item
按 Lexical 顺序构造 message.content

DirectProject payload 改为发送 userItem

策略重试保留同一 canonical item
2026-09-15 16:12:56 +08:00
k88936 ba3aa3ccdb 更新用户 Response item 里程碑进度
标记 canonical、wire 与失败关闭验收项

同步实施计划为进行中状态
2026-09-15 16:09:28 +08:00
k88936 2b38eaafba 补充前端用户 Response item 契约测试
覆盖 Lexical 文本与引用交错顺序

验证资源引用只发送稳定 resourceId
2026-09-15 16:08:34 +08:00
k88936 7d5b9071e7 删除旧 DirectProject 引用协议
移除 DirectCodexTurnReference 平行 DTO 与渲染路径

将引用数量上限收回 canonical user item 校验模块
2026-09-15 16:01:45 +08:00
k88936 9e83f1d88c 补齐 Response item 严格合同测试
拒绝资源引用额外身份字段与未知 content part

补充历史 item wire 投影边界测试

同步 DirectProject 历史文档为无 fallback 合同
2026-09-15 15:55:15 +08:00
k88936 7e35d7c344 严格使用 DirectProject Response item 历史
移除 game-creator-conversation.v1 读取 fallback

历史注入前逐项投影 canonical user item 为 Codex message

无效 item 在持久化前失败关闭
2026-09-15 15:42:27 +08:00
k88936 5ec40c8b83 抽离 DirectProject 身份解析模块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m16s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m20s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m4s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 1m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m22s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m20s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Native shell tests (pull_request) Successful in 5m27s
将项目 canonical identity 与 digest 放入独立文件

保持 Codex app-server 线程池调用接口不变
2026-09-15 15:22:39 +08:00
k88936 948a80fc49 重构 DirectProject Agent 深模块
将 direct_runtime 与 codex_app_server 改为目录模块

拆分 user item 的模型、校验与 wire 投影文件

抽离 DirectProject 历史注入载荷构造 seam
2026-09-15 15:19:17 +08:00
k88936 dfd6fadedf 新增 DirectProject 用户 Response item 契约
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m36s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m31s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m46s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 4m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m34s
Project CI / Native shell tests (pull_request) Successful in 6m35s
新增 Rust canonical user item 与 Codex wire 转换模块

增加 ts-rs 绑定目录及生成文件检查边界

更新聊天素材引用规范与里程碑实施计划
2026-09-15 14:45:58 +08:00
122 changed files with 1903 additions and 5237 deletions
-4
View File
@@ -7,10 +7,6 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
group: project-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: permissions:
contents: read contents: read
-3
View File
@@ -1,4 +1 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run format:staged npm run format:staged
-3
View File
@@ -1,4 +1 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run check:pre-push-master -- "$@" npm run check:pre-push-master -- "$@"
@@ -38,7 +38,6 @@ vi.mock('../api/adminApiClient', () => ({
interface MockIntersectionObserverController { interface MockIntersectionObserverController {
enter: (target: Element) => void; enter: (target: Element) => void;
enterAll: (targets: Element[]) => void;
isObserved: (target: Element) => boolean; isObserved: (target: Element) => boolean;
} }
@@ -107,25 +106,6 @@ 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) { isObserved(target) {
return observed.has(target); return observed.has(target);
}, },
@@ -773,10 +753,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
const thumbnails = entries.map((entry) => const thumbnails = entries.map((entry) =>
thumbnailElementForLabel(entry.label), thumbnailElementForLabel(entry.label),
); );
for (const thumbnail of thumbnails) { thumbnails.forEach((thumbnail) => {
expect(observer.isObserved(thumbnail)).toBe(true); expect(observer.isObserved(thumbnail)).toBe(true);
} observer.enter(thumbnail);
observer.enterAll(thumbnails); });
await act(async () => { await act(async () => {
await Promise.resolve(); await Promise.resolve();
}); });
@@ -796,7 +776,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
await vi.advanceTimersByTimeAsync(200); await vi.advanceTimersByTimeAsync(200);
}); });
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105); expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
}, 10_000); });
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => { test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
const observer = installIntersectionObserverMock(); const observer = installIntersectionObserverMock();
@@ -2,8 +2,6 @@
"schemaVersion": "game-creator-config.v2", "schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server", "agentMode": "codex_app_server",
"llm": { "llm": {
"customEnabled": false,
"visibleModels": [],
"apiKey": "", "apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1", "baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra", "model": "gpt-6-astra",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@genarrative/ai-game-creator-shell", "name": "@genarrative/ai-game-creator-shell",
"private": true, "private": true,
"version": "0.1.47", "version": "0.1.29",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node scripts/start-tauri-dev.mjs", "dev": "node scripts/start-tauri-dev.mjs",
+1 -1
View File
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "genarrative-ai-game-creator-shell" name = "genarrative-ai-game-creator-shell"
version = "0.1.47" version = "0.1.29"
dependencies = [ dependencies = [
"agent-runtime-core", "agent-runtime-core",
"axum", "axum",
@@ -1,6 +1,6 @@
[package] [package]
name = "genarrative-ai-game-creator-shell" name = "genarrative-ai-game-creator-shell"
version = "0.1.47" version = "0.1.29"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -2,7 +2,7 @@
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"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":"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":"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 必须非空且在原文件中唯一。所有 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":"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":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"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":"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}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
@@ -131,6 +131,7 @@ impl CodexAppServerCredential {
) -> Option<(&'a str, &'a str)> { ) -> Option<(&'a str, &'a str)> {
match self { match self {
Self::PlatformSession { .. } => None, Self::PlatformSession { .. } => None,
#[cfg(test)]
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
#[cfg(test)] #[cfg(test)]
@@ -138,7 +139,7 @@ impl CodexAppServerCredential {
.as_deref() .as_deref()
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)), .map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
#[cfg(not(test))] #[cfg(not(test))]
Self::AuthBridge { .. } => None, Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
} }
} }
} }
@@ -195,20 +196,6 @@ impl CodexAppServerStderrSummary {
} }
} }
/// 派发 app-server 的收尾与中断任务。
///
/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn`
/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程);
/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。
/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic"there is no reactor
/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409`
/// FAST_FAIL_FATAL_APP_EXITabort——现场就是"点终止,App 闪退"2026-09-16 的 WER
/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`fail-fast
/// 参数 `7`)。一律走 Tauri 的全局异步 runtime`main` 已把深栈 runtime 装进去。
fn spawn_codex_app_server_task(task: impl std::future::Future<Output = ()> + Send + 'static) {
tauri::async_runtime::spawn(task);
}
struct CodexTurnStartCancellation { struct CodexTurnStartCancellation {
inner: Weak<CodexAppServerInner>, inner: Weak<CodexAppServerInner>,
thread_id: String, thread_id: String,
@@ -270,7 +257,7 @@ impl CodexTurnStartCancellation {
}; };
let connection = CodexAppServerConnection { inner }; let connection = CodexAppServerConnection { inner };
let thread_id = self.thread_id.clone(); let thread_id = self.thread_id.clone();
spawn_codex_app_server_task(async move { tokio::spawn(async move {
let _ = connection let _ = connection
.request( .request(
"turn/interrupt", "turn/interrupt",
@@ -2031,13 +2018,7 @@ impl CodexAppServerConnection {
let codex_cli_version = game_creator_codex_cli_version_identity() let codex_cli_version = game_creator_codex_cli_version_identity()
.map_err(platform_llm::LlmError::InvalidConfig)?; .map_err(platform_llm::LlmError::InvalidConfig)?;
let mut effective_llm = llm.clone(); let mut effective_llm = llm.clone();
let credential = if llm.custom_enabled { let credential = if game_creator_official_llm_route_locked() {
crate::config::validate_custom_llm_connection(llm)
.map_err(platform_llm::LlmError::InvalidConfig)?;
CodexAppServerCredential::AppDataKey {
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
}
} else if game_creator_official_llm_route_locked() {
let session = current_platform_session().ok_or_else(|| { let session = current_platform_session().ok_or_else(|| {
platform_llm::LlmError::InvalidConfig( platform_llm::LlmError::InvalidConfig(
"authentication-required: 请先登录陶泥儿账号".to_string(), "authentication-required: 请先登录陶泥儿账号".to_string(),
@@ -2205,8 +2186,7 @@ impl CodexAppServerConnection {
true, true,
), ),
_ => ( _ => (
(llm.custom_enabled (workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.then(|| credential.direct_provider_route(llm)) .then(|| credential.direct_provider_route(llm))
.flatten() .flatten()
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
@@ -3580,7 +3560,7 @@ impl Drop for CodexThreadLease {
let connection = self.connection.clone(); let connection = self.connection.clone();
let key = self.key.clone(); let key = self.key.clone();
let thread_id = self.thread_id.clone(); let thread_id = self.thread_id.clone();
spawn_codex_app_server_task(async move { tokio::spawn(async move {
let mut threads = connection.inner.threads.lock().await; let mut threads = connection.inner.threads.lock().await;
if let Some(entry) = threads.get_mut(&key) { if let Some(entry) = threads.get_mut(&key) {
if entry.thread_id == thread_id { if entry.thread_id == thread_id {
@@ -3607,7 +3587,7 @@ impl Drop for CodexTurnGuard {
let connection = self.connection.clone(); let connection = self.connection.clone();
let thread_id = self.thread_id.clone(); let thread_id = self.thread_id.clone();
let turn_id = self.turn_id.clone(); let turn_id = self.turn_id.clone();
spawn_codex_app_server_task(async move { tokio::spawn(async move {
connection.inner.turns.lock().await.remove(&turn_id); connection.inner.turns.lock().await.remove(&turn_id);
connection.inner.turn_backlog.lock().await.remove(&turn_id); connection.inner.turn_backlog.lock().await.remove(&turn_id);
let _ = connection let _ = connection
@@ -4503,19 +4483,6 @@ mod tests {
assert!(table.select(&key, None).is_err()); assert!(table.select(&key, None).is_err());
} }
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
/// runtime 上下文。`tokio::spawn` 在那里 panicpanic 跨不过 IPC 回调边界就把整个
/// 进程 abort0xC0000409"点终止就闪退")。这条用例把派发入口钉在没有 runtime
/// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。
#[test]
fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() {
let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {}));
assert!(
joined.join().is_ok(),
"没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务"
);
}
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上 /// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。 /// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
#[test] #[test]
@@ -4776,8 +4743,6 @@ mod tests {
fn test_llm() -> GameCreatorLlmConfig { fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig { GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "fixture-secret".to_string(), api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(), base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(), model: "fixture-model".to_string(),
@@ -5563,59 +5528,6 @@ mod tests {
assert_ne!(command_token, provider_key); assert_ne!(command_token, provider_key);
} }
#[tokio::test]
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
let mut llm = test_llm();
llm.custom_enabled = true;
llm.api_key = "custom-upstream-fixture-secret".into();
llm.base_url = "http://127.0.0.1:9/v1".into();
llm.model = "vendor/model.v1:latest".into();
llm.visible_models = vec![llm.model.clone()];
let credential = CodexAppServerCredential::AppDataKey {
fingerprint: "custom-fixture".into(),
};
let (base, key) = credential
.direct_provider_route(&llm)
.expect("custom route");
assert_eq!(base, llm.base_url);
assert_eq!(key, llm.api_key);
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
for mode in [
CodexAppServerWorkspaceMode::DirectProject,
CodexAppServerWorkspaceMode::ToolHost,
] {
let mut command = tokio::process::Command::new("fixture");
configure_game_creator_codex_app_server_command_for_mode(
&mut command,
&llm,
mode,
Some(&proxy),
None,
true,
)
.unwrap();
let arguments = command
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join("\n");
let params = codex_app_server_thread_start_params(
&llm.model,
std::path::Path::new("fixture-workspace"),
mode,
String::new(),
true,
);
assert_eq!(params["model"], "vendor/model.v1:latest");
assert!(!arguments.contains(&llm.api_key));
assert!(!arguments.contains("/api/llm"));
for (_, value) in command.as_std().get_envs() {
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
}
}
}
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() { async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
@@ -527,6 +527,10 @@ fn process_design_batch(
let result = if uncertain { let result = if uncertain {
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string()) Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
} else { } else {
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"design.tool",
)?;
execute_design_tool(root, resources, session, &call) execute_design_tool(root, resources, session, &call)
}; };
let error = result let error = result
@@ -1022,15 +1026,6 @@ pub(crate) async fn continue_design_agent_at(
finish_design_command(root, resources, session, active, run, emit).await 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( pub(crate) async fn decide_design_phase_at(
root: &Path, root: &Path,
resources: &DesignResources, resources: &DesignResources,
@@ -1063,8 +1058,7 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn hydrate_design_agent_session( pub(crate) fn hydrate_design_agent_session(
app: tauri::AppHandle,
project_path: String, project_path: String,
) -> Result<Option<DesignView>, String> { ) -> Result<Option<DesignView>, String> {
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
@@ -1090,33 +1084,8 @@ pub(crate) async fn hydrate_design_agent_session(
if session.project_id != project_id { if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into()); return Err("策划会话与当前项目不匹配".into());
} }
let Some(active) = let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? Ok(Some(design_view(&session, active.is_none())))
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 { fn design_session_error_is_recoverable(error: &str) -> bool {
@@ -1989,94 +1958,4 @@ mod tests {
.any(|message| message.text.contains("重试后继续"))); .any(|message| message.text.contains("重试后继续")));
assert!(next.session.last_error.is_none()); 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,31 +321,15 @@ pub(crate) fn execute_design_file_tool(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut matches = Vec::new(); let mut matches = Vec::new();
let mut edit_errors = Vec::new();
let mut valid_edits = 0;
for (index, (old, new)) in normalized.iter().enumerate() { 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(); let count = content.matches(old).count();
if count == 0 { if count == 0 {
edit_errors.push(format!( return Err(format!("edits[{index}] 原文未找到:{display}"));
"edits[{index}] 原文未找到:{}{}",
display,
design_patch_location_hint(&content, old)
));
continue;
} }
if count != 1 { if count != 1 {
let start = content.find(old).expect("count checked"); return Err(format!(
let line = design_patch_line_number(&content, start); "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
edit_errors.push(format!(
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line}"
)); ));
continue;
} }
let start = content.find(old).expect("count checked"); let start = content.find(old).expect("count checked");
let end = start + old.len(); let end = start + old.len();
@@ -353,33 +337,13 @@ pub(crate) fn execute_design_file_tool(
.iter() .iter()
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end) .find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
{ {
edit_errors.push(format!( return Err(format!(
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text" "edits[{index}] 与 edits[{other_index}] 修改范围重叠{display}"
)); ));
continue;
} }
matches.push((index, start, end)); matches.push((index, start, end));
valid_edits += 1;
let _ = new; 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(); let mut updated = content.clone();
for (index, start, end) in matches.into_iter().rev() { for (index, start, end) in matches.into_iter().rev() {
let (_, new) = &normalized[index]; let (_, new) = &normalized[index];
@@ -432,60 +396,6 @@ 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( pub(crate) fn list_design_workspace_files(
root: &Path, root: &Path,
) -> Result<Vec<DesignWorkspaceEntry>, String> { ) -> Result<Vec<DesignWorkspaceEntry>, String> {
@@ -783,22 +693,6 @@ mod tests {
) )
.expect_err("escape"); .expect_err("escape");
assert!(escaped.contains("路径")); 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( let patched = execute_design_file_tool(
root, root,
"patch_file", "patch_file",
@@ -5,8 +5,9 @@ mod validation;
mod wire; mod wire;
pub(crate) use model::{ pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole,
DirectCodexUserRuntimeRegionPart,
}; };
pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{ pub(crate) use wire::{
@@ -36,6 +36,21 @@ pub(crate) enum DirectCodexUserContentPart {
AgcResourceReference { resource_id: String }, AgcResourceReference { resource_id: String },
#[serde(rename = "agc_runtime_region_reference")] #[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) name: String,
pub(crate) media_type: String,
#[ts(type = "number")]
pub(crate) size: u64,
pub(crate) local_path: String,
pub(crate) status: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
@@ -40,6 +40,18 @@ pub(crate) fn validate_direct_codex_user_item(
reference_count = reference_count.saturating_add(1); reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?; validate_runtime_region_reference(&manifest, reference)?;
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
if reference.name.trim().is_empty() {
return Err("附件缺少文件名".to_string());
}
if !reference.local_path.trim().is_empty() {
sanitize_attachment_local_path(&reference.local_path)
.ok_or_else(|| "附件项目路径无效".to_string())?;
}
if !matches!(reference.status.trim(), "imported" | "failed") {
return Err("附件状态无效".to_string());
}
}
} }
} }
if reference_count > MAX_DIRECT_CODEX_REFERENCES { if reference_count > MAX_DIRECT_CODEX_REFERENCES {
@@ -100,6 +100,20 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
summary.push(']'); summary.push(']');
summary summary
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
reference.name.trim(),
reference.media_type.trim(),
reference.size
);
if !reference.local_path.trim().is_empty() {
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
summary
}
}; };
input.push(serde_json::json!({ "type": "text", "text": text })); input.push(serde_json::json!({ "type": "text", "text": text }));
} }
@@ -174,4 +188,26 @@ mod tests {
.expect_err("history item without type must fail"); .expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}"); assert!(error.contains("缺少 type"), "{error}");
} }
#[test]
fn attachment_parts_remain_in_canonical_order_when_projected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
let content = projected["content"].as_array().expect("content array");
assert_eq!(content.len(), 2);
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
}
} }
File diff suppressed because one or more lines are too long
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex( pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String, project_path: String,
prompt: String, prompt: String,
mut user_item: DirectCodexUserItem, user_item: DirectCodexUserItem,
creation_type: Option<String>, creation_type: Option<String>,
client_turn_id: Option<String>, client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> { ) -> Result<String, String> {
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,24 +42,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?; })?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let mut audit = DirectCodexTurnAudit::start( let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]);
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context =
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
audit.finish(false);
error
})?;
let DirectCodexUserItem::Message(message) = &mut user_item;
message.content.push(DirectCodexUserContentPart::InputText {
text: attachment_context,
});
}
validate_direct_codex_user_item(root, &user_item).map_err(|error| { validate_direct_codex_user_item(root, &user_item).map_err(|error| {
audit.finish(false); audit.finish(false);
error error
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
root: PathBuf, root: PathBuf,
token: String, token: String,
session_user_id: String, session_user_id: String,
session_identity_generation: u64, session_generation: u64,
} }
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
@@ -1241,8 +1241,7 @@ fn external_mcp_session_id(root: &Path) -> String {
material.push('\0'); material.push('\0');
material.push_str(&session.user_id); material.push_str(&session.user_id);
material.push('\0'); material.push('\0');
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。 material.push_str(&session.generation.to_string());
material.push_str(&session.identity_generation.to_string());
} }
format!("mcp-{:x}", Sha256::digest(material.as_bytes())) format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
} }
@@ -1760,9 +1759,7 @@ async fn handle_external_mcp_http_request(
let Some(session) = current_platform_session() else { let Some(session) = current_platform_session() else {
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
}; };
if session.user_id != state.session_user_id if session.user_id != state.session_user_id || session.generation != state.session_generation {
|| session.identity_generation != state.session_identity_generation
{
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
let response = EXTERNAL_MCP_BRIDGE_URL let response = EXTERNAL_MCP_BRIDGE_URL
@@ -1797,7 +1794,7 @@ pub(crate) async fn start_external_mcp_loopback(
root, root,
token: token.clone(), token: token.clone(),
session_user_id: session.user_id, session_user_id: session.user_id,
session_identity_generation: session.identity_generation, session_generation: session.generation,
}; };
let app = Router::new() let app = Router::new()
.route(&route, post(handle_external_mcp_http_request)) .route(&route, post(handle_external_mcp_http_request))
@@ -11,10 +11,6 @@ use super::external_generation_state::{
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState, retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
}; };
use super::*; use super::*;
use crate::platform_session::{
acquire_platform_session_identity_lease, validate_platform_session_identity,
PlatformSessionIdentity,
};
use reqwest::multipart::{Form, Part}; use reqwest::multipart::{Form, Part};
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
@@ -1514,7 +1510,10 @@ struct PreparedPlatformArtAssetSlice {
#[derive(Clone)] #[derive(Clone)]
struct PreparedPlatformSessionFence { struct PreparedPlatformSessionFence {
identity: PlatformSessionIdentity, user_id: String,
api_base_url: String,
generation: u64,
access_token_sha256: String,
} }
impl PreparedPlatformSessionFence { impl PreparedPlatformSessionFence {
@@ -1522,17 +1521,41 @@ impl PreparedPlatformSessionFence {
access access
.frozen_platform_session() .frozen_platform_session()
.map(|session| PreparedPlatformSessionFence { .map(|session| PreparedPlatformSessionFence {
identity: session.identity(), user_id: session.user_id.clone(),
api_base_url: session.api_base_url.clone(),
generation: session.generation,
access_token_sha256: format!(
"{:x}",
Sha256::digest(session.access_token.as_bytes())
),
}) })
} }
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。 let matches = current_platform_session().is_some_and(|session| {
validate_platform_session_identity(&self.identity) session.user_id == self.user_id
&& session.api_base_url == self.api_base_url
&& session.generation == self.generation
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
== self.access_token_sha256
});
if matches {
Ok(())
} else {
Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
)
}
} }
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> { fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
acquire_platform_session_identity_lease(&self.identity) acquire_validated_platform_session_fingerprint(
&self.user_id,
&self.api_base_url,
self.generation,
&self.access_token_sha256,
)
} }
} }
@@ -10613,7 +10636,7 @@ mod canvas_generation_tests {
} }
drop(owner_a_access); drop(owner_a_access);
drop(frozen_owner_a); drop(frozen_owner_a);
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2) install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
.expect("switch to owner B"); .expect("switch to owner B");
let error = match request_platform_art_asset_with_runtime_options_at( let error = match request_platform_art_asset_with_runtime_options_at(
@@ -10738,13 +10761,7 @@ mod canvas_generation_tests {
.recv_timeout(Duration::from_secs(3)) .recv_timeout(Duration::from_secs(3))
.expect("wait for accepted response"); .expect("wait for accepted response");
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(50));
install_platform_session( install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
"post-202-user-b",
"post-202-token-b",
&switch_base_url,
2,
2,
)
.expect("switch platform account after accepted response"); .expect("switch platform account after accepted response");
}); });
let runtime_context = PlatformArtGenerationRuntimeContext { let runtime_context = PlatformArtGenerationRuntimeContext {
@@ -1431,13 +1431,12 @@ mod external_generation_state_tests {
base_url, base_url,
); );
let frozen_a = current_platform_session().expect("freeze owner A"); let frozen_a = current_platform_session().expect("freeze owner A");
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch"); validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
replace_platform_session_for_gui_owner( replace_platform_session_for_gui_owner(
"fingerprint-owner-b", "fingerprint-owner-b",
"fingerprint-token-b", "fingerprint-token-b",
base_url, base_url,
2, 2,
2,
) )
.expect("switch global session to owner B"); .expect("switch global session to owner B");
let current_b = current_platform_session().expect("owner B is current after switch"); let current_b = current_platform_session().expect("owner B is current after switch");
@@ -605,8 +605,6 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
response_stream_fixture("finalization-tool-plan-repair-chain-run"); response_stream_fixture("finalization-tool-plan-repair-chain-run");
let root = project.path(); let root = project.path();
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "finalization-tool-plan-key".to_string(), api_key: "finalization-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "finalization-tool-plan-model".to_string(), model: "finalization-tool-plan-model".to_string(),
@@ -970,8 +968,6 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
let root = project.path(); let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
let old_llm = GameCreatorLlmConfig { let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-provider-key".to_string(), api_key: "old-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-provider-model".to_string(), model: "old-provider-model".to_string(),
@@ -1077,8 +1073,6 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
LlmMessage::user("修复格式"), LlmMessage::user("修复格式"),
]); ]);
let old_llm = GameCreatorLlmConfig { let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-tool-plan-provider-key".to_string(), api_key: "old-tool-plan-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-tool-plan-model".to_string(), model: "old-tool-plan-model".to_string(),
@@ -1198,8 +1192,6 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
response_stream_fixture("generic-retry-drift-tool-plan-chain-run"); response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
let root = project.path(); let root = project.path();
let old_llm = GameCreatorLlmConfig { let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-generic-retry-key".to_string(), api_key: "old-generic-retry-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-generic-retry-model".to_string(), model: "old-generic-retry-model".to_string(),
@@ -1285,8 +1277,6 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
response_stream_fixture("tool-plan-capacity-preflight-run"); response_stream_fixture("tool-plan-capacity-preflight-run");
let root = project.path(); let root = project.path();
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-capacity-key".to_string(), api_key: "tool-plan-capacity-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-capacity-model".to_string(), model: "tool-plan-capacity-model".to_string(),
@@ -1410,8 +1400,6 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
LlmMessage::user("修复格式"), LlmMessage::user("修复格式"),
]); ]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "durable-control-tool-plan-key".to_string(), api_key: "durable-control-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "durable-control-tool-plan-model".to_string(), model: "durable-control-tool-plan-model".to_string(),
@@ -1537,8 +1525,6 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
snapshot.request_slot = "loop-0-repair-0".to_string(); snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]); let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-cleanup-key".to_string(), api_key: "tool-plan-cleanup-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-cleanup-model".to_string(), model: "tool-plan-cleanup-model".to_string(),
@@ -1611,8 +1597,6 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
snapshot.request_slot = "loop-0-repair-0".to_string(); snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]); let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "terminal-handoff-key".to_string(), api_key: "terminal-handoff-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "terminal-handoff-model".to_string(), model: "terminal-handoff-model".to_string(),
@@ -1709,8 +1693,6 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
let root = project.path(); let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "provider-key".to_string(), api_key: "provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "provider-model".to_string(), model: "provider-model".to_string(),
@@ -2,9 +2,8 @@ use super::*;
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> = pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
OnceLock::new(); OnceLock::new();
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。 pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock< std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
> = OnceLock::new(); > = OnceLock::new();
#[cfg(test)] #[cfg(test)]
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
@@ -294,8 +293,8 @@ pub(crate) use entrypoints::{
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
game_creator_agent_runtime_update_event, generate_local_game_draft_at, game_creator_agent_runtime_update_event, generate_local_game_draft_at,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
set_game_creator_agent_runtime_update_app_handle, set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink, start_game_creator_manifest_invalidation_event_sink,
validate_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink,
@@ -1,12 +1,11 @@
use super::*; use super::*;
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
fn lock_game_creator_manifest_invalidation_event_sinks( fn lock_game_creator_manifest_invalidation_event_sink(
) -> std::sync::MutexGuard<'static, Vec<GameCreatorManifestInvalidationEventSink>> { ) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
.get_or_init(|| Mutex::new(Vec::new())) .get_or_init(|| Mutex::new(None))
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) .unwrap_or_else(|poisoned| poisoned.into_inner())
} }
@@ -220,7 +219,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
token: &str, token: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?; let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
register_game_creator_manifest_invalidation_event_sink(sink); install_game_creator_manifest_invalidation_event_sink(sink);
Ok(()) Ok(())
} }
@@ -241,29 +240,10 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
}) })
} }
/// 登记一个界面窗口的事件接收端。 pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
///
/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有
/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。
pub(crate) fn register_game_creator_manifest_invalidation_event_sink(
sink: GameCreatorManifestInvalidationEventSink, sink: GameCreatorManifestInvalidationEventSink,
) { ) {
let mut sinks = lock_game_creator_manifest_invalidation_event_sinks(); *lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
if let Some(existing) = sinks
.iter_mut()
.find(|existing| existing.token == sink.token)
{
*existing = sink;
return;
}
if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX {
sinks.remove(0);
}
sinks.push(sink);
}
fn remove_game_creator_manifest_invalidation_event_sink(token: &str) {
lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token);
} }
#[cfg(test)] #[cfg(test)]
@@ -278,20 +258,14 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard {
} }
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> { pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
lock_game_creator_manifest_invalidation_event_sinks() lock_game_creator_manifest_invalidation_event_sink().clone()
.first()
.cloned()
}
pub(crate) fn configured_sinks(&self) -> Vec<GameCreatorManifestInvalidationEventSink> {
lock_game_creator_manifest_invalidation_event_sinks().clone()
} }
} }
#[cfg(test)] #[cfg(test)]
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
fn drop(&mut self) { fn drop(&mut self) {
lock_game_creator_manifest_invalidation_event_sinks().clear(); *lock_game_creator_manifest_invalidation_event_sink() = None;
} }
} }
@@ -307,43 +281,16 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
} }
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone(); let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
if sinks.is_empty() { let Some(sink) = sink else {
return Ok(()); return Ok(());
} };
let event = GameCreatorManifestInvalidatedEvent { let envelope = GameCreatorManifestInvalidationRelayEnvelope {
token: sink.token,
event: GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(), project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
}; },
let mut failed_tokens = Vec::new();
let mut last_error = None;
for sink in &sinks {
match relay_game_creator_manifest_invalidation_to_sink(sink, &event) {
Ok(()) => {}
Err(error) => {
// 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。
failed_tokens.push(sink.token.clone());
last_error = Some(error);
}
}
}
if !failed_tokens.is_empty() {
lock_game_creator_manifest_invalidation_event_sinks()
.retain(|sink| !failed_tokens.contains(&sink.token));
}
match last_error {
Some(error) => Err(error),
None => Ok(()),
}
}
fn relay_game_creator_manifest_invalidation_to_sink(
sink: &GameCreatorManifestInvalidationEventSink,
event: &GameCreatorManifestInvalidatedEvent,
) -> Result<(), String> {
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
token: sink.token.clone(),
event: event.clone(),
}; };
let payload = serde_json::to_vec(&envelope) let payload = serde_json::to_vec(&envelope)
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
@@ -1939,9 +1939,8 @@ pub(crate) async fn polish_local_project_prompt(
} }
#[tauri::command] #[tauri::command]
pub(crate) fn read_platform_account_session_state( pub(crate) fn read_platform_account_session_generation() -> u64 {
) -> crate::platform_session::PlatformSessionWriteState { current_platform_session_generation()
crate::platform_session::current_platform_session_write_state()
} }
#[tauri::command] #[tauri::command]
@@ -1949,45 +1948,28 @@ pub(crate) async fn install_platform_account_session(
user_id: String, user_id: String,
access_token: String, access_token: String,
api_base_url: String, api_base_url: String,
identity_generation: u64, generation: u64,
revision: u64,
) -> Result<(), String> { ) -> Result<(), String> {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
validate_platform_session_input( validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)?;
install_external_agent_runner_platform_session( install_external_agent_runner_platform_session(
&user_id, &user_id,
&access_token, &access_token,
&api_base_url, &api_base_url,
identity_generation, generation,
revision,
)?; )?;
install_platform_session( install_platform_session(&user_id, &access_token, &api_base_url, generation)
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)
}) })
.await .await
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
} }
#[tauri::command] #[tauri::command]
pub(crate) async fn clear_platform_account_session( pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
shutdown_game_creator_codex_app_servers()?; shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(identity_generation, revision)?; clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(identity_generation, revision); clear_platform_session(generation);
Ok(()) Ok(())
}) })
.await .await
@@ -2009,8 +1991,6 @@ pub(crate) fn write_game_creator_app_config(
.lock() .lock()
.map_err(|_| "配置写入锁不可用")?; .map_err(|_| "配置写入锁不可用")?;
let (current, overlays) = load_game_creator_app_config_for_write()?; let (current, overlays) = load_game_creator_app_config_for_write()?;
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
config.llm.custom_enabled = current.llm.custom_enabled;
config.selected_model_id = current.selected_model_id; config.selected_model_id = current.selected_model_id;
config.selected_model_is_default = current.selected_model_is_default; config.selected_model_is_default = current.selected_model_is_default;
persist_game_creator_app_config(config, overlays, false) persist_game_creator_app_config(config, overlays, false)
@@ -2047,12 +2027,7 @@ pub(crate) fn select_game_creator_model(
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock() .lock()
.map_err(|_| "配置写入锁不可用")?; .map_err(|_| "配置写入锁不可用")?;
let (mut config, overlays) = load_game_creator_app_config_for_write()?; if model_id.is_empty()
if config.llm.custom_enabled {
if !config.llm.visible_models.contains(&model_id) {
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
}
} else if model_id.is_empty()
|| model_id.len() > 64 || model_id.len() > 64
|| !model_id || !model_id
.bytes() .bytes()
@@ -2060,21 +2035,12 @@ pub(crate) fn select_game_creator_model(
{ {
return Err("模型标识无效".into()); return Err("模型标识无效".into());
} }
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.selected_model_id = model_id; config.selected_model_id = model_id;
config.selected_model_is_default = is_default; config.selected_model_is_default = is_default;
persist_game_creator_app_config(config, overlays, true) persist_game_creator_app_config(config, overlays, true)
} }
#[tauri::command]
pub(crate) async fn discover_game_creator_llm_models(
llm: GameCreatorLlmConfig,
) -> Result<Vec<String>, String> {
if !load_game_creator_app_config()?.llm.custom_enabled {
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
}
fetch_custom_llm_models(&llm).await
}
fn persist_game_creator_app_config( fn persist_game_creator_app_config(
config: GameCreatorAppConfig, config: GameCreatorAppConfig,
overlays: Vec<(PathBuf, serde_json::Value)>, overlays: Vec<(PathBuf, serde_json::Value)>,
@@ -2090,8 +2056,8 @@ fn persist_game_creator_app_config(
let previous = overlay.clone(); let previous = overlay.clone();
if let Some(fields) = overlay.as_object_mut() { if let Some(fields) = overlay.as_object_mut() {
for (key, value) in fields.iter_mut() { for (key, value) in fields.iter_mut() {
if !model_only if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") == model_only
{ {
if let Some(saved_value) = saved.get(key) { if let Some(saved_value) = saved.get(key) {
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
@@ -4265,7 +4231,14 @@ pub(crate) async fn import_account_editor_assets_for_agent(
access.validate_frozen_session()?; access.validate_frozen_session()?;
let _platform_session_lease = frozen_session let _platform_session_lease = frozen_session
.as_ref() .as_ref()
.map(|session| acquire_platform_session_identity_lease(&session.identity())) .map(|session| {
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
)
})
.transpose()?; .transpose()?;
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
access.validate_frozen_session()?; access.validate_frozen_session()?;

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