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 1ca5ded23..7deef1bc7 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 @@ -593,10 +593,9 @@ function ResourceReferenceEditor({ const text = clipboardData.getData('text/plain'); if (!text.trim()) return false; const providers = providersRef.current; - // 不等待、不依赖:只让 provider 有机会按需补读,好让下一次粘贴能解析。 - for (const provider of providers) provider.onPasteText?.(text); + // 只认「此刻就绪」的候选:数据还没到的种类本次按文本保留,输入区不等待也不补读。 const references = providers.flatMap( - (provider) => provider.candidates?.() ?? [], + (provider) => provider.lookup?.() ?? [], ); const content = buildContentFromPastedText(text, references); if (!content) return false; @@ -888,16 +887,16 @@ function ProviderMentionMenu({ [onOpenChange, trigger], ); - // 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`match` 始终保持纯函数, + // 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`fuzzyLookup` 始终保持纯函数, // 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。 useEffect(() => { provider.onMenuQueryChange?.(query); }, [provider, query]); const options = useMemo(() => { - if (query === null || !provider.match) return []; + if (query === null || !provider.fuzzyLookup) return []; return provider - .match(query) + .fuzzyLookup(query) .map((reference) => new ReferenceMentionOption(reference)); }, [provider, query]); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts index 9914ef29a..7f724c365 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts @@ -49,14 +49,14 @@ export function createResourceReferenceProvider({ }): ReferenceProvider { return { trigger: '@', - match: (query) => + fuzzyLookup: (query) => resourceProviderData(assets) .references.filter((reference) => resourceReferenceMatchesQuery(reference, query), ) .slice(0, MENTION_OPTION_LIMIT), - // 粘贴解析用的全量候选:与菜单同一份「可提及」清单,只去掉 query 过滤与截断。 - candidates: () => resourceProviderData(assets).references, + // 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。 + lookup: () => resourceProviderData(assets).references, toReference: (part: DirectCodexUserContentPart): ChatReference | null => { if (part.type !== 'agc_resource_reference') return null; const asset = resourceProviderData(assets).byId.get(part.resourceId); 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 3804d2ba2..228b6ddd3 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 @@ -115,13 +115,9 @@ export function useSkillReferenceProvider(): ReferenceProvider { onMenuQueryChange: (query) => { if (query !== null) ensureCatalog(); }, - // 粘贴解析:目录还没就绪时返回空数组,本次 `$名称` 逐字保留; - // 同时用 onPasteText 补发一次懒加载,让下一次粘贴能解析。 - onPasteText: (text) => { - if (text.includes('$')) ensureCatalog(); - }, - candidates: () => skillReferences(skills), - match: (query) => { + // 精确查找:目录还没就绪(用户还没敲过 `$`)时返回空数组,本次 `$名称` 逐字保留。 + lookup: () => skillReferences(skills), + fuzzyLookup: (query) => { return skillReferences(skills) .filter( (reference) => 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 9321dace4..bf78a4901 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 @@ -19,22 +19,25 @@ export type ReferenceProvider = { */ trigger: string | null; /** - * 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。 + * 模糊查询:候选菜单的过滤。名字写明它是模糊的——按 query 做包含匹配、大小写不敏感, + * 并在 provider 内部截断到候选上限。静默 provider 不实现。 * * **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会 * 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。 + * 精确查找用 `lookup`,不要拿它反查 token。 */ - match?: (query: string) => ChatReference[]; + fuzzyLookup?: (query: string) => ChatReference[]; /** - * 全量候选枚举:粘贴解析用「文本 + 候选 → canonical content」的反解析在这里取候选。 + * 精确查找用的全量候选:某一刻 provider 真正能解析出的所有引用,不做模糊过滤、不截断。 * - * 与 `match` 的区别只有「过滤与截断」:`match` 是菜单形状(按 query 过滤、截断到候选上限), - * 这里给的是**当前就能解析出的全部引用**,不截断。没有触发符的静默 provider 不实现。 + * 与 `fuzzyLookup` 的区别只有「模糊与截断」,两者共用同一份候选来源。没有触发符的静默 + * provider 不实现。 * * **同样是纯函数**:输入区在粘贴事件里同步调用它;只返回已就绪的快照,不发起任何读取。 - * 数据还没到时返回空数组(或省略不实现),本次粘贴未命中的 token 逐字保留。 + * 数据还没到时返回空数组(或省略不实现),解析不出的 token 逐字保留——例如 Skill 目录的就绪 + * 时机仍是用户第一次敲出 `$`,冷启动时粘贴 `$名称` 就是字面文本,不会被猜成别的引用。 */ - candidates?: () => ChatReference[]; + lookup?: () => ChatReference[]; /** * 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在 * 带触发符的 provider 上调用**。 @@ -43,14 +46,6 @@ export type ReferenceProvider = { * 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。 */ onMenuQueryChange?: (query: string | null) => void; - /** - * 粘贴文本的补读钩子(可选):正文里可能含有本 provider 的 token 时由输入区调用一次。 - * - * 输入区**不等待也不依赖**它的结果——本次粘贴只用 `candidates()` 已经就绪的候选;这里只是让 - * provider 有机会按需补读,好让下一次粘贴能解析(例如 Skill 目录是应用级异步读取,冷启动时 - * 还没有候选)。判断「这段文本里有没有我的触发符」是 provider 自己的事。 - */ - onPasteText?: (text: string) => 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 231f3cbf2..3b0178857 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -82,7 +82,7 @@ describe('资源引用 provider', () => { expect(provider.isReady?.()).toBe(true); }); - it('候选只含可提及素材,`match` 按显示名 / id / kind 过滤并截断到 8 条', () => { + it('候选只含可提及素材,`fuzzyLookup` 按显示名 / id / kind 模糊过滤并截断到 8 条', () => { const assets = [ heroAsset, asset('asset-icon', 'icon', 'image/png', 'assets/btn.png'), @@ -91,21 +91,21 @@ describe('资源引用 provider', () => { asset('asset-agent', 'document', 'text/markdown', '.agent/notes.md'), ]; const scoped = createResourceReferenceProvider({ assets }); - expect(scoped.match?.('')).toHaveLength(2); + expect(scoped.fuzzyLookup?.('')).toHaveLength(2); const many = Array.from({ length: 10 }, (_, index) => asset(`asset-${index}`, 'image', 'image/png', `assets/pic-${index}.png`), ); const limited = createResourceReferenceProvider({ assets: many }); - expect(limited.match?.('')).toHaveLength(8); + expect(limited.fuzzyLookup?.('')).toHaveLength(8); - expect(provider.match?.('hero')?.map((item) => item.type)).toEqual([ + expect(provider.fuzzyLookup?.('hero')?.map((item) => item.type)).toEqual([ 'resource', ]); - expect(provider.match?.('HERO-IDLE')).toHaveLength(1); - expect(provider.match?.('character')).toHaveLength(1); - expect(provider.match?.(' hero ')).toHaveLength(1); - expect(provider.match?.('missing')).toEqual([]); + expect(provider.fuzzyLookup?.('HERO-IDLE')).toHaveLength(1); + expect(provider.fuzzyLookup?.('character')).toHaveLength(1); + expect(provider.fuzzyLookup?.(' hero ')).toHaveLength(1); + expect(provider.fuzzyLookup?.('missing')).toEqual([]); }); it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => { @@ -116,7 +116,7 @@ describe('资源引用 provider', () => { ], }); - const candidates = sameName.match?.('') ?? []; + const candidates = sameName.fuzzyLookup?.('') ?? []; // 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。 expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([ '@hero', @@ -127,7 +127,7 @@ describe('资源引用 provider', () => { ); }); - it('`candidates` 给粘贴解析用的全量清单:与菜单同一份可提及素材,但不受菜单截断影响', () => { + it('`lookup` 给精确查找用的全量清单:与菜单同一份可提及素材,但不受模糊过滤与截断影响', () => { const assets = [ heroAsset, asset('asset-orphan', 'image', 'image/png', ''), @@ -143,27 +143,27 @@ describe('资源引用 provider', () => { const scoped = createResourceReferenceProvider({ assets }); // 不可提及素材(没有 localPath)不在候选里,条目数与展示名口径与菜单一致。 - expect(scoped.match?.('')).toHaveLength(8); - expect(scoped.candidates?.()).toHaveLength(11); - expect(scoped.candidates?.().map(chatReferenceMentionToken)).toEqual( + expect(scoped.fuzzyLookup?.('')).toHaveLength(8); + expect(scoped.lookup?.()).toHaveLength(11); + expect(scoped.lookup?.().map(chatReferenceMentionToken)).toEqual( expect.arrayContaining(['@hero-idle', '@pic-0', '@pic-9']), ); // 粘贴解析只认「显示名逐字一致」的 token,所以候选的 token 必须是显示名形态。 expect( scoped - .candidates?.() + .lookup?.() .some( (item) => 'resourceId' in item && item.resourceId === 'asset-orphan', ), ).toBe(false); // 菜单就是「同一份候选 + query 过滤 + 截断」,所以截断后的前缀逐字一致。 - expect(scoped.candidates?.().slice(0, 8)).toEqual(scoped.match?.('')); + expect(scoped.lookup?.().slice(0, 8)).toEqual(scoped.fuzzyLookup?.('')); }); - it('清单为空时 `candidates` 是空数组:粘贴不会把任何 token 当成引用', () => { - expect( - createResourceReferenceProvider({ assets: [] }).candidates?.(), - ).toEqual([]); + it('清单为空时 `lookup` 是空数组:粘贴不会把任何 token 当成引用', () => { + expect(createResourceReferenceProvider({ assets: [] }).lookup?.()).toEqual( + [], + ); }); it('`toReference` 只认资源 part:资产已删除时不合成引用', () => { @@ -186,7 +186,7 @@ describe('资源引用 provider', () => { }); it('`refresh` 按 manifest 换显示名:改名换新引用、未变恒等、已删除原样返回', () => { - const reference = provider.match?.('hero')?.[0] as ResourceReference; + const reference = provider.fuzzyLookup?.('hero')?.[0] as ResourceReference; expect(provider.refresh(reference)).toBe(reference); const renamed = createResourceReferenceProvider({ @@ -344,7 +344,7 @@ describe('运行画面区域 provider', () => { }); describe('Skill provider', () => { - it('触发符是 `$`:挂载与 `match` 都不发查询,菜单第一次打开时才读应用级目录', async () => { + it('触发符是 `$`:挂载与 `fuzzyLookup` 都不发查询,菜单第一次打开时才读应用级目录', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; @@ -358,9 +358,9 @@ describe('Skill provider', () => { expect(result.current.trigger).toBe('$'); expect(invoke).not.toHaveBeenCalled(); - // `match` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。 + // `fuzzyLookup` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。 act(() => { - expect(result.current.match?.('')).toEqual([]); + expect(result.current.fuzzyLookup?.('')).toEqual([]); }); expect(invoke).not.toHaveBeenCalled(); @@ -372,7 +372,7 @@ describe('Skill provider', () => { expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); }); await waitFor(() => { - expect(result.current.match?.('')).toHaveLength(1); + expect(result.current.fuzzyLookup?.('')).toHaveLength(1); }); // 目录只读一次:后续每次敲 `$` 都复用同一份候选。 expect(invoke).toHaveBeenCalledTimes(2); @@ -381,13 +381,13 @@ describe('Skill provider', () => { result.current.onMenuQueryChange?.(null); }); expect(invoke).toHaveBeenCalledTimes(2); - expect(result.current.match?.('测试')?.[0]).toMatchObject({ + expect(result.current.fuzzyLookup?.('测试')?.[0]).toMatchObject({ type: 'skill', name: 'agc-test-skill', }); }); - it('粘贴补读:`candidates` 保持纯函数(目录没到就是空数组),`onPasteText` 只在文本含 `$` 时补读一次', async () => { + it('`lookup` 是纯函数:目录还没读就返回空数组、也不发起读取,菜单打开后才查得到', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; @@ -399,35 +399,22 @@ describe('Skill provider', () => { const { result } = renderHook(() => useSkillReferenceProvider()); - // 冷启动:粘贴解析只能看到「当前就绪」的候选,目录没读过就是空数组,本次 `$名称` 逐字保留。 + // 冷启动:精确查找只能看到「此刻就绪」的候选,目录没读过就是空数组——粘贴 `$名称` 因此按字面保留。 act(() => { - expect(result.current.candidates?.()).toEqual([]); + expect(result.current.lookup?.()).toEqual([]); }); expect(invoke).not.toHaveBeenCalled(); - // 不含触发符的粘贴不触发任何读取。 + // 菜单第一次打开才读目录,读回之后精确查找立刻可用。 act(() => { - result.current.onPasteText?.('把这一版改成夜景'); - }); - expect(invoke).not.toHaveBeenCalled(); - - // 含 `$` 的粘贴补发一次懒加载:本次仍解析不出,下一次粘贴起才有候选。 - act(() => { - result.current.onPasteText?.('用 $agc-test-skill 出图'); + result.current.onMenuQueryChange?.(''); }); await waitFor(() => { - expect(result.current.candidates?.()).toHaveLength(1); + expect(result.current.lookup?.()).toHaveLength(1); }); - expect(invoke).toHaveBeenCalledTimes(2); - expect( - result.current.candidates?.().map(chatReferenceMentionToken), - ).toEqual(['$agc-test-skill']); - - // 已就绪后再粘贴同一个 token,不会再读一次目录。 - act(() => { - result.current.onPasteText?.('用 $agc-test-skill 出图'); - }); - expect(invoke).toHaveBeenCalledTimes(2); + expect(result.current.lookup?.().map(chatReferenceMentionToken)).toEqual([ + '$agc-test-skill', + ]); }); it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => { @@ -476,17 +463,17 @@ describe('Skill provider', () => { result.current.onMenuQueryChange?.(''); }); await waitFor(() => { - expect(result.current.match?.('')).toHaveLength(8); + expect(result.current.fuzzyLookup?.('')).toHaveLength(8); }); // 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。 - expect(result.current.match?.('builtin-0')).toHaveLength(1); - expect(result.current.match?.('builtin-')).toHaveLength(8); - expect(result.current.match?.('client-skill')).toMatchObject([ + expect(result.current.fuzzyLookup?.('builtin-0')).toHaveLength(1); + expect(result.current.fuzzyLookup?.('builtin-')).toHaveLength(8); + expect(result.current.fuzzyLookup?.('client-skill')).toMatchObject([ { type: 'skill', name: 'client-skill' }, ]); // 未启用与非 Skill 扩展都不是候选。 - expect(result.current.match?.('client-off')).toEqual([]); - expect(result.current.match?.('client-plugin')).toEqual([]); + expect(result.current.fuzzyLookup?.('client-off')).toEqual([]); + expect(result.current.fuzzyLookup?.('client-plugin')).toEqual([]); }); it('一次瞬时失败不锁死候选:下一次 `match` 还会重读目录,并在控制台留痕', async () => { @@ -514,7 +501,7 @@ describe('Skill provider', () => { result.current.onMenuQueryChange?.('a'); }); await waitFor(() => { - expect(result.current.match?.('')).toHaveLength(1); + expect(result.current.fuzzyLookup?.('')).toHaveLength(1); }); expect(invoke).toHaveBeenCalledTimes(4); // 读取失败不能静默:控制台要留下可排障的一条。 diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 0b8cd75bf..ea1feaa2a 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -87,6 +87,45 @@ function defineEventClassShim(name: 'DragEvent' | 'ClipboardEvent') { defineEventClassShim('DragEvent'); defineEventClassShim('ClipboardEvent'); +/** + * jsdom 的 `Range` 没有 `getBoundingClientRect`,而候选菜单打开时会用它测锚点位置 + * (`LexicalTypeaheadMenuPlugin` 的 `getRect`);真实浏览器两者都在,这里只补测试环境缺的那部分。 + */ +function defineRangeRectShim() { + if (typeof Range === 'undefined') return; + if (typeof Range.prototype.getBoundingClientRect === 'function') return; + Range.prototype.getBoundingClientRect = () => + typeof DOMRect === 'function' + ? new DOMRect() + : ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + } as DOMRect); +} + +defineRangeRectShim(); + +/** jsdom 没有 `ResizeObserver`,候选菜单打开时会构造它;真实浏览器都有。 */ +function defineResizeObserverShim() { + if (typeof window === 'undefined') return; + if (typeof window.ResizeObserver === 'function') return; + window.ResizeObserver = class ResizeObserverStub { + observe() {} + + unobserve() {} + + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +defineResizeObserverShim(); + function asset( id: string, kind: string, @@ -480,7 +519,7 @@ describe('ResourceReferenceInput', () => { expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); }); - test('Skill 目录冷启动:第一次粘贴 `$名称` 保持字面并补读目录,第二次粘贴才成 chip', async () => { + test('Skill 目录冷启动:粘贴 `$名称` 保持字面,敲过一次 `$` 之后粘贴才成 chip', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; @@ -501,17 +540,24 @@ describe('ResourceReferenceInput', () => { onChange={vi.fn()} />, ); + + // 冷目录:精确查找没有候选,`$名称` 逐字保留,也不替用户去读目录。 + pasteComposerText('用 $agc-test-skill 出图'); + await waitFor(() => { + expect(draftText(ref.current?.getDraft())).toBe( + '用 $agc-test-skill 出图', + ); + }); expect(invoke).not.toHaveBeenCalled(); - // 冷目录:候选还没到,`$名称` 逐字保留,同时补读一次目录。 - pasteComposerText('用 $agc-test-skill 出图'); + // 用户敲出 `$`(菜单懒加载)之后目录才就绪,此后粘贴才重建 chip。 + act(() => { + ref.current?.clear(); + }); + insertComposerText('$'); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); }); - expect(draftText(ref.current?.getDraft())).toBe( - '用 $agc-test-skill 出图', - ); - act(() => { ref.current?.clear(); }); diff --git a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md index 7b098a22a..47cadf7df 100644 --- a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md +++ b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md @@ -16,17 +16,21 @@ - 输入区删除 `versions` / `activeVersionId` / `showTriggerButton` / `onReferencePickerOpen` / `skills`,`assets` 被注入的 provider 取代;`projectPath` 只保留给输入区自己的润色链路。 - 输入区与宿主之间只留三个通用接缝:`providers`(引用来源)、`inputActions`(操作排里的宿主控件,例如 `@` 触发钮)、`submitSuppressed`(宿主浮层打开时 Enter 让位)。引用种类一个都不进输入区。 -- `provider.match` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 +- `provider.fuzzyLookup` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 - 附件并入 `ChatReference`,编辑器收敛为单一引用节点类型,附件 chip 的 DOM 契约逐字保留;附件导入成功后以芯片进入正文,失败不插入;控制器不再持有附件数组,`MAX_CHAT_COMPOSER_ATTACHMENTS` 改为按草稿中的附件芯片数计算,导入进行中禁止发送。 - 用户可见行为保持不变,唯一例外是已裁决的缺陷修复:非 DirectProject 宿主不再出现 `$` Skill 候选。 ## 修订(2026-09-22):粘贴解析需要第二道只读缝 -粘贴进来的纯文本要按同一套引用文本语法反解析回正文芯片,因此 provider 在 `match(query)`(菜单形状:按 query 过滤并截断)之外,再暴露两项可选能力: +粘贴进来的纯文本要按同一套引用文本语法反解析回正文芯片,因此 provider 的两个查询能力按「模糊 / 精确」分开命名,各自说清自己的语义: -- `candidates()`:当前就绪的全部候选,与菜单共用同一份对象集合,不截断、不按 query 过滤;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。 -- `onPasteText(text)`:粘贴文本里可能出现本 provider 的 token 时的补读钩子;输入区不等待也不依赖它的结果(Skill 目录是应用级异步读取,冷启动时本次粘贴解析不出,下一次才有候选)。 +- `fuzzyLookup(query)`:候选菜单那条路——按 query 做包含匹配、大小写不敏感,并在 provider 内部截断到候选上限。名字写明它是模糊的,避免被拿去反查 token。 +- `lookup()`:精确查找那条路——某一刻 provider 真正能解析出的全部引用,不做模糊过滤、不截断,与菜单共用同一份候选来源;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。 -这没有推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是菜单那条懒加载路径,`onPasteText` 只是让「用户把 `$名称` 贴进来」等价于「他自己敲了 `$`」,且**不改变**「挂载即查询会让工作区路径非法时也产生后端访问」这条边界。附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。 +这不推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是唯一的懒加载入口,粘贴解析**只用此刻就绪的候选**,不等待、不补读。Skill 目录因此还是「用户第一次敲出 `$` 才读」——冷启动时粘贴 `$名称` 就按字面文本保留(看得见、不是猜错),不为了粘贴去提前读盘。 + +附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。 歧义口径:同一个 token 对应多条引用身份(同名素材)时一律按文本保留;解析只认显示名逐字一致(不认扩展名、resourceId、大小写变体),未命中的 token 与其余文字逐字保留。 + +(本次把上一条同名决策里的 `match` / `candidates` 改名为 `fuzzyLookup` / `lookup`,语义不变;旧名不再保留。) diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index d48ed7993..0e049968a 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -13,7 +13,7 @@ 3. 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、不猜路径或文件名。 4. 只有真的解析出引用时才接管:同 namespace 的 `application/x-lexical-editor` 负载、不含 token 的纯文本、图片文件粘贴一律放行编辑器默认导入,现有粘贴行为逐字不变。 5. 附件与运行画面区域不参与粘贴解析(静默 provider 没有候选),它们的 token 粘贴时按文本保留。 -6. Skill 目录冷启动不阻塞粘贴:`candidates()` 是纯函数,目录没到就是空数组(本次 `$名称` 保留为文本),`onPasteText` 补读一次让下一次粘贴能解析。 +6. Skill 目录冷启动不阻塞粘贴:`lookup()` 是纯函数,目录没到就是空数组——冷启动时粘贴 `$名称` 保留为文本(不等待、不补读),用户敲过一次 `$` 后即可解析。 ## 流程判定 @@ -22,7 +22,7 @@ ## 提交切分 1. **反解析口径**:`resourceReferences.ts` 新增 `buildContentFromPastedText(text, references)`(粘贴侧唯一反解析;与 `buildContentFromTextTokens` 同一套边界规则),配规则矩阵单测。 -2. **provider 契约**:`reference-source/types.ts` 新增可选的 `candidates()` 与 `onPasteText(text)`;`resourceReferenceProvider` 给全量可提及候选,`skillReferenceProvider` 给去重后的 Skill 候选并按需补读目录。 +2. **provider 契约**:`reference-source/types.ts` 把菜单查询改名为 `fuzzyLookup(query)`、新增精确查找 `lookup()`(两者共用同一份候选来源);`resourceReferenceProvider` 给全量可提及候选,`skillReferenceProvider` 给去重后的 Skill 候选。 3. **输入区接管**:`ResourceReferenceInput` 注册 `COMMAND_PRIORITY_CRITICAL` 的 `PASTE_COMMAND`,只在解析出引用时 `preventDefault` 并在一次 `editor.update`(`PASTE_TAG`)内按选区插入;配集成用例。 4. **文档**:`CONTEXT.md` 术语、本计划、ADR 修订节、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md` 与决策记录。 @@ -38,6 +38,6 @@ ## 执行状态(2026-09-22) -已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `candidates` 与 `onPasteText` 及用例;输入区 `PASTE_COMMAND` 接管与 4 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动补读);文档同步。 +已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `fuzzyLookup` / `lookup` 及用例;输入区 `PASTE_COMMAND` 接管与 5 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动保持字面且敲过 `$` 后可解析);文档同步。 未做(本次范围外):斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片的解析、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f73ffbccf..00b1d093d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,12 +5,12 @@ - 背景:引用输入区的 `@` / `$` 只由 `LexicalTypeaheadMenuPlugin` 的逐字敲击触发,粘贴走 Lexical 默认路径(`text/plain` → 纯文本),所以从用户消息气泡复制回来的 `@显示名` / `$名称` 粘进来就是死文本;同时 chip 的 `text/plain` 是占位符,复制出去再粘回来必然丢引用(气泡显示文本才是完整 token 的形态)。 - 决策(口径):新增粘贴侧唯一反解析 `buildContentFromPastedText(text, references)`(`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`),候选由宿主注入的 provider 枚举,token 就是 `chatReferenceMentionToken`——与出站显示逐字同一个字符串,所以**不做任何兼容别名**:不认 `@hero.png`、`resourceId`、大小写变体、全角 `@`;边界仍是「行首 / 行尾或空白」(显示侧 token 前后补空白,两端自洽)。 - 决策(宁可不成芯片也不能认错):同一个 token 对应多条引用身份(同名素材)时一律按文本保留;未命中的 token 静默保留、不提示、不猜文件名或路径;附件与运行画面区域不参与(静默 provider 没有候选,`@附件名` / `@区域标签` 按文本保留)。 -- 决策(provider 契约加两道可选缝):`ReferenceProvider.candidates()` 给「当前就绪的全部候选」(不截断、不按 query 过滤,与菜单共用同一份集合),`onPasteText(text)` 是粘贴时的补读钩子(输入区不等待、不依赖结果)。这不推翻上一步「懒加载只走 `onMenuQueryChange`」的结论:`onPasteText` 只是让「用户把 `$名称` 贴进来」等价于「他自己敲了 `$`」,预载 Skill 目录与「工作台路径非法时不产生后端访问」的边界都不动。 +- 决策(provider 契约按「模糊 / 精确」分两个口):上一条里的 `match(query)` 改名 `fuzzyLookup(query)`(名字写明它是包含匹配 + 截断的模糊菜单查询),`candidates()` 改名 `lookup()`(精确查找用的、就绪的全量候选,不模糊不截断),两者共用同一份候选来源。粘贴解析只用 `lookup()` 此刻就绪的候选:不等待、不补读,也不为了解析去提前读盘;Skill 目录仍是「用户第一次敲出 `$` 才读」,冷启动时粘贴 `$名称` 保持字面文本,敲过一次 `$` 后即可重建芯片。曾一度加过的 `onPasteText` 补读钩子已删除——它既不改变本次粘贴的结果,又让输入区反过来关心 provider 的触发符。 - 决策(接管范围):输入区在 `COMMAND_PRIORITY_CRITICAL` 注册 `PASTE_COMMAND`,**只在真的解析出引用时**接管(同 namespace 的 `application/x-lexical-editor` 负载、无 token 纯文本、图片文件一律 `return false` 走默认导入);接管时一次 `editor.update(..., { tag: PASTE_TAG })` 内按选区插入,所以一次 Ctrl+Z 整体回退,token 之外逐字保留。 - 原因:粘贴是用户此刻的编辑,事后回头改写他的输入(例如清单到齐后再把文本改成芯片)等于前端替用户重写内容;而任何「多候选取其一」「按文件名猜资源」的启发式都会制造看不出错的错引用。 - 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,ResourceReferenceInput.tsx,reference-source/{types.ts,resourceReferenceProvider.ts,skillReferenceProvider.ts}}`、`apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts,resourceReferenceInput.test.tsx}`、`CONTEXT.md`、`docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md`(修订节)、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、本文件。 - 未纳入本次:斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 -- 验证方式:`buildContentFromPastedText` 规则矩阵单测(含「显示文本再粘贴回来得到同一份 content」这条逆运算)、provider 的 `candidates` / `onPasteText` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动补读);另跑 `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +- 验证方式:`buildContentFromPastedText` 规则矩阵单测(含「显示文本再粘贴回来得到同一份 content」这条逆运算)、provider 的 `fuzzyLookup` / `lookup` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动保持字面且敲过 `$` 后可解析);另跑 `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 ## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index f8f415c1d..f75bb17f7 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 目录,只在用户第一次敲出 `$` 时发生——输入区在 effect 里回调 provider 的 `onMenuQueryChange`,`match` 本身是纯函数;失败会放开重试); +- `useSkillReferenceProvider()` → `$` Skill 候选(读取应用级 Skill 目录,只在用户第一次敲出 `$` 时发生——输入区在 effect 里回调 provider 的 `onMenuQueryChange`,`fuzzyLookup` 本身是纯函数;失败会放开重试); - 附件与运行画面区域是**静默 provider**(无触发符、无候选),只参与正文 part 的身份解析、改名刷新与文本形态。 输入区按 `providers` 数组顺序取第一个非空回答,不判断任何引用种类,也没有总装 builder。**没注入就没有这类引用**:只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill(Rust `direct_codex_user_item_to_codex_turn_input`),所以只有它注入 Skill provider;策划输入盒、画布生成面板、资源卡快速编辑与画布生成浮层只注入资源 + 两个静默 provider,不再出现退化成正文文本的 `$` 误导入口(已裁决的缺陷修复)。 @@ -39,7 +39,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 - 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 -- Skill 目录是应用级异步读取,走候选菜单的懒加载口径:冷启动时第一次粘贴 `$名称` 会保持为文本,同时补读一次目录,下一次粘贴起才有候选(输入区不等待目录,粘贴不被网络或磁盘读取阻塞)。 +- 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 +- Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。 ## 拖拽引用(2026-09-21)