diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 8a9ccfb63..ad0cb2396 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -796,6 +796,12 @@ function ProviderMentionMenu({ [onOpenChange, trigger], ); + // 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`match` 始终保持纯函数, + // 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。 + useEffect(() => { + provider.onMenuQueryChange?.(query); + }, [provider, query]); + const options = useMemo(() => { if (query === null || !provider.match) return []; return provider diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts index e387e9351..e3c34da47 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts @@ -62,8 +62,10 @@ function matchesSkillQuery(skill: SkillCatalogItem, query: string) { * Skill 引用的 provider(宿主 hook)。 * * 与资源 provider 不同,Skill 候选是**异步**的应用级读取,所以它必须是一份 React 状态: - * `match` 第一次被调用(即用户敲出 `$`)时发起读取,结果到了之后宿主重渲染, - * 输入区随之拿到新的候选。读取本身不进输入区,只有宿主才知道这条路该不该存在—— + * 用户敲出 `$` 打开候选菜单时(`onMenuQueryChange` 收到非 `null`,由输入区在 effect 里回调) + * 发起读取,结果到了之后宿主重渲染,输入区随之拿到新的候选。 + * `match` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。 + * 读取本身不进输入区,只有宿主才知道这条路该不该存在—— * 目前只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill。 */ export function useSkillReferenceProvider(): ReferenceProvider { @@ -76,8 +78,8 @@ export function useSkillReferenceProvider(): ReferenceProvider { void loadSkillCatalog() .then((items) => setSkills(items)) .catch((error) => { - // 失败不静默:一次瞬时失败下一次 `match` 会重试,但持续失败至少要在控制台留痕, - // 否则用户看到的是「敲 `$` 什么都没有」,排障时没有任何线索。 + // 失败不静默:一次瞬时失败会在下一次菜单查询变化(继续敲字或重开菜单)时重试, + // 但持续失败至少要在控制台留痕,否则用户看到的是「敲 `$` 什么都没有」,排障时没有任何线索。 console.warn( '[skill-reference] Skill 目录读取失败,下次触发重试', error, @@ -90,8 +92,11 @@ export function useSkillReferenceProvider(): ReferenceProvider { return useMemo( () => ({ trigger: '$', + // 懒加载走菜单回调:只有用户真的敲出 `$`(query 非 null)时才值得读这份应用级目录。 + onMenuQueryChange: (query) => { + if (query !== null) ensureCatalog(); + }, match: (query) => { - ensureCatalog(); const seen = new Set(); return skills .filter((skill) => { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts index 7c60440b3..ca138d75f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts @@ -18,8 +18,21 @@ export type ReferenceProvider = { * (附件、运行画面区域就是这样进来的)。 */ trigger: string | null; - /** 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。 */ + /** + * 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。 + * + * **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会 + * 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。 + */ match?: (query: string) => ChatReference[]; + /** + * 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在 + * 带触发符的 provider 上调用**。 + * + * 这是懒加载的唯一入口:例如 Skill 目录是应用级异步读取,第一次收到非 `null` 时再发起, + * 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。 + */ + onMenuQueryChange?: (query: string | null) => void; /** canonical part → 引用;不属于本 provider 或暂时无法解析时返回 `null`。 */ toReference: (part: DirectCodexUserContentPart) => ChatReference | null; /** 引用身份刷新(资源改名等);不属于本 provider 时返回 `null`,原样返回表示无需改写。 */ diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index d06527f19..d7e6bf23c 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -305,7 +305,7 @@ describe('运行画面区域 provider', () => { }); describe('Skill provider', () => { - it('触发符是 `$`,挂载时不发查询,第一次 `match` 才读应用级目录', async () => { + it('触发符是 `$`:挂载与 `match` 都不发查询,菜单第一次打开时才读应用级目录', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; @@ -319,8 +319,15 @@ describe('Skill provider', () => { expect(result.current.trigger).toBe('$'); expect(invoke).not.toHaveBeenCalled(); + // `match` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。 act(() => { - result.current.match?.(''); + expect(result.current.match?.('')).toEqual([]); + }); + expect(invoke).not.toHaveBeenCalled(); + + // 懒加载只由菜单回调触发(菜单打开 → 输入区在 effect 里给非 null 的 query)。 + act(() => { + result.current.onMenuQueryChange?.(''); }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); @@ -330,6 +337,11 @@ describe('Skill provider', () => { }); // 目录只读一次:后续每次敲 `$` 都复用同一份候选。 expect(invoke).toHaveBeenCalledTimes(2); + // 菜单关闭(query 为 `null`)不触发读取。 + act(() => { + result.current.onMenuQueryChange?.(null); + }); + expect(invoke).toHaveBeenCalledTimes(2); expect(result.current.match?.('测试')?.[0]).toMatchObject({ type: 'skill', name: 'agc-test-skill', @@ -379,7 +391,7 @@ describe('Skill provider', () => { const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { - result.current.match?.(''); + result.current.onMenuQueryChange?.(''); }); await waitFor(() => { expect(result.current.match?.('')).toHaveLength(8); @@ -410,14 +422,14 @@ describe('Skill provider', () => { const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { - result.current.match?.(''); + result.current.onMenuQueryChange?.(''); }); - // 一次 `match` 读两份目录:内置 Skill 与客户端扩展。 + // 一次触发读两份目录:内置 Skill 与客户端扩展。 await waitFor(() => { expect(invoke).toHaveBeenCalledTimes(2); }); act(() => { - result.current.match?.(''); + result.current.onMenuQueryChange?.('a'); }); await waitFor(() => { expect(result.current.match?.('')).toHaveLength(1); diff --git a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md index 43c116452..bd50d9767 100644 --- a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md +++ b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md @@ -2,7 +2,7 @@ 状态:已接受 -引用输入区(`ResourceReferenceInput`)不再自己去拿候选。素材清单、Skill 目录、缩略图预览这些项目级或应用级事实全部改由宿主注入:输入区只接受一组「引用 provider」(每种引用一份,暴露触发符、候选、身份解析与正文文本形态),宿主按需选择性注入——只注入资源 provider 就只存在 `@`,再注入 Skill provider 才出现 `$`。素材选择面板同时拆成独立组件(`ResourceReferencePicker`),自己拿数据、由宿主渲染,确认后经输入区句柄的 `insertReferences` 交回。 +引用输入区(`ResourceReferenceInput`)不再自己去拿候选。素材清单、Skill 目录、缩略图预览这些项目级或应用级事实全部改由宿主注入:输入区只接受一组「引用 provider」(每种引用一份,暴露触发符、候选、身份解析与正文文本形态;需要为菜单开合拉一次数据的 provider 另有 `onMenuQueryChange`,由输入区在 effect 里回调),宿主按需选择性注入——只注入资源 provider 就只存在 `@`,再注入 Skill provider 才出现 `$`。素材选择面板同时拆成独立组件(`ResourceReferencePicker`),自己拿数据、由宿主渲染,确认后经输入区句柄的 `insertReferences` 交回。 这么改的理由是双向的。留在组件里的读取属于后端副作用,越过了「共享表现组件不拥有后端副作用与正式业务状态」的边界;而「组件自己查 Skill 目录」又让所有宿主无差别获得 `$` 候选,可只有 DirectProject 那条路径会把 `agc_skill_reference` 解析成真 Skill,其余宿主只把它退化成字面文本,形成误导入口。注入之后「没注入就没有这类引用」成为默认,可见性不再需要额外的开关。 @@ -16,5 +16,6 @@ - 输入区删除 `versions` / `activeVersionId` / `showTriggerButton` / `onReferencePickerOpen` / `skills`,`assets` 被注入的 provider 取代;`projectPath` 只保留给输入区自己的润色链路。 - 输入区与宿主之间只留三个通用接缝:`providers`(引用来源)、`inputActions`(操作排里的宿主控件,例如 `@` 触发钮)、`submitSuppressed`(宿主浮层打开时 Enter 让位)。引用种类一个都不进输入区。 +- `provider.match` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 - 附件并入 `ChatReference`,编辑器收敛为单一引用节点类型,附件 chip 的 DOM 契约逐字保留;附件导入成功后以芯片进入正文,失败不插入;控制器不再持有附件数组,`MAX_CHAT_COMPOSER_ATTACHMENTS` 改为按草稿中的附件芯片数计算,导入进行中禁止发送。 - 用户可见行为保持不变,唯一例外是已裁决的缺陷修复:非 DirectProject 宿主不再出现 `$` Skill 候选。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3504e8f55..28b5cdc7c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,7 +3,7 @@ ## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离 - 背景:`ResourceReferenceInput`(`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`)同时承担「拿数据」与「编辑数据」:素材以未过滤 manifest 传入后由组件自己派生候选、显示名与「当前版本素材」scope,Skill 候选由组件自己 invoke `list_agc_skill_catalog` 与 `list_client_extensions`(只在用户敲出 `$` 时触发),素材选择面板与缩略图预览 invoke 也住在组件内部。后果是 5 个宿主(DirectProject 聊天、策划输入盒、画布生成面板、资源卡快速编辑、测试夹具)无差别获得 `$` Skill 候选,而只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill(Rust `direct_codex_user_item_to_codex_turn_input`,`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs`),其余宿主只把它退化成字面文本,形成误导入口。 -- 决策(注入):输入区只接受宿主注入的 `providers: readonly ReferenceProvider[]`。每种引用一个独立工厂——`createResourceReferenceProvider({ assets })`、`useSkillReferenceProvider()`(Skill 候选是异步的应用级读取,所以它同时是宿主 hook:第一次 `match` 时才发起读取,结果到了宿主重渲染,读取不进输入区),附件与运行画面区域为静默 provider(无触发符、无候选);**没有总装 builder**,宿主按需选择性注入。provider 暴露 `trigger` / `match(query)` / `toReference(part)` / `refresh(reference)` / `mentionToken(part)` 与可选的 `isReady()`(数据未到齐时输入区先不把初始草稿落成文本,否则草稿里的引用会被静默丢掉),输入区只按数组顺序取第一个非空回答,不判断引用种类。 +- 决策(注入):输入区只接受宿主注入的 `providers: readonly ReferenceProvider[]`。每种引用一个独立工厂——`createResourceReferenceProvider({ assets })`、`useSkillReferenceProvider()`(Skill 候选是异步的应用级读取,所以它同时是宿主 hook:第一次候选菜单打开时才发起读取——`match` 保持纯函数,懒加载走 `onMenuQueryChange`,由输入区在 effect 里回调;结果到了宿主重渲染,读取不进输入区),附件与运行画面区域为静默 provider(无触发符、无候选);**没有总装 builder**,宿主按需选择性注入。provider 暴露 `trigger` / `match(query)` / `toReference(part)` / `refresh(reference)` / `mentionToken(part)` 与可选的 `onMenuQueryChange(query)`(菜单懒加载的唯一入口,`match` 保持纯函数)与 `isReady()`(数据未到齐时输入区先不把初始草稿落成文本,否则草稿里的引用会被静默丢掉),输入区只按数组顺序取第一个非空回答,不判断引用种类。 - 决策(分离):素材选择面板拆成独立组件 `ResourceReferencePicker`(含一个可复用的 `@` 触发钮 `ResourceReferencePickerAction`),自己拿数据(`assets` / `versions` / `activeVersionId` / `projectPath` 与缩略图预览 invoke),由宿主渲染并把确认结果交给输入区句柄的 `insertReferences`——这是两者之间唯一的接缝。输入区删除 `versions` / `activeVersionId` / `showTriggerButton` / `openPicker` / `skills`,改为收宿主的 `inputActions`(操作排槽位,放触发钮)与 `submitSuppressed`(宿主浮层打开时 Enter 不提交,与候选菜单同一口径);`projectPath` 只保留给输入区自己的润色链路。 - 决策(附件):附件并入 `ChatReference`,编辑器收敛为单一引用节点类型,附件 chip 的 DOM 契约逐字保留;附件在**导入成功后**以芯片插入正文,`status === 'failed'` 或异常一律不插入;控制器不再持有 `attachments` 数组,`ComposerPendingAttachments` 与提交时的附件 parts 拼接一并删除,单次上限 `MAX_CHAT_COMPOSER_ATTACHMENTS = 8` 改为按草稿中的附件芯片数计算(否则上限失效),导入进行中禁止发送。 - 决策(显示口径):`directCodexContentToPromptText` 的引用 token 前后各补一个空白(相邻已是空白或相邻即另一个 token 时不重复),并加 `// TODO we will rewrite this with ref as component later.`;该函数的 `resourceId → 显示名` 反查改由调用方注入,函数自己不再读 manifest。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index b246b591a..940c49ff5 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -9,7 +9,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, `ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂): - `createResourceReferenceProvider({ assets })` → `@` 素材候选; -- `useSkillReferenceProvider()` → `$` Skill 候选(读取应用级 Skill 目录,只在用户第一次敲出 `$` 时发生;失败会放开重试); +- `useSkillReferenceProvider()` → `$` Skill 候选(读取应用级 Skill 目录,只在用户第一次敲出 `$` 时发生——输入区在 effect 里回调 provider 的 `onMenuQueryChange`,`match` 本身是纯函数;失败会放开重试); - 附件与运行画面区域是**静默 provider**(无触发符、无候选),只参与正文 part 的身份解析、改名刷新与文本形态。 输入区按 `providers` 数组顺序取第一个非空回答,不判断任何引用种类,也没有总装 builder。**没注入就没有这类引用**:只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill(Rust `direct_codex_user_item_to_codex_turn_input`),所以只有它注入 Skill provider;策划输入盒、画布生成面板、资源卡快速编辑与画布生成浮层只注入资源 + 两个静默 provider,不再出现退化成正文文本的 `$` 误导入口(已裁决的缺陷修复)。