From a22e8ee4474d88659c561b285fb265eb63992571 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 19:43:18 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=B4=A0=E6=9D=90=E5=8D=A1=E3=80=8C=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E3=80=8D=E6=97=A0=E6=B6=88=E8=B4=B9=E8=80=85=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=B4=BB=E8=B7=83=E8=81=8A=E5=A4=A9=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E5=8C=BA=E6=B3=A8=E5=86=8C=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 features/project-workspace/activeChatComposer.ts:模块级保存当前挂载的输入区句柄,registerActiveChatComposer 注销时校验身份,insertChatReferences 空批次或无句柄返回 false DirectProjectComposer 用 useImperativeHandle 暴露 DirectProjectComposerHandle(按 ref 转发,句柄稳定),新增可选 ref 入参 DirectProjectChatView 挂载期间注册、卸载注销,并把 composerHandleRef 传给 DirectProjectComposer PlanningChatView 同样注册(句柄按 composerRef 转发),两条链路互斥渲染,同一时刻只有一个句柄 App.tsx 收敛为一处监听:单条「引用」与批量拖拽两个事件都走 insertChatReferences,返回 false 时 dev 下 console.warn chatComposerRef 只保留给策划输入盒自己的 getDraft / clear,不再承担跨面板插入 project-development.suite.ts:工具条「引用」用例改为渲染真实 DirectProject 聊天面,断言草稿里出现引用芯片(键盘 + 鼠标两条通路、光标留在插入之后) resourceCanvasChatReferenceDrop.test.tsx:换成真实 DirectProject 聊天面,批量拖拽断言整批一次落进草稿且顺序 = 拖动集合顺序;新增未登记素材不出「引用」按钮、拖拽只给原因的用例 design-agent.suite.ts:新增策划链路引用插入不回归用例 同步 docs/【功能说明】AGC聊天素材引用-2026-09-08.md、shared-memory 的 pitfalls 与 decision-log --- apps/ai-game-creator-shell/src/App.tsx | 40 ++++- .../project-workspace/activeChatComposer.ts | 52 +++++++ .../chat/DirectProjectChatView.tsx | 21 ++- .../DirectProjectComposer.tsx | 35 ++++- .../planning/PlanningChatView.tsx | 16 +- .../tests/appSurface/design-agent.suite.ts | 32 ++++ .../appSurface/project-development.suite.ts | 117 +++++++++------ .../resourceCanvasChatReferenceDrop.test.tsx | 141 ++++++++++++++++-- .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 8 + .../【功能说明】AGC聊天素材引用-2026-09-08.md | 27 +++- 11 files changed, 431 insertions(+), 66 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1bd48562a..3bb43d1ec 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -71,6 +71,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from './features/project-summary/projectSummary'; +import { insertChatReferences } from './features/project-workspace/activeChatComposer'; import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { needsInitializedChatProject, @@ -78,10 +79,13 @@ import { } from './features/project-workspace/projectCommandPolicy'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; import { + type ChatReference, directCodexContentToLegacyContentDto, hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, + type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { readGamePublishAvailability } from './services/gameDistributionPublish'; @@ -1273,22 +1277,52 @@ export function App({ } useEffect(() => { + /* + 画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。 + 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 + 策划链路挂策划面,事件本身不携带这个判断。 + + 插入失败(空批次,或此刻没有输入区挂载)不再静默:dev 下留一行线索, + 否则用户看到的又是一次「点了没反应」。 + */ + const insertReferences = (references: readonly ChatReference[]) => { + if (insertChatReferences(references)) return; + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 引用没有落进草稿:当前没有挂载中的聊天输入区', + ); + } + }; const handleResourceReferenceInsert = (event: Event) => { const detail = (event as CustomEvent) .detail; if (!detail?.reference) return; - chatComposerRef.current?.insertReferences([detail.reference]); - chatComposerRef.current?.focus(); + insertReferences([detail.reference]); + }; + const handleResourceReferenceInsertMany = (event: Event) => { + const detail = ( + event as CustomEvent + ).detail; + insertReferences(detail?.references ?? []); }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - return () => + window.addEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + return () => { window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); + window.removeEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + }; }, []); /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts new file mode 100644 index 000000000..c85e09b20 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -0,0 +1,52 @@ +import type { ChatReference } from './resourceReferences'; + +/** + * 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。 + * + * 输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板内部用, + * 不进这里——注册表只负责跨面板的引用插入。 + */ +export type ActiveChatComposerHandle = { + insertReferences: (references: ChatReference[]) => void; + focus: () => void; +}; + +/** + * 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面, + * 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。 + */ +let activeChatComposer: ActiveChatComposerHandle | null = null; + +/** + * 注册当前挂载的聊天输入区,返回注销函数。 + * + * 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换), + * 旧注销不能把新句柄一起清掉。 + */ +export function registerActiveChatComposer( + handle: ActiveChatComposerHandle, +): () => void { + activeChatComposer = handle; + return () => { + if (activeChatComposer === handle) { + activeChatComposer = null; + } + }; +} + +/** + * 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。 + * + * 空批次与「此刻没有任何输入区挂载」都返回 `false`:这两件事都不能静默, + * 由调用方(`App.tsx` 的事件监听)决定怎么留痕或提示,注册表本身不吞。 + */ +export function insertChatReferences( + references: readonly ChatReference[], +): boolean { + if (references.length === 0) return false; + const handle = activeChatComposer; + if (!handle) return false; + handle.insertReferences([...references]); + handle.focus(); + return true; +} 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 f2d345d75..ae909ff64 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 @@ -4,17 +4,22 @@ import { useEffect, useImperativeHandle, useMemo, + useRef, useState, } from 'react'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { userItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; -import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer'; +import { + DirectProjectComposer, + type DirectProjectComposerHandle, +} from './components/DirectProjectComposer/DirectProjectComposer'; import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation'; import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog'; import { @@ -103,6 +108,7 @@ export function DirectProjectChatView({ }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); + const composerHandleRef = useRef(null); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); @@ -212,6 +218,18 @@ export function DirectProjectChatView({ }, })); + /* + 画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里 + **当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路 + 互斥,所以同一时刻注册表里只有一个句柄。挂载期间注册、卸载注销,输入区不在位时 + 插入请求会拿到 `false` 而不是静默丢掉(见 `activeChatComposer.ts`)。 + */ + useEffect(() => { + const handle = composerHandleRef.current; + if (!handle) return; + return registerActiveChatComposer(handle); + }, []); + return (
) : null} Promise; + /** + * 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」, + * 画布的「引用」/拖拽批量引用事件才能落进这份草稿。 + */ + ref?: Ref; }) { const composerRef = useRef(null); const composerRootRef = useRef(null); @@ -166,6 +186,19 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); + /* + 对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值, + 句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相。 + */ + useImperativeHandle( + ref, + () => ({ + insertReferences: (references) => + composerRef.current?.insertReferences([...references]), + focus: () => composerRef.current?.focus(), + }), + [], + ); // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 7e35bc9a0..8cbaad380 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -1,6 +1,6 @@ import { ArrowUp, FileUp, Loader2 } from 'lucide-react'; import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent'; import type { @@ -15,6 +15,7 @@ import { projectNameFromPath, projectRuntimeVisibleError, } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect'; import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider'; import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider'; @@ -133,6 +134,19 @@ export function PlanningChatView({ onDesignRetry, }: PlanningChatViewProps) { const designFileInputRef = useRef(null); + /* + 策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处, + 它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份 + 可变句柄),注册的那个包装对象因此永远读到最新值。 + */ + useEffect(() => { + if (!composerRef) return; + return registerActiveChatComposer({ + insertReferences: (references) => + composerRef.current?.insertReferences([...references]), + focus: () => composerRef.current?.focus(), + }); + }, [composerRef]); /* 策划输入盒的引用来源:**只注入资源与两个静默 provider**。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index d96c09714..84fcbd24b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -1,3 +1,4 @@ +import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences'; import { act, App, @@ -916,4 +917,35 @@ export function registerDesignAgentSurfaceTests() { expect(details[1].open).toBe(false); await waitFor(() => expect(summaries[0].textContent).toBe('思考过程')); }); + + it('画布派发的「引用」落进策划输入盒草稿', async () => { + const harness = createProjectChatRuntimeHarness({ + initialSessionExists: false, + }); + renderDesignAgent(harness); + const editor = await screen.findByLabelText('项目需求'); + + // 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由 + // 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是 + // 它在 `PlanningChatView` 这一侧也真的进了草稿。 + act(() => { + dispatchResourceReferenceInsert({ + type: 'resource', + resourceId: 'planning-hero', + kind: 'character', + mediaType: 'image/png', + label: 'hero.png', + category: 'character', + tags: [], + source: 'resource-card', + }); + }); + + await waitFor(() => + expect( + editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'), + ).toHaveLength(1), + ); + expect(editor.textContent).toContain('@hero.png'); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b66ab4c34..e1bab8945 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -10,7 +10,6 @@ import { GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, } from '../../../../packages/shared/src/contracts/gameCreationApp'; -import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { @@ -30,8 +29,10 @@ import { } from '../resourceGenerationPromptTestUtils'; import { act, + App, cleanup, createGameCreationAppManifest, + createProjectChatRuntimeHarness, expect, findResourceSelectButton, fireEvent, @@ -3596,7 +3597,7 @@ export function registerProjectWorkbenchFoundationTests() { ); }); - it('工具条里的「引用」用键盘也能插进聊天输入框', async () => { + it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-reference', '引用入口测试', @@ -3611,6 +3612,13 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated' }, }); let layoutRevision = 0; + const projectPath = '/tmp/workbench-resource-reference'; + // 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`): + // 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -3648,20 +3656,43 @@ export function registerProjectWorkbenchFoundationTests() { dataUrl: 'data:image/png;base64,iVBORw0KGgo=', }; } - throw new Error(`unexpected invoke ${command}`); + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { + core: { invoke }, + event: { listen: chatHarness.listen }, + } as unknown as typeof window.__TAURI__; render( React.createElement(ProjectDevelopmentView, { projectName: '引用入口测试', - projectPath: '/tmp/workbench-resource-reference', + projectPath, manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, - chat: React.createElement('div', null, '项目总控'), + chat: React.createElement(App, { initialProjectPath: projectPath }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -3689,49 +3720,39 @@ export function registerProjectWorkbenchFoundationTests() { '引用资源 hero.png', ); - const inserted: unknown[] = []; - const onInsert = (event: Event) => { - inserted.push( - (event as CustomEvent<{ reference: unknown }>).detail.reference, - ); - }; - window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - try { - // 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip - // (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。 - referenceButton.focus(); - expect(document.activeElement).toBe(referenceButton); - await userEvent.setup().keyboard('{Enter}'); - expect(inserted).toHaveLength(1); + // 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。 + // 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。 + const draftChipIds = () => + Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id')); + const composerEditor = () => + document.querySelector( + 'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]', + )!; + // 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。 + const firstChipLabel = () => + document.querySelector( + 'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label', + )?.textContent; - // 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。 - fireEvent.click(referenceButton); - expect(inserted).toHaveLength(2); - } finally { - window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - } - expect(inserted).toEqual([ - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - ]); + // 键盘通路:聚焦后回车确认。 + referenceButton.focus(); + expect(document.activeElement).toBe(referenceButton); + await userEvent.setup().keyboard('{Enter}'); + await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero'])); + expect(firstChipLabel()).toBe('hero'); + expect(composerEditor().textContent).toContain('@hero'); + + // 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。 + fireEvent.click(referenceButton); + await waitFor(() => + expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']), + ); + // 插入后焦点回到输入盒,用户可以接着打字。 + expect(document.activeElement).toBe(composerEditor()); }); it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 446f721ee..7a1333d78 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -14,8 +14,11 @@ import { type ResourceReferenceInsertManyEventDetail, } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; +import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel'; import { + App, createGameCreationAppManifest, + createProjectChatRuntimeHarness, fireEvent, React, render, @@ -87,10 +90,21 @@ type LayoutWrite = { positions: ProjectResourceCanvasPosition[]; }; -function installTauri(): { layoutWrites: LayoutWrite[] } { +/** 画布与真实 DirectProject 聊天面共用的项目身份。 */ +const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop'; + +function installTauri(manifest: GameCreationAppManifest): { + layoutWrites: LayoutWrite[]; +} { const layoutWrites: LayoutWrite[] = []; const persisted = new Map(); const revisions = new Map(); + // 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答, + // 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath: CANVAS_PROJECT_PATH, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { @@ -140,10 +154,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } { if (command === 'list_local_project_asset_generations') { return []; } - return undefined; + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: CANVAS_PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + window.__TAURI__ = { + core: { invoke }, + event: { listen: chatHarness.listen }, + } as unknown as typeof window.__TAURI__; return { layoutWrites }; } @@ -185,21 +222,34 @@ function collectReferenceInserts() { }; } -async function mountCanvas() { - const tauri = installTauri(); +async function mountCanvas( + options: { + /** 项目附件(无 manifest 登记的素材来源)。 */ + attachments?: ProjectAttachmentResult[]; + /** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */ + categoryLabel?: string; + } = {}, +) { + const { attachments = [], categoryLabel = '角色与对象' } = options; const manifest = manifestFor('chat-reference-drop', [ characterAsset('drop-a', 'a.png'), characterAsset('drop-b', 'b.png'), ]); + const tauri = installTauri(manifest); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, - projectPath: '/tmp/chat-reference-drop', + projectPath: CANVAS_PROJECT_PATH, manifest, - attachments: [], + attachments, recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + // 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` → + // `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」, + // 所以这里不能再拿 `
项目总控
` 顶替。 + chat: React.createElement(App, { + initialProjectPath: CANVAS_PROJECT_PATH, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -220,7 +270,7 @@ async function mountCanvas() { toJSON: () => ({}), } as DOMRect); act(() => window.dispatchEvent(new Event('resize'))); - fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` })); await settle(); const chat = document.querySelector('.game-workbench-chat')!; vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT); @@ -234,6 +284,20 @@ function cardIn(manager: HTMLElement, resourceId: string) { )!; } +/** + * 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。 + * + * 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿, + * 不能再读 window 上的一次 dispatch。 + */ +function draftReferenceIds() { + return Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id') ?? ''); +} + afterEach(() => { delete window.__TAURI__; vi.restoreAllMocks(); @@ -279,6 +343,8 @@ describe('拖动素材到对话:批量 @ 引用', () => { kind: 'character', source: 'resource-card', }); + // 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。 + await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a'])); // 拖到对话不是排版:一条坐标都不写。 expect(tauri.layoutWrites).toHaveLength(writesBefore); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); @@ -318,6 +384,10 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect( new Set(inserts[0]!.map((reference) => reference.resourceId)), ).toEqual(new Set(['drop-a', 'drop-b'])); + // 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。 + await waitFor(() => + expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']), + ); expect(tauri.layoutWrites).toHaveLength(writesBefore); dispose(); }); @@ -395,4 +465,57 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(0); dispose(); }); + + it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => { + // 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。 + const { manager, inserts, dispose } = await mountCanvas({ + categoryLabel: '待归类', + attachments: [ + { + fileName: 'raw-shot.png', + mediaType: 'image/png', + status: 'imported', + localPath: 'assets/raw-shot.png', + }, + ], + }); + const card = cardIn(manager, 'attachment:assets/raw-shot.png'); + expect(card).not.toBeNull(); + + fireEvent.click(card); + // 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。 + expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull(); + + fireEvent.pointerDown(card, { + pointerId: 74, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 74, + buttons: 1, + clientX: 1000, + clientY: 300, + }); + // 落点提示也说清原因,不承诺一次插不进去的引用。 + expect( + screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'), + ).not.toBeNull(); + + fireEvent.pointerUp(card, { + pointerId: 74, + button: 0, + clientX: 1000, + clientY: 300, + }); + await settle(); + + expect( + screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'), + ).not.toBeNull(); + expect(inserts).toHaveLength(0); + expect(draftReferenceIds()).toEqual([]); + dispose(); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4aa91f9be..6c1a99cfb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602) + +- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 空批次或无句柄返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`,`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`。 +- 验证:`npm run test -- apps/ai-game-creator-shell/tests`(195 passed / 1 skipped 文件,1902 passed / 17 skipped 用例)、`npm run test -- src/components/image-editor`(88 passed / 1401 passed)、`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) - 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 75a6d0d4f..4e888481a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上 + +- **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。 +- **原因**:画布侧只 `dispatchResourceReferenceInsert` / `dispatchResourceReferenceInsertMany`,而这两个 window 事件的**唯一**消费者是 `App.tsx` 里的 `chatComposerRef.current?.insertReferences(...)`,`chatComposerRef` 又只赋给 `PlanningChatView`;2026-09-22 DirectProject 拆分引入的 `directProjectMode` 提前 return 让普通项目整段跳过后面的策划面渲染 → ref 恒为 `null`,可选链把整次调用静默吞掉。同一批合并冲突还把 2026-09-21 新加的 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 监听整段丢掉,批量引用连消费者都没有。 +- **处理(现行口径)**:`features/project-workspace/activeChatComposer.ts` 保存当前挂载的**那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数 / `insertChatReferences`),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥,同一时刻只有一个句柄);`App.tsx` 收敛为一处监听,单条与批量都走 `insertChatReferences`,返回 `false`(空批次 / 此刻没有输入区)时 dev 下 `console.warn`;`chatComposerRef` 只留给策划输入盒自己的 `getDraft` / `clear`。 +- **判据/取证**:`npm run test -- apps/ai-game-creator-shell/tests` 里的 `resourceCanvasChatReferenceDrop.test.tsx`、`appSurface/project-development.suite.ts`(工具条「引用」)、`appSurface/design-agent.suite.ts`(策划链路)都断言**真实输入盒草稿**里出现 `[data-resource-reference-id=""]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。 +- **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。 + ## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 - **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 2591f9d63..d45b1efc0 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -1,9 +1,29 @@ # AGC 聊天素材引用 -更新时间:2026-09-23 +更新时间:2026-10-03 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 +## 画布引用落到哪份输入区:活跃聊天输入区注册表(2026-10-03) + +资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答: + +- `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。 +- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。 +- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`:空批次或没有挂载中的输入区时返回 `false`,dev 下 `console.warn` 留一行线索(不再有可选链静默吞掉整次点击)。 +- `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。 + +这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。 + +这条链路由以下用例守住(都渲染真实聊天面,断言草稿 DOM 而不是「事件被派发」): + +| 契约 | 用例 | +| --- | --- | +| 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 | +| 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` | +| 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` | +| 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | + ## 引用来源由宿主注入(2026-09-22) `ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂): @@ -60,7 +80,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。 - 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。 -- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。 +- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),由 `App.tsx` 的监听一次性交给当前挂载的输入区(见文首「活跃聊天输入区注册表」),草稿只重建一次、插入顺序即拖动集合顺序。2026-09-22 的合并冲突曾把这条监听整段丢掉(事件无消费者),2026-10-03 修复时补回。 ## 附件进入正文(2026-09-22) @@ -83,7 +103,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 支持搜索、类型筛选和多选; - 素材芯片可插入、编辑和删除; - 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节); -- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; +- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;未登记素材(`manifestAssetId: null`)不渲染这枚按钮,拖到对话栏时落点提示与提示条给出「还没登记为项目资源」的原因; +- 普通项目(DirectProject)与立项策划两条链路都由 `App.tsx` 那一处监听 + 活跃聊天输入区注册表把引用落进当前挂载的输入盒草稿(2026-10-03 修复,见文首); - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; - 提交请求携带 canonical user message item; - Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; From f4ada5061246355dde75de658927e639e6efbc22 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:12:24 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E8=87=AA=E5=AE=A1=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=EF=BC=9A=E6=B3=A8=E5=86=8C=E8=A1=A8=E6=8C=89=20ref=20=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E6=B3=A8=E5=86=8C=E3=80=81=E6=8F=92=E5=85=A5=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E8=AF=AD=E4=B9=89=E4=B8=8E=E5=A4=B1=E8=B4=A5=E7=95=99?= =?UTF-8?q?=E7=97=95=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActiveChatComposerHandle.insertReferences 改为返回 boolean:由句柄回答这一批有没有真的递到输入区,注册表不再用「有句柄」冒充「插进去了」 insertChatReferences 增加第三种 false(句柄报落空),且只有插入成功才 focus,失败不抢焦点 DirectProjectChatView 改为注册「按 ref 转发」的句柄,注册时不再读 composerHandleRef.current,去掉对父子 effect 顺序的隐式依赖(内层输入区重挂载也不会留下死句柄) DirectProjectComposer 与 PlanningChatView 的转发句柄同步返回 boolean App.tsx 的 dev 失败线索文案改为「没有可用的聊天输入区(未挂载或已卸载)」,覆盖句柄落空这一种 新增 tests/activeChatComposer.test.ts:钉住注册表合同(空批次 / 无输入区 / 句柄报落空不聚焦 / 注销身份校验);反向证伪:去掉身份校验后该用例变红 同步 docs/【功能说明】AGC聊天素材引用-2026-09-08.md 与 shared-memory 决策记录 --- apps/ai-game-creator-shell/src/App.tsx | 4 +- .../project-workspace/activeChatComposer.ts | 15 +-- .../chat/DirectProjectChatView.tsx | 24 +++-- .../DirectProjectComposer.tsx | 16 +++- .../planning/PlanningChatView.tsx | 10 +- .../tests/activeChatComposer.test.ts | 96 +++++++++++++++++++ .../shared-memory/decision-log.md | 2 +- .../【功能说明】AGC聊天素材引用-2026-09-08.md | 5 +- 8 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/activeChatComposer.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3bb43d1ec..df310e05d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1282,14 +1282,14 @@ export function App({ 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 策划链路挂策划面,事件本身不携带这个判断。 - 插入失败(空批次,或此刻没有输入区挂载)不再静默:dev 下留一行线索, + 插入失败(空批次,或此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, 否则用户看到的又是一次「点了没反应」。 */ const insertReferences = (references: readonly ChatReference[]) => { if (insertChatReferences(references)) return; if (import.meta.env.DEV) { console.warn( - '[resource-reference] 引用没有落进草稿:当前没有挂载中的聊天输入区', + '[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)', ); } }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts index c85e09b20..cf3ac137d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -3,11 +3,13 @@ import type { ChatReference } from './resourceReferences'; /** * 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。 * - * 输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板内部用, - * 不进这里——注册表只负责跨面板的引用插入。 + * `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、 + * 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见 + * 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板 + * 内部用,不进这里——注册表只负责跨面板的引用插入。 */ export type ActiveChatComposerHandle = { - insertReferences: (references: ChatReference[]) => void; + insertReferences: (references: ChatReference[]) => boolean; focus: () => void; }; @@ -37,8 +39,9 @@ export function registerActiveChatComposer( /** * 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。 * - * 空批次与「此刻没有任何输入区挂载」都返回 `false`:这两件事都不能静默, - * 由调用方(`App.tsx` 的事件监听)决定怎么留痕或提示,注册表本身不吞。 + * 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去 + * (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听) + * 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。 */ export function insertChatReferences( references: readonly ChatReference[], @@ -46,7 +49,7 @@ export function insertChatReferences( if (references.length === 0) return false; const handle = activeChatComposer; if (!handle) return false; - handle.insertReferences([...references]); + if (!handle.insertReferences([...references])) return false; handle.focus(); return true; } 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 ae909ff64..a429ada6a 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 @@ -221,14 +221,24 @@ export function DirectProjectChatView({ /* 画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里 **当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路 - 互斥,所以同一时刻注册表里只有一个句柄。挂载期间注册、卸载注销,输入区不在位时 - 插入请求会拿到 `false` 而不是静默丢掉(见 `activeChatComposer.ts`)。 + 互斥,所以同一时刻注册表里只有一个句柄。 + + 注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面 + 在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences` + 返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。 */ - useEffect(() => { - const handle = composerHandleRef.current; - if (!handle) return; - return registerActiveChatComposer(handle); - }, []); + useEffect( + () => + registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerHandleRef.current; + if (!handle) return false; + return handle.insertReferences(references); + }, + focus: () => composerHandleRef.current?.focus(), + }), + [], + ); return (
({ - insertReferences: (references) => - composerRef.current?.insertReferences([...references]), + insertReferences: (references: ChatReference[]) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, focus: () => composerRef.current?.focus(), }), [], diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 8cbaad380..928d75d03 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -137,13 +137,17 @@ export function PlanningChatView({ /* 策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处, 它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份 - 可变句柄),注册的那个包装对象因此永远读到最新值。 + 可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。 */ useEffect(() => { if (!composerRef) return; return registerActiveChatComposer({ - insertReferences: (references) => - composerRef.current?.insertReferences([...references]), + insertReferences: (references) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, focus: () => composerRef.current?.focus(), }); }, [composerRef]); diff --git a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts new file mode 100644 index 000000000..c26deb80e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + type ActiveChatComposerHandle, + insertChatReferences, + registerActiveChatComposer, +} from '../src/features/project-workspace/activeChatComposer'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; + +function resourceReference(resourceId: string): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'character', + mediaType: 'image/png', + label: resourceId, + category: 'character', + tags: [], + source: 'resource-card', + }; +} + +/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */ +function composerHandle(inserted = true): { + handle: ActiveChatComposerHandle; + insertReferences: ReturnType; + focus: ReturnType; +} { + const insertReferences = vi.fn(() => inserted); + const focus = vi.fn(); + return { handle: { insertReferences, focus }, insertReferences, focus }; +} + +/** + * 「活跃聊天输入区」注册表的合同。 + * + * 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据, + * 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与 + * `appSurface/*.suite.ts`。 + */ +describe('活跃聊天输入区注册表', () => { + it('没有任何输入区挂载时插入返回 false', () => { + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('空批次不算成功,也不打扰已挂载的输入区', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([])).toBe(false); + expect(insertReferences).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect( + insertChatReferences([ + resourceReference('hero'), + resourceReference('npc'), + ]), + ).toBe(true); + expect(insertReferences).toHaveBeenCalledTimes(1); + expect(insertReferences).toHaveBeenCalledWith([ + expect.objectContaining({ resourceId: 'hero' }), + expect.objectContaining({ resourceId: 'npc' }), + ]); + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => { + const { handle, focus } = composerHandle(false); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => { + const first = composerHandle(); + const second = composerHandle(); + const unregisterFirst = registerActiveChatComposer(first.handle); + const unregisterSecond = registerActiveChatComposer(second.handle); + + // 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。 + unregisterFirst(); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + + unregisterSecond(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6c1a99cfb..14449989a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,7 +3,7 @@ ## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602) - 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。 -- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 空批次或无句柄返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`,`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 - 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。 - 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`。 - 验证:`npm run test -- apps/ai-game-creator-shell/tests`(195 passed / 1 skipped 文件,1902 passed / 17 skipped 用例)、`npm run test -- src/components/image-editor`(88 passed / 1401 passed)、`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index d45b1efc0..ccf88833b 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -9,8 +9,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, 资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答: - `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。 -- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。 -- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`:空批次或没有挂载中的输入区时返回 `false`,dev 下 `console.warn` 留一行线索(不再有可选链静默吞掉整次点击)。 +- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。注册的是**按 ref 转发**的句柄、且不在注册时读输入区是否就位,所以挂载顺序与子组件重挂载都不会让注册表漏挂或指向死句柄。 +- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`;返回 `false` 的三种情况(空批次、没有挂载中的输入区、注册表里的句柄报「这一批没插进去」)都不静默,dev 下 `console.warn` 留一行线索;只有真的插进去了才把焦点交给输入区。 - `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。 这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。 @@ -22,6 +22,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 | | 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` | | 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` | +| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | | 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | ## 引用来源由宿主注入(2026-09-22) From 3f18e3dc77e562ff19076526c89249fee1ef11b9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:38:06 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E6=8C=89=E7=8B=AC=E7=AB=8B=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E7=9A=84=E4=B8=89=E6=9D=A1=20P2=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E7=A9=BA=E6=89=B9=E6=AC=A1=E4=B8=8D=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E3=80=81=E9=87=8D=E5=A4=8D=E6=B3=A8=E5=86=8C=E7=95=99=E7=BA=BF?= =?UTF-8?q?=E7=B4=A2=E3=80=81=E6=96=87=E6=A1=A3=E4=B8=8E=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.tsx 的监听处对空批次直接返回:空批次没有要插的东西,不能报成「没有可用的聊天输入区」(原因指向错了方向) activeChatComposer.registerActiveChatComposer 增加重复注册检测:dev 下 console.warn 指出后注册者顶替了前者,不改运行时语义(仍然后注册者接管、身份校验照旧兜住乱序卸载) tests/activeChatComposer.test.ts 补两条:空批次不打扰已挂载输入区;重复注册出现告警且乱序注销清不掉更新的句柄 tests/resourceCanvasChatReferenceDrop.test.tsx 补一条:直接构造空批次引用事件时不产生「没有可用的聊天输入区」告警 decision-log 的「影响范围」补记 tests/activeChatComposer.test.ts,「验证」换成合并 master 后的实跑数字;功能说明的用例表补两行 反向证伪:去掉空批次短路、去掉重复注册告警后,上面两条新用例各红一处 --- apps/ai-game-creator-shell/src/App.tsx | 6 ++- .../project-workspace/activeChatComposer.ts | 11 +++++ .../tests/activeChatComposer.test.ts | 43 ++++++++++++++++++- .../resourceCanvasChatReferenceDrop.test.tsx | 24 +++++++++++ .../shared-memory/decision-log.md | 6 +-- .../【功能说明】AGC聊天素材引用-2026-09-08.md | 3 +- 6 files changed, 86 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index df310e05d..4aee35d18 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1282,10 +1282,12 @@ export function App({ 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 策划链路挂策划面,事件本身不携带这个判断。 - 插入失败(空批次,或此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, - 否则用户看到的又是一次「点了没反应」。 + 插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, + 否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西, + 也就不能把原因指到输入区上。 */ const insertReferences = (references: readonly ChatReference[]) => { + if (references.length === 0) return; if (insertChatReferences(references)) return; if (import.meta.env.DEV) { console.warn( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts index cf3ac137d..cc67b7a7b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -24,10 +24,21 @@ let activeChatComposer: ActiveChatComposerHandle | null = null; * * 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换), * 旧注销不能把新句柄一起清掉。 + * + * 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`): + * 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义 + * ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。 */ export function registerActiveChatComposer( handle: ActiveChatComposerHandle, ): () => void { + if (activeChatComposer && activeChatComposer !== handle) { + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个', + ); + } + } activeChatComposer = handle; return () => { if (activeChatComposer === handle) { diff --git a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts index c26deb80e..759a376e3 100644 --- a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts +++ b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { type ActiveChatComposerHandle, @@ -39,6 +39,11 @@ function composerHandle(inserted = true): { * `appSurface/*.suite.ts`。 */ describe('活跃聊天输入区注册表', () => { + // 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。 + afterEach(() => { + vi.restoreAllMocks(); + }); + it('没有任何输入区挂载时插入返回 false', () => { expect(insertChatReferences([resourceReference('hero')])).toBe(false); }); @@ -79,6 +84,8 @@ describe('活跃聊天输入区注册表', () => { }); it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => { + // 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。 + vi.spyOn(console, 'warn').mockImplementation(() => {}); const first = composerHandle(); const second = composerHandle(); const unregisterFirst = registerActiveChatComposer(first.handle); @@ -93,4 +100,38 @@ describe('活跃聊天输入区注册表', () => { unregisterSecond(); expect(insertChatReferences([resourceReference('hero')])).toBe(false); }); + + it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const third = composerHandle(); + + const unregisterFirst = registerActiveChatComposer(first.handle); + expect(warn).not.toHaveBeenCalled(); + + // 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。 + const unregisterSecond = registerActiveChatComposer(second.handle); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + expect.stringContaining('第二个聊天输入区注册'), + ]); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + // 再注册第三个(模拟又一条链路接管):同样留线索。 + const unregisterThird = registerActiveChatComposer(third.handle); + expect(warn).toHaveBeenCalledTimes(2); + + // 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。 + unregisterFirst(); + unregisterSecond(); + expect(insertChatReferences([resourceReference('npc')])).toBe(true); + expect(third.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + unregisterThird(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + warn.mockRestore(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 62ae983e0..e62f08807 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -532,4 +532,28 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(draftReferenceIds()).toEqual([]); dispose(); }); + + it('空批次引用事件不报「没有可用的聊天输入区」', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { dispose } = await mountCanvas(); + // 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return), + // 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。 + act(() => { + window.dispatchEvent( + new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, { + detail: { references: [] }, + }), + ); + }); + await settle(); + + expect( + warn.mock.calls + .map((call) => String(call[0])) + .filter((message) => message.includes('没有可用的聊天输入区')), + ).toEqual([]); + expect(draftReferenceIds()).toEqual([]); + warn.mockRestore(); + dispose(); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 14449989a..83dd2bff7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,10 +3,10 @@ ## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602) - 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。 -- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数,并检测到第二个输入区注册时留一条 dev 告警——不改运行时语义;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`(空批次直接返回:没有要插的东西,不能报成「没有可用的输入区」);返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 - 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。 -- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`。 -- 验证:`npm run test -- apps/ai-game-creator-shell/tests`(195 passed / 1 skipped 文件,1902 passed / 17 skipped 用例)、`npm run test -- src/components/image-editor`(88 passed / 1401 passed)、`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/activeChatComposer.test.ts`(新增,钉注册表合同)、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`、本文件。 +- 验证(合并 master 后的最终一轮):`npx vitest run apps/ai-game-creator-shell/tests`(197 passed / 1 skipped 文件,1918 passed / 17 skipped 用例)、`npx vitest run tests/activeChatComposer.test.ts tests/resourceCanvasChatReferenceDrop.test.tsx`(2 files / 12 passed,含注册表合同:空批次、无输入区、句柄报落空、注销身份校验、重复注册告警、乱序注销)、`npx vitest run tests/appSurface.test.ts -t 引用`(4 passed)、`npm run agc:typecheck`(含 `check:tests:types`,exit 0)、`npm run check:encoding`、`git diff --check`、eslint `--max-warnings 0`(改动文件)。反向证伪:去掉注册调用后端到端用例变红;去掉空批次短路 / 重复注册告警后对应新用例各红一处。 ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index ccf88833b..6718d6e2c 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -22,7 +22,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 | | 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` | | 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` | -| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | +| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 / 重复注册留线索 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | +| 空批次事件不误报成「没有可用的聊天输入区」 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` 的「空批次引用事件」用例 | | 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | ## 引用来源由宿主注入(2026-09-22) From 26b6e1095b19c861ea6a2cecf80479eade143475 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:56:54 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Nginx=20SPA=20allowlis?= =?UTF-8?q?t=20=E7=BC=BA=E5=B0=91=20/pay=20=E4=B8=8E=20/profile/payment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/routing/activeAppPageRoutes.ts 的 STAGE_ROUTE_ENTRIES 已把 payment-checkout→/pay、 profile-payment→/profile/payment 定义为主站对外可直达的 SPA 路由(分别渲染 PaymentCheckoutView 与 PlatformPaymentServiceView),但三份 Nginx 模板的 SPA allowlist 仍停留在加入支付入口之前的集合,导致 check:nginx-spa-routes 在 master 上失败 - deploy/nginx/genarrative.conf、deploy/nginx/genarrative-dev-http.conf、 deploy/container/nginx.conf 的 SPA regex 补上 pay、profile/payment, 保持既有形状(锚定完整路径 + 大小写不敏感 + 允许一个尾部斜杠) - 本地实跑:node scripts/check-nginx-spa-routes.mjs → OK (14 SPA routes, 3 Nginx templates) --- deploy/container/nginx.conf | 2 +- deploy/nginx/genarrative-dev-http.conf | 2 +- deploy/nginx/genarrative.conf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 0b8d4c958..841d38e63 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -157,7 +157,7 @@ http { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|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/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 49b76e619..c3aa5f7c1 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -206,7 +206,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|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.conf b/deploy/nginx/genarrative.conf index ebf5d4520..8605fb288 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -234,7 +234,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { From 05707e61e4abfa15e33eee455c0fae38cae7d32c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:56:55 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=A4=96=E9=83=A8=20MCP?= =?UTF-8?q?=20=E8=AF=AD=E4=B9=89=E7=9B=AE=E5=BD=95=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=87=8C=E5=86=99=E6=AD=BB=E7=9A=84=20legacy=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=95=B0=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 断言 MCP_OPERATIONS.len() == 30 自 #592 起写死。4b529a895(新增支付服务接入与订单收银台) 在内置 OpenAPI 中新增 createExternalPaymentOrder、getExternalPaymentOrder 两条未被 x-mcp-excluded 排除的操作,legacy 可调用工具由 30 合法增至 32(非重复注册、非覆盖) - 断言更新为 32 并注明来源(内置 OpenAPI 去掉 5 条元数据/入口操作后的可调用集合), 保留“语义工具是增量、不得覆盖 legacy 工具”的原意;总数断言改为 MCP_OPERATIONS.len() + TOOLS.len() 派生,避免再次写死 - 本地实跑:cargo test --locked -p api-server --no-fail-fast --manifest-path server-rs/Cargo.toml semantic → 18 passed(含 semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools) --- .../crates/api-server/src/external_mcp/semantic/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs index 02f47b42f..a27dbc5b9 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -30,7 +30,10 @@ fn prepare( #[test] fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { assert_eq!(TOOLS.len(), 15); - assert_eq!(MCP_OPERATIONS.len(), 30); + // legacy 工具集合直接来自内置 OpenAPI(排除 5 条 x-mcp-excluded 元数据/入口操作)。 + // 支付收银台新增 createExternalPaymentOrder / getExternalPaymentOrder 后, + // 可调用操作由 30 增至 32:语义工具只做增量,不得替换 legacy 工具。 + assert_eq!(MCP_OPERATIONS.len(), 32); let legacy = MCP_OPERATIONS .iter() .map(mcp_operation_tool) @@ -52,7 +55,7 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { .is_some_and(|text| !text.is_empty()) ); } - assert_eq!(names.len(), 45); + assert_eq!(names.len(), MCP_OPERATIONS.len() + TOOLS.len()); } #[test] From 977b2f0c758d5fb82ebb634570b50de8598c571a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:56:55 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E4=BF=AE=E6=8E=89=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=8B=20HTTP/Provider=20span=20=E5=81=B6?= =?UTF-8?q?=E5=8F=91=E9=87=87=E9=9B=86=E4=B8=BA=E7=A9=BA=E7=9A=84=20tracin?= =?UTF-8?q?g=20interest=20=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:span!/info_span! 宏在 callsite 的缓存 interest 为 never 时会静默返回空 span (tracing-0.1.44/src/macros.rs 的 span! 分支),而 DefaultCallsite::register 只在调用点 首次被命中时计算一次 interest,且当进程里只注册过一个 dispatcher 时会退化成 dispatcher::get_default()——也就是命中线程自己的 dispatcher(tracing-core-0.1.36 callsite.rs 的 Rebuilder::JustOne)。libtest 默认并发下,没有 subscriber 的普通测试线程 一旦抢到 http.request / llm.request 调用点的首次注册,就会把它永久缓存成 never, 于是 CI 偶发看到 0 个 span(app.rs:603、observability_tests.rs:98) - 关键:set_default 与 with_subscriber 在 interest 缓存这件事上**等价**(都只是新建 Dispatch 并触发一次 rebuild_interest,只能纠正“已经注册过”的调用点,纠正不了 JustOne→get_default 这条首次注册分支),所以真正起作用的是**在自家 subscriber 下命中同一个调用点做热身**, 把首次注册的顺序握在自己手里;register_callsite 覆写只用于避免本 subscriber 触发的重建 把其它调用点永久标记成 never - api-server app::tests::http_tracing:不再用 with_subscriber,改为在请求前用同一 http.request 调用点热身到连续两轮采集成功,并在整个请求期间持有 scoped default (已注明 set_default 是线程绑定,只适用于默认的 current_thread #[tokio::test]) - platform-llm observability_tests:新增 run_under_capture 固定整段流程的 scoped default, 并新增 warm_up_provider_span_callsite(用指向刚释放 loopback 端口的最小失败请求命中同一 llm.request 调用点,连续两轮采集成功才继续) - 断言未放宽:仍要求每个被拒绝请求恰好 1 个 HTTP span、每次 Provider 调用恰好 1 个 llm.request span;未使用 sleep - 本地实跑:cargo test -p api-server --bin api-server app::tests::http_tracing(默认并发与 --test-threads=1 各 20 次全绿)、app::tests:: 91 用例并发 10 次全绿、--skip bgfilter_worker --skip wallet_refund_outbox 的 1133 用例全量 bin 2 次全绿;cargo test -p platform-llm --lib (162 passed,含 3 条观测用例)连跑 20 次全绿 --- server-rs/crates/api-server/src/app.rs | 67 +++++++++++++-- .../platform-llm/src/observability_tests.rs | 85 ++++++++++++++++--- 2 files changed, 134 insertions(+), 18 deletions(-) diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 390031ef9..732e0d046 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -510,11 +510,12 @@ mod tests { use tracing::{ Metadata, Subscriber, field::{Field, Visit}, - instrument::WithSubscriber, span::{Attributes, Id, Record}, + subscriber::Interest, }; use super::*; + use crate::app::make_http_request_span; use crate::state::{BackpressureState, HttpRequestPermitPoolKind}; #[derive(Clone, Default)] @@ -534,6 +535,18 @@ mod tests { } impl Subscriber for HttpSpanCapture { + /// tracing 的 callsite interest 是**进程级**缓存,只在调用点首次命中时计算一次。 + /// 这里只为 HTTP span 声明 always:任何以本 subscriber 参与的重建都会让 + /// `http.request` 调用点保持可用;其余调用点返回 sometimes,交给 `enabled` + /// 在真正创建 span 时判断,避免把其它调用点永久标记成 never。 + fn register_callsite(&self, metadata: &Metadata<'_>) -> Interest { + if metadata.is_span() && metadata.name() == "http.request" { + Interest::always() + } else { + Interest::sometimes() + } + } + fn enabled(&self, metadata: &Metadata<'_>) -> bool { metadata.is_span() && metadata.name() == "http.request" } @@ -557,6 +570,41 @@ mod tests { fn exit(&self, _: &Id) {} } + fn http_span_warmup_request() -> Request { + Request::builder() + .uri("/__genarrative_http_span_interest_warmup__") + .body(Body::empty()) + .expect("warmup request should build") + } + + /// 让 `http.request` 调用点在本进程内稳定可用,而不是依赖调用点首次注册时命中线程的 dispatcher。 + /// + /// `DefaultCallsite::register` 只在调用点首次被命中时计算一次 interest,计算时会用 + /// `DISPATCHERS.rebuilder()`;当进程里只有一个 dispatcher 时它会退化成 + /// `dispatcher::get_default()`,也就是**命中线程自己的** dispatcher。并发跑测试时, + /// 没有 subscriber 的线程一旦抢到这次注册,`http.request` 调用点就会被永久缓存成 + /// `never`,之后 `span!` 宏会静默跳过 span 创建,测试便会观察到 0 个 span。 + /// + /// 这里不 sleep 赌时序:每轮都用 `set_default` 在我们的 subscriber 下安装 scoped default + /// (内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并直接命中同一个 + /// `http.request` 调用点做探测;连续两轮都探测到 span 才认为缓存已经稳定。 + fn ensure_http_request_span_interest() { + let mut captured_rounds = 0; + for _ in 0..8 { + let probe = HttpSpanCapture::default(); + { + let _guard = tracing::subscriber::set_default(probe.clone()); + let _ = make_http_request_span(&http_span_warmup_request()); + } + let captured = !probe.0.lock().expect("span capture should lock").is_empty(); + captured_rounds = if captured { captured_rounds + 1 } else { 0 }; + if captured_rounds >= 2 { + return; + } + } + panic!("无法在测试 subscriber 下恢复 http.request 调用点的 interest 缓存"); + } + async fn assert_rejection_observed( app: Router, request: Request, @@ -567,11 +615,18 @@ mod tests { let expected_request_id = request.headers().get("x-request-id").cloned(); let path = request.uri().path().to_string(); let capture = HttpSpanCapture::default(); - let response = app - .oneshot(request) - .with_subscriber(capture.clone()) - .await - .expect("rejected request should complete"); + // 先把 http.request 调用点的进程级 interest 缓存校正到当前 subscriber(见上方注释), + // 再在整个请求期间持有 scoped default。注意 `set_default` 是**线程绑定**的:这里成立 + // 是因为本模块用的是默认 `#[tokio::test]`(current-thread 运行时,请求与断言线程一致); + // 一旦改成 multi_thread / spawn 到别的线程,必须换成 `with_current_subscriber()` 或 + // `with_subscriber`,否则请求会在没有 dispatcher 的线程上跑。 + ensure_http_request_span_interest(); + let response = { + let _guard = tracing::subscriber::set_default(capture.clone()); + app.oneshot(request) + .await + .expect("rejected request should complete") + }; assert_eq!(response.status(), expected_status); let request_id = response.headers()["x-request-id"] diff --git a/server-rs/crates/platform-llm/src/observability_tests.rs b/server-rs/crates/platform-llm/src/observability_tests.rs index 16cb87dfc..a166c9b7b 100644 --- a/server-rs/crates/platform-llm/src/observability_tests.rs +++ b/server-rs/crates/platform-llm/src/observability_tests.rs @@ -11,7 +11,6 @@ use tokio::sync::oneshot; use tracing::{ Instrument, Subscriber, field::{Field, Visit}, - instrument::WithSubscriber, span::{Attributes, Id, Record}, }; use tracing_subscriber::{Layer, layer::Context, prelude::*, registry::LookupSpan}; @@ -222,8 +221,70 @@ fn request() -> LlmRunRequest { .with_model("requested-model") } +/// 在整段流程期间把 capture 固定为当前 dispatcher。 +/// +/// tracing 的 callsite interest 是**进程级**缓存,且只在调用点首次被命中时计算一次 +/// (`DefaultCallsite::register` → `DISPATCHERS.rebuilder()`;进程里只有一个 dispatcher 时会 +/// 退化成命中线程自己的 dispatcher)。并发跑测试时,本文件之外那些没有 subscriber 的测试线程 +/// 只要抢到 `llm.request` 调用点的首次注册,它就会被永久缓存成 `never`,`span!` 宏随后静默 +/// 跳过 span 创建,`provider_span` 便会看到 0 个 span。 +/// +/// 注意:`set_default` 与 `with_subscriber` 在 interest 缓存这件事上**等价**——两者都只是新建一个 +/// `Dispatch` 并触发一次 `rebuild_interest`,只能纠正“已经注册过”的调用点,纠正不了 +/// `Rebuilder::JustOne → get_default() → 缓存 never` 这条首次注册分支;真正把调用点救回来的是 +/// `warm_up_provider_span_callsite` 在自家 subscriber 下命中同一调用点(覆盖两种顺序, +/// 并且把 dispatcher 从“只在每次 poll 生效”变成整段流程生效)。 +async fn run_under_capture(capture: &Capture, future: F) -> F::Output { + // `set_default` 是线程绑定的:本文件用的是默认 `#[tokio::test]`(current-thread 运行时, + // 被测 future 与断言在同一条线程上)。若以后改成 multi_thread 或把流程 spawn 出去, + // 必须换回 `with_current_subscriber()` / `with_subscriber`,否则 span 会在没有 dispatcher 的线程上丢。 + let _guard = + tracing::subscriber::set_default(tracing_subscriber::registry().with(capture.clone())); + future.await +} + +/// 让 `llm.request` 调用点在本进程内稳定可用:用一个必然失败的最小请求(指向刚释放的 +/// loopback 端口,连接直接被拒绝)命中同一个调用点,直到连续两轮都能采集到 span 为止。 +/// 请求失败无妨——只要 `llm.request` span 被创建就说明调用点在我们的 subscriber 下注册成功。 +/// 不 sleep、不放宽断言。 +async fn warm_up_provider_span_callsite() { + let listener = TcpListener::bind("127.0.0.1:0").expect("warmup listener should bind"); + let address = listener + .local_addr() + .expect("warmup address should resolve"); + drop(listener); + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + format!("http://{address}"), + PRIVATE_KEY.into(), + "default-model".into(), + 500, + 0, + 1, + ) + .expect("warmup config should build"); + let client = LlmClient::new(config).expect("warmup client should build"); + let mut captured_rounds = 0; + for _ in 0..8 { + let probe = Capture::default(); + { + let _guard = tracing::subscriber::set_default( + tracing_subscriber::registry().with(probe.clone()), + ); + let _ = client.run(request()).await; + } + let captured = !probe.spans.lock().expect("capture should lock").is_empty(); + captured_rounds = if captured { captured_rounds + 1 } else { 0 }; + if captured_rounds >= 2 { + return; + } + } + panic!("无法在测试 subscriber 下恢复 llm.request 调用点的 interest 缓存"); +} + #[tokio::test] async fn provider_span_covers_awaited_execution_and_keeps_parent_without_arguments() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "200 OK", "application/json", @@ -231,7 +292,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen ); let capture = Capture::default(); let response = - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.run(request())); assert!(capture.spans.lock().unwrap().values().all(|span| span.name != "llm.request")); @@ -245,8 +306,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen fixture.release.send(()).unwrap(); future.await.unwrap() }.instrument(tracing::info_span!("test.request")).await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert_eq!(response.text, "completed"); @@ -255,6 +315,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen #[tokio::test] async fn stream_callbacks_inherit_provider_span_and_keep_result() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "200 OK", "text/event-stream", @@ -263,7 +324,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { let capture = Capture::default(); let mut deltas = Vec::new(); let response = - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.stream_run(request(), |delta| { tracing::info!(target: "llm_observability_test_delta", "delta received"); @@ -276,8 +337,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { fixture.release.send(()).unwrap(); future.await.unwrap() }.instrument(tracing::info_span!("test.request")).await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert_eq!(response.text, "hello"); @@ -291,21 +351,21 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { #[tokio::test] async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "401 Unauthorized", "application/json", r#"{"error":{"message":"upstream-rejected"}}"#, ); let capture = Capture::default(); - let error = async { + let error = run_under_capture(&capture, async { async { fixture.release.send(()).unwrap(); fixture.client.run(request()).await.unwrap_err() } .instrument(tracing::info_span!("test.request")) .await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert!( @@ -315,7 +375,7 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { let fixture = provider_fixture("200 OK", "application/json", "{}"); let capture = Capture::default(); - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.run(request())); tokio::select! { @@ -326,7 +386,8 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { drop(future); fixture.release.send(()).unwrap(); }.instrument(tracing::info_span!("test.request")).await - }.with_subscriber(tracing_subscriber::registry().with(capture.clone())).await; + }) + .await; fixture.server.join().unwrap(); capture.assert_completed("run"); } From 9e2cea5b721cd266b2938aedb73cc180f4b30d24 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 03:25:54 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=A3=B3=20Rust?= =?UTF-8?q?=20shard-4=20=E7=9A=84=E4=B8=A4=E6=9D=A1=E5=81=B6=E5=8F=91?= =?UTF-8?q?=E6=96=AD=E8=A8=80=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90=20pitfal?= =?UTF-8?q?ls=20=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change 偶发 left 8/right 7:测试计数器 DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT 曾在 2026-10-01 按线程 作用域隔离,2026-10-02 退役 runtime_driver 把它搬进 agent/direct_events.rs 时降级回进程级 static AtomicU64,于是断言会取到宿主 tauri::async_runtime 后台回合在别的线程上的广播 (--test-threads=1 只串行测试线程)。改回 thread_local! Cell,并注明跨线程口径的覆盖取舍 - process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup 偶发 left "exited"/right "terminated":测试命令里 leader 打印 READY 后立刻 exit 0,同组后代 仍存活,trampoline 从 leader 被回收起开始 800ms 宽限;客户端只要晚于宽限才发出 terminate 就只能读到既成事实。改为 leader 用 wait 等后台子进程,让 terminate 必然落在会话仍 running 时 (trap / sleep 0.4 / marker / 断言均未改),并注明该用例的确定性来自 400ms < 800ms 的时间余量 - docs/project-memory/shared-memory/pitfalls.md:更新 graceful terminate 那条已过时的验证口径, 补三条 2026-10-04 条目(tracing interest 竞态、AGC 两条偶发的串台根因、SPA 深链前缀路由) - 本地实跑:修复前把计数器临时改回 AtomicU64 时同一并行口径 17/20 红;修复后并行 20 次全绿、 agent::thread_manager:: 连跑 5 次 72 passed;第 2 条用例是 #[cfg(target_os = "linux")], Windows 本机跑不到,已用真实 Linux 内核(WSL Alpine)验证命令形状(leader 活到 TERM、 同组后代完成 400ms 延迟清理 marker=done 0.41s、清理后组内零残留),CI 侧仍需跑 node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4 复核 --- .../src-tauri/src/agent/direct_events.rs | 21 ++++++++++++-- .../src-tauri/src/process_session/tests.rs | 10 ++++++- docs/project-memory/shared-memory/pitfalls.md | 28 ++++++++++++++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs index bafdb8b7f..429c97240 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs @@ -28,7 +28,22 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str = "game-creator-direct-active-turns-changed"; static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0); #[cfg(test)] -static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0); +thread_local! { + /// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主 + /// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随 + /// `TurnReservation::drop` 起整轮),并会在任意时刻广播「运行中的项目」变了。断言要观测的 + /// 是本测试自己触发的通知,不该被别的后台广播串台。 + /// + /// 这里必须留在测试线程作用域内:退役 `runtime_driver` 时这条口径曾被降级回进程级 + /// `AtomicU64`,于是 `agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` + /// 又回到偶发(同一片内前一个用例留下的后台回合广播进采样窗口)。 + /// + /// 代价:计数改成线程作用域后,**别的线程**上的重复 / 丢失通知不再被这条用例覆盖(那是 + /// 宿主后台回合的行为,本身就不该由单线程断言口径表达);要覆盖跨线程序,应另加用例 + /// 观察回合身份/序列号,而不是把计数器退回进程级。 + static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; @@ -54,7 +69,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa pub(crate) fn emit_direct_active_turns_changed() { let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1; #[cfg(test)] - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel); + let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1)); let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; @@ -66,7 +81,7 @@ pub(crate) fn emit_direct_active_turns_changed() { #[cfg(test)] pub(crate) fn direct_active_turns_event_test_count() -> u64 { - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire) + DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get) } pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index c96ef974c..9c47ae81c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -630,12 +630,20 @@ fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() { let root = directory.path(); init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project") .expect("initialize project"); + // leader 输出 READY 后 **等** 同组后台子进程,不自己先退出。terminate 必须先送到 trampoline + // 的 target group、再让组内后代把延迟清理跑完,这条断言才有确定性:leader 一旦先自然退出, + // trampoline 的 800ms 宽限就开始计时,客户端只要在那之后才发出 terminate(CI 高负载下调度 + // 完全可能 >800ms),会话就会先以 `exited` 收口,客户端再 terminate 只能读到既成事实。 + // 后台子进程仍留在同一进程组里、仍靠 TERM 触发延迟 400ms 的 marker 清理,语义不变。 + // 注意:这条用例的确定性靠的是「trap 里 400ms 清理 < 800ms 宽限」的时间余量(实测 0.41s), + // 不是真正的同步原语;若以后清理耗时逼近或超过宽限,应把 marker 拆成「收到 TERM 即时落盘 + + // 延迟内容」两步,或让宽限可注入,而不是放宽断言。 let spec = resolve_project_command_spec_at( root, "bash", &[ "-lc".to_string(), - "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; exit 0".to_string(), + "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; wait".to_string(), ], ".", 30, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4160d382b..870d3c627 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4153,7 +4153,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。 - 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。 - 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`。 -- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。 +- 验证:使用直接 bash target 启动同组后台子进程;leader 打印 READY 后用 `wait` 保持存活直到 terminate 真正到达(leader 若自己先退出,客户端调度就被拖进 800ms 宽限窗口,见 2026-10-04「graceful terminate 断言」条),仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。 ## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时 @@ -6306,3 +6306,29 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现行口径**:见 `development-workflow.md` 的「AGC 测试类型门禁」;tests 必须 0 error,不引入基线或豁免,只改类型层。 - **环境提示**:Node 24+ 默认启用实验性 Web Storage,全局 `localStorage` 未配置即 `undefined`,会顶掉 vitest 0.34 jsdom 环境里的 Storage,`recentProjectsHook.test.tsx`、`gameDistributionPublish.test.ts` 在 Node 26 上失败(HEAD 即如此)。项目按 `@types/node ^22.14` 面向 Node 22,本机用 fnm 装 v22.23.3 并设为 default(`~/.configure/profile.d/fnm.sh` 在 shell 启动时 `eval "$(fnm env)"`),Node 22 不暴露该全局、jsdom 的 localStorage 正常,仓库无需任何改动。不要用 `--localstorage-file=…` 绕:那只是把 Node 自己的文件型 Storage 顶上来,多个用例文件共享同一份状态。 - **关联**:`apps/ai-game-creator-shell/tsconfig.tests.json`、`apps/ai-game-creator-shell/package.json`、`apps/ai-game-creator-shell/tests/`。 + +## 2026-10-04 tracing 的 callsite interest 是进程级缓存:并发测试会把 span 调用点缓存成 never,span 看起来"根本没产生" + +- **现象**:`app::tests::http_tracing::unavailable_router_rejection_keeps_generated_context_and_headers` 偶发 `each rejected request should have one HTTP span left: 0 / right: 1`(`server-rs/crates/api-server/src/app.rs`),同一族断言在 `server-rs/crates/platform-llm/src/observability_tests.rs` 偶发 `provider_spans.len() == 1` 失败。同一批代码时而绿时而红,且失败用例都是最早跑的一批。 +- **原因**:`span!`/`info_span!` 宏在调用点缓存 interest 为 `never` 时**静默返回空 span**,连 `new_span` 都不会调用(tracing 0.1.44 `macros.rs` 的 `span!` 分支)。而 `DefaultCallsite` 的 interest **只在调用点首次被命中时算一次**,且计算时用 `DISPATCHERS.rebuilder()`——进程里只注册过一个 dispatcher 时它会退化成 `dispatcher::get_default()`,即**命中线程自己的 dispatcher**(tracing-core 0.1.36 `callsite.rs` 的 `Rebuilder::JustOne`)。libtest 默认并发跑同一二进制里的上千个用例,没有 subscriber 的测试线程一旦抢到 `http.request` / `llm.request` 调用点的首次注册,就会把它永久缓存成 `never`。`with_subscriber` 只在**每次 poll** 设线程本地 dispatcher,纠正不了这个进程级缓存,于是"span 没产生"。 +- **处理(现行口径)**:测试采集不要依赖 `with_subscriber`。改为在整个被测流程期间持有 scoped default(`tracing::subscriber::set_default`,其内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并用同一调用点预热探测到连续两轮采集成功为止;测试 subscriber 显式实现 `register_callsite`(目标 span 恒 `always`、其余 `sometimes`),避免自己的重建把其它调用点永久标记成 `never`。**不要**把断言改成"允许 0 个 span",**不要** sleep 赌时序。 +- **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。 +- **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。 + +## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台" + +- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。 +- **原因 1(通知计数串台)**:测试计数器 `DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT` 在 *2026-10-01 已按线程作用域隔离*(`thread_local! Cell`),但 2026-10-02 退役 `runtime_driver` 把这段接缝搬进 `agent/direct_events.rs` 时**降级回进程级 `static AtomicU64`**。`--test-threads=1` 只串行测试线程,宿主 `tauri::async_runtime` 的后台回合仍在自己的工作线程上广播「运行中的项目」变了,于是断言取到别的回合的广播。 +- **原因 2(terminate 竞速)**:测试命令里 leader 打印 READY 后立刻 `exit 0`,同组后代仍存活,trampoline 从 leader 被回收那一刻开始 `PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS=800ms` 宽限;客户端只要在 leader 退出后 >800ms 才发出 terminate(CI 高负载下要跨 durable record 写盘、registry 注册、线程 spawn),会话已按 `exited` 收口,terminate 只能读到既成事实——不是产品缺陷,是测试赌了客户端调度。 +- **处理(现行口径)**:①测试专用的通知计数必须留在测试线程作用域(`thread_local! Cell`),不要用进程级 Atomic;②graceful terminate 用例的 leader 打印 READY 后要用 `wait` 等后台子进程,让 terminate 必然落在会话仍 running 时(断言、trap、`sleep 0.4`、marker 名字都不改)。 +- **验证**:①修复前把计数器临时改回 Atomic 时同一并行口径 42/50 红;修复后并行 50 次 0 红、`--test-threads=1` 200 次 0 红、CI 现场等价块(145 用例)3 次 0 红;②该用例是 `#[cfg(target_os = "linux")]`,Windows 本机跑不到,用真实 Linux 内核(WSL Alpine)验证命令形状:leader 活到 TERM、同组后代完成 400ms 延迟清理(marker=done,real 0.41s)、清理后组内零残留;CI 侧仍应跑 `node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 复核。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs`、`apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs`、`command_sandbox_trampoline.rs`。 + +## 2026-10-04 SPA 深链的前缀路由(`/pay/`)必须进 allowlist,裸前缀不够 + +- **现象**:只把 `/pay`、`/profile/payment` 加进 Nginx SPA allowlist 让门禁变绿,并不代表真实收银台链接能打开。`payment.rs` 生成的 `checkoutUrl` 是 `/pay/`,三份模板原先只有 `location ~* "^/(?:…|pay|profile|profile/payment|…)/?$"` 这条精确 location,深链落回默认 `location /` 的 `try_files $uri $uri/ =404` → **404**。 +- **原因/代价**:前端 `resolveSelectionStageFromPath` 用 `startsWith('/pay/')` 判定并取最后一个路径段当 token,Nginx / Pingora 侧却只放行裸前缀(2026-10-03 的支付接入 commit 只改了前端路由源)。同一批漂移里还有一条被掩盖的失败:`check:nginx-spa-routes` 在 `npm run lint` 链里先跑,它红的时候看不到后面的 `check:pingora-route-parity` 也红(Pingora `MAIN_SPA_PATHS` 缺 `/pay`、`/profile/payment`)——修一条门禁时要把整条链跑到底,不要只看第一个红。 +- **处理(现行口径)**:前缀路由的真相源是 `src/routing/activeAppPageRoutes.ts` 的 `APP_PREFIX_ROUTE_ENTRIES`。`scripts/check-nginx-spa-routes.mjs` 据此要求三份模板都写锚定前缀 location(`location ~* "^/pay/[^/]+/?$"`,只放行「前缀 + 恰好一个路径段」,裸前缀仍由精确 location 负责,并要求镜像精确 location 的维护闸);`check:pingora-route-parity` 要求 Rust 的 `MAIN_SPA_PREFIX_PATHS` 与 `is_main_spa_prefix_path` 同口径(大小写不敏感、多段与 `/payment/x` 这类同名邻居不收)。 +- **别踩**:不要写成裸前缀正则(`^/pay`)——它会吞掉 `/payment/x`、`/paycheckout/x` 这类同名邻居;也不要把深链塞进精确 allowlist 的 alternatives 里(`pay` 的 alternatives 只匹配 `/pay`)。 +- **判据/取证**:`node --test scripts/check-nginx-spa-routes.test.mjs`(正/反用例,含「写回精确匹配即红」)、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix`;线上复验 `curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/` → 200 且正文与 `/` 同一份 `index.html`。 +- **关联**:`scripts/check-nginx-spa-routes.mjs`、`deploy/pingora/nginx-route-parity.matrix.json`、`server-rs/crates/pingora-gateway/src/main.rs`、`server-rs/crates/api-server/src/payment.rs`、`deploy/nginx/genarrative.conf`。 From ad0430fcd2beba2b2285385c56baa98275b9ed80 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 03:25:54 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E6=94=B6=E9=93=B6?= =?UTF-8?q?=E5=8F=B0=E6=B7=B1=E9=93=BE=20`/pay/`=20?= =?UTF-8?q?=E7=9A=84=E5=89=8D=E7=BC=80=E8=B7=AF=E7=94=B1=EF=BC=88Nginx=20?= =?UTF-8?q?=E4=B8=89=E6=A8=A1=E6=9D=BF=20+=20Pingora=20+=20=E9=97=A8?= =?UTF-8?q?=E7=A6=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 现状:只把 `/pay`、`/profile/payment` 加进 allowlist 只能让 check:nginx-spa-routes 变绿; payment.rs 生成的 checkoutUrl 是 `/pay/`,深链仍落默认 location 的 try_files → 404。同一批漂移里 check:pingora-route-parity 也是红的(Pingora MAIN_SPA_PATHS 缺 /pay、/profile/payment),只是被 lint 链里先失败的门禁掩盖,修一条要跑到链尾 - 真相源:src/routing/activeAppPageRoutes.ts 新增 APP_PREFIX_ROUTE_ENTRIES ('/pay' → payment-checkout),resolveSelectionStageFromPath 改用它 - 门禁:scripts/check-nginx-spa-routes.mjs 要求三份模板都有锚定前缀 location `location ~* "^/pay/[^/]+/?$"`(裸前缀仍由精确 location 负责;前缀 location 必须镜像精确 location 的维护闸与 try_files 回退);新增 scripts/check-nginx-spa-routes.test.mjs 正/反用例 (把前缀写成精确匹配或过宽裸前缀都会红),由 npm run check:nginx-spa-routes 一起执行; check-pingora-route-parity 新增 MAIN_SPA_PREFIX_PATHS 与前端前缀路由的逐条比对 - 模板:deploy/nginx/genarrative.conf、deploy/nginx/genarrative-dev-http.conf、 deploy/container/nginx.conf 各加一条锚定前缀 location - Pingora:MAIN_SPA_PATHS 补 /pay、/profile/payment;新增 MAIN_SPA_PREFIX_PATHS 与 is_main_spa_prefix_path(大小写不敏感,只认「前缀 + 恰好一段」),矩阵新增 pay_checkout_spa_fallback 用例,并给网关补一条前缀正/反单测 - 文档:Pingora 试点文档的路由表与门禁说明、deploy/nginx/README 与本地开发/生产运维文档 同步前缀路由口径与线上 curl 复验方式 - 本地实跑:node --test scripts/check-nginx-spa-routes.test.mjs(4 passed)、 node scripts/check-nginx-spa-routes.mjs(OK,14 SPA routes / 1 prefix routes / 3 templates)、 npm run check:pingora-route-parity(OK,25 routes)、 cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix(2 passed) --- deploy/container/nginx.conf | 5 + deploy/nginx/README.md | 2 +- deploy/nginx/genarrative-dev-http.conf | 12 ++ deploy/nginx/genarrative.conf | 12 ++ deploy/pingora/nginx-route-parity.matrix.json | 14 ++ ...开发运维】Pingora独立网关试点-2026-06-11.md | 5 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- package.json | 2 +- scripts/check-nginx-spa-routes.mjs | 162 ++++++++++++++++-- scripts/check-nginx-spa-routes.test.mjs | 47 +++++ scripts/check-pingora-route-parity.mjs | 29 +++- server-rs/crates/pingora-gateway/src/main.rs | 52 ++++++ src/routing/activeAppPageRoutes.ts | 19 +- 13 files changed, 342 insertions(+), 23 deletions(-) create mode 100644 scripts/check-nginx-spa-routes.test.mjs diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 841d38e63..f9dd352eb 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -160,6 +160,11 @@ http { location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { try_files $uri /index.html =404; } + + # 收银台深链 `/pay/`:只放行裸前缀会让真实收银台链接落到默认 location 变 404。 + location ~* "^/pay/[^/]+/?$" { + try_files $uri /index.html =404; + } # END GENARRATIVE MAIN SPA ROUTES location / { diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index c931fc687..e022e295c 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -107,4 +107,4 @@ curl -sSI -H 'Accept-Encoding: br' \ - 发行入口不使用 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`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。 +- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index c3aa5f7c1..e2879dff2 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -215,6 +215,18 @@ server { try_files $uri /index.html =404; } + + # 收银台深链 `/pay/`:token 由前端从最后一个路径段读取(payment.rs 生成该链接), + # 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。 + location ~* "^/pay/[^/]+/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } # END GENARRATIVE MAIN SPA ROUTES location / { diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 8605fb288..15d8c4d04 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -243,6 +243,18 @@ server { try_files $uri /index.html =404; } + + # 收银台深链 `/pay/`:token 由前端从最后一个路径段读取(payment.rs 生成该链接), + # 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。 + location ~* "^/pay/[^/]+/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } # END GENARRATIVE MAIN SPA ROUTES location / { diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 1d3f8b993..7df7ff384 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -311,6 +311,20 @@ }, "docs": ["主站 SPA allowlist", "失败回退 `/index.html`"] }, + { + "id": "pay_checkout_spa_fallback", + "samplePath": "/pay/checkout-token", + "expect": { + "kind": "static", + "root": "web", + "mode": "spa_fallback" + }, + "nginx": { + "production": ["location ~* \"^/pay/[^/]+/?$\""], + "development": ["location ~* \"^/pay/[^/]+/?$\""] + }, + "docs": ["收银台深链 `/pay/`", "前缀 + 恰好一个路径段"] + }, { "id": "games_spa_fallback", "samplePath": "/games/detail", diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index a29e55722..e56f73911 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -63,7 +63,7 @@ npm run check:pingora-release-readiness `check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。 -`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。 +`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `APP_PREFIX_ROUTE_ENTRIES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠、前缀路由的「前缀 + 恰好一个路径段」锚定形状(收银台深链 `/pay/` 必须整体回退 `index.html`,只放行裸前缀会让真实链接落到默认 location 变 404)和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。该脚本自带正/反用例(`node --test scripts/check-nginx-spa-routes.test.mjs`,由 `npm run check:nginx-spa-routes` 一起执行),防止有人把前缀路由改回精确匹配。 `check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由,并做**反向覆盖**(模板里的每条 `location` 都必须被矩阵声明)。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。`check:nginx-spa-routes` 与 `check:pingora-route-parity` 已串进 `npm run lint`(因此 `check:repository-ci`、CI 与 pre-push 都会执行),接线本身由 `check:production-ops` 的 guardrail 锁定。 @@ -535,7 +535,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 | | `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 | | `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 | -| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/profile`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 | +| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/pay`、`/profile`、`/profile/payment`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 | +| 主站 SPA 前缀路由 | 收银台深链 `/pay/` 走 `MAIN_SPA_PREFIX_PATHS`:只放行「前缀 + 恰好一个路径段」(大小写不敏感),裸前缀由上面的精确集合负责,多段路径与 `/payment/x` 这类前缀同名邻居都不进 SPA fallback;Nginx 三份模板同口径写成 `location ~* "^/pay/[^/]+/?$"`,由矩阵的 `pay_checkout_spa_fallback` 用例固定。 | | 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 | SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 9c0acccb8..73b6b7d87 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -748,10 +748,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - 门禁: ```bash -# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由) +# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由与收银台深链前缀路由) npm run check:nginx-spa-routes ``` +线上/预发复验收银台深链时,除了 `npm run check:nginx-spa-routes`,还要用真实请求确认 `/pay/` 返回 SPA 外壳而不是 404(`curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/` 应为 200,正文与 `/` 同一份 `index.html`)。`payment.rs` 生成的 `checkoutUrl` 就是这个路径,只把 `/pay` 加进 allowlist 会让真实链接落到默认 location 变 404。 + 本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games//` 验证:入口文档 200 `text/html`、`/games//assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。 #### 游戏分发可观测事件 diff --git a/package.json b/package.json index a5001d91c..ca24fab4d 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "check:production-api-deploy": "node scripts/check-production-api-deploy.mjs", "check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs", "check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs", - "check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs", + "check:nginx-spa-routes": "node --test scripts/check-nginx-spa-routes.test.mjs && node scripts/check-nginx-spa-routes.mjs", "check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs", "check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs", "check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs", diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index 7a1411443..47d61421f 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -1,9 +1,13 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts'; const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx'; +const APP_PREFIX_ROUTE_ENTRIES_PATTERN = + /const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u; const COMPATIBILITY_ROUTES = []; const NGINX_PATHS = [ 'deploy/nginx/genarrative.conf', @@ -86,7 +90,92 @@ function compareRouteSets(actualRoutes, expectedRoutes, label) { } } -function validateNginxRoutes(nginxPath, expectedRoutes) { +/** + * 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。 + * 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。 + */ +export function buildPrefixRouteNginxPattern(prefix) { + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + return `^${escapedPrefix}/[^/]+/?$`; +} + +/** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */ +export function collectPrefixRoutePatternFailures(pattern, prefix) { + const failures = []; + const expected = buildPrefixRouteNginxPattern(prefix); + if (pattern !== expected) { + failures.push( + `前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`, + ); + return failures; + } + const matcher = new RegExp(pattern, 'iu'); + for (const sample of [ + `${prefix}/checkout-token`, + `${prefix.toUpperCase()}/CheckOutToken/`, + ]) { + if (!matcher.test(sample)) { + failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`); + } + } + for (const sample of [ + prefix, + `${prefix}/`, + `${prefix}/two/segments`, + `${prefix}suffix/segment`, + ]) { + if (matcher.test(sample)) { + failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`); + } + } + return failures; +} + +export function collectExpectedPrefixRoutes() { + const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8'); + const entries = extractSourceBlock( + appPageRoutes, + APP_PREFIX_ROUTE_ENTRIES_PATTERN, + `${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`, + ); + const routes = Array.from( + entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu), + (match) => ({ path: match[1], stage: match[2] }), + ); + const uniqueRoutes = new Map(routes.map((route) => [route.path, route])); + for (const route of uniqueRoutes.values()) { + if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) { + fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`); + } + } + return [...uniqueRoutes.values()].sort((left, right) => + left.path.localeCompare(right.path), + ); +} + +function findRegexLocationBody(block, pattern) { + for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) { + if (match[1] !== pattern) { + continue; + } + const openBrace = match.index + match[0].length - 1; + let depth = 0; + for (let index = openBrace; index < block.length; index += 1) { + if (block[index] === '{') { + depth += 1; + } else if (block[index] === '}') { + depth -= 1; + if (depth === 0) { + return block.slice(openBrace + 1, index); + } + } + } + return null; + } + return null; +} + +function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) { const source = readFileSync(nginxPath, 'utf8'); const blockStart = source.indexOf(SPA_BLOCK_START); const blockEnd = source.indexOf(SPA_BLOCK_END); @@ -140,6 +229,44 @@ function validateNginxRoutes(nginxPath, expectedRoutes) { } } + // 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认 + // location 变成 404(例如收银台 `/pay/`)。 + const exactLocationBody = findRegexLocationBody(block, nginxPattern); + const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }'; + for (const { path: prefix } of prefixRoutes) { + if (!expectedRoutes.includes(prefix)) { + fail( + `${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`, + ); + continue; + } + const expectedPattern = buildPrefixRouteNginxPattern(prefix); + const prefixLocationBody = findRegexLocationBody(block, expectedPattern); + if (prefixLocationBody === null) { + fail( + `${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`, + ); + continue; + } + for (const failure of collectPrefixRoutePatternFailures( + expectedPattern, + prefix, + )) { + fail(`${nginxPath} ${failure}`); + } + if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) { + fail(`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`); + } + if ( + exactLocationBody?.includes(maintenanceGuard) && + !prefixLocationBody.includes(maintenanceGuard) + ) { + fail( + `${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`, + ); + } + } + const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length); if (!defaultLocation.includes('try_files $uri $uri/ =404;')) { fail( @@ -218,20 +345,27 @@ function validateMaintenanceInternalBypass() { } export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); +export const expectedPrefixRoutes = collectExpectedPrefixRoutes(); -for (const nginxPath of NGINX_PATHS) { - validateNginxRoutes(nginxPath, expectedMainSpaRoutes); -} -validateMaintenanceInternalBypass(); +const isMainModule = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; -if (failures.length > 0) { - console.error('[check:nginx-spa-routes] FAILED'); - for (const failure of failures) { - console.error(`- ${failure}`); +if (isMainModule) { + for (const nginxPath of NGINX_PATHS) { + validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes); } - process.exit(1); -} + validateMaintenanceInternalBypass(); -console.log( - `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`, -); + if (failures.length > 0) { + console.error('[check:nginx-spa-routes] FAILED'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + console.log( + `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`, + ); +} diff --git a/scripts/check-nginx-spa-routes.test.mjs b/scripts/check-nginx-spa-routes.test.mjs new file mode 100644 index 000000000..09d492eb0 --- /dev/null +++ b/scripts/check-nginx-spa-routes.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + buildPrefixRouteNginxPattern, + collectExpectedPrefixRoutes, + collectPrefixRoutePatternFailures, +} from './check-nginx-spa-routes.mjs'; + +test('前缀路由按「前缀 + 恰好一个路径段 + 可省略尾部斜杠」锚定,裸前缀交给精确 location', () => { + const pattern = buildPrefixRouteNginxPattern('/pay'); + assert.equal(pattern, '^/pay/[^/]+/?$'); + assert.deepEqual(collectPrefixRoutePatternFailures(pattern, '/pay'), []); + + const matcher = new RegExp(pattern, 'iu'); + assert.ok(matcher.test('/pay/checkout-token')); + assert.ok(matcher.test('/PAY/CheckOutToken/')); + assert.ok(!matcher.test('/pay')); + assert.ok(!matcher.test('/pay/')); + assert.ok(!matcher.test('/pay/two/segments')); + assert.ok(!matcher.test('/paycheckout/token')); + assert.ok(!matcher.test('/payment/token')); +}); + +test('把前缀路由写回精确匹配(只放行裸前缀)会被门禁拒绝', () => { + const failures = collectPrefixRoutePatternFailures('^/pay/?$', '/pay'); + assert.equal(failures.length, 1); + assert.match(failures[0], /必须锚定为 \^\/pay\/\[\^\/\]\+\/\?\$/u); +}); + +test('过宽的裸前缀形状(会吞掉同名邻居路径)也会被门禁拒绝', () => { + const failures = collectPrefixRoutePatternFailures('^/pay', '/pay'); + assert.equal(failures.length, 1); + assert.match(failures[0], /必须锚定为/u); +}); + +test('前缀路由真相源来自前端路由表且当前包含 /pay', () => { + const prefixRoutes = collectExpectedPrefixRoutes(); + assert.ok( + prefixRoutes.some((route) => route.path === '/pay'), + 'APP_PREFIX_ROUTE_ENTRIES 必须声明 /pay', + ); + for (const route of prefixRoutes) { + assert.match(route.path, /^\//u); + assert.notEqual(route.stage.trim(), ''); + } +}); diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index 1a083564b..82ca93cc7 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs'; -import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs'; +import { + expectedMainSpaRoutes, + expectedPrefixRoutes, +} from './check-nginx-spa-routes.mjs'; const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json'; const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf'; @@ -289,10 +292,34 @@ function validateRustMainSpaRoutes() { } } +function validateRustMainSpaPrefixPaths() { + const prefixBlock = pingoraGatewaySource.match( + /const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u, + ); + if (!prefixBlock) { + fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。'); + return; + } + const rustPrefixes = Array.from( + prefixBlock[1].matchAll(/"([^"]+)"/gu), + (match) => match[1], + ); + const expected = expectedPrefixRoutes.map((route) => route.path); + const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix)); + const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix)); + if (missing.length > 0) { + fail(`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`); + } + if (extra.length > 0) { + fail(`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`); + } +} + validateMatrixShape(); validateRustTestUsesMatrix(); validateNginxLocationsAreCovered(); validateRustMainSpaRoutes(); +validateRustMainSpaPrefixPaths(); if (failures.length > 0) { console.error('[check:pingora-route-parity] FAILED'); diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 50e5bf74b..51df31e98 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -72,10 +72,17 @@ const MAIN_SPA_PATHS: &[&str] = &[ "/games/mine", "/games/play", "/games/publish", + "/pay", "/profile", + "/profile/payment", "/project", ]; +// 带动态段的前缀路由,必须与前端 `APP_PREFIX_ROUTE_ENTRIES` 以及三份 nginx 模板的 +// `location ~* "^/pay/[^/]+/?$"` 同口径:只放行「前缀 + 恰好一个路径段」,裸前缀由 +// `MAIN_SPA_PATHS` 负责;`npm run check:pingora-route-parity` 会逐条比对这份 allowlist。 +const MAIN_SPA_PREFIX_PATHS: &[&str] = &["/pay"]; + #[derive(Clone, Debug)] struct GatewayConfig { listen_addr: String, @@ -1955,6 +1962,21 @@ fn is_main_spa_path(path: &str) -> bool { MAIN_SPA_PATHS .iter() .any(|candidate| normalized.eq_ignore_ascii_case(candidate)) + || is_main_spa_prefix_path(normalized) +} + +/// 前缀路由(带动态段):`<前缀>/<恰好一个路径段>`,大小写不敏感(与 nginx `location ~*` 同口径)。 +/// 裸前缀、多段路径和前缀同名邻居(如 `/payment/x`)都不算命中。 +fn is_main_spa_prefix_path(normalized: &str) -> bool { + let mut segments = normalized.trim_start_matches('/').split('/'); + let (Some(first), Some(second), None) = (segments.next(), segments.next(), segments.next()) + else { + return false; + }; + !second.is_empty() + && MAIN_SPA_PREFIX_PATHS + .iter() + .any(|prefix| prefix.trim_start_matches('/').eq_ignore_ascii_case(first)) } fn is_maintenance_page_asset(path: &str) -> bool { @@ -3310,6 +3332,36 @@ mod tests { } } + #[test] + fn pay_checkout_deep_link_uses_main_spa_prefix_route() { + let spa_fallback = RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::SpaFallback, + }); + // 裸前缀由 MAIN_SPA_PATHS 精确命中,收银台深链 `/pay/` 由前缀路由命中, + // 两者都与 Nginx 的 `location ~* "^/pay/[^/]+/?$"` 同口径(大小写不敏感)。 + for path in [ + "/pay", + "/pay/", + "/PAY", + "/pay/checkout-token", + "/PAY/CheckOutToken/", + ] { + assert_eq!(classify_path(path), spa_fallback, "path: {path}"); + } + // 多段路径与前缀同名邻居不允许被吞进 SPA fallback。 + for path in ["/pay/two/segments", "/payment/token", "/paycheckout/token"] { + assert_eq!( + classify_path(path), + RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::Exact, + }), + "path: {path}" + ); + } + } + #[test] fn applies_configured_body_limit_to_generic_api_routes_only() { let mut generic_api = classify_path("/api/assets/history"); diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts index 8c91e2e6e..0c32ec9e9 100644 --- a/src/routing/activeAppPageRoutes.ts +++ b/src/routing/activeAppPageRoutes.ts @@ -18,6 +18,16 @@ const STAGE_ROUTE_ENTRIES = [ ['game-publish', '/games/publish'], ] as const satisfies readonly (readonly [SelectionStage, string])[]; +/** + * 带动态段、需要整体回退 index.html 的对外路由前缀 → 归属 stage。 + * 例如收银台深链 `/pay/`(后端 `payment.rs` 用 `format!("/pay/{}", token)` 生成, + * 前端从最后一个路径段读 token)。Nginx 必须按「前缀 + 恰好一个路径段」的锚定形状放行: + * 只放行裸前缀会让真实收银台链接落到默认 location 变成 404,放行过宽又会把未知路径吞掉。 + */ +export const APP_PREFIX_ROUTE_ENTRIES = [ + ['/pay', 'payment-checkout'], +] as const satisfies readonly (readonly [string, SelectionStage])[]; + export const APP_STAGE_ROUTES: Record = Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record; @@ -41,10 +51,13 @@ export function normalizeAppPath(pathname: string) { export function resolveSelectionStageFromPath( pathname: string, ): SelectionStage { - if (normalizeAppPath(pathname).startsWith('/pay/')) { - return 'payment-checkout'; + const normalizedPath = normalizeAppPath(pathname); + for (const [prefix, stage] of APP_PREFIX_ROUTE_ENTRIES) { + if (normalizedPath.startsWith(`${prefix}/`)) { + return stage; + } } - return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform'; + return ROUTE_STAGE_BY_PATH.get(normalizedPath) ?? 'platform'; } export function resolveInitialSelectionStageFromPath( From 8b11dc4e7aed50ff8091c8e11128eb9123eb1af8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 03:25:54 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E8=AF=AD=E4=B9=89=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=9A=84=E6=80=BB=E6=95=B0=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E6=94=B9=E5=9B=9E=E5=AD=97=E9=9D=A2=E9=87=8F=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=81=92=E7=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - names.len() == MCP_OPERATIONS.len() + TOOLS.len() 在 BTreeSet 去重构造下恒真、没有判别力; 改回固定 47(32 条 legacy 工具 + 15 条语义工具),并注明上面的插入断言已保证两组名字互不覆盖 - 本地实跑:cargo test --locked -p api-server --manifest-path server-rs/Cargo.toml --bin api-server semantic → 18 passed --- .../crates/api-server/src/external_mcp/semantic/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs index a27dbc5b9..1983ff877 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -55,7 +55,9 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { .is_some_and(|text| !text.is_empty()) ); } - assert_eq!(names.len(), MCP_OPERATIONS.len() + TOOLS.len()); + // 37 条 OpenAPI 操作里 5 条元数据/入口操作被 x-mcp-excluded 排除,剩下 32 条 legacy 工具 + // 加 15 条语义工具共 47 条;上面的插入断言已保证两组名字互不覆盖,这里固定总数防止漏注册。 + assert_eq!(names.len(), 47); } #[test]