f138f648e7
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 聊天素材引用功能说明的粘贴解析小节,并在实施计划补记本轮评审的收口
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||
import {
|
||
buildContentFromPastedText,
|
||
type ChatComposerDraft,
|
||
chatComposerDraftToDirectCodexUserItem,
|
||
type ChatReference,
|
||
chatReferenceMentionToken,
|
||
contentPartText,
|
||
directCodexContentToPromptText,
|
||
hasMeaningfulDirectCodexContent,
|
||
normalizeMentionName,
|
||
resourceDisplayName,
|
||
resourceLabelResolver,
|
||
resourceReferenceFromAsset,
|
||
} from '../src/features/project-workspace/resourceReferences';
|
||
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
|
||
|
||
describe('DirectProject user Response item', () => {
|
||
it('保留 Lexical content 的文本与引用交错顺序', () => {
|
||
const draft: ChatComposerDraft = {
|
||
content: [
|
||
{ type: 'input_text', text: '先看 ' },
|
||
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
||
{ type: 'input_text', text: ' 再看 ' },
|
||
{
|
||
type: 'agc_runtime_region_reference',
|
||
label: '主画面',
|
||
runId: 'run-1',
|
||
versionId: 'version-1',
|
||
elementTag: 'canvas',
|
||
elementRole: 'playfield',
|
||
text: '可试玩区域',
|
||
width: 320,
|
||
height: 180,
|
||
resourceIds: ['asset-hero'],
|
||
},
|
||
],
|
||
};
|
||
|
||
expect(
|
||
chatComposerDraftToDirectCodexUserItem(draft, 'turn-1:user'),
|
||
).toEqual({
|
||
type: 'message',
|
||
role: 'user',
|
||
id: 'turn-1:user',
|
||
content: draft.content,
|
||
});
|
||
});
|
||
|
||
it('资源引用只投影稳定 resourceId,不携带展示字段', () => {
|
||
const draft: ChatComposerDraft = {
|
||
content: [
|
||
{ type: 'input_text', text: '请使用素材' },
|
||
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
||
],
|
||
};
|
||
|
||
expect(
|
||
chatComposerDraftToDirectCodexUserItem(draft, 'turn-2:user'),
|
||
).toEqual({
|
||
type: 'message',
|
||
role: 'user',
|
||
id: 'turn-2:user',
|
||
content: [
|
||
{ type: 'input_text', text: '请使用素材' },
|
||
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
||
],
|
||
});
|
||
});
|
||
|
||
it('原样保留纯空白 input_text,不替用户改写提示词', () => {
|
||
const draft: ChatComposerDraft = {
|
||
content: [
|
||
{ type: 'input_text', text: '先看' },
|
||
{ type: 'input_text', text: ' ' },
|
||
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
||
{ type: 'input_text', text: '\n' },
|
||
],
|
||
};
|
||
|
||
expect(
|
||
chatComposerDraftToDirectCodexUserItem(draft, 'turn-3:user').content,
|
||
).toEqual(draft.content);
|
||
});
|
||
|
||
it('只有最终 content 全为空白时才判定为空输入', () => {
|
||
expect(
|
||
hasMeaningfulDirectCodexContent([{ type: 'input_text', text: ' \n ' }]),
|
||
).toBe(false);
|
||
expect(hasMeaningfulDirectCodexContent([])).toBe(false);
|
||
// 空白文本仍然保留,但只要有实际文本或引用就不能当空输入拒发。
|
||
expect(
|
||
hasMeaningfulDirectCodexContent([
|
||
{ type: 'input_text', text: ' \n' },
|
||
{ type: 'input_text', text: '看' },
|
||
]),
|
||
).toBe(true);
|
||
expect(
|
||
hasMeaningfulDirectCodexContent([
|
||
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
||
]),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('展示用文本由 content 派生,引用按 @ 显示名展开', () => {
|
||
expect(
|
||
directCodexContentToPromptText(
|
||
[
|
||
{ type: 'input_text', text: '用 ' },
|
||
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
||
{ type: 'input_text', text: ' 做主视觉' },
|
||
],
|
||
resourceLabelResolver([]),
|
||
),
|
||
).toBe('用 @asset-hero 做主视觉');
|
||
});
|
||
});
|
||
|
||
describe('粘贴文本反解析', () => {
|
||
const assetEntry = (
|
||
id: string,
|
||
localPath: string,
|
||
): GameCreationAppAssetManifestEntry => ({
|
||
id,
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath,
|
||
source: { kind: 'uploaded' },
|
||
});
|
||
const heroAsset = assetEntry('hero', 'assets/hero.png');
|
||
const enemyAsset = assetEntry('enemy', 'assets/enemy.png');
|
||
const hero = resourceReferenceFromAsset(heroAsset, 'asset-picker');
|
||
const enemy = resourceReferenceFromAsset(enemyAsset, 'asset-picker');
|
||
const skill: ChatReference = { type: 'skill', name: 'image-gen' };
|
||
|
||
it('命中显示名 token 时原位换成引用,其余字符逐字保留', () => {
|
||
expect(buildContentFromPastedText('看 @hero 这一版', [hero])).toEqual([
|
||
{ type: 'input_text', text: '看 ' },
|
||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||
{ type: 'input_text', text: ' 这一版' },
|
||
]);
|
||
expect(buildContentFromPastedText('用 $image-gen 出图', [skill])).toEqual([
|
||
{ type: 'input_text', text: '用 ' },
|
||
{ type: 'agc_skill_reference', name: 'image-gen' },
|
||
{ type: 'input_text', text: ' 出图' },
|
||
]);
|
||
});
|
||
|
||
it('不做兼容别名:只认显示名,且 token 前后必须是行首 / 行尾或空白', () => {
|
||
// 带扩展名的文件名、紧贴中文、全角 `@`、大小写变体都不解析——宁可不成 chip,也不能认错。
|
||
expect(buildContentFromPastedText('@hero.png', [hero])).toBeNull();
|
||
expect(buildContentFromPastedText('看@hero这一版', [hero])).toBeNull();
|
||
expect(buildContentFromPastedText('@hero', [hero])).toBeNull();
|
||
expect(buildContentFromPastedText('@HERO', [hero])).toBeNull();
|
||
// 行首 / 行尾同样是边界。
|
||
expect(buildContentFromPastedText('@hero', [hero])).toEqual([
|
||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||
]);
|
||
});
|
||
|
||
it('未命中的 token 逐字保留,混在正文里也只换认出那几条', () => {
|
||
expect(
|
||
buildContentFromPastedText('@hero 与 @unknown 都在', [hero]),
|
||
).toEqual([
|
||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||
{ type: 'input_text', text: ' 与 @unknown 都在' },
|
||
]);
|
||
// 一条都没命中时返回 null:调用方据此放行编辑器默认粘贴。
|
||
expect(buildContentFromPastedText('纯文本 @unknown', [hero])).toBeNull();
|
||
});
|
||
|
||
it('同名多候选一律按文本保留:宁可不成 chip,也不能认错引用', () => {
|
||
const sameName = [
|
||
resourceReferenceFromAsset(
|
||
assetEntry('asset-hero-a', 'characters/hero.png'),
|
||
'asset-picker',
|
||
),
|
||
resourceReferenceFromAsset(
|
||
assetEntry('asset-hero-b', 'enemies/hero.png'),
|
||
'asset-picker',
|
||
),
|
||
];
|
||
expect(buildContentFromPastedText('@hero', sameName)).toBeNull();
|
||
// 其余可判定的 token 照常解析。
|
||
expect(
|
||
buildContentFromPastedText('@hero @enemy', [...sameName, enemy]),
|
||
).toEqual([
|
||
{ type: 'input_text', text: '@hero ' },
|
||
{ type: 'agc_resource_reference', resourceId: 'enemy' },
|
||
]);
|
||
});
|
||
|
||
it('同一 token 出现几次就成几个 chip,换行保留为独立的文本 part', () => {
|
||
expect(buildContentFromPastedText('@hero\n@hero', [hero])).toEqual([
|
||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||
{ type: 'input_text', text: '\n' },
|
||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||
]);
|
||
});
|
||
|
||
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('整名就是扩展名(`.env` / `.gitignore`)时显示名回退 asset.id,不留空名', () => {
|
||
// 空名字会退化成只有触发符的裸 `@` token:菜单里是一枚空芯片,粘贴解析还会认领正文里
|
||
// 任何一处裸 `@`(见「退化 token 不参与解析」用例)。
|
||
expect(resourceDisplayName(assetEntry('dotenv', '.env'))).toBe('dotenv');
|
||
expect(
|
||
resourceDisplayName(assetEntry('gitignore', 'config/.gitignore')),
|
||
).toBe('gitignore');
|
||
expect(
|
||
resourceReferenceFromAsset(assetEntry('dotenv', '.env'), 'asset-picker')
|
||
.label,
|
||
).toBe('dotenv');
|
||
});
|
||
|
||
it('退化 token(名字为空只剩触发符)不参与解析,正文里的裸 `@` 逐字保留', () => {
|
||
// 上游仍然可能送来空名字的引用(显示名兜底只是把常见路径堵上),裸 `@` token 会在正文里
|
||
// 匹配到任何一处 `@`,认下来就是错认:这里必须按「宁可不成 chip」跳过。
|
||
const nameless: ChatReference = {
|
||
...resourceReferenceFromAsset(
|
||
assetEntry('dotenv', '.env'),
|
||
'asset-picker',
|
||
),
|
||
label: '',
|
||
};
|
||
expect(chatReferenceMentionToken(nameless)).toBe('@');
|
||
expect(
|
||
buildContentFromPastedText('@ 单独一个 @ 符号,看 @hero', [
|
||
nameless,
|
||
hero,
|
||
]),
|
||
).toEqual([
|
||
{ type: 'input_text', text: '@ 单独一个 @ 符号,看 ' },
|
||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||
]);
|
||
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: '看 ' },
|
||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||
{ type: 'input_text', text: ' 这一版,再用 ' },
|
||
{ type: 'agc_skill_reference', name: 'image-gen' },
|
||
{ type: 'input_text', text: ' 出图' },
|
||
];
|
||
const displayed = directCodexContentToPromptText(
|
||
content,
|
||
resourceLabelResolver([heroAsset]),
|
||
);
|
||
expect(displayed).toBe('看 @hero 这一版,再用 $image-gen 出图');
|
||
expect(buildContentFromPastedText(displayed, [hero, skill])).toEqual(
|
||
content,
|
||
);
|
||
});
|
||
});
|