重构:候选懒加载改为菜单回调,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
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:
@@ -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
|
||||
|
||||
+10
-5
@@ -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) => {
|
||||
|
||||
+14
-1
@@ -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);
|
||||
|
||||
@@ -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 候选。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,不再出现退化成正文文本的 `$` 误导入口(已裁决的缺陷修复)。
|
||||
|
||||
Reference in New Issue
Block a user