7e009a4c94
- 新增 features/project-workspace/reference-source(types + 资源/Skill/附件/运行画面区域四个 provider 工厂),输入区按 providers 顺序取第一个非空回答,不再判断引用种类 - ResourceReferenceInput 删除 assets / versions / activeVersionId / skills / showTriggerButton / openPicker,改为 providers / inputActions / submitSuppressed / containerRef 四个通用接缝 - 新增 ResourceReferencePicker 与 ResourceReferencePickerAction:面板自己拿数据(素材、版本、缩略图预览 invoke)并自管筛选态,由宿主渲染,确认后只经 insertReferences 交回输入区 - 四个宿主改为按需注入 provider 并各自渲染选择器:DirectProject 注入资源+Skill+附件+运行区域,策划输入盒 / 画布生成面板 / 资源卡快速编辑只注入资源+两个静默 provider,非 DirectProject 宿主不再出现 $ Skill 候选 - 附件并入 ChatReference,编辑器收敛为单一 ResourceReferenceNode(删除 AttachmentReferenceNode),附件 chip 的 DOM 契约(data-attachment-reference / data-attachment-status / title / 移除按钮 aria-label)逐字保留 - 附件改为导入成功即以芯片进入正文、失败与异常一律不插入;控制器删除待发附件状态与 removeAttachment,ComposerPendingAttachments 与其提交时拼接一并删除,单次上限按草稿中的附件芯片数计算,导入进行中禁止发送 - directCodexContentToPromptText 的引用 token 前后各留一个空白(不重复),显示名反查改由调用方注入 resourceLabelResolver(assets),并补 TODO:后续改为按组件渲染引用 - 队列 chip 文案、快速编辑出站提示词、润色回填的引用恢复统一走 joinMentionText 的同一份 token 口径 - 新增 tests/referenceSourceProviders.test.ts 规则矩阵单测(触发符、候选过滤与截断、toReference、refresh、mentionToken、Skill 目录懒加载与失败重试),既有输入区/聊天用例迁移为注入 provider - styles.css 删除随面板外移与待发附件列表一起失效的规则
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
GameCreationAppAssetKind,
|
|
GameCreationAppAssetManifestEntry,
|
|
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
import { attachmentReferenceProvider } from '../src/features/project-workspace/reference-source/attachmentReferenceProvider';
|
|
import { createResourceReferenceProvider } from '../src/features/project-workspace/reference-source/resourceReferenceProvider';
|
|
import { runtimeRegionReferenceProvider } from '../src/features/project-workspace/reference-source/runtimeRegionReferenceProvider';
|
|
import { useSkillReferenceProvider } from '../src/features/project-workspace/reference-source/skillReferenceProvider';
|
|
import type {
|
|
AttachmentReference,
|
|
ChatReference,
|
|
ResourceReference,
|
|
RuntimeRegionReference,
|
|
} from '../src/features/project-workspace/resourceReferences';
|
|
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
|
|
|
|
function asset(
|
|
id: string,
|
|
kind: GameCreationAppAssetKind,
|
|
mediaType: string,
|
|
localPath: string,
|
|
): GameCreationAppAssetManifestEntry {
|
|
return {
|
|
id,
|
|
kind,
|
|
mediaType,
|
|
localPath,
|
|
source: { kind: 'uploaded' },
|
|
};
|
|
}
|
|
|
|
const heroAsset = asset(
|
|
'asset-hero',
|
|
'character',
|
|
'image/png',
|
|
'assets/hero-idle.png',
|
|
);
|
|
|
|
const runtimeRegionPart: DirectCodexUserContentPart = {
|
|
type: 'agc_runtime_region_reference',
|
|
label: '主画面',
|
|
runId: 'run-1',
|
|
versionId: 'version-1',
|
|
elementTag: 'canvas',
|
|
elementRole: 'playfield',
|
|
text: '可试玩区域',
|
|
width: 320,
|
|
height: 180,
|
|
resourceIds: ['asset-hero'],
|
|
};
|
|
|
|
const attachmentPart: DirectCodexUserContentPart = {
|
|
type: 'agc_attachment_reference',
|
|
name: 'brief.md',
|
|
mediaType: 'text/markdown',
|
|
size: 128,
|
|
localPath: 'notes/brief.md',
|
|
status: 'imported',
|
|
};
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
window.__TAURI__ = undefined;
|
|
});
|
|
|
|
describe('资源引用 provider', () => {
|
|
const provider = createResourceReferenceProvider({ assets: [heroAsset] });
|
|
|
|
it('触发符是 `@`,且清单为空时先不自称就绪', () => {
|
|
expect(provider.trigger).toBe('@');
|
|
expect(createResourceReferenceProvider({ assets: [] }).isReady?.()).toBe(
|
|
false,
|
|
);
|
|
// 清单到位后才允许把草稿落进编辑器,否则引用会被当成已删除丢掉。
|
|
expect(provider.isReady?.()).toBe(true);
|
|
});
|
|
|
|
it('候选只含可提及素材,`match` 按显示名 / id / kind 过滤并截断到 8 条', () => {
|
|
const assets = [
|
|
heroAsset,
|
|
asset('asset-icon', 'icon', 'image/png', 'assets/btn.png'),
|
|
// 没有 localPath 与 `.agent/` 下的产物都不是可提及素材。
|
|
asset('asset-orphan', 'image', 'image/png', ''),
|
|
asset('asset-agent', 'document', 'text/markdown', '.agent/notes.md'),
|
|
];
|
|
const scoped = createResourceReferenceProvider({ assets });
|
|
expect(scoped.match?.('')).toHaveLength(2);
|
|
|
|
const many = Array.from({ length: 10 }, (_, index) =>
|
|
asset(`asset-${index}`, 'image', 'image/png', `assets/pic-${index}.png`),
|
|
);
|
|
const limited = createResourceReferenceProvider({ assets: many });
|
|
expect(limited.match?.('')).toHaveLength(8);
|
|
|
|
expect(provider.match?.('hero')?.map((item) => item.type)).toEqual([
|
|
'resource',
|
|
]);
|
|
expect(provider.match?.('HERO-IDLE')).toHaveLength(1);
|
|
expect(provider.match?.('character')).toHaveLength(1);
|
|
expect(provider.match?.(' hero ')).toHaveLength(1);
|
|
expect(provider.match?.('missing')).toEqual([]);
|
|
});
|
|
|
|
it('`toReference` 只认资源 part:资产已删除时不合成引用', () => {
|
|
expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe(
|
|
null,
|
|
);
|
|
expect(provider.toReference(attachmentPart)).toBe(null);
|
|
expect(
|
|
provider.toReference({
|
|
type: 'agc_resource_reference',
|
|
resourceId: 'asset-hero',
|
|
}),
|
|
).toMatchObject({ type: 'resource', label: 'hero-idle' });
|
|
expect(
|
|
provider.toReference({
|
|
type: 'agc_resource_reference',
|
|
resourceId: 'asset-gone',
|
|
}),
|
|
).toBe(null);
|
|
});
|
|
|
|
it('`refresh` 按 manifest 换显示名:改名换新引用、未变恒等、已删除原样返回', () => {
|
|
const reference = provider.match?.('hero')?.[0] as ResourceReference;
|
|
expect(provider.refresh(reference)).toBe(reference);
|
|
|
|
const renamed = createResourceReferenceProvider({
|
|
assets: [
|
|
asset('asset-hero', 'character', 'image/png', 'assets/hero-v2.png'),
|
|
],
|
|
});
|
|
const refreshed = renamed.refresh(reference);
|
|
expect(refreshed?.type === 'resource' ? refreshed.label : null).toBe(
|
|
'hero-v2',
|
|
);
|
|
|
|
const deleted = createResourceReferenceProvider({
|
|
assets: [asset('asset-other', 'icon', 'image/png', 'assets/btn.png')],
|
|
});
|
|
expect(deleted.refresh(reference)).toBe(reference);
|
|
|
|
const skill: ChatReference = { type: 'skill', name: 'agc-test-skill' };
|
|
expect(provider.refresh(skill)).toBe(null);
|
|
});
|
|
|
|
it('`mentionToken` 展开为 `@显示名`,清单缺失时退回引用 id', () => {
|
|
expect(
|
|
provider.mentionToken({
|
|
type: 'agc_resource_reference',
|
|
resourceId: 'asset-hero',
|
|
}),
|
|
).toBe('@hero-idle');
|
|
expect(
|
|
provider.mentionToken({
|
|
type: 'agc_resource_reference',
|
|
resourceId: 'asset-gone',
|
|
}),
|
|
).toBe('@asset-gone');
|
|
expect(provider.mentionToken({ type: 'input_text', text: '看' })).toBe(
|
|
null,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('附件 provider', () => {
|
|
it('静默:没有触发符也没有候选,注入它不会加出任何入口', () => {
|
|
expect(attachmentReferenceProvider.trigger).toBe(null);
|
|
expect(attachmentReferenceProvider.match).toBeUndefined();
|
|
});
|
|
|
|
it('`toReference` 逐字搬运附件字段,并只认附件 part', () => {
|
|
expect(attachmentReferenceProvider.toReference(attachmentPart)).toEqual({
|
|
type: 'attachment',
|
|
name: 'brief.md',
|
|
mediaType: 'text/markdown',
|
|
size: 128,
|
|
localPath: 'notes/brief.md',
|
|
status: 'imported',
|
|
});
|
|
expect(
|
|
attachmentReferenceProvider.toReference({
|
|
type: 'agc_resource_reference',
|
|
resourceId: 'asset-hero',
|
|
}),
|
|
).toBe(null);
|
|
expect(
|
|
attachmentReferenceProvider.toReference({ type: 'input_text', text: '' }),
|
|
).toBe(null);
|
|
});
|
|
|
|
it('`refresh` 对附件恒等,`mentionToken` 是 `@附件名`', () => {
|
|
const reference = attachmentReferenceProvider.toReference(
|
|
attachmentPart,
|
|
) as AttachmentReference;
|
|
expect(attachmentReferenceProvider.refresh(reference)).toBe(reference);
|
|
expect(
|
|
attachmentReferenceProvider.refresh({ type: 'skill', name: 'x' }),
|
|
).toBe(null);
|
|
expect(attachmentReferenceProvider.mentionToken(attachmentPart)).toBe(
|
|
'@brief.md',
|
|
);
|
|
expect(
|
|
attachmentReferenceProvider.mentionToken({
|
|
type: 'input_text',
|
|
text: '',
|
|
}),
|
|
).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('运行画面区域 provider', () => {
|
|
it('静默且只认运行画面区域 part,可选字段缺失时收成 undefined', () => {
|
|
expect(runtimeRegionReferenceProvider.trigger).toBe(null);
|
|
expect(runtimeRegionReferenceProvider.match).toBeUndefined();
|
|
expect(
|
|
runtimeRegionReferenceProvider.toReference(runtimeRegionPart),
|
|
).toEqual({
|
|
type: 'runtime-region',
|
|
label: '主画面',
|
|
runId: 'run-1',
|
|
versionId: 'version-1',
|
|
elementTag: 'canvas',
|
|
elementRole: 'playfield',
|
|
text: '可试玩区域',
|
|
width: 320,
|
|
height: 180,
|
|
resourceIds: ['asset-hero'],
|
|
source: 'runtime-picker',
|
|
});
|
|
expect(
|
|
runtimeRegionReferenceProvider.toReference({
|
|
type: 'agc_runtime_region_reference',
|
|
label: '主画面',
|
|
runId: null,
|
|
versionId: null,
|
|
elementTag: null,
|
|
elementRole: null,
|
|
text: null,
|
|
width: null,
|
|
height: null,
|
|
resourceIds: [],
|
|
}),
|
|
).toEqual({
|
|
type: 'runtime-region',
|
|
label: '主画面',
|
|
runId: undefined,
|
|
versionId: undefined,
|
|
elementTag: undefined,
|
|
elementRole: undefined,
|
|
text: undefined,
|
|
width: undefined,
|
|
height: undefined,
|
|
resourceIds: [],
|
|
source: 'runtime-picker',
|
|
});
|
|
expect(attachmentReferenceProvider.toReference(runtimeRegionPart)).toBe(
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('`refresh` 对运行区域恒等,`mentionToken` 是 `@标签`', () => {
|
|
const reference = runtimeRegionReferenceProvider.toReference(
|
|
runtimeRegionPart,
|
|
) as RuntimeRegionReference;
|
|
expect(runtimeRegionReferenceProvider.refresh(reference)).toBe(reference);
|
|
expect(
|
|
runtimeRegionReferenceProvider.refresh({ type: 'skill', name: 'x' }),
|
|
).toBe(null);
|
|
expect(runtimeRegionReferenceProvider.mentionToken(runtimeRegionPart)).toBe(
|
|
'@主画面',
|
|
);
|
|
expect(
|
|
runtimeRegionReferenceProvider.mentionToken({
|
|
type: 'input_text',
|
|
text: '@主画面',
|
|
}),
|
|
).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('Skill provider', () => {
|
|
it('触发符是 `$`,挂载时不发查询,第一次 `match` 才读应用级目录', 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 [];
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
});
|
|
window.__TAURI__ = { core: { invoke: invoke as never } };
|
|
|
|
const { result } = renderHook(() => useSkillReferenceProvider());
|
|
expect(result.current.trigger).toBe('$');
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
result.current.match?.('');
|
|
});
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.match?.('')).toHaveLength(1);
|
|
});
|
|
// 目录只读一次:后续每次敲 `$` 都复用同一份候选。
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
expect(result.current.match?.('测试')?.[0]).toMatchObject({
|
|
type: 'skill',
|
|
name: 'agc-test-skill',
|
|
});
|
|
});
|
|
|
|
it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => {
|
|
const invoke = vi.fn(async (command: string) => {
|
|
if (command === 'list_agc_skill_catalog') {
|
|
return Array.from({ length: 9 }, (_, index) => ({
|
|
name: `builtin-${index}`,
|
|
description: '内置',
|
|
}));
|
|
}
|
|
if (command === 'list_client_extensions') {
|
|
return [
|
|
{
|
|
name: 'builtin-0',
|
|
extensionType: 'skill',
|
|
enabled: true,
|
|
status: 'enabled',
|
|
},
|
|
{
|
|
name: 'client-skill',
|
|
extensionType: 'skill',
|
|
enabled: true,
|
|
status: 'enabled',
|
|
},
|
|
// 未启用 / 非 Skill 的扩展不进候选。
|
|
{
|
|
name: 'client-off',
|
|
extensionType: 'skill',
|
|
enabled: false,
|
|
status: 'enabled',
|
|
},
|
|
{
|
|
name: 'client-plugin',
|
|
extensionType: 'plugin',
|
|
enabled: true,
|
|
status: 'enabled',
|
|
},
|
|
];
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
});
|
|
window.__TAURI__ = { core: { invoke: invoke as never } };
|
|
|
|
const { result } = renderHook(() => useSkillReferenceProvider());
|
|
act(() => {
|
|
result.current.match?.('');
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.match?.('')).toHaveLength(8);
|
|
});
|
|
// 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。
|
|
expect(result.current.match?.('builtin-0')).toHaveLength(1);
|
|
expect(result.current.match?.('builtin-')).toHaveLength(8);
|
|
expect(result.current.match?.('client-skill')).toMatchObject([
|
|
{ type: 'skill', name: 'client-skill' },
|
|
]);
|
|
// 未启用与非 Skill 扩展都不是候选。
|
|
expect(result.current.match?.('client-off')).toEqual([]);
|
|
expect(result.current.match?.('client-plugin')).toEqual([]);
|
|
});
|
|
|
|
it('一次瞬时失败不锁死候选:下一次 `match` 还会重读目录', async () => {
|
|
const invoke = vi
|
|
.fn(async (command: string) => {
|
|
if (command === 'list_agc_skill_catalog') {
|
|
return [{ name: 'agc-test-skill' }];
|
|
}
|
|
if (command === 'list_client_extensions') return [];
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
})
|
|
.mockRejectedValueOnce(new Error('transient'));
|
|
window.__TAURI__ = { core: { invoke: invoke as never } };
|
|
|
|
const { result } = renderHook(() => useSkillReferenceProvider());
|
|
act(() => {
|
|
result.current.match?.('');
|
|
});
|
|
// 一次 `match` 读两份目录:内置 Skill 与客户端扩展。
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
});
|
|
act(() => {
|
|
result.current.match?.('');
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.match?.('')).toHaveLength(1);
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('`toReference` / `refresh` / `mentionToken` 只认 Skill', async () => {
|
|
const invoke = vi.fn(async () => []);
|
|
window.__TAURI__ = { core: { invoke: invoke as never } };
|
|
const { result } = renderHook(() => useSkillReferenceProvider());
|
|
|
|
expect(
|
|
result.current.toReference({ type: 'agc_skill_reference', name: 'demo' }),
|
|
).toEqual({ type: 'skill', name: 'demo' });
|
|
expect(
|
|
result.current.toReference({
|
|
type: 'agc_resource_reference',
|
|
resourceId: 'asset-hero',
|
|
}),
|
|
).toBe(null);
|
|
const reference: ChatReference = { type: 'skill', name: 'demo' };
|
|
expect(result.current.refresh(reference)).toBe(reference);
|
|
expect(result.current.refresh({ type: 'resource' } as ChatReference)).toBe(
|
|
null,
|
|
);
|
|
expect(
|
|
result.current.mentionToken({
|
|
type: 'agc_skill_reference',
|
|
name: 'demo',
|
|
}),
|
|
).toBe('$demo');
|
|
expect(
|
|
result.current.mentionToken({ type: 'input_text', text: '$demo' }),
|
|
).toBe(null);
|
|
});
|
|
});
|