From ba45ca7048da786ee01b80a3fbeb306427a4520a Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 23 Sep 2026 12:59:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=9C=A8=20DirectProject=20=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E5=8F=8D=E9=A6=88=E4=B8=8E=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布动作即时回显权限检查与构建打包状态 - DirectProject 对话渲染项目权限确认卡片并回传确认与取消 - 权限拒绝、取消和查询失败统一回显到项目对话 - 新增发布反馈回归用例并记录排障口径 --- apps/ai-game-creator-shell/src/App.tsx | 41 ++- .../chat/DirectProjectChatView.tsx | 27 ++ .../tests/gamePublishFeedback.test.tsx | 272 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 + 4 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 30427f1be..d3f1a814a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1145,6 +1145,7 @@ export function App({ ...current, { role: 'assistant', text: message }, ]); + directProjectChatRef.current?.announce(message); return true; } if (!policyView.policy.confirmCommands.includes(commandId)) { @@ -1155,6 +1156,7 @@ export function App({ ...current, { role: 'assistant', text: readyMessage }, ]); + directProjectChatRef.current?.announce(readyMessage); return true; } @@ -1180,6 +1182,7 @@ export function App({ ...current, { role: 'assistant', text: message }, ]); + directProjectChatRef.current?.announce(message); return true; } catch { return false; @@ -1229,7 +1232,11 @@ export function App({ announcePublishMessage('先打开一个项目再发布'); return; } + // 权限查询也可能慢或挂住,先回一条即时反馈,别让按钮看起来没反应。 + announcePublishMessage('正在检查发布权限…'); const runExport = async () => { + // 导出可能包含构建步骤,再回一条即时反馈。 + announcePublishMessage('正在构建并打包试玩包,请稍候…'); try { const result = await invoke( 'export_local_project_package', @@ -1251,16 +1258,26 @@ export function App({ ); } }; - const queued = await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'project.export_package', - nextProjectPath, - '导出试玩包并打开「发布到游戏广场」面板。', - '导出试玩包需要确认,确认后继续。', - () => void runExport(), - ); - if (!queued) { - await runExport(); + try { + const queued = await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'project.export_package', + nextProjectPath, + '导出试玩包并打开「发布到游戏广场」面板。', + '导出试玩包需要确认,确认后继续。', + () => void runExport(), + ); + if (!queued) { + await runExport(); + } + } catch (error) { + // 权限查询失败也必须回话:onClick 的 Promise 没有 catch 时用户只会看到 + // 「点了没反应」,这里把它收敛成聊天里的可读错误。 + announcePublishMessage( + `发布前权限检查失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); } } @@ -1368,6 +1385,7 @@ export function App({ ? '已取消对比项目 checkpoint' : '已取消导出本地试玩包'; setWorkspaceStatus(status); + directProjectChatRef.current?.announce(status); } if (pending.commandId === 'project.create') { setWorkspaceStatus('已取消'); @@ -2320,6 +2338,9 @@ export function App({ onRequestGamePublish={ gamePublishAllowed ? requestGamePublish : undefined } + onCancelConfirmation={cancelUiCommandConfirmation} + onConfirmConfirmation={confirmUiCommand} + pendingConfirmation={pendingUiConfirmation} projectPath={localProject?.projectPath ?? projectPath ?? null} ref={directProjectChatRef} /> diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index a067a1a01..2787dab0c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -10,6 +10,7 @@ import { import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; +import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; import { directCodexUserItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; @@ -80,6 +81,13 @@ export type DirectProjectChatViewProps = { ensureConversationWriteAllowed: DirectProjectConversationWriteGate; /** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */ onRequestGamePublish?: () => void; + /** + * 工作台壳入队的项目权限确认:聊天只展示并转发确认/取消, + * 策略读取、拒绝判定和确认后的动作仍由工作台壳持有。 + */ + pendingConfirmation?: PendingUiConfirmation | null; + onConfirmConfirmation?: () => void; + onCancelConfirmation?: () => void; /** 工作台壳把运行/预览这类动作结果交给聊天的出口。 */ ref?: Ref; }; @@ -95,6 +103,9 @@ function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) { export function DirectProjectChatView({ onRequestGamePublish, + onConfirmConfirmation, + onCancelConfirmation, + pendingConfirmation = null, projectPath, initialTurn = null, ensureConversationReadAllowed, @@ -242,6 +253,22 @@ export function DirectProjectChatView({ onLoadEarlierHistory={() => void loadEarlierHistory()} onScroll={handleScroll} /> + {pendingConfirmation && + onConfirmConfirmation && + onCancelConfirmation ? ( +
+ + {pendingConfirmation.commandId} + {pendingConfirmation.detail} + + + +
+ ) : null} + vi.fn(async () => true), +); + +vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../src/services/gameDistributionPublish') + >(); + return { + ...actual, + readGamePublishAvailability: readGamePublishAvailabilityMock, + }; +}); + +const PROJECT_PATH = '/tmp/game-publish-feedback-project'; +const PROJECT_ID = 'game-publish-feedback-project'; + +function createFixtureManifest(): GameCreationAppManifest { + return createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'); +} + +function installTauri( + options: { + exportPackage?: () => unknown; + policy?: ReturnType; + readPolicy?: () => unknown; + } = {}, +) { + const manifest = createFixtureManifest(); + const chatHarness = createProjectChatRuntimeHarness({ + projectPath: PROJECT_PATH, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') return manifest; + if (command === 'get_local_game_project_revision') return { revision: 1 }; + if (command === 'read_project_permission_policy') { + if (options.readPolicy) return options.readPolicy(); + return options.policy ?? emptyProjectPolicy(); + } + if (command === 'export_local_project_package') { + if (options.exportPackage) return options.exportPackage(); + return { + projectPath: PROJECT_PATH, + packagePath: `${PROJECT_PATH}/exports/game.zip`, + packageRelativePath: 'exports/game.zip', + fileCount: 1, + totalBytes: 1, + }; + } + return chatHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke: invoke as never }, + event: { listen: chatHarness.listen as never }, + }; + return { invoke }; +} + +function renderPublishProject() { + return render( + , + ); +} + +async function clickPublish() { + const publish = await screen.findByRole('button', { + name: '发布到游戏广场', + }); + fireEvent.click(publish); + return screen.findByLabelText('陶泥儿项目对话'); +} + +beforeEach(() => { + window.history.pushState({}, '', '/'); + readGamePublishAvailabilityMock.mockReset(); + readGamePublishAvailabilityMock.mockResolvedValue(true); +}); + +function createExportPackageResult() { + return { + projectPath: PROJECT_PATH, + packagePath: `${PROJECT_PATH}/exports/game.zip`, + packageRelativePath: 'exports/game.zip', + fileCount: 1, + totalBytes: 1, + }; +} + +function createConfirmPolicy() { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['project.export_package'], + }, + }; +} + +describe('客户端发布入口的可见反馈', () => { + it('导出成功时先回即时反馈,再回结果并打开发布面板', async () => { + installTauri({ exportPackage: createExportPackageResult }); + renderPublishProject(); + + const surface = await clickPublish(); + await waitFor(() => { + expect(surface.textContent ?? '').toContain('正在检查发布权限…'); + expect(surface.textContent ?? '').toContain( + '正在构建并打包试玩包,请稍候…', + ); + expect(surface.textContent ?? '').toContain( + '已构建并打包试玩包:exports/game.zip', + ); + }); + expect( + await screen.findByRole('dialog', { name: '发布到游戏广场' }), + ).not.toBeNull(); + }); + + it('导出失败时把可读错误写回项目对话', async () => { + installTauri({ + exportPackage: () => { + throw new Error('导出试玩包前需要先生成 exports/README.md'); + }, + }); + renderPublishProject(); + + const surface = await clickPublish(); + await waitFor(() => { + expect(surface.textContent ?? '').toContain( + '导出试玩包前需要先生成 exports/README.md', + ); + }); + }); + + it('策略要求确认时在 DirectProject 里展示确认卡片,确认后继续导出', async () => { + const exportPackage = vi.fn(createExportPackageResult); + installTauri({ exportPackage, policy: createConfirmPolicy() }); + renderPublishProject(); + + const publish = await screen.findByRole('button', { + name: '发布到游戏广场', + }); + fireEvent.click(publish); + + const commandLabel = await screen.findByText('project.export_package'); + const card = commandLabel.closest('.pending-command'); + expect(card).not.toBeNull(); + expect(exportPackage).not.toHaveBeenCalled(); + expect( + within(card as HTMLElement).getByText( + '导出试玩包并打开「发布到游戏广场」面板。', + ), + ).not.toBeNull(); + + fireEvent.click( + within(card as HTMLElement).getByRole('button', { name: '确认' }), + ); + await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); + const surface = await screen.findByLabelText('陶泥儿项目对话'); + expect(surface.textContent ?? '').toContain( + '正在构建并打包试玩包,请稍候…', + ); + expect(surface.textContent ?? '').toContain( + '已构建并打包试玩包:exports/game.zip', + ); + }); + + it('取消权限确认时把取消结果写回项目对话', async () => { + const exportPackage = vi.fn(createExportPackageResult); + installTauri({ exportPackage, policy: createConfirmPolicy() }); + renderPublishProject(); + + fireEvent.click( + await screen.findByRole('button', { name: '发布到游戏广场' }), + ); + const commandLabel = await screen.findByText('project.export_package'); + const card = commandLabel.closest('.pending-command'); + expect(card).not.toBeNull(); + fireEvent.click( + within(card as HTMLElement).getByRole('button', { name: '取消' }), + ); + + const surface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => { + expect(surface.textContent ?? '').toContain('已取消导出本地试玩包'); + }); + expect(exportPackage).not.toHaveBeenCalled(); + }); + + it('确认时策略已改为拒绝,也要把拒绝原因写回项目对话', async () => { + const exportPackage = vi.fn(createExportPackageResult); + let denyOnNextPolicyRead = false; + installTauri({ + exportPackage, + readPolicy: () => { + if (!denyOnNextPolicyRead) return createConfirmPolicy(); + return { + path: '.agent/policy.json', + policy: { + deniedCommands: ['project.export_package'], + confirmCommands: [], + }, + }; + }, + }); + renderPublishProject(); + + fireEvent.click( + await screen.findByRole('button', { name: '发布到游戏广场' }), + ); + const commandLabel = await screen.findByText('project.export_package'); + const card = commandLabel.closest('.pending-command'); + expect(card).not.toBeNull(); + denyOnNextPolicyRead = true; + fireEvent.click( + within(card as HTMLElement).getByRole('button', { name: '确认' }), + ); + + const surface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => { + expect(surface.textContent ?? '').toContain( + '项目权限策略拒绝执行:project.export_package', + ); + }); + expect(exportPackage).not.toHaveBeenCalled(); + }); + + it('权限查询失败时也在项目对话里回显错误', async () => { + installTauri({ + readPolicy: () => { + throw new Error('策略文件损坏'); + }, + }); + renderPublishProject(); + + const surface = await clickPublish(); + await waitFor(() => { + expect(surface.textContent ?? '').toContain( + '发布前权限检查失败:策略文件损坏', + ); + }); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 65695d13b..d269c8c32 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5805,6 +5805,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:① 产品行为保留“导出成功后自动打开面板”(一键发布入口),但受影响的用例必须先用 `findByRole('dialog', { name: '发布到游戏广场' })` 断言面板出现、点「关闭发布面板」再继续后续会话操作;② 给这类“新增自动弹窗”改流程时,先跑一遍相关 `appSurface` 用例,避免只跑新增用例;③ 排查同类“点了没反应”时,先看当前是否有焦点陷阱模态打开,而不是先怀疑事件绑定或状态。 - **关联**:`apps/ai-game-creator-shell/src/App.tsx`(`setPublishPanelOpen(true)`)、`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx`、`apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts`。 +## 2026-09-23 AGC 发布按钮点了没反应:DirectProject 不渲染 workspaceStatus,权限确认也没有挂载点 + +- **现象**:AGC 聊天头能看到「发布」按钮,点击后没有任何提示、面板也不出现;项目权限要求确认时同样静默,用户只能描述为「点了没反应」。 +- **原因**:普通项目固定走 `DirectProjectChatView`,它不渲染工作台状态行,也不渲染 `PlanningChatView` 里那块 `pending-command` 确认卡片;发布提示、权限确认和取消结果原先只写 `workspaceStatus` / `messages`。上游 `f502829fd` 已把发布提示同时 `announce` 回 DirectProject,但确认卡片仍未挂载到 DirectProject;权限查询抛错也没有 catch,确认时策略改为拒绝同样只写工作台消息。结果等待确认、命令被拒、确认后拒绝这几条路径仍表现为「点了没反应」。 +- **处理(现行口径)**:发布相关提示统一走 `announcePublishMessage`,同时写 `workspaceStatus` 和 `directProjectChatRef.current.announce`;`DirectProjectChatView` 增加 `pendingConfirmation` / `onConfirmConfirmation` / `onCancelConfirmation`,在对话列表与输入盒之间渲染与 `PlanningChatView` 同形的确认条;`requestGamePublish` 对权限查询整段兜底并把错误回显到聊天,点击后先回「正在检查发布权限…」,进入导出再回「正在构建并打包试玩包」。 +- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx`(正常发布 / 导出失败 / 确认继续 / 取消 / 确认时拒绝 / 权限查询失败 6 条)与 AGC 全量前端用例通过;`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding`、`git diff --check` 通过。 +- **关联**:`apps/ai-game-creator-shell/src/App.tsx`、`apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx`、`apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx`。 + ## 2026-09-21 受控 Lexical 输入区的回写用被动 effect:滞后渲染的 props 会把用户草稿清空 - **现象**:DirectProject 输入盒里粘贴(或连续输入)长文本,提交时 `chat_with_game_creator_direct_codex` 根本没发出去,界面停在空输入盒;`chat-composer` 用例里表现为「队列/终止/语音追加」五条一起红,但手工操作只在快速输入后偶发。