修复:粘贴插入解析不出引用时落下文本形态,不再什么都不插
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: '看 ' },
|
||||
|
||||
Reference in New Issue
Block a user