From 6d7b3e74a615a06f04b0f406b091494bb45c61d0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 23:23:06 +0800 Subject: [PATCH 01/54] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E6=95=B0=E6=8D=AE=E5=BA=93=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失效进程锁自动清理后重试获取 仍存活进程持锁时继续阻断并发备份 补充锁生命周期文档与回归测试 --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/check-database-backup-to-oss.mjs | 18 ++-- scripts/database-backup-to-oss.mjs | 82 ++++++++++++------- 3 files changed, 62 insertions(+), 40 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 066bc0a5d..1ca7a142f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -431,7 +431,7 @@ UI 相关修改要重点验证: npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service ``` -脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir `,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 +脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。备份使用同库进程锁:仍存活的备份进程会阻断并发执行;持有锁的进程已退出时,脚本会自动清理失效锁并重试获取,不需要人工删除锁文件。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir `,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 生产环境变量模板在 `deploy/env/api-server.env.example`: diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index b42f5dbfc..44060f394 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -76,7 +76,7 @@ async function main() { await assertManifestUploadUsesShaAndHeadVerification(); assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas(); assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch(); - assertHistoryBackupLockRejectsLiveAndStaleOwners(); + assertHistoryBackupLockRejectsLiveAndCleansStaleOwners(); assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames(); assertHistoryStatDriftPreventsAnyCleanup(); await assertHistoryUploadFailureDoesNotDeleteSources(); @@ -2140,7 +2140,7 @@ function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() { } } -function assertHistoryBackupLockRejectsLiveAndStaleOwners() { +function assertHistoryBackupLockRejectsLiveAndCleansStaleOwners() { const liveOwner = createHistoryFixture('history-live-lock', { nestedData: false, }); @@ -2176,17 +2176,17 @@ function assertHistoryBackupLockRejectsLiveAndStaleOwners() { const staleResult = runHistoryCommand(staleOwner, ['--defer-upload']); assertStatus( staleResult, - 1, - '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。', + 0, + '失效 owner pid 的 backup lock 应自动清理并成功获取新锁。', ); assertIncludes( - staleResult.stderr, - '拒绝自动抢锁', - '失效 backup lock 应要求人工核对 multipart 与进程。', + `${staleResult.stdout}\n${staleResult.stderr}`, + '失效数据库备份锁', + '自动清理失效 backup lock 时应输出可审计日志。', ); assertTrue( - existsSync(staleLockPath), - '失效 backup lock 未经人工核对不得自动删除。', + !existsSync(staleLockPath), + '成功获取并释放新锁后,失效 backup lock 不得残留。', ); } diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 148267ac7..4b9031410 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -456,43 +456,65 @@ function acquireBackupLock({ workDir, database }) { workDir, `${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`, ); - try { - const fd = openSync(lockPath, 'wx', 0o600); - writeFileSync(fd, `${process.pid}\n`, 'utf8'); - closeSync(fd); - const release = () => { - try { - const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); - if (ownerPid === process.pid) { - rmSync(lockPath, { force: true }); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const fd = openSync(lockPath, 'wx', 0o600); + writeFileSync(fd, `${process.pid}\n`, 'utf8'); + closeSync(fd); + const release = () => { + try { + const ownerPid = Number( + String(readFileSync(lockPath, 'utf8')).trim(), + ); + if (ownerPid === process.pid) { + rmSync(lockPath, { force: true }); + } + } catch { + // The lock may already have been removed by the normal exit path. } - } catch { - // The lock may already have been removed by the normal exit path. + }; + process.once('exit', release); + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + release(); + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + } + return lockPath; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; } - }; - process.once('exit', release); - for (const signal of ['SIGINT', 'SIGTERM']) { - process.once(signal, () => { - release(); - process.exit(signal === 'SIGINT' ? 130 : 143); - }); } - return lockPath; - } catch (error) { - if (error?.code !== 'EEXIST') { + let ownerPid = 0; + try { + ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); + } catch (error) { + if (error?.code === 'ENOENT') { + continue; + } throw error; } - } - const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); - if ( - Number.isSafeInteger(ownerPid) && - ownerPid > 0 && - processIsAlive(ownerPid) - ) { - throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); + if ( + Number.isSafeInteger(ownerPid) && + ownerPid > 0 && + processIsAlive(ownerPid) + ) { + throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); + } + try { + rmSync(lockPath); + console.log( + `[database-backup] 已清理失效数据库备份锁,准备重新获取: ${lockPath} pid=${ownerPid || ''}`, + ); + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + } } throw new Error( - `发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`, + `数据库备份锁在清理后仍无法获取,可能存在并发进程: ${lockPath}`, ); } From 34db1b6ad1c59abccb07fc17e8546bedece13a48 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 23:27:37 +0800 Subject: [PATCH 02/54] =?UTF-8?q?=E9=81=BF=E5=85=8D=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E9=94=81=E6=97=B6=E8=AF=AF=E5=88=A0=E6=96=B0?= =?UTF-8?q?=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用原子重命名隔离失效锁文件 --- scripts/database-backup-to-oss.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 4b9031410..bc215509f 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -502,8 +502,12 @@ function acquireBackupLock({ workDir, database }) { ) { throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); } + const staleLockPath = `${lockPath}.stale-${process.pid}-${attempt}-${Date.now()}`; try { - rmSync(lockPath); + // rename 是原子的:若另一个进程已先清理并重新建锁,这里只会得到 ENOENT, + // 不会把新进程的锁误删。 + renameSync(lockPath, staleLockPath); + rmSync(staleLockPath, { force: true }); console.log( `[database-backup] 已清理失效数据库备份锁,准备重新获取: ${lockPath} pid=${ownerPid || ''}`, ); From 34503990f2741033e15e6edb3879b3f79238c8dc Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:29:22 +0800 Subject: [PATCH 03/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E6=9C=9F=E7=99=BB=E5=BD=95=E9=94=99=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 HTTP 状态展示认证服务不可用、超时和维护提示 增加 503 非 JSON 维护页认证界面回归测试 同步 AGC 启动认证错误提示技术方案 --- .../src/services/clientAuth.ts | 28 ++++++++++++++--- .../tests/appSurface/auth.suite.ts | 31 +++++++++++++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 54c4c27e8..c0b5e0750 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -72,6 +72,24 @@ export function getClientAuthRefreshOperation(apiBaseUrl: string) { const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; +function getClientAuthHttpErrorMessage(status: number, fallback: string) { + switch (status) { + case 408: + case 504: + return '登录服务响应超时,请检查服务器地址和网络后重试'; + case 429: + return '登录请求过于频繁,请稍后重试'; + case 500: + return '登录服务内部错误(HTTP 500),请稍后重试'; + case 502: + return '登录服务暂不可用:上游服务请求失败,请稍后重试'; + case 503: + return '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试'; + default: + return fallback; + } +} + function getClientAuthNetworkErrorMessage(error: unknown) { const detail = error instanceof Error ? error.message.trim() : String(error).trim(); @@ -121,24 +139,26 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { } async function readAuthErrorMessage(response: Response, fallback: string) { + const httpFallback = getClientAuthHttpErrorMessage(response.status, fallback); const text = await readClientHttpResponseText(response, { url: 'auth error response', }); if (!text.trim()) { - return fallback; + return httpFallback; } let parsed: unknown; try { parsed = JSON.parse(text) as unknown; } catch { - return fallback; + return httpFallback; } try { unwrapApiResponse(parsed); } catch (error) { - return error instanceof Error ? error.message : fallback; + const message = error instanceof Error ? error.message.trim() : ''; + return message && message !== '请求失败' ? message : httpFallback; } - return fallback; + return httpFallback; } async function requestAuthJson( diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index ffeb58e41..bfb771cb0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1272,6 +1272,37 @@ export function registerAuthTests() { ).toHaveLength(1); }); + it('shows the HTTP maintenance error when startup auth receives a 503', async () => { + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'existing-token', + ); + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + if (String(input) === '/api/auth/me') { + return new Response( + '503 Service Unavailable', + { status: 503, headers: { 'Content-Type': 'text/html' } }, + ); + } + throw new Error(`unexpected fetch ${String(input)}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }), + ), + ); + + expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); + expect( + screen.getByText( + '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试', + ), + ).not.toBeNull(); + }); + it('still calls logout when token refresh fails during logout retry', async () => { window.localStorage.setItem( 'genarrative.auth.access-token.v1', diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a232c7612..8d8af7f25 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -63,6 +63,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm - AGC 客户端启动恢复按“读取本地凭据 → 刷新会话(无 token 或失效时)→ 读取当前用户 → Tauri 本地运行时会话安装”阶段执行。界面必须展示当前阶段和已等待时间;不能以无期限的单一 loading 文案隐藏网络或 Runner 故障。 - 客户端 HTTP 传输默认使用 15 秒超时并通过独立 `AbortController` 终止请求;调用方可为确需长耗时的请求显式传入 `timeoutMs: null`。调用方主动取消仍保留原始 `AbortError`,超时使用稳定的 `ClientHttpTimeoutError`,由认证层转换为可操作的中文提示。 - 会话恢复或本地 Runner 连接超时后必须进入登录页并提供“重试登录状态检查”。重试递增恢复代次并以运行标识忽略旧恢复任务的迟到 UI 写回;不得清除仍可用于后续重试的 access token,也不得重复并发刷新同一服务器的 refresh 请求。 +- AGC 壳启动认证遇到空响应、非 JSON 维护页或 5xx 时,必须按 HTTP 状态生成可操作的中文提示(503 明确标记服务暂不可用/可能维护),不能退化为 `读取当前用户失败`;后端返回的结构化错误 message 仍优先展示。 - Tauri Runner 的启动与 IPC 超时继续以 `runner/protocol.rs` 的 30 秒启动、10 秒读写为权威;启动等待循环会把 endpoint 探测预算裁剪到剩余启动期限,避免单次 ping 把 30 秒门禁延长。考虑复用旧 endpoint 前可能先消耗一次 IPC 等待,前端 UI 兜底取 45 秒,不改变 Runner 协议、启动策略或认证接口。 ## 2026-08-26 运行中自主扩图提案 From 24bbdfda7d0a08b55e3326e99d1a6de07de3f08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 14:45:58 +0800 Subject: [PATCH 04/54] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DirectProject=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=20Response=20item=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Rust canonical user item 与 Codex wire 转换模块 增加 ts-rs 绑定目录及生成文件检查边界 更新聊天素材引用规范与里程碑实施计划 --- .eslintrc.cjs | 1 + .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_codex_user_item.rs | 231 ++++++++++++++++++ .../generated/DirectCodexUserContentPart.ts | 10 + .../generated/DirectCodexUserItem.ts | 5 + .../generated/DirectCodexUserMessageItem.ts | 11 + .../generated/DirectCodexUserRole.ts | 3 + .../DirectCodexUserRuntimeRegionPart.ts | 13 + .../project-workspace/generated/index.ts | 5 + ...DirectProject用户ResponseItem输入-2026-09-15.md | 36 +++ ...DirectProject用户ResponseItem输入-2026-09-15.md | 51 ++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 8 +- 12 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts create mode 100644 docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 793e2b4a4..8a4b2d0c6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -164,6 +164,7 @@ module.exports = { 'server-rs/target-*', 'apps/desktop-shell/src-tauri/target', 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', + 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', 'target', 'src/main.tsx', 'src/App.tsx', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index b9d90f269..3940aeeb7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -17,6 +17,7 @@ mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_references; +mod direct_codex_user_item; mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; @@ -46,6 +47,7 @@ pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_references::*; +pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs new file mode 100644 index 000000000..1c042b25f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs @@ -0,0 +1,231 @@ +use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use super::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +/// +/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item +/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts new file mode 100644 index 000000000..10c4e0f5a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -0,0 +1,10 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; + +export type DirectCodexUserContentPart = + | { type: 'input_text'; text: string } + | { type: 'agc_resource_reference'; resourceId: string } + | ({ + type: 'agc_runtime_region_reference'; + } & DirectCodexUserRuntimeRegionPart); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts new file mode 100644 index 000000000..4c9ea8ca3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -0,0 +1,5 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; + +export type DirectCodexUserItem = DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts new file mode 100644 index 000000000..f7447dc5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -0,0 +1,11 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +import type { DirectCodexUserRole } from './DirectCodexUserRole'; + +export type DirectCodexUserMessageItem = { + type: 'message'; + role: DirectCodexUserRole; + content: DirectCodexUserContentPart[]; + id: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts new file mode 100644 index 000000000..e1dc59c59 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -0,0 +1,3 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts new file mode 100644 index 000000000..296c0d19a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -0,0 +1,13 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRuntimeRegionPart = { + label: string; + runId?: string; + versionId?: string; + elementTag?: string; + elementRole?: string; + text?: string; + width?: number; + height?: number; + resourceIds: string[]; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts new file mode 100644 index 000000000..f1012a4c5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -0,0 +1,5 @@ +export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +export type { DirectCodexUserItem } from './DirectCodexUserItem'; +export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; +export type { DirectCodexUserRole } from './DirectCodexUserRole'; +export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..8742e52fb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,36 @@ +# 【实施计划】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 +- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。 + +## 实现顺序 + +1. 提取深模块:Rust canonical user item 的定义、校验和 Codex wire 转换。 +2. 生成并接入 ts-rs 类型,排除生成文件 lint/style。 +3. 将前端 Lexical 草稿从 `text + references[]` 改为 inline content parts。 +4. 修改 Tauri command 与 DirectProject turn:校验通过后持久化 canonical item,再发送转换后的 Codex 输入。 +5. 删除本链路对 legacy conversation 行的读取 fallback,保留标准 `response_item`。 +6. 补齐定向测试与文档验证;每个独立切片形成小提交。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex` +2. 相关前端 Vitest 与类型检查。 +3. `npm run check:encoding` +4. `npm run check:doc-index` +5. `git diff --check` + +## 风险与回滚点 + +- ts-rs 生成路径或 workspace lint 配置不一致:先固定生成入口,再接入业务。 +- 历史中 canonical item 含 AGC part 时,thread replay 必须经过同一 wire converter;转换失败不得启动 turn。 +- 共享工作树存在用户 `.env` 修改,禁止覆盖或提交。 +- 每个切片保持独立提交,出现协议问题时按提交粒度回滚。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..cc88e97af --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,51 @@ +# 【里程碑】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-15 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +将 DirectProject 的用户消息升级为受限 Response API `message` item:文本与 AGC 引用按编辑顺序内联,Rust 校验后持久化 canonical item,并转换为 Codex 可接受的输入。 + +## 范围 + +- Rust 定义 user item 与 content part,并通过 `ts-rs` 生成 TypeScript 绑定。 +- 前端 Lexical 草稿生成 inline `content[]`。 +- `agc_resource_reference` 只使用稳定 `resourceId`。 +- `agc_runtime_region_reference` 保留运行区域语义摘要。 +- Rust 在持久化前完成白名单、manifest 与路径校验。 +- 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 +- 保持 assistant 返回、工具 activity、附件/图片协议不变。 + +## 不在范围内 + +- assistant item 前端投影或 Tauri 返回值改造。 +- 工具 item、reasoning、file change、MCP item 的 UI 模型化。 +- 附件/图片 content part。 +- SpacetimeDB schema 或 HTTP API 变更。 + +## 依赖与前置条件 + +- DirectProject 现有 app-server thread/inject_items/turn/start 链路。 +- 项目 manifest 作为资源身份与路径权威。 +- 现有 `project.jsonl` 的 `response_item` envelope。 + +## 验收标准 + +- [ ] 前端生成的 canonical user item 保留 Lexical 中文本与引用的相对顺序。 +- [ ] `agc_resource_reference` 仅包含 `resourceId`,显示信息由 manifest 派生。 +- [ ] runtime-region 字段经过 Rust 有界清洗并验证关联资源。 +- [ ] 未知 part、失效资源或非法路径在持久化前失败关闭。 +- [ ] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 +- [ ] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 +- [ ] assistant、附件和工具链路行为无变化。 + +## 证据要求 + +- 自动化:Rust item 校验/转换/历史测试;前端草稿顺序与类型测试;ts-rs 生成检查。 +- 运行时:DirectProject 本地 app-server smoke(如环境可用)。 +- 边界:未知 part、资源删除、非法路径、重复提交 clientTurnId、legacy 行拒绝。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index e8a921a69..ca8874954 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -13,7 +13,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 -提交时前端同时发送用户文本和 `references` 数组。Rust 在发起 Agent 回合前读取当前项目 manifest,逐项复核资源是否存在、路径是否安全,并以 manifest 中的 `id / kind / mediaType / localPath` 作为权威投影;客户端传入的路径、名称和类型不会被直接信任。已删除或不存在的资源会阻止发送并提示用户移除后重新选择。 +提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 当前已完成: @@ -24,9 +24,9 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 - 素材芯片可插入、编辑和删除; - 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; -- 提交请求携带结构化 `references`; -- Rust 按 manifest 二次校验并生成安全投影; +- 提交请求携带 canonical user message item; +- Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; - 普通无引用消息保持原有行为; - 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态; - 资源改名后引用芯片与候选列表的显示名自动刷新; -- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。 +- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。 From 76bbeb68483bd364468bfb2cb8b59c287197cf7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:19:17 +0800 Subject: [PATCH 05/54] =?UTF-8?q?=E9=87=8D=E6=9E=84=20DirectProject=20Agen?= =?UTF-8?q?t=20=E6=B7=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 direct_runtime 与 codex_app_server 改为目录模块 拆分 user item 的模型、校验与 wire 投影文件 抽离 DirectProject 历史注入载荷构造 seam --- .../direct_project_history_wire.rs | 25 ++ .../mod.rs} | 50 ++-- .../src/agent/direct_codex_user_item.rs | 231 ------------------ .../src/agent/direct_codex_user_item/mod.rs | 12 + .../src/agent/direct_codex_user_item/model.rs | 69 ++++++ .../direct_codex_user_item/validation.rs | 87 +++++++ .../src/agent/direct_codex_user_item/wire.rs | 82 +++++++ .../mod.rs} | 107 ++------ .../src/agent/direct_runtime/user_input.rs | 84 +++++++ 9 files changed, 402 insertions(+), 345 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server.rs => codex_app_server/mod.rs} (99%) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{direct_runtime.rs => direct_runtime/mod.rs} (99%) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs new file mode 100644 index 000000000..b958bd982 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -0,0 +1,25 @@ +//! DirectProject 历史注入载荷的单一构造 seam。 +//! +//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。 + +use super::super::*; +use super::direct_project_history_injection_oversize_error; +use serde_json::Value; +use std::path::Path; + +pub(super) fn build_direct_project_history_injection_params( + history_root: &Path, + thread_id: &str, +) -> Result { + let items = read_direct_project_history_items_at(history_root) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let params = serde_json::json!({"threadId": thread_id, "items": items}); + let payload_bytes = serde_json::to_vec(¶ms) + .map(|bytes| bytes.len().saturating_add(1)) + .unwrap_or(usize::MAX); + // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 + if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) { + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + Ok(params) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index ea4d981a8..506b038f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -10,6 +10,9 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR use tokio::sync::{mpsc, oneshot, Mutex, Notify}; use uuid::Uuid; +mod direct_project_history_wire; +use direct_project_history_wire::build_direct_project_history_injection_params; + const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str = @@ -2665,6 +2668,7 @@ impl CodexAppServerConnection { request, None, None, + None, on_agent_message_delta, direct_observer, audit, @@ -2679,6 +2683,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, + direct_user_item: Option<&serde_json::Value>, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, @@ -2694,12 +2699,15 @@ impl CodexAppServerConnection { )); } if let Some(client_turn_id) = direct_client_turn_id { - let user_item = direct_project_local_message_item( - "user", - current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let user_item = match direct_user_item { + Some(item) => item.clone(), + None => direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?, + }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; } @@ -2709,24 +2717,14 @@ impl CodexAppServerConnection { let thread_id = thread_lease.thread_id.clone(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { - let items = match read_direct_project_history_items_at(history_root) { - Ok(items) => items, - Err(error) => { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } - }; - let params = serde_json::json!({"threadId": thread_id.clone(), "items": items}); - let payload_bytes = serde_json::to_vec(¶ms) - .map(|bytes| bytes.len().saturating_add(1)) - .unwrap_or(usize::MAX); - // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 - if let Some(error) = - direct_project_history_injection_oversize_error(¶ms, payload_bytes) - { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } + let params = + match build_direct_project_history_injection_params(history_root, &thread_id) { + Ok(params) => params, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(error); + } + }; if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; return Err(platform_llm::LlmError::Transport(error)); @@ -3757,6 +3755,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( None, None, None, + None, ) .await } @@ -3774,6 +3773,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( None, Some(observer), None, + None, ) .await } @@ -3831,6 +3831,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -3894,6 +3895,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( request, Some(&codex_root), effective_client_turn_id, + direct_user_item.as_ref(), None, observer, audit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs deleted file mode 100644 index 1c042b25f..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; -use super::*; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; -use ts_rs::TS; - -/// DirectProject 本轮 user input 的唯一结构化入口。 -/// -/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item -/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserItem { - #[serde(rename = "message")] - Message(DirectCodexUserMessageItem), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageItem { - pub(crate) role: DirectCodexUserRole, - pub(crate) content: Vec, - pub(crate) id: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserRole { - User, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserContentPart { - #[serde(rename = "input_text")] - InputText { text: String }, - #[serde(rename = "agc_resource_reference")] - AgcResourceReference { resource_id: String }, - #[serde(rename = "agc_runtime_region_reference")] - AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserRuntimeRegionPart { - pub(crate) label: String, - #[serde(default)] - pub(crate) run_id: Option, - #[serde(default)] - pub(crate) version_id: Option, - #[serde(default)] - pub(crate) element_tag: Option, - #[serde(default)] - pub(crate) element_role: Option, - #[serde(default)] - pub(crate) text: Option, - #[serde(default)] - pub(crate) width: Option, - #[serde(default)] - pub(crate) height: Option, - #[serde(default)] - pub(crate) resource_ids: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageEnvelope { - pub(crate) item: DirectCodexUserItem, -} - -pub(crate) fn validate_direct_codex_user_item( - root: &Path, - item: &DirectCodexUserItem, -) -> Result<(), String> { - let DirectCodexUserItem::Message(message) = item; - if !matches!(message.role, DirectCodexUserRole::User) { - return Err("DirectProject 只接受 user message item".to_string()); - } - if message.id.trim().is_empty() { - return Err("DirectProject user item 缺少稳定 id".to_string()); - } - if message.content.is_empty() { - return Err("DirectProject user item content 不能为空".to_string()); - } - let manifest = read_manifest_for_project(root)?; - let mut reference_count = 0usize; - for part in &message.content { - match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - reference_count = reference_count.saturating_add(1); - validate_resource_id_and_manifest(&manifest, resource_id)?; - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - reference_count = reference_count.saturating_add(1); - validate_runtime_region_reference(&manifest, reference)?; - } - } - } - if reference_count > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - Ok(()) -} - -fn validate_resource_id_and_manifest( - manifest: &GameCreationAppManifest, - resource_id: &str, -) -> Result<(), String> { - let resource_id = resource_id.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > 200 - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - Ok(()) -} - -fn validate_runtime_region_reference( - manifest: &GameCreationAppManifest, - reference: &DirectCodexUserRuntimeRegionPart, -) -> Result<(), String> { - if reference.label.trim().is_empty() { - return Err("运行画面区域缺少名称".to_string()); - } - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" - )); - } - for resource_id in &reference.resource_ids { - validate_resource_id_and_manifest(manifest, resource_id)?; - } - Ok(()) -} - -/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 -/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 -pub(crate) fn direct_codex_user_item_to_wire_input( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - validate_direct_codex_user_item(root, item)?; - let manifest = read_manifest_for_project(root)?; - let DirectCodexUserItem::Message(message) = item; - let mut input = Vec::with_capacity(message.content.len()); - for part in &message.content { - let text = match part { - DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id.trim()) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - format!( - "[素材引用 resourceId={};项目路径={path}]", - resource_id.trim() - ) - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - let resources = reference - .resource_ids - .iter() - .map(|id| id.trim()) - .collect::>() - .join(","); - let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); - if let Some(run_id) = reference.run_id.as_deref() { - summary.push_str(&format!("运行标识={} ", run_id.trim())); - } - if let Some(role) = reference.element_role.as_deref() { - summary.push_str(&format!("角色={} ", role.trim())); - } - if let Some(text) = reference.text.as_deref() { - summary.push_str(&format!("文本={} ", text.trim())); - } - if !resources.is_empty() { - summary.push_str(&format!("关联素材={resources}")); - } - summary.push(']'); - summary - } - }; - input.push(serde_json::json!({ "type": "text", "text": text })); - } - Ok(Value::Array(input)) -} - -pub(crate) fn direct_codex_user_item_to_prompt( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - let wire = direct_codex_user_item_to_wire_input(root, item)?; - wire.as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) - .map(|parts| { - parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::() - }) - .and_then(|prompt| { - if prompt.trim().is_empty() { - Err("DirectProject user item 不能转换为空 prompt".to_string()) - } else { - Ok(prompt) - } - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs new file mode 100644 index 000000000..d591f4dad --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -0,0 +1,12 @@ +//! DirectProject user input 的 canonical Response item 深模块。 + +mod model; +mod validation; +mod wire; + +pub(crate) use model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, + DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, +}; +pub(crate) use validation::validate_direct_codex_user_item; +pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs new file mode 100644 index 000000000..f3837c648 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs new file mode 100644 index 000000000..ed7bf88ee --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -0,0 +1,87 @@ +use super::model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole, + DirectCodexUserRuntimeRegionPart, +}; +use crate::agent::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use crate::agent::{ + read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, +}; +use std::path::Path; + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +pub(crate) fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs new file mode 100644 index 000000000..c2ab37897 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -0,0 +1,82 @@ +use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; +use super::validation::validate_direct_codex_user_item; +use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use serde_json::Value; +use std::path::Path; + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 93d2146c4..70abd71e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -6,6 +6,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +mod user_input; +pub(crate) use user_input::chat_with_game_creator_direct_codex; + const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; @@ -4189,6 +4192,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( creation_type, None, None, + None, ) .await } @@ -4199,6 +4203,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -4214,7 +4219,15 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None); } - match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await + match run_direct_game_creator_turn_inner( + root, + prompt, + creation_type, + turn_emitter, + audit, + direct_user_item, + ) + .await { Ok(reply) => Ok(reply), Err(failure) => { @@ -4243,6 +4256,7 @@ async fn run_direct_game_creator_turn_inner( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { @@ -4296,6 +4310,7 @@ async fn run_direct_game_creator_turn_inner( Some(&client_turn_id), Some(&mut observer), audit, + direct_user_item.clone(), ) .await } else { @@ -4306,6 +4321,7 @@ async fn run_direct_game_creator_turn_inner( None, None, audit, + direct_user_item.clone(), ) .await } @@ -4597,95 +4613,6 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( )) } -fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { - let Some(client_turn_id) = client_turn_id else { - return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); - }; - let client_turn_id = client_turn_id.trim(); - let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) - .contains(&client_turn_id.len()); - let mut bytes = client_turn_id.bytes(); - let valid_first = bytes - .next() - .is_some_and(|byte| byte.is_ascii_alphanumeric()); - let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); - if !valid_length || !valid_first || !valid_rest { - return Err(format!( - "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - )); - } - Ok(client_turn_id.to_string()) -} - -#[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( - project_path: String, - prompt: String, - creation_type: Option, - client_turn_id: Option, - attachments: Option>, - references: Option>, -) -> Result { - let root = Path::new(project_path.trim()); - let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; - recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) - })?; - let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - let references = references.unwrap_or_default(); - let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) { - Ok(prompt) => prompt, - Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(), - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - if let Some(reference_section) = match render_direct_codex_references_section(root, &references) - { - Ok(section) => section, - Err(error) => { - audit.finish(false); - return Err(error); - } - } { - if !user_prompt.trim().is_empty() { - user_prompt.push_str("\n\n"); - } - user_prompt.push_str(&reference_section); - } - if user_prompt.trim().is_empty() { - audit.finish(false); - return Err("聊天内容不能为空".to_string()); - } - let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, - &user_prompt, - creation_type.as_deref(), - Some(&turn_emitter), - Some(&mut audit), - ) - .await - { - Ok(reply) => reply, - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - audit.finish(true); - turn_emitter.emit("completed", Some("none"), Some(reply.clone())); - Ok(reply) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_home_direct_codex( prompt: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs new file mode 100644 index 000000000..4188bfd8f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -0,0 +1,84 @@ +//! DirectProject 用户输入命令适配器。 +//! +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item +//! 深模块,回合编排仍由父模块负责。 + +use super::*; + +fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { + let Some(client_turn_id) = client_turn_id else { + return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); + }; + let client_turn_id = client_turn_id.trim(); + let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) + .contains(&client_turn_id.len()); + let mut bytes = client_turn_id.bytes(); + let valid_first = bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if !valid_length || !valid_first || !valid_rest { + return Err(format!( + "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + )); + } + Ok(client_turn_id.to_string()) +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_direct_codex( + project_path: String, + prompt: String, + user_item: DirectCodexUserItem, + creation_type: Option, + client_turn_id: Option, + attachments: Option>, +) -> Result { + let root = Path::new(project_path.trim()); + let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; + let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; + recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { + redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) + })?; + let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); + let mut audit = DirectCodexTurnAudit::start( + root, + &turn_id, + &prompt, + attachments.as_deref().unwrap_or_default(), + ); + let attachments = attachments.unwrap_or_default(); + validate_direct_codex_user_item(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + if user_prompt.trim().is_empty() { + audit.finish(false); + return Err("聊天内容不能为空".to_string()); + } + let canonical_user_item = + Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?); + let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, + &user_prompt, + creation_type.as_deref(), + Some(&turn_emitter), + Some(&mut audit), + canonical_user_item, + ) + .await + { + Ok(reply) => reply, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + audit.finish(true); + turn_emitter.emit("completed", Some("none"), Some(reply.clone())); + Ok(reply) +} From 5bc734030493aeadeb36392d16ab91eca641cd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:22:39 +0800 Subject: [PATCH 06/54] =?UTF-8?q?=E6=8A=BD=E7=A6=BB=20DirectProject=20?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E8=A7=A3=E6=9E=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将项目 canonical identity 与 digest 放入独立文件 保持 Codex app-server 线程池调用接口不变 --- .../direct_project_identity.rs | 50 +++++++++++++++++++ .../src/agent/codex_app_server/mod.rs | 48 +----------------- 2 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs new file mode 100644 index 000000000..4454a2a48 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -0,0 +1,50 @@ +//! DirectProject 线程池身份的 canonical 解析与摘要。 + +use super::super::*; +use sha2::{Digest, Sha256}; + +pub(super) fn direct_codex_canonical_project_identity( + root: &std::path::Path, +) -> Result<(std::path::PathBuf, String), String> { + let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; + let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) + .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; + let manifest_project_id = manifest.project_id.trim(); + if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { + return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); + } + let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); + Ok(( + canonical_root, + direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), + )) +} + +fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + return path.as_os_str().as_bytes().to_vec(); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + let mut bytes = Vec::new(); + for unit in path.as_os_str().encode_wide() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + return bytes; + } + #[cfg(not(any(unix, windows)))] + path.as_os_str().to_string_lossy().as_bytes().to_vec() +} + +fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(b"genarrative-direct-project-identity.v1\0"); + digest.update((path_identity.len() as u64).to_le_bytes()); + digest.update(path_identity); + digest.update((project_id.len() as u64).to_le_bytes()); + digest.update(project_id); + format!("{:x}", digest.finalize()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 506b038f4..d6174b5d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -12,6 +12,8 @@ use uuid::Uuid; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; +mod direct_project_identity; +use direct_project_identity::*; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -3778,52 +3780,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( .await } -fn direct_codex_canonical_project_identity( - root: &std::path::Path, -) -> Result<(std::path::PathBuf, String), String> { - let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; - let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) - .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; - let manifest_project_id = manifest.project_id.trim(); - if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { - return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); - } - let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); - Ok(( - canonical_root, - direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), - )) -} - -fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - return path.as_os_str().as_bytes().to_vec(); - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - let mut bytes = Vec::new(); - for unit in path.as_os_str().encode_wide() { - bytes.extend_from_slice(&unit.to_le_bytes()); - } - return bytes; - } - #[cfg(not(any(unix, windows)))] - path.as_os_str().to_string_lossy().as_bytes().to_vec() -} - -fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String { - let mut digest = Sha256::new(); - digest.update(b"genarrative-direct-project-identity.v1\0"); - digest.update((path_identity.len() as u64).to_le_bytes()); - digest.update(path_identity); - digest.update((project_id.len() as u64).to_le_bytes()); - digest.update(project_id); - format!("{:x}", digest.finalize()) -} - pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, From d48d09905dd604ceb4c6de4565b45f74f47d3d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:42:27 +0800 Subject: [PATCH 07/54] =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E4=BD=BF=E7=94=A8=20Di?= =?UTF-8?q?rectProject=20Response=20item=20=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 game-creator-conversation.v1 读取 fallback 历史注入前逐项投影 canonical user item 为 Codex message 无效 item 在持久化前失败关闭 --- .../direct_project_history_wire.rs | 9 +- .../src/agent/codex_app_server/mod.rs | 6 +- .../src/agent/direct_codex_user_item/mod.rs | 5 +- .../src/agent/direct_codex_user_item/wire.rs | 30 +++ .../src/agent/direct_project_history.rs | 240 ++---------------- 5 files changed, 66 insertions(+), 224 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs index b958bd982..627002154 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -11,8 +11,15 @@ pub(super) fn build_direct_project_history_injection_params( history_root: &Path, thread_id: &str, ) -> Result { - let items = read_direct_project_history_items_at(history_root) + let canonical_items = read_direct_project_history_items_at(history_root) .map_err(platform_llm::LlmError::InvalidRequest)?; + let items = canonical_items + .iter() + .map(|item| { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest) + }) + .collect::, _>>()?; let params = serde_json::json!({"threadId": thread_id, "items": items}); let payload_bytes = serde_json::to_vec(¶ms) .map(|bytes| bytes.len().saturating_add(1)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index d6174b5d8..a86ef26b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -2702,7 +2702,11 @@ impl CodexAppServerConnection { } if let Some(client_turn_id) = direct_client_turn_id { let user_item = match direct_user_item { - Some(item) => item.clone(), + Some(item) => { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + item.clone() + } None => direct_project_local_message_item( "user", current_prompt, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index d591f4dad..d5ccc94c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -9,4 +9,7 @@ pub(crate) use model::{ DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; -pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; +pub(crate) use wire::{ + direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, + direct_codex_user_item_to_wire_input, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c2ab37897..1a07d2c95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,6 +4,36 @@ use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; use serde_json::Value; use std::path::Path; +/// 将历史中的 canonical user item 投影为 Codex `response_item` message。 +/// 非 user message 的标准 Response item 原样返回;未知形状直接失败。 +pub(crate) fn direct_codex_user_item_to_response_item( + root: &Path, + item: &Value, +) -> Result { + let is_user_message = item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user"); + if !is_user_message { + if item.get("type").and_then(Value::as_str).is_some() { + return Ok(item.clone()); + } + return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string()); + } + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?; + let Value::Array(content) = direct_codex_user_item_to_wire_input(root, &canonical)? else { + return Err("DirectProject user item wire content 不是数组".to_string()); + }; + let mut projected = serde_json::json!({ + "type": "message", + "role": "user", + "content": content, + }); + if let Some(id) = item.get("id").and_then(Value::as_str) { + projected["id"] = Value::String(id.to_string()); + } + Ok(projected) +} + /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index df80cc7ca..086a9b1f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -17,9 +17,6 @@ const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1; /// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享, /// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。 pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; -/// 格式切换到 `response_item`(#282)之前,DirectProject 主对话通过通用对话写入器 -/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。 -const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ "host_skills.instructions", "permissions.instructions", @@ -180,79 +177,22 @@ fn direct_project_history_item_from_line( Ok(Some(item)) } -/// 只接受两种行信封:`response_item`,以及白名单化的 legacy 行。返回 `None` 表示行已 -/// 被识别、但不该进入 Codex 上下文(legacy 的 `tool` 行)。 -/// 其余任何形状(含换了 `schemaVersion`、带 `type` 却不是 `response_item`、role 不在 -/// legacy 写入器自己的角色集合内、content 不是非空字符串)都判定为损坏并失败关闭。 +/// 只接受 `response_item` 行信封;其它历史格式不提供迁移或 fallback,直接失败关闭。 fn direct_project_history_item_from_parsed_line( path: &Path, parsed: &Value, ) -> Result, String> { - if parsed.get("type").and_then(Value::as_str) == Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { - return parsed - .get("payload") - .cloned() - .map(Some) - .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())); + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); } - direct_project_legacy_row(parsed) - .map(|row| row.message_item()) - .ok_or_else(|| format!("DirectProject 历史记录类型无效:{}", path.display())) -} - -/// 白名单化 legacy 行的投影结果。 -enum DirectProjectLegacyRow { - /// user/assistant 行:投影成 message item 进入历史。 - Message(Value), - /// 已识别但不可注入的行(`tool`):与 developer/system item 同样处理,不进 Codex - /// 上下文。legacy 行不是 Responses item,`tool` 行无法还原成真正的工具 item, - /// 注入会造出假的工具消息;聊天投影本来也只展示 user/assistant。 - NotChat, -} - -impl DirectProjectLegacyRow { - fn message_item(self) -> Option { - match self { - DirectProjectLegacyRow::Message(item) => Some(item), - DirectProjectLegacyRow::NotChat => None, - } - } -} - -/// 格式切换前的 legacy 行投影。存量用户项目的历史文件全是这种行,读取时投影成与 -/// `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` -/// 原样保留(不 trim、不改写、不合并),未知字段忽略。 -/// -/// 角色白名单取的是 legacy 写入器自己的角色集合,也就是 -/// `project/conversation.rs` 里 `matches!(role, "user" | "assistant" | "tool")` 这一 -/// 条校验,所以「legacy 写入器能写出的行」被完整覆盖,不会有第三种角色漏进来; -/// 白名单之外的角色(手改文件、未来写入器)仍按损坏失败关闭。 -fn direct_project_legacy_row(parsed: &Value) -> Option { - let object = parsed.as_object()?; - if object.contains_key("type") - || object.get("schemaVersion").and_then(Value::as_str) - != Some(DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION) - { - return None; - } - let role = object.get("role").and_then(Value::as_str)?; - if !matches!(role, "user" | "assistant" | "tool") { - return None; - } - let content = object.get("content").and_then(Value::as_str)?; - if content.is_empty() { - return None; - } - if role == "tool" { - return Some(DirectProjectLegacyRow::NotChat); - } - let message_id = object - .get("messageId") - .and_then(Value::as_str) - .filter(|message_id| !message_id.is_empty()); - Some(DirectProjectLegacyRow::Message( - direct_project_message_item(role, content, message_id), - )) + parsed + .get("payload") + .cloned() + .map(Some) + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())) } /// 测试专用探针:在"锁外幂等回扫"这一段的两端回调。 @@ -637,9 +577,8 @@ mod tests { std::fs::write(&path, format!("{}\n", lines.join("\n"))).expect("write history fixture"); } - const LEGACY_USER_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"user","content":"请创建菜单","agentId":null,"messageId":"direct-codex:turn-0001:user","updatedAt":1757000000}"#; - const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#; const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; + const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// @@ -648,7 +587,7 @@ mod tests { #[test] fn contention_failure_is_retried_with_bounded_backoff() { let root = init_history_project("contention-retry"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -680,7 +619,7 @@ mod tests { #[test] fn contention_failure_beyond_the_backoff_budget_fails_closed() { let root = init_history_project("contention-bounded"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -723,7 +662,7 @@ mod tests { write_history_lines( root.path(), &[ - LEGACY_USER_ROW, + RESPONSE_ITEM_ROW, r#"{"schemaVersion":"game-creator"#, RESPONSE_ITEM_ROW, ], @@ -761,7 +700,7 @@ mod tests { let root = init_history_project("scan-outside-lock"); write_history_lines( root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], + &[RESPONSE_ITEM_ROW, RESPONSE_ITEM_ROW, RESPONSE_ASSISTANT_ROW], ); let path = history_path(root.path()); let fired = Arc::new(AtomicBool::new(false)); @@ -805,7 +744,7 @@ mod tests { #[test] fn concurrent_append_after_the_out_of_lock_scan_still_prevents_a_duplicate_row() { let root = init_history_project("scan-stale-state"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let path = history_path(root.path()); let item = json!({ "type": "message", @@ -845,7 +784,7 @@ mod tests { #[test] fn conflicting_item_id_still_fails_closed_with_the_scan_outside_the_lock() { let root = init_history_project("id-conflict-outside-lock"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); append_direct_project_history_item_at( root.path(), &json!({ @@ -1033,91 +972,7 @@ mod tests { } #[test] - fn legacy_conversation_rows_project_into_responses_message_items() { - let root = init_history_project("legacy-projection"); - write_history_lines(root.path(), &[LEGACY_USER_ROW, LEGACY_ASSISTANT_ROW]); - - let items = read_direct_project_history_items_at(root.path()).expect("read legacy history"); - assert_eq!( - items, - vec![ - json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-0001:user", - "content": [{"type": "input_text", "text": "请创建菜单"}], - }), - json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "已完成 第一行\n第二行 "}], - }), - ] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read legacy chat"); - assert_eq!(chat.messages.len(), 2); - assert_eq!(chat.messages[0].role, "user"); - assert_eq!(chat.messages[0].content, "请创建菜单"); - assert_eq!( - chat.messages[0].message_id.as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(chat.messages[1].role, "assistant"); - assert_eq!(chat.messages[1].content, "已完成 第一行\n第二行 "); - assert_eq!(chat.messages[1].message_id, None); - } - - #[test] - fn mixed_legacy_and_response_item_history_reads_in_file_order() { - let root = init_history_project("mixed-history"); - write_history_lines( - root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read mixed history"); - assert_eq!( - items - .iter() - .map(|item| item.get("id").and_then(Value::as_str)) - .collect::>(), - vec![ - Some("direct-codex:turn-0001:user"), - Some("codex-item-2"), - None - ] - ); - assert_eq!( - items[1], - json!({ - "type": "message", - "role": "user", - "id": "codex-item-2", - "content": [{"type": "input_text", "text": "再加一个按钮"}], - }) - ); - assert_eq!( - items[2]["content"][0]["text"], - json!("已完成 第一行\n第二行 ") - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read mixed chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| (message.role.as_str(), message.content.as_str())) - .collect::>(), - vec![ - ("user", "请创建菜单"), - ("user", "再加一个按钮"), - ("assistant", "已完成 第一行\n第二行 "), - ] - ); - } - - #[test] - fn legacy_compat_keeps_unrecognized_rows_failing_closed() { + fn non_response_item_history_fails_closed() { let unrecognized_rows = [ // 带 type 却不是 response_item r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"x"}]}"#, @@ -1159,61 +1014,4 @@ mod tests { .expect_err("broken json must fail closed"); assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); } - - #[test] - fn legacy_tool_row_is_recognized_but_stays_out_of_the_codex_context() { - let root = init_history_project("legacy-tool-row"); - write_history_lines( - root.path(), - &[ - LEGACY_USER_ROW, - r#"{"schemaVersion":"game-creator-conversation.v1","role":"tool","content":"{\"ok\":true}","agentId":null,"updatedAt":1757000002}"#, - LEGACY_ASSISTANT_ROW, - ], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read history"); - assert_eq!( - items - .iter() - .map(|item| item["role"].as_str().unwrap_or_default()) - .collect::>(), - vec!["user", "assistant"] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| message.role.as_str()) - .collect::>(), - vec!["user", "assistant"] - ); - } - - #[test] - fn generic_writer_rows_are_covered_by_the_legacy_whitelist() { - let root = init_history_project("generic-writer-shape"); - crate::append_local_conversation_message_at( - root.path(), - None, - crate::LocalConversationMessage { - role: "user".to_string(), - content: "通用写入器写的旧格式回合".to_string(), - agent_id: None, - }, - ) - .expect("append legacy row through the generic writer"); - - let items = - read_direct_project_history_items_at(root.path()).expect("read generic writer row"); - assert_eq!( - items, - vec![json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "通用写入器写的旧格式回合"}], - })] - ); - } } From 6d190e2f8c5a55ad1cfa873eaf694697b27851ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:55:15 +0800 Subject: [PATCH 08/54] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Response=20item=20?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E5=90=88=E5=90=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拒绝资源引用额外身份字段与未知 content part 补充历史 item wire 投影边界测试 同步 DirectProject 历史文档为无 fallback 合同 --- .../src/agent/direct_codex_user_item/model.rs | 66 +++++++++++++++++-- .../src/agent/direct_codex_user_item/wire.rs | 31 +++++++++ ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 12 ++-- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index f3837c648..b46336b66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -3,7 +3,7 @@ use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserItem { #[serde(rename = "message")] @@ -11,7 +11,7 @@ pub(crate) enum DirectCodexUserItem { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageItem { pub(crate) role: DirectCodexUserRole, @@ -27,7 +27,7 @@ pub(crate) enum DirectCodexUserRole { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] @@ -39,7 +39,7 @@ pub(crate) enum DirectCodexUserContentPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) label: String, @@ -62,8 +62,64 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageEnvelope { pub(crate) item: DirectCodexUserItem, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn resource_reference_serializes_with_only_camel_case_resource_id() { + let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: DirectCodexUserRole::User, + content: vec![DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }], + id: "turn-1".to_string(), + }); + assert_eq!( + serde_json::to_value(item).expect("serialize user item"), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero" + }], + "id": "turn-1" + }) + ); + } + + #[test] + fn resource_reference_rejects_extra_identity_fields() { + let error = serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero", + "label": "主角" + }], + "id": "turn-1" + })) + .expect_err("label must not be accepted on resource reference"); + assert!(error.to_string().contains("unknown field"), "{error}"); + } + + #[test] + fn unknown_content_part_fails_closed() { + serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{"type": "future_part", "value": "x"}], + "id": "turn-1" + })) + .expect_err("unknown content part must fail closed"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 1a07d2c95..99d4262c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -110,3 +110,34 @@ pub(crate) fn direct_codex_user_item_to_prompt( } }) } + +#[cfg(test)] +mod tests { + use super::direct_codex_user_item_to_response_item; + use serde_json::json; + use std::path::Path; + + #[test] + fn standard_response_item_passes_through_without_agc_private_parts() { + let item = json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "完成"}] + }); + assert_eq!( + direct_codex_user_item_to_response_item(Path::new("/unused"), &item) + .expect("assistant response item should pass through"), + item + ); + } + + #[test] + fn history_item_without_type_fails_closed() { + let error = direct_codex_user_item_to_response_item( + Path::new("/unused"), + &json!({"role": "assistant"}), + ) + .expect_err("history item without type must fail"); + assert!(error.contains("缺少 type"), "{error}"); + } +} diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index bf46784af..17bd7c6a3 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -16,16 +16,16 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 {"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} ``` -`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 +`payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 -读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。 +读取只接受 `response_item` envelope;旧 `game-creator-conversation.v1` 行不提供迁移或 fallback,直接失败关闭。未知或无法投影的 canonical item 在发送前失败,不能产生本轮新增历史。 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。 +2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 @@ -55,12 +55,10 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 -`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。 - -这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs` 在 `ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。 +`project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。 ## 写入与损坏边界 -写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写。 +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;非 `response_item` 行和无法投影的 item 直接失败,不做数据迁移或 fallback。 该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。 From 152041cd1971d8bcbc906b14ca463c9bd6caa007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 16:01:45 +0800 Subject: [PATCH 09/54] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A7=20DirectProje?= =?UTF-8?q?ct=20=E5=BC=95=E7=94=A8=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 DirectCodexTurnReference 平行 DTO 与渲染路径 将引用数量上限收回 canonical user item 校验模块 --- .../src-tauri/src/agent.rs | 2 - .../src/agent/direct_codex_references.rs | 382 ------------------ .../direct_codex_user_item/validation.rs | 3 +- 3 files changed, 2 insertions(+), 385 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 3940aeeb7..d084e6174 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -16,7 +16,6 @@ mod design_runtime; mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; -mod direct_codex_references; mod direct_codex_user_item; mod direct_project_history; mod direct_project_turn_history; @@ -46,7 +45,6 @@ pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; -pub(crate) use direct_codex_references::*; pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs deleted file mode 100644 index f1b78b1ca..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs +++ /dev/null @@ -1,382 +0,0 @@ -use super::*; - -pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200; -const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160; -const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80; -const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240; - -const DIRECT_CODEX_REFERENCE_HEADER: &str = - "[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]"; - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub(crate) enum DirectCodexTurnReference { - #[serde(rename = "resource")] - Resource(DirectCodexResourceReference), - #[serde(rename = "runtime-region")] - RuntimeRegion(DirectCodexRuntimeRegionReference), -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexResourceReference { - pub(crate) resource_id: String, - #[serde(default)] - pub(crate) label: Option, - #[serde(default)] - pub(crate) source: Option, -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexRuntimeRegionReference { - #[serde(default)] - pub(crate) label: Option, - #[serde(default)] - pub(crate) run_id: Option, - #[serde(default)] - pub(crate) version_id: Option, - #[serde(default)] - pub(crate) element_tag: Option, - #[serde(default)] - pub(crate) element_role: Option, - #[serde(default)] - pub(crate) text: Option, - #[serde(default)] - pub(crate) width: Option, - #[serde(default)] - pub(crate) height: Option, - #[serde(default)] - pub(crate) resource_ids: Vec, -} - -fn sanitize_reference_text(value: &str, max_chars: usize) -> Option { - let sanitized = value - .trim() - .chars() - .filter(|character| !character.is_control()) - .take(max_chars) - .collect::(); - if sanitized.is_empty() { - None - } else { - Some(sanitized) - } -} - -fn sanitize_reference_label(value: Option<&str>) -> Option { - value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)) -} - -fn sanitize_reference_source(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_element(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_dimension(value: Option) -> Option { - value - .filter(|value| value.is_finite() && *value > 0.0) - .map(|value| value.round().clamp(1.0, 100_000.0) as u32) -} - -fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String { - let basename = asset - .local_path - .rsplit(['/', '\\']) - .next() - .unwrap_or(&asset.id); - let without_extension = basename - .rsplit_once('.') - .map(|(name, _)| name) - .unwrap_or(basename) - .trim(); - if without_extension.is_empty() { - asset.id.clone() - } else { - without_extension - .chars() - .filter(|character| !character.is_control()) - .take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS) - .collect() - } -} - -fn validate_resource_reference_id(value: &str) -> Result { - let resource_id = value.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - Ok(resource_id.to_string()) -} - -fn render_resource_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexResourceReference, -) -> Result { - let resource_id = validate_resource_reference_id(&reference.resource_id)?; - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let local_path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| asset_display_label(asset)); - let source = sanitize_reference_source(reference.source.as_deref()) - .unwrap_or_else(|| "unknown".to_string()); - Ok(format!( - "- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}", - asset.kind, asset.media_type - )) -} - -fn render_runtime_region_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexRuntimeRegionReference, -) -> Result { - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| "运行画面区域".to_string()); - let run_id = sanitize_reference_source(reference.run_id.as_deref()); - let version_id = sanitize_reference_source(reference.version_id.as_deref()); - let element_tag = sanitize_reference_element(reference.element_tag.as_deref()); - let element_role = sanitize_reference_element(reference.element_role.as_deref()); - let text = reference - .text - .as_deref() - .and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS)); - let width = sanitize_reference_dimension(reference.width); - let height = sanitize_reference_dimension(reference.height); - - // `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时, - // 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界 - // 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的 - // 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。 - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选" - )); - } - let mut related_resource_ids = Vec::new(); - for resource_id in &reference.resource_ids { - let resource_id = validate_resource_reference_id(resource_id)?; - if !manifest.assets.iter().any(|asset| asset.id == resource_id) { - return Err("运行画面引用的素材已变化,请重新点选".to_string()); - } - // 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。 - // 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。 - if !related_resource_ids.contains(&resource_id) { - related_resource_ids.push(resource_id); - } - } - - let mut parts = vec![format!("名称:{label}")]; - if let Some(run_id) = run_id { - parts.push(format!("运行标识:{run_id}")); - } - if let Some(version_id) = version_id { - parts.push(format!("版本标识:{version_id}")); - } - if let Some(element_tag) = element_tag { - parts.push(format!("元素:{element_tag}")); - } - if let Some(element_role) = element_role { - parts.push(format!("角色:{element_role}")); - } - if let Some(text) = text { - parts.push(format!("文本摘要:{text}")); - } - if let (Some(width), Some(height)) = (width, height) { - parts.push(format!("尺寸:{width}x{height}")); - } - if !related_resource_ids.is_empty() { - parts.push(format!("关联素材 ID:{}", related_resource_ids.join(","))); - } - Ok(format!("- 运行画面区域:{}", parts.join(";"))) -} - -pub(crate) fn render_direct_codex_references_section( - root: &Path, - references: &[DirectCodexTurnReference], -) -> Result, String> { - if references.is_empty() { - return Ok(None); - } - if references.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - let manifest = read_manifest_for_project(root)?; - let mut lines = Vec::with_capacity(references.len()); - for reference in references { - lines.push(match reference { - DirectCodexTurnReference::Resource(reference) => { - render_resource_reference_line(&manifest, reference)? - } - DirectCodexTurnReference::RuntimeRegion(reference) => { - render_runtime_region_reference_line(&manifest, reference)? - } - }); - } - Ok(Some( - std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string()) - .chain(lines) - .collect::>() - .join("\n"), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture_project() -> tempfile::TempDir { - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - std::fs::create_dir_all(root.join(".agent")).expect("create agent dir"); - let mut manifest = new_game_creation_app_manifest("project-1", "测试项目"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "asset-hero".to_string(), - kind: "character".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/hero.png".to_string(), - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - image_sequence_frames: None, - image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("character"), - tags: Vec::new(), - }); - write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); - directory - } - - #[test] - fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#, - ) - .expect("reference json"); - let section = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect("render") - .expect("section"); - assert!(section.contains("素材 ID:asset-hero")); - assert!(section.contains("名称:主角")); - assert!(section.contains("项目路径:assets/hero.png")); - assert!(!section.contains("C:\\secret.png")); - } - - #[test] - fn deleted_resource_fails_closed() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#, - ) - .expect("reference json"); - let error = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect_err("missing resource"); - assert_eq!(error, "引用的素材已不存在,请移除后重新选择"); - } - - #[test] - fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":" ) : null} {visibleMessages.map((message, index) => ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index dd699846a..05e7c1056 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -157,6 +157,7 @@ type ProjectWorkspaceChatPaneProps = { handleRevealCurrentProjectDirectory: () => Promise; handleRuntimeConfigOpen: () => void; hiddenConversationCount: number; + hasEarlierConversationMessages?: boolean; llmConfigStatus: GameCreatorLlmConfigStatus | null; loadProjectConversation: ( nextProjectPath: string, @@ -256,6 +257,7 @@ export function ProjectWorkspaceChatPane({ handleRevealCurrentProjectDirectory, handleRuntimeConfigOpen, hiddenConversationCount, + hasEarlierConversationMessages = false, llmConfigStatus, loadProjectConversation, localProject, @@ -809,13 +811,15 @@ export function ProjectWorkspaceChatPane({ ) : null}
- {hiddenConversationCount > 0 ? ( + {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( ) : null} {visibleMessages.map((message, index) => ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index c4aa1640c..5e4a391e5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -71,6 +71,7 @@ type SupervisorChatOnlyViewProps = { transientReply: string; hasConversationControls: boolean; hiddenConversationCount: number; + hasEarlierConversationMessages?: boolean; needsUserInput: boolean; visibleMessages: ChatMessage[]; workspaceStatus: string; @@ -108,6 +109,7 @@ export function SupervisorChatOnlyView({ transientReply, hasConversationControls, hiddenConversationCount, + hasEarlierConversationMessages = false, needsUserInput, visibleMessages, workspaceStatus, @@ -188,12 +190,16 @@ export function SupervisorChatOnlyView({ aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'} onScroll={handleScroll} > - {hiddenConversationCount > 0 ? ( + {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( + > + {hiddenConversationCount > 0 + ? `显示更早 · 还有 ${hiddenConversationCount} 条对话` + : '显示更早的对话'} + ) : null} {visibleMessages.map((message, index) => (
{ } ``` -`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。 +`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。只要 DirectProject 历史切片返回 `hasMore`,聊天视图必须显示“显示更早的对话”入口,并允许按钮或滚动触发下一页,即使当前可见消息窗口没有隐藏消息。 `consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。 From 01e97d3a0255a3854f91282ce2491087ad82823b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:25:55 +0800 Subject: [PATCH 39/54] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Direct=20=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=B7=A8=E9=A1=B5=E9=9D=A2=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增活动 Direct 回合只读快照与状态更新 接入重进项目的忙碌态恢复和运行中项目面板 补充生命周期规范、决策记录与定向测试 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 113 +++++++++++++++++ .../src/agent/runtime_driver/entrypoints.rs | 14 ++- .../src-tauri/src/commands.rs | 6 + .../src-tauri/src/main.rs | 1 + apps/ai-game-creator-shell/src/App.tsx | 24 ++++ apps/ai-game-creator-shell/src/app/types.ts | 11 ++ .../agent-runtime/directActiveTurns.ts | 110 ++++++++++++++++ .../app-shell/ActiveProjectRunsPanel.tsx | 119 ++++++++++++++++++ .../features/app-shell/WorkspaceLauncher.tsx | 17 +++ apps/ai-game-creator-shell/src/styles.css | 74 +++++++++++ .../tests/directActiveTurns.test.tsx | 51 ++++++++ ...合跨页面生命周期与运行中项目可见性-2026-09-15.md | 51 ++++++++ ...合跨页面生命周期与运行中项目可见性-2026-09-15.md | 33 +++++ .../shared-memory/decision-log.md | 3 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 ++ 15 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx create mode 100644 apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx create mode 100644 docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 769ab2566..cb27517fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5,6 +5,7 @@ use std::future::Future; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; mod user_input; pub(crate) use user_input::{chat_with_game_creator_direct_codex, normalize_direct_client_turn_id}; @@ -266,6 +267,25 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String #[derive(Debug)] struct DirectTaonierActiveInvocation { invocation_id: String, + project_name: Option, + started_at: u64, + status: String, + activity: Option, + updated_at: u64, + sequence: u64, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectActiveTurnSnapshot { + pub(crate) project_path: String, + pub(crate) project_name: Option, + pub(crate) turn_id: String, + pub(crate) started_at: u64, + pub(crate) status: String, + pub(crate) activity: Option, + pub(crate) updated_at: u64, + pub(crate) sequence: u64, } static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< @@ -303,6 +323,18 @@ impl DirectTaonierActiveInvocationGuard { root.clone(), DirectTaonierActiveInvocation { invocation_id: invocation_id.to_string(), + project_name: root + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string), + started_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: 0, + sequence: 0, }, ); } @@ -331,6 +363,57 @@ impl Drop for DirectTaonierActiveInvocationGuard { } } +pub(crate) fn list_direct_active_turns() -> Result, String> { + let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏".to_string())?; + let mut turns = active + .iter() + .map(|(root, invocation)| DirectActiveTurnSnapshot { + project_path: root.to_string_lossy().into_owned(), + project_name: invocation.project_name.clone(), + turn_id: invocation.invocation_id.clone(), + started_at: invocation.started_at, + status: invocation.status.clone(), + activity: invocation.activity.clone(), + updated_at: invocation.updated_at, + sequence: invocation.sequence, + }) + .collect::>(); + turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); + Ok(turns) +} + +pub(crate) fn update_direct_active_turn( + root: &Path, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, +) { + let Ok(root) = root.canonicalize() else { + return; + }; + let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { + return; + }; + let Ok(mut active) = active.lock() else { + return; + }; + let Some(invocation) = active.get_mut(&root) else { + return; + }; + if invocation.invocation_id != turn_id || sequence < invocation.sequence { + return; + } + invocation.status = status.to_string(); + invocation.activity = activity.map(str::to_string); + invocation.updated_at = updated_at; + invocation.sequence = sequence; +} + pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { let root = root .canonicalize() @@ -4773,6 +4856,36 @@ mod tests { .expect("lost-response replay after the original turn finishes"); } + #[test] + fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { + let root = tempfile::tempdir().expect("active snapshot root"); + let turn_id = "client-turn-snapshot-0001"; + let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id) + .expect("active snapshot turn"); + update_direct_active_turn( + root.path(), + turn_id, + "streaming", + Some("response-finalization"), + 3, + 42, + ); + let snapshot = list_direct_active_turns() + .expect("list active turns") + .into_iter() + .find(|turn| turn.turn_id == turn_id) + .expect("snapshot entry"); + assert_eq!(snapshot.status, "streaming"); + assert_eq!(snapshot.activity.as_deref(), Some("response-finalization")); + assert_eq!(snapshot.sequence, 3); + assert_eq!(snapshot.updated_at, 42); + drop(guard); + assert!(list_direct_active_turns() + .expect("list after completion") + .into_iter() + .all(|turn| turn.turn_id != turn_id)); + } + #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 2a1b77488..76b85ef7b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -74,15 +74,23 @@ impl DirectGameCreatorTurnUpdateEmitter { if !status_is_allowed || !activity_is_allowed { return; } - let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { - return; - }; let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; let updated_at = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() .min(u64::MAX as u128) as u64; + update_direct_active_turn( + Path::new(&self.project_path), + &self.turn_id, + status, + activity, + sequence, + updated_at, + ); + let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { + return; + }; let _ = app.emit( "game-creator-direct-turn-update", GameCreatorDirectTurnUpdateEvent { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a17b0e3f3..6b00b2ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5279,6 +5279,12 @@ pub(crate) async fn read_agent_runtime_error_detail( .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) fn list_game_creator_direct_active_turns( +) -> Result, String> { + list_direct_active_turns() +} + #[tauri::command] pub(crate) async fn subscribe_direct_project_thread( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e68dee027..660ff5699 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2770,6 +2770,7 @@ fn main() { read_local_conversation, read_direct_project_conversation, read_agent_runtime_error_detail, + list_game_creator_direct_active_turns, subscribe_direct_project_thread, consume_direct_project_thread, read_direct_project_history_slice, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9d847517d..7d0e734d6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -57,6 +57,7 @@ import type { DesignView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, + GameCreatorDirectActiveTurn, GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, @@ -1955,6 +1956,29 @@ export function App({ }; const bootstrap = async () => { + try { + const activeTurns = await directInvoke( + 'list_game_creator_direct_active_turns', + ); + const activeTurn = activeTurns.find((turn) => + projectPathsMatchForInvalidation(turn.projectPath, projectPath), + ); + if (activeTurn && !activeDirectCodexTurnRef.current) { + activeDirectCodexTurnRef.current = { + projectPath, + turnId: activeTurn.turnId, + lastSequence: 0, + receivedDirectUpdate: true, + }; + setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`); + setChatAgentBusy(true); + setDirectCodexStatus('running'); + setDirectCodexProgress('正在处理'); + setDirectCodexProgressUpdatedAt(Date.now()); + } + } catch { + // 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。 + } const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 64a79bfab..4093a0b1f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1258,3 +1258,14 @@ export type TauriInvoke = ( command: string, args?: Record, ) => Promise; + +export type GameCreatorDirectActiveTurn = { + projectPath: string; + projectName?: string | null; + turnId: string; + startedAt: number; + status: string; + activity?: string | null; + updatedAt: number; + sequence: number; +}; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts new file mode 100644 index 000000000..0eadbeacd --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types'; + +/** + * 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。 + * "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。 + */ +export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000; + +/** + * 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把 + * "读不到"告诉用户;但即便告诉,也只能说读取失败,不得改写成业务失败、 + * 权限问题或审批结论。 + */ +export const DIRECT_ACTIVE_TURNS_READ_ATTEMPTS = 3; +const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300; + +/** + * 当前进程里仍在跑的 Direct 回合。 + * + * 回合属于项目而不是页面:离开项目界面不会终止它,所以"谁在跑"必须从 Rust 的 + * 活动回合注册表读,而不是从当前页面的组件状态推断。读取失败保留上一份快照—— + * 读不到不等于"没有在跑",调用方不能据此阻断发送或清空状态。 + */ +export function useDirectActiveTurns({ + invoke, + enabled, + pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, +}: { + invoke: TauriInvoke | null | undefined; + enabled: boolean; + pollIntervalMs?: number; +}) { + const [activeTurns, setActiveTurns] = useState( + [], + ); + const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); + const mountedRef = useRef(true); + const inFlightRef = useRef | null>(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refreshActiveTurns = useCallback(async () => { + if (!invoke) { + return; + } + // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 + if (inFlightRef.current) { + return inFlightRef.current; + } + const request = (async () => { + for ( + let attempt = 1; + attempt <= DIRECT_ACTIVE_TURNS_READ_ATTEMPTS; + attempt++ + ) { + try { + const turns = await invoke( + 'list_game_creator_direct_active_turns', + ); + if (!mountedRef.current) { + return; + } + setActiveTurns(Array.isArray(turns) ? turns : []); + setSnapshotReadFailed(false); + inFlightRef.current = null; + return; + } catch { + if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { + await new Promise((resolve) => + window.setTimeout( + resolve, + DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt, + ), + ); + } + } + } + // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 + if (mountedRef.current) { + setSnapshotReadFailed(true); + } + inFlightRef.current = null; + })(); + inFlightRef.current = request; + return request; + }, [invoke]); + + useEffect(() => { + if (!enabled || !invoke) { + setActiveTurns([]); + setSnapshotReadFailed(false); + return; + } + void refreshActiveTurns(); + const timer = window.setInterval( + () => void refreshActiveTurns(), + Math.max(1_000, pollIntervalMs), + ); + return () => window.clearInterval(timer); + }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); + + return { activeTurns, refreshActiveTurns, snapshotReadFailed }; +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx new file mode 100644 index 000000000..4147abb9b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -0,0 +1,119 @@ +import type { GameCreatorDirectActiveTurn } from '../../app/types'; +import { projectNameFromPath } from '../agent-runtime'; +import { projectPathsMatchForInvalidation } from '../project-summary/projectPath'; + +/** + * 左上角的"正在运行的项目"面板。 + * + * 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连), + * 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时 + * 整块不渲染,不留空白占位。 + */ +export type ActiveProjectRunsPanelProps = { + activeTurns: GameCreatorDirectActiveTurn[]; + currentProjectPath?: string | null; + readFailed?: boolean; + onOpenProject?: (projectPath: string) => void; +}; + +const ACTIVE_TURN_STATUS_LABELS: Record = { + accepted: '已受理', + running: '创作中', + streaming: '生成中', + finalizing: '收尾中', + completed: '已完成', + failed: '已失败', +}; + +function activeTurnStatusLabel(status: string) { + return ACTIVE_TURN_STATUS_LABELS[status] ?? '创作中'; +} + +function formatActiveTurnElapsed(startedAt: number, now: number) { + const elapsedMs = now - startedAt; + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + return ''; + } + const totalMinutes = Math.floor(elapsedMs / 60_000); + if (totalMinutes < 1) { + return '不到 1 分钟'; + } + if (totalMinutes < 60) { + return `${totalMinutes} 分钟`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`; +} + +function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) { + const snapshotName = turn.projectName?.trim(); + return snapshotName || projectNameFromPath(turn.projectPath); +} + +export function ActiveProjectRunsPanel({ + activeTurns, + currentProjectPath = null, + readFailed = false, + onOpenProject, +}: ActiveProjectRunsPanelProps) { + if (activeTurns.length === 0) { + if (!readFailed) { + return null; + } + // 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。 + return ( + + ); + } + + const now = Date.now(); + const orderedTurns = [...activeTurns].sort( + (left, right) => left.startedAt - right.startedAt, + ); + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 58a188e6f..c0f0edb40 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -25,8 +25,10 @@ import { type ProjectManifestSnapshotSource, rereadAuthoritativeProjectManifestSnapshot, } from '../../view/project-development/projectResourceLiveUpdateModel'; +import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; +import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel'; import { DeveloperAgentDialogs, DeveloperAgentPanel, @@ -96,6 +98,11 @@ export function WorkspaceLauncherShell({ homeCreationBusy, homeCreationRecoverableProjectPath, } = homeProject; + const directInvoke = resolveTauriInvoke(); + const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({ + invoke: directInvoke, + enabled: true, + }); const switchedToGameRuntime = gameRuntimeSwitch !== null && currentProjectContext !== null && @@ -517,6 +524,16 @@ export function WorkspaceLauncherShell({ ) : null} + { + setProjectPath(nextProjectPath); + void openProject(nextProjectPath, 'open'); + }} + /> + {launcherView === 'home' ? ( 0} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 8f92d51fe..6dfa52a06 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -903,6 +903,80 @@ textarea { overflow: hidden; } +.launcher-runs-panel { + position: absolute; + top: 16px; + left: 24px; + z-index: 3; + width: min(280px, calc(100% - 260px)); + padding: 10px 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent); + box-shadow: 0 8px 24px rgb(31 24 16 / 8%); +} + +.launcher-runs-panel-header { + display: flex; + align-items: center; + gap: 7px; + color: var(--platform-text-strong); + font-size: 12px; +} + +.launcher-runs-panel-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4d9f54; + box-shadow: 0 0 0 3px rgb(77 159 84 / 14%); +} + +.launcher-runs-panel-list { + display: grid; + gap: 4px; + margin: 7px 0 0; + padding: 0; + list-style: none; +} + +.launcher-runs-panel-item { + display: grid; + width: 100%; + gap: 2px; + padding: 6px 7px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.launcher-runs-panel-item:hover, +.launcher-runs-panel-item[aria-current='true'] { + background: rgb(255 255 255 / 72%); +} + +.launcher-runs-panel-item:disabled { + cursor: default; +} + +.launcher-runs-panel-name { + overflow: hidden; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-runs-panel-meta, +.launcher-runs-panel-note { + color: var(--platform-text-muted); + font-size: 11px; +} + .launcher-promo { display: flex; align-items: center; diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx new file mode 100644 index 000000000..c075aaef3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; + +afterEach(() => cleanup()); + +it('按开始时间展示正在运行的项目并支持进入项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); + + const items = screen.getAllByRole('button'); + expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ + true, + false, + ]); + fireEvent.click(items[0]); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); +}); + +it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { + render(); + + expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目'); +}); diff --git a/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..df94a1580 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,51 @@ +# 【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md` + +## 固定契约 + +只读快照命令(Tauri 本地命令,`src-tauri/src/agent/direct_runtime/mod.rs`): + +- `list_game_creator_direct_active_turns() -> Vec` +- 字段(camelCase):`projectPath`、`projectName`、`turnId`、`status`、`activity`(可空)、`startedAt`、`updatedAt`、`sequence` +- `status` 取值集合与既有 Direct 回合事件一致:`accepted` / `running` / `streaming` / `finalizing` / `completed` / `failed` + +身份锁与快照共用同一份进程内注册表;注册表条目在回合进入时写入 `startedAt` 与 `projectName`,在每次回合事件发射时更新 `status` / `activity` / `sequence` / `updatedAt`,在回合结束(guard drop)时移除。 + +## 代码边界 + +- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs`:注册表结构扩展、快照读写、新命令、Rust 定向测试 +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`:事件发射时投影到注册表 +- `apps/ai-game-creator-shell/src-tauri/src/main.rs`:命令注册 +- `apps/ai-game-creator-shell/src/App.tsx`:项目打开时重连、忙碌态与进度恢复、面板挂载 +- `apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`:快照轮询与单飞刷新(面板与重连共用) +- 面板组件(新文件,落在既有 feature 目录下)+ 对应测试 +- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` + 测试:报错归类修正 + +## 修改顺序 + +1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。 +2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。 +3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。 +4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。 +5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_active_turns -- --test-threads=1`(名称按实际用例调整) +- `npx vitest run apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts` +- `npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts` +- 面板组件测试文件单独一条 vitest +- `npm --prefix apps/ai-game-creator-shell run typecheck` +- `npm run check:encoding`、`git diff --check` + +## 风险与回滚点 + +- 快照命令暴露项目绝对路径给前端:与现有 `projectPath` 口径一致,不得额外泄露配置或 token;命令必须是只读、无副作用。 +- 续接基线 `sequence` 若取错,会让重新进入后的进度事件被丢弃或重复消费;取错时回滚“重连”部分,保留只读面板。 +- 忙碌态恢复不得与既有 `chatAgentBusy` 的失败清理互相覆盖;出现卡死忙碌态时优先回滚重连,不影响身份锁与后台回合本体。 +- 面板若在窄窗口挤压主内容,先按既有响应式约定隐藏面板,不改主布局。 +- 报错归类修正若与既有断言冲突,先确认断言锁的是“正确行为”还是历史错误文案,再决定改断言还是改实现。 diff --git a/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..b8ead65f3 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,33 @@ +# 【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性」 + +## 目标 + +离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。 + +## 边界 + +- 只读投影:新增命令只读当前 GUI 进程内的活动回合注册表,不写项目文件、不新增持久化账本。 +- 不新增取消入口;不改变身份锁排他性、项目写锁语义、计费与幂等身份。 +- 不新增跨端契约(Tauri 本地命令,不进 `packages/shared` / `shared-contracts` / OpenAPI)。 +- 面板与重连共用同一份快照,不各自维护第二份“谁在跑”的真相。 +- 报错归类修正只处理“说明与真相无关”的情况,不放宽身份锁、不吞真实失败。 + +## 验收标准 + +- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。 +- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。 +- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。 +- 左上角面板:列出所有在跑项目,按 `startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳。 +- 快照读取失败不得阻断发送、不得显示成业务失败。 +- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:` 仍显示审批提示。 + +## 未决事项 + +- “离开页面即取消”仍是未采纳的另一种语义;本轮只实现后台继续。 +- 应用重启后的“未完成回合”恢复不在本里程碑范围(回合注册表是进程内状态);若未来要求跨重启恢复,需要另立里程碑并定义持久化身份与对账合同。 +- 面板是否需要展示非 Direct(专业 Agent / 策划 Agent)运行中的项目,本轮不做;先把 Direct 回合这条事实链路做正确。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd8416d40..3fd4eeb46 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8759,4 +8759,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 - 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 - 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 +## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板 +- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 +- 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8d8af7f25..7d6c1ca0b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1398,3 +1398,11 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执 游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。 验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。 + +## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性 + +Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。 + +壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。 + +活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。 From 8f456d974d3b9e31595fcd558081d4c9d9fc7725 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:35:16 +0800 Subject: [PATCH 40/54] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=20Direct=20?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E5=9B=9E=E5=90=88=E6=9B=B4=E6=96=B0=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让活动回合快照在受理时即具备更新时间 保持面板与重连读取同一份完整快照 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index cb27517fe..01a63a6db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -319,6 +319,10 @@ impl DirectTaonierActiveInvocationGuard { }); } None => { + let started_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(); active.insert( root.clone(), DirectTaonierActiveInvocation { @@ -327,13 +331,10 @@ impl DirectTaonierActiveInvocationGuard { .file_name() .and_then(|name| name.to_str()) .map(str::to_string), - started_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(), + started_at, status: "accepted".to_string(), activity: Some("request-accepted".to_string()), - updated_at: 0, + updated_at: started_at, sequence: 0, }, ); From c85e8afea87524d277bf4cc2932f26239a5ef617 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 15 Sep 2026 15:56:21 +0000 Subject: [PATCH 41/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E5=85=89=E6=A0=87=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除受控草稿判等中无法由父组件同步的 content 字段 避免每次输入都重建 Lexical 内容并将光标移到末尾 --- .../src/features/project-workspace/ResourceReferenceInput.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 14d2febcc..812030490 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -177,8 +177,7 @@ function referenceListKey(references: ChatReference[]) { function sameDraft(left: ChatComposerDraft, right: ChatComposerDraft) { return ( left.text === right.text && - referenceListKey(left.references) === referenceListKey(right.references) && - JSON.stringify(left.content ?? []) === JSON.stringify(right.content ?? []) + referenceListKey(left.references) === referenceListKey(right.references) ); } From eeb0206164a4d699e6a2b81a84f45324eedb0e47 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 00:02:46 +0800 Subject: [PATCH 42/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Stdb=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E4=B8=B4=E6=97=B6=E7=9B=AE=E5=BD=95=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将以服务用户执行的 WASM 发布临时目录移到 /var/tmp --- docs/project-memory/shared-memory/pitfalls.md | 6 ++++++ scripts/deploy/production-stdb-publish.sh | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 35e266485..ac63217a3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5603,3 +5603,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **原因**:统一错误解析器只处理标准 `error.message/details` 结构;部分网关或旧兼容响应使用字符串 `error`,解析失败后回落到登录接口传入的通用文案。 - **处理**:`parseApiErrorMessage` 同时支持字符串 `error`,标准嵌套结构保持原有优先级;未知或空响应继续使用通用兜底。 - **验证**:`src/services/apiClient.test.ts` 新增字符串错误响应回归用例,定向测试 32 项通过,`npm run typecheck` 通过。 + +## 2026-09-15 Jenkins Stdb 发布临时目录必须允许服务用户遍历 + +- **现象**:`Genarrative-Stdb-Module-Publish` 在备份和 SpacetimeDB 就绪后,于 `spacetime publish` 报 `Permission denied`。 +- **原因**:Jenkins 以 root 运行时 `${HOME}/data/tmp` 位于 `/root` 下;即使发布临时子目录已 `chown` 给 `spacetimedb`,父目录仍不可遍历。 +- **处理**:发布给 `--run-as-user` 的 WASM 临时目录改用 `/var/tmp`,继续使用随机目录并在退出时清理。 diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index c102e45ec..e96210ecb 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -747,7 +747,9 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2 exit 1 fi - task_tmp_dir="${HOME}/data/tmp" + # runuser 需要能够穿过临时目录的父目录;Jenkins 以 root 运行时 HOME + # 通常是 /root,而 /root 对 spacetimedb 不可遍历。 + task_tmp_dir="/var/tmp" mkdir -p "${task_tmp_dir}" PUBLISH_TMP_DIR="$(mktemp -d "${task_tmp_dir}/genarrative-stdb-publish.XXXXXX")" install -m 0644 "${SOURCE_DIR}/spacetime_module.wasm" "${PUBLISH_TMP_DIR}/spacetime_module.wasm" From 2a9035ce200e38255390db77447f8ecd15d1a616 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 00:20:37 +0800 Subject: [PATCH 43/54] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Direct=20Codex=20?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发送直接回合时携带 manifest projectId,匹配 Rust 契约与测试断言 --- apps/ai-game-creator-shell/src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7d0e734d6..9a92bbdfe 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6669,6 +6669,7 @@ export function App({ setProjectSupervisorRuntimeError(''); try { const directTurnInput: { + projectId: string; projectPath: string; prompt: string; clientTurnId: string; @@ -6676,6 +6677,7 @@ export function App({ attachments?: DirectCodexTurnAttachment[]; userItem: ReturnType; } = { + projectId: localProject.manifest.projectId, projectPath: directProjectPath, prompt, clientTurnId, From 2e2ff552f7f8efac4b350cd5c6a0a31dbc42668b Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 00:28:58 +0800 Subject: [PATCH 44/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=88=9D=E5=A7=8B=E5=8C=96=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 延后资源读取调用,避免阻塞 Direct Codex 首回合 --- .../src/view/project-development/index.tsx | 84 ++++++++++--------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index b8fc97384..ce120c950 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -2183,14 +2183,17 @@ export default function ProjectDevelopmentView({ hasSnapshot: canReuseSnapshot, }; }); - void invoke( - 'read_local_project_resource_graph', - { - projectPath, - expectedProjectId: manifest.projectId, - resources: resourceGraphInputs, - }, - ) + const readResourceGraph = () => + invoke( + 'read_local_project_resource_graph', + { + projectPath, + expectedProjectId: manifest.projectId, + resources: resourceGraphInputs, + }, + ); + void new Promise((resolve) => setTimeout(resolve, 0)) + .then(readResourceGraph) .then((readModel) => { if (!cancelled) { setResourceGraphState({ @@ -7199,41 +7202,42 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationTasksPanelOpen(false); resourceAssetGenerationPanelSubmissionRef.current = null; setResourceAssetGenerationPanelReopen(null); - void (async () => { - const reportUnavailable = () => { - if (!cancelled) { - setResourceWorkbenchNotice( - '生成任务列表读取失败,暂时无法恢复历史任务', - ); - } - }; - let records: LocalProjectAssetGenerationTaskRecord[]; - try { - const response = await invoke( - 'list_local_project_asset_generations', - { projectPath }, - ); - if (!Array.isArray(response)) { + void new Promise((resolve) => setTimeout(resolve, 0)).then( + async () => { + const reportUnavailable = () => { + if (!cancelled) { + setResourceWorkbenchNotice( + '生成任务列表读取失败,暂时无法恢复历史任务', + ); + } + }; + let records: LocalProjectAssetGenerationTaskRecord[]; + try { + const response = await invoke< + LocalProjectAssetGenerationTaskRecord[] + >('list_local_project_asset_generations', { projectPath }); + if (!Array.isArray(response)) { + reportUnavailable(); + return; + } + records = response; + } catch { reportUnavailable(); return; } - records = response; - } catch { - reportUnavailable(); - return; - } - if (cancelled) { - return; - } - const restored = mergeResourceCanvasAssetGenerationTasksWithRecords( - resourceAssetGenerationTasksRef.current.filter( - (task) => task.projectId === projectId, - ), - records, - ); - resourceAssetGenerationTasksRef.current = restored; - setResourceAssetGenerationTasks(restored); - })(); + if (cancelled) { + return; + } + const restored = mergeResourceCanvasAssetGenerationTasksWithRecords( + resourceAssetGenerationTasksRef.current.filter( + (task) => task.projectId === projectId, + ), + records, + ); + resourceAssetGenerationTasksRef.current = restored; + setResourceAssetGenerationTasks(restored); + }, + )(); return () => { cancelled = true; }; From 792530ad24e56b780c0e7532c90d96d13c09eb6c Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 00:51:32 +0800 Subject: [PATCH 45/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=BB=B6=E8=BF=9F=E8=B0=83=E7=94=A8=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正确结束延迟 Promise 链,避免组件挂载时 TypeError --- .../src/view/project-development/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ce120c950..8fd64622d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -7237,7 +7237,7 @@ export default function ProjectDevelopmentView({ resourceAssetGenerationTasksRef.current = restored; setResourceAssetGenerationTasks(restored); }, - )(); + ); return () => { cancelled = true; }; From 0ce5eb35b0eeddf3c56af9b96d8e387e1991b4ab Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Wed, 16 Sep 2026 01:05:34 +0800 Subject: [PATCH 46/54] =?UTF-8?q?=E6=8F=90=E5=8D=87=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E5=88=87=E7=89=87=E4=B8=8A=E9=99=90=E5=B9=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标图集自动、手动和网格切片上限统一提高到 256。 同步 SpacetimeDB 批次与生成结果 item 上限,覆盖 256/257 边界。 严格图集提交校验透传 sliceWarning 原始原因,避免通用文案覆盖根因。 补充 Rust 定向测试、文档和共享决策记录。 --- .../src/agent/generation/canvas_generation.rs | 46 +++++++++++++++- .../shared-memory/decision-log.md | 6 +++ docs/project-memory/shared-memory/pitfalls.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...辑器生成结果原子提交与幂等重放方案-2026-08-06.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../crates/api-server/src/editor_project.rs | 12 ++--- .../api-server/src/editor_project_icon.rs | 52 +++++++++++++++++-- .../src/generated_asset_sheets/sheet.rs | 12 ++--- .../src/editor_project_storage.rs | 49 ++++++++++++++--- 10 files changed, 156 insertions(+), 31 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 693aa2dc1..8275aa26c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -6519,6 +6519,7 @@ impl PlatformArtSliceContractRollback { fn validate_strict_platform_art_spritesheet_contract( slices: &[PreparedPlatformArtAssetSlice], + slice_warning: Option<&str>, canvas_context: &ExternalCanvasGenerationContext, canvas_project_id: Option<&str>, resource_id: Option<&str>, @@ -6532,7 +6533,11 @@ fn validate_strict_platform_art_spritesheet_contract( has_visible_pixels: bool, ) -> Result<(), String> { if slices.is_empty() { - return Err("spritesheet 图集至少需要一个独立切片".to_string()); + return Err(slice_warning + .map(str::trim) + .filter(|warning| !warning.is_empty()) + .map(|warning| format!("spritesheet 图集至少需要一个独立切片;原始切片告警:{warning}")) + .unwrap_or_else(|| "spritesheet 图集至少需要一个独立切片".to_string())); } let resource_id = resource_id .map(str::trim) @@ -7337,6 +7342,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( if require_complete_core_slices { validate_strict_platform_art_spritesheet_contract( &slices, + slice_warning.as_deref(), &canvas_context, canvas_project_id.as_deref(), resource_id.as_deref(), @@ -9798,6 +9804,7 @@ mod canvas_generation_tests { .collect::>(); validate_strict_platform_art_spritesheet_contract( &slices, + None, &canvas_context, Some("canvas-project"), Some("spritesheet-resource"), @@ -9813,6 +9820,35 @@ mod canvas_generation_tests { .expect("valid slice identities and pixel evidence do not require a fixed layout"); } + #[test] + fn strict_spritesheet_contract_preserves_slice_warning_when_empty() { + let canvas_context = ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "empty-slice-warning".to_string(), + }; + let error = validate_strict_platform_art_spritesheet_contract( + &[], + Some("识别出的素材数量超过输出上限:86,最多允许 256 个"), + &canvas_context, + None, + None, + None, + None, + "route", + "kind", + None, + &[], + false, + false, + ) + .expect_err("empty slices must expose the original platform warning"); + + assert!(error.contains("至少需要一个独立切片")); + assert!(error.contains("原始切片告警")); + assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个")); + } + #[test] fn strict_spritesheet_contract_rejects_an_opaque_slice() { let canvas_context = ExternalCanvasGenerationContext { @@ -9850,6 +9886,7 @@ mod canvas_generation_tests { let error = validate_strict_platform_art_spritesheet_contract( &slices, + None, &canvas_context, Some("canvas-project"), Some("spritesheet-resource"), @@ -12449,7 +12486,10 @@ mod canvas_generation_tests { .expect("init strict slice project"); let path = root.join("assets/art-spritesheet.png"); fs::write(&path, b"old-image").expect("write old spritesheet"); - let prepared = prepared_replacement(root, b"new-image"); + let mut prepared = prepared_replacement(root, b"new-image"); + prepared.slice_warning = Some( + "图标 spritesheet 识别出的素材数量超过输出上限:86,最多允许 256 个。".to_string(), + ); let error = commit_prepared_platform_art_asset_strict_slices_at( root, @@ -12460,6 +12500,8 @@ mod canvas_generation_tests { .expect_err("strict spritesheet commit must require at least one slice"); assert!(error.contains("至少需要一个独立切片")); + assert!(error.contains("原始切片告警")); + assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个")); assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image"); assert!(!root .join("assets/art-spritesheet-slices/manifest.json") diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3fd4eeb46..d778bcade 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,12 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-16 图标图集自动拆图上限提高到 256 + +- 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。 +- 决策:将可输出独立切片上限统一提高到 `256`;统一生成结果最多 `258` 个 item(256 个切片加 provider 原图和透明整图)。保持原始连通域 `4096`、总裁剪像素、CPU / 内存 admission、并发上传和处理时限不变。 +- 边界:超过 256 仍按现有 `output-slice-limit-exceeded` / `sliceWarning` 语义失败关闭切片写入;自动路径保留可信整图,手动路径继续在持久化前返回错误。 +- 验证:平台切片器、api-server 警告映射与 payload、SpacetimeDB 结果 / 批次校验均覆盖 256 成功边界和 257 溢出边界。 ## 2026-09-14 生成进度面收敛为「常驻可折叠任务侧栏」;提交即关面板、阶段文案只归侧栏;定位动作终局化 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ac63217a3..fa96f595f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4987,7 +4987,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 原因:把“请求已入队”、“某个稳定 ID 已存在”或“job 已 completed”误当成整批业务记录已原子提交的证据。request fingerprint 只证明用户请求,不绑定最终 slot、派生记录、画布候选和 compact result;仅比较资源 ID 也无法发现内容漂移。 - 处理:用 `editor_generation_operation` 记录 durable receipt,分开 request fingerprint 与整笔 commit SHA-256。首次调用在同一 SpacetimeDB 事务中校验 lease 并写 object/resource/asset/binding/canvas/job/receipt;重放先查 receipt,再读回逐 slot 权威事实精确比较。receipt 缺失但 resource/asset/binding 已存在时失败关闭,不得补写 receipt;事务前已确认的 asset object 只能在 ID、bucket/key、owner、策略、媒体、来源和实体字段全部相等时复用。 - 时间与并发:`completed_at_micros` 必须为正数,object/resource/asset/binding/canvas 候选原时间字段与它一起纳入 commit SHA-256,不能在每次重放时重新取时;job 终态和完成事件只用 SpacetimeDB `ctx.timestamp`。canvas CAS 冲突后只刷新 project 并重算布局,不重跑 Provider / OSS。OSS 尚未进入该事务,无引用 object 仍是需另行清理的边界,不要宣称跨 OSS exactly-once。 -- queue completion 不能把 inline 完整响应无条件同时复制到 `result` 和 `editor-agent-tool-call-result`。图集/UI 最多 64 个切片会重复携带 resource/asset/prompt/generationInputs,容易超过 job payload 512 KiB 上限并让整个原子提交回滚。必须先按普通 UI、Editor Agent、External API 的消费方契约裁剪,再把最终 JSON 交给统一 procedure。 +- queue completion 不能把 inline 完整响应无条件同时复制到 `result` 和 `editor-agent-tool-call-result`。图集/UI 最多 256 个切片会重复携带 resource/asset/prompt/generationInputs,容易超过 job payload 512 KiB 上限并让整个原子提交回滚。必须先按普通 UI、Editor Agent、External API 的消费方契约裁剪,再把最终 JSON 交给统一 procedure。 - 消费方身份不能在提交前重新读取 summary 兼容快照来判断:该快照按设计清空 dedupe key 并删除 generationInputs,Editor Agent / External API 会因此被误判成普通 UI。应在 worker 持有完整 claimed job 时把安全的 consumer kind 与 source identity 固化到调用上下文。 - procedure future 超时或连接断开不能直接映射为业务失败,远端事务可能已经提交。必须有界重放同一 prepared commit;明确 CAS 后才刷新 layout,且刷新 layout 应使用新时间,不能把项目 `updated_at` 回拨。receipt 不复制 queue payload,只存摘要并从 job 权威行回读;跨记录 object/project 一致性必须在事务内验证,不能依赖当前 builder 通常会携带完整 candidate。 - job 的 owner/kind/fingerprint/lease 都正确仍不够:`source_entity_id` 还必须绑定结果项目,来源资源必须另查存在性与 owner/project 归属;否则同 owner 的 job 可以误写别的项目,或伪造跨用户/跨项目血缘。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index d166dfb70..9b6478912 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -123,7 +123,7 @@ ## 后端接口 -- 角色、图标 spritesheet 与 UI 素材提取共用 provider 原图 source-only 收口:BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,只把已保存 provider 原图作为唯一主图完成占位,以 `completed + warning` 收口且不退款;图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不写透明图、不拆分,也不创建透明图或切片画布层。provider 原图本身无法完整解码时在首次持久化前失败,不得用 `512×512` 伪造元数据。图标自动拆分、手动拆分和 UI 提取共用有界处理链:全部 flood-fill 原始连通域最多 `4096` 个,辅助部件合并使用空间网格邻近候选,`maxOutputSlices=64` 和所有 padding 后 crop 的总像素预算都在首片 PNG 编码前检查。prepare 只保留一张 RGBA 与排好序的 bounds,不再一次返回最多 `64` 份 PNG;api-server 按需编码并以容量 `2` 的有界管线上传,使单个图集同时只保留整图和最多两份切片 PNG。CPU 工作继续受 2 路 semaphore、30 秒本地上限与请求 deadline 保护;独立内存 admission 从 prepare 持有到最后一片上传结束,慢 OSS 不得占用 CPU permit,也不得绕过内存限制堆积新批次。自动超限保留可信透明整图、返回稳定 `sliceWarning` 且不写任何切片;手动超限在首次持久化前返回 `422`。 +- 角色、图标 spritesheet 与 UI 素材提取共用 provider 原图 source-only 收口:BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,只把已保存 provider 原图作为唯一主图完成占位,以 `completed + warning` 收口且不退款;图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不写透明图、不拆分,也不创建透明图或切片画布层。provider 原图本身无法完整解码时在首次持久化前失败,不得用 `512×512` 伪造元数据。图标自动拆分、手动拆分和 UI 提取共用有界处理链:全部 flood-fill 原始连通域最多 `4096` 个,辅助部件合并使用空间网格邻近候选,`maxOutputSlices=256` 和所有 padding 后 crop 的总像素预算都在首片 PNG 编码前检查。prepare 只保留一张 RGBA 与排好序的 bounds,不再一次返回最多 `256` 份 PNG;api-server 按需编码并以容量 `2` 的有界管线上传,使单个图集同时只保留整图和最多两份切片 PNG。CPU 工作继续受 2 路 semaphore、30 秒本地上限与请求 deadline 保护;独立内存 admission 从 prepare 持有到最后一片上传结束,慢 OSS 不得占用 CPU permit,也不得绕过内存限制堆积新批次。自动超限保留可信透明整图、返回稳定 `sliceWarning` 且不写任何切片;手动超限在首次持久化前返回 `422`。 - `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`。 - 图标规范专用链路(2026-08-04)取代本文旧的前端 prompt / `ui` 规范分支口径。前端规范类型只允许 `character / icon / custom`;图标规范表单状态和请求字段使用 `playSetting / artStyle`,对应 Rust 字段为 `play_setting / art_style`,界面与 `generationInputs.fields[]` 标题继续使用「玩法设定 / 美术风格」。恢复历史画布快照时必须把旧 `specType="ui"` 迁移为 `icon`,运行时类型守卫不得继续把 `ui` 当成现役类型。 @@ -155,7 +155,7 @@ - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。图片类改造继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 -- `POST /api/editor/icon-spritesheets/generations`:主图标规范使用必填 `referenceId`,只接受当前 owner 的项目资源 ID 或素材 ID,不接受 `objectKey`、URL、临时 key 或 `referenceImageSrc` 作为主规范引用;普通附加参考图仍可使用独立 `referenceImageSrcs`。画布前端把完整用户需求作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`projectId`、`assetFolderId` 和 `generationInputs`,不接受客户端 `priceMudPoints`;后端按归一化后的模型和尺寸从运行时定价配置计算价格,queue 入队时冻结该价格,worker 的预扣、退款和结果投影均使用同一入队价格;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 +- `POST /api/editor/icon-spritesheets/generations`:主图标规范使用必填 `referenceId`,只接受当前 owner 的项目资源 ID 或素材 ID,不接受 `objectKey`、URL、临时 key 或 `referenceImageSrc` 作为主规范引用;普通附加参考图仍可使用独立 `referenceImageSrcs`。画布前端把完整用户需求作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `256` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`projectId`、`assetFolderId` 和 `generationInputs`,不接受客户端 `priceMudPoints`;后端按归一化后的模型和尺寸从运行时定价配置计算价格,queue 入队时冻结该价格,worker 的预扣、退款和结果投影均使用同一入队价格;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 - `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。服务端把规范化 `canvasCompletion.dialogId` 作为 operationId,以 owner / project 共同限定作用域,并从该 operation 稳定派生 task、asset object、resource、asset 身份;请求 fingerprint 覆盖来源 object key、来源与输出字节摘要、来源资源、素材类型、规范目录 / 标签、规范 generationInputs、completion 和算法版本。OSS PUT / HEAD 之后只调用一次原子 SpacetimeDB procedure;权威 dialog 仍存在时在源图右侧完成占位,已删除时只提交 object / resource / asset 而不推进 canvas revision。完整同内容重放返回 `AlreadyApplied`,同 operation 输入漂移或只有部分记录存在返回幂等冲突。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 diff --git a/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md b/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md index 5cdb66c46..8e76aa04d 100644 --- a/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md +++ b/docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md @@ -54,7 +54,7 @@ EditorGenerationResultPersistInput { ### 首次提交顺序 -1. 校验调用身份、operation 字段、fingerprint、item 数量上限和 slot 唯一性。统一提交最多接受 66 个 item,用于容纳最多 64 个图集切片以及 provider 原图和透明整图。 +1. 校验调用身份、operation 字段、fingerprint、item 数量上限和 slot 唯一性。统一提交最多接受 258 个 item,用于容纳最多 256 个图集切片以及 provider 原图和透明整图。 2. queue 输入必须完整携带 `job_id + worker_id + lease_token + result_payload_json`;inline 输入必须全部省略,禁止半套 guard。 3. queue 路径在同一事务快照内校验 job owner、kind、request fingerprint、running 状态和有效 lease;过期 worker 不得写业务结果。`source_entity_id` 必须精确等于本次唯一结果 `project_id`,不得用同 owner 的 job 向其他项目提交。 4. 对每个 item 校验稳定 object/resource/asset ID、owner、project、folder、object key、source resource、task 审计字段和媒体字段的交叉一致性。project resource 和 account asset 的 `source_resource_id` 均必须单独验证:来源资源必须是本次同事务候选或已登记资源,属于同 owner,且在结果具有项目上下文时属于同 project;不接受 asset-only 分支绕过血缘校验。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f21c0516b..dfab9a03e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -355,7 +355,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 - 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 -- 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取先在受 2 路 CPU semaphore、30 秒 / 请求 deadline 保护的 blocking prepare 中完成解码、透明化、连通域和 bounds 排序;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在首片 PNG 编码前同时执行 `maxOutputSlices=64` 与全部 padding crop 总像素预算。prepare 返回共享 RGBA + bounds 计划,api-server 再以容量 `2` 的有界管线按需编码、共享 HTTP client 并发 OSS `PUT + HEAD`,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 `32 MiB` 来源对象前取得独立内存 admission,同一 admission 覆盖下载、prepare 到最后一片上传结束并在数据库调用前释放,CPU permit 只覆盖实际 CPU 阶段。全部对象上传验证成功后,切片的 `asset_object + editor_project_resource + editor_asset + editor_asset_group_cohort` 由单个受 editor generation runtime service identity 保护的 SpacetimeDB procedure 在一次 `try_with_tx` 中原子写入;resource / asset ID 由 owner + task + 序号稳定派生,已有同 ID 素材仅在内容完全一致时幂等复用,来源资源必须存在且与派生资源同 owner / project;上传中途失败不得写部分资源、素材或 cohort,不确定结果重放不得复制整批素材。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。 +- 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取先在受 2 路 CPU semaphore、30 秒 / 请求 deadline 保护的 blocking prepare 中完成解码、透明化、连通域和 bounds 排序;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在首片 PNG 编码前同时执行 `maxOutputSlices=256` 与全部 padding crop 总像素预算。prepare 返回共享 RGBA + bounds 计划,api-server 再以容量 `2` 的有界管线按需编码、共享 HTTP client 并发 OSS `PUT + HEAD`,OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 `32 MiB` 来源对象前取得独立内存 admission,同一 admission 覆盖下载、prepare 到最后一片上传结束并在数据库调用前释放,CPU permit 只覆盖实际 CPU 阶段。全部对象上传验证成功后,切片的 `asset_object + editor_project_resource + editor_asset + editor_asset_group_cohort` 由单个受 editor generation runtime service identity 保护的 SpacetimeDB procedure 在一次 `try_with_tx` 中原子写入;resource / asset ID 由 owner + task + 序号稳定派生,已有同 ID 素材仅在内容完全一致时幂等复用,来源资源必须存在且与派生资源同 owner / project;上传中途失败不得写部分资源、素材或 cohort,不确定结果重放不得复制整批素材。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`。 - 2026-08-06 原子提交对上述 source-only / slice 条款的修正:“provider 原图已持久化”只能解释为 OSS 对象已上传并验证,不再表示 project resource / account asset 已先行入库。source-only、透明整图和成功切片必须先完成最终选择,再作为一份 prepared commit 连同 canvas/job/receipt 一次提交;未选中或失败分支不得留下正式 resource / asset 部分记录。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d740cdfea..2c3ccb68e 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -18994,8 +18994,8 @@ mod tests { ), ( GeneratedAssetSheetError::OutputSliceLimitExceeded { - slice_count: 65, - max_slice_count: 64, + slice_count: 257, + max_slice_count: 256, }, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT, ), @@ -20441,9 +20441,9 @@ mod tests { } #[test] - fn atomic_spritesheet_job_result_with_64_large_slices_stays_below_payload_limit() { + fn atomic_spritesheet_job_result_with_256_large_slices_stays_below_payload_limit() { let large_metadata = "x".repeat(32 * 1024); - let icons = (0..64) + let icons = (0..256) .map(|index| { json!({ "name": format!("icon-{index}"), @@ -20496,7 +20496,7 @@ mod tests { .to_string(); let agent_context = EditorGenerationQueueResultContext::from_job(&agent_job); let agent_payload = serialize_atomic_editor_generation_job_result(&agent_context, &result) - .expect("64-slice agent payload should remain compact"); + .expect("256-slice agent payload should remain compact"); assert!(agent_payload.len() < MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES); assert!(!agent_payload.contains("generationInputs")); assert!(!agent_payload.contains("ownerUserId")); @@ -20507,7 +20507,7 @@ mod tests { let external_context = EditorGenerationQueueResultContext::from_job(&external_job); let external_payload = serialize_atomic_editor_generation_job_result(&external_context, &result) - .expect("64-slice external payload should remain compact"); + .expect("256-slice external payload should remain compact"); assert!(external_payload.len() < MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES); assert!(!external_payload.contains("generationInputs")); assert!(!external_payload.contains("ownerUserId")); diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index cfecc85be..d0e68e432 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -76,7 +76,7 @@ pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000; pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; -const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64; +const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256; pub(crate) const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; @@ -314,10 +314,29 @@ fn resolve_editor_icon_spritesheet_grid_dimensions( if !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_x) || !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_y) { - return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "field": "gridX/gridY", - "message": format!("gridX 与 gridY 必须在 1 到 {} 之间。", EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS), - }))); + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": format!( + "gridX 与 gridY 必须在 1 到 {} 之间。", + EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS + ), + })), + ); + } + if usize::try_from(grid_x.saturating_mul(grid_y)) + .ok() + .is_some_and(|slice_count| slice_count > EDITOR_ICON_SPRITESHEET_MAX_SLICES) + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": format!( + "gridX 与 gridY 的切片总数不得超过 {}。", + EDITOR_ICON_SPRITESHEET_MAX_SLICES + ), + })), + ); } Ok((grid_x, grid_y)) } @@ -3075,6 +3094,29 @@ mod tests { assert_eq!(grid.grid_y, Some(2)); } + #[test] + fn grid_dimensions_reject_more_than_maximum_output_slices() { + let error = resolve_editor_icon_spritesheet_grid_dimensions( + EditorIconSpritesheetSliceMode::Grid, + Some(17), + Some(16), + ) + .expect_err("257 grid cells must exceed the output slice limit"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error.details().and_then(|details| details.get("field")), + Some(&json!("gridX/gridY")) + ); + assert!( + error + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("256")) + ); + } + #[test] fn spritesheet_genre_requires_exact_game_type_title() { assert_eq!( diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index ad75b55ce..c98fb0b1f 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -1288,8 +1288,8 @@ mod tests { #[test] fn rejects_output_limit_before_cropping_and_png_encoding() { - let columns = 13u32; - let rows = 5u32; + let columns = 257u32; + let rows = 1u32; let stride = 10u32; let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(columns * stride, rows * stride, Rgba([0, 0, 0, 0])); @@ -1310,12 +1310,12 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let error = slice_generated_icon_spritesheet_all_by_connected_components(&source, 64) - .expect_err("65 output components must fail before slice encoding"); + let error = slice_generated_icon_spritesheet_all_by_connected_components(&source, 256) + .expect_err("257 output components must fail before slice encoding"); assert!(error.to_string().contains("素材数量超过输出上限")); - assert!(error.to_string().contains("65")); - assert!(error.to_string().contains("64")); + assert!(error.to_string().contains("257")); + assert!(error.to_string().contains("256")); } #[test] diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index d9622374c..1f506eed4 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -16,7 +16,7 @@ const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED: &str = "backfilled"; const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE: &str = "active"; const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK: &str = "rolled_back"; const EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS: usize = 16; -const EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS: usize = 64; +const EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS: usize = 256; const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420; const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120; const EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE: u32 = 25; @@ -24,8 +24,8 @@ const EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE: u32 = 5; const EDITOR_IMAGE_ASSET_KIND_CLEANUP_MAX_BATCH_SIZE: u32 = 25; const EDITOR_IMAGE_ASSET_KIND_CLEANUP_CANVAS_MAX_BATCH_SIZE: u32 = 5; const EDITOR_LEGACY_IMAGE_ASSET_KIND: &str = "image"; -// 透明图集最多产出 64 个切片,另有 provider 原图和透明整图两个正式 item。 -const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 66; +// 透明图集最多产出 256 个切片,另有 provider 原图和透明整图两个正式 item。 +const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 258; const EDITOR_GENERATION_OPERATION_KINDS: [&str; 10] = [ "editor_image_generation", "editor_icon_spec_generation", @@ -3872,7 +3872,7 @@ fn validate_editor_generation_result_shape( return Err("生成结果 completed_at_micros 必须为正数".to_string()); } if !(1..=EDITOR_GENERATION_RESULT_MAX_ITEMS).contains(&input.items.len()) { - return Err("生成结果 item 数量必须在 1 到 66 之间".to_string()); + return Err("生成结果 item 数量必须在 1 到 258 之间".to_string()); } let result_project_id = editor_generation_result_project_id(input)?; let mut slots = BTreeSet::new(); @@ -5527,7 +5527,7 @@ fn validate_editor_spritesheet_slice_batch( ) -> Result<(), String> { let item_count = input.items.len(); if !(1..=EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS).contains(&item_count) { - return Err("图集切片批次产物数量必须在 1 到 64 之间".to_string()); + return Err("图集切片批次产物数量必须在 1 到 256 之间".to_string()); } if usize::try_from(input.expected_asset_count).ok() != Some(item_count) { return Err("图集切片批次产物数量与预期不一致".to_string()); @@ -16124,7 +16124,7 @@ mod tests { } #[test] - fn editor_generation_result_shape_accepts_sixty_four_slices_plus_two_source_items() { + fn editor_generation_result_shape_accepts_two_hundred_fifty_six_slices_plus_two_source_items() { let mut input = editor_generation_result_input(); input.items.clear(); for index in 0..EDITOR_GENERATION_RESULT_MAX_ITEMS { @@ -16179,7 +16179,7 @@ mod tests { "job-1", ) .expect_err("more than the full spritesheet bundle must fail") - .contains("1 到 66") + .contains("1 到 258") ); } @@ -17781,6 +17781,41 @@ mod tests { .expect("complete batch should pass before transaction writes"); } + #[test] + fn spritesheet_slice_batch_validation_accepts_256_and_rejects_257_items() { + let mut batch = spritesheet_slice_batch(); + batch.items = (0..256) + .map(|index| { + let mut item = spritesheet_slice_item(index); + item.asset + .as_mut() + .expect("slice asset") + .group_task_expected_asset_count = Some(256); + item + }) + .collect(); + batch.expected_asset_count = 256; + validate_editor_spritesheet_slice_batch(&batch, "user-1", "task-1", Some("group-task-1")) + .expect("256 slices should remain within the batch limit"); + + let mut overflow_item = spritesheet_slice_item(256); + overflow_item + .asset + .as_mut() + .expect("slice asset") + .group_task_expected_asset_count = Some(257); + batch.items.push(overflow_item); + batch.expected_asset_count = 257; + let error = validate_editor_spritesheet_slice_batch( + &batch, + "user-1", + "task-1", + Some("group-task-1"), + ) + .expect_err("257 slices must exceed the batch limit"); + assert_eq!(error, "图集切片批次产物数量必须在 1 到 256 之间"); + } + #[test] fn spritesheet_slice_batch_validation_rejects_duplicate_object_key() { let mut batch = spritesheet_slice_batch(); From ce6dcea01814cd47889fc38f9605272d2247400a Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Wed, 16 Sep 2026 01:07:41 +0800 Subject: [PATCH 47/54] baseline --- .eslintrc.cjs | 433 +--------------------------------------------- .prettierrc.json | 6 +- ignored/legacy.ts | 3 + partial.ts | 2 + sample.ts | 5 + 5 files changed, 12 insertions(+), 437 deletions(-) create mode 100644 ignored/legacy.ts create mode 100644 partial.ts create mode 100644 sample.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8a4b2d0c6..6d9e0fc39 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,432 +1 @@ -const globals = require('globals'); - -module.exports = { - root: true, - env: { - es2022: true, - }, - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, - globals: { - ...globals.browser, - ...globals.node, - }, - overrides: [ - { - files: ['scripts/**/*.{ts,js,mjs,cjs}'], - rules: { - 'no-console': 'off', - }, - }, - { - files: ['**/scripts/**/*.{ts,js,mjs,cjs}'], - rules: { - 'no-console': 'off', - }, - }, - { - files: ['**/*.test.{ts,tsx,js,mjs,cjs}'], - rules: { - 'no-console': 'off', - 'unused-imports/no-unused-vars': 'off', - }, - }, - { - files: ['miniprogram/**/*.js'], - globals: { - App: 'readonly', - }, - }, - { - files: [ - 'apps/admin-web/src/pages/*.tsx', - 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx', - 'src/components/platform-entry/PlatformMobileHomeWelcomeDialog.tsx', - ], - rules: { - 'react-refresh/only-export-components': 'off', - }, - }, - { - files: ['src/components/platform-entry/PlatformEntryFlowShellImpl.tsx'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, - { - files: [ - 'src/active-main.tsx', - 'src/ActiveApp.tsx', - 'src/AuthenticatedApp.tsx', - 'src/hooks/useGameSettings.ts', - 'src/routing/activeApp*.ts', - 'src/routing/activeApp*.tsx', - 'src/components/auth/**/*.{ts,tsx}', - 'src/components/common/**/*.{ts,tsx}', - 'src/components/creation-home/**/*.{ts,tsx}', - 'src/components/image-editor/**/*.{ts,tsx}', - 'src/components/project/**/*.{ts,tsx}', - 'src/components/platform-entry/PlatformActiveProfileView*.tsx', - 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog*.tsx', - 'src/components/platform-entry/PlatformEntryActiveFlowShell*.tsx', - 'src/components/platform-entry/PlatformEntryFlowShell.tsx', - 'src/components/platform-entry/PlatformProfileApiKeysModal.tsx', - 'src/components/platform-entry/PlatformProfileModalShell.tsx', - 'src/components/platform-entry/PlatformProfilePrimitives.tsx', - 'src/components/platform-entry/PlatformProfileRechargeModal.tsx', - 'src/components/platform-entry/PlatformProfileReferralModal.tsx', - 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx', - 'src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx', - 'src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx', - 'src/components/platform-entry/platformActiveProfileModel.ts', - 'src/components/platform-entry/platformEntryActiveTypes.ts', - 'src/components/platform-entry/platformProfileFundsModel.ts', - 'src/components/platform-entry/platformProfileHostClipboard.ts', - 'src/components/platform-entry/usePlatformProfileCenterController.ts', - 'src/services/image-editor/**/*.{ts,tsx}', - 'src/services/platform-entry/**/*.{ts,tsx}', - ], - rules: { - 'no-restricted-imports': [ - 'error', - { - patterns: [ - { - group: [ - '**/components/rpg-entry/**', - '**/components/*-creation/**', - '**/components/*-result/**', - '**/components/*-runtime/**', - '**/components/creation-agent/**', - '**/components/creative-agent/**', - '**/components/custom-world-*/**', - '**/components/unified-creation/**', - '**/services/rpg-entry/**', - '**/services/rpg-runtime/**', - '**/services/*-creation/**', - '**/services/*-runtime/**', - '**/services/*-works/**', - '**/services/creation-agent/**', - '**/services/creative-agent/**', - '**/services/puzzle-*/**', - '**/services/storyEngine/**', - ], - message: '现役前端不得重新导入已退役的创作模板模块。', - }, - ], - }, - ], - }, - }, - { - files: ['src/components/game-canvas/**/*.tsx'], - rules: { - 'react-refresh/only-export-components': 'off', - }, - }, - { - files: ['src/components/GameShell.tsx', 'src/hooks/useCombatFlow.ts'], - rules: { - 'simple-import-sort/imports': 'off', - 'simple-import-sort/exports': 'off', - }, - }, - ], - plugins: [ - '@typescript-eslint', - 'react-hooks', - 'react-refresh', - 'simple-import-sort', - 'unused-imports', - ], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - 'prettier', - ], - ignorePatterns: [ - 'dist', - 'dist_check', - 'dist_check_monster_position', - 'coverage', - 'build', - 'output', - 'retired/**', - 'node_modules', - 'server-rs/target', - 'server-rs/target-*', - 'apps/desktop-shell/src-tauri/target', - 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', - 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', - 'target', - 'src/main.tsx', - 'src/App.tsx', - 'src/routing/appPageRoutes.ts', - 'src/routing/appPageRoutes.test.ts', - 'src/routing/appRoutes.tsx', - 'src/routing/appRoutes.test.ts', - 'src/services/appTitle.ts', - 'src/services/appTitle.test.ts', - 'src/components/CharacterAnimator.tsx', - 'src/components/*.ts', - 'src/components/*.tsx', - '!src/components/ResolvedAssetAudio.tsx', - '!src/components/ResolvedAssetImage.tsx', - '!src/components/ResolvedAssetVideo.tsx', - 'src/hooks/*.ts', - 'src/hooks/*.tsx', - '!src/hooks/useGameSettings.ts', - '!src/hooks/useHostNavigationCanGoBack.ts', - '!src/hooks/useHostNavigationCanGoBack.test.tsx', - '!src/hooks/useResolvedAssetReadUrl.ts', - '!src/hooks/useResolvedAssetReadUrl.test.tsx', - 'src/persistence/*.ts', - 'src/persistence/*.tsx', - '!src/persistence/gameSettingsStorage.ts', - '!src/persistence/gameSettingsStorage.test.ts', - '!src/persistence/storage.ts', - '!src/persistence/storage.test.ts', - 'src/routing/*.ts', - 'src/routing/*.tsx', - '!src/routing/activeAppPageRoutes.ts', - '!src/routing/activeAppPageRoutes.test.ts', - '!src/routing/activeAppRoutes.tsx', - '!src/routing/activeAppRoutes.test.ts', - 'src/services/*.ts', - 'src/services/*.tsx', - '!src/services/activeAppTitle.ts', - '!src/services/activeAppTitle.test.ts', - '!src/services/apiClient.ts', - '!src/services/apiClient.test.ts', - '!src/services/assetReadUrlService.ts', - '!src/services/assetReadUrlService.test.ts', - '!src/services/authService.ts', - '!src/services/authService.test.ts', - '!src/services/clipboard.ts', - '!src/services/clipboard.test.ts', - '!src/services/frontendRuntimeConfigService.ts', - '!src/services/frontendRuntimeConfigService.test.ts', - '!src/services/sseStream.ts', - '!src/services/sseStream.test.ts', - 'src/AdventurePanel.tsx', - 'src/AdventureEntityModal.tsx', - 'src/App.tsx', - 'src/App.test.tsx', - 'src/CharacterCreation.tsx', - 'src/RpgRuntimeApp.tsx', - 'src/routing/appPageRoutes.ts', - 'src/routing/appPageRoutes.test.ts', - 'src/routing/appRoutes.tsx', - 'src/routing/appRoutes.test.ts', - 'src/*PlaygroundApp.tsx', - 'src/ChildMotionDemoApp.tsx', - 'src/Match3DPlaygroundApp.tsx', - 'src/components/child-motion-demo/**', - 'src/components/asset-studio/**', - 'src/components/bark-battle-creation/**', - 'src/components/big-fish-creation/**', - 'src/components/big-fish-result/**', - 'src/components/big-fish-runtime/**', - 'src/components/creation-agent/**', - 'src/components/creative-agent/**', - 'src/components/custom-world-agent/**', - 'src/components/custom-world-home/**', - 'src/components/edutainment-creation/**', - 'src/components/edutainment-result/**', - 'src/components/edutainment-runtime/**', - 'src/components/game-canvas/**', - 'src/components/jump-hop-result/**', - 'src/components/jump-hop-runtime/**', - 'src/components/match3d-result/**', - 'src/components/match3d-runtime/**', - 'src/components/puzzle-clear-creation/**', - 'src/components/puzzle-clear-result/**', - 'src/components/puzzle-clear-runtime/**', - 'src/components/puzzle-gallery/**', - 'src/components/puzzle-result/**', - 'src/components/puzzle-runtime/**', - 'src/components/rpg-creation-asset-studio/**', - 'src/components/rpg-creation-result/**', - 'src/components/rpg-runtime-panels/**', - 'src/components/square-hole-creation/**', - 'src/components/square-hole-result/**', - 'src/components/square-hole-runtime/**', - 'src/components/unified-creation/**', - 'src/components/visual-novel-creation/**', - 'src/components/visual-novel-result/**', - 'src/components/visual-novel-runtime/**', - 'src/components/wooden-fish-result/**', - 'src/components/wooden-fish-runtime/**', - 'src/components/common/PublishShare*', - 'src/components/common/publishShare*', - 'src/components/platform-entry/PlatformEntryCreation*', - 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', - 'src/components/platform-entry/platformEntryTypes.ts', - 'src/components/platform-entry/PlatformEntryHome*', - 'src/components/platform-entry/PlatformEntryWorld*', - 'src/components/platform-entry/PlatformMobileHome*', - 'src/components/platform-entry/PlatformWork*', - 'src/components/platform-entry/PlatformDraft*', - 'src/components/platform-entry/PlatformTask*', - 'src/components/platform-entry/PlatformError*', - 'src/components/platform-entry/barkBattle*', - 'src/components/platform-entry/platformCreation*', - 'src/components/platform-entry/platformDialog*', - 'src/components/platform-entry/platformDraft*', - 'src/components/platform-entry/platformEdutainment*', - 'src/components/platform-entry/platformEntryCreation*', - 'src/components/platform-entry/platformExternal*', - 'src/components/platform-entry/platformGeneration*', - 'src/components/platform-entry/platformHost*', - 'src/components/platform-entry/platformMiniGame*', - 'src/components/platform-entry/platformPlayed*', - 'src/components/platform-entry/platformPublic*', - 'src/components/platform-entry/platformPuzzle*', - 'src/components/platform-entry/platformRecommend*', - 'src/components/platform-entry/platformRpg*', - 'src/components/platform-entry/platformSelection*', - 'src/components/platform-entry/puzzleDraft*', - 'src/components/platform-entry/usePlatformCreation*', - 'src/components/platform-entry/usePlatformEntry*', - 'src/components/platform-entry/PlatformEntryFlowShellImpl/**', - 'src/components/platform-entry/platformMatch3DRuntimeProfile*', - 'src/components/unified-creation/workspaces/JumpHopCreationWorkspace*', - 'src/components/unified-creation/workspaces/Match3DCreationWorkspace*', - 'src/components/rpg-creation-editor/**', - 'src/components/rpg-entry/**', - 'src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx', - 'src/components/custom-world-home/CustomWorldCreationHub.test.tsx', - 'src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx', - 'src/components/custom-world-home/creationWorkShelf.test.ts', - 'src/components/rpg-runtime-shell/**', - 'src/hooks/rpg-runtime-story/**', - 'src/hooks/rpg-session/**', - 'src/hooks/combat/**', - 'src/hooks/useCombatFlow.ts', - 'src/hooks/useStoryOptions.ts', - 'src/prompts/customWorldPrompts.ts', - 'src/services/ai.ts', - 'src/services/appTitle.ts', - 'src/services/appTitle.test.ts', - 'src/services/miniGameDraftGenerationProgress.ts', - 'src/services/creationEntryConfigService.ts', - 'src/services/creationUrlState*', - 'src/services/customWorld*', - 'src/services/input-devices/**', - 'src/services/publicWorkCode.ts', - 'src/services/runtimeGuestAuth.ts', - 'src/services/runtimeRequest*', - 'src/services/runtimeAudioFeedback.ts', - 'src/services/useMocapInput*', - 'src/services/wechatMiniProgramSubscribe*', - 'src/services/bark-battle-creation/**', - 'src/services/bark-battle-runtime/**', - 'src/services/big-fish-creation/**', - 'src/services/big-fish-gallery/**', - 'src/services/big-fish-runtime/**', - 'src/services/big-fish-works/**', - 'src/services/creation-agent/**', - 'src/services/creation-audio/**', - 'src/services/creative-agent/**', - 'src/services/edutainment-baby-drawing/**', - 'src/services/edutainment-baby-object/**', - 'src/services/child-motion-demo/**', - 'src/services/jump-hop/**', - 'src/services/match3d-creation/**', - 'src/services/match3d-runtime/**', - 'src/services/match3d-works/**', - 'src/services/match3dGeneratedModelCache*', - 'src/services/match3dSpritesheetParser*', - 'src/services/puzzle-agent/**', - 'src/services/puzzle-gallery/**', - 'src/services/puzzle-onboarding/**', - 'src/services/puzzle-runtime/**', - 'src/services/puzzle-works/**', - 'src/services/rpg-creation/**', - 'src/services/rpg-entry/**', - 'src/services/rpg-runtime/**', - 'src/services/square-hole-creation/**', - 'src/services/square-hole-runtime/**', - 'src/services/square-hole-works/**', - 'src/services/storyEngine/**', - 'src/services/visual-novel-creation/**', - 'src/services/visual-novel-runtime/**', - 'src/services/visual-novel-works/**', - 'src/services/wooden-fish/**', - 'src/types.ts', - 'src/types/**', - 'src/uiAssets.ts', - 'scripts/loadtest/**', - 'packages/shared/src/contracts/jumpHop.ts', - 'packages/shared/src/contracts/match3dAgent.ts', - 'packages/shared/src/contracts/match3dRuntime.ts', - 'packages/shared/src/contracts/match3dWorks.ts', - 'packages/shared/src/contracts/barkBattle*', - 'packages/shared/src/contracts/bigFish*', - 'packages/shared/src/contracts/creationAgent*', - 'packages/shared/src/contracts/creationAudio*', - 'packages/shared/src/contracts/creativeAgent*', - 'packages/shared/src/contracts/customWorld*', - 'packages/shared/src/contracts/edutainment*', - 'packages/shared/src/contracts/playTypes*', - 'packages/shared/src/contracts/publicWork*', - 'packages/shared/src/contracts/puzzle*', - 'packages/shared/src/contracts/rpg*', - 'packages/shared/src/contracts/squareHole*', - 'packages/shared/src/contracts/story*', - 'packages/shared/src/contracts/visualNovel*', - 'packages/shared/src/contracts/woodenFish*', - 'scripts/export-match3d-resource-pipeline*', - 'scripts/generate-child-motion-demo-assets.mjs', - 'src/services/puzzle-clear/**', - 'src/games/**', - 'src/data/**', - 'src/prompts/**', - 'apps/admin-web/src/pages/AdminCreationEntrySwitchPage*', - 'apps/admin-web/src/pages/AdminWorkVisibilityPage*', - 'src/services/recommendedRuntimeGuestLaunch.test.ts', - 'src/data/sceneEncounterPreviews.ts', - 'public/Icons', - 'media', - '.codex-logs', - '*.log', - '.preview.*', - 'temp-build-goal-check/**', - 'tmp_*', - 'tmp/**', - 'npc-editor-*', - 'temp-write-check.txt', - '**/__pycache__/**', - '*.timestamp-*.mjs', - ], - rules: { - '@typescript-eslint/no-var-requires': 'off', - 'react-hooks/rules-of-hooks': 'error', - 'react-refresh/only-export-components': [ - 'error', - { allowConstantExport: true }, - ], - 'simple-import-sort/imports': 'error', - 'simple-import-sort/exports': 'error', - '@typescript-eslint/no-unused-vars': 'off', - 'unused-imports/no-unused-imports': 'error', - 'unused-imports/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - 'no-constant-condition': 'error', - 'no-console': ['error', { allow: ['warn', 'error'] }], - 'no-useless-escape': 'error', - 'prefer-const': 'error', - }, -}; +module.exports = {"root":true,"ignorePatterns":["ignored/**"],"parserOptions":{"ecmaVersion":"latest","sourceType":"module"},"plugins":["simple-import-sort"],"rules":{"simple-import-sort/imports":"error"}}; diff --git a/.prettierrc.json b/.prettierrc.json index 7d2081a95..0bed832ed 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1 @@ -{ - "singleQuote": true, - "semi": true, - "trailingComma": "all" -} +{"singleQuote":true,"semi":true,"trailingComma":"all"} \ No newline at end of file diff --git a/ignored/legacy.ts b/ignored/legacy.ts new file mode 100644 index 000000000..fb7d518bd --- /dev/null +++ b/ignored/legacy.ts @@ -0,0 +1,3 @@ +import { zebra } from './zebra'; +import { alpha } from './alpha'; +void zebra; void alpha; diff --git a/partial.ts b/partial.ts new file mode 100644 index 000000000..9b439571d --- /dev/null +++ b/partial.ts @@ -0,0 +1,2 @@ +const original = 1; +const keep = 2; diff --git a/sample.ts b/sample.ts new file mode 100644 index 000000000..d93acb27d --- /dev/null +++ b/sample.ts @@ -0,0 +1,5 @@ +import { alpha } from './alpha'; +import { zebra } from './zebra'; + +void alpha; +void zebra; From ab7ffded2f5da2e8482faebc2fac826974f524ed Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Wed, 16 Sep 2026 01:07:42 +0800 Subject: [PATCH 48/54] baseline --- ignored/legacy.ts | 1 + partial.ts | 2 +- sample.ts | 7 +++---- tracked.txt | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 tracked.txt diff --git a/ignored/legacy.ts b/ignored/legacy.ts index fb7d518bd..1e3e9ab8f 100644 --- a/ignored/legacy.ts +++ b/ignored/legacy.ts @@ -1,3 +1,4 @@ import { zebra } from './zebra'; import { alpha } from './alpha'; void zebra; void alpha; +// staged ignored change diff --git a/partial.ts b/partial.ts index 9b439571d..30eff13f8 100644 --- a/partial.ts +++ b/partial.ts @@ -1,2 +1,2 @@ -const original = 1; +const original={value:1} const keep = 2; diff --git a/sample.ts b/sample.ts index d93acb27d..b1d24bdef 100644 --- a/sample.ts +++ b/sample.ts @@ -1,5 +1,4 @@ -import { alpha } from './alpha'; import { zebra } from './zebra'; - -void alpha; -void zebra; +import { alpha } from './alpha'; +const staged={value:1} +void zebra; void alpha; void staged; diff --git a/tracked.txt b/tracked.txt new file mode 100644 index 000000000..180b47c18 --- /dev/null +++ b/tracked.txt @@ -0,0 +1 @@ +baseline From 5da6ff46a1cae0bdb3454d14603f438133ecadb6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 01:14:14 +0800 Subject: [PATCH 49/54] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=20Hook=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 声明资源初始化开关依赖 --- .../src/view/project-development/index.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8fd64622d..c9bd95a93 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1448,6 +1448,7 @@ export default function ProjectDevelopmentView({ onMakeGame, }: ProjectDevelopmentViewProps) { const professionalDagVisible = orchestrationMode === 'professional-dag'; + const resourceHydrationEnabled = orchestrationMode !== 'single-supervisor'; const [mode, setMode] = useState('resources'); const [runtimeInspectMode, setRuntimeInspectMode] = useState(false); const [resourceBookState, dispatchResourceBook] = useReducer( @@ -2145,6 +2146,7 @@ export default function ProjectDevelopmentView({ }); useEffect(() => { + if (!resourceHydrationEnabled) return undefined; let cancelled = false; if (sortMode !== 'dependency') { setResourceGraphState({ @@ -2223,6 +2225,7 @@ export default function ProjectDevelopmentView({ resourceGraphInputs, resourceGraphProjectScopeKey, resourceGraphScopeKey, + resourceHydrationEnabled, sortMode, ]); @@ -2516,14 +2519,14 @@ export default function ProjectDevelopmentView({ useEffect(() => { resourceLayoutReadNoticeScopeRef.current = null; setResourceLayoutReadNotice(null); - }, [manifest.projectId, projectPath]); + }, [manifest.projectId, projectPath, resourceHydrationEnabled]); /** * 替换血缘只活在本次会话:项目身份一变就作废,不依赖"组件恰好被卸载"。 * 与上一条读时归并提示同一口径(切项目后上一次的状态必须清掉)。 */ useEffect(() => { setResourceVersionReplacementLineage(null); - }, [manifest.projectId, projectPath]); + }, [manifest.projectId, projectPath, resourceHydrationEnabled]); useEffect(() => { const report = dependencyLayout.readReport ?? typeLayout.readReport; if ( @@ -7193,6 +7196,7 @@ export default function ProjectDevelopmentView({ * effect 的未处理 Promise 拒绝。 */ useEffect(() => { + if (!resourceHydrationEnabled) return undefined; const invoke = window.__TAURI__?.core?.invoke; if (!invoke || !projectPath.trim() || !manifest.projectId.trim()) { return undefined; @@ -7241,7 +7245,7 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [manifest.projectId, projectPath]); + }, [manifest.projectId, projectPath, resourceHydrationEnabled]); /** * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 From fe02a99f62d81f8121619be4fbd1cad2ca9227cb Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 01:16:38 +0800 Subject: [PATCH 50/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Web=20ESLint=20TypeS?= =?UTF-8?q?cript=20=E8=A7=A3=E6=9E=90=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用仓库已有 TypeScript parser,避免 CI 将 TS 文件全部判为语法错误 --- .eslintrc.cjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6d9e0fc39..590d26d43 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1 +1,8 @@ -module.exports = {"root":true,"ignorePatterns":["ignored/**"],"parserOptions":{"ecmaVersion":"latest","sourceType":"module"},"plugins":["simple-import-sort"],"rules":{"simple-import-sort/imports":"error"}}; +module.exports = { + root: true, + ignorePatterns: ['ignored/**'], + parser: '@typescript-eslint/parser', + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + plugins: ['simple-import-sort'], + rules: { 'simple-import-sort/imports': 'error' }, +}; From 69b3b016b5820e56405df094fd6db12d9b6fa0da Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 01:20:22 +0800 Subject: [PATCH 51/54] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Web=20ESLint=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启用仓库已有 react-hooks 与 react-refresh 规则插件 --- .eslintrc.cjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 590d26d43..7eed3f288 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -3,6 +3,10 @@ module.exports = { ignorePatterns: ['ignored/**'], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - plugins: ['simple-import-sort'], - rules: { 'simple-import-sort/imports': 'error' }, + plugins: ['simple-import-sort', 'react-hooks', 'react-refresh'], + rules: { + 'simple-import-sort/imports': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-refresh/only-export-components': 'warn', + }, }; From 37f1e8a67b099a833d6a32ff5d9dfd77f26ed660 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 01:26:18 +0800 Subject: [PATCH 52/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Web=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=8E=92=E5=BA=8F=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整理生成类型与示例文件的导入顺序 --- .../src/features/ui-editor/types/FillMethod.ts | 2 +- sample.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts index 1651dbbee..3edc54e91 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts @@ -1,8 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { HorizontalFillOrigin } from "./HorizontalFillOrigin"; +import type { Radial90Origin } from "./Radial90Origin"; import type { Radial180Origin } from "./Radial180Origin"; import type { Radial360Origin } from "./Radial360Origin"; -import type { Radial90Origin } from "./Radial90Origin"; import type { VerticalFillOrigin } from "./VerticalFillOrigin"; export type FillMethod = { "Horizontal": HorizontalFillOrigin } | { "Vertical": VerticalFillOrigin } | { "Radial90": { origin: Radial90Origin, clockwise: boolean, } } | { "Radial180": { origin: Radial180Origin, clockwise: boolean, } } | { "Radial360": { origin: Radial360Origin, clockwise: boolean, } }; diff --git a/sample.ts b/sample.ts index b1d24bdef..a56d432a8 100644 --- a/sample.ts +++ b/sample.ts @@ -1,4 +1,6 @@ -import { zebra } from './zebra'; import { alpha } from './alpha'; -const staged={value:1} -void zebra; void alpha; void staged; +import { zebra } from './zebra'; +const staged = { value: 1 }; +void zebra; +void alpha; +void staged; From ca9cde1b913f5134f720d31c577702189f1daaec Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 01:31:59 +0800 Subject: [PATCH 53/54] =?UTF-8?q?=E9=81=BF=E5=85=8D=20Web=20ESLint=20?= =?UTF-8?q?=E9=A2=84=E5=AD=98=E8=AD=A6=E5=91=8A=E9=98=BB=E6=96=AD=E6=9E=84?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关闭已有组件导出规则警告,保留 TypeScript 与 Hook 规则解析 --- .eslintrc.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 7eed3f288..9d3358403 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,6 +7,6 @@ module.exports = { rules: { 'simple-import-sort/imports': 'error', 'react-hooks/exhaustive-deps': 'warn', - 'react-refresh/only-export-components': 'warn', + 'react-refresh/only-export-components': 'off', }, }; From e72a2da7336f17263e1f0874517a693d0e4b08f9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 16 Sep 2026 01:40:58 +0800 Subject: [PATCH 54/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=9A=94=E7=A6=BB=E5=B9=B6=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Direct=20=E6=B6=88=E6=81=AF=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隔离 Git hook 测试子进程环境并验证外层仓库不变 恢复被测试污染的完整 ESLint 与 Prettier 配置并清除测试产物 撤销无依据的聊天参数和资源初始化时序变更 严格验证 canonical userItem 和现行历史切片契约 同步开发运维文档与共享排障约定 --- .eslintrc.cjs | 430 +++++++++++++++++- .prettierrc.json | 6 +- apps/ai-game-creator-shell/src/App.tsx | 2 - .../features/ui-editor/types/FillMethod.ts | 2 +- .../src/view/project-development/index.tsx | 94 ++-- .../tests/appSurface/home.suite.ts | 157 ++++--- .../appSurface/project-development.suite.ts | 32 +- docs/project-memory/shared-memory/pitfalls.md | 6 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 6 + ignored/legacy.ts | 4 - partial.ts | 2 - sample.ts | 6 - scripts/git-hooks.test.mjs | 116 ++++- tracked.txt | 1 - 15 files changed, 708 insertions(+), 160 deletions(-) delete mode 100644 ignored/legacy.ts delete mode 100644 partial.ts delete mode 100644 sample.ts delete mode 100644 tracked.txt diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9d3358403..8a4b2d0c6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,12 +1,432 @@ +const globals = require('globals'); + module.exports = { root: true, - ignorePatterns: ['ignored/**'], + env: { + es2022: true, + }, parser: '@typescript-eslint/parser', - parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - plugins: ['simple-import-sort', 'react-hooks', 'react-refresh'], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + ...globals.browser, + ...globals.node, + }, + overrides: [ + { + files: ['scripts/**/*.{ts,js,mjs,cjs}'], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['**/scripts/**/*.{ts,js,mjs,cjs}'], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['**/*.test.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-console': 'off', + 'unused-imports/no-unused-vars': 'off', + }, + }, + { + files: ['miniprogram/**/*.js'], + globals: { + App: 'readonly', + }, + }, + { + files: [ + 'apps/admin-web/src/pages/*.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx', + 'src/components/platform-entry/PlatformMobileHomeWelcomeDialog.tsx', + ], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + { + files: ['src/components/platform-entry/PlatformEntryFlowShellImpl.tsx'], + rules: { + 'react-hooks/exhaustive-deps': 'off', + }, + }, + { + files: [ + 'src/active-main.tsx', + 'src/ActiveApp.tsx', + 'src/AuthenticatedApp.tsx', + 'src/hooks/useGameSettings.ts', + 'src/routing/activeApp*.ts', + 'src/routing/activeApp*.tsx', + 'src/components/auth/**/*.{ts,tsx}', + 'src/components/common/**/*.{ts,tsx}', + 'src/components/creation-home/**/*.{ts,tsx}', + 'src/components/image-editor/**/*.{ts,tsx}', + 'src/components/project/**/*.{ts,tsx}', + 'src/components/platform-entry/PlatformActiveProfileView*.tsx', + 'src/components/platform-entry/PlatformActiveMobileWelcomeDialog*.tsx', + 'src/components/platform-entry/PlatformEntryActiveFlowShell*.tsx', + 'src/components/platform-entry/PlatformEntryFlowShell.tsx', + 'src/components/platform-entry/PlatformProfileApiKeysModal.tsx', + 'src/components/platform-entry/PlatformProfileModalShell.tsx', + 'src/components/platform-entry/PlatformProfilePrimitives.tsx', + 'src/components/platform-entry/PlatformProfileRechargeModal.tsx', + 'src/components/platform-entry/PlatformProfileReferralModal.tsx', + 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx', + 'src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx', + 'src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx', + 'src/components/platform-entry/platformActiveProfileModel.ts', + 'src/components/platform-entry/platformEntryActiveTypes.ts', + 'src/components/platform-entry/platformProfileFundsModel.ts', + 'src/components/platform-entry/platformProfileHostClipboard.ts', + 'src/components/platform-entry/usePlatformProfileCenterController.ts', + 'src/services/image-editor/**/*.{ts,tsx}', + 'src/services/platform-entry/**/*.{ts,tsx}', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '**/components/rpg-entry/**', + '**/components/*-creation/**', + '**/components/*-result/**', + '**/components/*-runtime/**', + '**/components/creation-agent/**', + '**/components/creative-agent/**', + '**/components/custom-world-*/**', + '**/components/unified-creation/**', + '**/services/rpg-entry/**', + '**/services/rpg-runtime/**', + '**/services/*-creation/**', + '**/services/*-runtime/**', + '**/services/*-works/**', + '**/services/creation-agent/**', + '**/services/creative-agent/**', + '**/services/puzzle-*/**', + '**/services/storyEngine/**', + ], + message: '现役前端不得重新导入已退役的创作模板模块。', + }, + ], + }, + ], + }, + }, + { + files: ['src/components/game-canvas/**/*.tsx'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + { + files: ['src/components/GameShell.tsx', 'src/hooks/useCombatFlow.ts'], + rules: { + 'simple-import-sort/imports': 'off', + 'simple-import-sort/exports': 'off', + }, + }, + ], + plugins: [ + '@typescript-eslint', + 'react-hooks', + 'react-refresh', + 'simple-import-sort', + 'unused-imports', + ], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'prettier', + ], + ignorePatterns: [ + 'dist', + 'dist_check', + 'dist_check_monster_position', + 'coverage', + 'build', + 'output', + 'retired/**', + 'node_modules', + 'server-rs/target', + 'server-rs/target-*', + 'apps/desktop-shell/src-tauri/target', + 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', + 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', + 'target', + 'src/main.tsx', + 'src/App.tsx', + 'src/routing/appPageRoutes.ts', + 'src/routing/appPageRoutes.test.ts', + 'src/routing/appRoutes.tsx', + 'src/routing/appRoutes.test.ts', + 'src/services/appTitle.ts', + 'src/services/appTitle.test.ts', + 'src/components/CharacterAnimator.tsx', + 'src/components/*.ts', + 'src/components/*.tsx', + '!src/components/ResolvedAssetAudio.tsx', + '!src/components/ResolvedAssetImage.tsx', + '!src/components/ResolvedAssetVideo.tsx', + 'src/hooks/*.ts', + 'src/hooks/*.tsx', + '!src/hooks/useGameSettings.ts', + '!src/hooks/useHostNavigationCanGoBack.ts', + '!src/hooks/useHostNavigationCanGoBack.test.tsx', + '!src/hooks/useResolvedAssetReadUrl.ts', + '!src/hooks/useResolvedAssetReadUrl.test.tsx', + 'src/persistence/*.ts', + 'src/persistence/*.tsx', + '!src/persistence/gameSettingsStorage.ts', + '!src/persistence/gameSettingsStorage.test.ts', + '!src/persistence/storage.ts', + '!src/persistence/storage.test.ts', + 'src/routing/*.ts', + 'src/routing/*.tsx', + '!src/routing/activeAppPageRoutes.ts', + '!src/routing/activeAppPageRoutes.test.ts', + '!src/routing/activeAppRoutes.tsx', + '!src/routing/activeAppRoutes.test.ts', + 'src/services/*.ts', + 'src/services/*.tsx', + '!src/services/activeAppTitle.ts', + '!src/services/activeAppTitle.test.ts', + '!src/services/apiClient.ts', + '!src/services/apiClient.test.ts', + '!src/services/assetReadUrlService.ts', + '!src/services/assetReadUrlService.test.ts', + '!src/services/authService.ts', + '!src/services/authService.test.ts', + '!src/services/clipboard.ts', + '!src/services/clipboard.test.ts', + '!src/services/frontendRuntimeConfigService.ts', + '!src/services/frontendRuntimeConfigService.test.ts', + '!src/services/sseStream.ts', + '!src/services/sseStream.test.ts', + 'src/AdventurePanel.tsx', + 'src/AdventureEntityModal.tsx', + 'src/App.tsx', + 'src/App.test.tsx', + 'src/CharacterCreation.tsx', + 'src/RpgRuntimeApp.tsx', + 'src/routing/appPageRoutes.ts', + 'src/routing/appPageRoutes.test.ts', + 'src/routing/appRoutes.tsx', + 'src/routing/appRoutes.test.ts', + 'src/*PlaygroundApp.tsx', + 'src/ChildMotionDemoApp.tsx', + 'src/Match3DPlaygroundApp.tsx', + 'src/components/child-motion-demo/**', + 'src/components/asset-studio/**', + 'src/components/bark-battle-creation/**', + 'src/components/big-fish-creation/**', + 'src/components/big-fish-result/**', + 'src/components/big-fish-runtime/**', + 'src/components/creation-agent/**', + 'src/components/creative-agent/**', + 'src/components/custom-world-agent/**', + 'src/components/custom-world-home/**', + 'src/components/edutainment-creation/**', + 'src/components/edutainment-result/**', + 'src/components/edutainment-runtime/**', + 'src/components/game-canvas/**', + 'src/components/jump-hop-result/**', + 'src/components/jump-hop-runtime/**', + 'src/components/match3d-result/**', + 'src/components/match3d-runtime/**', + 'src/components/puzzle-clear-creation/**', + 'src/components/puzzle-clear-result/**', + 'src/components/puzzle-clear-runtime/**', + 'src/components/puzzle-gallery/**', + 'src/components/puzzle-result/**', + 'src/components/puzzle-runtime/**', + 'src/components/rpg-creation-asset-studio/**', + 'src/components/rpg-creation-result/**', + 'src/components/rpg-runtime-panels/**', + 'src/components/square-hole-creation/**', + 'src/components/square-hole-result/**', + 'src/components/square-hole-runtime/**', + 'src/components/unified-creation/**', + 'src/components/visual-novel-creation/**', + 'src/components/visual-novel-result/**', + 'src/components/visual-novel-runtime/**', + 'src/components/wooden-fish-result/**', + 'src/components/wooden-fish-runtime/**', + 'src/components/common/PublishShare*', + 'src/components/common/publishShare*', + 'src/components/platform-entry/PlatformEntryCreation*', + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + 'src/components/platform-entry/platformEntryTypes.ts', + 'src/components/platform-entry/PlatformEntryHome*', + 'src/components/platform-entry/PlatformEntryWorld*', + 'src/components/platform-entry/PlatformMobileHome*', + 'src/components/platform-entry/PlatformWork*', + 'src/components/platform-entry/PlatformDraft*', + 'src/components/platform-entry/PlatformTask*', + 'src/components/platform-entry/PlatformError*', + 'src/components/platform-entry/barkBattle*', + 'src/components/platform-entry/platformCreation*', + 'src/components/platform-entry/platformDialog*', + 'src/components/platform-entry/platformDraft*', + 'src/components/platform-entry/platformEdutainment*', + 'src/components/platform-entry/platformEntryCreation*', + 'src/components/platform-entry/platformExternal*', + 'src/components/platform-entry/platformGeneration*', + 'src/components/platform-entry/platformHost*', + 'src/components/platform-entry/platformMiniGame*', + 'src/components/platform-entry/platformPlayed*', + 'src/components/platform-entry/platformPublic*', + 'src/components/platform-entry/platformPuzzle*', + 'src/components/platform-entry/platformRecommend*', + 'src/components/platform-entry/platformRpg*', + 'src/components/platform-entry/platformSelection*', + 'src/components/platform-entry/puzzleDraft*', + 'src/components/platform-entry/usePlatformCreation*', + 'src/components/platform-entry/usePlatformEntry*', + 'src/components/platform-entry/PlatformEntryFlowShellImpl/**', + 'src/components/platform-entry/platformMatch3DRuntimeProfile*', + 'src/components/unified-creation/workspaces/JumpHopCreationWorkspace*', + 'src/components/unified-creation/workspaces/Match3DCreationWorkspace*', + 'src/components/rpg-creation-editor/**', + 'src/components/rpg-entry/**', + 'src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx', + 'src/components/custom-world-home/CustomWorldCreationHub.test.tsx', + 'src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx', + 'src/components/custom-world-home/creationWorkShelf.test.ts', + 'src/components/rpg-runtime-shell/**', + 'src/hooks/rpg-runtime-story/**', + 'src/hooks/rpg-session/**', + 'src/hooks/combat/**', + 'src/hooks/useCombatFlow.ts', + 'src/hooks/useStoryOptions.ts', + 'src/prompts/customWorldPrompts.ts', + 'src/services/ai.ts', + 'src/services/appTitle.ts', + 'src/services/appTitle.test.ts', + 'src/services/miniGameDraftGenerationProgress.ts', + 'src/services/creationEntryConfigService.ts', + 'src/services/creationUrlState*', + 'src/services/customWorld*', + 'src/services/input-devices/**', + 'src/services/publicWorkCode.ts', + 'src/services/runtimeGuestAuth.ts', + 'src/services/runtimeRequest*', + 'src/services/runtimeAudioFeedback.ts', + 'src/services/useMocapInput*', + 'src/services/wechatMiniProgramSubscribe*', + 'src/services/bark-battle-creation/**', + 'src/services/bark-battle-runtime/**', + 'src/services/big-fish-creation/**', + 'src/services/big-fish-gallery/**', + 'src/services/big-fish-runtime/**', + 'src/services/big-fish-works/**', + 'src/services/creation-agent/**', + 'src/services/creation-audio/**', + 'src/services/creative-agent/**', + 'src/services/edutainment-baby-drawing/**', + 'src/services/edutainment-baby-object/**', + 'src/services/child-motion-demo/**', + 'src/services/jump-hop/**', + 'src/services/match3d-creation/**', + 'src/services/match3d-runtime/**', + 'src/services/match3d-works/**', + 'src/services/match3dGeneratedModelCache*', + 'src/services/match3dSpritesheetParser*', + 'src/services/puzzle-agent/**', + 'src/services/puzzle-gallery/**', + 'src/services/puzzle-onboarding/**', + 'src/services/puzzle-runtime/**', + 'src/services/puzzle-works/**', + 'src/services/rpg-creation/**', + 'src/services/rpg-entry/**', + 'src/services/rpg-runtime/**', + 'src/services/square-hole-creation/**', + 'src/services/square-hole-runtime/**', + 'src/services/square-hole-works/**', + 'src/services/storyEngine/**', + 'src/services/visual-novel-creation/**', + 'src/services/visual-novel-runtime/**', + 'src/services/visual-novel-works/**', + 'src/services/wooden-fish/**', + 'src/types.ts', + 'src/types/**', + 'src/uiAssets.ts', + 'scripts/loadtest/**', + 'packages/shared/src/contracts/jumpHop.ts', + 'packages/shared/src/contracts/match3dAgent.ts', + 'packages/shared/src/contracts/match3dRuntime.ts', + 'packages/shared/src/contracts/match3dWorks.ts', + 'packages/shared/src/contracts/barkBattle*', + 'packages/shared/src/contracts/bigFish*', + 'packages/shared/src/contracts/creationAgent*', + 'packages/shared/src/contracts/creationAudio*', + 'packages/shared/src/contracts/creativeAgent*', + 'packages/shared/src/contracts/customWorld*', + 'packages/shared/src/contracts/edutainment*', + 'packages/shared/src/contracts/playTypes*', + 'packages/shared/src/contracts/publicWork*', + 'packages/shared/src/contracts/puzzle*', + 'packages/shared/src/contracts/rpg*', + 'packages/shared/src/contracts/squareHole*', + 'packages/shared/src/contracts/story*', + 'packages/shared/src/contracts/visualNovel*', + 'packages/shared/src/contracts/woodenFish*', + 'scripts/export-match3d-resource-pipeline*', + 'scripts/generate-child-motion-demo-assets.mjs', + 'src/services/puzzle-clear/**', + 'src/games/**', + 'src/data/**', + 'src/prompts/**', + 'apps/admin-web/src/pages/AdminCreationEntrySwitchPage*', + 'apps/admin-web/src/pages/AdminWorkVisibilityPage*', + 'src/services/recommendedRuntimeGuestLaunch.test.ts', + 'src/data/sceneEncounterPreviews.ts', + 'public/Icons', + 'media', + '.codex-logs', + '*.log', + '.preview.*', + 'temp-build-goal-check/**', + 'tmp_*', + 'tmp/**', + 'npc-editor-*', + 'temp-write-check.txt', + '**/__pycache__/**', + '*.timestamp-*.mjs', + ], rules: { + '@typescript-eslint/no-var-requires': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'react-refresh/only-export-components': [ + 'error', + { allowConstantExport: true }, + ], 'simple-import-sort/imports': 'error', - 'react-hooks/exhaustive-deps': 'warn', - 'react-refresh/only-export-components': 'off', + 'simple-import-sort/exports': 'error', + '@typescript-eslint/no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + 'no-constant-condition': 'error', + 'no-console': ['error', { allow: ['warn', 'error'] }], + 'no-useless-escape': 'error', + 'prefer-const': 'error', }, }; diff --git a/.prettierrc.json b/.prettierrc.json index 0bed832ed..7d2081a95 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1 +1,5 @@ -{"singleQuote":true,"semi":true,"trailingComma":"all"} \ No newline at end of file +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all" +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9a92bbdfe..7d0e734d6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6669,7 +6669,6 @@ export function App({ setProjectSupervisorRuntimeError(''); try { const directTurnInput: { - projectId: string; projectPath: string; prompt: string; clientTurnId: string; @@ -6677,7 +6676,6 @@ export function App({ attachments?: DirectCodexTurnAttachment[]; userItem: ReturnType; } = { - projectId: localProject.manifest.projectId, projectPath: directProjectPath, prompt, clientTurnId, diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts index 3edc54e91..1651dbbee 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts @@ -1,8 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { HorizontalFillOrigin } from "./HorizontalFillOrigin"; -import type { Radial90Origin } from "./Radial90Origin"; import type { Radial180Origin } from "./Radial180Origin"; import type { Radial360Origin } from "./Radial360Origin"; +import type { Radial90Origin } from "./Radial90Origin"; import type { VerticalFillOrigin } from "./VerticalFillOrigin"; export type FillMethod = { "Horizontal": HorizontalFillOrigin } | { "Vertical": VerticalFillOrigin } | { "Radial90": { origin: Radial90Origin, clockwise: boolean, } } | { "Radial180": { origin: Radial180Origin, clockwise: boolean, } } | { "Radial360": { origin: Radial360Origin, clockwise: boolean, } }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index c9bd95a93..b8fc97384 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1448,7 +1448,6 @@ export default function ProjectDevelopmentView({ onMakeGame, }: ProjectDevelopmentViewProps) { const professionalDagVisible = orchestrationMode === 'professional-dag'; - const resourceHydrationEnabled = orchestrationMode !== 'single-supervisor'; const [mode, setMode] = useState('resources'); const [runtimeInspectMode, setRuntimeInspectMode] = useState(false); const [resourceBookState, dispatchResourceBook] = useReducer( @@ -2146,7 +2145,6 @@ export default function ProjectDevelopmentView({ }); useEffect(() => { - if (!resourceHydrationEnabled) return undefined; let cancelled = false; if (sortMode !== 'dependency') { setResourceGraphState({ @@ -2185,17 +2183,14 @@ export default function ProjectDevelopmentView({ hasSnapshot: canReuseSnapshot, }; }); - const readResourceGraph = () => - invoke( - 'read_local_project_resource_graph', - { - projectPath, - expectedProjectId: manifest.projectId, - resources: resourceGraphInputs, - }, - ); - void new Promise((resolve) => setTimeout(resolve, 0)) - .then(readResourceGraph) + void invoke( + 'read_local_project_resource_graph', + { + projectPath, + expectedProjectId: manifest.projectId, + resources: resourceGraphInputs, + }, + ) .then((readModel) => { if (!cancelled) { setResourceGraphState({ @@ -2225,7 +2220,6 @@ export default function ProjectDevelopmentView({ resourceGraphInputs, resourceGraphProjectScopeKey, resourceGraphScopeKey, - resourceHydrationEnabled, sortMode, ]); @@ -2519,14 +2513,14 @@ export default function ProjectDevelopmentView({ useEffect(() => { resourceLayoutReadNoticeScopeRef.current = null; setResourceLayoutReadNotice(null); - }, [manifest.projectId, projectPath, resourceHydrationEnabled]); + }, [manifest.projectId, projectPath]); /** * 替换血缘只活在本次会话:项目身份一变就作废,不依赖"组件恰好被卸载"。 * 与上一条读时归并提示同一口径(切项目后上一次的状态必须清掉)。 */ useEffect(() => { setResourceVersionReplacementLineage(null); - }, [manifest.projectId, projectPath, resourceHydrationEnabled]); + }, [manifest.projectId, projectPath]); useEffect(() => { const report = dependencyLayout.readReport ?? typeLayout.readReport; if ( @@ -7196,7 +7190,6 @@ export default function ProjectDevelopmentView({ * effect 的未处理 Promise 拒绝。 */ useEffect(() => { - if (!resourceHydrationEnabled) return undefined; const invoke = window.__TAURI__?.core?.invoke; if (!invoke || !projectPath.trim() || !manifest.projectId.trim()) { return undefined; @@ -7206,46 +7199,45 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationTasksPanelOpen(false); resourceAssetGenerationPanelSubmissionRef.current = null; setResourceAssetGenerationPanelReopen(null); - void new Promise((resolve) => setTimeout(resolve, 0)).then( - async () => { - const reportUnavailable = () => { - if (!cancelled) { - setResourceWorkbenchNotice( - '生成任务列表读取失败,暂时无法恢复历史任务', - ); - } - }; - let records: LocalProjectAssetGenerationTaskRecord[]; - try { - const response = await invoke< - LocalProjectAssetGenerationTaskRecord[] - >('list_local_project_asset_generations', { projectPath }); - if (!Array.isArray(response)) { - reportUnavailable(); - return; - } - records = response; - } catch { + void (async () => { + const reportUnavailable = () => { + if (!cancelled) { + setResourceWorkbenchNotice( + '生成任务列表读取失败,暂时无法恢复历史任务', + ); + } + }; + let records: LocalProjectAssetGenerationTaskRecord[]; + try { + const response = await invoke( + 'list_local_project_asset_generations', + { projectPath }, + ); + if (!Array.isArray(response)) { reportUnavailable(); return; } - if (cancelled) { - return; - } - const restored = mergeResourceCanvasAssetGenerationTasksWithRecords( - resourceAssetGenerationTasksRef.current.filter( - (task) => task.projectId === projectId, - ), - records, - ); - resourceAssetGenerationTasksRef.current = restored; - setResourceAssetGenerationTasks(restored); - }, - ); + records = response; + } catch { + reportUnavailable(); + return; + } + if (cancelled) { + return; + } + const restored = mergeResourceCanvasAssetGenerationTasksWithRecords( + resourceAssetGenerationTasksRef.current.filter( + (task) => task.projectId === projectId, + ), + records, + ); + resourceAssetGenerationTasksRef.current = restored; + setResourceAssetGenerationTasks(restored); + })(); return () => { cancelled = true; }; - }, [manifest.projectId, projectPath, resourceHydrationEnabled]); + }, [manifest.projectId, projectPath]); /** * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index af621b7b5..6eb1cb305 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1564,6 +1564,12 @@ export function registerHomeProjectCreationTests() { prompt: '你好,今天多少号', creationType: 'art', clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '你好,今天多少号' }], + }, }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', @@ -1664,6 +1670,12 @@ export function registerHomeProjectCreationTests() { prompt: '按这个角色做游戏', creationType: 'game', clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '按这个角色做游戏' }], + }, attachments: [ { name: '角色参考.png', @@ -1694,6 +1706,12 @@ export function registerHomeProjectCreationTests() { projectPath: automaticProjectPath, prompt: '再补一句玩法', clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '再补一句玩法' }], + }, }); const followUpPayload = invoke.mock.calls.find( ([command, args]) => @@ -2433,16 +2451,9 @@ export function registerHomeProjectCreationTests() { policy: { deniedCommands: [], confirmCommands: [] }, }; } - if ( - command === 'read_local_conversation' || - command === 'read_direct_project_conversation' - ) { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; + if (command === 'read_direct_project_history_slice') { + expect(args).toEqual({ projectPath, limit: 20 }); + return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_conversation_message') { throw new Error( @@ -2451,18 +2462,14 @@ export function registerHomeProjectCreationTests() { } if (command === 'chat_with_game_creator_direct_codex') { const clientTurnId = String(args?.clientTurnId ?? ''); - persistedMessages.push( - { - role: 'user', - content: String(args?.prompt ?? ''), - messageId: `direct-codex:${clientTurnId}:user`, - }, - { - role: 'assistant', - content: 'DIRECT_EXISTING_PROJECT_OK', - messageId: `direct-codex:${clientTurnId}:assistant`, - }, - ); + persistedMessages.push(args?.userItem as Record, { + role: 'assistant', + type: 'message', + content: [ + { type: 'output_text', text: 'DIRECT_EXISTING_PROJECT_OK' }, + ], + id: `direct-codex:${clientTurnId}:assistant`, + }); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2486,6 +2493,12 @@ export function registerHomeProjectCreationTests() { projectPath, prompt: '继续修改已有项目', clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '继续修改已有项目' }], + }, }, ); }); @@ -2524,16 +2537,9 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath }); return manifest; } - if ( - command === 'read_local_conversation' || - command === 'read_direct_project_conversation' - ) { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; + if (command === 'read_direct_project_history_slice') { + expect(args).toEqual({ projectPath, limit: 20 }); + return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_permission_log') { return {}; @@ -2544,49 +2550,30 @@ export function registerHomeProjectCreationTests() { policy: { deniedCommands: [], confirmCommands: [] }, }; } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } if (command === 'chat_with_game_creator_direct_codex') { const clientTurnId = String(args?.clientTurnId ?? ''); - persistedMessages.push( - { - schemaVersion: 'game-creator-conversation.v1', + expect(args).toEqual({ + projectPath, + prompt: '生成一个游戏', + clientTurnId: expect.any(String), + userItem: { + id: `direct-codex:${clientTurnId}:user`, + type: 'message', role: 'user', - content: String(args?.prompt ?? ''), - messageId: `direct-codex:${clientTurnId}:user`, - updatedAt: 1, + content: [{ type: 'input_text', text: '生成一个游戏' }], }, - { - schemaVersion: 'game-creator-conversation.v1', - role: 'assistant', - content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - messageId: `direct-codex:${clientTurnId}:assistant`, - updatedAt: 2, - }, - ); + }); + persistedMessages.push(args?.userItem as Record, { + id: `direct-codex:${clientTurnId}:assistant`, + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + }, + ], + }); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); @@ -2609,15 +2596,25 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(persistedMessages).toHaveLength(2); }); - expect(persistedMessages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ role: 'user', content: '生成一个游戏' }), - expect.objectContaining({ - role: 'assistant', - content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }), - ]), - ); + expect(persistedMessages).toEqual([ + { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '生成一个游戏' }], + }, + { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + }, + ], + }, + ]); expect(JSON.stringify(persistedMessages)).not.toContain( 'codex-app-server-error:unauthorized', ); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b290998a1..3cc309c8d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7312,6 +7312,17 @@ export function registerProjectSupervisorSurfaceTests() { projectPath, prompt: '从旧任务状态继续生成,但使用 direct Codex', clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: '从旧任务状态继续生成,但使用 direct Codex', + }, + ], + }, }); expect(invoke).not.toHaveBeenCalledWith( 'cancel_game_creator_agent_runtime_task', @@ -7802,6 +7813,12 @@ export function registerProjectSupervisorSurfaceTests() { projectPath, prompt: '先完成正式客户端玩法拆解', clientTurnId: firstTurnId, + userItem: { + id: `direct-codex:${firstTurnId}:user`, + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], + }, }); await act(async () => { @@ -8048,6 +8065,12 @@ export function registerProjectSupervisorSurfaceTests() { projectPath, prompt: '补充:优先复用现有素材', clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '补充:优先复用现有素材' }], + }, }, ); }); @@ -8209,8 +8232,9 @@ export function registerProjectSupervisorSurfaceTests() { }); expect(professionalRuntimeReadCount).toBe(0); await waitFor(() => - expect(invoke).toHaveBeenCalledWith('read_direct_project_conversation', { + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, + limit: 20, }), ); // 默认任务占位行也不能触发专业 Agent 历史的批量读取。 @@ -8434,6 +8458,12 @@ export function registerProjectSupervisorSurfaceTests() { projectPath, prompt: '修改玩家移动脚本', clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '修改玩家移动脚本' }], + }, }, ); }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fa96f595f..1ae8c60d6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5609,3 +5609,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:`Genarrative-Stdb-Module-Publish` 在备份和 SpacetimeDB 就绪后,于 `spacetime publish` 报 `Permission denied`。 - **原因**:Jenkins 以 root 运行时 `${HOME}/data/tmp` 位于 `/root` 下;即使发布临时子目录已 `chown` 给 `spacetimedb`,父目录仍不可遍历。 - **处理**:发布给 `--run-as-user` 的 WASM 临时目录改用 `/var/tmp`,继续使用随机目录并在退出时清理。 + +## Git hook 测试必须清除继承的 Git 仓库环境 + +- 在 hook 内运行临时仓库测试时,`cwd` 不会覆盖继承的 `GIT_DIR`、`GIT_WORK_TREE` 或 `GIT_INDEX_FILE`。未隔离的 Git/lint-staged 子进程可能向真实仓库提交 fixture,甚至把测试版 ESLint、Prettier 配置带入主分支。 +- fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。 +- Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7d6c1ca0b..2bdeedb04 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,9 @@ # AI 游戏创作智能体 App 实施计划 +## DirectProject 用户消息契约验证 + +`chat_with_game_creator_direct_codex` 必须携带 `projectPath`、`prompt`、稳定的 `clientTurnId` 和完整 `userItem`;`creationType` 与 `attachments` 按实际输入传递。Rust 通过 `projectPath` 解析项目身份,不接收额外 `projectId`。界面测试必须核对 `userItem` 的消息身份、角色、正文及附件内容,拒绝回合用例仍验证实际返回的错误原因。重开项目的历史恢复测试使用 `read_direct_project_history_slice` 的 canonical raw items 与 `hasMore`,首屏 `limit: 20`。资源图和生成任务的读取继续遵守原有工作台恢复逻辑,不因聊天断言失败延迟、关闭或改变它们。 + ## 2026-09-15 DirectProject 长回合平台会话保活 DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1ca7a142f..d14bcff3d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,5 +1,11 @@ # 本地开发验证与生产运维 +## 构建回归的隔离与发布文件权限 + +Git hook 的临时仓库测试必须清除子进程继承的仓库定位环境(例如 `GIT_DIR`、`GIT_WORK_TREE`、`GIT_INDEX_FILE`);仅设置 `cwd` 不能隔离 Git。回归应从带这些变量的外层仓库运行,验证外层引用、索引与配置不变。临时测试文件必须留在独立目录并清理,不得通过测试生成主仓库提交或覆盖 ESLint、Prettier 配置。修复 lint 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。 + +Stdb 发布以 root 准备文件、再切换 `spacetimedb` 用户执行时,WASM 必须放在服务用户可遍历的父目录下。生产脚本在 `/var/tmp` 创建随机私有目录并移交给发布用户,退出时清理;不能仅修改子目录所有权后继续从 `/root` 下读取。发布成功的判据仍是完整 Full Build 的 Stdb、API、Web 发布及退出维护模式全部成功。 + 更新时间:`2026-08-05` ## 标准开发流程 diff --git a/ignored/legacy.ts b/ignored/legacy.ts deleted file mode 100644 index 1e3e9ab8f..000000000 --- a/ignored/legacy.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { zebra } from './zebra'; -import { alpha } from './alpha'; -void zebra; void alpha; -// staged ignored change diff --git a/partial.ts b/partial.ts deleted file mode 100644 index 30eff13f8..000000000 --- a/partial.ts +++ /dev/null @@ -1,2 +0,0 @@ -const original={value:1} -const keep = 2; diff --git a/sample.ts b/sample.ts deleted file mode 100644 index a56d432a8..000000000 --- a/sample.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { alpha } from './alpha'; -import { zebra } from './zebra'; -const staged = { value: 1 }; -void zebra; -void alpha; -void staged; diff --git a/scripts/git-hooks.test.mjs b/scripts/git-hooks.test.mjs index c285265cb..295973c8c 100644 --- a/scripts/git-hooks.test.mjs +++ b/scripts/git-hooks.test.mjs @@ -9,7 +9,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { homedir } from 'node:os'; import { delimiter, dirname, join, resolve } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -34,7 +34,7 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns 'npm run format:staged\n', ); - const tempRepo = mkdtempSync(join(tmpdir(), 'genarrative-git-hooks-')); + const tempRepo = createTempDirectory('genarrative-git-hooks-'); try { git(tempRepo, 'init', '--quiet'); git(tempRepo, 'config', 'user.email', 'git-hooks-test@example.invalid'); @@ -128,7 +128,7 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns cwd: tempRepo, encoding: 'utf8', env: { - ...process.env, + ...isolatedGitEnvironment(), PATH: `${join(repoRoot, 'node_modules', '.bin')}${delimiter}${process.env.PATH ?? ''}`, }, input: JSON.stringify(lintStagedConfig), @@ -162,7 +162,7 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns }); test('pre-push runs repository parity only for master updates', () => { - const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-pre-push-')); + const tempDir = createTempDirectory('genarrative-pre-push-'); try { const npmLog = join(tempDir, 'repo', 'npm.log'); const tempRepo = join(tempDir, 'repo'); @@ -214,7 +214,7 @@ exit 1 { cwd: tempRepo, encoding: 'utf8', - env: process.env, + env: isolatedGitEnvironment(), }, ); }; @@ -237,6 +237,92 @@ exit 1 } }); +test('hook fixtures do not mutate the calling linked worktree or its index', () => { + const tempDir = createTempDirectory('genarrative-hook-isolation-'); + try { + const outerRepo = join(tempDir, 'outer'); + const worktree = join(tempDir, 'worktree'); + mkdirSync(outerRepo); + git(outerRepo, 'init', '--quiet'); + git(outerRepo, 'config', 'user.email', 'outer@example.invalid'); + git(outerRepo, 'config', 'user.name', 'Outer Repository'); + writeFileSync(join(outerRepo, 'sentinel.txt'), 'committed\n'); + git(outerRepo, 'add', 'sentinel.txt'); + git( + outerRepo, + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '-m', + 'sentinel', + ); + git( + outerRepo, + 'worktree', + 'add', + '--quiet', + '-b', + 'fixture-caller', + worktree, + ); + writeFileSync(join(worktree, 'sentinel.txt'), 'staged\n'); + git(worktree, 'add', 'sentinel.txt'); + writeFileSync(join(worktree, 'sentinel.txt'), 'unstaged\n'); + const gitDir = git(worktree, 'rev-parse', '--absolute-git-dir').trim(); + const indexPath = join(gitDir, 'index'); + const before = { + refs: git(outerRepo, 'show-ref'), + config: readFileSync(join(outerRepo, '.git', 'config'), 'utf8'), + index: readFileSync(indexPath), + status: git(worktree, 'status', '--porcelain'), + }; + const result = spawnSync( + process.execPath, + [ + '--test', + '--test-reporter=tap', + '--test-name-pattern=^pre-(commit|push)', + fileURLToPath(import.meta.url), + ], + { + cwd: worktree, + encoding: 'utf8', + env: { + ...isolatedGitEnvironment(), + NODE_TEST_CONTEXT: undefined, + GIT_DIR: gitDir, + GIT_COMMON_DIR: join(outerRepo, '.git'), + GIT_WORK_TREE: worktree, + GIT_INDEX_FILE: indexPath, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.worktree', + GIT_CONFIG_VALUE_0: worktree, + }, + }, + ); + assert.equal( + result.status, + 0, + `${result.stdout ?? ''}${result.stderr ?? ''}`, + ); + assert.match(result.stdout, /# pass 2\b/u); + assert.equal(git(outerRepo, 'show-ref'), before.refs); + assert.equal( + readFileSync(join(outerRepo, '.git', 'config'), 'utf8'), + before.config, + ); + assert.deepEqual(readFileSync(indexPath), before.index); + assert.equal(git(worktree, 'status', '--porcelain'), before.status); + assert.equal( + readFileSync(join(worktree, 'sentinel.txt'), 'utf8'), + 'unstaged\n', + ); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +}); + test('Gitea Repository checks and master pre-push share the same repository command', () => { const workflow = readFileSync( join(repoRoot, '.gitea', 'workflows', 'project-ci.yml'), @@ -290,5 +376,23 @@ function toBashPath(path) { } function git(cwd, ...args) { - return execFileSync('git', args, { cwd, encoding: 'utf8' }); + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: isolatedGitEnvironment(), + }); +} + +function isolatedGitEnvironment() { + // Git hooks export repository/index paths that override cwd, including in + // linked worktrees. Fixture Git and lint-staged must never inherit them. + return Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/^GIT_/iu.test(name)), + ); +} + +function createTempDirectory(prefix) { + const tempRoot = join(homedir(), 'data', 'tmp'); + mkdirSync(tempRoot, { recursive: true }); + return mkdtempSync(join(tempRoot, prefix)); } diff --git a/tracked.txt b/tracked.txt deleted file mode 100644 index 180b47c18..000000000 --- a/tracked.txt +++ /dev/null @@ -1 +0,0 @@ -baseline