修复:引用名内部空白统一折成 -,粘贴解析不再多插一枚芯片
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / Frontend tests (pull_request) Successful in 2m4s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m19s
Project CI / Native shell tests (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m16s

resourceReferences.ts 新增共享 normalizeMentionName:内部空白折 `-`、裁掉首尾,token 层幂等再折一次
素材显示名(resourceDisplayName)、Skill 名(目录读入与 toReference / mentionToken)、附件名(导入映射与 toReference / mentionToken)统一过这一份口径
resourceDisplayName 去掉 `|| asset.id` 兜底:引用名假定非空,不再回退 resourceId
前缀重叠 bug 由此收口:`@hero` 不再命中 `@hero-v2`(后一个字符不是空白),粘贴只剩正确的那一枚芯片
新增用例:素材 / Skill / 附件三类归一化口径、前缀重叠回归、Skill 目录名带空白、附件名带空白;把 normalizeMentionName 变异成恒等函数后新增用例全红
顺带修掉 referenceSourceProviders 用例里重命名遗留的 provider.match 断言与 skill provider 注释里的 match
同步 CONTEXT.md 术语、功能说明、实施计划与决策记录
This commit is contained in:
2026-09-23 10:40:21 +08:00
parent 072bf691d9
commit 5e36b61534
10 changed files with 184 additions and 19 deletions
+4
View File
@@ -210,6 +210,10 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用名**:
引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`
_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白
**引用候选枚举**:
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
@@ -1,5 +1,8 @@
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { ChatReference } from '../resourceReferences';
import {
type ChatReference,
normalizeMentionName,
} from '../resourceReferences';
import type { ReferenceProvider } from './types';
/** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */
@@ -8,7 +11,7 @@ export function attachmentReferenceFromPart(
): ChatReference {
return {
type: 'attachment',
name: part.name,
name: normalizeMentionName(part.name),
mediaType: part.mediaType,
size: part.size,
localPath: part.localPath,
@@ -32,7 +35,9 @@ export function createAttachmentReferenceProvider(): ReferenceProvider {
refresh: (reference: ChatReference): ChatReference | null =>
reference.type === 'attachment' ? reference : null,
mentionToken: (part) =>
part.type === 'agc_attachment_reference' ? `@${part.name}` : null,
part.type === 'agc_attachment_reference'
? `@${normalizeMentionName(part.name)}`
: null,
};
}
@@ -2,7 +2,10 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../../app/tauri';
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { ChatReference } from '../resourceReferences';
import {
type ChatReference,
normalizeMentionName,
} from '../resourceReferences';
import type { ReferenceProvider } from './types';
/** 候选菜单最多展示多少条:与资源候选同一上限。 */
@@ -25,7 +28,7 @@ function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
'list_agc_skill_catalog',
).then((items) =>
items.map((item) => ({
name: item.name,
name: normalizeMentionName(item.name),
description: item.description,
})),
),
@@ -44,7 +47,7 @@ function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
item.enabled &&
item.status === 'enabled',
)
.map((item) => ({ name: item.name })),
.map((item) => ({ name: normalizeMentionName(item.name) })),
),
]).then(([builtin, client]) => [...builtin, ...client]);
}
@@ -61,7 +64,7 @@ function matchesSkillQuery(
);
}
/** 目录项 → 引用:同名只留第一条(与 `match` 同一份去重口径)。 */
/** 目录项 → 引用:同名只留第一条(与 `fuzzyLookup` 同一份去重口径)。 */
function skillReferences(skills: readonly SkillCatalogItem[]) {
const seen = new Set<string>();
const references: ChatReference[] = [];
@@ -83,7 +86,7 @@ function skillReferences(skills: readonly SkillCatalogItem[]) {
* 与资源 provider 不同,Skill 候选是**异步**的应用级读取,所以它必须是一份 React 状态:
* 用户敲出 `$` 打开候选菜单时(`onMenuQueryChange` 收到非 `null`,由输入区在 effect 里回调)
* 发起读取,结果到了之后宿主重渲染,输入区随之拿到新的候选。
* `match` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
* `fuzzyLookup` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
* 读取本身不进输入区,只有宿主才知道这条路该不该存在——
* 目前只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill。
*/
@@ -127,12 +130,14 @@ export function useSkillReferenceProvider(): ReferenceProvider {
},
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
part.type === 'agc_skill_reference'
? { type: 'skill', name: part.name }
? { type: 'skill', name: normalizeMentionName(part.name) }
: null,
refresh: (reference: ChatReference): ChatReference | null =>
reference.type === 'skill' ? reference : null,
mentionToken: (part) =>
part.type === 'agc_skill_reference' ? `$${part.name}` : null,
part.type === 'agc_skill_reference'
? `$${normalizeMentionName(part.name)}`
: null,
}),
[ensureCatalog, skills],
);
@@ -154,6 +154,21 @@ export function resourceLabelResolver(
};
}
/**
* 引用名(资源显示名 / Skill 名 / 附件名)的空白不变量:`@显示名`、`$名称`、`@附件名` 里
* 不允许出现空白,内部空白统一折成 `-`。
*
* 引用的名字同时就是它在正文里的 token,而 token 的边界规则是「前后为空白或行首行尾」
* `isMentionTokenBoundary`)。名字里一旦有空白,`@hero v2` 在反解析时会被切成 `@hero` +
* 文本 `v2`:短名字抢先命中,真正的引用反而变成补在末尾的孤儿。空白折成 `-` 之后 token 自带
* 边界,`@hero` 不会再命中 `@hero-v2`(后一个字符是 `-`,不是空白)。
*
* 不在这里做 `resourceId` 之类的兜底:引用名假定非空,空名字属于上游数据问题,不靠兜底掩盖。
*/
export function normalizeMentionName(value: string) {
return value.trim().replace(/\s+/gu, '-');
}
/**
* canonical content → 可读文本;每个引用 part 经 `tokenOf` 展开,文本 part 逐字保留。
*
@@ -209,10 +224,14 @@ export function directCodexContentToPromptText(
) {
return joinMentionText(content, (part) => {
if (part.type === 'input_text') return null;
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
if (part.type === 'agc_skill_reference') return `$${part.name}`;
if (part.type === 'agc_attachment_reference')
return `@${normalizeMentionName(part.name)}`;
if (part.type === 'agc_skill_reference')
return `$${normalizeMentionName(part.name)}`;
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
return `@${resolveResourceLabel(part.resourceId) ?? part.resourceId}`;
return `@${normalizeMentionName(
resolveResourceLabel(part.resourceId) ?? part.resourceId,
)}`;
});
}
@@ -290,8 +309,12 @@ export function chatReferenceToContentPart(
* 资源与运行画面区域 `@显示名`。资源引用自带显示名,所以这里不必再查 manifest。
*/
export function chatReferenceMentionToken(reference: ChatReference): string {
if (reference.type === 'skill') return `$${reference.name}`;
if (reference.type === 'attachment') return `@${reference.name}`;
if (reference.type === 'skill')
return `$${normalizeMentionName(reference.name)}`;
if (reference.type === 'attachment')
return `@${normalizeMentionName(reference.name)}`;
if (reference.type === 'resource')
return `@${normalizeMentionName(reference.label)}`;
return `@${reference.label}`;
}
@@ -605,7 +628,7 @@ export function directCodexContentToLegacyContentDto(
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id;
return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id;
return normalizeMentionName(fileName.replace(/\.[^.]+$/u, ''));
}
export function resourceReferenceFromAsset(
@@ -1,4 +1,5 @@
import type { LauncherImportedAttachment } from '../../../../app/types';
import { normalizeMentionName } from '../../../../features/project-workspace/resourceReferences';
export type DirectCodexTurnAttachment = {
name: string;
@@ -16,7 +17,8 @@ export function toDirectCodexTurnAttachments(
}
return imported.map((item) => {
const attachment: DirectCodexTurnAttachment = {
name: item.fileName,
// 附件名同时是正文里的 `@附件名` token,所以和其它引用名一样不允许空白(见 normalizeMentionName)。
name: normalizeMentionName(item.fileName),
mediaType: item.mediaType,
};
if (
@@ -230,7 +230,23 @@ describe('资源引用 provider', () => {
describe('附件 provider', () => {
it('静默:没有触发符也没有候选,注入它不会加出任何入口', () => {
expect(attachmentReferenceProvider.trigger).toBe(null);
expect(attachmentReferenceProvider.match).toBeUndefined();
expect(attachmentReferenceProvider.fuzzyLookup).toBeUndefined();
});
it('附件名过同一份空白口径:`toReference` 与 `mentionToken` 都是 `@brief-v2.md`', () => {
const part = {
type: 'agc_attachment_reference' as const,
name: 'brief v2.md',
mediaType: 'text/markdown',
size: 128,
localPath: 'notes/brief v2.md',
status: 'imported',
};
expect(attachmentReferenceProvider.toReference(part)).toMatchObject({
type: 'attachment',
name: 'brief-v2.md',
});
expect(attachmentReferenceProvider.mentionToken(part)).toBe('@brief-v2.md');
});
it('`toReference` 逐字搬运附件字段,并只认附件 part', () => {
@@ -387,6 +403,45 @@ describe('Skill provider', () => {
});
});
it('目录里的名字带空白时折成 `-`:候选与 token 是同一份口径', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return [{ name: 'agc test skill', description: '测试 Skill' }];
}
if (command === 'list_client_extensions') {
return [
{
name: 'client skill',
extensionType: 'skill',
enabled: true,
status: 'enabled',
},
];
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke: invoke as never } };
const { result } = renderHook(() => useSkillReferenceProvider());
act(() => {
result.current.onMenuQueryChange?.('');
});
await waitFor(() => {
expect(result.current.lookup?.()).toHaveLength(2);
});
expect(
result.current
.lookup?.()
.map((item) => (item.type === 'skill' ? item.name : null)),
).toEqual(['agc-test-skill', 'client-skill']);
expect(
result.current.mentionToken?.({
type: 'agc_skill_reference',
name: 'agc test skill',
}),
).toBe('$agc-test-skill');
});
it('`lookup` 是纯函数:目录还没读就返回空数组、也不发起读取,菜单打开后才查得到', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
@@ -6,8 +6,11 @@ import {
type ChatComposerDraft,
chatComposerDraftToDirectCodexUserItem,
type ChatReference,
chatReferenceMentionToken,
directCodexContentToPromptText,
hasMeaningfulDirectCodexContent,
normalizeMentionName,
resourceDisplayName,
resourceLabelResolver,
resourceReferenceFromAsset,
} from '../src/features/project-workspace/resourceReferences';
@@ -196,6 +199,46 @@ describe('粘贴文本反解析', () => {
]);
});
it('引用名内部空白折成 `-`:资源显示名 / Skill 名 / 附件名同一条口径', () => {
expect(normalizeMentionName('hero v2')).toBe('hero-v2');
expect(normalizeMentionName(' 英雄\u3000参考\t')).toBe('英雄-参考');
expect(
resourceDisplayName(assetEntry('hero-v2', 'assets/hero v2.png')),
).toBe('hero-v2');
expect(
chatReferenceMentionToken({ type: 'skill', name: 'image gen' }),
).toBe('$image-gen');
expect(
chatReferenceMentionToken({
type: 'attachment',
name: 'brief v2.md',
mediaType: 'text/markdown',
size: 1,
localPath: 'notes/brief v2.md',
status: 'imported',
}),
).toBe('@brief-v2.md');
});
it('空白折成 `-` 后 token 自带边界:前缀重叠不再多插一个 chip', () => {
const heroV2Asset = assetEntry('hero-v2', 'assets/hero v2.png');
const heroV2 = resourceReferenceFromAsset(heroV2Asset, 'asset-picker');
const displayed = directCodexContentToPromptText(
[
{ type: 'input_text', text: '看' },
{ type: 'agc_resource_reference', resourceId: 'hero-v2' },
{ type: 'input_text', text: '这一版' },
],
resourceLabelResolver([heroAsset, heroV2Asset]),
);
expect(displayed).toBe('看 @hero-v2 这一版');
expect(buildContentFromPastedText(displayed, [hero, heroV2])).toEqual([
{ type: 'input_text', text: '看 ' },
{ type: 'agc_resource_reference', resourceId: 'hero-v2' },
{ type: 'input_text', text: ' 这一版' },
]);
});
it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => {
const content: DirectCodexUserContentPart[] = [
{ type: 'input_text', text: '看 ' },
@@ -41,3 +41,12 @@
已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `fuzzyLookup` / `lookup` 及用例;输入区 `PASTE_COMMAND` 接管与 5 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动保持字面且敲过 `$` 后可解析);文档同步。
未做(本次范围外):斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片的解析、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。
## 追加执行状态(2026-09-23):前缀重叠按「引用名无空白」收口
自动评审留下的唯一破坏性项(`@hero``@hero v2` 互为前缀时粘贴会多插一枚短名芯片)不改反解析,改为把不变量前移到引用名:
- `resourceReferences.ts` 新增共享 `normalizeMentionName(value)`(内部空白折 `-`、裁首尾),素材显示名(`resourceDisplayName`)、Skill 名(目录读入与 `toReference` / `mentionToken`)、附件名(导入映射与 `toReference` / `mentionToken`)与两个 token 投影(`chatReferenceMentionToken``directCodexContentToPromptText`)统一过这一份;token 层重复归一化是幂等的。
- 名称里不再做 `resourceId` 兜底(`resourceDisplayName` 去掉 `|| asset.id`);假定引用名非空。
- 代价与取舍:`hero v2``hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。
- 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。
@@ -1,5 +1,15 @@
# 决策记录
## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName`
- 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero``hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠``@hero v2` 内部也算一次合法命中。
- 决策:把不变量前移到引用名——`@显示名` / `$名称` / `@附件名` 的名字内部不允许空白,统一经共享 `normalizeMentionName(value)`(内部空白折成 `-`、裁掉首尾)处理。落点是名字的产生处:`resourceDisplayName()`、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`,外加两个 token 投影(`chatReferenceMentionToken``directCodexContentToPromptText`)——token 层幂等再折一次,「token 里没有空白」就是不变量本身的性质,不依赖上游数据干净。
- 决策(不兜底):引用名假定非空,不做 `resourceId` 之类的兜底;`resourceDisplayName` 原来的 `|| asset.id` 一并去掉。
- 原因:不改反解析是因为粘贴解析与润色回包共用 `buildContentFromTextTokens`,改匹配算法要冒回归润色的风险;而「名字里带空白的 token」本来就无法手敲(候选触发器 `allowWhitespace: false`,空格处菜单就关),显示口径与输入口径早就不一致。折成 `-` 之后 token 自带边界:`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`,不是空白),前缀重叠不可能再发生。
- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,reference-source/{skillReferenceProvider.ts,attachmentReferenceProvider.ts}}``apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts``apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts}``CONTEXT.md``docs/【功能说明】AGC聊天素材引用-2026-09-08.md``docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md`
- 代价(已接受):归一化可能撞名(`hero v2``hero-v2` 同名),走既有的「同名多候选一律按文本保留」——不认错,但两者都成不了芯片;改动前生成的旧文本(历史回合 prompt、旧气泡)里的 `@hero v2` 不再解析,重试 / 润色回填时那条引用会退化成末尾孤儿(内容不丢、位置可能不对)。
- 验证方式:`normalizeMentionName``resourceDisplayName``chatReferenceMentionToken` 的口径单测;「空白折 `-` 后 token 自带边界、前缀重叠只剩正确芯片」的回归用例;Skill 目录名带空白与附件名带空白的 provider 用例;把 `normalizeMentionName` 变异成恒等函数后以上新增用例全部变红。另跑受影响用例、`npm run typecheck``npm run check:encoding``git diff --check`
## 2026-09-22 引用粘贴解析:只认显示口径的 token,宁可不成芯片也不能认错
- 背景:引用输入区的 `@` / `$` 只由 `LexicalTypeaheadMenuPlugin` 的逐字敲击触发,粘贴走 Lexical 默认路径(`text/plain` → 纯文本),所以从用户消息气泡复制回来的 `@显示名` / `$名称` 粘进来就是死文本;同时 chip 的 `text/plain` 是占位符,复制出去再粘回来必然丢引用(气泡显示文本才是完整 token 的形态)。
@@ -1,6 +1,6 @@
# AGC 聊天素材引用
更新时间:2026-09-22
更新时间:2026-09-23
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
@@ -42,6 +42,15 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
- 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。
- Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。
## 引用名的空白不变量(2026-09-23)
引用的名字(素材显示名、Skill 名、附件名)同时就是它在正文里的 token,所以**名字内部不允许出现空白**:内部空白统一经共享的 `normalizeMentionName` 折成 `-``hero v2.png``@hero-v2``my skill``$my-skill`),首尾空白照旧裁掉。
- 归一化落在名字的产生处:`resourceDisplayName()`(素材显示名由文件名派生)、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`、以及两个 token 投影(`chatReferenceMentionToken``directCodexContentToPromptText`)。token 层再折一次是幂等的,因此「token 里绝不会有空白」是这一层的性质,不依赖上游数据干净。
- 为什么必须有这条不变量:token 的边界规则是「前后为空白或行首行尾」。名字里带空白时,`@hero v2` 会被反解析切成 `@hero` + 文本 `v2`,短名字抢先命中,真正的引用反而被当成孤儿补到正文末尾(粘贴回填会多出一枚错芯片)。折成 `-` 之后 token 自带边界,`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`)。
- 名字里不做 `resourceId` 之类的兜底:引用名假定非空。
- 已存在的老文本(本次改动前生成的回合 prompt、历史气泡)里的 `@hero v2` 不再解析——粘贴时按字面保留,重试 / 润色回填时该引用会退化成末尾孤儿。内容不丢,位置可能不对。
## 拖拽引用(2026-09-21
除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。