重构:候选懒加载改为菜单回调,match 收成纯函数
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- ReferenceProvider 新增可选 onMenuQueryChange(query):输入区在 useEffect 里回调(菜单关闭时收到 null),作为懒加载的唯一入口
- ProviderMentionMenu 不再让 provider 在渲染期产生副作用:match 只负责过滤已就绪的数据,契约里写明它必须是纯函数
- useSkillReferenceProvider 把目录读取从 match 搬进 onMenuQueryChange,第一次敲出 $ 才读;失败仍放开重试(下一次菜单查询变化时重试)并保留 console.warn
- referenceSourceProviders 用例改为经菜单回调驱动,并显式断言 match 不触发任何 Tauri invoke、菜单关闭(null)也不触发
- 同步 ADR、决策记录与功能说明里 provider 契约的那段描述
This commit is contained in:
2026-09-22 17:24:24 +08:00
parent aa973ea34d
commit d5fb34209a
7 changed files with 52 additions and 15 deletions
@@ -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
@@ -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<string>();
return skills
.filter((skill) => {
@@ -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`,原样返回表示无需改写。 */
@@ -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);