diff --git a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md index e6ba4d50c..916f60814 100644 --- a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md +++ b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md @@ -41,3 +41,14 @@ ## 注意 不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。 + +## 通用表查询页的枚举展示(2026-09-23 起) + +后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析: + +- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。 +- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。 +- 映射同时应用到 `cells` 与 `raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。 +- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。 + +因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`。 diff --git a/.env.example b/.env.example index ac31e1dd9..60dc47b7e 100644 --- a/.env.example +++ b/.env.example @@ -237,14 +237,10 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false" # 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。 # Windows/macOS 是系统维度,不填写 dev-win/dev-mac。 +# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.world,release 对应 https://www.genarrative.world。 +# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。 GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev" -# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。 -# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。 -# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。 -# dev 使用 https://dev.genarrative.world;release 使用 https://www.genarrative.world。 -GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082" - # Optional: official VikingDB credentials for regenerating build-tag similarities # with the Python embedding script. The script auto-loads `.env.local` and uses # the fixed `bge-large-zh` embedding model. diff --git a/CONTEXT.md b/CONTEXT.md index 59acab2ff..d2c271f90 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -194,6 +194,14 @@ _Avoid_: 进度通知、快照轮询、第二套历史 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer +**项目对话输入**: +AGC 项目对话的输入只有自然语言回合(含 `@` 素材引用与附件);需要动作时由 Runtime 工具与确认卡承接,不从输入文本解析控制词。 +_Avoid_: 斜杠命令、聊天命令草稿、命令发现列表 + +**项目命令 id**: +AGC 运行期工具与项目权限策略使用的稳定标识(`GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`),由 Rust 运行期策略校验与 App 权限审计 / 项目前置条件判定消费,不是用户输入语法。 +_Avoid_: 把命令 id 当作可输入的聊天命令、为权限位补聊天入口 + **引用候选**: 输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。 _Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里 @@ -210,6 +218,18 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内 引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。 _Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段 +**引用名**: +引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`。 +_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白 + +**引用候选枚举**: +一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。 +_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单 + +**引用粘贴解析**: +把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。 +_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字 + **引用输入区**: 只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。 _Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部 @@ -240,6 +260,7 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附 - **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。 - **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。 - 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。 +- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token(`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。 - **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。 ## Example dialogue diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 05cfdc10e..7c5464f06 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -8,10 +8,12 @@ import { getAdminUserDetail, importAdminAgcTemplates, listAdminAgcTrackingEvents, + listAdminGameDistributionGames, listAdminGameDistributionReviews, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, + restoreAdminGameDistributionGame, reviewAdminGameDistributionVersion, suspendAdminGameDistributionGame, updateAdminAccount, @@ -501,7 +503,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键' { decision: 'approve', expectedPublicationRevision: 3, - entryUrl: 'https://games.example.test/releases/game_1/index.html', }, ); @@ -521,12 +522,90 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键' body: JSON.stringify({ decision: 'approve', expectedPublicationRevision: 3, - entryUrl: 'https://games.example.test/releases/game_1/index.html', }), }), ); }); +test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, data: { games: [] } }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + data: { + game: { + id: 'game/1', + title: '测试游戏', + status: 'published', + publicationRevision: 10, + }, + replayed: false, + }, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + + await listAdminGameDistributionGames( + 'admin-token', + { limit: 80 }, + controller.signal, + ); + await restoreAdminGameDistributionGame( + 'admin-token', + ' game/1 ', + ' game-restore-key-1 ', + { expectedPublicationRevision: 9 }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games?limit=50', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'GET', + signal: controller.signal, + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + }), + }), + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + '/admin/api/game-distribution/games/game%2F1/restore', + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-restore-key-1', + }), + body: JSON.stringify({ expectedPublicationRevision: 9 }), + }), + ); + + expect(() => + restoreAdminGameDistributionGame('admin-token', ' ', 'key', { + expectedPublicationRevision: 9, + }), + ).toThrow('缺少游戏 ID'); + expect(() => + restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', { + expectedPublicationRevision: 9, + }), + ).toThrow('恢复幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + test('安全下架请求携带公开修订号、原因与幂等键', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index da313c68b..8aae410af 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -32,6 +32,9 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminGameDistributionGameListResponse, + AdminGameDistributionRestoreRequest, + AdminGameDistributionRestoreResponse, AdminGameDistributionReviewListResponse, AdminGameDistributionReviewRequest, AdminGameDistributionReviewResponse, @@ -1240,6 +1243,46 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +export function listAdminGameDistributionGames( + token: string, + options: { limit?: number } = {}, + signal?: AbortSignal, +) { + const requestedLimit = options.limit ?? 50; + const normalizedLimit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50) + : 50; + return request( + `/admin/api/game-distribution/games?limit=${normalizedLimit}`, + { token, signal }, + ); +} + +export function restoreAdminGameDistributionGame( + token: string, + gameId: string, + idempotencyKey: string, + payload: AdminGameDistributionRestoreRequest, +) { + const normalizedGameId = gameId.trim(); + const normalizedKey = idempotencyKey.trim(); + if (!normalizedGameId) { + throw new Error('缺少游戏 ID'); + } + if (!normalizedKey || normalizedKey.length > 128) { + throw new Error('恢复幂等键必须是 1 到 128 个字符'); + } + return request( + `/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + /** * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 * 两条审核结论。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 71159500c..01e99ed10 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload { export interface AdminDashboardMetricsPayload { generatedAssets: number; + /** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */ consumedMudPoints: number; + /** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */ + refundedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; newUserPaymentConversion: AdminDashboardPaymentConversionPayload; @@ -955,6 +958,8 @@ export interface AdminRechargeOrderEntryPayload { productTitle: string; productKind: string; amountCents: number; + /** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */ + paidAmountCents: number; status: string; paymentChannel: string; paidAtMicros?: number | null; @@ -994,6 +999,8 @@ export interface AdminUserDetailResponse { phoneBound: boolean; wechatBound: boolean; historicalConsumedPoints: number; + /** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */ + cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; @@ -1103,7 +1110,6 @@ export interface AdminGameDistributionReviewRequest { decision: 'approve' | 'reject'; expectedPublicationRevision: number; reviewReason?: string; - entryUrl?: string; } export interface AdminGameDistributionReviewResponse { @@ -1126,6 +1132,57 @@ export interface AdminGameDistributionSuspendResponse { replayed: boolean; } +export interface AdminGameDistributionGameVersionEntry { + versionId: string; + gameId: string; + versionNumber: number; + status: string; + reviewReason: string | null; + packageBytes: number; + packageSha256: string; + createdAt: string; + updatedAt: string; + reviewedAt: string | null; + publishedAt: string | null; + entryUrl: string | null; +} + +export interface AdminGameDistributionGameEntry { + gameId: string; + title: string; + author: { + id: string; + name: string; + avatarUrl: string | null; + }; + status: string; + versionCount: number; + playCount: number; + activeVersionId: string | null; + publicationRevision: number; + createdAt: string; + updatedAt: string; + versions: AdminGameDistributionGameVersionEntry[]; +} + +export interface AdminGameDistributionGameListResponse { + games: AdminGameDistributionGameEntry[]; +} + +export interface AdminGameDistributionRestoreRequest { + expectedPublicationRevision: number; +} + +export interface AdminGameDistributionRestoreResponse { + game: { + id: string; + title: string; + status: string; + publicationRevision: number; + }; + replayed: boolean; +} + export interface AdminAgcTemplatePayload { id: string; title: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 73b48d326..f9294a7a5 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -29,6 +29,7 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; +import { AdminGameManagementPage } from '../pages/AdminGameManagementPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -321,6 +322,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'game-management' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( { expect(routeHash('game-distribution')).toBe('#game-distribution'); }); +test('后台游戏管理路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'game-management', + label: '游戏管理', + hash: '#game-management', + }); + expect(resolveAdminRoute('#game-management')).toBe('game-management'); + expect(routeHash('game-management')).toBe('#game-management'); +}); + test('member 可单独获得游戏审核 Tab 权限', () => { const routes = getAccessibleAdminRoutes({ accountRole: 'member', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 46aa13750..d6c948348 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -17,6 +17,7 @@ export type AdminRouteId = | 'editor-generation-pricing' | 'editor-showcase' | 'game-distribution' + | 'game-management' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -60,6 +61,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-templates', label: '模板管理', hash: '#agc-templates' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' }, + { id: 'game-management', label: '游戏管理', hash: '#game-management' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx index a8ba9f380..0c14b58be 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -51,6 +51,7 @@ const detail: AdminUserDetailResponse = { phoneBound: true, wechatBound: true, historicalConsumedPoints: 1234, + cumulativeRechargedCents: 128800, canReconcileConsumption: true, wallet, rechargeOrders: [ @@ -62,6 +63,7 @@ const detail: AdminUserDetailResponse = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -124,7 +126,11 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('历史花费')).toBeTruthy(); expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy(); + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('¥1288.00')).toBeTruthy(); expect(screen.getByText('order-1')).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); await user.keyboard('{Escape}'); await waitFor(() => @@ -133,6 +139,29 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 await waitFor(() => expect(document.activeElement).toBe(trigger)); }); +test('累计充值读取不到时展示未知,不用订单列表近似', async () => { + vi.mocked(getAdminUserDetail).mockResolvedValue({ + ...detail, + cumulativeRechargedCents: null, + }); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '查看用户信息' })); + await screen.findByText('陶泥用户'); + + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe( + '未知', + ); +}); + test('只有陶泥号时按 publicUserCode 查询用户', async () => { const user = userEvent.setup(); render( diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 8aa07d1be..66725d952 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -364,6 +364,7 @@ export function AdminUserDetailDialog({ 订单 商品 实付 + 发放泥点 退款 状态 @@ -377,11 +378,13 @@ export function AdminUserDetailDialog({ {formatMicros(order.createdAtMicros)} + {order.productTitle || order.productId} - {order.productTitle || order.productId} - 发放 {order.pointsDelta} 泥点 + {order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'} - {formatMoney(order.amountCents)} + {order.pointsDelta} 泥点 {formatMoney(order.cumulativeSuccessRefundCents)} 欠账 {order.unrecoveredPoints} 泥点 @@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
登录方式
{detail.loginMethod || '-'}
+
+
累计充值
+
+ {typeof detail.cumulativeRechargedCents === 'number' + ? formatMoney(detail.cumulativeRechargedCents) + : '未知'} +
+
绑定状态
diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx index 667e44af5..11c3f5493 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx @@ -339,7 +339,10 @@ test.each([409, 503])( await confirmWrite(); const message = await screen.findByRole('alert'); const feedback = message.parentElement!; - expect(document.activeElement).toBe(feedback); + // 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect), + // 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地, + // 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。 + await waitFor(() => expect(document.activeElement).toBe(feedback)); expect(feedback.tabIndex).toBe(-1); expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(viewport.scrollTop).toBe(20); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index a278a19dc..3653df930 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = { metrics: { generatedAssets: 12, consumedMudPoints: 88, + refundedMudPoints: 24, totalRegisteredUsers: 1200, newRegisteredUsers: 16, newUserPaymentConversion: { @@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(await screen.findByText('总计数据')).toBeTruthy(); expect(screen.getByText('时段数据')).toBeTruthy(); expect(screen.getByText('本日生产素材数')).toBeTruthy(); + expect(screen.getByText('本日消耗泥点数')).toBeTruthy(); + expect(screen.getByText('本日退还泥点数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('新增用户转化与留存')).toBeTruthy(); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index 933abf45a..111e7e9a0 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -128,6 +128,12 @@ export function AdminDashboardPage({ value: metrics?.consumedMudPoints ?? 0, unit: '泥点', }, + { + id: 'refunded-mud-points', + label: `${rangePrefix(granularity)}退还泥点数`, + value: metrics?.refundedMudPoints ?? 0, + unit: '泥点', + }, { id: 'new-registered-users', label: `${rangePrefix(granularity)}新增用户数`, diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index ae00212d9..8291e815e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import { @@ -9,10 +15,7 @@ import { suspendAdminGameDistributionGame, } from '../api/adminApiClient'; import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; -import { - AdminGameDistributionReviewPage, - resolveGameReleaseEntryUrlError, -} from './AdminGameDistributionReviewPage'; +import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage'; vi.mock('../api/adminApiClient', () => ({ isAdminApiError: vi.fn( @@ -53,23 +56,7 @@ beforeEach(() => { }); }); -test('发行入口必须是带完整来源的 HTTPS 地址', () => { - expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口'); - expect( - resolveGameReleaseEntryUrlError('http://games.test/a/index.html'), - ).toBe('发行入口必须以 https:// 开头'); - expect( - resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'), - ).toBe('发行入口不能包含 query 或 fragment'); - expect( - resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'), - ).toBe('发行入口不能包含凭据'); - expect( - resolveGameReleaseEntryUrlError('https://games.test/a/index.html'), - ).toBe(''); -}); - -test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => { +test('通过审核只提交当前 publicationRevision 并刷新列表', async () => { vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ version: { ...entry, status: 'published' }, replayed: false, @@ -83,9 +70,10 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 ); await screen.findByText('game_1'); - fireEvent.change(screen.getByLabelText('发行入口'), { - target: { value: 'https://games.test/releases/game_1/index.html' }, - }); + expect(screen.queryByLabelText('发行入口')).toBeNull(); + expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull(); + expect(screen.queryByLabelText('拒绝理由')).toBeNull(); + expect(screen.queryByLabelText('下架原因')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '通过' })); await waitFor(() => @@ -99,7 +87,6 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 expect(payload).toEqual({ decision: 'approve', expectedPublicationRevision: 4, - entryUrl: 'https://games.test/releases/game_1/index.html', }); await waitFor(() => expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes( @@ -108,7 +95,12 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 ); }); -test('缺少拒绝理由时不调用审核接口', async () => { +test('点击拒绝后填写理由再提交审核接口', async () => { + vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ + version: { ...entry, status: 'rejected', reviewReason: '运行时报错' }, + replayed: false, + }); + render( { await screen.findByText('game_1'); fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + const dialog = await screen.findByRole('dialog'); + const reasonInput = within(dialog).getByRole('textbox', { + name: '拒绝理由', + }); - expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' })); + expect(await within(dialog).findByText('拒绝审核必须填写理由')).toBeTruthy(); expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); + + fireEvent.change(reasonInput, { target: { value: '运行时报错' } }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' })); + + await waitFor(() => + expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1), + ); + const [, versionId, , payload] = + vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? []; + expect(versionId).toBe('version-1'); + expect(payload).toEqual({ + decision: 'reject', + expectedPublicationRevision: 4, + reviewReason: '运行时报错', + }); }); -test('安全下架需要二次确认,并携带公开修订号与原因', async () => { +test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => { vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ game: { id: 'game_1', @@ -142,16 +154,21 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async ); await screen.findByText('game_1'); - fireEvent.change(screen.getByLabelText('下架原因'), { - target: { value: '盗用素材' }, - }); fireEvent.click(screen.getByRole('button', { name: '安全下架' })); + const reasonDialog = await screen.findByRole('dialog'); + fireEvent.change( + within(reasonDialog).getByRole('textbox', { name: '下架原因' }), + { + target: { value: '盗用素材' }, + }, + ); + fireEvent.click( + within(reasonDialog).getByRole('button', { name: '继续下架' }), + ); - // 第一次点击只弹出确认面板,不直接调用后端。 - expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); - expect(await screen.findByRole('dialog')).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '确认' })); + await screen.findByText('确认操作'); + const confirmDialog = screen.getByRole('dialog'); + fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' })); await waitFor(() => expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), @@ -168,7 +185,24 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); }); -test('取消确认时不下架', async () => { +test('取消理由输入时不做审核操作', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); +}); + +test('取消安全下架确认时不下架', async () => { render( { await screen.findByText('game_1'); fireEvent.click(screen.getByRole('button', { name: '安全下架' })); - await screen.findByRole('dialog'); - fireEvent.click(screen.getByRole('button', { name: '取消' })); + const reasonDialog = await screen.findByRole('dialog'); + fireEvent.change( + within(reasonDialog).getByRole('textbox', { name: '下架原因' }), + { + target: { value: '盗用素材' }, + }, + ); + fireEvent.click( + within(reasonDialog).getByRole('button', { name: '继续下架' }), + ); + + await screen.findByText('确认操作'); + const confirmDialog = screen.getByRole('dialog'); + fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' })); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index b1e993ed7..7a3d8890e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -1,3 +1,4 @@ +import { Modal, TextField } from '@genarrative/shared/components'; import { RefreshCcw } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -15,6 +16,11 @@ interface AdminGameDistributionReviewPageProps { onUnauthorized: (message?: string) => void; } +interface ReviewReasonPrompt { + decision: 'reject' | 'suspend'; + entry: AdminGameDistributionReviewEntry; +} + function formatBytes(value: number) { if (value >= 1024 * 1024) { return `${(value / (1024 * 1024)).toFixed(1)} MiB`; @@ -47,26 +53,6 @@ function createReviewIdempotencyKey(versionId: string) { return `game-review-${versionId}-${random}`.slice(0, 128); } -export function resolveGameReleaseEntryUrlError(value: string) { - const normalized = value.trim(); - if (!normalized) return '请填写发行入口'; - if (!normalized.startsWith('https://')) { - return '发行入口必须以 https:// 开头'; - } - if (normalized.includes('?') || normalized.includes('#')) { - return '发行入口不能包含 query 或 fragment'; - } - try { - const parsed = new URL(normalized); - if (parsed.username || parsed.password) { - return '发行入口不能包含凭据'; - } - } catch { - return '发行入口不是合法 URL'; - } - return ''; -} - export function AdminGameDistributionReviewPage({ token, onUnauthorized, @@ -78,15 +64,11 @@ export function AdminGameDistributionReviewPage({ const [busyVersionId, setBusyVersionId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); - const [entryUrlByVersion, setEntryUrlByVersion] = useState< - Record - >({}); - const [reasonByVersion, setReasonByVersion] = useState< - Record - >({}); - const [suspendReasonByGame, setSuspendReasonByGame] = useState< - Record - >({}); + const [reasonPrompt, setReasonPrompt] = useState( + null, + ); + const [reasonDraft, setReasonDraft] = useState(''); + const [reasonError, setReasonError] = useState(''); const [busyGameId, setBusyGameId] = useState(''); const writeConfirm = useAdminWriteConfirm(); @@ -110,16 +92,10 @@ export function AdminGameDistributionReviewPage({ async function submitReview( entry: AdminGameDistributionReviewEntry, decision: 'approve' | 'reject', + reason = '', ) { - const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim(); - const reason = (reasonByVersion[entry.versionId] ?? '').trim(); - if (decision === 'approve') { - const invalid = resolveGameReleaseEntryUrlError(entryUrl); - if (invalid) { - setErrorMessage(invalid); - return; - } - } else if (!reason) { + const trimmedReason = reason.trim(); + if (decision === 'reject' && !trimmedReason) { setErrorMessage('拒绝审核必须填写理由'); return; } @@ -135,12 +111,11 @@ export function AdminGameDistributionReviewPage({ ? { decision, expectedPublicationRevision: entry.publicationRevision, - entryUrl, } : { decision, expectedPublicationRevision: entry.publicationRevision, - reviewReason: reason, + reviewReason: trimmedReason, }, ); setStatusMessage( @@ -160,8 +135,11 @@ export function AdminGameDistributionReviewPage({ * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 * 由服务端返回冲突,前端只提示刷新,不静默重试。 */ - async function suspendGame(entry: AdminGameDistributionReviewEntry) { - const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + async function suspendGame( + entry: AdminGameDistributionReviewEntry, + reason: string, + ) { + const trimmedReason = reason.trim(); const confirmed = await writeConfirm.confirmWrite({ action: '安全下架游戏', target: `${entry.gameId}(版本 v${entry.versionNumber})`, @@ -177,11 +155,10 @@ export function AdminGameDistributionReviewPage({ createSuspendIdempotencyKey(entry.gameId), { expectedPublicationRevision: entry.publicationRevision, - ...(reason ? { reason } : {}), + ...(trimmedReason ? { reason: trimmedReason } : {}), }, ); setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); - setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' })); await loadReviews(); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); @@ -190,6 +167,39 @@ export function AdminGameDistributionReviewPage({ } } + function openReasonPrompt( + entry: AdminGameDistributionReviewEntry, + decision: ReviewReasonPrompt['decision'], + ) { + setReasonDraft(''); + setReasonError(''); + setReasonPrompt({ decision, entry }); + } + + function closeReasonPrompt() { + setReasonPrompt(null); + setReasonDraft(''); + setReasonError(''); + } + + function confirmReasonPrompt() { + if (!reasonPrompt) return; + + const reason = reasonDraft.trim(); + if (reasonPrompt.decision === 'reject' && !reason) { + setReasonError('拒绝审核必须填写理由'); + return; + } + + const { decision, entry } = reasonPrompt; + closeReasonPrompt(); + if (decision === 'reject') { + void submitReview(entry, 'reject', reason); + return; + } + void suspendGame(entry, reason); + } + return (
@@ -268,25 +278,6 @@ export function AdminGameDistributionReviewPage({ {formatTime(entry.createdAt)}
-
- - - setEntryUrlByVersion((current) => ({ - ...current, - [entry.versionId]: event.target.value, - })) - } - disabled={busy} - /> -
-
- - - setReasonByVersion((current) => ({ - ...current, - [entry.versionId]: event.target.value, - })) - } - disabled={busy} - /> -
-
- - - setSuspendReasonByGame((current) => ({ - ...current, - [entry.gameId]: event.target.value, - })) - } - disabled={busy} - /> -
) : null}
+ {reasonPrompt ? ( + + + +
+ } + > + { + setReasonDraft(event.target.value); + if (reasonError) setReasonError(''); + }} + /> + + ) : null} {writeConfirm.confirmDialog} ); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx new file mode 100644 index 000000000..18407c5bb --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx @@ -0,0 +1,265 @@ +/* @vitest-environment jsdom */ + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import { + listAdminGameDistributionGames, + restoreAdminGameDistributionGame, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { + AdminGameDistributionGameEntry, + AdminGameDistributionGameVersionEntry, +} from '../api/adminApiTypes'; +import { AdminGameManagementPage } from './AdminGameManagementPage'; + +vi.mock('../api/adminApiClient', () => ({ + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + listAdminGameDistributionGames: vi.fn(), + restoreAdminGameDistributionGame: vi.fn(), + suspendAdminGameDistributionGame: vi.fn(), +})); + +const version: AdminGameDistributionGameVersionEntry = { + versionId: 'version-3', + gameId: 'game_1', + versionNumber: 3, + status: 'published', + reviewReason: '测试原因', + packageBytes: 2048, + packageSha256: + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + createdAt: '2026-09-20T08:00:00Z', + updatedAt: '2026-09-20T08:00:00Z', + reviewedAt: '2026-09-20T09:00:00Z', + publishedAt: '2026-09-20T10:00:00Z', + entryUrl: 'https://game.example.com/game_1', +}; + +const publishedGame: AdminGameDistributionGameEntry = { + gameId: 'game_1', + title: '测试游戏', + author: { + id: 'user_1', + name: '作者甲', + avatarUrl: 'https://example.com/avatar.png', + }, + status: 'published', + versionCount: 21, + playCount: 345, + activeVersionId: 'version-3', + publicationRevision: 4, + createdAt: '2026-09-18T08:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', + versions: [version], +}; + +const suspendedGame: AdminGameDistributionGameEntry = { + gameId: 'game_2', + title: '下架游戏', + author: { + id: 'user_2', + name: '作者乙', + avatarUrl: null, + }, + status: 'suspended', + versionCount: 2, + playCount: 8, + activeVersionId: null, + publicationRevision: 7, + createdAt: '2026-09-19T08:00:00Z', + updatedAt: '2026-09-21T08:00:00Z', + versions: [], +}; + +beforeEach(() => { + vi.mocked(listAdminGameDistributionGames) + .mockReset() + .mockResolvedValue({ games: [publishedGame] }); + vi.mocked(restoreAdminGameDistributionGame) + .mockReset() + .mockResolvedValue({ + game: { + id: suspendedGame.gameId, + title: suspendedGame.title, + status: 'published', + publicationRevision: 8, + }, + replayed: false, + }); + vi.mocked(suspendAdminGameDistributionGame) + .mockReset() + .mockResolvedValue({ + game: { + id: publishedGame.gameId, + title: publishedGame.title, + status: 'suspended', + publicationRevision: 5, + }, + replayed: false, + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +test('列表展示作者头像、状态、版本数和游玩数', async () => { + vi.mocked(listAdminGameDistributionGames).mockResolvedValue({ + games: [publishedGame, suspendedGame], + }); + + render( + , + ); + + const publishedRow = (await screen.findByText('测试游戏')).closest('tr')!; + expect(within(publishedRow).getByText('作者甲')).toBeTruthy(); + const avatar = within(publishedRow).getByRole('img', { + name: '作者甲 头像', + }); + expect(avatar.getAttribute('src')).toBe('https://example.com/avatar.png'); + expect(within(publishedRow).getByText('已公开')).toBeTruthy(); + expect(within(publishedRow).getByText('21')).toBeTruthy(); + expect(within(publishedRow).getByText('345')).toBeTruthy(); + + const suspendedRow = (await screen.findByText('下架游戏')).closest('tr')!; + expect(within(suspendedRow).getByText('作者乙')).toBeTruthy(); + expect(suspendedRow.querySelector('.admin-user-avatar')?.textContent).toBe( + '作', + ); + expect(within(suspendedRow).getByText('已下架')).toBeTruthy(); +}); + +test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列表', async () => { + vi.mocked(listAdminGameDistributionGames) + .mockResolvedValueOnce({ games: [suspendedGame] }) + .mockResolvedValueOnce({ + games: [{ ...suspendedGame, status: 'published' }], + }); + + render( + , + ); + + await screen.findByText('下架游戏'); + expect(screen.queryByRole('button', { name: '下架' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '恢复' })); + + expect(restoreAdminGameDistributionGame).not.toHaveBeenCalled(); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(restoreAdminGameDistributionGame).toHaveBeenCalledTimes(1), + ); + const [token, gameId, idempotencyKey, payload] = + vi.mocked(restoreAdminGameDistributionGame).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(gameId).toBe('game_2'); + expect(String(idempotencyKey)).toContain('game_2'); + expect(payload).toEqual({ expectedPublicationRevision: 7 }); + await waitFor(() => + expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2), + ); + expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy(); +}); + +test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => { + render( + , + ); + + await screen.findByText('测试游戏'); + fireEvent.change(screen.getByLabelText('下架原因'), { + target: { value: '违规内容' }, + }); + fireEvent.click(screen.getByRole('button', { name: '下架' })); + + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), + ); + const [token, gameId, idempotencyKey, payload] = + vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(gameId).toBe('game_1'); + expect(String(idempotencyKey)).toContain('game_1'); + expect(payload).toEqual({ + expectedPublicationRevision: 4, + reason: '违规内容', + }); + expect(await screen.findByText('游戏《测试游戏》已下架')).toBeTruthy(); +}); + +test('版本历史弹层展示版本条目与统计信息', async () => { + render( + , + ); + + await screen.findByText('测试游戏'); + fireEvent.click(screen.getByRole('button', { name: '版本历史' })); + const dialog = await screen.findByRole('dialog'); + + expect(within(dialog).getByText('共 21 个版本,展示最近 20 个')).toBeTruthy(); + expect(within(dialog).getByText('v3')).toBeTruthy(); + expect(within(dialog).getByText('published')).toBeTruthy(); + expect(within(dialog).getByText('2.0 KiB')).toBeTruthy(); + expect(within(dialog).getByText('abcdef012345')).toBeTruthy(); + expect(within(dialog).getByText('测试原因')).toBeTruthy(); + expect( + within(dialog).getByText('https://game.example.com/game_1'), + ).toBeTruthy(); +}); + +test('接口失败显示错误文案', async () => { + vi.mocked(listAdminGameDistributionGames).mockRejectedValue( + new Error('游戏列表读取失败'), + ); + + render( + , + ); + + expect(await screen.findByText('游戏列表读取失败')).toBeTruthy(); +}); + +test('401 交给 onUnauthorized 处理', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(listAdminGameDistributionGames).mockRejectedValue( + Object.assign(new Error('未授权'), { status: 401 }), + ); + + render( + , + ); + + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); + expect(screen.queryByRole('alert')).toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.tsx new file mode 100644 index 000000000..2ab32bff4 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameManagementPage.tsx @@ -0,0 +1,456 @@ +import { RefreshCcw, X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { + listAdminGameDistributionGames, + restoreAdminGameDistributionGame, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { + AdminGameDistributionGameEntry, + AdminGameDistributionGameVersionEntry, +} from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface AdminGameManagementPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const GAME_STATUS_META: Record = { + published: { label: '已公开', className: 'admin-status-ok' }, + suspended: { label: '已下架', className: 'admin-status-error' }, + unpublished: { label: '未公开', className: 'admin-status-pending' }, +}; + +function gameStatusMeta(status: string) { + return ( + GAME_STATUS_META[status] ?? { + label: status || '—', + className: 'admin-status-pending', + } + ); +} + +function formatBytes(value: number) { + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)} MiB`; + } + if (value >= 1024) { + return `${(value / 1024).toFixed(1)} KiB`; + } + return `${value} B`; +} + +function formatTime(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function formatOptionalTime(value: string | null) { + return value ? formatTime(value) : '—'; +} + +function authorName(entry: AdminGameDistributionGameEntry) { + return entry.author?.name?.trim() || '—'; +} + +function authorInitial(entry: AdminGameDistributionGameEntry) { + const name = authorName(entry); + return name === '—' ? '—' : (Array.from(name)[0] ?? '—'); +} + +function createGameActionIdempotencyKey( + action: 'suspend' | 'restore', + gameId: string, +) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const prefix = action === 'suspend' ? 'game-suspend' : 'game-restore'; + return `${prefix}-${gameId}-${random}`.slice(0, 128); +} + +export function AdminGameManagementPage({ + token, + onUnauthorized, +}: AdminGameManagementPageProps) { + const [games, setGames] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [busyGameId, setBusyGameId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [suspendReasonByGame, setSuspendReasonByGame] = useState< + Record + >({}); + const [versionGame, setVersionGame] = + useState(null); + const writeConfirm = useAdminWriteConfirm(); + + const loadGames = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminGameDistributionGames(token, { + limit: 50, + }); + setGames(response.games); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, [token, onUnauthorized]); + + useEffect(() => { + void loadGames(); + }, [loadGames]); + + useEffect(() => { + if (!versionGame) return undefined; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setVersionGame(null); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [versionGame]); + + async function suspendGame(entry: AdminGameDistributionGameEntry) { + const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + if (!reason) { + setErrorMessage('下架原因不能为空'); + setStatusMessage(''); + return; + } + + const confirmed = await writeConfirm.confirmWrite({ + action: '下架游戏', + target: `${entry.title || entry.gameId}(${entry.gameId})`, + }); + if (!confirmed) return; + + setBusyGameId(entry.gameId); + setErrorMessage(''); + setStatusMessage(''); + try { + await suspendAdminGameDistributionGame( + token, + entry.gameId, + createGameActionIdempotencyKey('suspend', entry.gameId), + { + expectedPublicationRevision: entry.publicationRevision, + reason, + }, + ); + setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`); + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: '', + })); + await loadGames(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyGameId(''); + } + } + + async function restoreGame(entry: AdminGameDistributionGameEntry) { + const confirmed = await writeConfirm.confirmWrite({ + action: '恢复游戏', + target: `${entry.title || entry.gameId}(${entry.gameId})`, + }); + if (!confirmed) return; + + setBusyGameId(entry.gameId); + setErrorMessage(''); + setStatusMessage(''); + try { + await restoreAdminGameDistributionGame( + token, + entry.gameId, + createGameActionIdempotencyKey('restore', entry.gameId), + { expectedPublicationRevision: entry.publicationRevision }, + ); + setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`); + await loadGames(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyGameId(''); + } + } + + return ( +
+
+

游戏管理

+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+

游戏列表

+ 共 {games.length} 条 +
+ + {isLoading ? ( +

正在加载游戏列表…

+ ) : null} + + {!isLoading && games.length === 0 ? ( +

暂无游戏。

+ ) : null} + + {!isLoading && games.length > 0 ? ( +
+ + + + + + + + + + + + + + {games.map((entry) => { + const status = gameStatusMeta(entry.status); + const isSuspended = entry.status === 'suspended'; + const busy = busyGameId === entry.gameId; + return ( + + + + + + + + + + ); + })} + +
标题作者gameId状态版本数游玩数操作
+ {entry.title?.trim() || '—'} + +
+
+ {entry.author?.avatarUrl ? ( + {`${authorName(entry)} + ) : ( + authorInitial(entry) + )} +
+
+ {authorName(entry)} + {entry.author?.id?.trim() || '—'} +
+
+
+ {entry.gameId} + + + {status.label} + + {entry.versionCount}{entry.playCount} +
+ {!isSuspended ? ( + <> +
+ + + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: event.target.value, + })) + } + disabled={busy} + /> +
+ + + ) : ( + + )} + +
+
+
+ ) : null} +
+ + {versionGame ? ( +
{ + if (event.target === event.currentTarget) { + setVersionGame(null); + } + }} + > +
+
+
+

版本历史

+ + {versionGame.title?.trim() || '—'}({versionGame.gameId}) + +
+ +
+

+ 共 {versionGame.versionCount} 个版本,展示最近 20 个 +

+ {versionGame.versions.length === 0 ? ( +

暂无版本记录。

+ ) : ( +
+ + + + + + + + + + + + + + + + {versionGame.versions.map((version) => ( + + ))} + +
版本状态包大小SHA创建时间审核时间公开时间审核原因发行入口
+
+ )} +
+
+ ) : null} + + {writeConfirm.confirmDialog} +
+ ); +} + +function GameVersionRow({ + version, +}: { + version: AdminGameDistributionGameVersionEntry; +}) { + return ( + + v{version.versionNumber} + {version.status || '—'} + {formatBytes(version.packageBytes)} + + {version.packageSha256?.slice(0, 12) || '—'} + + {formatOptionalTime(version.createdAt)} + {formatOptionalTime(version.reviewedAt)} + {formatOptionalTime(version.publishedAt)} + {version.reviewReason?.trim() || '—'} + + {version.entryUrl ? ( + + {version.entryUrl} + + ) : ( + '—' + )} + + + ); +} diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 81e4fcbde..79f150653 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -64,6 +64,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -129,6 +130,40 @@ beforeEach(() => { ); }); +test('未支付订单不显示实付金额,发放泥点单独成列', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-pending', + status: 'pending', + paidAtMicros: null, + paidAmountCents: 0, + pointsDelta: 0, + }, + { ...baseOrder, orderId: 'order-paid' }, + ], + }); + renderPage(); + + expect( + await screen.findByRole('columnheader', { name: '实付' }), + ).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); + + const unpaidRow = (await screen.findByText('order-pending')).closest( + 'tr', + ) as HTMLElement; + const unpaidCells = within(unpaidRow).getAllByRole('cell'); + expect(unpaidCells[3]?.textContent).toContain('未支付'); + expect(unpaidCells[4]?.textContent).toBe('0 泥点'); + + const paidRow = screen.getByText('order-paid').closest('tr') as HTMLElement; + const paidCells = within(paidRow).getAllByRole('cell'); + expect(paidCells[3]?.textContent).toBe('¥6.00'); + expect(paidCells[4]?.textContent).toBe('60 泥点'); +}); + test('充值订单查询传递全部筛选字段', async () => { const user = userEvent.setup(); renderPage(); diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index d1f4848b0..04188469c 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({ 用户 订单 支付 - 金额 / 泥点 + 实付 + 发放泥点 退款与追回 钱包 状态 @@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
- {formatMoney(order.amountCents)} - 发放 {order.pointsDelta} 泥点 + {formatOrderPaidAmount(order)} + {order.paidAmountCents > 0 ? null : ( + 订单 {formatMoney(order.amountCents)} + )} + + + {order.pointsDelta} 泥点 累计 {formatMoney(order.cumulativeSuccessRefundCents)} @@ -1312,6 +1318,13 @@ function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } +/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */ +function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) { + return order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'; +} + function formatCentsInput(cents: number) { return (cents / 100).toFixed(2); } diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index 0e745a308..1519bb867 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -217,7 +217,7 @@ export function AdminRedeemCodePage({
} + onHomeOpen={vi.fn()} + onProjectsOpen={vi.fn()} + onNotice={vi.fn()} + />, + ); +} + +/** + * jsdom 完全没有 Fullscreen API(`document.fullscreenEnabled` / `requestFullscreen` / + * `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来: + * 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。 + */ +function installFullscreenHost() { + let fullscreenElement: Element | null = null; + const requestFullscreen = vi.fn(function (this: Element) { + // 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。 + // eslint-disable-next-line @typescript-eslint/no-this-alias + fullscreenElement = this; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + const exitFullscreen = vi.fn(() => { + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: exitFullscreen, + }); + Object.defineProperty(Element.prototype, 'requestFullscreen', { + configurable: true, + writable: true, + value: requestFullscreen, + }); + return { requestFullscreen, exitFullscreen }; +} + +afterEach(() => { + document.body.innerHTML = ''; + Reflect.deleteProperty(document, 'fullscreenElement'); + Reflect.deleteProperty(document, 'exitFullscreen'); + Reflect.deleteProperty(Element.prototype, 'requestFullscreen'); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('运行页「全屏预览」', () => { + it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => { + const { requestFullscreen, exitFullscreen } = installFullscreenHost(); + renderRunView(); + + const button = await screen.findByRole('button', { name: '全屏预览' }); + // 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。 + const stage = document.querySelector('.game-run-preview'); + expect(stage).not.toBeNull(); + expect(button.closest('.game-run-preview')).toBe(stage); + expect(button.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(button); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + // 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。 + expect(document.fullscreenElement).toBe(stage); + const exitButton = await screen.findByRole('button', { + name: '退出全屏预览', + }); + expect(exitButton.getAttribute('aria-pressed')).toBe('true'); + + fireEvent.click(exitButton); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + expect( + (await screen.findByRole('button', { name: '全屏预览' })).getAttribute( + 'aria-pressed', + ), + ).toBe('false'); + }); + + it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => { + renderRunView(); + + await screen.findByTitle('运行页全屏预览 游戏运行画面'); + expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts index 2ddf16993..bd3aecbf6 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts +++ b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts @@ -4,10 +4,18 @@ import { buildTemplateRows, computeTemplateGridColumns, computeTemplateGridLayout, + computeTemplateGridLayoutWithScrollbar, computeTemplateRowHeight, + TEMPLATE_CARD_ACTIONS_HEIGHT, TEMPLATE_CARD_GAP, + TEMPLATE_CARD_META_HEIGHT, TEMPLATE_CARD_MIN_WIDTH, + TEMPLATE_CARD_SUMMARY_HEIGHT, + TEMPLATE_CARD_TAGS_HEIGHT, + TEMPLATE_CARD_TEXT_GAP, TEMPLATE_CARD_TEXT_HEIGHT, + TEMPLATE_CARD_TEXT_PADDING, + TEMPLATE_CARD_TITLE_HEIGHT, } from '../src/features/template-library/templateLibraryGrid'; import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel'; @@ -50,6 +58,19 @@ describe('computeTemplateGridColumns', () => { }); describe('computeTemplateRowHeight', () => { + it('budgets the same height the card rows actually need', () => { + // 这些数字对应 TemplateCard 的 `h-5`/`h-4`/`h-8`/`h-5.5`/`h-7` 与 `p-3`/`gap-2`: + // 行高契约比真实内容小,被截断的就是卡片里的标题与简介。 + expect(TEMPLATE_CARD_TITLE_HEIGHT).toBe(20); + expect(TEMPLATE_CARD_META_HEIGHT).toBe(16); + expect(TEMPLATE_CARD_SUMMARY_HEIGHT).toBe(32); + expect(TEMPLATE_CARD_TAGS_HEIGHT).toBe(22); + expect(TEMPLATE_CARD_ACTIONS_HEIGHT).toBe(28); + expect(TEMPLATE_CARD_TEXT_PADDING).toBe(12); + expect(TEMPLATE_CARD_TEXT_GAP).toBe(8); + expect(TEMPLATE_CARD_TEXT_HEIGHT).toBe(174); + }); + it('keeps cover ratio + fixed text block', () => { // 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161 expect(computeTemplateRowHeight(300)).toBe( @@ -94,6 +115,59 @@ describe('computeTemplateGridLayout', () => { }); }); +describe('computeTemplateGridLayoutWithScrollbar', () => { + const innerWidth = (layout: { columnCount: number; columnWidth: number }) => + layout.columnCount * layout.columnWidth; + + it('reserves the classic scrollbar width once the grid scrolls vertically', () => { + // 经典滚动条(Windows/WebView2 ≈ 17px)不在包裹层宽度里,不预留就会多出一条横向滚动条。 + const plain = computeTemplateGridLayout({ + containerWidth: 1184, + itemCount: 13, + }); + const layout = computeTemplateGridLayoutWithScrollbar({ + containerWidth: 1184, + itemCount: 13, + viewportHeight: 500, + scrollbarWidth: 17, + }); + + expect(layout.columnCount).toBe(4); + // 内层宽度必须落在竖滚动条左侧的可用宽度里,否则又会出现横向滚动条。 + expect(innerWidth(layout)).toBeLessThanOrEqual(1184 - 17); + expect(innerWidth(layout)).toBeLessThan(innerWidth(plain)); + // 行高仍按预留后的列宽算,卡片内容不会被压。 + expect(layout.rowHeight).toBe(computeTemplateRowHeight(layout.columnWidth)); + }); + + it('keeps the plain layout when nothing overflows or the scrollbar is an overlay', () => { + const plain = computeTemplateGridLayout({ + containerWidth: 1184, + itemCount: 4, + }); + // 只有一行:不会竖向滚动,不需要预留,右侧不留白边。 + expect( + computeTemplateGridLayoutWithScrollbar({ + containerWidth: 1184, + itemCount: 4, + viewportHeight: 900, + scrollbarWidth: 17, + }), + ).toEqual(plain); + // overlay 滚动条占宽 0:与不预留完全一致。 + expect( + computeTemplateGridLayoutWithScrollbar({ + containerWidth: 1184, + itemCount: 13, + viewportHeight: 500, + scrollbarWidth: 0, + }), + ).toEqual( + computeTemplateGridLayout({ containerWidth: 1184, itemCount: 13 }), + ); + }); +}); + describe('buildTemplateRows', () => { it('chunks entries per row and pads the tail with nulls', () => { const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2); diff --git a/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts index 5eb52ca01..a3852fcdf 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts +++ b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { collectGameTemplateRuntimes, + collectGameTemplateTagOptions, collectGameTemplateTags, EMPTY_TEMPLATE_LIBRARY_FILTERS, filterGameTemplates, @@ -145,6 +146,24 @@ describe('tag and runtime options', () => { ]); }); + it('keeps the same order while reporting how many templates carry each tag', () => { + // 筛选条上的标签 chip 要显示命中数量,顺序必须与 `collectGameTemplateTags` 完全一致。 + const withBlank = [ + ...templates, + template({ id: 'blank-tag', tags: ['', ' ', '经营'] }), + ]; + const options = collectGameTemplateTagOptions(withBlank); + expect(options).toEqual([ + { tag: '经营', assetCount: 3 }, + { tag: '三消', assetCount: 1 }, + { tag: '射击', assetCount: 1 }, + { tag: '像素', assetCount: 1 }, + ]); + expect(options.map((option) => option.tag)).toEqual( + collectGameTemplateTags(withBlank), + ); + }); + it('collects distinct runtimes and labels them', () => { expect(collectGameTemplateRuntimes(templates)).toEqual([ 'godot', diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx index 7a27cdbd9..5e3d3086d 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -8,7 +8,6 @@ import type { TemplateLibraryFilters, } from '../src/features/template-library/templateLibraryModel'; import { - collectGameTemplateTags, EMPTY_TEMPLATE_LIBRARY_FILTERS, filterGameTemplates, } from '../src/features/template-library/templateLibraryModel'; @@ -81,7 +80,6 @@ function controller( notice: '', templates, visibleTemplates: templates, - tagOptions: ['空白', '2d', 'canvas', '网页'], runtimeOptions: ['html'], installedCount: 1, filters, @@ -190,6 +188,28 @@ describe('TemplateLibraryView', () => { expect(viewport?.querySelector('article')).not.toBeNull(); }); + it('pins every card text row to the height the grid contract budgets for it', () => { + // 回归点:文字区若是 `grid` 的 auto 行,行高会按 max-content 算成「一行」, + // 标题 / 简介 / 标签会被逐行截断(现场表现为标题文字被切掉)。这里钉住 + // 卡片每一行的固定高度,改动必须同时改 `TEMPLATE_CARD_*_HEIGHT` 那组常量。 + render( {}} />); + + const card = cardFor('空白网页工程'); + const textBlock = card.children[1] as HTMLElement; + const rows = Array.from(textBlock.children) as HTMLElement[]; + + expect(textBlock.className).toContain('flex-col'); + expect(rows.map((row) => row.className)).toEqual([ + expect.stringContaining('h-5'), + expect.stringContaining('h-4'), + expect.stringContaining('h-8'), + expect.stringContaining('h-5.5'), + expect.stringContaining('h-7'), + ]); + // 每行都不参与压缩,否则 flex 会把文字压回去。 + rows.forEach((row) => expect(row.className).toContain('shrink-0')); + }); + it('offers 更新 instead of 下载 when the installed version is stale', () => { const stale = template({ id: 'blank-web', @@ -253,6 +273,17 @@ describe('TemplateLibraryView', () => { expect(clearFilters).toHaveBeenCalled(); }); + it('hides a cover that failed to load instead of showing a broken image', () => { + render( {}} />); + + const cover = cardFor('空白网页工程').querySelector( + 'img', + ) as HTMLImageElement; + expect(cover.style.visibility).toBe(''); + fireEvent.error(cover); + expect(cover.style.visibility).toBe('hidden'); + }); + it('starts a download and a template project from the card actions', () => { const downloadTemplate = vi.fn(async () => undefined); const createProjectFromTemplate = vi.fn(async () => undefined); @@ -289,11 +320,16 @@ describe('TemplateLibraryView', () => { const busyCard = cardFor('空白二维画布工程'); const buttons = Array.from(busyCard.querySelectorAll('button')); + // 忙状态写在触发它的按钮上(文案就地变成「创建中」),动作行里不额外塞第三个元素, + // 否则最小卡宽(250px)下两个按钮的文案会被挤成两行、顶出卡片。 + expect(buttons).toHaveLength(2); expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe( true, ); - expect(busyCard.textContent).toContain('正在创建项目'); - expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目'); + expect(busyCard.textContent).toContain('创建中'); + expect(busyCard.textContent).not.toContain('使用模板'); + expect(cardFor('空白网页工程').textContent).toContain('使用模板'); + expect(cardFor('空白网页工程').textContent).not.toContain('创建中'); }); it('shows empty, no-match, error and notice states', () => { @@ -390,7 +426,6 @@ describe('大库量渲染(1000 条假数据)', () => { templates: bulk, visibleTemplates: bulk, installedCount: bulk.filter((entry) => entry.installed).length, - tagOptions: collectGameTemplateTags(bulk), })} onBack={() => {}} />, diff --git a/apps/ai-game-creator-shell/tests/themedModal.test.tsx b/apps/ai-game-creator-shell/tests/themedModal.test.tsx index 5242722e0..3d0be715e 100644 --- a/apps/ai-game-creator-shell/tests/themedModal.test.tsx +++ b/apps/ai-game-creator-shell/tests/themedModal.test.tsx @@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) { ); } +/** + * 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。 + * 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。 + */ +function WindowChromeHarness({ + onMinimize, + onWorkspaceClick, +}: { + onMinimize: () => void; + onWorkspaceClick: () => void; +}) { + return ( + <> +
+ +
+ + undefined} ariaLabel="测试弹窗"> + + + + ); +} + describe('ThemedModal', () => { beforeEach(() => { vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation( @@ -105,4 +133,23 @@ describe('ThemedModal', () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(document.activeElement).toBe(opener); }); + + it('lets window title bar clicks through while workspace clicks stay trapped', async () => { + const user = userEvent.setup(); + const onMinimize = vi.fn(); + const onWorkspaceClick = vi.fn(); + render( + , + ); + await screen.findByRole('dialog', { name: '测试弹窗' }); + + await user.click(screen.getByRole('button', { name: '最小化' })); + expect(onMinimize).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: '工作区按钮' })); + expect(onWorkspaceClick).not.toHaveBeenCalled(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts new file mode 100644 index 000000000..b9bbb16d9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { repoPath } from './repoPath'; +import { parseStyleSheet } from './styleCascade'; + +const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css'); + +/** + * 全屏弹层清单:每一层都必须从自绘标题栏下方开始。 + * + * 标题栏是窗口边框,不是弹层内容 —— 只要有一个全屏遮罩盖住它,弹窗打开时 + * 「最小化 / 最大化 / 关闭」就会被挡住。焦点陷阱那一半的问题见 + * `themedModal.test.tsx` 与 `WindowChrome.test.tsx`;新增全屏弹层时把类名加进这份清单。 + */ +const WINDOW_CHROME_SAFE_OVERLAYS = [ + // ThemedModal 与共享弹层的通用遮罩:top 由这条规则统一抬到标题栏下方。 + '.fixed.inset-0', + '.app-update-overlay', + '.game-publish-progress-overlay', + '.launcher-dialog-backdrop', + '.settings-overlay', + '.game-approval-backdrop', + '.project-chat-settings-backdrop', +] as const; + +function declarationsForSelector(css: string, selector: string) { + const merged = new Map(); + for (const rule of parseStyleSheet(css)) { + if (!rule.selectors.includes(selector)) { + continue; + } + for (const [property, value] of rule.declarations) { + merged.set(property, value); + } + } + return merged; +} + +describe('窗口标题栏与全屏弹层的层叠约定', () => { + const css = readFileSync(STYLES_PATH, 'utf8'); + + it.each(WINDOW_CHROME_SAFE_OVERLAYS)('%s 从标题栏下方开始', (selector) => { + const declarations = declarationsForSelector(css, selector); + expect( + declarations.get('top'), + `${selector} 必须声明 top: var(--window-chrome-height)`, + ).toBe('var(--window-chrome-height)'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts index c54b50f16..8cde18820 100644 --- a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts +++ b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts @@ -98,7 +98,128 @@ function contrastRatio(first: Rgba, second: Rgba) { ); } +/** 取渐变里的色标(`linear-gradient(135deg, #b3542f, #8f3f22)` → 两个颜色)。 */ +function parseGradientStops(source: string): Rgba[] { + const body = source.slice(source.indexOf('(') + 1, source.lastIndexOf(')')); + return body + .split(',') + .map((part) => part.trim()) + .filter((part) => part.startsWith('#') || part.startsWith('rgb')) + .map((part) => parseCssColor(part.split(/\s+/)[0] ?? part)); +} + +/** 页面背景(`--platform-body-fill`)里的不透明色标:chip 实际落在这层之上。 */ +function parseBodyFillUnderlays(source: string): Rgba[] { + return Array.from(source.matchAll(/#[\da-f]{6}/gi)).map((match) => + parseCssColor(match[0]), + ); +} + describe('workbench theme contrast', () => { + /** + * 筛选 chip 的两态对比:用户反馈「选中和没选中的颜色看不出差别」,根因是选中态 + * 只换了低透明度的暖色底(两态对比 1.09:1)。这里把「选中 = 实心填充」这条口径 + * 钉死:反白文字在渐变两端都要过 AA,且与未选底色至少差 3:1。 + */ + it('keeps the chip selected state legible and distinct in both themes', () => { + const css = readFileSync(themePath, 'utf8'); + const themes = [ + { + name: 'light', + block: getCssBlock(css, '.platform-theme--light'), + // 浅色主题下 chip 落在页面底色上,用页面渐变的最亮与最暗色标夹住两种情况。 + idleUnderlays: parseBodyFillUnderlays( + getCssVariable( + getCssBlock(css, '.platform-theme--light'), + '--platform-body-fill', + ), + ), + }, + { + name: 'dark', + block: getCssBlock(css, '.platform-theme--dark'), + idleUnderlays: parseBodyFillUnderlays( + getCssVariable( + getCssBlock(css, '.platform-theme--dark'), + '--platform-body-fill', + ), + ), + }, + ]; + + expect( + parseGradientStops('linear-gradient(135deg, #b3542f, #8f3f22)'), + ).toEqual([ + [179, 84, 47, 1], + [143, 63, 34, 1], + ]); + + for (const theme of themes) { + const activeFill = parseGradientStops( + getCssVariable(theme.block, '--platform-chip-active-fill'), + ); + const activeText = parseCssColor( + getCssVariable(theme.block, '--platform-chip-active-text'), + ); + const idleFill = parseCssColor( + getCssVariable(theme.block, '--platform-chip-idle-fill'), + ); + expect(activeFill, `${theme.name} active gradient stops`).toHaveLength(2); + expect( + theme.idleUnderlays.length, + `${theme.name} body fill stops`, + ).toBeGreaterThan(0); + + // 反白文字:渐变两端都要过 AA,不能只保证深的那一端。 + for (const stop of activeFill) { + expect( + contrastRatio(activeText, stop), + `${theme.name} label on fill ${stop.slice(0, 3).join(',')}`, + ).toBeGreaterThanOrEqual(4.5); + } + + // 两态可分辨:选中填充与任意页面底色上的未选 chip 至少差 3:1。 + for (const underlay of theme.idleUnderlays) { + const idleChip = compositeColor(idleFill, underlay); + for (const stop of activeFill) { + expect( + contrastRatio(stop, idleChip), + `${theme.name} selected vs idle over ${underlay.slice(0, 3).join(',')}`, + ).toBeGreaterThanOrEqual(3); + } + } + } + }); + + /** + * 焦点环可见性:键盘用户靠它找焦点。旧口径是 15% 透明度的暖色,合成到页面底色只有 + * 1.17:1——等于没有焦点提示。这里按 WCAG 非文本对比 3:1 钉住两套皮肤。 + */ + it('keeps the keyboard focus ring visible in both themes', () => { + const css = readFileSync(themePath, 'utf8'); + for (const selector of [ + '.platform-theme--light', + '.platform-theme--dark', + ]) { + const block = getCssBlock(css, selector); + const ring = parseCssColor( + getCssVariable(block, '--platform-input-focus-ring'), + ); + const underlays = parseBodyFillUnderlays( + getCssVariable(block, '--platform-body-fill'), + ); + expect(underlays.length, `${selector} body fill stops`).toBeGreaterThan( + 0, + ); + for (const underlay of underlays) { + expect( + contrastRatio(ring, underlay), + `${selector} focus ring over ${underlay.slice(0, 3).join(',')}`, + ).toBeGreaterThanOrEqual(3); + } + } + }); + it('keeps warm user bubbles above WCAG AA text contrast', () => { const css = readFileSync(themePath, 'utf8'); const light = getCssBlock(css, '.platform-theme--light'); diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 55f051ba0..2ce6a6ddb 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -5,11 +5,10 @@ GENARRATIVE_ENV=container GENARRATIVE_API_HOST=0.0.0.0 GENARRATIVE_API_PORT=8082 -# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。 -# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。 -# 以下为 compose 默认宿主机入口;更改映射端口或接入域名时同步修改,不填容器内部地址。 -# dev 使用 https://dev.genarrative.world;release 使用 https://www.genarrative.world。 -GENARRATIVE_AGC_ANALYTICS_ORIGIN=http://127.0.0.1:18080 +# 官网客户端下载与客户端埋点共用部署渠道;修改后重启 API Server。 +# dev 对应 https://dev.genarrative.world;container + dev 额外允许 loopback 地址及可变映射端口。 +# release 对应 https://www.genarrative.world,且不接受 loopback 地址;其它渠道不接收埋点。 +GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL=dev GENARRATIVE_API_LOG=info,tower_http=info GENARRATIVE_API_LISTEN_BACKLOG=1024 GENARRATIVE_API_WORKER_THREADS=4 @@ -72,3 +71,6 @@ GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL=gpt-5.4-mini WECHAT_MINIPROGRAM_MESSAGE_TOKEN= WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY= + +# 游戏发行入口固定为平台同源路径 /games/{gameId}/:审核通过时由 api-server 自己派生, +# 不需要部署侧配置发行域名或通配证书。 diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 9e34fbaca..0b8d4c958 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -90,8 +90,9 @@ http { location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; + # 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; @@ -135,12 +136,28 @@ http { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + # BEGIN GENARRATIVE MAIN SPA ROUTES location = / { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { + location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { try_files $uri /index.html =404; } # END GENARRATIVE MAIN SPA ROUTES diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 3cd99970f..02202a8ec 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -4,10 +4,10 @@ GENARRATIVE_ENV=production GENARRATIVE_API_HOST=127.0.0.1 GENARRATIVE_API_PORT=8082 -# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。 -# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。 -# 以下为 release;dev 部署改为 https://dev.genarrative.world。 -GENARRATIVE_AGC_ANALYTICS_ORIGIN=https://www.genarrative.world +# 官网客户端下载与客户端埋点共用部署渠道;修改后重启 API Server。 +# release 对应 https://www.genarrative.world;dev 部署改为 dev,对应 https://dev.genarrative.world。 +# production 环境的埋点不接受 loopback 地址;其它渠道不接收埋点。 +GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL=release GENARRATIVE_API_LOG=info,tower_http=info GENARRATIVE_API_LISTEN_BACKLOG=1024 GENARRATIVE_API_WORKER_THREADS=4 @@ -179,6 +179,10 @@ GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev +# 游戏发行入口固定为平台同源路径 /games/{gameId}/:审核通过时由 api-server 自己派生, +# 不需要部署侧配置发行域名或通配证书;边缘由 nginx 的 +# genarrative-game-distribution-path.conf 把该路径映射到发行网关。 + # SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index c3c4bff34..c931fc687 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -4,8 +4,8 @@ ## 请求体大小 -- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 64m`。 -- 该值只用于让携带参考图 Data URL 的创作接口抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。 +- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 210m`。 +- 该值只用于让携带参考图 Data URL 的创作接口和游戏发行包 PUT(路由上限 200 MiB + 1 KiB)抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。Pingora 网关侧的 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同样不低于该值,否则请求会在网关层被 413。 - 若线上看到 `413 Request Entity Too Large`,并且 access log 里 `request_time=0.000 upstream_status=-`,通常是 Nginx 没有加载该模板或未 reload;先执行 `nginx -T | grep client_max_body_size` 和 `nginx -t` 再检查 `api-server`。 ## gzip @@ -100,10 +100,11 @@ curl -sSI -H 'Accept-Encoding: br' \ - br 可用时返回 `Content-Encoding: br`。 - 响应头应包含 `Vary: Accept-Encoding`。 -## 游戏发行来源(每游戏独立 origin) +## 游戏发行来源(平台同源路径) -- `deploy/nginx/genarrative-release-origin.conf` 为已公开游戏提供每游戏独立来源:`https://.games.example.com/`。部署前替换域名、通配证书路径与 upstream 端口,并为 `*.games.example.com` 配置通配 DNS 与通配 TLS。 -- 该来源只把子域根路径映射到 `…/releases//index.html`、其余路径映射到 `…/releases//<原路径>`;平台 API、后台、SPA 与上传接口都不在这个来源上暴露,命中即 404。 -- 发行来源不使用 Cookie:带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。 -- 审核通过时填写的 `entryUrl` 就是该子域根地址 `https://.games.example.com/`;换版本或下架只改变后端公开投影,边缘不需要改配置。 -- 门禁:`npm run check:release-origin-config` 会逐条校验模板约束、交叉检查发行网关仍在设置上述响应头,并在本机存在 `nginx` 与 `openssl` 时用自签通配证书渲染一份临时配置执行 `nginx -t`。 +- 现役发行入口是平台同源路径 `https://<平台域名>/games//`。三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games//` 与 `/games//` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 或通配 TLS。 +- 该 location 的正则必须整体加双引号:`location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"`。不加引号时 nginx 会把 `{32}` 当块定界符,`nginx -t` 报 `pcre2_compile() failed: missing closing parenthesis`。 +- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。 +- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。 +- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 +- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index f210766ab..49b76e619 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -119,8 +119,9 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; + # 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; @@ -178,6 +179,22 @@ server { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + # BEGIN GENARRATIVE MAIN SPA ROUTES location = / { error_page 503 /maintenance.html; @@ -189,7 +206,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { + location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/nginx/genarrative-release-origin.conf b/deploy/nginx/genarrative-release-origin.conf deleted file mode 100644 index 5e120726d..000000000 --- a/deploy/nginx/genarrative-release-origin.conf +++ /dev/null @@ -1,80 +0,0 @@ -# 游戏发行来源(每游戏独立 origin) -# -# 部署前替换: -# 1) `games.example.com` 为真实发行域,并为 `*.games.example.com` 配置通配 DNS -# 与通配 TLS 证书; -# 2) `ssl_certificate` / `ssl_certificate_key` 指向该通配证书; -# 3) upstream 端口与 api-server 实际监听一致。 -# -# 设计约定: -# - 每个已公开游戏使用自己的子域:`https://.games.example.com/`; -# - 该来源只把请求映射到发行网关 -# `/api/game-distribution/releases//…`,平台 API、后台、SPA 与上传 -# 接口都不在这个来源上暴露; -# - 发行来源从不使用 Cookie:带 Cookie 的请求直接 403,转发前也会清空 Cookie; -# - `X-Content-Type-Options` / CORP / 无凭据 CORS / HTML CSP / 内容类型白名单由 -# api-server 发行网关设置,这里不覆盖,避免两层策略漂移; -# - 公开版本切换与下架由后端 `publication_revision` CAS 决定,边缘只做按主机映射。 - -upstream genarrative_release_api { - server 127.0.0.1:8082; - keepalive 32; -} - -server { - listen 80; - server_name ~^(?[a-z0-9_]+)\.games\.example\.com$; - - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl http2; - server_name ~^(?[a-z0-9_]+)\.games\.example\.com$; - - ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/games.example.com/privkey.pem; - - access_log /var/log/nginx/genarrative-release.access.log; - error_log /var/log/nginx/genarrative-release.error.log warn; - - # 发行文件是公开静态资源,从不携带平台 Cookie。带上 Cookie 的请求说明它落在 - # 平台会话来源上,直接拒绝,避免发行内容被主站同源脚本读取。 - if ($http_cookie) { - return 403; - } - - # 子域根路径直接服务该游戏的 index.html,游戏内其余资源按相对路径原样交给 - # 发行网关;这样审核通过时填写的 entryUrl 就是 https://.games.example.com/。 - location = / { - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Request-Id $request_id; - proxy_set_header Cookie ""; - proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html; - proxy_read_timeout 60s; - proxy_send_timeout 60s; - } - - location / { - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Request-Id $request_id; - proxy_set_header Cookie ""; - proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri; - proxy_read_timeout 60s; - proxy_send_timeout 60s; - } -} diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 87bf769e3..981a7c932 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -139,8 +139,9 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; + # 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; @@ -198,6 +199,22 @@ server { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + # BEGIN GENARRATIVE MAIN SPA ROUTES location = / { error_page 503 /maintenance.html; @@ -209,7 +226,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { + location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 59eaaa850..f1c90ee98 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -124,14 +124,14 @@ "nginx": { "production": [ "location ~ ^/api(?:/|$)", - "client_max_body_size 64m;", + "client_max_body_size 210m;", "limit_conn genarrative_api_conn 64;", "limit_req zone=genarrative_api_rps burst=64 nodelay;", "add_header X-Accel-Buffering no always;" ], "development": [ "location ~ ^/api(?:/|$)", - "client_max_body_size 64m;", + "client_max_body_size 210m;", "limit_conn genarrative_api_conn 64;", "limit_req zone=genarrative_api_rps burst=64 nodelay;", "add_header X-Accel-Buffering no always;" diff --git a/deploy/pingora/pingora-gateway.env.example b/deploy/pingora/pingora-gateway.env.example index 467d559d2..d85eb293a 100644 --- a/deploy/pingora/pingora-gateway.env.example +++ b/deploy/pingora/pingora-gateway.env.example @@ -28,7 +28,7 @@ GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/en GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http -GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864 +GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960 # gzip 默认开启;等级和最小响应长度对齐 Nginx gzip_comp_level 5 / gzip_min_length 1024。 # Pingora 正式化口径固定为 gzip-only;br / zstd 不进入当前网关,Brotli 继续由 Nginx / 前置代理承担。 GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip diff --git a/docs/README.md b/docs/README.md index 582761faa..8a19850f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,7 @@ - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [DirectProject 独立聊天容器与工作台钱包布局](./adr/【ADR】DirectProject独立聊天容器与工作台钱包布局-2026-09-18.md):DirectProject 与 Supervisor 等路径分容器,钱包入口由项目工作台布局独立承载。 +- [退役 AGC 项目对话斜杠命令](./adr/【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md):AGC 项目对话与终端 swarm chat 均不再解析斜杠命令,终端聊天入口一并退役;实现、测试、门禁与文档承诺全部删除,命令 id 与权限位作为项目策略词汇表保留。 - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md index bd50d9767..47cadf7df 100644 --- a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md +++ b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md @@ -16,6 +16,21 @@ - 输入区删除 `versions` / `activeVersionId` / `showTriggerButton` / `onReferencePickerOpen` / `skills`,`assets` 被注入的 provider 取代;`projectPath` 只保留给输入区自己的润色链路。 - 输入区与宿主之间只留三个通用接缝:`providers`(引用来源)、`inputActions`(操作排里的宿主控件,例如 `@` 触发钮)、`submitSuppressed`(宿主浮层打开时 Enter 让位)。引用种类一个都不进输入区。 -- `provider.match` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 +- `provider.fuzzyLookup` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 - 附件并入 `ChatReference`,编辑器收敛为单一引用节点类型,附件 chip 的 DOM 契约逐字保留;附件导入成功后以芯片进入正文,失败不插入;控制器不再持有附件数组,`MAX_CHAT_COMPOSER_ATTACHMENTS` 改为按草稿中的附件芯片数计算,导入进行中禁止发送。 - 用户可见行为保持不变,唯一例外是已裁决的缺陷修复:非 DirectProject 宿主不再出现 `$` Skill 候选。 + +## 修订(2026-09-22):粘贴解析需要第二道只读缝 + +粘贴进来的纯文本要按同一套引用文本语法反解析回正文芯片,因此 provider 的两个查询能力按「模糊 / 精确」分开命名,各自说清自己的语义: + +- `fuzzyLookup(query)`:候选菜单那条路——按 query 做包含匹配、大小写不敏感,并在 provider 内部截断到候选上限。名字写明它是模糊的,避免被拿去反查 token。 +- `lookup()`:精确查找那条路——某一刻 provider 真正能解析出的全部引用,不做模糊过滤、不截断,与菜单共用同一份候选来源;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。 + +这不推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是唯一的懒加载入口,粘贴解析**只用此刻就绪的候选**,不等待、不补读。Skill 目录因此还是「用户第一次敲出 `$` 才读」——冷启动时粘贴 `$名称` 就按字面文本保留(看得见、不是猜错),不为了粘贴去提前读盘。 + +附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。 + +歧义口径:同一个 token 对应多条引用身份(同名素材)时一律按文本保留;解析只认显示名逐字一致(不认扩展名、resourceId、大小写变体),未命中的 token 与其余文字逐字保留。 + +(本次把上一条同名决策里的 `match` / `candidates` 改名为 `fuzzyLookup` / `lookup`,语义不变;旧名不再保留。) diff --git a/docs/adr/【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md b/docs/adr/【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md new file mode 100644 index 000000000..296bef0d6 --- /dev/null +++ b/docs/adr/【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md @@ -0,0 +1,34 @@ +# 【ADR】退役AGC项目对话斜杠命令与终端swarm入口-2026-09-22 + +状态:已接受 + +## 背景 + +AGC(`apps/ai-game-creator-shell`)的项目对话曾把用户能力挂在「聊天输入 `/`」上:除真正带执行语义的 `/history` 外,还累积了几十个只生成后续草稿或只读摘要的命令(`/brief`、`/status`、`/read`、`/trace`、`/art`、`/export`、`/cover`、`/remember` 等)。无 GUI 的终端 swarm chat 入口 `--swarm-chat` 另有一套控制命令(`/help`、`/agents`、`/status`、`/history`、`/compact`、`/resume`、`/goal`、`/quit`)。 + +这些命令的实际状态是:正式对话面已经换成 DirectProject 单容器,斜杠命令列表不再有渲染入口,`/history` 之外没有任何现役调用方;但命令字面量仍分散在控制器分支、`chatPromptPolish` 的 `/` 前缀绕过、命令参数校验文案、`projectSummaryConstants` 的命令清单、只服务已退役摘要面板的 `project-summary/*Summaries.ts`、构建期门禁 `scripts/check-config.mjs`、无人调用的 Tauri 能力清单命令、`swarm_cli` 的终端输入解析与帮助输出,以及多份权威文档的承诺里。 + +保留它的代价持续存在:每次调整对话形态都要同步维护这套死词汇表和它的门禁,而且「命令」一词在 AGC 里同时指用户斜杠命令与项目权限命令 id(`GAME_CREATION_APP_COMMANDS`)两件事,术语歧义会直接误导后续改动。 + +## 决策 + +- 斜杠命令语义整体退役,按「从未存在」处理:不保留入口、不做兼容提示、不写 tombstone,实现、专属测试、构建期门禁条目与文档承诺一并删除,历史由 Git 保存。 +- 删除项:Direct 聊天的 `/history` 精确匹配分支与 `reloadHistory`;`chatPromptPolish` 的 `/` 前缀绕过;`chatCommandMetadata`、`projectSummaryConstants.chatCommandHelp`、`memoryCommands.parseRememberInput` 等命令清单与参数解析;只服务退役 Supervisor 摘要面板、零外部调用的 `project-summary/*Summaries.ts` 与 `agentTrace.ts`;草稿回填死链(前端 `agentPresentation.ts` 的草稿推导与 Rust `suggested_canvas_tool_call`);无人调用的 Tauri 命令 `get_game_creation_agent_capabilities` 与 `get_limited_local_commands`;钉住上述字符串的门禁条目与专属测试。 +- 终端 swarm chat 入口连带其命令层整体退役:`--swarm-chat`、`src-tauri/src/swarm_cli.rs` 与整个 `swarm_cli/` 目录(`/help`、`/agents`、`/status`、`/history`、`/compact`、`/resume`、`/goal`、`/quit` 的解析、帮助输出、turn 派发、观察器、报告与专属测试)一并删除;`SwarmChatFlow`、`SwarmTurnObservation`、`SwarmTurnOutcome::Quit`、`SwarmConfirmationResolution::Quit`、`SWARM_TURN_*_ERROR`、只服务终端命令的 `agent.compact` / `agent.resume` / `agent.run_status` 校验(`swarm_cli/input.rs` 内那份)与 `print_runtime_response_stream_status` 也随之消失;同名权限 id 在现役 Tauri 命令与 Runtime 生命周期上的门禁保持不动。只服务终端交互内核的 `agent/interaction.rs` 整层(`AgentInteractionAction`、tool registry、`game_creator_agent_uses_interaction_kernel`、`decide_game_creator_agent_interaction_turn_for_session_at`、`AgentInteractionProviderStreamSink`)同样删除;其上仅存的自然语言 steer 决策路径 `decide_game_creator_agent_runtime_steer_at` 在收尾复查后一并删除(见「影响」的复查收尾)。 +- 保留项(它们不是斜杠命令):命令 id 注册表 `GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`(Rust 运行期项目权限策略的词汇表;App 前端只用 `GameCreationAppCommandDescriptor` 类型表达权限判定与审计日志粒度,数组本体由 Rust 策略路径与跨语言一致性门禁消费)、`needsInitializedChatProject` 与项目权限策略链路、Rust 侧路径与路由的 `/` 前缀校验、`--agent-*` CLI 控制命令(Goal、steer、cancel、retry、context compact、resume、状态查询)与 Tauri IPC 注册。 +- 应用内项目对话的输入只剩自然语言回合(外加 `@` 素材引用与附件);需要动作时由 Runtime 工具、确认卡和既有 CLI 控制命令承接,不由聊天文本解析控制词。 +- 需要显式控制时改用现有 `--agent-*` CLI 命令:手动压缩是 `--agent-context-compact`,恢复扫描是 `--agent-resume`,Goal 生命周期是 `--agent-goal-*`。 + +## 备选方案与取舍 + +1. **只删正式用户窗口的入口,保留解析层**:看似省事,但命令字面量与分支继续存在,新对话形态仍要绕过它们,正是本次要消除的持续维护成本。 +2. **保留 `/history` 作为唯一命令**:它确实是唯一有执行语义的入口,但保留一个精确匹配的 `/` 语法就要求保留前缀绕过判断、命令被发现与文档承诺的整套口径;DirectProject 的历史重读改由重新进入对话/重新订阅自然完成,不需要用户输入控制词。 +3. **加兼容层(识别到已知命令时给提示或忽略)**:等于把死词汇表永久固化在解析层,与「按从未存在处理」相反,且会长期占据用户可见面。 + +## 影响 + +- 术语收敛:AGC 里的「命令」此后指项目内部命令 id 与权限位,「斜杠命令」作为已退役说法不再出现在权威文档与代码注释中。 +- 应用内项目对话的可见行为不变:正式对话面本来就不渲染命令列表,`/history` 之外没有可执行路径;删除后唯一的用户可见差异是输入以 `/` 开头时按普通文本处理。终端侧不再有 swarm chat 入口,也没有任何斜杠命令面。 +- 删除范围包含构建期门禁条目,因此不得为退役概念新增守卫测试或断言残留字符串的 check 条目;防止概念回归依靠架构边界(没有解析层可写)而不是字符串钉桩。 +- 复查收尾(同一决定的后续提交):配置向导里指向已删 npm 脚本的 `test:chat` 调用与失去含义的 `--configure-only` 开关、App 内 `请先用 /project …` 用户文案、`interaction.json` 中只服务已删交互内核的 7 个 prompt 键、harness 里因调用方被删而零引用的死导出,以及过期注释一并清理;无前端调用方的 Tauri 命令 `start_game_creator_agent_runtime_task` 随本次清理删除(`main.rs` 注册与 `check-config.mjs` 条目同步移除);`steer_game_creator_agent_runtime_task` 同时是 `decide_game_creator_agent_runtime_steer_at` 的唯一非测试入口,删掉该命令后整条 LLM steer 判定链(`agent/interaction.rs`、`runtime.interrupt_for_steer_decision`、`agent.runtime.steer_decision` 持久记录、`steer_decision_*` prompt 键与只测该链的专属用例)一并退役。 +- `.agent/logs/command.log`、项目权限确认卡与 `commandRuns` 属于项目命令审计,继续保留,不受本次退役影响。 diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index cdf6b4e73..14b25c309 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -75,7 +75,8 @@ - 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。 - 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `` / `` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。 -- 运行视窗下方继续保留“信息展示”和“数值微调”区域标题及原有面板高度;没有真实资源信息或已登记微调项时,内容区域保持空白,不显示示例字段、默认数值、未载入控件或功能说明,也不得因内容为空压缩两个面板。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 +- 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。 +- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是**资源选中态**:在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息栏渲染它的只读字段;运行画面上的“点选素材”只往对话插入引用,不改选中):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 - 数值修改立即写入当前项目的编辑态配置。 - 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。 diff --git a/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..21aa5c760 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md @@ -0,0 +1,49 @@ +# AGC 发行包分片续传上传实施计划 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | runtime-smoke-passed(存储原语、服务端入口、原生上传器、渲染进程接线与真实栈分片续传 smoke 均已落地) | +| Date | 2026-09-23 | +| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md` | + +## 修改边界与顺序 + +1. **存储原语(已完成)**:`server-rs/crates/platform-oss/src/lib.rs` 新增 `append_internal_object` / `append_internal_object_with_retry` 与 `OssAppendInternalObjectRequest` / `OssAppendInternalObjectResponse`;复用现役 V4 签名助手 `signed_request_builder`(查询串已参与签名)与 `run_internal_put_with_retry` 的可重试分类。`position = 0` 追加到末尾,`position > 0` 必须等于对象当前长度;返回 `next_position` 作为权威已收字节。 +2. **服务端入口(已完成)**:`server-rs/crates/api-server/src/modules/game_distribution.rs` + - 新增 `GET .../package/upload-state`、`PUT .../package/chunk`、`POST .../package/complete`、`POST .../package/reset` 四个路由,沿用作者鉴权、`game-distribution:publish` 灰度开关与 `Idempotency-Key` 约定; + - 分片大小 `PACKAGE_UPLOAD_CHUNK_BYTES = 8 MiB`,分片请求体放行量为分片大小 + 1 KiB; + - 从整包 `PUT` 抽出共享收口 `confirm_validated_package`(声明比对 → 确认 → 结构化事件),两种入口共用; + - 新增 `game_distribution_oss_client` / `game_distribution_package_object_key` / `staged_package_bytes` / `require_octet_stream_content_type` / `package_upload_offset` 辅助函数;偏移不一致返回 `409 PACKAGE_UPLOAD_OFFSET_MISMATCH` 与权威偏移;未收齐返回 `409 PACKAGE_UPLOAD_INCOMPLETE`;校验失败删除半包并落 `upload_failed`。 +3. **AGC 原生上传器(已完成)**:新增 `apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs`:内容寻址暂存(`/game-package-staging/.zip`,重启后同包复用同一文件)、`upload-state → chunk → complete` 循环、409 权威偏移续传(响应丢失后按服务端已收字节对齐,不重放不跳段)、仅对传输/超时/408/429/5xx 退避重试(默认 4 次尝试)、`game-package-upload-progress` 进度事件;暂存路径必须落在暂存目录内。命令 `prepare_local_project_game_package` / `upload_local_project_game_package` 已注册,整包回传命令 `read_local_project_export_package` 退役(`read_local_project_export_package_at` 仍供暂存使用)。 +4. **渲染进程接线(已完成)**:`apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` 改为 `prepare`(拿摘要与暂存路径)→ 创建游戏 → 创建版本 → 原生分片上传 → 送审;`LocalProjectExportPackagePayload` 整包类型退役,改为 `StagedGamePackage` / `GamePackageUploadOutcome`;不再有任何整包字节进 IPC。 +5. **真实栈 smoke(已完成)**:本地 api-server + 真实 OSS bucket 上跑通「中断 → 续传 → 确认」。做法与证据: + - 先用 `npm run dev:spacetime` 把当前模块发布到本地库(`genarrative-game-creator-dev`,自动迁移完成),再用 `npm run dev:api-server` 起 `127.0.0.1:8082`; + - 本地库的 `feature_gate_config` 原本为空(发布开关默认关闭),用 `spacetime call … upsert_feature_gate_config` 写入 `game-distribution:publish enabled=true rollout=100`; + - `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:8082 npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts` → **1 passed / 3.9s**(发行包 9.0 MiB,跨 8 MiB 分片边界); + - 用例断言实际发送过的分片偏移序列等于 `[0, 8388608]`:第一片只发一次,中断后的续传从权威偏移开始,不重放也不跳段; + - api-server 侧同一轮日志:`package_chunk_stored offset=0 chunk_bytes=8388608 received_bytes=8388608 elapsed_ms=201`、`package_chunk_stored offset=8388608 chunk_bytes=1049210 received_bytes=9437818 elapsed_ms=82`、`package_confirmed package_bytes=9437818 file_count=3 oss_put_skipped=true elapsed_ms=884`。 + - 为了能指向本地栈,用例还补了两处基础设施修正:把客户端平台基址切到传入的 base URL(`setClientServerSelection({preset:'custom'})`),以及桥接层把 jsdom realm 的 `Headers` / `Blob` / `FormData` 降级成 Node 侧原生值(`FormData` 手工序列化为 multipart 字节,否则 OSS 直传回 405)。 + +## 不改的部分 + +- 网页端发布路径与整包 `PUT` 语义不变;`MAX_PACKAGE_BYTES`、展开量、单文件与文件数上限不变。 +- 未新增 SpacetimeDB 表或字段:已收字节的事实来源是 OSS 对象长度,版本状态机沿用既有 `awaiting_upload → uploaded → …`。 +- 未引入半包定时清理任务。 + +## 验证命令 + +- `cargo test -p platform-oss`(74 passed) +- `cargo test -p api-server game_distribution`(23 passed,含新增 `package_chunk_size_stays_inside_declared_limits`、`package_upload_offset_requires_non_negative_integer`、`package_chunk_content_type_must_be_octet_stream`) +- `cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +- `cargo test game_package_upload`(AGC 原生侧 4 passed:分片规划无缝无重叠、409 权威偏移解析、URL 拼接、内容寻址暂存与路径校验) +- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run --workspace apps/ai-game-creator-shell typecheck`(含 `check-config.mjs` 的命令登记门禁) +- `npx vitest run`(发布函数 6 passed、发布面板 9 passed、发布反馈 5 passed;真实链路用例在无 `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL` 时按设计跳过) +- 待做:真实栈 smoke(本地 api-server + 真实 OSS bucket 上跑「中断 → 续传 → 完成」,含 `x-oss-next-append-position` 语义确认) + +## 风险与回滚点 + +- **对象可追加性**:`platform-oss` 之前没有追加写,首次真实调用需要在真实 bucket 上确认 `x-oss-next-append-position` 语义;失败时回滚点是 `platform-oss` 新增函数与四条路由(整包 `PUT` 不受影响,可独立回退)。 +- **半包对象**:分片写入直接落在版本键上,未完成时是半包。它不进公开目录、不服务发行网关;失败或作者重置时删除。若删除失败会记录 `package_staging_delete_failed` 告警,需要人工确认对象键状态。 +- **重置语义**:只有 `awaiting_upload` / `upload_failed` 允许重置,避免破坏已确认事实。 +- **内存**:完成动作按 200 MiB 上限回读整包再校验,峰值与整包 `PUT` 同量级;分片路径不再让整包驻留客户端。 diff --git a/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md index 0b57d90e7..982d7cb08 100644 --- a/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md +++ b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md @@ -10,7 +10,7 @@ - 允许修改: - `apps/ai-game-creator-shell/scripts/channel-identity.mjs`(新增,渠道身份单点定义) - - `apps/ai-game-creator-shell/scripts/build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs`、`agent-swarm-test-chat.mjs` + - `apps/ai-game-creator-shell/scripts/build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs` - `apps/ai-game-creator-shell/src-tauri/src/main.rs`、`src-tauri/src/windows.rs`、`src-tauri/src/config.rs` - 对应测试:`build-release.test.mjs`、`prepare-macos-codex.test.mjs` - 文档:AGC 更新主规范、共享记忆与本计划对 @@ -47,3 +47,10 @@ - 风险:非默认渠道首次以新身份安装,老 `dev` 用户不会自动迁移本地数据。回滚点:渠道身份只影响非默认渠道构建,撤销该渠道的构建产物即可,仓库侧无数据迁移。 - 风险:窗口标题改为构建期产品名后,标题不再等于配置里的字面量。回滚点:去掉 `main.rs` 的标题覆盖调用,行为回到配置标题。 - 风险:ACL managed 识别放宽到前缀族。回滚点:`is_game_creator_packaged_app_data_leaf` 收紧回单一直线值,但非默认渠道的提权修复会重新失败关闭。 + +## 2026-09-23 追加:dev 渠道展示名统一 + +- `dev` 渠道继续复用 `world.genarrative.ai-game-creator`,保证既有安装、升级链和 AppData 路径不变;展示名统一为 `陶泥儿开发版`。 +- `channel-identity.mjs` 是展示名单一来源。发布构建将同一 `productName` 同时注入 Tauri 安装配置、原生窗口标题和 `VITE_AGC_PRODUCT_NAME`;React 自绘标题栏及关于/运行时配置展示从该注入值读取。 +- 因此 Windows NSIS 默认生成的快捷方式、开始菜单/卸载注册表展示名随 Tauri `productName` 变为 `陶泥儿开发版`;未新增自定义注册表或快捷方式实现。 +- 静态 Tauri 基线配置同步为 `陶泥儿开发版`,本地壳与正式 `dev` 包的显示名保持一致。 diff --git a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md index 031cd576c..8b103da65 100644 --- a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md @@ -33,3 +33,10 @@ Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-1 - 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。 - 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。 - 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。 + +## 2026-09-23 维护态错误展示补充 + +- `apps/ai-game-creator-shell/src/services/clientApi.ts` 解析并保留维护响应的 `error.code`、HTTP 状态与 `meta.requestId`,识别网关 `MAINTENANCE` 后广播客户端维护事件。 +- `apps/ai-game-creator-shell/src/components/modal/MaintenanceNotice.tsx` 在客户端根部统一展示不可被局部业务兜底替代的大弹窗;发布、上传、资源换签等共用 `requestClientApi` 的请求均进入同一出口。 +- 普通 500、资源损坏和本地 `blob:` 图片预览失败不自动归类为维护;图片换签接口在维护期间失败时会触发统一弹窗,但本地刚选中的图片预览仍不依赖后端。 +- 验收补充:维护期间发布接口不能只显示“创建平台游戏失败”等局部文案;维护弹窗出现一次即可覆盖并发失败请求,关闭后业务页仍可重试。 diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md new file mode 100644 index 000000000..c90010981 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -0,0 +1,63 @@ +# 引用粘贴解析实施计划 + +对应:[引用候选由宿主注入](../../adr/【ADR】引用候选由宿主注入-2026-09-22.md)(含 2026-09-22 修订节);决策记录见 `docs/project-memory/shared-memory/decision-log.md` 的 2026-09-22 条目。上一步的重构计划见 [引用输入区重构与宿主注入](【实施计划】引用输入区重构与宿主注入-2026-09-22.md),那份明确不含本项。 + +## 一句话交付与验收判据 + +把从用户消息气泡(或任何同口径文本)复制出来的 `@显示名` / `$名称` 粘贴进引用输入区时,原位重建同顺序的引用芯片;其余文字逐字保留。 + +验收判据: + +1. 粘贴文本 → 引用:`@显示名` 与 `$名称` 逐字命中当前宿主注入的 provider 候选时原位换成芯片,一次 Ctrl+Z 整体回退,token 之外的每个字符(含换行与空白)原样保留。 +2. 逐字一致、不做兼容别名:不认 `@hero.png`、`resourceId`、大小写变体、全角 `@`;token 前后必须是行首 / 行尾或空白。 +3. 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、不猜路径或文件名。 +4. 只有真的解析出引用时才接管:同 namespace 的 `application/x-lexical-editor` 负载、不含 token 的纯文本、图片文件粘贴一律放行编辑器默认导入,现有粘贴行为逐字不变。 +5. 附件与运行画面区域不参与粘贴解析(静默 provider 没有候选),它们的 token 粘贴时按文本保留。 +6. Skill 目录冷启动不阻塞粘贴:`lookup()` 是纯函数,目录没到就是空数组——冷启动时粘贴 `$名称` 保留为文本(不等待、不补读),用户敲过一次 `$` 后即可解析。 + +## 流程判定 + +本次是共享组件的一处行为增量 + provider 契约加两个可选能力,不动 schema、不动公开 API/DTO、不动后端;按轻量流程只建本实施计划,不新建主规范与里程碑规范(与上一步重构同一判定)。 + +## 提交切分 + +1. **反解析口径**:`resourceReferences.ts` 新增 `buildContentFromPastedText(text, references)`(粘贴侧唯一反解析;与 `buildContentFromTextTokens` 同一套边界规则),配规则矩阵单测。 +2. **provider 契约**:`reference-source/types.ts` 把菜单查询改名为 `fuzzyLookup(query)`、新增精确查找 `lookup()`(两者共用同一份候选来源);`resourceReferenceProvider` 给全量可提及候选,`skillReferenceProvider` 给去重后的 Skill 候选。 +3. **输入区接管**:`ResourceReferenceInput` 注册 `COMMAND_PRIORITY_CRITICAL` 的 `PASTE_COMMAND`,只在解析出引用时 `preventDefault` 并在一次 `editor.update`(`PASTE_TAG`)内按选区插入;配集成用例。 +4. **文档**:`CONTEXT.md` 术语、本计划、ADR 修订节、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md` 与决策记录。 + +## 验证 + +定向:`npx vitest run apps/ai-game-creator-shell/tests/resourceReferences.test.ts apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`;全量:`npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。真实客户端手感(粘贴时的候选菜单位置、大段文本粘贴观感、Tauri 剪贴板只带图片时的行为)留待真机验收。 + +## 风险与回滚 + +- 解析接管会绕过 Lexical 的 `text/html` 富文本导入:只在文本里真的解析出引用时接管,取舍已在上一条判据里限定;要完全避开富文本场景可以后续按 `clipboardData.types` 再收窄。 +- 气泡里若出现与素材同名的 `@区域标签` / `@附件名`,粘贴会被认成素材引用——纯文本无法区分,属已知取舍。 +- 回滚按提交粒度 revert;canonical content 形状、provider 的既有能力与出站文本口径都不变。 + +## 执行状态(2026-09-22) + +已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `fuzzyLookup` / `lookup` 及用例;输入区 `PASTE_COMMAND` 接管与 5 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动保持字面且敲过 `$` 后可解析);文档同步。 + +未做(本次范围外):斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片的解析、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 + +## 追加执行状态(2026-09-23):前缀重叠按「引用名无空白」收口 + +自动评审留下的唯一破坏性项(`@hero` 与 `@hero v2` 互为前缀时粘贴会多插一枚短名芯片)不改反解析,改为把不变量前移到引用名: + +- `resourceReferences.ts` 新增共享 `normalizeMentionName(value)`(内部空白折 `-`、裁首尾),素材显示名(`resourceDisplayName`)、Skill 名(目录读入与 `toReference` / `mentionToken`)、附件名(导入映射与 `toReference` / `mentionToken`)与两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)统一过这一份;token 层重复归一化是幂等的。 +- 名称里不做 `resourceId` 兜底这一条按评审校正:词干为空(`.env` / `.gitignore` 这类整名就是扩展名的文件)时 `resourceDisplayName` 回退 `asset.id`,回退值同样过 `normalizeMentionName`;`normalizeMentionName` 自己仍只做归一化。 +- 代价与取舍:`hero v2` 与 `hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。 +- 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。 + +## 追加执行状态(2026-09-23 第二轮自动评审):空名字与插入兜底收口 + +第二轮自动评审的四个问题逐个提交处理,落点都在 `apps/ai-game-creator-shell`: + +- 显示名不再可能为空:`resourceDisplayName` 在文件名词干为空(`.env` / `.gitignore`)时回退 `asset.id`,回退值同样过 `normalizeMentionName`(提交 `4e90465c4`)。这条校正了上一节「不做兜底」的写法,决策记录与功能说明同步。 +- 粘贴解析跳过退化 token:`buildContentFromPastedText` 组装候选时跳过 `token.length <= 1` 的条目,裸 `@` / `$` 不再认领正文里的触发符(提交 `3f9c698a6`)。 +- 粘贴接管改成「插入真的发生之后」才 `preventDefault`:`$insertContentAtSelection` 返回「插进去没有」,插入为空时放行默认粘贴;编辑器已在更新中(回调被排队)时按原口径先接管(提交 `3391ecf7d`)。 +- 插入兜底不再留空:provider 的 `toReference` 与 `mentionToken` 都答不出来的 part 退到新增的 `contentPartText`(通用文本形态,资源落 `@resourceId`)。此前这一支会什么都不插,是「粘贴内容逐字保留」唯一的例外分支;现在没有例外。新增单测覆盖四类 part 的文本形态,新增输入区集成用例证明被破坏的 provider 契约下这段粘贴仍按文本落下,去掉兜底即变红。 +- 归一化撞名(`hero v2` 与 `hero-v2` 折成同一个 token)只在解析侧兜住、候选菜单不提示冲突,这一条涉及「哪些素材能被 @ 到」的产品取舍,未改代码,留给下一轮决定。 +- 润色回写与粘贴共用同一条落点兜底:新增 `mentionTokenOrText`(provider 的 `mentionToken`,拿不到就退 `contentPartText` 的通用文本形态),润色回写的候选扫描、整根替换的落点与粘贴插入的兜底都走它。由此润色回写不再有「provider 答不出 token 的 part 直接消失」和「整根替换时 part 解析不出引用就整条吃掉」两条静默丢弃路径;初始草稿(`applyContent`)与润色回写(`applyPolishedTextToRoot`)共用 `applyContentToRoot`,所以恢复出来的草稿里已解析不出的引用现在落成 `@resourceId` 文本而不是被吃掉。 diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index 0620f55c5..84a2fa4da 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -121,6 +121,12 @@ - 发布灰度改为**默认关闭**并修掉客户端“看得到点不动”:`is_game_distribution_publish_enabled_for_user` 现在要求 gate 行存在且 `enabled=true`(未登录、无行、`enabled=false` 一律 false),因此没配灰度时 `gameDistributionPublishEnabled=false`,AGC 不再渲染「发布到游戏广场」按钮、网页入口也不出现;AGC 侧新增 `announcePublishMessage`,把「已构建并打包试玩包」「先打开一个项目再发布」等提示通过 DirectProject 聊天容器的 `announce` 出口回话(普通项目不渲染工作台状态行,之前只写 workspaceStatus 才会表现为点击无反应)。后台「灰度发布配置」新增「可配置开关」列表:预设开关在未创建行时也可见并可一键配置(不再需要先猜 gate key)。 +## 2026-09-23 口径更新:发行入口改为服务端派生 + +- 管理员不再填写 `entryUrl`:审核通过时 `api-server` 读版本取 gameId,按部署模板 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`(生产形如 `https://{gameId}.games.<发行域名>/`,必须含 `{gameId}` 占位符)派生每游戏独立来源地址,再走原有 HTTPS / 无凭据 / 无 query / 无 fragment 校验;后台审核 DTO 与页面已删除该输入框。 +- 上文「已完成证据」中描述「管理员填写 / 要求 HTTPS 发行入口」的条目是当时的交付事实,当前口径以主规范《平台入口与玩法链路》《本地开发验证与生产运维》与 `shared-memory/decision-log.md` 的 2026-09-23 条目为准。 +- 非生产环境未配置模板时仍回落到本地发行网关回环地址(用于免 TLS 验证内嵌游玩);生产未配置模板、模板缺 `{gameId}`、gameId 非主机安全字符或派生结果非法时,审核通过直接失败。 + ## 尚未完成 - 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。 diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md b/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md new file mode 100644 index 000000000..a0817adcc --- /dev/null +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md @@ -0,0 +1,51 @@ +# 实施计划:游戏分发阶段 C · AGC 发布资料 AI 生成 + +- 状态:`in_progress` +- 日期:`2026-09-23` +- 上游:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 的“AGC 游戏分发与在线游玩合同” +- 里程碑:`docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md` 阶段 C + +## 1. 交付结果 + +AGC 发布面板完成三项收敛: + +1. 移除 ZIP 路径、文件数、体积和“只上传字节”等技术摘要。 +2. 打开面板时基于有界、脱敏的项目上下文免费生成一句话简介和七类白名单分类;失败保留本地兜底,不阻断发布,生成结果可编辑。 +3. 游戏封面支持基于项目上下文生成,复用现役图片生成与泥点扣费链路;生成结果登记为当前账号平台素材后自动作为 `coverAssetId`,不二次上传。 + +## 2. 实现边界 + +- 只改 AGC 发布面板、AGC 发布 service、api-server 内部发布资料建议路由、shared DTO、定向测试和文档。 +- 不改网页发布表单、游戏分发审核 API、发行包上传/审核状态机、SpacetimeDB schema 或 `/api/external/v1` OpenAPI。 +- 简介/分类生成不写用户泥点账本;封面生成继续由现役 `execute_billable_asset_operation_with_cost` 负责预扣、幂等、失败退款和结果登记。 +- 传给文本模型的上下文只包含项目名称、创作目标、任务标题/状态、素材 kind/相对路径、运行状态和最近编辑提示;不包含绝对路径、聊天记录、凭据、Token 或完整 manifest。 + +## 3. 实现步骤 + +1. `packages/shared` 与 `shared-contracts` 增加发布资料建议请求/响应 DTO,分类继续使用 `GAME_DISTRIBUTION_CATEGORIES`。 +2. api-server 增加 `POST /api/game-distribution/publish-metadata/suggestions`,经 Bearer 鉴权后使用内部文本模型生成严格 JSON;解析失败或模型不可用时返回本地确定性兜底,不触发钱包扣费。 +3. AGC `GameDistributionPublishPanel` 打开时调用建议接口;用户未修改字段时回填,用户已编辑或迟到响应不得覆盖,失败不影响发布。 +4. AGC 封面生成复用 `POST /api/editor/images/generations` 的 `publication-material`、`gpt-image-2`、`16:9`、`2K` 合同;按钮和确认弹窗从 `/api/editor/generation-pricing` 读取并显示具体泥点数。若响应进入队列,轮询 `/api/runtime/external-generation/jobs/{operationId}`,完成后直接使用 `assetObjectId`。 +5. 发布截图同批并行上传;成功缩略图悬浮显示“删除|预览”,失败缩略图内部显示错误并在下方保留删除按钮。失败项不参与发布,不阻断同批其它截图。 +6. 定向测试覆盖面板删除技术摘要、免费资料回填、封面价格/确认/生成/`coverAssetId` 直用、截图并行与单张失败跳过、服务端 parser/route 与输入边界。 + +## 4. 验收判据 + +- 发布面板不出现 `发行包摘要`、ZIP 相对路径或文件数/体积。 +- 建议接口成功时简介和分类自动回填;分类只可能是七类之一;接口失败时保留原创作目标或通用兜底。 +- 建议请求不会调用钱包、不会写 `asset_operation_consume` 或 LLM Router 额度账本。 +- 封面按钮和确认弹窗显示后端运行时定价对应的具体泥点数;确认后调用现役图片生成接口,生成结果直接成为发布 `coverAssetId`,没有第二次上传或素材身份分叉。 +- 同批截图并行上传;成功图悬浮显示删除/预览,失败图内部显示错误并有独立删除按钮;失败项不参与发布且不影响其它截图。 +- 生成失败、余额不足和队列失败均在面板内可见且可重试,不进入聊天历史。 +- 定向 vitest、api-server Rust 测试、AGC typecheck、`check:encoding`、`git diff --check` 通过。 + +## 5. 非目标 + +- 不做标题 AI 改写。 +- 不做网页发布页的自动生成。 +- 不新增发布草稿持久化、生成历史、重试队列或 SpacetimeDB 表。 +- 不改变封面上传入口和手动选择封面的能力。 + +## 6. 本次缺陷修复记录 + +- 2026-09-23:AGC 生成游戏封面请求补充 `generationInputs.source = "ai-game-creator-client"`。队列 worker 依据该来源选择 `GameCreatorResourceEditor` 结果契约;未标记来源时会按 `Standard` 紧凑化并省略 `result`,导致生成完成后无法回传 `assetObjectId`。对应前端定向测试已锁定请求字段与平台素材 ID 回填链路。 diff --git a/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..a881e7e88 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md @@ -0,0 +1,58 @@ +# AGC 发行包分片续传上传 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | runtime-smoke-passed(真实栈「中断 → 续传 → 确认」已通过;AGC 真机一键发布与 200 MiB 档容量数据未验证) | +| Date | 2026-09-23 | +| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(真实发行包与资料合同第 10 条、幂等并发与恢复) | + +## 背景与触发 + +AGC 一键发布今天把整包字节从 WebView 侧送出:`read_local_project_export_package` 先把 `packageBytes` 整包过一遍 IPC 回到渲染进程,渲染进程再用 `@tauri-apps/plugin-http` 发整包 `PUT`,而该插件会把 body 序列化成 `Array.from(new Uint8Array(buffer))` 再走一次 IPC。两次整包 IPC 决定了 AGC 实际可发布的包远小于服务端 200 MiB 上限,失败时表现为客户端侧传输错误(例如「无法连接登录服务」),服务端访问日志里没有这次请求;断流后也只能整包白传。本里程碑把上传下沉到原生侧并支持分片续传。 + +## 目标 + +1. AGC 一键发布由原生进程直接读取本地试玩包、按服务端下发的固定分片大小上传,整包字节不再经过 WebView IPC。 +2. 传输中断、网络失败、客户端进程退出或应用重启后,同一 `versionId` 只补传缺失字节,不白传整包。 +3. 分片入口与现役整包 `PUT` 共用同一版本状态机、摘要口径、幂等键与包校验;网页端发布路径不变。 + +## 不在本里程碑内 + +- 不改网页端发布路径(继续整包 `PUT`),不为浏览器实现续传。 +- 不做并行分片上传、不做客户端直传 OSS(分片仍经 `api-server` 转发,与今天整包路径同一出口)。 +- 不做「后台自动续传」:续传只在下一次发布动作或应用重启后的重试里发生,不引入常驻重传任务。 +- 不做未完成分片会话的定时清理任务;半包对象的回收单独开里程碑。 +- 不改发行包上限、展开量、单文件与文件数上限。 + +## 合同要点 + +- **入口与状态**:分片续传对既有 `versionId` 生效,版本状态沿用 `awaiting_upload → uploaded → …`;分片入口与整包入口互斥,同一版本同时只能有一个写入者,第二个写入返回 `409 UPLOAD_IN_PROGRESS`。 +- **权威偏移**:服务端记录的已收字节是唯一权威。客户端分片偏移与之不符时返回 `409` 与权威偏移,客户端按权威偏移续传;重复分片不得造成重复写入。 +- **完成动作**:全部字节到齐后才执行校验与确认;校验失败删除半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`)。重新上传同一版本前必须显式重置分片会话,重置后偏移归零,不允许在半包之上续写不同字节。 +- **可见性**:半包对象不进入公开目录、不服务发行网关、不改变当前公开版本;与既有「未通过审核不改变 `activeVersionId`」口径一致。 +- **原生侧边界**:原生上传只读本地试玩包并逐片发送,进度以事件回传渲染进程;渲染进程不再持有整包字节。 + +## 依赖 + +- `platform-oss`:需要一组可续写的对象写入原语(追加语义或等价的分片会话),以及读取已收字节的探测能力;现役只有整对象 `PUT`。 +- `api-server`:`modules/game_distribution.rs` 新增分片入口与完成动作,复用既有 `validate_release_zip`、OSS 上传重试分类、`package_confirmed` / `package_rejected` 可观测事件。 +- AGC:`src-tauri` 新增原生上传命令与进度事件,`src/services/gameDistributionPublish.ts` 改为调用原生命令;`read_local_project_export_package` 不再为发布回传整包字节。 +- 反代/网关:分片请求体远小于现役 210 MiB 放行量,沿用现有配置,不改限额。 + +## 验收标准 + +1. **不再整包过 IPC**:发布 200 MiB 档包时,渲染进程侧不出现整包字节(对照 `read_local_project_export_package` 的返回体与 IPC 报文大小),上传由原生进程完成。 +2. **续传生效**:上传中途断开传输后重发同一版本,只补传缺失分片;分片请求数、已传字节与最终包摘要三项均可复核。 +3. **跨重启续传**:上传中断时退出应用并重启,重新发布时服务端返回权威已收字节,客户端从该偏移继续,最终确认成功。 +4. **偏移与重复**:分片偏移不符返回 `409` 与权威偏移;重复提交同一分片不产生重复写入;同版本第二个写入者返回 `409 UPLOAD_IN_PROGRESS`。 +5. **失败关闭**:完成动作里校验失败(非法 ZIP、超限、压缩比越界等)删除半包对象、版本落 `upload_failed`,半包不出现在公开目录,也不影响当前公开版本。 +6. **兼容与回归**:整包 `PUT` 路径与既有测试保持绿;`npm run check:doc-index`、`npm run check:encoding`、`git diff --check` 通过;`check:spacetime-schema` 按是否新增持久字段决定是否纳入。 +7. **运行时证据(已获得)**:本地 api-server(`127.0.0.1:8082`,库 `genarrative-game-creator-dev`)+ 真实 OSS bucket 上跑通 `gameDistributionPublishLive.test.ts`:9.0 MiB 发行包跨 8 MiB 分片边界,第一片只发送一次,中断后续传从权威偏移 `8388608` 继续、第二片 `received_bytes=9437818`,最后 `package_confirmed`(`oss_put_skipped=true`);整轮 3.9s。**未获得**:AGC 真机(Tauri 运行时)一键发布的端到端运行,以及 200 MiB 档的耗时 / 内存容量数据。 + +## 待评审的决策点 + +1. **续写原语**:OSS 追加写(顺序、单对象、续传只需回读当前长度)对比 OSS Multipart(可并行、更通用但需要多组新操作)。建议追加写,顺序续传已满足本里程碑目标。 +2. **分片大小**:建议 8 MiB(200 MiB 上限 → 最多 25 片,单片请求体远低于现役放行量)。 +3. **重置语义**:建议只有显式重置(作者点「重新上传」或 `reupload` 恢复动作)才删除半包并归零;其余情况一律按权威偏移续传。 +4. **半包回收**:本里程碑只标记未完成会话,不做定时清理;回收另立里程碑(涉及「不得删除仍被公开版本引用的对象」口径)。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index bb852bae7..0a1ebbe3d 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -42,7 +42,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 - 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 - 「可显示」的判据取**前端回合反馈**:一次翻页操作连拉到「合并后聊天投影的回合数增加」为止。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但可能整页落进已渲染回合的折叠「执行过程」,不构成用户可见反馈;口径只在 `directHistoryPaging.ts` 里实现一份,首屏与「显示更早」共用。 -- 首屏切片的**新端边界**只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏,`/history` 手动重读保持按当前文件尾取尾屏的恢复语义。 +- 首屏切片的**新端边界**只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏,手动重读保持按当前文件尾取尾屏的恢复语义。 ## 依赖与前置条件 diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 4024b8e92..e55f03b43 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -86,6 +86,8 @@ ## 阶段 C:双端发布与现代游戏体验 +- AGC 发布资料生成切片实施计划:`docs/project-memory/plans/【实施计划】游戏分发阶段C-AGC发布资料AI生成-2026-09-23.md`。 + ### 前置条件 - B 已验收;网页根入口、桌面/移动导航、暖色主题交互稿及首版设备范围已评审。 @@ -94,6 +96,8 @@ ### 行为与验收 - [ ] AGC 从已构建 dist 生成根入口为 `index.html` 的真实包,一次提交动作完成检查、资料确认、上传和送审;状态及失败原因与服务端回读一致。 +- [ ] AGC 发布面板隐藏发行包技术摘要;打开时基于有界、脱敏的项目上下文免费生成一句话简介与白名单分类,失败保留本地兜底且不阻断发布;作者始终可以直接编辑生成结果。 +- [ ] AGC 发布封面支持基于项目上下文生成,复用现役图片生成与泥点扣费链路;生成结果登记为当前账号平台素材后自动作为 `coverAssetId`,不二次上传。 - [ ] 网页可选 ZIP、提交封面和必需资料,进入相同上传/校验/审核流程;任一客户端可以查看同账号游戏状态,更新沿用相同 `gameId`。 - [ ] 上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布。 - [ ] 目录支持真实数据、关键词/分类/设备筛选、空/错/加载态;详情提供明确主动作,搜索与返回恢复上下文。 @@ -103,7 +107,7 @@ ### 证据要求 -- 自动化:AGC 打包与操作恢复定向 Rust 测试、两端客户端/组件/路由/状态测试及类型检查。 +- 自动化:AGC 打包与操作恢复定向 Rust 测试、两端客户端/组件/路由/状态测试及类型检查;发布资料免费生成必须验证不写入钱包账本,封面生成必须验证扣费幂等、失败退款与 `assetObjectId` 直用。 - 运行时:AGC 一次真实发布、网页一次真实 ZIP 上传,分别审核后从桌面和手机游玩;浏览器覆盖游戏模块、素材、音频、触屏及横竖屏。 - 边界:未构建/失效 dist、资料缺失、换账号、迟到响应、审核拒绝、非移动游戏及真实空态。 @@ -116,7 +120,7 @@ ### 行为与验收 - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。 -- [ ] 100 MiB 包与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。 +- [ ] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。已有证据覆盖 100 MiB 档,上限提升后的档位待复跑。 - [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。 - [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。 - [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。 diff --git a/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md new file mode 100644 index 000000000..581077bf8 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md @@ -0,0 +1,85 @@ +# 【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22 + +状态:已完成 +父规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` + +## 目标与判据 + +一句话目标:AGC 应用内不再存在任何斜杠命令语义,终端 swarm chat CLI 入口(`--swarm-chat`)连同其命令层整体退役,且不再残留只服务这两者的实现、测试、文案与构建期门禁;行为上等同于它们从未存在。 + +验收判据: +- AGC 应用内(`apps/ai-game-creator-shell/src/**`)除路径/路由字面量外,不存在形如 `'/xxx'` 的命令字面量,也不存在对 `/` 开头输入的命令分支。 +- 斜杠命令专属模块与其导出全部消失,`projectSummary` 桶文件只保留现役导出。 +- 权威文档不再承诺「聊天输入 `/`」能力,终端章节点也不再把控制命令列为入口。 +- 终端侧不再存在 swarm chat 入口与其命令层;`--agent-run`、`--agent-enqueue`、`--agent-steer`、`--agent-resume`、`--agent-context-compact`、`--preview-serve` 等运维类控制命令保持现役。 +- 依赖终端入口的真实 E2E 与脚本(`user-input`、`supervisor-autonomous-playable-lane-defense`、三个 supervisor-swarm 混合套件、`agc:test`、`agc:test:chat*`、`agc:chat`、`agc:swarm`)与交互式 CLI harness 管道一并消失。 +- 不新增任何守卫测试或 check 脚本条目。 + +## 范围 + +范围内(删除): +- `features/project-summary/projectSummaryConstants.ts` 的 `chatCommandHelp` 与全部命令清单常量。 +- `features/project-summary/chatCommandMetadata.ts`(斜杠命令参数校验文案)。 +- Direct 聊天的 `/history` 精确匹配分支与 `reloadHistory`。 +- `chatPromptPolish` 的 `/` 前缀绕过分支。 +- `memoryCommands.ts` 的 `parseRememberInput`(`/remember` 参数解析器)。 +- 只服务退役 Supervisor 摘要面板、零外部调用的 `project-summary/*Summaries.ts` 与 `agentTrace.ts`。 +- `/sync-canvas-project`、`/read `、`/trace` 的草稿回填死链(前端 `agentPresentation.ts` + Rust `suggested_canvas_tool_call`)。 +- 无人调用的 Tauri 命令 `get_game_creation_agent_capabilities`、`get_limited_local_commands`。 +- 钉住上述字符串的构建期门禁条目与专属测试。 +- 终端 swarm chat 入口与整个命令层:`cli.rs` 的 `SwarmChat` 变体、`--swarm-chat` 解析与派发、`src/swarm_cli.rs` 与 `src/swarm_cli/` 整个目录(输入解析与帮助输出、`/agents`、`/status` 打印器、`/goal` 引擎、`/compact`、`/resume`、`/quit` 退出分支、观察器、报告、turn 派发与等待)、`SwarmChatFlow`、`SwarmTurnObservation`、`SwarmTurnOutcome::Quit`、`SwarmConfirmationResolution::Quit`、`SWARM_TURN_*_ERROR`,以及 `swarm_cli/input.rs` 中那份只为终端命令存在的 `agent.compact` / `agent.resume` / `agent.run_status` 权限校验(同名权限 id 在现役 Tauri 命令与 Runtime 生命周期上的门禁保持不动)。 +- 只服务终端交互内核的 `agent/interaction.rs` 整层:`AgentInteractionAction`、tool registry、`game_creator_agent_uses_interaction_kernel`、`decide_game_creator_agent_interaction_turn_for_session_at`、`AgentInteractionProviderStreamSink`;其上的自然语言 steer 决策 LLM 路径 `decide_game_creator_agent_runtime_steer_at` 在收尾复查后一并删除,见「收尾清理」。 +- 只能由命令触达的打印器:`print_runtime_response_stream_status` 及其专属测试。 +- 依赖终端入口的脚本与套件:`scripts/agent-swarm-test-chat.mjs`、`scripts/agent-runtime-deterministic-playable-e2e.mjs`、`scripts/deterministic-lane-defense-provider.mjs`,套件 `user-input`、`supervisor-autonomous-playable-lane-defense`、`supervisor-swarm-autonomous-chat`、`supervisor-swarm-static-isolated-autonomous-chat`、`supervisor-swarm-collaboration-policy-mixed-recovery`,以及 harness 里的交互式 CLI 管道(`startInteractiveCli`、`writeInteractiveCliLine`、`waitForInteractiveCli*`、`closeInteractiveCli`、`activeInteractiveCliSessions`、`answerRemainingInteractiveQuestions` 等)与只服务这些套件的混合套件分支、状态字段、sentinel 与 npm 脚本。 同时清掉只被这些套件调用的 harness 残件:`collaboration-assertions.mjs` 的混合/静态隔离断言族(`supervisorSwarmMixed*`、`observeSupervisorSwarmStaticIsolatedProviderOverlap`)、`repair-recovery.mjs` 的静态隔离观测调用点、evidence 模板与校验里的 `mixed*` / `staticIsolated*` / `initialBatchRecovery*` 占位字段,以及 `self-test.mjs` 中对应的合成用例与汇总字段。 + +范围外(保留): +- 命令 id 注册表 `GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`(项目权限策略词汇表;App 前端只用 `GameCreationAppCommandDescriptor` 类型表达权限判定与审计粒度,数组本体由 Rust 策略路径消费)。 +- `needsInitializedChatProject` 与项目权限策略链路。 +- Rust 侧路径/路由的 `/` 前缀校验。 +- `--agent-run`、`--agent-enqueue`、`--agent-task`、`--agent-steer`、`--agent-retry`、`--agent-cancel`、`--agent-confirm`、`--agent-resume`、`--agent-runtime-status`、`--agent-goal-*`、`--agent-context-compact`、`--runner-status`、`--runner-shutdown-if-idle`、`--llm-status`、`--preview-serve`、`--environment-check`、`--direct-codex-chat` 等运维与开发 CLI 控制命令。 +- 保留的真实 E2E 套件:`supervisor-swarm`、`supervisor-swarm-transient-retry`、`supervisor-swarm-final-reply-transient-retry`、`supervisor-swarm-tool-plan-handoff-runner-kill`、`goal-runtime`、`response-stream`、`web-search`、`context-compaction`、`scoped-agents`、`project-skill`、`parallel-read`、`steer-runner-kill`、`process-session`。 + +## 检查点 + +1. 斜杠语义层:Direct `/history`、润色绕过、`chatCommandHelp`/`chatCommandMetadata`、`parseRememberInput`、桶文件与门禁、专属测试。 +2. 死链与死模块:草稿回填链(前端 + Rust + 断言)、零调用摘要模块、`agentTrace.ts`。 +3. 无人调用的能力清单 Tauri 命令。 +4. 文档收口:AGC 主专题命令承诺、ADR、`decision-log.md`、`CONTEXT.md`。 +5. 终端 swarm chat 命令层与只服务它的权限门禁、打印器、专属测试。 +6. 终端 swarm chat 入口本体、交互内核、`--swarm-chat` 派发与其专属 Rust 测试。 +7. 依赖终端入口的 e2e 套件、deterministic wrapper/provider、npm 脚本、构建期门禁条目、harness 交互式 CLI 管道、混合套件分支,以及随之失效的混合/静态隔离断言族与 evidence 占位字段。 +8. 文档收口:终端入口退役后主实施计划、Runtime 文档、ADR、decision-log、pitfalls、里程碑计划的最终口径。 + +## 验证 + +- `npm run --workspace apps/ai-game-creator-shell typecheck`(含 `skill-pack:check` 与 `check-config.mjs`):通过。 +- 定向用例 `npx vitest run tests/appSurface.test.ts`:211 tests(202 passed / 9 skipped);`chatPromptPolish`、`rememberCommand`、`ChatMarkdownMessage`、`agentRuntimeModel` 等相关用例集通过。 +- `cargo check --tests`:0 error;删改文件无新增 `dead_code` 告警(按「父提交 vs 本次」引用数逐条比对告警标识符确认)。 +- `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`:通过。 +- 真实 E2E harness 自检 `node scripts/agent-runtime-real-e2e.mjs --self-test`:`status: PASS`;harness 相对与具名 import 全部可解析。 +- 收尾清理(2026-09-23,LLM steer 判定链):`cargo check --tests` 0 error,告警与改动前基线一致(无新增 `dead_code`);`npm --workspace apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过;幂等复核 `steer_decision` / `interrupt_for_steer_decision` / `decide_game_creator_agent_runtime_steer_at` 在 `apps/`、`packages/` 源码中零命中。 + +## 文档收口 + +- `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`:删除全部「聊天输入 `/`」能力条目,保留并改写其中的非命令事实;`/compact`、`/mcp`、`/goal`、`/resume` 的终端承诺改为对应 `--agent-*` 入口。 +- `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`:删除终端 swarm chat 入口整节,控制面统一记为 `--agent-*` CLI(`/goal` → `--agent-goal-*`、`/compact` → `--agent-context-compact`、`/mcp` → 开发配置面板与真实 E2E 核验);终端不再承担任何交互式聊天职责。 +- `docs/adr/【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md`:本次决策与影响边界。 +- `docs/project-memory/shared-memory/decision-log.md`:新增 2026-09-22 决策条目,并清理已被本次退役取代的历史命令条目。 +- `docs/project-memory/shared-memory/pitfalls.md`、`docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md`:移除 `/history` 入口表述,改为「显式重新加载对话」。 +- `CONTEXT.md`:新增「项目对话输入」「项目命令 id」术语,把「斜杠命令」标为已退役说法;不再出现 `/history` 重读入口。 +- `docs/README.md`:登记新 ADR。 + +## 收尾清理 + +复查(2026-09-23)发现退役残留与随之产生的零引用代码,按同一口径清理: + +- 配置向导 `scripts/game-creator-config-wizard.mjs`:删除指向已删脚本的 `npm run test:chat` 调用、失去含义的 `--configure-only` 开关,以及只服务该分支的 `askYesNo` 与 `npmCommand`。 +- 用户文案:App 内 `请先用 /project 设置本地项目。` 改为 `请先打开本地项目。`。 +- 注释与 prompt:`chatPromptPolish` 头部注释的 `/` 命令表述、`harness/process.mjs` 与 `scripts/check-config.mjs` 中指向已退役入口的说明,以及 `interaction.json` 中只服务已删交互内核的 7 个键(`execute_description`、`resume_description`、`project_location_description`、`protocol`、`system`、`user`、`user_with_context`)。 +- harness 死导出:`runtime-state.mjs` 的 4 个 schema 常量、`assertions/runtime.mjs`、`harness/project.mjs`、`collaboration-policy.mjs`、`persistence.mjs` 中因调用方被删而零引用的函数,连同因此失去用途的 import。 +- 无前端调用方的 Tauri 命令 `start_game_creator_agent_runtime_task` 与 `steer_game_creator_agent_runtime_task` 全部删除(`main.rs` 注册与 `check-config.mjs` 条目同步移除)。删掉 steer 命令后其唯一非测试入口的整条 LLM steer 判定链一并退役:`agent/interaction.rs`(`runtime_steer_decision` 工具、判定请求构建、响应解析、`decide_game_creator_agent_runtime_steer_at`)、`runtime.interrupt_for_steer_decision` RPC 与派发分支、持久 decision 记录(`agent.runtime.steer_decision`)、`AgentRuntimeProviderInterrupt::applied_steer_cursor`、`AgentRuntimeSteerResult` 的 `assistantReply / interruptDecision / decisionReason` 字段、`steer_decision_*` prompt 键,以及只测该链条的 provider / runner / runtime_state 用例。 + +## 未做与边界 + +- 不为退役概念新增守卫测试或断言残留字符串的 check 条目。 +- 不为终端入口与已删套件补充替代实现、兼容别名或迁移提示。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1b42991f..5838a77f4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,9 +1,93 @@ # 决策记录 +## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏 + +- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。 +- 决策一:新增 `useElementFullscreen`(`apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**(`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`,Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。 +- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 存在资源选中态——在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息展示渲染它的只读字段;运行画面上的「点选素材」只往对话插入引用,不改选中):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。手动态按资源 id 在渲染期派生(不挂 effect 回写):手动收 / 展只对做出动作时的那张资源有效,换到别的资源回到默认(有内容即展开),同一张资源即使清空选中后再选回也仍记得上一次的手动状态;这样「刚有内容」的那一帧就已经是展开态,不会先画一帧收起态再展开。 +- 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。 +- 决策四(同日收口全屏回归):用户报「退出全屏后画布仍保持全屏比例」。根因不在全屏本身,而在运行画面的尺寸上报回灌——自适应页面把视口原样报回(内容尺寸 = 容器尺寸),宿主把它当成「内容高水位」,`resolveLocalGamePreviewFitLayout` 的 `max(容器, 内容)` 就把画布钉在全屏那一帧的尺寸上;退出后 iframe 视口不再变化,桥也不会再上报,于是永远回不去(实测 1015×660 → 全屏 1416×808 → 退出仍是 1416×808、缩放到 0.72,画面按全屏比例缩成一条带黑边的窄幅)。修法:内容尺寸与它被接受时的容器尺寸在两个轴上都相等(<1px)时不算高水位,直接按容器尺寸给画布;真比容器高的页面(内容 ≠ 视口,桥注入的原始动机)仍按原生尺寸缩放显示。回归用例 `tests/localGamePreviewFrame.test.ts` 的 `returns the fitted iframe to the container after the host viewport shrinks`(改前必红,实测 1416px vs 1015px)。 +- 决策四的残余边界(明确不修):若某个**固定尺寸**页面恰好等于它被接受时的容器尺寸,且缩小容器后它上报的内容尺寸再不变,就会一直按容器取画布(页面自身溢出被裁)。评审提过「内容尺寸没变也把这条记录改认新容器」,我实现后又**实测回退**了:那条过渡期上报(内容还是放大前的旧值、视口已是缩小后的容器)会被当成固有尺寸,全屏那类问题原样复现且同样永久(iframe 回到旧尺寸后桥不再上报)。两者在宿主拿到的数据上不可区分,按 AGC 常态(桥对自适应与「固定画布但自适应文档」两类页面实测都报「内容 = 视口」)选自适应优先;页面报告新内容尺寸时立即回到 `max(容器, 内容)` 等比缩小(用例 `refits to the reported content size after the container shrinks`)。根治方向在桥 / 协议侧:尺寸消息再带一个「本页是否视口耦合」的布尔(桥内部已有逐元素耦合采样与排除耦合后的边界),拟合直接按它判定,不必用两个数字相等去猜——属桥与协议的独立变更,本 PR 不做。 +- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);`tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具并补上面两条用例;AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染(`数值微调面板` 这条已随删除面消失的 label 断言同步删掉,避免恒真)。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1812 项,合并上游退役提交后的口径)、编码检查与 `git diff --check` 通过;并用真实 Chromium(挂同一份组件 + 客户端真实注入的尺寸桥脚本,`fullbleed` 与 `fixed` 两种游戏页)冒烟:右下角按钮只把画面那一格送进全屏且可退出、退出后画布缩回容器尺寸、选中资源后信息栏自动展开(190px)、收起后画面变高(26px→636px)、再展开恢复。 + +## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它 + +- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()` 且 `click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay` 用 `inset: 0` 真的把标题栏盖住了。 +- 决策:把自绘标题栏定为**窗口边框**,不属于弹层内容:① portal 到 body 的全屏弹层一律 `top: var(--window-chrome-height)`,禁止用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容的点击继续被拦住;③ `WindowChrome` 的标题栏加 `data-window-chrome-bar` 标记,作为这条约定的唯一契约点。 +- 影响范围:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`(`:root` 注释、`.app-update-overlay`、`.game-publish-progress-overlay`)。 +- 验证方式:`tests/themedModal.test.tsx`(标题栏点击放行、工作区点击仍被拦)、`tests/WindowChrome.test.tsx`(弹窗打开时三个窗口按钮仍调用原生窗口 API)、`tests/windowChromeOverlayContract.test.ts`(7 个全屏弹层都从标题栏下方开始)、`tests/gamePublishFeedback.test.tsx` 与 appSurface(208 passed);两处新增用例都做过「去掉修复即失败」的反向确认。`npm run --workspace apps/ai-game-creator-shell typecheck`、eslint、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-09-23 游戏发行包上限提升到 200 MiB(反代放行量与发行缓存同步) + +- 背景:游戏广场发行包上限原为 100 MiB(`module-game-distribution` 的 `MAX_PACKAGE_BYTES` 与网页端 `GAME_PACKAGE_MAX_BYTES`),而 Nginx 三份模板与 Pingora 网关的通用 `/api` 放行量是 64 MiB。上限只改一层没有意义:包体超过 100 MiB 时先在反代层被 413,`api-server` 的 ZIP 校验根本不会执行。 +- 决策:发行包上限 100 MiB → 200 MiB;展开总量 250 MiB → 500 MiB(保持 2.5 倍余量);单文件 64 MiB、最多 10,000 个文件、展开/压缩比 100 三条内容规则不变;发行包路由请求体上限继续从包上限派生(200 MiB + 1 KiB)。反代放行量统一放宽到 210 MiB:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 使用 `client_max_body_size 210m`,Pingora `DEFAULT_MAX_API_BODY_BYTES` 改为 `220200960` 并同步 `deploy/pingora/pingora-gateway.env.example`。发行静态资源进程内缓存字节预算 200 MiB → 256 MiB,让 200 MiB 档发行包仍能进缓存、且不独占整份预算。 +- 边界:包内单个文件仍不得超过 64 MiB;线上 Pingora 环境文件若仍写 `67108864`,必须在重启网关前同步改值,否则发行包 PUT 会在网关层被 413。AGC 一键发布经 `@tauri-apps/plugin-http` 传整包字节,实际可发布体积还受该传输方式限制,200 MiB 档的客户端容量需要单独验证。 +- 影响范围:`server-rs/crates/module-game-distribution/src/package.rs`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`server-rs/crates/pingora-gateway/src/main.rs`、`src/components/game-distribution/gameZipPackage.ts`、`deploy/{nginx,container,pingora}`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`。 +- 验证方式:`cargo test -p module-game-distribution`(13 passed,其中 `accepts_package_above_the_previous_hundred_mib_limit` 用两个 50 MiB 存储型条目构造 100 MiB 出头的包;把上限临时改回 100 MiB 时该用例确实失败,证明它能守住新上限)、`cargo test -p api-server game_distribution`(20 passed,含新增的请求体上限覆盖包上限断言)、`cargo test -p pingora-gateway`(38 passed,含 `matches_nginx_route_parity_matrix`)、`npx vitest run src/components/game-distribution`(46 passed)、`cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。`npm run check:pingora-route-parity` 仍在 dev-http / 容器模板缺少 `/games` 等 SPA 路由处失败,改动前同样失败,与本次口径无关。200 MiB 档真实栈容量证据(上传耗时、api-server 峰值内存、超限 413 口径)尚未复跑,发布前需按阶段 D 脚本重跑一轮。 + +## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName` + +- 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。 +- 决策:把不变量前移到引用名——`@显示名` / `$名称` / `@附件名` 的名字内部不允许空白,统一经共享 `normalizeMentionName(value)`(内部空白折成 `-`、裁掉首尾)处理。落点是名字的产生处:`resourceDisplayName()`、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`,外加两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)——token 层幂等再折一次,「token 里没有空白」就是不变量本身的性质,不依赖上游数据干净。 +- 决策(不兜底):引用名假定非空,不做 `resourceId` 之类的兜底;`resourceDisplayName` 原来的 `|| asset.id` 一并去掉。 +- 校正(2026-09-23,评审项):`resourceDisplayName` 的空名字兜底不能一并去掉——整名就是扩展名时(`.env` / `.gitignore`)去掉扩展名得到空串,显示名成了空串,token 退化成只有触发符的裸 `@`(候选菜单里是空芯片,粘贴解析还会认领正文里任何一处裸 `@`)。改为词干为空时回退 `asset.id`(仍过 `normalizeMentionName`);`normalizeMentionName` 自己没有兜底、只做归一化这条不变。 +- 落点兜底(2026-09-23,评审项):part 落进正文的兜底链统一为 `mentionTokenOrText`(provider 的 `mentionToken`,拿不到就退 `contentPartText` 的通用文本形态,即 `@resourceId` / `$名称` / `@附件名` / `@区域标签`)。粘贴插入、润色回写的候选扫描与整根替换共用它,删掉两处静默丢弃路径(provider 答不出 token 的 part 在润色翻译里消失;整根替换时解析不出引用的 part 被吃掉)。副作用是恢复出来的初始草稿里已解析不出的引用落成 `@resourceId` 文本而不是消失——宁可留文本,也不让内容凭空少一段。 +- 原因:不改反解析是因为粘贴解析与润色回包共用 `buildContentFromTextTokens`,改匹配算法要冒回归润色的风险;而「名字里带空白的 token」本来就无法手敲(候选触发器 `allowWhitespace: false`,空格处菜单就关),显示口径与输入口径早就不一致。折成 `-` 之后 token 自带边界:`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`,不是空白),前缀重叠不可能再发生。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,reference-source/{skillReferenceProvider.ts,attachmentReferenceProvider.ts}}`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts`、`apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts}`、`CONTEXT.md`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md`。 +- 代价(已接受):归一化可能撞名(`hero v2` 与 `hero-v2` 同名),走既有的「同名多候选一律按文本保留」——不认错,但两者都成不了芯片;改动前生成的旧文本(历史回合 prompt、旧气泡)里的 `@hero v2` 不再解析,重试 / 润色回填时那条引用会退化成末尾孤儿(内容不丢、位置可能不对)。 +- 验证方式:`normalizeMentionName`、`resourceDisplayName`、`chatReferenceMentionToken` 的口径单测;「空白折 `-` 后 token 自带边界、前缀重叠只剩正确芯片」的回归用例;Skill 目录名带空白与附件名带空白的 provider 用例;把 `normalizeMentionName` 变异成恒等函数后以上新增用例全部变红。另跑受影响用例、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-09-22 引用粘贴解析:只认显示口径的 token,宁可不成芯片也不能认错 + +- 背景:引用输入区的 `@` / `$` 只由 `LexicalTypeaheadMenuPlugin` 的逐字敲击触发,粘贴走 Lexical 默认路径(`text/plain` → 纯文本),所以从用户消息气泡复制回来的 `@显示名` / `$名称` 粘进来就是死文本;同时 chip 的 `text/plain` 是占位符,复制出去再粘回来必然丢引用(气泡显示文本才是完整 token 的形态)。 +- 决策(口径):新增粘贴侧唯一反解析 `buildContentFromPastedText(text, references)`(`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`),候选由宿主注入的 provider 枚举,token 就是 `chatReferenceMentionToken`——与出站显示逐字同一个字符串,所以**不做任何兼容别名**:不认 `@hero.png`、`resourceId`、大小写变体、全角 `@`;边界仍是「行首 / 行尾或空白」(显示侧 token 前后补空白,两端自洽)。 +- 决策(宁可不成芯片也不能认错):同一个 token 对应多条引用身份(同名素材)时一律按文本保留;未命中的 token 静默保留、不提示、不猜文件名或路径;附件与运行画面区域不参与(静默 provider 没有候选,`@附件名` / `@区域标签` 按文本保留)。 +- 决策(provider 契约按「模糊 / 精确」分两个口):上一条里的 `match(query)` 改名 `fuzzyLookup(query)`(名字写明它是包含匹配 + 截断的模糊菜单查询),`candidates()` 改名 `lookup()`(精确查找用的、就绪的全量候选,不模糊不截断),两者共用同一份候选来源。粘贴解析只用 `lookup()` 此刻就绪的候选:不等待、不补读,也不为了解析去提前读盘;Skill 目录仍是「用户第一次敲出 `$` 才读」,冷启动时粘贴 `$名称` 保持字面文本,敲过一次 `$` 后即可重建芯片。曾一度加过的 `onPasteText` 补读钩子已删除——它既不改变本次粘贴的结果,又让输入区反过来关心 provider 的触发符。 +- 决策(接管范围):输入区在 `COMMAND_PRIORITY_CRITICAL` 注册 `PASTE_COMMAND`,**只在真的解析出引用时**接管(同 namespace 的 `application/x-lexical-editor` 负载、无 token 纯文本、图片文件一律 `return false` 走默认导入);接管时一次 `editor.update(..., { tag: PASTE_TAG })` 内按选区插入,所以一次 Ctrl+Z 整体回退,token 之外逐字保留。 +- 原因:粘贴是用户此刻的编辑,事后回头改写他的输入(例如清单到齐后再把文本改成芯片)等于前端替用户重写内容;而任何「多候选取其一」「按文件名猜资源」的启发式都会制造看不出错的错引用。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,ResourceReferenceInput.tsx,reference-source/{types.ts,resourceReferenceProvider.ts,skillReferenceProvider.ts}}`、`apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts,resourceReferenceInput.test.tsx}`、`CONTEXT.md`、`docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md`(修订节)、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、本文件。 +- 未纳入本次:斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 +- 验证方式:`buildContentFromPastedText` 规则矩阵单测(含「显示文本再粘贴回来得到同一份 content」这条逆运算)、provider 的 `fuzzyLookup` / `lookup` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动保持字面且敲过 `$` 后可解析);另跑 `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + +## 2026-09-23 最近项目检查失败不进终态 + +- 背景:最近项目列表把一次性的目录检查失败当成终态——5s 超时被吞成 `null`,增量投影又把上一轮的 `null` 原样搬进下一轮,且没有重试或重查入口。AGC 一次 IPC 停顿之后,整张列表会永久停在「检查失败 + 待识别」,首页「最近项目」同时因 `canOpen` 过滤变空,只能重启客户端恢复(issue #490)。 +- 决策:单次检查失败先就地重试一次(300ms);失败结果不进新投影(失败项回到「检查中」并重新检查);一轮结束仍有**可重试**失败时按 15s / 45s / 120s 重跑整张列表,重跑上限 3 次,失败集合变化或整轮无失败即重置预算;重命名后的单条刷新复用同一套重试与有界重查。 +- 提权边界:Windows ACL 自动提权类失败(`DACL`、`权限`、`error 5`、`安全对象不属于当前用户`、`特权`、`1300`、`AGC ACL 提权修复未成功`)判定为不可重试——重试等于在用户刚点「否」后再弹一次 UAC(提权闸门只存在于单次 invoke 内,进程级没有冷却记忆)。这类项目在用户再次主动打开/新建项目或重命名刷新之前不再自动重试,也不驱动整表重查。 +- 验证:`apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx` 覆盖「单次失败就地重试」「失败不跨轮保留」「提权类失败不重试」(前两者在改前代码上必挂);`tests/appSurface/home.suite.ts` 的失败态改为等待最终状态;退避重查用一次性脚本验证持续失败后 15s 自动恢复(脚本未入库)。 + +## 2026-09-22 筛选控件选中态:类名收敛到 helper,视觉收敛到「实心填充 + 反白文字」 + +- 背景:`platform-category-chip` 的类名字符串此前在三个宿主各抄一份(共享筛选条 `PlatformResourceFilterBar`、资源画布筛选浮层 `ResourceFilterPanel`、模板库筛选区),「选中的筛选胶囊长什么样」随时会各自漂移;更严重的是选中态本身只用了 `--platform-cool-*` 这组低透明度暖色,实测选中/未选底色对比只有 1.09:1,用户反馈「选中和没选中的颜色看不出差别」。 +- 决策:① 类名口径收敛到 `packages/shared/src/components/platformCategoryChipModel.ts` 的 `getPlatformCategoryChipClassName(active)`(从 `@genarrative/shared/components` 导出),三处宿主统一改调它;② 选中态改为**实心品牌填充 + 反白文字**,语义色收在新的 `--platform-chip-idle-fill` / `--platform-chip-active-{fill,border,text,shadow}`(浅色皮肤深暖填充、深色皮肤亮靛蓝填充 + 深文字),`PlatformSegmentedTabs` 新增 `tone="accent"` 与 chip 共用这套色;③ `src/index.css`(平台 Web/平台 H5)里那份重复的 `--active` 规则同步改口径,避免覆盖共享样式把 Web 端打回旧样子。 +- 状态阶梯(同一份口径,三个状态不许互相冒充):静止 = 浅底 + 中性描边 + 常规文字;悬停 = 中性加描边 + 极淡暖底 + 深色文字(**品牌色只能属于「已选中」**,悬停用品牌色会让未选中的 chip 看起来已选中);按下 = 再压一层;选中 = 实心填充 + 反白文字,是唯一的强状态。运行时分段的 `accent` 未选中悬停同理(淡暖底 + 深文字)。 +- 焦点态同批收口:`--platform-input-focus-ring` 从 15% 透明度改成实心色(合成后 1.17:1 的环等于没有),筛选 chip / 分段项 / 排序按钮的焦点提示改用 `outline: 2px solid ; outline-offset: 2px`——不再用 `box-shadow` 画环,避免被选中态自己的投影盖掉。 +- 原因:二元状态必须靠**填充/明度**表达而不是色相微调;颜色只允许在 `packages/shared/src/theme.css` 的语义变量里出现,组件不再自己写颜色字面量。 +- 影响范围:`packages/shared/src/theme.css`、`packages/shared/src/components/{platformCategoryChipModel.ts,styles.css,PlatformSegmentedTabs.tsx,PlatformResourceFilterBar.tsx,index.ts}`、`src/index.css`、`apps/ai-game-creator-shell/src/view/{project-development/ResourceFilterPanel.tsx,template-library/index.tsx}`。 +- 验证方式:`apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts` 按 WCAG 公式断言两套皮肤都满足「选中文字 ≥ 4.5:1(渐变两端)」且「选中填充 vs 未选底色 ≥ 3:1」;`platformCategoryChipModel.test.ts` 钉住选中类名分支;`PlatformResourceFilterBar.test.tsx` / `resourceFilterPanel.test.tsx` / `src/index.test.ts` 覆盖各宿主。真机 AGC 客户端截图实测两态填充对比 6.0:1、选中文字 4.8–6.0:1。 + +## 2026-09-22 退役 AGC 项目对话斜杠命令与终端 swarm chat 入口 + +- 背景:AGC 项目对话曾把大量能力挂在「聊天输入 `/`」上(`/history`、`/read`、`/help`、`/status`、`/trace`、`/export`、`/preview`、`/remember`、`/brief` 等),无 GUI 的终端 swarm chat 入口 `--swarm-chat` 又自带一套控制命令(`/help`、`/agents`、`/status`、`/history`、`/compact`、`/resume`、`/goal`、`/quit`)。两套入口都没有现役调用方,撤回成本却持续存在:命令字面量散落在前端命令分支、润色绕过、摘要模块、`swarm_cli` 终端输入解析、构建期门禁条目和文档承诺里,任何新对话形态都要额外维护这套死词汇表。 +- 决策:斜杠命令语义与终端 swarm chat 入口整体退役,按「从未存在」处理。应用侧删除 Direct 聊天的 `/history` 精确匹配分支与 `reloadHistory`、`chatPromptPolish` 的 `/` 前缀绕过、`chatCommandMetadata` / `chatCommandHelp` / `memoryCommands` 的命令清单与参数解析、只服务退役 Supervisor 摘要面板的 `project-summary/*Summaries.ts` 与 `agentTrace.ts`、草稿回填死链(前端 `agentPresentation.ts` + Rust `suggested_canvas_tool_call`)、无人调用的 Tauri 命令 `get_game_creation_agent_capabilities` / `get_limited_local_commands`,以及钉住这些字符串的构建期门禁条目与专属测试。终端侧连同入口一并删除:`--swarm-chat`、`src-tauri/src/swarm_cli.rs` 与整个 `swarm_cli/` 目录(命令解析与帮助输出、turn 派发、观察器、报告、专属测试)、`SwarmChatFlow`、`SwarmTurnObservation`、`SwarmTurnOutcome::Quit`、`SwarmConfirmationResolution::Quit`、`SWARM_TURN_*_ERROR`、只服务这些命令的 `agent.compact` / `agent.resume` / `agent.run_status` 权限门禁与 `print_runtime_response_stream_status` 打印器,以及只服务终端交互内核的 `agent/interaction.rs` 整层(`AgentInteractionAction`、tool registry、`game_creator_agent_uses_interaction_kernel`、`decide_game_creator_agent_interaction_turn_for_session_at`、`AgentInteractionProviderStreamSink`);该文件只保留自然语言 steer 决策路径 `decide_game_creator_agent_runtime_steer_at`。真实 E2E 的交互式 CLI 管道、`scripts/agent-swarm-test-chat.mjs`、`agentSwarmTestEntry.test.ts` 与 `agc:test:chat` / `agc:test:chat:manual` / `agc:chat` / `agc:swarm` 等 npm 脚本同步删除。 +- 保留项:命令 id 注册表 `GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`(项目权限策略词汇表;App 前端只用 `GameCreationAppCommandDescriptor` 类型表达权限判定与审计粒度,数组本体由 Rust 策略路径消费)、`needsInitializedChatProject`,以及 `--agent-run` / `--agent-enqueue` / `--agent-steer` / `--agent-resume` / `--agent-context-compact` 等非聊天 CLI 控制命令与 Tauri IPC 注册。 +- 影响范围:`apps/ai-game-creator-shell/src/**`(Direct 聊天控制器、润色、`project-summary`、`project-workspace`)、`src-tauri/src/**`(`cli.rs`、`main.rs`、`swarm_cli` 整目录删除、`agent/interaction.rs` 收敛、命令注册、canvas 生成、provider / project 测试)、`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/**`、`scripts/check-config.mjs`、root 与 App 的 `package.json` 脚本、`tests/**`,以及 AGC 主实施计划文档、Runtime V1.1 文档与 `CONTEXT.md` 术语。 +- 验证方式:`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run --workspace apps/ai-game-creator-shell typecheck`(含 `check-config.mjs` 的脚本与门禁一致性)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`、`cargo check --tests`(告警消息集与基线一致)、`npm run check:encoding`、`git diff --check`;保留的 e2e 套件为 `supervisor-swarm`、`-transient-retry`、`-final-reply-transient-retry`、`-tool-plan-handoff-runner-kill`、`goal-runtime`、`response-stream`、`web-search`、`context-compaction`、`scoped-agents`、`project-skill`、`parallel-read`、`steer-runner-kill`、`process-session`。 +- 关联文档:[【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22](../../adr/【ADR】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md)、[【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22](../plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md)、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-09-23 AGC 发布资料免费生成与封面泥点生成 + +- 背景:AGC 发布面板仍展示 ZIP 路径、文件数和体积,同时一句话简介只取创作目标、分类固定为“其他”,封面只能手选;这与发布页应隐藏技术信息、使用创作上下文降低填写成本的目标不一致。 +- 决策:发布面板移除发行包技术摘要。简介和分类由 AGC 调用平台内部免费文本模型生成,输入仅限有界、脱敏的项目名称、创作目标、任务状态、素材类型和最近编辑摘要;生成失败保留本地兜底,不扣用户泥点且不阻断发布。分类始终收敛到七类白名单。 +- 决策:封面支持基于项目上下文生成,复用现役编辑器图片生成接口和泥点扣费 wrapper;按钮与确认弹窗显示后端运行时定价对应的具体泥点数。服务端返回的 `assetObjectId` 直接作为 `coverAssetId`,禁止生成后再直传导致素材身份分叉。生成失败原因留在发布面板内。 +- 决策:发布截图同批并行上传;成功缩略图在鼠标移入时显示“删除|预览”,失败缩略图在图片内显示错误并保留独立删除按钮。单张失败只跳过该张,不阻断同批上传或发布。 +- 边界:网页发布表单、游戏分发审核 API、SpacetimeDB schema、外部 `/api/external/v1` OpenAPI 均不变;临时项目上下文不落库、不进聊天记录。 +- 验证:AGC 发布面板与发布 service 定向 vitest、api-server 发布资料 parser/route 测试、AGC 与 api-server 类型/编译检查、编码和 diff 检查。 + ## 2026-09-23 AGC 发布前先守可运行原型门禁 - 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。 -- 决策:`project.export_package` 的发布专用导出链路先检查可玩入口;没有入口时,只有 `code-prototype` 已完成或存在运行中的预览才允许执行 `build`,否则直接返回“首个可运行原型尚未完成,暂不能发布”。发布不再进入聊天确认卡,改为独立全屏进度弹窗;运行中遮罩覆盖整个工作区并阻止交互,失败留在弹窗内,成功后切换到发布资料面板。 +- 决策:`project.export_package` 的发布专用导出链路先检查可玩入口;没有入口时,只有 `code-prototype` 已完成或存在运行中的预览才允许执行 `build`,否则直接返回“首个可运行原型尚未完成,暂不能发布”。发布阻断反馈使用独立提示弹窗,不写入 Direct 聊天记录;发布过程不再进入聊天确认卡,改为独立全屏进度弹窗;运行中遮罩覆盖整个工作区并阻止交互,失败留在弹窗内,成功后切换到发布资料面板。 - 边界:已有可运行入口仍直接打包;原型已完成但缺构建产物时保留原有自动构建;缺失 `exports/README.md` 仍在导出前自动生成。 - 验证:Rust `publish_export` 4/4、前端发布相关测试 16/16、AGC `tsc`、编码检查和 `git diff --check` 通过。 @@ -53,7 +137,7 @@ - 当前合同唯一维护入口为[客户端本地埋点与主站入库契约](../../technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md),原始需求作为仓库内历史来源保存;后续里程碑规范与实施计划放在 `docs/project-memory/plans/`。 - 本地采集阶段已验收明文 JSONL 持久化;当前仍不做加密。一个项目对应一个目标,事件按业务节点采集,5 分钟封存,7 天或 20 MiB 清理;上传失败也受保留上限约束。 -- 当前上传实现已完成隔离环境验收,证据见同一主规范第 13 节:每 15 分钟上传匹配当前账号与平台的封存批次,新增一张客户端事件私有表、批次原子入库与幂等确认、成功清理及独立后台明细栏目;失败静默留待下周期重试。真实客户端文件、HTTP、数据库与后台查询已关联同一事件验证,浏览器列表/筛选/详情通过;未部署生产。保持原 12 类事件和原采集边界。上线须配置 `GENARRATIVE_AGC_ANALYTICS_ORIGIN`,按数据库、API/后台、客户端顺序发布。 +- 当前上传实现已完成隔离环境验收,证据见同一主规范第 13 节:每 15 分钟上传匹配当前账号与平台的封存批次,新增一张客户端事件私有表、批次原子入库与幂等确认、成功清理及独立后台明细栏目;失败静默留待下周期重试。真实客户端文件、HTTP、数据库与后台查询已关联同一事件验证,浏览器列表/筛选/详情通过;未部署生产。保持原 12 类事件和原采集边界。埋点接收复用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL` 的 dev/release 官方站点映射;仅 dev 渠道且 `GENARRATIVE_ENV` 为 development/test/container 时额外接受 loopback origin,无独立埋点环境变量。按数据库、API/后台、客户端顺序发布。 - 已按技术负责人授权开始实施:合同与本地队列、会话窗口与项目接入、策划阶段成果、首次提交及两类 Agent run 已实现并经独立审查;定向测试、生产编译和前序 GUI 启停证据统一见主规范第 12 节。不得宣称完整产品采集已上线。 ## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离 @@ -289,7 +373,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 ## 2026-09-20 最近项目检查保持项目级隔离 - 背景:最近项目刷新会重新检查所有路径。若其中一个目录损坏、超时或不可读,清空整张状态表会让已确认正常的项目暂时全部显示“检查中”,用户只能移除坏项目后看到列表恢复。 -- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次结果,只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。 +- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次**成功**结果,失败结果不进新投影并在本轮重新检查(见 2026-09-23 条目),只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。 - 验证:`recentProjectsHook.test.tsx` 覆盖“新增慢/坏项目刷新时保留正常项目”;`recentProjectsModel.test.ts`、`unityProjectOpen.test.tsx` 与前端类型检查一并执行。 ## 2026-09-17 GameCreationApp 资源 kind 只保留一份词汇表:严格解析 + `app_log!` 留痕 @@ -421,7 +505,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 - 背景:ADR「首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片」只落了一半。`DirectThreadManager` 是搬运层,内存里没有「已完成条目」的锚点,`subscribe` 一律返回 `last_completed_item_id: None`;`commands.rs` 的 `subscribe_direct_project_thread` 在为空时用 `read_direct_project_last_item_id_at` 从磁盘回填,所以线上回执里的值是真的(订阅那一刻文件里最后一条可显示条目的原始 item id)。前端侧:首屏一直在 `loadProjectConversation` 里用 `beforeItemId: null` 直接取文件尾一屏,`lastCompletedItemId` 自 `1b40f030e` 起不再被任何代码读取。 - 决策(锚点语义):首屏切片的新端(较新一侧)边界就是这个锚点,**含锚点条目本身**;切片命令新增 `throughItemId` 参数表达「取到这条为止」。比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠(原来的文件尾读取会把订阅回执之后才完成的条目也拉进历史,与运行态事件同 id 重叠,只靠前端合并兜住)。 -- 决策(读取时机):订阅回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏。`/history` 手动重读保持「按当前文件尾取尾屏」的恢复语义,不锚定。 +- 决策(读取时机):订阅回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏。手动重读保持「按当前文件尾取尾屏」的恢复语义,不锚定。 - 决策(翻页不变):向后翻页仍用切片返回的 `firstItemId` 作 `beforeItemId`(不含锚点),`hasMore` 与连拉口径不变。 - 影响范围:`agent/direct_project_history.rs`(切片锚点 + `through_item_id` 参数)、`commands.rs`(`read_direct_project_history_slice` 命令参数)、AGC 前端首屏读取接线与测试骨架。**未改**:DirectRuntime 的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入与进度事件、`list_game_creator_direct_active_turns`、SpacetimeDB 与 HTTP 契约。 - 验证方式(已跑):Rust 侧 `cargo test agent::direct_project_history`(22 passed,含「窗口取到锚点那条、排除比锚点更新的条目、`beforeItemId` 与 `throughItemId` 互斥报错」三类用例);前端 `npx vitest run .../directHistoryAnchorGate.test.ts`(10 passed)与 appSurface 的 `anchors the first history page at the subscribe receipt instead of the file tail`(全量 475 tests / 457 passed / 17 skipped;唯一失败 `edits the published runtime config without leaking API keys into chat` 与本次改动无关,stash 掉本次前端改动后同样变红);`tsc` / ESLint / prettier / `check:encoding` / `check:doc-index` / `git diff --check` 全绿。变异验证:闸门忽略「已消费」、首屏不等闸门两处改动各自让对应用例变红。 @@ -484,7 +568,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 - 决策(事件与退出):manifest 失效与 Runtime update relay 的接收端从单槽改为按 `event_sink_token` 去重的注册表并广播,发送失败只淘汰该接收端;GUI 退出先释放本窗口参与锁,仍有其它窗口时保留 Runner(`agent.runner.gui_exit.retained_for_other_windows`),最后一个窗口才请求关闭。Runner 启动失败时先按最新 endpoint 复用一次,避免两个窗口同时冷启动时的实例锁竞争被误报成启动失败。 - 边界:本机 GUI ↔ Runner 协议方法与参数不变,不引入多 Runner、不做跨 AppData 会话共享;项目级 `.agent/project.lock` 不变,多窗口仍不能并行写同一项目;平台登录态 generation 单调与 claim 失配失败关闭语义保持不变;混用新旧版本二进制访问同一 AppData 不属于支持场景。 - 验证:定向 Rust `runner::tests::gui_owner_*` 11 条与新增的参与锁多窗口 / 存活判定 / claim 采纳与轮换 / 同 claim 第二个窗口不清空登录态用例全部通过;真实 debug 二进制 Windows smoke 证明同一 AppData 两个 GUI 都完成 `startup.setup.complete`、只存在一个 `--agent-runner` 进程、关闭一个窗口后另一个窗口与 Runner 继续存活、最后一个窗口退出后 Runner 退出并删除 endpoint;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 -- 未验证 / 已知环境问题:真实安装包双开需要重新构建发布后才能验证;`durable_provider_handoff_prevents_shutdown_even_when_corrupt`、`durable_provider_retry_prevents_shutdown_and_reopens_writes`、`runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor` 三条用例在本机改动前的基线上即失败(Windows 安全对象 owner 校验与 Provider 请求重复),与本决策无关。 +- 未验证 / 已知环境问题:真实安装包双开需要重新构建发布后才能验证;`durable_provider_handoff_prevents_shutdown_even_when_corrupt`、`durable_provider_retry_prevents_shutdown_and_reopens_writes` 两条用例在本机改动前的基线上即失败(Windows 安全对象 owner 校验与 Provider 请求重复),与本决策无关。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(2026-09-16 节)、`docs/project-memory/plans/【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md`。 ## 2026-09-16 DirectProject 三维请求解除 Phaser 固定约束,由 Codex 自选技术栈 @@ -2404,7 +2488,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 2026-07-12 安全边界:`project.verify` 的 script 最多 160 个字符,固定使用系统 script shell,并在解析和执行前拒绝项目级 `.npmrc`。Runtime context bundle 必须绑定 `projectId / agentId / taskId / sessionId / runId / source / task`,结尾换行计入 64 KiB 上限;恢复时还要校验 `nextLoopIndex`、context window、当前窗口已完成轮数、观察指纹、计划和 observation 数量。bundle 写入必须拒绝父目录符号链接,读取必须基于同一文件句柄限制到 64 KiB,并清洗项目路径及常见平台凭据;已观察动作只有在 observation 写入 context checkpoint 后才能删除 ledger,下一轮 planning 和跨重启恢复不得再被旧 ledger 抢占。 - 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。 -- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;单 Agent Runtime 每 6 轮形成一个上下文压缩窗口,窗口有新的独立 observation 时压缩上下文并在同一 run 继续,最近 6 轮没有独立进展或相邻窗口重复时以 `failed / budget-exhausted` 和 `loop-budget-exhausted` 终止,不生成总结伪装完成。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 +- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。后台任务入口 `start_game_creator_agent_background_task_for_session_at` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;单 Agent Runtime 每 6 轮形成一个上下文压缩窗口,窗口有新的独立 observation 时压缩上下文并在同一 run 继续,最近 6 轮没有独立进展或相邻窗口重复时以 `failed / budget-exhausted` 和 `loop-budget-exhausted` 终止,不生成总结伪装完成。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents`、`events` 和 `tasks` 文件。 - 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区持续显示动态状态和进行中提示,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会跳过空 `choices` 心跳 / 元数据事件,收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;正文与 finish reason 已接收后出现尾包异常时保存已完成正文,不把整轮改写成失败。持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。 - 2026-07-11 补充:为缩小单 Agent 与 Codex CLI 在代码任务上的差距,Runtime 工具箱新增 `project.search` 和 `file.patch`,并扩展 `file.read` 的按行分页。`project.search` 在项目内执行有界字面量检索,默认忽略大小写,返回相对路径、行号和匹配行,跳过 `.agent`、敏感配置、依赖和构建目录;权限继承 `file.read`。`file.read` 接受 `startLine / maxLines`,返回带行号的最多 240 行、8,000 字符上下文,允许 Agent 继续分页而不是只看到文件开头约 900 字符。`file.patch` 只做 `oldText -> newText` 精确替换,必须声明预期匹配数,匹配数不符时不写入;它继承 `file.write` 权限,复用项目写锁和 Runtime 动作账本,并追加不含代码正文的 `agent.runtime.file.patch` 审计记录。三者组成“搜索定位 -> 分段读取 -> 局部修改 -> 再次读取验证”的最小代码工作闭环,不开放任意 shell。 @@ -2422,7 +2506,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 2026-07-12 补充,2026-07-27 更正:OpenAI Chat / Responses 的后台 Agent 工具 planning 改用唯一 `submit_agent_tool_plan` 原生 function tool,字符串 `tool_choice=required` 和 strict schema;只接受恰好一次同名调用,arguments 继续经过本地计划 schema、工具白名单和权限策略校验,错误函数、多调用或非法 arguments 进入原有两次格式修复预算且不产生副作用。本条原写「Anthropic 保留文本 JSON 回退;planning 非流式」,已由 2026-07-27「Anthropic 与流式统一使用 Provider 原生工具」取代——Anthropic 同样发送原生工具目录,planning 不再因协议强制非流式。每轮成功协议写 `agent.runtime.tool_plan.protocol`,修复审计记录 protocol、callId 和 functionName。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。 -- 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan` 和 `code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 `/agents` 汇总里。 +- 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan` 和 `code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 Agent 汇总里。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 统一注入本 Agent 的 Runtime 连续上下文,包括最近状态、runId、当前任务、计划、观察、最近回复、最近工具动作、最近事件、最近任务和工具策略摘要;上下文只按规范 taskId 读取本 Agent runtime,进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时继承同 Agent 上次 `recentToolCalls` 和 `lastResponse`,让下一轮任务能基于前一轮真实行动证据继续推理,同时不串入其他 Agent 的 runtime。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `file.list`,让 Agent 可先列出项目文件摘要或某个相对目录下的条目,再决定是否读取具体文件或继续行动。该工具复用 `file.list` 项目权限策略,策略要求确认或拒绝时不会枚举项目文件;observation 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `project.diff`,让 Agent 可基于已存在 checkpoint 观察本地项目新增、修改和删除摘要。该工具复用 `project.diff` 项目权限策略,策略要求确认或拒绝时不会执行 diff;observation 只包含 checkpoint id、三类计数和项目相对路径,不返回本机绝对路径或文件正文。 @@ -2451,18 +2535,18 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-07-03 AI 游戏创作 App 本地试玩包导出只打包运行白名单 - 背景:AI 游戏创作 App 需要给普通用户提供首版本地试玩包,但不能把项目记忆、trace、日志、运行时配置或密钥类文件混入可分发 ZIP。 -- 决策:v1 新增 `/export` 聊天入口和 `project.export_package` 确认命令。导出前重新校验 `game/index.html` 是可试玩自包含 HTML;ZIP 只包含 `game/**`、`assets/**` 和 `exports/README.md`,输出到 `exports/playtest-package-*.zip`;导出拒绝符号链接和不安全条目路径,并写入 manifest `commandRuns`、`.agent/logs/command.log` 和 `.agent/agent.db`。 -- 补充:新增 `/exports` 只读聊天入口和 `project.export_list` 自动命令,用于列出当前项目 `exports/playtest-package-*.zip` 历史试玩包;该入口只读、不删除旧包、不做系统分享,给用户继续 `/export` 或显示目录的草稿。 -- 影响范围:`apps/ai-game-creator-shell` 的聊天命令、Tauri 本地项目能力、共享命令契约和 AI 游戏创作 App 实施计划。 +- 决策:v1 新增 `project.export_package` 确认命令。导出前重新校验 `game/index.html` 是可试玩自包含 HTML;ZIP 只包含 `game/**`、`assets/**` 和 `exports/README.md`,输出到 `exports/playtest-package-*.zip`;导出拒绝符号链接和不安全条目路径,并写入 manifest `commandRuns`、`.agent/logs/command.log` 和 `.agent/agent.db`。 +- 补充:新增 `project.export_list` 只读自动命令,用于列出当前项目 `exports/playtest-package-*.zip` 历史试玩包;该入口只读、不删除旧包、不做系统分享。 +- 影响范围:`apps/ai-game-creator-shell` 的 Tauri 本地项目能力、共享命令契约和 AI 游戏创作 App 实施计划。 - 验证方式:运行 AI 游戏创作壳主窗口 smoke、Tauri `export` 定向测试、共享契约测试、类型检查、编码检查和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-07-01 AI 游戏创作 App v1 使用本地 JSONL 对话和派生 Agent 状态 - 背景:AI 游戏创作 App 已有 Godcoder 式本地工程护栏、项目黑板、角色私有记忆、manifest 和 run trace;新增结构化对话记录、agent 状态列表和单 agent 对话入口时,需要避免引入平行状态源或提前承诺后台 runner 能力。 -- 决策:v1 结构化对话记录统一使用本地 `.agent/conversations/` append-only JSONL。普通聊天写 `.agent/conversations/project.jsonl`;从 agent 状态列表进入单个 agent 后,用户消息、agent 回复、工具建议和错误只写对应 `.agent/conversations/agents/.jsonl`。Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单和 `.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus 派生,并把 `taskGraph.tasks` 的任务状态与 active / carry-over / ready 编排标记显示在主窗口和单 agent 对话入口中;单 agent 最近证据里的安全相对输入 / 输出路径只填入 `/read ` 草稿,仍由用户发送并走既有 `file.read` / `agent.trace_read` 权限流。不新增独立状态数据库。项目黑板和角色私有记忆继续只保存稳定摘要,不承载原始对话流水。 +- 决策:v1 结构化对话记录统一使用本地 `.agent/conversations/` append-only JSONL。普通聊天写 `.agent/conversations/project.jsonl`;从 agent 状态列表进入单个 agent 后,用户消息、agent 回复、工具建议和错误只写对应 `.agent/conversations/agents/.jsonl`。Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单和 `.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus 派生,并把 `taskGraph.tasks` 的任务状态与 active / carry-over / ready 编排标记显示在主窗口和单 agent 对话入口中;单 agent 最近证据里的安全相对输入 / 输出路径仍由既有 `file.read` / `agent.trace_read` 权限流处理。不新增独立状态数据库。项目黑板和角色私有记忆继续只保存稳定摘要,不承载原始对话流水。 - 补充:2026-07-08 起普通用户入口改为单窗口客户端首页;旧独立启动器 / 主窗口切换口径废止。首页发送需求或项目组新建项目时,先选择目录并在非空目录时二次确认,初始化成功后写最近项目并切到项目开发占位;取消或初始化失败则不切换视图、不写最近项目。最近工作区只保存在本机 WebView storage,可单项移除或清空,不进入项目文件或共享记忆;已初始化项目优先显示 manifest 项目名并保留路径副信息,`.agent/run.latest.json` 可读时显示最近 run 状态。最近项目路径缺失、不是目录、缺少可读 `.agent/manifest.json` 或检查失败时禁用打开,刷新只重新执行只读检查;“显示”只用系统文件管理器打开已确认存在的本地目录,未初始化但存在的目录也可显示,避免把历史路径误当新项目重建。 -- 补充:项目开发占位“显示目录”复用同一只读目录打开能力,只打开当前本地项目目录,不初始化项目、不写项目文件、不切换工作区;顶部只读显示 manifest 项目名、项目路径、最近 `.agent/run.latest.json` 的 run 状态摘要和当前预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。最近项目资产入口只读展示 localPath、kind、mediaType 和 source.kind,点击仍走原 `file.read` 权限流;旁边的“读取命令”只填入 `/read ` 草稿,不直接读取文件或绕过权限。项目开发占位里的项目黑板和 Agent 状态快捷入口仍只填入聊天草稿,不直接读取 run 辅助文件、不写 `.agent/policy.json`、不调用 LLM。 +- 补充:项目开发占位“显示目录”复用同一只读目录打开能力,只打开当前本地项目目录,不初始化项目、不写项目文件、不切换工作区;顶部只读显示 manifest 项目名、项目路径、最近 `.agent/run.latest.json` 的 run 状态摘要和当前预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。最近项目资产入口只读展示 localPath、kind、mediaType 和 source.kind,读取仍走原 `file.read` 权限流,不直接读取文件或绕过权限。 - 补充:首页、项目组和项目开发占位共用同一个运行时配置弹窗,配置只读写 Tauri 应用配置目录中的 `game-creator.config.json`,不写入项目文件或对话历史。 - 补充:项目组“打开”只进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;路径不存在、不是文件夹或只是普通文件夹时不切换到项目开发占位、不创建目录,用户需要创建或初始化时走“新建项目”。 - 影响范围:`apps/ai-game-creator-shell` 的主窗口 agent 状态列表、单 agent 对话入口、本地项目文件结构、共享契约和 AI 游戏创作 App 实施计划。 @@ -2472,7 +2556,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-06-30 AI 游戏创作 App 使用客户端配置文件 - 背景:`apps/ai-game-creator-shell` 是客户端 App,不应通过 `.env` 或进程环境变量承载 LLM / 画板同步配置;旧口径会让本地 secrets、CLI wrapper 和桌面 App 启动逻辑混在一起。 -- 决策:仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板;发布 App 启动时在 Tauri 应用配置目录写入默认 `game-creator.config.json`,真实密钥和本机覆盖项都保存在该运行时配置文件中。主窗口提供“配置”面板读写该运行时 JSON;开发 CLI 无 AppHandle 时才回退读取仓库旁边的模板和 gitignored 本机覆盖文件。`llm.apiKey/baseUrl/model/apiKind/stream/requestTimeoutMs/maxRetries/retryBackoffMs` 驱动全局 LLM 路径,`agentLlm.` 可为 Planner、Generator 和角色 agent 单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局配置;`editorApi.baseUrl/apiKey` 驱动画板项目同步;`/llm-status` 只展示全局和各 agent resolved 后的 baseUrl、model、apiKind、stream 和 API Key 是否存在,不显示密钥;`/llm-routes` 复用同一只读检查结果,按 agent 展示 resolved provider 路由、单独路由数量和缺口数量,不请求上游、不显示密钥、不写项目。生成游戏或平台美术遇到 LLM / editorApi 缺配置错误时,主窗口自动打开运行时配置弹窗,但错误消息仍只显示缺失项,不回显密钥值。 +- 决策:仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板;发布 App 启动时在 Tauri 应用配置目录写入默认 `game-creator.config.json`,真实密钥和本机覆盖项都保存在该运行时配置文件中。主窗口提供“配置”面板读写该运行时 JSON;开发 CLI 无 AppHandle 时才回退读取仓库旁边的模板和 gitignored 本机覆盖文件。`llm.apiKey/baseUrl/model/apiKind/stream/requestTimeoutMs/maxRetries/retryBackoffMs` 驱动全局 LLM 路径,`agentLlm.` 可为 Planner、Generator 和角色 agent 单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局配置;`editorApi.baseUrl/apiKey` 驱动画板项目同步;主窗口运行时配置面板与生成入口只展示全局和各 agent resolved 后的 baseUrl、model、apiKind、stream 和 API Key 是否存在,不显示密钥、不请求上游、不写项目。生成游戏或平台美术遇到 LLM / editorApi 缺配置错误时,主窗口自动打开运行时配置弹窗,但错误消息仍只显示缺失项,不回显密钥值。 - 影响范围:AI 游戏创作 App 的 Tauri Rust 配置加载、主窗口配置面板、CLI wrapper、agent-run smoke、`check-config` 门禁、`.gitignore` 和实施计划文档。 - 验证方式:运行 `npm run ai-game-creator-shell:typecheck`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 @@ -2584,16 +2668,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-06-26 AI 游戏创作 App 生成过程必须在聊天可见 - 背景:普通用户窗口只保留聊天入口,但如果生成确认后只显示“已生成草案”和本地产物路径,真实 LLM / Agent loop 会被误解成固定模板落盘。 -- 决策:`game.generate_draft` 保持正式用户窗口不展示开发面板,但必须通过聊天实时显示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator、ArtifactWriter 和自检进度;生成完成后普通聊天消息直接展示 `.agent/run.latest.json` 的 Run、LLM 对话、loop 轮次、active / carry-over 任务、编排轮次、最近步骤、建议命令和本地产物快照;没有同步建议命令时,首个安全产物只提供 `/read` 草稿,`/trace` 继续读取同一份完整证据。 +- 决策:`game.generate_draft` 保持正式用户窗口不展示开发面板,但必须通过聊天实时显示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator、ArtifactWriter 和自检进度;生成完成后普通聊天消息直接展示 `.agent/run.latest.json` 的 Run、LLM 对话、loop 轮次、active / carry-over 任务、编排轮次、最近步骤、建议命令和本地产物快照;产物证据仍从同一份 run trace 读取。 - 影响范围:`apps/ai-game-creator-shell/src/App.tsx`、`apps/ai-game-creator-shell/src-tauri/src/main.rs`、AI 游戏创作 App 聊天体验和实施计划文档。 - 验证方式:运行 `npm run ai-game-creator-shell:check`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-06-26 AI 游戏创作 App 增加显式质量评审 Gate -- 背景:AI 游戏创作 App 已有 Evaluator loop 和静态 smoke,但任务图、能力清单和 trace 中没有单独的质检 / 评审任务,用户无法从 `/tasks`、`/trace` 或 `/audit` 看出质量评审是明确环节。 +- 背景:AI 游戏创作 App 已有 Evaluator loop 和静态 smoke,但任务图、能力清单和 trace 中没有单独的质检 / 评审任务,用户无法从任务图、run trace 或审计摘要看出质量评审是明确环节。 - 决策:保持策划、美术、程序、数值、音乐、运营 6 个专业组不变,在程序组内新增 `quality-review` / `Review` 角色任务;Evaluator 的评审 step 绑定到该任务,依赖顺序为 `code-prototype -> quality-review -> preview-readiness -> preview-playtest -> publish-strategy -> publish-package`。`game.static_smoke` 只完成 `preview-readiness`,不代替质量评审。 -- 影响范围:AI 游戏创作 App 任务图、共享契约、Tauri trace / manifest 状态推导、聊天 `/capabilities` `/tasks` `/trace` `/audit` 摘要和实施计划文档。 +- 影响范围:AI 游戏创作 App 任务图、共享契约、Tauri trace / manifest 状态推导、任务图 / trace 摘要和实施计划文档。 - 验证方式:运行 `npm run ai-game-creator-shell:check`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 @@ -2611,7 +2695,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:AI 游戏创作 App 的 `game.generate_draft` 已接入 LLM,但单次请求仍不能体现 Planner / Generator / Evaluator 的协作闭环,也无法把评估反馈作为下一轮生成输入。 - 决策:v1 使用最小文件驱动 loop,不引入 LangChain、AutoGen、Microsoft Agent Framework 或 OpenAI Agents SDK sidecar。Planner 写 `.agent/spec.md`;每轮先调用策划、数值、美术、音乐、程序、运营 6 组下的 15 个角色 agent,角色 brief 写到 `.agent/passes/pass-N/groups//*.md`,再由 `GroupCoordinator` 汇总到 `.agent/passes/pass-N/groups/*.md`;Generator 读取 spec、`.agent/findings.md` 和 6 组汇总 brief 生成结构化游戏草案。LLM JSON 必须带 `handoffs` 数组并覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每轮再把这些结构化交接快照写到 `.agent/passes/pass-N/`。Evaluator 做本地静态验收并写 `.agent/findings.md`,最多 3 轮;返工轮必须把 findings 转成结构化 `repairRoutes`,记录每条问题命中的 taskIds 和 reason,再据此选择 activeTaskIds。每次运行另写 `.agent/run.latest.json` 和 `.agent/runs/.json`,记录 step、角色级 `toolCalls`、组汇总、专业组交接、输入输出路径、artifact 字节数与 `fnv1a64:` checksum;每个 step 带 phase、taskId、group 和 role,trace 顶层 `taskGraph` 记录 goal、readyTaskIds、activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和当前任务状态,`passPlans` 逐轮记录 mode、summary、activeTaskIds、carriedTaskIds、dependencyWaves、repairFocus 和 repairRoutes;latest 是当前指针,runs 目录保留历史 trace,作为开发窗口和后续工具调用 trace 的事实源,schema 由共享 TS/Rust 契约 `game-creator-agent-run.v1` 固定。最终产物写盘时追加 `ArtifactWriter / file.write.local_artifacts` step,随后自动跑白名单 `game.static_smoke`,检查 `game/index.html` 具备 canvas、canvas 渲染上下文、绘制调用、主循环、输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,再把 Playtest 工具调用写回 trace;通过后把 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,让下一次 Planner / 角色 agent / Generator 从记忆输入直接看到上一轮稳定原型;后续 `preview.start` 会在已有 trace 上追加 Preview 工具调用和本地预览 URL。 -- 决策补充:普通用户聊天 `/trace` 读取同一份 `.agent/run.latest.json`,但摘要必须把 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 从内部 taskId 映射成专业组 / 角色 / 任务名,确保不打开开发窗口也能看出 6 组 agent、组内角色、返工路线和 carry-over 真实发生。 +- 决策补充:run trace 摘要读取同一份 `.agent/run.latest.json`,但摘要必须把 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 从内部 taskId 映射成专业组 / 角色 / 任务名,确保不打开开发窗口也能看出 6 组 agent、组内角色、返工路线和 carry-over 真实发生。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/main.rs`、`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs` 和 AI 游戏创作智能体 App 实施计划。 - 验证方式:运行 AI 游戏创作壳 Rust 测试、共享契约 TS/Rust 测试、壳 typecheck、编码检查和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 @@ -2619,7 +2703,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-06-24 AI 游戏创作 App 编排 v1 使用 ready-task 选择器 - 背景:AI 游戏创作 App 已有专业组任务拆分和依赖字段,但如果没有当前可执行任务选择器,“任务编排”只停留在静态清单,普通用户在聊天里也看不到下一步由哪组 agent 接手。 -- 决策:v1 编排先使用最小 ready-task 规则:只选择 `pending` 且所有依赖任务均为 `completed` 的任务;共享 TS/Rust 契约和 `platform-agent` 都提供同一语义的选择器,聊天 `/tasks` 只展示下一步可执行专业组,不新增独立编排面板或外部 agent 框架。 +- 决策:v1 编排先使用最小 ready-task 规则:只选择 `pending` 且所有依赖任务均为 `completed` 的任务;共享 TS/Rust 契约和 `platform-agent` 都提供同一语义的选择器,任务摘要只展示下一步可执行专业组,不新增独立编排面板或外部 agent 框架。 - 影响范围:`packages/shared/src/contracts/gameCreationApp.ts`、`server-rs/crates/shared-contracts/src/game_creation_app.rs`、`server-rs/crates/platform-agent/src/game_creation.rs`、`apps/ai-game-creator-shell/src/App.tsx` 和 AI 游戏创作智能体 App 实施计划。 - 验证方式:运行共享契约测试、`platform-agent` 与 `shared-contracts` 的 Rust 测试、AI 游戏创作壳 typecheck、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 @@ -6237,88 +6321,19 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 2026-06-25 调整:新增 `npm run ai-game-creator-shell:agent-run:smoke` 作为无密钥开发验证入口。脚本在本机启动 OpenAI-compatible 测试 provider,预置一个本地上传图片和一个本地上传音频,并复用真实 `--agent-run`、本地落盘、`game.static_smoke` 和本地 HTTP 预览;脚本会断言 provider 请求体包含图片与音频资产上下文、生成 HTML 引用 `/assets/...`、预览服务能用 `GET` 读取这些资产、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、第二轮重跑 Evaluator 命中任务及其下游影响任务,未受影响组 carry-over,再自动给 CLI 发送回车停止预览。该脚本仅验证 runtime,不作为产品生成 fallback。 - 2026-06-25 调整:新增根级 `npm run ai-game-creator-shell:check` 作为 v1 开发验收入口,串起壳 typecheck、`platform-agent` 编排测试、`shared-contracts` 契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke,避免测试口径散落成多条手工命令。 - 2026-06-25 调整:`scripts/check-native-shells.mjs` 的 AI 游戏创作项从单独 typecheck 升级为 `npm run ai-game-creator-shell:check`,让原生壳总门禁覆盖 agent loop、本地落盘、静态自检和本地 HTTP 预览 smoke。 -- 2026-06-25 调整,2026-06-30 更新:普通用户通过聊天输入 `/llm-status` 触发只读 `llm.config_check`,用于检查 LLM base_url、model 和 API Key 是否已从客户端配置读取;状态消息不得显示或保存 API Key。终端可用 `npm run ai-game-creator-shell:llm-status` 做同类配置自检,缺配置时以非零状态退出。发布 App 的真实密钥只放 Tauri 应用配置目录中的 `game-creator.config.json`;主窗口“配置”面板可读写该文件,但 API Key 不写入聊天、本地项目、trace 或 manifest。 - 2026-06-25 调整:`npm run ai-game-creator-shell:dev` 固定加载 `http://127.0.0.1:3080/`,Vite 继续 `strictPort` 与 Tauri `devUrl` 对齐。`beforeDevCommand` 改为先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,避免上次 Tauri 退出后遗留的同 app Vite 进程导致二次启动失败;如果 3080 是其它服务,仍直接失败并要求释放端口,不做端口漂移。 - 2026-06-25 调整:`preview.start` / `preview.stop` 必须追加 `.agent/logs/preview.log`,并把该日志列入 Preview trace step 的输出路径和 artifact 清单;这样 `preview-playtest` 任务声明的日志产物与实际本地 HTTP 预览行为一致。 - 2026-06-25 调整:AI 游戏创作 App v1 仍只维护一个全局本地 HTTP 预览实例;启动新项目预览替换旧预览时,必须 best-effort 把旧项目的 manifest preview 状态、`.agent/logs/preview.log` 和 run trace 记录为 stopped,避免旧项目状态残留 `running`。旧项目目录已删除时不阻断新预览启动。 -- 2026-06-25 调整,2026-07-18 替代:正式用户 App 的项目运行工作台承载当前授权项目的本地游戏预览,release / dev CSP 都只允许 `frame-src http://127.0.0.1:*`;`/preview`、`/run` 和生成完成后的用户侧路径启动 `127.0.0.1` HTTP preview 后直接切换客户端运行视图,不再调用系统外部浏览器。 -- 2026-06-25 调整:`project.create` 成功后的 durable 权限证据必须在聊天 `/project` 和开发窗口初始化两条入口统一写入 `.agent/logs/command.log`,避免同一能力因为入口不同导致 `/audit` 或开发排障证据不一致。 +- 2026-06-25 调整,2026-07-18 替代:正式用户 App 的项目运行工作台承载当前授权项目的本地游戏预览,release / dev CSP 都只允许 `frame-src http://127.0.0.1:*`;生成完成后的用户侧路径启动 `127.0.0.1` HTTP preview 后直接切换客户端运行视图,不再调用系统外部浏览器。 +- 2026-06-25 调整:`project.create` 成功后的 durable 权限证据必须在开发窗口初始化入口写入 `.agent/logs/command.log`,避免同一能力因为入口不同导致审计或开发排障证据不一致。 - 2026-06-25 调整:`.agent/run.latest.json` 和 `.agent/runs/.json` 必须记录 loop 的 `maxPasses` 与 `stopReason`,开发窗口直接展示该状态,避免只从 summary 文案推断 loop 是否跑满、通过、返工、写入产物或进入预览。本地 HTTP 预览的 `/` 映射到 `game/index.html`,路径解析必须 canonicalize 项目根目录和目标文件,只允许访问项目内 `game/` 与 `assets/`,拒绝 `memory/`、`.agent/`、`exports/`、`..`、反斜杠和符号链接越界;常见图片、音频、视频和 Web 资源必须返回对应 MIME。这样上传和画板回流资产能被生成游戏引用,但记忆、trace 和导出包不会被预览服务暴露。 -- 2026-06-26 调整,2026-07-03 更新:AI 游戏创作 App 借鉴 Harbour 的控制平面思想,但不搬 Harbour 后台。最近 run 在 `.agent/run.latest.json` 增加可选 `lifecycleStatus`,并通过 `/agent-status`、`/agent-kill`、`/agent-retry`、`/agent-resume [说明]` 控制本地生命周期,写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`;聊天里的状态 / 控制结果可填入 `/read .agent/output.jsonl` 草稿继续查看 run 输出,但不直接读取文件或绕过 `file.read` 策略。v1 的 kill/retry/resume 只更新本地状态和上下文包,不伪装成能中断已发出的上游 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 -- 2026-07-03 调整:主窗口 Agent 状态栏新增“继续说明”,只把 `/agent-resume ` 填入聊天输入框,让用户补充说明后再走原确认流;策略快捷入口新增 project.index、asset.register、memory.write、preview.open、preview.stop、conversation.read 和 conversation.write 确认草稿,同样只填输入框,不直接写 `.agent/policy.json`。 +- 2026-06-26 调整,2026-07-03 更新:AI 游戏创作 App 借鉴 Harbour 的控制平面思想,但不搬 Harbour 后台。最近 run 在 `.agent/run.latest.json` 增加可选 `lifecycleStatus`,并通过 Runtime 状态控制动作维护本地生命周期,写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`。v1 的 kill/retry/resume 只更新本地状态和上下文包,不伪装成能中断已发出的上游 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 - 2026-07-03 调整:主窗口 header 常驻项目摘要只从当前已加载的 manifest / trace 派生任务完成数、ready 数、资产来源分布和最近命令结果;未选择工作区时不显示,不为了摘要额外触发 Tauri 读取或写入,也不把任务、文件、run history 或预览开发面板搬进普通用户窗口。 -- 2026-07-03 调整:普通用户通过聊天输入 `/brief` 触发项目简报入口,只基于主窗口当前已加载的 manifest、最近 run trace、预览状态、资产数量和最近命令生成聊天内简报,并提供 `/next` 作为后续草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览,也不得新增普通用户面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/goal` 查看创作目标,只基于当前 manifest.goal、最近 run goal 和 taskGraph.goal 汇总项目目标来源,并提供 `/agent-resume 细化目标:` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取 spec、上下文或 trace 文件,也不得新增普通用户目标面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/guide` 查看操作导引,只基于当前 manifest、最近 run trace、preview 和已加载命令状态判断未开始、需修复、可预览、可导出或已导出阶段,给出最多 3 个推荐命令和首选草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动 run、不得启动预览、不得写项目,也不得新增普通用户导引面板。`/guide` 只回答“下一步怎么操作”,不承接 `/brief` 的项目快照、`/mvp` 的最小范围或 `/plan` 的分工计划。 -- 2026-07-04 调整:普通用户通过聊天输入 `/progress` 查看项目进度,只基于当前 manifest、最近 run trace、preview、任务、素材和已加载命令状态汇总项目阶段、任务完成度、最近 run、预览、素材和交付进度,并提供 `/run`、`/review`、`/share`、`/test-plan`、`/todo`、`/trace` 或 `/guide` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动 run、不得启动预览、不得导出试玩包、不得写项目,也不得新增普通用户进度面板。`/progress` 只回答“当前走到哪了”,不承接 `/status` 的项目状态详情、`/ready` 的试玩门槛判断、`/groups` 的逐组进度或 `/next` 的长命令目录。 -- 2026-07-04 调整:普通用户通过聊天输入 `/spec` 查看创作规格包,只基于当前 manifest、最近 run trace、任务声明产物和 trace 输入 / 输出路径汇总 Planner 规格、玩法设计、数值表、美术清单、音频清单和发布说明状态,并提供 `/read .agent/spec.md` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取规格文件、不得启动预览、不得写项目,也不得新增普通用户规格面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/mvp` 查看本轮最小可玩范围,只基于当前 manifest、最近 run trace、preview、任务、资产和最近命令汇总 MVP 内、当前状态、试玩包状态和暂不做事项,并提供 `/review`、`/criteria`、`/trace`、`/run`、`/export`、`/exports` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包,也不得新增普通用户 MVP 面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/pitch` 查看试玩定位与卖点,只基于当前 manifest、最近 run trace 和 preview 状态汇总试玩定位、一句话、核心乐趣、当前可演示状态、测试者讲解口径和暂不承诺事项,并提供 `/mvp`、`/review`、`/trace`、`/open-preview` 或 `/run` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得直接继续 run,也不得新增普通用户定位面板。该入口服务试玩讲解,不承接 `/listing` 的作品页包装。 -- 2026-07-04 调整:普通用户通过聊天输入 `/demo` 准备 30 秒试玩讲解稿,只基于当前 manifest、最近 run trace 和 preview 状态汇总开场、讲解顺序、口播稿、演示状态、最近试玩证据和收反馈口径,并提供 `/run`、`/open-preview`、`/trace`、`/review` 或 `/test-plan` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得发布作品,也不得新增普通用户讲解面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/rules` 查看玩法操作与规则,只基于当前 manifest、最近 run trace.taskGraph、trace artifacts 和 steps 汇总玩法目标、操作 / 胜负 / 重开口径、设计与入口产物状态、相关任务和最近程序 / 试玩步骤,并提供 `/read game/game_design.md`、`/agent-resume 操作说明:...` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取设计文件、不得启动预览或继续 run,也不得新增普通用户规则面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/tutorial` 查看新手引导检查,只基于当前 manifest、最近 run trace、preview 和任务状态汇总首屏目标、首局 30 秒引导、原型证据、试玩任务、最近引导证据和补齐项,并提供 `/rules`、`/review`、`/agent-resume 新手引导:...`、`/open-preview` 或 `/run` 草稿;该入口不得触发 Tauri 读写、不得读取设计文件、不得启动或打开预览、不得直接继续 run,也不得新增普通用户引导面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/mobile` 查看移动试玩检查,只基于当前 manifest、最近 run trace、preview 和任务状态汇总移动试玩目标、键盘 / 触屏输入口径、原型证据、移动检查项、关联任务和最近移动相关步骤,并提供 `/rules`、`/review`、`/agent-resume 移动试玩:...`、`/open-preview` 或 `/run` 草稿;该入口不得触发 Tauri 读写、不得读取代码文件、不得启动或打开预览、不得直接继续 run,也不得新增普通用户移动适配面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/compatibility` 准备兼容性说明,只基于当前 manifest、最近 run trace、preview 和静态自检状态汇总推荐环境、输入兼容、不承诺范围、反馈口径和参考命令,并提供 `/run`、`/mobile`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得发布作品、不得写项目,也不得新增普通用户兼容性面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/accessibility` 查看可读性与无障碍检查,只基于当前 manifest、最近 run trace、preview 和任务状态汇总文字可读、颜色对比、按钮 / 状态命名、键盘等价、可见焦点、非颜色唯一反馈和静音可玩检查,并提供 `/rules`、`/review`、`/agent-resume 可读性与无障碍:...`、`/open-preview` 或 `/run` 草稿;该入口不得触发 Tauri 读写、不得读取代码或 trace 文件、不得启动或打开预览、不得直接继续 run,也不得新增普通用户无障碍面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/localization` 查看本地化与文案检查,只基于当前 manifest、最近 run trace、preview 和发布说明产物状态汇总默认语言、文案范围、关联任务、检查口径、暂不做事项和参考命令,并提供 `/read exports/README.md`、`/agent-resume 本地化与文案:...`、`/review` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得发布作品、不得写项目,也不得新增普通用户本地化面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/performance` 查看性能与加载检查,只基于当前 manifest、最近 run trace、preview、资产数量和 trace artifact 摘要汇总入口自包含、首屏不空白、素材体积、主循环稳定、无远程依赖和预览启动检查,并提供 `/run-artifacts`、`/review`、`/open-preview`、`/run` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取产物或日志文件、不得启动或打开预览、不得直接继续 run,也不得新增普通用户性能面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/polish` 查看试玩前打磨清单,只基于当前 manifest、最近 run trace、preview、最近自检和资产数量汇总试玩前打磨范围、推荐检查顺序、关联任务和最近打磨相关步骤,并提供 `/agent-resume 打磨:...`、`/review`、`/feedback` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动预览、不得导出试玩包、不得写项目,也不得新增普通用户打磨面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/credits` 查看素材署名与来源,只基于当前 manifest.assets 汇总素材数量、上传 / 生成 / 画板来源分布、来源清单和交付前需要确认的授权 / 模型 / 画板资源口径,并提供 `/assets` 草稿;该入口不得触发 Tauri 读写、不得刷新资产、不得读取素材清单、不得导出试玩包,也不得新增普通用户署名面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/blockers` 查看当前阻塞项,只基于当前 manifest、最近 run trace、preview、最近命令、ready / failed 任务、导出记录和资产概况汇总当前阻塞项,并提供 `/run`、`/export`、`/todo`、`/review`、`/trace`、`/tasks`、`/logs`、`/art` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动预览、不得导出试玩包、不得写项目,也不得新增普通用户阻塞面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/ready` 查看试玩就绪度,只基于当前 manifest、最近 run trace、preview、最近自检、导出记录、ready / failed 任务和资产概况汇总可交付判断,并提供 `/run`、`/export`、`/todo`、`/review`、`/trace`、`/tasks`、`/art`、`/share` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动预览、不得导出试玩包、不得写项目,也不得新增普通用户就绪度面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/evidence` 查看当前验证证据台账,只基于当前 manifest、最近 run trace、preview、最近命令、静态自检、导出记录、素材和最近试玩步骤汇总已有验证证据与缺口,并提供 `/run`、`/export`、`/art`、`/logs`、`/review`、`/next` 或 `/ready` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得写项目,也不得新增普通用户证据面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/deps` 查看任务依赖链,只基于当前 manifest.tasks 和最近 run trace.taskGraph 汇总 active / carry / ready / 等待依赖、可执行任务与等待依赖,并提供 `/criteria`、`/todo`、`/tasks` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取任务文件、不得启动 run、不得修改项目,也不得新增普通用户依赖面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/revise` 准备下一轮改版说明草稿,只基于当前 manifest 和最近 run trace 汇总返工焦点、失败 / active / carry / ready 任务、最近评审 / 试玩步骤、预览和导出缺口,并填入 `/agent-resume 改版说明:...` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得继续 run、不得启动预览、不得导出试玩包、不得写项目,也不得新增普通用户改版面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/privacy` 查看隐私与导出边界,只基于当前 manifest、授权项目路径、最近 run trace、preview、资产来源和导出记录汇总 API Key、预览、本地试玩包、内部文件、素材来源和 trace 的隐私 / 交付边界,并提供 `/credits`、`/exports` 或 `/config` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得导出试玩包、不得启动预览、不得写项目,也不得新增普通用户隐私面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/risks` 查看当前项目风险,只基于主窗口当前已加载的 manifest、最近 run trace、预览状态、任务状态、资产来源和最近命令派生风险摘要,并提供首个风险处理草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览,也不得新增普通用户面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/criteria` 查看当前任务验收标准,只基于当前 manifest.tasks 和最近 run trace.taskGraph 汇总 active、carry、ready、失败或待处理任务的验收条件和产物,并提供 `/tasks` 草稿;该入口不得触发 Tauri 读写、不得读取任务文件或 trace 文件,也不得新增普通用户验收面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/groups` 查看专业组进度,只基于当前 manifest.tasks 和最近 run trace.taskGraph / passPlans 汇总六个专业组的完成、active、carry、ready、失败数量和下一步任务,并提供 `/tasks` 草稿;该入口不得触发 Tauri 读写、不得读取任务文件或 trace 文件,也不得新增普通用户专业组面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/budget` 查看最近 run 预算,只基于当前最近 run trace 汇总轮次、工具调用、stopReason 和下一步建议,并提供 `/review`、`/publish`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取 trace 文件,也不得新增普通用户预算面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/qa` 查看质量检查清单,只基于当前 manifest、最近 run trace、最近命令和 preview 状态汇总 Evaluator、任务、静态自检、试玩和产物状态,并提供 `/review`、`/tasks`、`/trace`、`/playtest`、`/publish` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取 trace 或日志文件、不得启动或打开预览,也不得新增普通用户 QA 面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/changes` 查看最近生成变更,只基于当前 manifest、最近 run trace 的 artifacts / steps 和最近命令汇总可验产物、最近输出、当前资产和真实差异查看方向,并提供 `/read <首个可验产物>` 或 `/run-artifacts` 草稿;该入口不得触发 Tauri 读写、不得读取产物或日志文件、不得执行 checkpoint diff,也不得新增普通用户变更面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/todo` 查看下一轮小步清单,只基于当前 manifest 和最近 run trace 汇总失败、active、carry、ready 或待处理任务,并提供 `/tasks`、`/review` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取任务文件、不得启动 run、不得修改项目,也不得新增普通用户小步面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/plan` 查看下一轮分工计划,只基于当前 manifest 和最近 run trace 汇总协作顺序、各专业组接手任务、空档组和首个继续执行草稿,并提供 `/agent-resume 下一轮计划:...`、`/review` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取任务文件、不得启动 run、不得修改项目,也不得新增普通用户计划面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/review` 查看 Evaluator 评审状态,只基于主窗口当前已加载的最近 run trace 派生通过 / 需返工状态、返工焦点、返工路线和最近评审步骤,并提供 `/read .agent/findings.md` 或 `/agent-resume ` 草稿;该入口不得直接读取评审文件、不得触发 Tauri 读写,也不得新增普通用户评审面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/context` 查看生成上下文来源,只基于当前 manifest 和最近 run trace 列出项目对话、短期记忆、长期记忆、项目黑板、Agent 对话、Agent 私有记忆、manifest、最近 trace 和最近 LLM 输入路径,并提供 `/read` 或 `/memory blackboard` 草稿;该入口不得触发 Tauri 读写、不得读取上下文文件,也不得新增普通用户上下文面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/timeline` 查看项目活动时间线,只基于当前 manifest.commandRuns 和最近 run trace 汇总最近命令、日志读取草稿和最近 Agent 步骤,并提供 `/read`、`/trace` 或 `/history` 草稿;该入口不得触发 Tauri 读写、不得读取日志或 trace 文件,也不得新增普通用户时间线面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/playtest` 查看试玩状态,只基于主窗口当前已加载的 manifest、最近 run trace 和 preview 状态派生原型是否通过、预览是否运行、Playtest 任务状态、最近试玩步骤和预览日志读取命令,并提供 `/run`、`/open-preview`、`/trace` 或 `/review` 草稿;该入口不得触发 Tauri 读写、不得启动或打开预览、不得读取日志,也不得新增普通用户试玩面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/test-plan` 准备手动测试计划,只基于主窗口当前已加载的 manifest、最近 run trace 和 preview 状态汇总手动用例、关联 Preview / Playtest 任务和最近试玩证据,并提供 `/run`、`/open-preview`、`/trace`、`/review` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得直接继续 run,也不得新增普通用户测试面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/audience` 查看首批试玩对象,只基于主窗口当前已加载的 manifest、最近 run trace、preview 和试玩任务状态汇总首批试玩人群、测试者规模、观察重点和暂不面向场景,并提供 `/run`、`/feedback`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得直接继续 run,也不得新增普通用户对象面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/invite` 准备试玩邀请文案,只基于主窗口当前已加载的 manifest、最近 run trace 和 preview 状态汇总邀请对象、短文案、发送前检查和收反馈口径,并提供 `/run`、`/feedback`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得写项目,也不得新增普通用户邀请面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/bug-report` 准备缺陷复现记录,只基于主窗口当前已加载的 manifest、最近 run trace 和 preview 状态汇总复现入口、最近试玩证据、记录模板、严重度口径和修复草稿,并提供 `/run`、`/agent-resume 缺陷修复:`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得写项目,也不得新增普通用户缺陷面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/survey` 准备试玩问卷问题,只基于主窗口当前已加载的 manifest、最近 run trace 和 preview 状态汇总问卷使用场景、五个核心问题、记录格式和追踪方式,并提供 `/run`、`/invite`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得写项目,也不得新增普通用户问卷面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/cover` 准备封面与缩略图检查,只基于主窗口当前已加载的 manifest、最近 run trace、preview 和资产状态汇总封面候选、用途尺寸、选择口径和补齐路径,并提供 `/run`、`/art`、`/listing`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得截屏、不得裁剪、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得发布作品、不得写项目,也不得新增普通用户封面面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/screenshots` 准备宣传截图清单,只基于主窗口当前已加载的 manifest、最近 run trace、preview 和资产状态汇总截图目标、拍摄顺序、命名建议和作品页搭配,并提供 `/run`、`/listing`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得截屏、不得读取文件、不得启动或打开预览、不得导出试玩包、不得写项目,也不得新增普通用户截图面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/trailer` 准备试玩短视频脚本,只基于主窗口当前已加载的 manifest、最近 run trace、preview 和资产状态汇总 15 秒结构、镜头清单、口播节奏和录制提示,并提供 `/run`、`/share`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得录屏、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得写项目,也不得新增普通用户录屏面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/faq` 准备试玩常见问答,只基于主窗口当前已加载的 manifest、最近 run trace 和 preview 状态汇总试玩问答、回答口径、测试者提醒和交付搭配,并提供 `/run`、`/share`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得写项目,也不得新增普通用户 FAQ 面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/post` 准备社区发布文案,只基于主窗口当前已加载的 manifest、最近 run trace、preview 和资产状态汇总短文案、长文案结构、标签建议和 CTA,并提供 `/run`、`/store`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得上传云端、不得发布作品、不得读取文件、不得启动或打开预览、不得导出试玩包、不得写项目,也不得新增普通用户社区发布面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/store` 准备上架资料清单,只基于主窗口当前已加载的 manifest、最近 run trace、preview、资产和发布说明状态汇总必备资料、首发范围、上架前检查和参考命令,并提供 `/run`、`/listing`、`/review`、`/trace`、`/read exports/README.md` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得上传云端、不得发布作品、不得读取文件、不得启动或打开预览、不得导出试玩包、不得写项目,也不得新增普通用户上架面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/media-kit` 准备媒体资料包清单,只基于主窗口当前已加载的 manifest、最近 run trace、preview、资产和发布说明状态汇总对外资料、素材缺口、组装顺序和参考命令,并提供 `/run`、`/screenshots`、`/review`、`/trace`、`/read exports/README.md` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得截屏、不得录屏、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得发布作品、不得写项目,也不得新增普通用户媒体包面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/release-notes` 准备试玩更新说明,只基于主窗口当前已加载的 manifest、最近 run trace、preview、资产和发布说明状态汇总本轮变化、主要产物、玩家可见说明和已知限制,并提供 `/run`、`/media-kit`、`/review`、`/trace`、`/read exports/README.md` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得发布作品、不得写项目,也不得新增普通用户更新说明面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/known-issues` 准备已知问题清单,只基于主窗口当前已加载的 manifest、最近 run trace、preview 和任务状态汇总已知问题、试玩限制、反馈入口和发送前检查,并提供 `/run`、`/share`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得发布作品、不得写项目,也不得新增普通用户已知问题面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/feedback` 准备试玩反馈和修改说明,只基于当前 manifest、最近 run trace 和 preview 状态列出反馈方向、反馈模板和参考命令,并提供 `/run`、`/agent-resume 试玩反馈:`、`/review` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得直接继续 run,也不得新增普通用户反馈面板。 -- 2026-07-04 调整:普通用户通过聊天输入 `/retention` 准备首轮复玩/留存观察清单,只基于当前 manifest、最近 run trace、preview、最近试玩证据、素材数量、发布说明和导出状态汇总测试者样本、复玩信号、记录模板和暂不做事项,并提供 `/run`、`/feedback`、`/review`、`/trace` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览、不得导出试玩包、不得上传云端、不得发布作品、不得写项目,也不得新增普通用户留存面板;首版不做真实埋点、留存报表、用户画像、A/B 实验、排行榜或账号留存。 -- 2026-07-03 调整:普通用户通过聊天输入 `/listing` 准备作品页文案清单,只基于当前 manifest、最近 run trace、发布组任务和资产清单汇总标题、一句话卖点、标签口径、封面素材、发布说明和最近运营步骤,并提供 `/read exports/README.md`、`/review`、`/art`、`/publish` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得读取发布说明、不得上传云端、不得发布作品,也不得新增普通用户作品页面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/handoff` 生成当前项目交接摘要,只基于主窗口当前已加载的 manifest、授权项目路径、最近 run trace、Agent 状态和已加载 run 历史生成交接信息,并提供 `/next` 后续草稿;该入口不得触发 Tauri 读写、不得读取文件、不得启动或打开预览,也不得新增普通用户面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/runs` 查看已加载 Run 历史读取命令,只基于主窗口当前已加载的 latest trace 和最多 100 个历史 run 中已经载入的批次生成 `/trace` 或 `/read .agent/runs/...` 草稿;该入口不得额外触发 Tauri 读取、不得滚动加载更多历史、不得启动或打开预览,也不得新增普通用户面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/run-files` 查看 Agent 运行辅助文件读取命令,只列出 `.agent/output.jsonl`、`.agent/activity.jsonl` 和 `.agent/context.bundle.json` 对应 `/read` 草稿并提供首个草稿;该入口不得直接读取辅助文件、不得触发 Tauri 读写,也不得新增普通用户面板。 -- 2026-07-03 调整:`/llm-status` 读取到的 agent 级 LLM 配置状态可回填到主窗口 Agent 状态列表、聊天侧 `/agents` 汇总和单 Agent 对话头部,显示 provider 类型、模型、流式开关和 API Key 是否已读取;密钥本体仍不能进入聊天、状态列表、manifest、trace 或本地项目文件。 -- 2026-07-03 调整:开发窗口日志面板提供 `.agent/logs/command.log`、`.agent/logs/preview.log` 和 `.agent/logs/agent.log` 的只读查看入口,复用 `file.read` 授权策略;普通用户聊天输入 `/logs` 只列出这三个日志文件对应的 `/read ...` 草稿 / 命令并提供首个草稿,不直接读取日志,不新增普通用户日志面板,实际读取仍走聊天侧 `file.read`。 -- 2026-07-03 调整:单 Agent 对话面板允许用户把当前输入手动追加到该 agent 的 `memory/agents//.md` 私有记忆;写入复用 `memory.write` 项目策略、项目锁和 Tauri 本地目录能力,不把普通对话流水自动混入私有记忆;聊天侧 `/agent-conversations` 和 `/agent-memories` 只列出同一批 Agent 对话与私有记忆读取命令并提供首个 `/read` 草稿,不直接读取文件。 -- 2026-07-03 调整:普通用户通过聊天输入 `/art` 查看美术素材,只基于当前 manifest 盘点图片、视频和序列帧素材的数量、来源、画板接入状态和路径,并提供 `/generate-art 首版核心美术素材` 或 `/read assets/manifest.art.json` 草稿;该入口不得触发 Tauri 读写、平台生成、画板同步或新增普通用户美术面板。 -- 2026-07-03 调整:主窗口新增音效登记和画板音频导入快捷入口,只填入 `/asset-register assets/audio/sfx.wav audio audio/wav` 或 `/import-canvas-asset assets/audio/sfx.wav ` 草稿;聊天输入 `/audio` 只基于当前 manifest 盘点音频素材、来源和路径,并给出登记音效或读取 `assets/manifest.audio.json` 的草稿。音乐组仍复用现有资产登记 / 画板回流链路,不新增独立音频生成系统。 -- 2026-07-03 调整:普通用户通过聊天输入 `/balance` 查看数值与难度口径,只基于当前 manifest.tasks、最近 run trace.taskGraph、trace artifacts 和 steps 汇总数值组任务、验收口径、`game/balance.json` 状态和最近数值步骤,并提供 `/read game/balance.json` 或 `/agent-resume 数值调整:...` 草稿;该入口不得触发 Tauri 读写、不得读取数值表、不得启动预览或继续 run,也不得新增普通用户数值面板。 -- 2026-07-03 调整:主窗口新增常用生成产物读取入口,只把入口 HTML、设计、数值、美术清单、音频清单和发布说明对应的 `/read` 草稿填入聊天输入框;聊天命令 `/artifacts` 只列出同一组固定读取命令并提供首个读取草稿,`/run-artifacts` 只列出最近 trace 里的产物读取命令并提供首个 `/read` 草稿,`/logs` 只列出固定日志读取命令;实际读取仍走聊天侧 `file.read` 权限流,不直接读本地文件。 -- 2026-07-03 调整:普通用户通过聊天输入 `/share` 准备试玩交付清单,只基于当前 manifest、授权项目路径、最近 run trace、preview 状态和 manifest.commandRuns 汇总原型通过状态、本地预览、本地试玩包、测试者说明和反馈收集方向,并提供 `/export`、`/exports`、`/trace`、`/review` 或 `/next` 草稿;该入口不得触发 Tauri 读写、不得导出试玩包、不得列出历史包、不得上传云端、不得生成公开分享链接,也不得新增普通用户分享面板。 -- 2026-07-03 调整,2026-07-04 更新:普通用户通过聊天输入 `/next` 触发下一步建议入口,只基于主窗口当前已加载的 manifest、最近 run trace 和最近命令摘要生成聊天建议,列出 `/goal`、`/guide`、`/progress`、`/spec`、`/mvp`、`/pitch`、`/demo`、`/rules`、`/tutorial`、`/mobile`、`/compatibility`、`/accessibility`、`/localization`、`/performance`、`/polish`、`/blockers`、`/ready`、`/evidence`、`/deps`、`/revise`、`/privacy`、`/audience`、`/invite`、`/bug-report`、`/survey`、`/cover`、`/screenshots`、`/trailer`、`/faq`、`/post`、`/store`、`/media-kit`、`/release-notes`、`/known-issues`、`/tasks`、`/criteria`、`/groups`、`/balance`、`/budget`、`/qa`、`/changes`、`/plan`、`/todo`、`/trace`、`/review`、`/context`、`/timeline`、`/playtest`、`/test-plan`、`/feedback`、`/retention`、`/share`、`/listing`、`/run`、`/open-preview`、`/assets`、`/credits`、`/art`、`/audio`、`/publish`、`/artifacts`、`/run-artifacts`、`/passes`、`/run-files`、`/internals`、`/logs`、`/agent-resume ` 等安全命令草稿方向,并提供一个首选草稿;该命令不得直接执行 Tauri 读写、启动或打开预览、读取本地文件,也不得绕过原有命令确认和 `file.read` 权限流。 -- 2026-07-03 调整:普通用户通过聊天输入 `/publish` 生成发布准备清单,只基于主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要列出原型通过、预览、任务、资产、音频、包装说明和试玩包状态,并提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿;该入口不得触发 Tauri 读写、不得启动或打开预览、不得读取文件,也不得新增普通用户发布面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/internals` 只列出 `.agent/manifest.json`、`.agent/run.latest.json`、`.agent/spec.md`、`.agent/findings.md`、`.agent/policy.json`、`.agent/project.index.json`、`.agent/agent.db` 和 `.agent/conversations/project.jsonl` 的 `/read` 草稿,并提供首个读取草稿;该入口不得直接读取内部文件、不得触发 Tauri 读写,也不得新增普通用户内部文件面板。 -- 2026-07-03 调整:普通用户通过聊天输入 `/passes` 只从当前已加载的最近 run trace artifacts 中筛选 `.agent/passes/` 轮次产物,列出 `/read` 草稿并提供首个读取草稿;该入口不得直接读取轮次文件、不得触发 Tauri 读写,也不得新增普通用户轮次面板。 +- 2026-07-03 调整:agent 级 LLM 配置状态可回填到主窗口 Agent 状态列表和单 Agent 对话头部,显示 provider 类型、模型、流式开关和 API Key 是否已读取;密钥本体仍不能进入聊天、状态列表、manifest、trace 或本地项目文件。 +- 2026-07-03 调整:开发窗口日志面板提供 `.agent/logs/command.log`、`.agent/logs/preview.log` 和 `.agent/logs/agent.log` 的只读查看入口,复用 `file.read` 授权策略;普通用户窗口不提供这组日志入口。 +- 2026-07-03 调整:单 Agent 对话面板允许用户把当前输入手动追加到该 agent 的 `memory/agents//.md` 私有记忆;写入复用 `memory.write` 项目策略、项目锁和 Tauri 本地目录能力,不把普通对话流水自动混入私有记忆。 +- 2026-07-03 调整:音乐组复用现有资产登记 / 画板回流链路,不新增独立音频生成系统。 - 2026-06-25 调整:本地 HTTP 预览静态 `HEAD` 必须返回与 `GET` 相同的真实 `Content-Length`,但不返回 body;浏览器、图片、音频和视频探测不能拿到 `Content-Length: 0` 的假响应。 -- 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。 -- 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。 -- 2026-06-25 调整:普通用户通过聊天输入 `/import-canvas-export /绝对/画板素材.zip 画板项目ID` 触发待确认 `canvas.export_import`,读取现有 `/editor/canvas` 素材导出 ZIP。导入命令只读取用户指定 ZIP,写入当前本地项目 `assets/canvas-imports/`,基础护栏限制路径逃逸、文件数量和解压体积;导出包没有真实 resourceId 时,用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端画板资源行。 -- 普通用户通过聊天输入 `/sync-canvas-project 画板项目ID` 触发待确认 `canvas.project_sync`;普通模式用当前陶泥儿登录态读取 `/api/editor/projects/{projectId}` 并通过 `/api/assets/read-url` 换签,高级模式使用对应 External v1 路由。固定官方 origin、owner 和凭据均不写入 manifest、Agent DB、trace 或日志。 - `game.generate_draft` 在当前模式具备画板服务授权且美术组缺少 `canvas` 来源图片资产时,复用同一平台生成链路生成首版美术素材并下载到本地;普通模式使用登录态内部路由,高级模式使用 External v1。 - 美术组 `Asset` 和音乐组 `SFX` 在缺少对应 `canvas` 来源资产时建议同步;普通模式未登录或高级模式 Developer Key 缺失时只给出准确的能力不可用说明,不伪造生成结果。 - 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。 @@ -6329,7 +6344,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 2026-07-10 调整:`.agent/policy.json` 支持 `agentPolicies`,用规范 Agent id 保存单个 Agent 的 `deniedCommands / confirmCommands`。Runtime 计算有效工具策略时把项目级策略和 Agent 级策略叠加,项目级策略继续对所有 Agent 生效,Agent 级策略只能进一步拒绝或要求确认,不能放宽项目级策略;拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令`、`/agent-policy-allow Agent 命令`、`/agent-policy-confirm Agent 命令` 和 `/agent-policy-auto Agent 命令`,继续通过 `project.policy_write` 确认卡写入策略。 - 2026-07-10 调整:后台 Agent 工具命中确认策略时不再当作 `blocked` observation 继续收尾,而是把当前 Runtime 写成 `status/phase = waiting-for-confirmation`,`waitingOn` 固定为等待开发者确认工具动作,`recentToolCalls`、事件流、任务记录和 `taskQueue.waitingForConfirmation` 都保留该事实;同一 Agent 的后台 drain 暂停,不继续消费后续 pending 任务。命中拒绝策略仍使用 `blocked` observation 交回 Agent 修正计划。 - 2026-07-10 调整:Agent Runtime 后台任务支持按 Agent / runId 取消和重试。取消先通过 `.agent/runtime/cancel//.json` 写入本地取消请求;pending 任务被取消后不会被 drain 消费,running 任务在原 worker 仍持锁时只投影为 `cancelling`,必须等当前 LLM 或工具调用返回后的检查点真正停下,才由持锁 worker 向任务 JSONL、事件流和 `agent.db` 追加 `cancelled` 审计,不再继续执行工具或保存最终 assistant 回复。`cancelling` 期间禁止重试;重试只能基于已有非 running / pending / waiting-for-confirmation / cancelling 任务创建新的 run,并继续走 `agent.resume` 自动权限和同一 Agent 队列锁。 -- 2026-07-10 调整:Agent Runtime 后台任务的 `runId` 是同一 Agent 任务历史的身份,不允许复用覆盖。`start_game_creator_agent_runtime_task`、`agent.delegate` 和 retry 进入后台队列前会读取该 Agent 全量 task JSONL 历史;若调用方传入的规范化 runId 已存在,Runtime 自动追加 `-dup--` 生成实际 runId。任务队列、delegate observation 和 `agent.db` 审计都必须使用实际 runId,避免 `latest_game_creator_agent_runtime_tasks` 按 runId 去重时折叠掉不同任务。 +- 2026-07-10 调整:Agent Runtime 后台任务的 `runId` 是同一 Agent 任务历史的身份,不允许复用覆盖。`start_game_creator_agent_background_task_for_session_at`、`agent.delegate` 和 retry 进入后台队列前会读取该 Agent 全量 task JSONL 历史;若调用方传入的规范化 runId 已存在,Runtime 自动追加 `-dup--` 生成实际 runId。任务队列、delegate observation 和 `agent.db` 审计都必须使用实际 runId,避免 `latest_game_creator_agent_runtime_tasks` 按 runId 去重时折叠掉不同任务。 - 2026-07-10 调整:Agent Runtime 的 `memory.write scope=agent` 只能写当前 Agent 自己的私有记忆。若 action 指定其他 `agentId / targetAgentId`,Runtime 返回 `blocked` observation,不写目标 Agent 私有记忆、不写 `agent.runtime.memory.write` 审计;跨 Agent 共享稳定结论必须走 `blackboard.write`,给单个 Agent 留上下文必须走 `agent.message`。 - 2026-07-10 调整:Agent Runtime 和本地对话使用 append-only JSONL 作为事实源时,进程内必须按目标文件路径串行追加整行。`.agent/agent.db`、`.agent/conversations/**/*.jsonl`、`.agent/runtime/events/*.jsonl`、`.agent/runtime/tasks/*.jsonl`、`.agent/activity.jsonl` 和 `.agent/output.jsonl` 统一走共享追加 helper,避免多个后台 Agent 并行完成时 JSON record 与换行交错。 - 2026-07-10 调整:Agent Runtime 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。Runtime 将精确 `action` 输入、当前 task/run、loop 轮次、action 序号、计划、已有 observations 与后续 loop 所需上下文先做敏感内容和项目绝对路径校验,再通过临时文件替换原子写入 `.agent/runtime/pending-actions//.json`;公共 runtime state 的 `pendingToolAction` 只暴露 `actionId / actionFingerprint / tool / inputSummary / reason / requestedAt` 安全摘要,完整输入不进入公共状态。`actionFingerprint` 绑定工具名、完整输入 JSON 与实际执行使用的 task context;`actionId` 还绑定 run、loop、action 序号和 occurrence nonce,使同一 run 内输入相同的两次动作仍是两个不同发生。确认和拒绝都必须匹配 `runId + actionId`,Runtime 会重算指纹并与私有落盘动作及公共摘要交叉校验,不一致时失败关闭。确认通过后在同一 run 直接执行持久化的原 action,把真实 observation 接回后续 Agent loop,不创建新 run,也不让模型重复生成待确认动作;拒绝不执行工具,写入 `blocked` observation 后在同一 run 继续规划。待确认账本按 `pending-confirmation / approved / executing / observed-approved / observed-rejected` 迁移:重启时 `approved` 可恢复精确动作,已持久化 observation 可直接续 loop,`executing` 表示外部副作用结果未知,Runtime 必须进入 `failed / needs-reconciliation` 并禁止自动重放,开发者核对项目状态后只能先取消原任务。waiting run、完整待确认动作和安全摘要均已落盘,App 重启不会越过该 run 去启动后续任务;等待期间同 Agent 新任务只保持 `pending`,确认、拒绝或取消结束后再由同一 drain 串行排空。`.agent/runtime/` 是 Runtime 私有控制面,通用 `file.list / file.read / file.write / file.delete` 不得列出、读取、修改或删除;checkpoint/index/diff/restore 继续整体排除该目录。每 Agent 锁包含唯一 token,旧持有者析构时只删除自己的锁;Linux 上其他仍存活进程的锁不会因超过固定时长被抢占。确认、拒绝及工具 observation 分别写入 `agent.runtime.tool_confirmation.approved`、`agent.runtime.tool_confirmation.rejected` 和 `agent.runtime.tool_observation` 审计;pending 和 confirmation 文件只在 observation/终态可靠落盘后清理,失败清理会显式报错。 @@ -6352,29 +6367,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 2026-07-10 调整:Agent Runtime V1 新增 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/.jsonl` 里的上一进程遗留 `running` 或 `pending` 任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`,并写 `agent.runtime.background_task.recovered` 审计记录。该能力只恢复本地 JSONL 队列到当前 App 进程,不是跨重启常驻 worker,也不承诺恢复已发出的上游 LLM 请求。 - 2026-07-10 调整:每个 Agent 新增独立持久化 Session 管理。legacy `agent-session-` 继续读写 `.agent/conversations/agents/.jsonl`;新 Session 写 `.agent/conversations/agents//sessions/.jsonl`,`.agent/runtime/sessions/.json` 原子保存 Session catalog 和 active Session。开发单 Agent 聊天页支持列表、创建、切换、归档和归档历史只读查看;归档不删除消息,运行中、排队中、等待确认、取消中或 `needs-reconciliation` 的 Session 不允许改变 active/归档。聊天、流式回调、后台 run、任务历史、事件历史和 prompt 连续上下文按启动时 `sessionId` 归属并过滤,`conversation.read` 和 self `agent.run_status` 通过 runId 使用同一 Session;恢复或处理待确认动作前校验 task、runtime state 和 pending action 的 Session 一致性。Runtime 的 OS 锁、FIFO 队列和恢复屏障仍属于 Agent,同一 Agent 不因多个 Session 获得并行执行能力。 - 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。 -- 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。 -- 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 +- 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比和确认回滚的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。 - 2026-06-24 调整:聊天区待确认命令的日志语义必须区分 `permission.pending`、`permission.confirm` 和 `permission.cancel`;待确认卡片必须展示本地写入目标路径,避免用户在不知道落盘位置时确认。 -- 2026-06-24 调整:普通用户通过聊天输入 `/status` 读取 `.agent/manifest.json` 的项目状态摘要,只在聊天消息里展示项目目录、任务状态、资产数量、预览状态和最近命令;不得为了状态查看暴露任务、文件或日志面板。 -- 2026-06-24 调整:普通用户通过聊天输入 `/files` 触发只读 `file.list`,只在聊天消息里展示本地项目文件摘要;不得把文件读写面板暴露到普通用户窗口。 -- 2026-06-24 调整,2026-07-03 更新:普通用户通过聊天输入 `/assets` 触发只读 `asset.list`,只在聊天消息里展示本地项目资产路径、类型和来源;资产列表消息可以填入首个资产的 `/read` 草稿,方便从聊天继续查看资产文本元数据,但仍不直接读取文件或绕过聊天命令;不得把资产面板暴露到普通用户窗口。 -- 2026-06-24 调整:普通用户通过聊天输入 `/read 本地相对路径` 触发只读 `file.read`,只在聊天消息里展示项目内文本文件并截断长文本;不得开放聊天里的文件写入或删除能力。 -- 2026-06-24 调整:普通用户通过聊天输入 `/tasks` 触发只读 `task.list`,只在聊天消息里展示专业组、角色、任务状态和产物交接;不得把任务面板暴露到普通用户窗口。 -- 2026-06-24 调整:普通用户只能通过聊天触发内置命令;当前 `/smoke` 映射到白名单 `command.run_limited game.static_smoke` 并走待确认卡片,不允许扩展成任意 shell 或自由命令解析。 -- 2026-06-24 调整:普通用户通过聊天输入 `/project /绝对路径` 触发 `project.create` 待确认命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;不要把开发窗口项目路径输入框暴露到正式用户界面。 -- 2026-06-25 调整:普通用户侧所有会写入、运行、查看 / 打开预览或导入本地产物的命令必须先完成 `/project` 初始化,包括 `game.generate_draft`、`asset.upload`、`game.run_local`、`command.run_limited`、`preview.start`、`preview.status`、`preview.open`、`preview.stop`、`memory.write`、`memory.delete`、`canvas.project_sync`、`canvas.asset_import` 和 `canvas.export_import`;没有已授权本地项目时只提示设置项目,不得落到默认 `/tmp` 草稿目录。 +- 2026-06-25 调整:普通用户侧所有会写入、运行、查看 / 打开预览或导入本地产物的命令必须先完成项目初始化,包括 `game.generate_draft`、`asset.upload`、`game.run_local`、`command.run_limited`、`preview.start`、`preview.status`、`preview.open`、`preview.stop`、`memory.write`、`memory.delete`、`canvas.project_sync`、`canvas.asset_import` 和 `canvas.export_import`;没有已授权本地项目时只提示设置项目,不得落到默认 `/tmp` 草稿目录。 - 2026-06-24 调整,2026-06-30 更新:终端测试入口使用同一个 Tauri Rust 二进制的 `--agent-run <本地项目绝对路径> <创作需求>`,只复用现有 `game.generate_draft`、`game.static_smoke` 和本地 HTTP 预览链路,不另建第二套 agent runtime;发布 App 的 LLM 配置从 Tauri 应用配置目录读取,不写入仓库默认配置或项目文件。需要自动验证时可追加 `--no-wait`,生成预览 trace 后立即停止本地预览,避免命令卡在回车等待。 - 2026-07-04 调整,2026-07-08 更新:`apps/ai-game-creator-shell/src-tauri/src/main.rs` 拆成薄入口,继续只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;CLI 参数解析与终端运行输出放入 `cli.rs`,Tauri command 包装放入 `commands.rs`,运行时配置 / LLM 配置检查放入 `config.rs`,Agent loop 与生成编排放入 `agent.rs`,上传 / 画板 / 平台美术生成接入放入 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放入 `project.rs`,本地 HTTP 预览 server、preview registry 和 preview Tauri command 放入 `preview.rs`,旧窗口 URL 与兼容 command 放入 `windows.rs`,Rust 单测放入 `tests.rs`。拆分不得改变 Tauri command 名、JSON 字段、`.agent/*` 路径、项目权限策略或错误语义。 - 2026-06-24 调整,2026-07-08 更新:AI 游戏创作 App 的 release 配置只登记一个普通用户窗口,登录后在同一 WebView 中进入首页、项目组和项目开发占位;开发专用单 Agent 对话、任务、文件、记忆、预览、日志和能力面板只能通过 Vite dev 的 `?dev/#dev` 分支或 debug 构建自动打开的 `developer` 开发窗口查看,不进入普通用户窗口。旧工作区窗口切换 command 只保留兼容,用户主流程不得调用它。 - 2026-06-24 调整,2026-07-18 更新:`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留一个普通用户窗口,用户侧预览只在项目运行工作台嵌入当前 `127.0.0.1` 游戏,且 Tauri 激活命令不得调用 opener;开发面板只能在 `devMode` 分支或 debug-only `developer` 窗口渲染,`developer` 窗口当前使用 `index.html?agent-chat` 并复用 `.agent/conversations/agents/.jsonl` 持久化单 Agent 对话;发布入口和普通用户窗口不得暴露 `Agent 聊天` 导航,也不得调用旧工作区窗口切换 command。 - 2026-07-10 调整:AI 游戏创作 App 的 Runtime 实时状态依赖 Tauri event listen。`src-tauri/capabilities/events.json` 必须覆盖 `client`、`developer`、`main`、`launcher`,只授予 `core:event:allow-listen` 与 `core:event:allow-unlisten`,不得向前端授予 emit;`check-config.mjs` 静态守住窗口和权限边界。Vite 开发服务器必须把仓库根目录加入 `server.fs.allow`,因为 App 直接加载 `packages/shared/src`;否则真实 WebView 会因共享源码 403 白屏,即使 TypeScript 检查仍通过。 - 2026-06-25 调整:`check:native-shells` 在 `ai-game-creator-shell:check` 之后必须追加 `ai-game-creator-shell:build -- --no-bundle`,让原生壳总门禁同时证明 AI 游戏创作独立 Tauri 壳能完成 release 编译,而不是只证明前端 / Rust 逻辑测试通过。 -- 2026-06-24 调整,2026-07-18 更新:普通用户通过聊天输入 `/preview` 触发待确认 `preview.start`,完成 `/project` 初始化后可通过 `/open-preview` 触发待确认 `preview.open` 并只激活当前已授权项目对应的 `127.0.0.1` 客户端运行视图,通过 `/preview-status` 查询当前项目预览,通过 `/preview-stop` 停止当前项目预览;用户工作台仅嵌入当前项目的 loopback 游戏,开发预览状态面板仍只在开发窗口可见,不能把 `preview.open` 扩展成任意 URL 打开能力,也不能展示或停止其它本地项目遗留的全局预览。 -- 2026-06-25 调整:`/preview-status` 虽然是只读命令,也必须写入 `preview.status` 命令日志并向聊天返回错误,不得因查询失败产生未捕获异常或无审计记录。 -- 2026-06-24 调整:普通用户通过聊天输入 `/memory [short]` 读取长期或短期记忆,通过 `/remember 内容` 待确认追加长期记忆,通过 `/forget-memory [short]` 待确认删除记忆;不得为了记忆查看或编辑暴露独立用户面板。 -- 2026-06-25 调整:`/remember` 支持可选 scope:`/remember short 内容` 追加短期记忆,`/remember long 内容` 或未写 scope 时追加长期记忆;仍统一走待确认 `memory.write`,不暴露独立用户面板。 -- 2026-06-24 调整:普通用户通过聊天输入 `/canvas 画板项目ID` 触发待确认 `canvas.project_open`,只打开本机 Genarrative 编辑器 `/editor/canvas?projectid=...`;不得把它扩展成远程站点或任意 URL 打开能力。 -- 2026-06-24 调整:普通用户通过聊天输入 `/import-canvas-asset 本地路径 画板项目ID 资源ID|object:资产对象ID [kind] [mediaType]` 触发待确认 `canvas.asset_import`,只登记项目目录内已有文件为 `canvas` 来源资产;只有 `assetObjectId` 时使用 `object:` 前缀,不伪造 resourceId;画板导出包回流使用 `/import-canvas-export /绝对/画板素材.zip 画板项目ID`。 - 验证方式:`npm run ai-game-creator-shell:typecheck`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`、`npm run test -- packages/shared/src/contracts/gameCreationApp.test.ts`、`cargo test -p shared-contracts game_creation_app --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 ## 2026-06-30 唯一码和私有码按用户限兑一次 @@ -6412,7 +6413,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-07-10 AI 游戏创作 Agent Runtime 执行边界 -- 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 调用 `start_game_creator_agent_runtime_task` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。 +- 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 走后台任务入口 `start_game_creator_agent_background_task_for_session_at` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。 - 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。消息区只在用户仍接近底部时自动跟随最新片段,用户向上查看历史后暂停跟随,切换会话、重新读取或主动发送时再恢复。 - 2026-07-12 修正:OpenAI-compatible 流式响应中 `choices` 为空数组或 `null` 的 usage / metadata 包不得再报缺少 `choices[0]`,必须跳过元数据并继续等待正文。首个 delta 前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误允许由 Rust 单 Agent 流式入口回退一次非流式请求;上游状态、鉴权、额度、超时、连接和请求错误直接保留原错误,前端不得再次发起普通 LLM 请求。已收到正文和完成原因后继续保留完整流式回复,不能被尾部坏包覆盖。 - 2026-07-12 修正:Tauri 聊天事件监听被拒绝后,前端选择的普通回复入口必须固定调用 `client.run`,即使 Agent 路由保留 `stream=true` 也不得再内部发 SSE。pending action 的 project revision 快照改为绑定 planning 请求发出前的版本;`file.delete` 取得项目写锁后必须再次校验 revision / verification gate,公共 pending 摘要必须与私有 ledger 完整相等,confirm / reject 只在迁移状态可靠落盘后启动 continuation。manifest 和 pending ledger 禁止 truncate/remove 旧文件后再替换,统一使用同目录临时文件的原子替换及可恢复 backup。 @@ -6720,14 +6721,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - Finalization 生产闭集:四条 lifecycle 只允许固定 lifecycle 字段和统一 `schemaVersion / updatedAt` envelope,并绑定 `responseChars / conversationPath`;assistant 审计只允许 `recordType / agentId / sessionId / role / path / messageId / finalizationId`,两条 completed 审计只允许 `recordType / agentId / taskId / sessionId / runId / source / finalizationId / messageId / responseFingerprint / responseChars`,再加同一 envelope。匹配必须逐字核对 finalization/message、Agent/task/Session/run/source、Goal/plan 快照、response fingerprint/chars 和 conversation path,四阶段还必须核对 ordinal/previousStage 与 JSONL 物理顺序;任何额外生产字段都不能获得 finalization reservation。 - 公共投影边界:task、Goal/steer、委派任务、`project.verify` 命令和 Provider/Runtime error 正文只保留在对应私有执行事实中。event、Agent DB、receipt、activity、output 与报告统一只存身份、状态、SHA-256、字符/字节/条目计数和经 URL、项目根、其它绝对路径及凭据清洗的有界摘要;公共 task 固定不存正文,委派只存 `taskSha256 / taskChars`,verify 只存脚本安全标识、`expectedCommandSha256 / expectedCommandChars`、timeout 和结果计数,error 只存 kind/fingerprint/chars 或脱敏摘要。禁止保留 task/Goal/委派/命令/error 的正文、preview、head 或 tail;普通非 Goal 任务也不例外。 - 真实验收器:revision 2 marker/path/content 不再预埋首轮项目 fixture;两个 revision 都必须命中同一交付路径的真实 `file.write` 或 `project.patchset create` 待确认动作,edit 前最终 marker/文件必须不存在,revision 1 已完成步骤在 revision 2 和终态不可回退。Goal suite 使用带 sentinel 的专用 AppData,配置只以 hardlink 复用并在清理前核对 inode/hash;全部 CLI 固定指向专用 config dir,Runner 强杀绑定 endpoint、boot、实际二进制/argv 和 OS 启动指纹,endpoint 丢失只允许回收已认领的同指纹进程。CLI JSON 只接受精确 assigned 前缀,Goal completion evidence 按四项生产契约逐字核对,公共扫描同时包含完整正文和两个 marker,失败报告从现存 task/event/Agent DB/conversation 分面容错回收部分证据而不再全报 0。 -- 展示边界:开发 Agent UI 使用 `执行 / 聊天 / 目标` 三段模式,Goal 创建/编辑通过独立弹层完成,并展示状态、revision、完成标准和暂停/恢复/清理;纯聊天 CLI 提供对应 `/goal` 命令。正式用户 Project Supervisor 页面不暴露 Goal 管理控件。 +- 展示边界:开发 Agent UI 使用 `执行 / 聊天 / 目标` 三段模式,Goal 创建/编辑通过独立弹层完成,并展示状态、revision、完成标准和暂停/恢复/清理;纯聊天 CLI 通过 `--agent-goal-*` 入口管理同一 Goal。正式用户 Project Supervisor 页面不暴露 Goal 管理控件。 - 验收现状:确定性回归与 UI 覆盖不能替代真实 Provider 长链路。截至 2026-07-15 尚未记录 V1.18 真实 Provider PASS;最新现场仍在首轮 planning、零 plan/action 时由对端关闭长连接,Rust 25.2 秒短请求成功只能证明基础通道。恢复后必须用一次性项目完成 Goal edit、pause、Runner 强杀、重启保持 paused、显式同 run resume、唯一 assistant 和零旧动作重放的交叉取证。 ## 2026-07-15 Project Supervisor 纯聊天短入口 - 决策:无 GUI 开发聊天省略 `parentAgentId` 时固定进入 `project-supervisor`;新增 `npm run agc:chat -- --config-dir [--init] ` 作为总控入口。原 `agc:swarm` 和显式 `` 继续保留给专业父 Agent 调试,不改变既有调用兼容性。 - 边界:短入口只复用现有 Swarm CLI、External Runner、Supervisor active Session、conversation、黑板、记忆和 durable 委派协议,不新增 Agent、HTTP 服务、数据库或旁路 Provider 调用。 -- 验收:CLI 单测覆盖省略 ID 默认总控和显式 ID 兼容;真实入口 smoke 用一次性项目启动 `agc:chat`,终端显示 `project-supervisor`、创建空总控 Session,并在未发起 LLM 请求时通过 `/quit` 正常退出和清理。 +- 验收:CLI 单测覆盖省略 ID 默认总控和显式 ID 兼容;真实入口 smoke 用一次性项目启动 `agc:chat`,终端显示 `project-supervisor`、创建空总控 Session,并在未发起 LLM 请求时通过 EOF(Ctrl-D)正常退出和清理。 ## 2026-07-15 后台 Agent 最终回复使用真实增量流 @@ -6753,9 +6754,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:只压缩旧 Agent/legacy conversation 和当前 run 的旧 observation,保留最近精确 tail;Goal、任务、结构化计划、steer、pending action、project/repository revision、verification、process/join/delegate、receipt 和 finalization 身份保持规范事实,不进入摘要改写。 - 持久化:私有 `game-creator-runtime-context-compaction.v1` sidecar 绑定 Agent/Session、source prefix 指纹、可选 run、summary 指纹、预算与 usage;同源幂等,追加后 revision 单调,前缀漂移失败关闭。context bundle 只绑定压缩元数据,不复制 summary 正文。 - 请求安全:compaction 使用独立 Provider lifecycle、稳定 request slot、零工具和零 web search。未知 started 或 completed 后 sidecar 未提交均按 orphan barrier 进入 reconciliation,禁止自动重发;sidecar 已提交后恢复直接复用。 -- 入口:自动压缩只发生在 background planning 安全边界;开发 Agent UI 与 `agc:chat` / `agc:swarm` 提供 `/compact`,但 in-flight Provider、执行中工具、pending confirmation 或未收束 Runtime 时拒绝手动压缩。正式用户 Supervisor 页面不增加压缩控件。 +- 入口:自动压缩只发生在 background planning 安全边界;显式手动压缩入口为 `--agent-context-compact`,但 in-flight Provider、执行中工具、pending confirmation 或未收束 Runtime 时拒绝手动压缩。正式用户 Supervisor 页面不增加压缩控件。 - 验收:除配置、幂等、篡改、恢复和公共零正文回归外,真实套件必须完成至少 30 轮、两次压缩和一次 Runner 强杀,证明请求低于阈值、原身份不变、工具零重放、唯一 assistant 与早期约束可召回;此前不得宣称整体 PASS。 -- 语义修正:历史“每 6 轮形成上下文压缩窗口”的表述由本条取代;6 轮只形成进度 checkpoint 并执行停滞检测,不改写 observation。真正摘要只由 token 阈值或显式 `/compact` 触发。 +- 语义修正:历史“每 6 轮形成上下文压缩窗口”的表述由本条取代;6 轮只形成进度 checkpoint 并执行停滞检测,不改写 observation。真正摘要只由 token 阈值或显式手动压缩触发。 - 实现收口:显式用户约束由确定性保留层逐字钉住并继续做凭据/绝对路径脱敏;`runtime.compact` 单独使用 6 分钟 IPC 响应窗口,其他 Runner 方法仍为 10 秒;普通后台任务公共审计只保存 `taskChars + taskSha256`;终态旧 bundle 只有在完整身份、Goal、revision、verification、observation、sidecar、steer 校验通过后才可刷新 legacy plan 投影。 - 真实验收:2026-07-15 正式 `openai_chat / gpt-5.5` 路由的隔离 `context-compaction` suite PASS。30/30 轮、两次 compaction revision、一次 pidfd Runner 强杀恢复、早期约束召回和 29134/64000 最大估算输入均满足;30 个 tool-plan 与 2 个 compaction lifecycle 唯一闭合,fallback replay、重复 message/audit、工具重放和公共正文/summary/API Key/诱饵/项目路径/正式配置路径泄漏均为 0。首轮第 22 轮 Provider transport 终态按规则 FAIL 且零重放,新 disposable 项目完整重跑取得 PASS,全部一次性现场已按 sentinel 清理。 @@ -7144,7 +7145,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:`--swarm-chat` 曾在模型调用前用字符串包含判断选择 Chat / Execute / Resume,否定句、复合请求和未列入词表的工作请求都会误路由;busy Runtime 期间的裸聊天还可能绕过 Agent lane 并与原 run 交错写同一 Session。 - 决策:删除自然语言关键词分类和硬编码自然语言直答。Project Supervisor 与角色目录中 `role.id=director` 的六个部门负责人使用统一 interaction loop;自然语言回复与 `project_location / runtime_execute / runtime_resume` 都来自同一次 Provider turn 的直接文本或原生 function tool。叶子专业 Agent 保持合同执行者,不接入该外层决策能力。 - Canonical 输入:`runtime_execute` 不允许模型提交 task 参数,真正入队始终使用用户原始消息,避免模型改写时丢失否定、范围和验收条件。非原生 tool Provider 使用同构严格 JSON envelope 适配;模型只能提出 intention,不能选择 runId、越过权限或直接执行项目副作用。 -- 并发与 Runner:`SwarmChat` 恢复为 External Runner 写入口,启动前必须显式使用项目外 AppData。已有 active Goal 或 busy Runtime 时,新输入只进入同 run durable steer;空闲 direct reply 的 user / assistant 在 Agent Session lane 内成对落盘。`/resume` 是显式控制命令,不能再由“继续”等字符串特判。 +- 并发与 Runner:`SwarmChat` 恢复为 External Runner 写入口,启动前必须显式使用项目外 AppData。已有 active Goal 或 busy Runtime 时,新输入只进入同 run durable steer;空闲 direct reply 的 user / assistant 在 Agent Session lane 内成对落盘。恢复扫描只能由显式恢复入口(`--agent-resume`)触发,不能再由“继续”等字符串特判。 - 扩展边界:首版 interaction capability 由一个定义同时派生工具名、描述、schema 和 dispatch kind,作为后续统一 Tool Registry 的窄入口。现有 Runtime Store、Tool Host、Goal、delegation、sandbox、revision、verification、finalization 和 exactly-once 保持自研且不迁入 Prompt 或 Skill;本轮不引入 Pi Node sidecar,也不宣称已完成全量工具 registry、PromptSection 或 Cargo crate 拆分。 - Provider 兼容:真实 OpenAI-compatible smoke 发现部分网关会在纯文本回复中返回 `tool_calls: null`;`platform-llm` 将该字段按缺省空列表解析,并保留真实工具调用数组语义。 - 验证:interaction parser `7/7`、swarm CLI `39/39`、Runner/config 门禁回归和 `platform-llm` null-tool-calls 回归通过。隔离 AppData 的真实 Provider 连续验证了身份直接回复、否定执行的架构解释、模型选择 `project_location` 和模型选择 `runtime_execute`;执行轮产生 `[已投递]` 后以 `turn.report outcome=settled`、busy/pending/reconciliation 均为 `0` 收束。 @@ -8222,6 +8223,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 时序:steer durable 入队后不先通知 Runner。`false` 或判定失败才调用只唤醒的 `runtime.steer`;`true` 直接调用 `runtime.interrupt_for_steer_decision`。后者必须读取已持久化判定,并只中断 `appliedSteerCursor < steer.sequence` 的旧 Provider;新规划 Provider、工具和外部副作用不可被误杀。 - 失败:判定调用、协议解析或持久化失败时公开回复“继续当前任务”,在下一安全边界应用 steer,绝不退化为默认中断。External Runner 与本地进程内执行保持同一语义。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.53,以及 `docs/project-memory/shared-memory/pitfalls.md` 的“Supervisor steer 不能只有内部排队事件”。 +- 2026-09-23 更新(已被「退役AGC项目对话斜杠命令与终端swarm chat入口」取代):`runtime.interrupt_for_steer_decision`、`steer_decision` LLM 判定链与 `steer_game_creator_agent_runtime_task` 命令已整体删除;steer 仍 durable 入队,但只保留 `runtime.steer` 唤醒路径,不再存在条件中断旧 Provider 的分支。 ## 2026-08-10 普通图片废弃 assetKind 的迁移与写入门禁 @@ -8433,6 +8435,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。 - 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。 - 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。 +- 补充(2026-09-23):上面「容器 resize 期间保留内容尺寸」只对**真比容器高 / 宽**的页面成立。上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回。否则运行画面被放大一次(例如「全屏预览」)就会把画布钉在那个尺寸上,退出后仍按全屏比例缩进小容器,且 iframe 视口不变 ⇒ 桥不再上报 ⇒ 永远回不去。判据与回归用例见 2026-09-23 两条条目(`resolveLocalGamePreviewFitLayout` 的回退分支与 `returns the fitted iframe to the container after the host viewport shrinks`)。 ## 2026-08-23 Direct Codex 显式重生成与切片一等资源 @@ -8445,7 +8448,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 权限边界:开放的是 `regenerate / registered resources / playtest` 等产品语义,不是原始最高权限。`regenerate` 只由当前请求最新一条原始 User 消息授权并绑定客户端稳定 `clientTurnId`;模型参数、MCP 自动批准和缺失 clientTurnId 都失败关闭。授权输入先对完整原文做 Unicode NFKC 与撇号规范化,随后整串必须完整匹配审核过的独立立即执行指令,只允许句号/感叹号收尾;不得剥离引号、方括号或代码片段,动作前后也不得携带 brief、条件、否定、选择、确认、费用、延迟或其它文本。复杂风格需求先单独描述,再由下一条独立确认消息授权,不能用开放式 deny 词表推断付费同意。同一进程重复水合相同 stable turn 时,“回合仍在运行”只作为非终态占用提示,不得以该 turn 的稳定 assistant messageId 持久化并覆盖原执行结果。DirectProject 的 cwd、sandbox writable root 与文件批准根只允许 canonical 且非 symlink/reparse point 的真实 `game/`,canonical 项目根的原生 OS 路径字节和权威 manifest `projectId` 经域标签及独立长度前缀编码后共同绑定连接池与 thread 身份;项目根、`assets/`、`.agent/` 不可写,网络关闭,命令、MCP 扩权和额外权限批准全部拒绝。受控 `agc_tools` 只在客户端内部从同一真实 `game/` cwd 反查已校验的 canonical 项目根,不把项目根加入 Codex writable roots。Codex 不获得任意 Tauri invoke、Token/Key/Cookie;`resources` 也只投影稳定身份与相对路径,不返回 prompt、provider route、URL 或绝对路径。 - Direct 恢复 claim:同一 App 实例重复水合相同 stable turn 并收到“仍在运行”时,必须释放该 `projectPath + clientTurnId` 的恢复 claim,且不得写稳定 assistant 终态。后续显式刷新对话可按原身份重新读取或续跑;不新增无界自动重试。 - 严格图集崩溃收口:workflow 在严格图集调用前先持久化 `strictSpritesheetPending` 并冻结底层严格事务覆盖的九项旧合同身份;旧路径可精确冻结为缺失。Provider 完成结果先绑定原 retained stage ledger。恢复在同一项目锁内对账严格事务;只有新九项合同、规范图/背景图替换锚点与 retained spritesheet result 三者一致才补写 `completed`,旧九项合同才允许补偿。旧合同判定、写 `compensating`、恢复两项素材与登记、回读和清锚点必须在同一项目锁内,重启已有 `compensating` 也重新判定;第三种混合、漂移或 foreign result 状态进入 reconciliation。不能在主图集与四切片已整体提交后仍按两文件 rollback 制造混合包;若中断前阶段告警尚未进入 durable completed result,恢复结果追加“原阶段告警无法完整重放”的明确 warning,不静默清空。 -- Direct 对话恢复从新到旧扫描全部合法 User 回合,遇到较新已回答回合继续向前,不得丢失更早未回答回合。成功返回时 Rust 已先持久化 assistant,前端冗余 append 失败也不得重跑 Provider;普通错误终态的显式 append 失败后,恢复 claim 必须保持到 React fallback writer 对同一稳定 assistant messageId 的写入明确成功或失败,不能在 writer 尚在途时按旧 `/history` 快照重跑。fallback 成功后释放 claim;fallback 失败时跳过该 writer 的无界迟到重试并释放 claim,后续显式 `/history` 才可复用原稳定 `clientTurnId`。终态收敛后删除 claim,避免长会话无界增长。 +- Direct 对话恢复从新到旧扫描全部合法 User 回合,遇到较新已回答回合继续向前,不得丢失更早未回答回合。成功返回时 Rust 已先持久化 assistant,前端冗余 append 失败也不得重跑 Provider;普通错误终态的显式 append 失败后,恢复 claim 必须保持到 React fallback writer 对同一稳定 assistant messageId 的写入明确成功或失败,不能在 writer 尚在途时按旧会话快照重跑。fallback 成功后释放 claim;fallback 失败时跳过该 writer 的无界迟到重试并释放 claim,后续显式重新加载对话才可复用原稳定 `clientTurnId`。终态收敛后删除 claim,避免长会话无界增长。 - 正式资源提交结算遵守同一顺序:阶段三 commit 成功后先持久化 `asset-commit-settlement-pending`,恢复器幂等补齐 `asset-durable-committed` 公开投影与 staging revision,再发布私有终态;公开投影已经存在时不得重复增加草稿 revision。恢复必须把私有回执与阶段三 commit ledger、transaction journal、manifest 资产和事件 payload 的完整身份绑定,任一错配都保留 pending 并失败关闭。回归同时覆盖三个 durable write cut,以及私有回执、commit ledger、journal 错配。 ## 2026-08-24 AGC Direct 抠图语义工具 @@ -8569,7 +8572,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 应用级日志持续写入 Tauri AppData 并滚动;报告系统完全忽略项目 `.agent/logs`、源码、prompt、配置、项目产物和截图。用户只可补充文字描述。 - 用户点击独立“报告问题”面板并确认后,批量提交当前进程事件和可取消的脱敏应用日志;失败只允许当前进程手动再次提交。 - 上传接口为登录态 `/api/error-reports`,后台新增 error-reports Tab、专用文件化诊断包、状态与受控下载;管理员查看/下载进入审计链路。 -- `/bug-report` 仅作为打开该面板的快捷入口,追加简短提示,不再生成包含项目、run 或截图口径的缺陷模板。 +- “报告问题”入口只打开该面板并追加简短提示,不再生成包含项目、run 或截图口径的缺陷模板。 - 2026-08-31 追加:事件 DTO 精简为 `eventId/fingerprint/source/message/stack/occurredAt/count`,提交请求携带 `submissionId` 做幂等。归档固定为 `events.jsonl`,服务端使用 `agc/error-reports/v1/{batchId}.zip` 私有 OSS key;元数据只保留 batch、用户、状态、大小、SHA-256 和 OSS key,事件正文/说明/日志从归档读取。OSS 不可用或上传失败时不写数据库,客户端可重新提交。 - 2026-09-01 追加:`application.log` 不再写结构化错误事件;Rust `app_log!` 和 WebView console 都写入普通文本 raw log,结构化事件仅保留在当前进程内,提交时才生成 ZIP 内的 `events.jsonl`。 - 2026-09-01 review 收口:错误报告修复详情请求竞态、下载 anchor 生命周期、客户端采集脱敏/指纹降级与 4xx 噪声、用户级幂等隔离、`agc` 私有 OSS 前缀越权、日志读取链接检查、ZIP 同名日志和元数据/归档清理一致性;同步在 `review.txt` 标注仍需产品/运维决定的架构项。 @@ -8630,10 +8633,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-31 LLM Router 独立账号与后置扣费修订 -- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `taonier` 分组;发现旧 Token 为其它分组时先更新为 `taonier`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 2026-09-23 调整:Token/API Key 分组由 `default` 改为与 Router 用户同组的 `taonier`;创建后无条件 PUT、登录恢复同样 PUT,只要 Token 分组不是 `taonier` 就纠正回来(常量见 `external_api_keys.rs` 的 `LLM_ROUTER_TOKEN_GROUP`)。该契约要求 Router 侧 `taonier` 分组已挂载所需模型与套餐,分组缺模型时会失败为 `model_not_found`;存量已签发且分组为 `default` 的 Key 会在该账号下次 provisioning / 登录恢复 / 显式准备 Key 时被纠正。 - 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `llm_router_account` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。 - AGC 调用固定为客户端 access token -> api-server -> Router。计费读取账号 `used_quota`,每 50000 quota 扣 1 泥点,美元数值乘 10、不乘汇率。首次模型调用前以当前累计额度完整建立免追扣基线,之后调用前后同步;扣钱包、写 `llm_router_consume` 流水与推进已结算额度同事务完成。小数和余额不足未支付部分继续累计,失败或重复同步不推进已结算额度,不使用本地 WAL 或余数队列。完整合同见 `docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md`。 -- AGC 状态面收口:Tauri `check_game_creator_llm_config`、`/llm-status` 与 `/llm-routes` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 +- AGC 状态面收口:Tauri `check_game_creator_llm_config` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 ## 2026-09-01 LLM Router provisioning 环境隔离与测试门禁 @@ -9277,3 +9281,51 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}` 与 `apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`。 - 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。 - 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx` 与 `DirectChatTurnState` 注释里的 A 项)。 + +## 2026-09-23 后台 Dashboard「消耗泥点」改为对冲退还后的净消耗 + +- 背景:Dashboard 的「消耗泥点数」只累计负向消费流水,生成失败退还、精选审核返还和 LLM Router 正向冲正都不参与抵扣,运营看到的「总消耗」明显高于用户实际花费(用户现场反馈)。 +- 决策:`GET /admin/api/dashboard` 的 `consumedMudPoints` 改为净消耗。先按北京时间业务日累计 `asset_operation_consume` / `llm_router_consume` 的负向流水绝对值,再用同期 `asset_operation_refund` 正向流水和 `llm_router_consume` 正向冲正流水按日抵扣:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的退还丢弃。因此每日净额非负,区间合计严格等于「消耗 − 退还」。新增 `refundedMudPoints` 与「退还泥点」趋势图,前台「消耗泥点数」卡旁并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出,不把退还金额藏进净额。 +- 边界(本次不改):用户详情「历史花费」(`profile_wallet_consumption_total` 投影与手动对账)维持既有「退款不冲减」决策,仍只累计负向消费流水;若要改成净额,必须单独走投影语义 + 对账口径变更,不能顺手改这一处。充值退款追回、余额重置、赠送和 hold 继续不计入消耗。 +- 影响范围:`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminDashboardPage.tsx`、对应用例与 `docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server dashboard_consumption`(新增 4 条净额用例全绿);`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin`(136 passed / 0 failed / 1 ignored);`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(218 passed);`npm run check:encoding`、`git diff --check`。 + +## 2026-09-23 后台充值订单实付口径、发放泥点列、用户累计充值与兑换码单位 + +- 背景:充值管理列表与用户详情把订单金额当实付展示,未支付订单也显示非 0 实付;发放泥点挤在「金额 / 泥点」一格或商品列小字里;用户详情看不到该用户累计充值额度;兑换码页的奖励数字没有单位,运营无法判断是元还是泥点。 +- 决策(实付只有支付过的订单才有):`AdminRechargeOrderEntryPayload` 新增 `paidAmountCents`,由 api-server 按订单 `paid_at` 是否存在判定——存在才等于订单金额,未支付 / 已关闭 / 已过期固定为 0。后台前端实付列显示 `未支付`(并附订单金额小字),退款面板「订单实付」读同一字段;订单金额 `amountCents` 不再被当作实付。 +- 决策(发放泥点独立成列):充值管理列表的表头由「金额 / 泥点」拆成「实付」与「发放泥点」两列,用户详情充值订单表同样新增「发放泥点」列,商品列只保留商品名;未支付订单发放为 0 泥点,与实付口径一致。 +- 决策(累计充值由后端算):用户详情新增 `cumulativeRechargedCents`,api-server 按 `user_id` 读取 `profile_recharge_order`、只累加 `paid_at` 存在的订单金额(退款不回减),单次读取上限 500 行;读取失败或命中上限返回 `null`,前端显示「读取失败」,不用用户详情最多 20 条订单在 BFF 或前端近似重算。此次只新增 BFF 字段,未改 SpacetimeDB 表结构与 procedure。 +- 决策(兑换码奖励是泥点):兑换码 `rewardPoints` 是奖励泥点(兑换成功按 `redeem_code_reward` 流水进钱包),后台输入标签改为「奖励泥点」、列表列头与单元格都带「泥点」单位。 +- 影响范围:`server-rs/crates/api-server/src/{admin.rs,admin_recharge.rs}`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/{AdminRechargeOrderPage.tsx,AdminRedeemCodePage.tsx}`、`apps/admin-web/src/components/AdminUserDetailDialog.tsx`、对应三个用例文件与后端架构数据契约文档。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server cumulative_recharge`(2 条新用例)与 `cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(220 passed),其中三个定向文件 33 passed(新增「未支付订单不显示实付金额,发放泥点单独成列」与「累计充值读取不到时展示未知,不用订单列表近似」)。 +- 边界(未验证):未连真实生产库核对历史订单的累计充值数值,也未跑真实栈 API smoke。 + +## 2026-09-23 后台表查询枚举列按 schema 展示变体名,不再只看数值 + +- 背景:后台「表查询」页的枚举列直接落回通用解码,只有 `profile_recharge_order` 的 `kind` / `status` 做了硬编码映射,其余 20 多张表的枚举列(如 `profile_wallet_ledger.source_type`、`tracking_event.scope_kind`、`profile_membership.tier`)都显示成 SATS 原始数值,运营在后台看不到枚举值。 +- 决策(按 schema 自动解析):api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 SpacetimeDB schema 的 `typespace.types` 与表的 `product_type_ref`,对每个「`Sum` 且所有变体都是单元变体」的列生成「列名 → 按变体索引排列的展示名」;变体名归一到 snake_case,与后台既有枚举字符串(`points` / `paid` / `asset_operation_consume`)同口径,因此原硬编码映射的展示结果不变,新增表与新增枚举列不再需要改代码。 +- 决策(边界):`Option<枚举>` 列单独标记为可空(`[0, [索引, []]]` 出变体名、`[1, []]` 仍是空值),`Option<普通值>` 与带载荷的 Sum 不参与映射,继续走通用解码(`Some` 解包、`None` 归空、时间戳原样透出);单变体枚举同样要出名字。映射同时作用于 `cells` 与 `raw`,关键词搜索、结构化筛选和稳定排序都按展示名生效。schema 读取失败时表查询以「表不存在」失败,不会退回展示数字。 +- 影响范围:`server-rs/crates/api-server/src/admin.rs`(新增 schema 解析与 `build_admin_database_enum_labels`,删除 `normalize_admin_database_known_enum`,`parse_admin_database_table_rows_sql_response` / 行构建与归一化改为接收枚举映射)、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md`。前端与 DTO 不变。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin::tests`(86 passed,含新增 `admin_database_enum_labels_come_from_schema_variants` 与改写后的充值订单枚举用例);用本地 dev schema 逐表回放同一算法,85 张表里 32 个枚举列全部解析出展示名、0 个残留;`cargo check -p api-server`、`cargo fmt --all --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 +- 边界(未验证):没有对真实 HTTP 表查询响应做端到端比对(本地 dev api-server 仍是改动前二进制,未重启)。 + +## 2026-09-23 游戏发行入口改为服务端按模板派生:管理员不再手填地址 + +- 背景:游戏审核通过要求管理员手填绝对 HTTPS `entryUrl`,现场出现「不知道该填什么、随手填一个外部站点也能过校验」的风险;而每游戏独立来源本身完全能由 gameId 推出,人工输入没有增加任何判断。 +- 决策(唯一口径):部署侧用 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE` 配置带 `{gameId}` 占位符的模板(生产形如 `https://{gameId}.games.<发行域名>/`);审核通过时 `api-server` 读版本取 gameId、替换模板、再走原有绝对 HTTPS / 无凭据 / 无 query / 无 fragment 校验后写入公开投影。后台审核请求 DTO 删除 `entryUrl`,页面不再渲染输入框,也不要求二次确认。 +- 决策(失败关闭):模板缺 `{gameId}`、生产未配置模板、gameId 含非主机安全字符或派生结果非法时,审核通过直接失败,不回落主站、内网或任意外部地址;非生产未配置模板时回落 `http://127.0.0.1:/api/game-distribution/releases/{gameId}/`,保持免 TLS 的本地内嵌游玩验证。 +- 边界:`entryUrl` 仍是公开投影字段,只是改由服务端写入;审核请求摘要不再包含它,表结构与版本回读不变;模板变更只影响之后新通过审核的版本,历史版本已冻结的 `entry_url` 不改写。 +- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`apps/admin-web/src/{api/adminApiTypes.ts,api/adminApiClient.test.ts,pages/AdminGameDistributionReviewPage.tsx,pages/AdminGameDistributionReviewPage.test.tsx}`、`scripts/check-game-distribution-media-e2e.mjs`、`deploy/{nginx,env,container}`、平台与运维主规范、发行里程碑实施计划。 +- 验证:`cargo check -p api-server`、`cargo test -p api-server game_distribution`(31 passed)、admin-web 定向 Vitest(19 passed)与 `apps/admin-web` typecheck、`npm run check:release-origin-config`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check` 全部通过;真实栈端到端(真实 OSS + SpacetimeDB + 审核通过)未在本轮复跑。 + +## 2026-09-24 游戏发行入口改为平台同源路径:取消发行域名与部署模板变量 + +- 背景:每游戏独立来源要求 `*.games.<域名>` 通配 DNS 与通配 TLS,一直未在任何环境落地,dev / release 审核通过直接报「发行来源未配置」;同时线上 SPA 白名单缺少 `games` 系列路由,`/games`、`/games/detail`、`/games/play` 在真实域名上全部 404。运行隔离实际由 iframe `sandbox="allow-scripts"` 的不透明来源承担,不需要独立 origin 兜底。 +- 决策(唯一口径):发行入口固定为平台同源路径 `/games/{gameId}/`。审核通过时 `api-server` 按 gameId 派生该相对路径写入公开投影,不再读取 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`;`AppConfig` 字段与两份部署 env 示例一并删除。dev / release / 预览环境口径一致,不再需要发行域名、通配 DNS 或通配 TLS。 +- 决策(边缘):`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 三份模板内联同一条同源发行入口 location,把 `/games//` 与 `/games//` 转发到发行网关,转发前清空 `Cookie`;正则整体必须加双引号,否则 `{32}` 会被 nginx 当块定界符。SPA allowlist 补齐 `components`、`design-system`、`games`、`games/detail`、`games/mine`、`games/play`、`games/publish`。 +- 决策(客户端):`normalizeGameEntryUrl` 接受相对路径与同源发行路径,按当前 origin 解析成绝对地址后交给 iframe;无尾斜杠会归一化补齐。同源非发行路径继续拒绝,非当前源的绝对 https 继续兼容历史数据。 +- 决策(退役):删除 `deploy/nginx/genarrative-release-origin.conf`、`scripts/check-release-origin-config.mjs` 与 `npm run check:release-origin-config`;独立来源不再作为上线门禁。 +- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。 +- 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。 +- 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 54c8f7deb..2efa4c511 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,29 @@ # 踩坑与排障记录 +## 策划回复的重复终态不能重新启动伪流式 + +策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。 + +## 2026-09-23 弹窗打开时自绘标题栏的最小化 / 最大化 / 关闭静默失效 + +- **现象**:AGC 打开「发布到游戏广场」面板(以及其它任何弹窗)后,右上角三个窗口按钮点了没有任何反应,拖拽标题栏也不能移动窗口;关掉弹窗立刻恢复。标题栏看着完全正常,遮罩也明显只压住了下面的工作区,所以很容易误判成「按钮自己坏了」或 Tauri 窗口 API 挂了。 +- **原因**:标题栏在模态之外,但它是窗口边框。`ThemedModal` 用的 `focus-trap-react` 在 **document 捕获阶段**监听 `mousedown`/`touchstart`/`click`:模态外的点击一律 `preventDefault()`,`click` 还会 `stopImmediatePropagation()`。React 的监听挂在 document 内的根容器上,捕获阶段就被掐掉的 `click` 永远到不了 React,于是既不报错也不执行 —— 与「焦点陷阱吞掉模态外点击」是同一类问题(见 2026-09-20 发布面板焦点陷阱那条)。另有一条独立的同类缺陷:`.app-update-overlay` 用 `inset: 0`,把标题栏真的盖住了,更新弹窗期间按钮被遮罩挡住。 +- **处理(现行口径)**:① 全屏弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`),不得用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容点击继续被拦;③ 新增全屏弹层时把类名补进 `apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts` 的清单。 +- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/themedModal.test.tsx apps/ai-game-creator-shell/tests/WindowChrome.test.tsx apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts`(标题栏点击放行、工作区点击仍被拦、7 个全屏弹层都在标题栏下方);两个新增用例去掉修复后确实失败,确认能守住这条约定。 +- **关联**:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`。 + +## Direct 宿主继续请求不能重发原始用户条目 + +原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 + +## 最近项目一次失败会被钉成终态 + +- **现象**:AGC 卡住一次后,项目列表每一行都显示「检查失败 + 待识别」,首页「最近项目」变成「暂无最近项目」;现场在后端恢复后逐条复跑 `inspect_local_project_directory`(8 个项目)全部 0ms 成功,界面仍然全红(issue #490)。 +- **原因**:单次检查的 5s 超时被吞成 `null` 写入状态表,而刷新用的增量投影是 `next[path] = current[path] ?? null`,把失败结果原样搬进下一轮;effect 只依赖列表与刷新计数器,既没有重试也没有 focus/visibility 重查。于是一次抖动会让整张列表永久停在失败态,首页同时被 `canOpen` 过滤清空。 +- **处理**:失败就地重试一次(300ms);失败结果不进新投影;一轮仍有可重试失败时按 15s / 45s / 120s 重跑整表(上限 3 次,失败集合变化即重置预算)。提权/权限类失败(`DACL`、`权限`、`error 5`、`安全对象不属于当前用户`、`特权`、`1300`、`AGC ACL 提权修复未成功`)按不可重试处理,在用户主动打开/新建项目或重命名刷新之前跳过——否则「提权被拒 → 300ms 后重试」会自己驱动 UAC 反复弹窗。 +- **验证**:`apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx` 的三条用例(「单次失败就地重试」「失败不跨轮保留」「提权类失败不重试」),改前代码上前两条必挂;`tests/appSurface/home.suite.ts` 的失败态断言改为等待最终状态。 +- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts`、`src-tauri/src/config.rs`。 + ## 策划 Agent 提示词中的相对路径不要当作内部实现删去 `project/...` 是 Agent 读写策划工作区的目标路径,`resources/...` 是查找内置分册、模板和例子的资源定位;即使阶段上下文也注入了同一产物路径,提示词里的路径仍是 Agent 需要的契约。清理宿主实现细节时不要误删这些相对路径,具体用法见[策划 Agent 路径说明](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#6-阶段与提示词注入)。 @@ -829,6 +853,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 并发边界:steer 入队、Runner `runtime.steer` 通知都不得直接触发 Provider interrupt。Codex app-server 的判定使用独立节点,不能等待主节点 turn 锁;判定为 true 后也只能中断 `appliedSteerCursor < steer.sequence` 的旧 Provider 请求,已经消费该 steer 后启动的新请求不可被误杀。已经开始的工具和外部动作不强杀,完成 observation 后再消费 steer。 - 验证:真实 mock LLM 回归必须覆盖状态询问回复且 `interruptCurrentProvider=false`;持久重放只保留一条语义回复;steer 入队后旧 Provider 继续运行,判定为 true 后才中断;新规划 Provider 的 cursor 已包含该 steer 时即使旧判定为 true 也不能中断。前端同秒多条消息保持“用户补充 → 判断提示/语义回复”的关联顺序。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs`、`apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs`、`apps/ai-game-creator-shell/src/features/agent-runtime/model.ts`。 +- 2026-09-23 更新:`agent/interaction.rs` 与 `steer-decision` LLM 判定链已整体删除,本条中「判定 LLM / `interruptCurrentProvider` / 只中断旧 cursor」的实现细节仅作历史记录;现役语义是 steer durable 入队后由 `runtime.steer` 唤醒,并在下一安全边界应用。 ## Jenkins 异步备份不能用 nohup 脱离作业 @@ -4435,9 +4460,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:`npm run agc:test:chat` 在进入聊天前报“Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启”;正式客户端仍能看到自己的待确认或委派任务,重复执行测试也持续失败。 - 原因:Runner 复用身份同时绑定协议版本和当前可执行文件 SHA-256。`cargo run` 重新编译后的 debug 二进制与正在运行的 release Runner 指纹不同,而旧入口只隔离测试项目、仍把正式 AppData 直接传给 CLI,于是测试会向正式 endpoint 发升级探测。正式 Runner 有 pending action、Provider sidecar、进程会话或非终态队列时拒绝退出是正确的安全门禁,不能通过强退或放宽 idle 判定让测试通过。 -- 处理:正式 AppData 只作只读配置来源。每次人工测试在系统临时根创建 `0700` sentinel 隔离目录,只把主配置和可选 local overlay 私有复制为 `0600` 普通文件;不得复制 endpoint、lock、`.previous` 或其它状态。LLM 检查与 Swarm CLI 全部使用隔离目录。退出时通过内部 CLI 请求 `runner.shutdown_if_idle`,确认隔离 endpoint 消失后才删除配置;仍有任务或无法确认退出时同时保留测试项目和隔离配置并报告路径。正式 Runner 的 PID、bootId、端口和 executable fingerprint 必须保持不变。 +- 处理:正式 AppData 只作只读配置来源。每次人工测试在系统临时根创建 `0700` sentinel 隔离目录,只把主配置和可选 local overlay 私有复制为 `0600` 普通文件;不得复制 endpoint、lock、`.previous` 或其它状态。LLM 检查与端到端测试入口全部使用隔离目录。退出时通过内部 CLI 请求 `runner.shutdown_if_idle`,确认隔离 endpoint 消失后才删除配置;仍有任务或无法确认退出时同时保留测试项目和隔离配置并报告路径。正式 Runner 的 PID、bootId、端口和 executable fingerprint 必须保持不变。 - 验证:单元测试覆盖私有 inode、权限、local overlay、禁止复制 endpoint/lock/备份、符号链接拒绝、sentinel 清理和 endpoint 存在时拒绝删除;真实 smoke 使用隔离 AppData 启动并收束空闲 Runner,前后比较正式 endpoint 身份且确认正式 PID 存活,再检查本轮 `/tmp` 项目和隔离配置均已清理。 -- 关联:`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`、`apps/ai-game-creator-shell/src-tauri/src/runner/client.rs`、`apps/ai-game-creator-shell/src-tauri/src/cli.rs`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner/client.rs`、`apps/ai-game-creator-shell/src-tauri/src/cli.rs`。 ## Swarm 队列 busy 不能直接当成 canonical run 可 steer @@ -4445,7 +4470,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 原因:旧 `runtime_is_busy` 同时包含当前 state 和队列汇总,调用方看到 `task_queue.pending > 0` 后仍从 canonical state 反推 steer、失败扫描和 turn report 的 runId;取消 tombstone 还会让恢复扫描在处理 A 后无条件跳过 B。底层拒绝 terminal steer 和保留 A 的真实失败历史都是正确行为,不能通过放宽门禁或删除历史记录修复。 - 处理:保留 queue busy 用于 Runner 存活判断,另由 Runtime 协议层提供唯一 steerable 判定。start mutation 返回实际 `acceptedRunId`,CLI 以它建立不可变 turn baseline;失败、reconciliation、用户交互、收束和报告只观察该 run。canonical 已推进到后续 run 时从 task journal 读取目标 run 的最终记录。旧 cancelled canonical 若仍有 pending 且无 running,恢复扫描跳过旧 run 的 pending action 恢复,直接启动队首 pending。若输入与已落盘 pending task 及最后一条 user 消息相同,则只观察原 run。Goal 路径也必须核对同一 Agent、Session、runId、Run Profile 和 steerable 状态。连续 run 的回复必须按确定性 finalization message ID 过滤;历史 specialist 失败必须以 `(agentId, runId)` 为键读取完整 journal,不能让滞后的非失败 state 删除 journal 已记录的失败;报告计数也不能退回 `recent_tasks` 的 12 条窗口。 - 验证:构造 cancelled run A、保留 A cancel tombstone、pending run B 和单份已落盘用户消息,证明恢复后 B 进入 running 并完成且 conversation 不重复。另覆盖观察 B 时忽略 A 及 A 子任务失败、观察 A 时仍正常失败、B 完成后 canonical 已推进到 C 仍可从 journal 收束 B、`turn.report.parentRunId` 始终为 baseline,以及 expected Goal runId 不一致时不选中目标。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`。 ## 2026-07-25 autonomous-game-build 不能只检查 game/index.html 就宣称正式项目完成 @@ -4460,9 +4485,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 终端真实测试不能混用配置参数、stdin EOF 和持续预览 -- 现象:开发者第一次运行 `agc:test:chat` 时必须先打开 GUI 才能配置 Provider;无 TTY 的脚本可能在 stdin 立即 EOF 后零任务成功退出,或者任务已经完成却继续等待 preview 的 `Ctrl+C`,导致自动化看似卡死。若为图省事增加 `--api-key`,密钥还会进入 shell history 和进程列表。 +- 现象:开发者第一次运行真实 E2E 聊天测试时必须先打开 GUI 才能配置 Provider;无 TTY 的脚本可能在 stdin 立即 EOF 后零任务成功退出,或者任务已经完成却继续等待 preview 的 `Ctrl+C`,导致自动化看似卡死。若为图省事增加 `--api-key`,密钥还会进入 shell history 和进程列表。 - 原因:把首次配置、手工多轮聊天、单轮真实测试和持续试玩当成同一个交互生命周期;同时让 GUI 与 CLI 使用不同配置入口,或把 EOF 既解释为“提交当前需求”又解释为“没有输入”,会让退出语义随调用环境漂移。 -- 处理:GUI 与 `npm run agc:config` 共用系统 AppData `game-creator.config.json`,终端隐藏输入 API Key 并禁止 `--api-key`;更新时保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置,POSIX 权限维持目录 `0700` / 文件 `0600` 并原子替换。显式 `--config-dir` 必须以 `world.genarrative.ai-game-creator` 为独立叶目录,不能让向导对 `/tmp`、AppData 根或共享目录整体 chmod / 重建 DACL。隐藏输入调用 `stdin.resume()` 后必须记住原 pause 状态,在成功、取消、异常和 `SIGINT / SIGTERM / SIGHUP` 路径恢复 raw mode 并 `pause()`,信号恢复后重发;只移除 `data` listener 会让 `--configure-only`、配置检查失败或 Ctrl+C 保持活动 stdin。缺配置时仅 TTY 人工会话可询问进入向导,非 TTY 立即失败并提示配置命令。 +- 处理:GUI 与 `npm run agc:config` 共用系统 AppData `game-creator.config.json`,终端隐藏输入 API Key 并禁止 `--api-key`;更新时保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置,POSIX 权限维持目录 `0700` / 文件 `0600` 并原子替换。显式 `--config-dir` 必须以 `world.genarrative.ai-game-creator` 为独立叶目录,不能让向导对 `/tmp`、AppData 根或共享目录整体 chmod / 重建 DACL。隐藏输入调用 `stdin.resume()` 后必须记住原 pause 状态,在成功、取消、异常和 `SIGINT / SIGTERM / SIGHUP` 路径恢复 raw mode 并 `pause()`,信号恢复后重发;只移除 `data` listener 会让 配置检查失败或 Ctrl+C 保持活动 stdin。缺配置时仅 TTY 人工会话可询问进入向导,非 TTY 立即失败并提示配置命令。 - Windows 密钥复制:`mode: 0o600` 和 POSIX `chmod` 在 Windows 上不能代替 DACL。隔离 AppData 目录必须先设置仅当前用户、禁止继承的 DACL;目标配置文件先以空文件创建并收紧 DACL,之后才允许把 API Key 字节写入。先 `copyFile` 再依赖 Rust 只读检查或事后收紧会留下密钥暴露窗口,也可能因继承 ACL 不满足 Runtime 合同而在首次 `--llm-status` 失败。 - Windows PowerShell 参数:不要把 DACL 目标路径和目录标记直接追加在 `powershell.exe -Command