修复:润色回写与粘贴共用同一条落点兜底,认不出的引用落文本而不是消失

ResourceReferenceInput 新增 mentionTokenOrText:provider 的 mentionToken,答不出来(契约被破坏)时退 contentPartText 的通用文本形态;粘贴插入、润色回写的候选扫描、整根替换的落点共用这一条
润色回写的候选不再跳过「答不出 token 的 part」:这类 part 在回包里没被提到时作为末尾孤儿补回来,而不是在翻译阶段直接消失
applyContentToRoot(初始草稿与润色回写共用)解析不出引用时落文本,不再是整条吃掉
新增两处输入区用例:provider 只答得出引用答不出 token 时润色回写仍保住引用;初始草稿里已解析不出的引用落成 @resourceId 文本。两处去掉兜底即变红
同步 AGC 聊天素材引用功能说明、引用粘贴解析实施计划与决策记录(落点兜底一条,含初始草稿行为变化的记录)
This commit is contained in:
2026-09-23 13:11:17 +08:00
parent f138f648e7
commit 28a6fd97f8
5 changed files with 87 additions and 5 deletions
@@ -237,6 +237,20 @@ function mentionTokenFromPart(
return null;
}
/**
* part 落进正文时用的文本:provider 的 `mentionToken`,它答不出来(provider 契约被破坏)时退到
* `contentPartText` 的通用文本形态。文本 part 自己就是文本,返回 `null`。
*
* 粘贴插入、润色回写的候选扫描与落点都走这一条:只要 part 不是文本,就一定有一段正常文本可落,
* 「认不出的引用走文本、绝不静默丢」在几个入口是同一份实现,不是各写一遍兜底。
*/
function mentionTokenOrText(
providers: readonly ReferenceProvider[],
part: DirectCodexUserContentPart,
): string | null {
return mentionTokenFromPart(providers, part) ?? contentPartText(part);
}
/**
* 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。
*
@@ -267,8 +281,10 @@ function applyPolishedTextToRoot(
) {
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
// provider 答不出 token 的 part 也照样进候选(token 退到 `mentionTokenOrText` 的通用文本形态):
// 它在回包里没被提到时会作为末尾孤儿补回来,而不是从这门翻译里直接消失。
const candidates = current.content.flatMap((part) => {
const token = mentionTokenFromPart(providers, part);
const token = mentionTokenOrText(providers, part);
return token ? [{ token, part }] : [];
});
applyContentToRoot(buildContentFromTextTokens(value, candidates), providers);
@@ -297,7 +313,11 @@ function applyContentToRoot(
const reference = referenceFromPart(providers, part);
if (reference) {
paragraph.append($createResourceReferenceNode(reference));
return;
}
// 解析不出引用的 part 落它的文本(与粘贴同一条兜底链),整根替换同样不许把内容吃掉。
const token = mentionTokenOrText(providers, part);
if (token) paragraph.append($createTextNode(token));
});
}
@@ -361,9 +381,7 @@ function $insertContentAtSelection(
return;
}
// 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
// provider 连 token 都答不出来(契约被破坏)时落它的通用文本形态,绝不留空。
const token =
mentionTokenFromPart(providers, part) ?? contentPartText(part);
const token = mentionTokenOrText(providers, part);
if (token) {
$selectionOrRootEnd()?.insertText(token);
inserted = true;
@@ -864,6 +864,68 @@ describe('ResourceReferenceInput', () => {
]);
});
test('润色回写时 provider 答不出 token 的 part 仍留在草稿里,不在这门翻译里消失', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
// provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。
const tokenlessProvider: ReferenceProvider = {
trigger: '@',
fuzzy_lookup: () => [],
lookup: () => [reference],
toReference: (part) =>
part.type === 'agc_resource_reference' ? reference : null,
refresh: (part) => part,
mentionToken: () => null,
};
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<>
<button
type="button"
onClick={() => composerRef.current?.replaceText('润色后的需求')}
>
</button>
<ResourceReferenceInput
ref={composerRef}
providers={[tokenlessProvider]}
initialContent={[chatReferenceToContentPart(reference)]}
projectPath="C:/project"
ariaLabel="聊天"
onChange={onChange}
/>
</>,
);
await settleComposer();
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
await settleComposer();
expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']);
expect(draftText(composerRef.current?.getDraft())).toBe(
'润色后的需求 @hero ',
);
});
test('初始草稿里解析不出的引用落文本形态,不从草稿里消失', async () => {
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<ComposerHost
ref={composerRef}
initialContent={[
{ type: 'agc_resource_reference', resourceId: 'missing-asset' },
]}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
onChange={vi.fn()}
/>,
);
await settleComposer();
expect(composerRef.current?.getDraft().content).toEqual([
{ type: 'input_text', text: '@missing-asset' },
]);
});
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
@@ -60,3 +60,4 @@
- 粘贴接管改成「插入真的发生之后」才 `preventDefault``$insertContentAtSelection` 返回「插进去没有」,插入为空时放行默认粘贴;编辑器已在更新中(回调被排队)时按原口径先接管(提交 `3391ecf7d`)。
- 插入兜底不再留空:provider 的 `toReference``mentionToken` 都答不出来的 part 退到新增的 `contentPartText`(通用文本形态,资源落 `@resourceId`)。此前这一支会什么都不插,是「粘贴内容逐字保留」唯一的例外分支;现在没有例外。新增单测覆盖四类 part 的文本形态,新增输入区集成用例证明被破坏的 provider 契约下这段粘贴仍按文本落下,去掉兜底即变红。
- 归一化撞名(`hero v2``hero-v2` 折成同一个 token)只在解析侧兜住、候选菜单不提示冲突,这一条涉及「哪些素材能被 @ 到」的产品取舍,未改代码,留给下一轮决定。
- 润色回写与粘贴共用同一条落点兜底:新增 `mentionTokenOrText`provider 的 `mentionToken`,拿不到就退 `contentPartText` 的通用文本形态),润色回写的候选扫描、整根替换的落点与粘贴插入的兜底都走它。由此润色回写不再有「provider 答不出 token 的 part 直接消失」和「整根替换时 part 解析不出引用就整条吃掉」两条静默丢弃路径;初始草稿(`applyContent`)与润色回写(`applyPolishedTextToRoot`)共用 `applyContentToRoot`,所以恢复出来的草稿里已解析不出的引用现在落成 `@resourceId` 文本而不是被吃掉。
@@ -6,6 +6,7 @@
- 决策:把不变量前移到引用名——`@显示名` / `$名称` / `@附件名` 的名字内部不允许空白,统一经共享 `normalizeMentionName(value)`(内部空白折成 `-`、裁掉首尾)处理。落点是名字的产生处:`resourceDisplayName()`、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`,外加两个 token 投影(`chatReferenceMentionToken``directCodexContentToPromptText`)——token 层幂等再折一次,「token 里没有空白」就是不变量本身的性质,不依赖上游数据干净。
- 决策(不兜底):引用名假定非空,不做 `resourceId` 之类的兜底;`resourceDisplayName` 原来的 `|| asset.id` 一并去掉。
- 校正(2026-09-23,评审项):`resourceDisplayName` 的空名字兜底不能一并去掉——整名就是扩展名时(`.env` / `.gitignore`)去掉扩展名得到空串,显示名成了空串,token 退化成只有触发符的裸 `@`(候选菜单里是空芯片,粘贴解析还会认领正文里任何一处裸 `@`)。改为词干为空时回退 `asset.id`(仍过 `normalizeMentionName`);`normalizeMentionName` 自己没有兜底、只做归一化这条不变。
- 落点兜底(2026-09-23,评审项):part 落进正文的兜底链统一为 `mentionTokenOrText`provider 的 `mentionToken`,拿不到就退 `contentPartText` 的通用文本形态,即 `@resourceId` / `$名称` / `@附件名` / `@区域标签`)。粘贴插入、润色回写的候选扫描与整根替换共用它,删掉两处静默丢弃路径(provider 答不出 token 的 part 在润色翻译里消失;整根替换时解析不出引用的 part 被吃掉)。副作用是恢复出来的初始草稿里已解析不出的引用落成 `@resourceId` 文本而不是消失——宁可留文本,也不让内容凭空少一段。
- 原因:不改反解析是因为粘贴解析与润色回包共用 `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` 不再解析,重试 / 润色回填时那条引用会退化成末尾孤儿(内容不丢、位置可能不对)。
@@ -40,7 +40,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
- 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。
- 附件与运行画面区域的 token`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。
- 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。
- 插入那一刻解析不出引用不留空:先退回 provider 的 token`@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。所以「粘贴进来的内容一个字符都不会凭空消失」在这条链路上没有例外分支。
- part 落进正文时解析不出引用不留空:先退回 provider 的 `mentionToken``@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。粘贴就地插入与润色回写的整根替换共用这一条兜底链(`mentionTokenOrText`),所以「认不出的引用走文本、绝不静默丢」没有例外分支。
- 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。
- Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。