diff --git a/CONTEXT.md b/CONTEXT.md index 8755820a6..0ab68ba28 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -210,6 +210,10 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内 引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。 _Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段 +**引用名**: +引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`。 +_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白 + **引用候选枚举**: 一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。 _Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts index 762ef2657..b0b9a0afb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts @@ -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, }; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts index 228b6ddd3..e99648c42 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts @@ -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 { '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 { 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(); 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], ); 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 bc4e0dfb6..fe81b1f3c 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 @@ -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( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts index aee761531..c5c7ec553 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts @@ -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 ( diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 3b0178857..6cfb4cd75 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -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') { diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index fe78e5bc3..ce275ecc3 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -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: '看 ' }, diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index 0e049968a..cc60b645a 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -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` 变异成恒等后新增用例全红。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00b1d093d..ffed66ba5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 的形态)。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index f75bb17f7..554cae23d 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -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 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。