diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index bef2cd660..08bfe5250 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -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); diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index aee75e632..3ed6d96c5 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -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: '看 ' }, diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 549cb3e06..f57cbf43e 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -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()`(就绪的全量候选,不模糊、不截断)。