修复:引用名内部空白统一折成 -,粘贴解析不再多插一枚芯片
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
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:
@@ -210,6 +210,10 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内
|
||||
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
|
||||
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
|
||||
|
||||
**引用名**:
|
||||
引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`。
|
||||
_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白
|
||||
|
||||
**引用候选枚举**:
|
||||
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
|
||||
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
|
||||
|
||||
+8
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-7
@@ -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(
|
||||
|
||||
+3
-1
@@ -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 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。
|
||||
|
||||
Reference in New Issue
Block a user