重构:引用 provider 的 match / candidates 改名为 fuzzyLookup / lookup,删除 onPasteText
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m7s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell 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 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 / AI game creator shell Rust smoke (pull_request) Successful in 1m16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m7s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell 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 lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
match(query) 改名 fuzzyLookup(query):名字写明它是包含匹配 + 截断的模糊菜单查询 candidates() 改名 lookup():名字写明它是精确查找用的就绪全量候选,不模糊不截断 删除 onPasteText 补读钩子:粘贴只用此刻就绪的候选,不等待、不补读,也不为解析提前读盘 输入区与资源 / Skill provider 同步改名,粘贴接管路径改读 lookup() provider 用例与粘贴集成用例同步:Skill 冷启动改为「保持字面,敲过 `$` 后可解析」 补 jsdom 缺的 Range.getBoundingClientRect 与 ResizeObserver 桩(候选菜单测量用,真实浏览器都有) 同步 ADR 修订、实施计划、功能说明与决策记录
This commit is contained in:
+5
-6
@@ -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]);
|
||||
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+3
-7
@@ -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) =>
|
||||
|
||||
+10
-15
@@ -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`,原样返回表示无需改写。 */
|
||||
|
||||
@@ -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);
|
||||
// 读取失败不能静默:控制台要留下可排障的一条。
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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`,语义不变;旧名不再保留。)
|
||||
|
||||
@@ -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` 形态调整、扩展安装卸载后的目录即时失效。
|
||||
|
||||
@@ -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,选择器面板与输入区分离
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user