修复:粘贴插入解析不出引用时落下文本形态,不再什么都不插
resourceReferences.ts 抽出 contentPartToken:part → 正文 token 的逐字投影,不补白、不裁剪(补白只在 joinMentionText 拼整段文本时加);directCodexContentToPromptText 与新导出 contentPartText 都走这一份 contentPartText 就是单个非文本 part 的文本形态:资源落 @resourceId、Skill 落 $名称、附件落 @附件名、运行区域落 @标签,文本 part 返回 null $insertContentAtSelection 的兜底链改成 provider 的 mentionToken 之外再接 contentPartText,解析不出引用也一定有一段正常文本落到光标处,删掉「两条都答不出来就什么都不插」这条违背「粘贴内容逐字保留」的分支 新增单测覆盖四类 part 的文本形态与文本 part 返回 null;新增输入区集成用例(provider 的 toReference 与 mentionToken 都答不出来时粘贴仍按 @resourceId 落下),去掉兜底即变红 同步 AGC 聊天素材引用功能说明的粘贴解析小节,并在实施计划补记本轮评审的收口
This commit is contained in:
+7
-3
@@ -64,6 +64,7 @@ import {
|
||||
chatReferenceKey,
|
||||
chatReferenceMentionToken,
|
||||
chatReferenceToContentPart,
|
||||
contentPartText,
|
||||
dedupeChatReferences,
|
||||
joinMentionText,
|
||||
} from './resourceReferences';
|
||||
@@ -323,8 +324,9 @@ function $selectionOrRootEnd() {
|
||||
* 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成
|
||||
* chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。
|
||||
*
|
||||
* 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本,
|
||||
* 绝不静默丢掉——粘贴进来的内容一个字符都不会凭空消失。
|
||||
* 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本;连 provider 的
|
||||
* `mentionToken` 都答不出来时退到 `contentPartText` 的通用文本形态。两条兜底合起来保证
|
||||
* 「粘贴进来的内容一个字符都不会凭空消失」,这个分支不存在什么都不插的出路。
|
||||
*
|
||||
* 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行
|
||||
* 编辑器的默认粘贴,否则这段文字两边都不管。
|
||||
@@ -359,7 +361,9 @@ function $insertContentAtSelection(
|
||||
return;
|
||||
}
|
||||
// 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
|
||||
const token = mentionTokenFromPart(providers, part);
|
||||
// provider 连 token 都答不出来(契约被破坏)时落它的通用文本形态,绝不留空。
|
||||
const token =
|
||||
mentionTokenFromPart(providers, part) ?? contentPartText(part);
|
||||
if (token) {
|
||||
$selectionOrRootEnd()?.insertText(token);
|
||||
inserted = true;
|
||||
|
||||
@@ -227,17 +227,44 @@ export function directCodexContentToPromptText(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
resolveResourceLabel: ResourceLabelResolver,
|
||||
) {
|
||||
return joinMentionText(content, (part) => {
|
||||
if (part.type === 'input_text') return null;
|
||||
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 `@${normalizeMentionName(
|
||||
resolveResourceLabel(part.resourceId) ?? part.resourceId,
|
||||
)}`;
|
||||
});
|
||||
return joinMentionText(content, (part) =>
|
||||
contentPartToken(part, resolveResourceLabel),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 part 在正文文本里的 token(`@显示名` / `$名称` / `@附件名` / `@区域标签`);文本 part
|
||||
* 没有 token,返回 `null`。
|
||||
*
|
||||
* 逐字返回 token 本身:不带补白、不裁剪——补白是 `joinMentionText` 在拼整段文本时加的,
|
||||
* 什么时候需要留白由那里的上下文决定,token 的投影不替它决定。
|
||||
*/
|
||||
function contentPartToken(
|
||||
part: DirectCodexUserContentPart,
|
||||
resolveResourceLabel: ResourceLabelResolver,
|
||||
): string | null {
|
||||
if (part.type === 'input_text') return null;
|
||||
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 `@${normalizeMentionName(
|
||||
resolveResourceLabel(part.resourceId) ?? part.resourceId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个非文本 part 的文本形态,就是它的 token(见 `contentPartToken`):资源拿不到显示名时
|
||||
* 退回 `resourceId`。文本 part 没有文本形态,返回 `null`。
|
||||
*
|
||||
* 只给「插入那一刻解析不出引用、也拿不到 provider 的 token」兜底:粘贴过来的字必须落下去,
|
||||
* 哪怕落成一段文本。它不参与解析,也不是解析依据——扫描一律用 `chatReferenceMentionToken`。
|
||||
*/
|
||||
export function contentPartText(
|
||||
part: DirectCodexUserContentPart,
|
||||
): string | null {
|
||||
return contentPartToken(part, () => undefined);
|
||||
}
|
||||
|
||||
/** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */
|
||||
|
||||
@@ -489,6 +489,41 @@ describe('ResourceReferenceInput', () => {
|
||||
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
|
||||
});
|
||||
|
||||
test('粘贴命中的引用连 token 都解析不出来时,落下它的文本形态而不是什么都不插', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
// provider 契约被破坏:lookup 有候选,但 toReference 与 mentionToken 都答不出来。
|
||||
// 这时必须落一段正常文本(资源退回 resourceId),粘贴进来的字不许凭空消失。
|
||||
const brokenProvider: ReferenceProvider = {
|
||||
trigger: '@',
|
||||
fuzzy_lookup: () => [],
|
||||
lookup: () => [
|
||||
resourceReferenceFromAsset(
|
||||
asset('ghost-asset', 'character', 'image/png', 'assets/幽灵.png'),
|
||||
'asset-picker',
|
||||
),
|
||||
],
|
||||
toReference: () => null,
|
||||
refresh: (reference) => reference,
|
||||
mentionToken: () => null,
|
||||
};
|
||||
render(
|
||||
<ResourceReferenceInput
|
||||
ref={ref}
|
||||
providers={[brokenProvider]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
pasteComposerText('看 @幽灵 一眼');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draftText(ref.current?.getDraft())).toBe('看 @ghost-asset 一眼');
|
||||
});
|
||||
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
|
||||
});
|
||||
|
||||
test('粘贴未命中的 token:整段按字面落进正文,不接管、不提示', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
chatComposerDraftToDirectCodexUserItem,
|
||||
type ChatReference,
|
||||
chatReferenceMentionToken,
|
||||
contentPartText,
|
||||
directCodexContentToPromptText,
|
||||
hasMeaningfulDirectCodexContent,
|
||||
normalizeMentionName,
|
||||
@@ -275,6 +276,41 @@ describe('粘贴文本反解析', () => {
|
||||
expect(buildContentFromPastedText('@ 只有裸符号', [nameless])).toBeNull();
|
||||
});
|
||||
|
||||
it('单 part 文本形态:解析不出引用时也有一段正常文本可落,不留空', () => {
|
||||
// 插入那一刻 provider 认不出这个 part 时,`$insertContentAtSelection` 落的就是这一份。
|
||||
const runtimeRegionPart = (label: string): DirectCodexUserContentPart => ({
|
||||
type: 'agc_runtime_region_reference',
|
||||
label,
|
||||
runId: null,
|
||||
versionId: null,
|
||||
elementTag: null,
|
||||
elementRole: null,
|
||||
text: null,
|
||||
width: null,
|
||||
height: null,
|
||||
resourceIds: [],
|
||||
});
|
||||
expect(
|
||||
contentPartText({ type: 'agc_resource_reference', resourceId: 'hero' }),
|
||||
).toBe('@hero');
|
||||
expect(
|
||||
contentPartText({ type: 'agc_skill_reference', name: 'image gen' }),
|
||||
).toBe('$image-gen');
|
||||
expect(
|
||||
contentPartText({
|
||||
type: 'agc_attachment_reference',
|
||||
name: 'brief v2.md',
|
||||
mediaType: 'text/markdown',
|
||||
size: 1,
|
||||
localPath: 'notes/brief v2.md',
|
||||
status: 'imported',
|
||||
}),
|
||||
).toBe('@brief-v2.md');
|
||||
expect(contentPartText(runtimeRegionPart('主画面'))).toBe('@主画面');
|
||||
// 文本 part 没有「文本形态」,它自己就是文本。
|
||||
expect(contentPartText({ type: 'input_text', text: '先看' })).toBeNull();
|
||||
});
|
||||
|
||||
it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => {
|
||||
const content: DirectCodexUserContentPart[] = [
|
||||
{ type: 'input_text', text: '看 ' },
|
||||
|
||||
@@ -50,3 +50,13 @@
|
||||
- 名称里不做 `resourceId` 兜底这一条按评审校正:词干为空(`.env` / `.gitignore` 这类整名就是扩展名的文件)时 `resourceDisplayName` 回退 `asset.id`,回退值同样过 `normalizeMentionName`;`normalizeMentionName` 自己仍只做归一化。
|
||||
- 代价与取舍:`hero v2` 与 `hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。
|
||||
- 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。
|
||||
|
||||
## 追加执行状态(2026-09-23 第二轮自动评审):空名字与插入兜底收口
|
||||
|
||||
第二轮自动评审的四个问题逐个提交处理,落点都在 `apps/ai-game-creator-shell`:
|
||||
|
||||
- 显示名不再可能为空:`resourceDisplayName` 在文件名词干为空(`.env` / `.gitignore`)时回退 `asset.id`,回退值同样过 `normalizeMentionName`(提交 `4e90465c4`)。这条校正了上一节「不做兜底」的写法,决策记录与功能说明同步。
|
||||
- 粘贴解析跳过退化 token:`buildContentFromPastedText` 组装候选时跳过 `token.length <= 1` 的条目,裸 `@` / `$` 不再认领正文里的触发符(提交 `3f9c698a6`)。
|
||||
- 粘贴接管改成「插入真的发生之后」才 `preventDefault`:`$insertContentAtSelection` 返回「插进去没有」,插入为空时放行默认粘贴;编辑器已在更新中(回调被排队)时按原口径先接管(提交 `3391ecf7d`)。
|
||||
- 插入兜底不再留空:provider 的 `toReference` 与 `mentionToken` 都答不出来的 part 退到新增的 `contentPartText`(通用文本形态,资源落 `@resourceId`)。此前这一支会什么都不插,是「粘贴内容逐字保留」唯一的例外分支;现在没有例外。新增单测覆盖四类 part 的文本形态,新增输入区集成用例证明被破坏的 provider 契约下这段粘贴仍按文本落下,去掉兜底即变红。
|
||||
- 归一化撞名(`hero v2` 与 `hero-v2` 折成同一个 token)只在解析侧兜住、候选菜单不提示冲突,这一条涉及「哪些素材能被 @ 到」的产品取舍,未改代码,留给下一轮决定。
|
||||
|
||||
@@ -40,6 +40,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
|
||||
- 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。
|
||||
- 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。
|
||||
- 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。
|
||||
- 插入那一刻解析不出引用时不留空:先退回 provider 的 token(`@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。所以「粘贴进来的内容一个字符都不会凭空消失」在这条链路上没有例外分支。
|
||||
- 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。
|
||||
- Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user