修复:粘贴解析跳过只有触发符的退化 token,不再错认正文里的裸 @

buildContentFromPastedText 组装候选时跳过 token 长度小于等于 1 的条目(引用名为空时 token 就是裸 `@` / `$`):边界规则会让它在正文里匹配到任何一处触发符,认下来等于把无关文字错认成一条引用
函数注释的保守规则从两条补成三条,写明空名字按「宁可不成 chip」的同一口径跳过
新增用例:空名字引用的 token 是 `@` 时不认领正文里的裸 `@`、其余 token 照常解析;去掉该跳过分支后用例变红
同步 AGC 聊天素材引用功能说明的粘贴解析小节
This commit is contained in:
2026-09-23 11:54:09 +08:00
parent 4e90465c45
commit 3f9c698a67
3 changed files with 30 additions and 1 deletions
@@ -464,9 +464,11 @@ function countMentionTokenOccurrences(value: string, token: string) {
* `chatReferenceMentionToken`——与显示口径逐字同一个字符串,所以「从气泡复制再粘贴」不需要
* 任何兼容别名:`@显示名` / `$名称` 认得出,`@hero.png`、resourceId、大小写变体一律不认。
*
* 条保守规则:
* 条保守规则:
*
* - 同名多候选(同一个 token 对应多条引用身份)一律按文本保留——宁可不成 chip,也不能认错引用。
* - 退化 token(只有触发符的裸 `@` / `$`,即引用名为空)不参与解析:边界规则会把正文里
* 任何一处裸 `@` 当成它,等于把无关文字错认成一条引用。
* - 只把正文里真的出现过的 token 交给 `buildContentFromTextTokens`,不做「未命中候选补到末尾」
* (那是润色回包的语义,照搬会把整份清单追加到粘贴文本后面)。同一 token 出现几次就展开几条
* 候选,所以重复出现的引用会各自原位成 chip。
@@ -492,6 +494,9 @@ export function buildContentFromPastedText(
}
const candidates: ContentTokenCandidate[] = [];
for (const [token, bucket] of referencesByToken) {
// 名字为空的引用只会给出一个裸触发符:它能在正文里匹配到任何一处 `@` / `$`,
// 认下来就是错认。空名字是上游数据问题,这里按「宁可不成 chip」的同一口径跳过。
if (token.length <= 1) continue;
if (bucket.length !== 1) continue;
const reference = bucket[0]!;
const part = chatReferenceToContentPart(reference);
@@ -252,6 +252,29 @@ describe('粘贴文本反解析', () => {
).toBe('dotenv');
});
it('退化 token(名字为空只剩触发符)不参与解析,正文里的裸 `@` 逐字保留', () => {
// 上游仍然可能送来空名字的引用(显示名兜底只是把常见路径堵上),裸 `@` token 会在正文里
// 匹配到任何一处 `@`,认下来就是错认:这里必须按「宁可不成 chip」跳过。
const nameless: ChatReference = {
...resourceReferenceFromAsset(
assetEntry('dotenv', '.env'),
'asset-picker',
),
label: '',
};
expect(chatReferenceMentionToken(nameless)).toBe('@');
expect(
buildContentFromPastedText('@ 单独一个 @ 符号,看 @hero', [
nameless,
hero,
]),
).toEqual([
{ type: 'input_text', text: '@ 单独一个 @ 符号,看 ' },
{ type: 'agc_resource_reference', resourceId: 'hero' },
]);
expect(buildContentFromPastedText('@ 只有裸符号', [nameless])).toBeNull();
});
it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => {
const content: DirectCodexUserContentPart[] = [
{ type: 'input_text', text: '看 ' },
@@ -37,6 +37,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
- 只认与显示口径逐字一致的 token:`@显示名`(素材)与 `$名称`Skill)。`@hero.png`、资源 ID、大小写变体、全角 `` 都不解析;token 前后必须是行首 / 行尾或空白(与出站文本「token 前后各留一个空白」自洽),所以从用户消息气泡复制出来的那段文字粘回来会重建同一批芯片。
- 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。
- 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。
- 附件与运行画面区域的 token`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。
- 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。
- 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。