1671d35995
让画布 Agent 消息统一走后端规划与 LLM 回复 修复生成图片在聊天、画布和素材库中的展示与恢复 补齐右键删除、画布焦点、聊天滚轮和面板避让交互 同步 Agent 事件契约、文档和定向回归测试
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
type EditorAgentConversationClient,
|
|
useEditorAgentConversation,
|
|
} from './useEditorAgentConversation';
|
|
|
|
function createClient(): EditorAgentConversationClient {
|
|
return {
|
|
listConversations: vi.fn().mockResolvedValue([
|
|
{
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
},
|
|
]),
|
|
createConversation: vi.fn().mockResolvedValue({
|
|
conversationId: 'conversation-2',
|
|
projectId: 'project-1',
|
|
title: '新对话',
|
|
messages: [],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:01:00.000Z',
|
|
}),
|
|
getConversation: vi.fn().mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
}),
|
|
deleteConversation: vi.fn().mockResolvedValue({
|
|
conversationId: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: '角色参考',
|
|
messages: [],
|
|
createdAt: '2026-07-03T00:00:00.000Z',
|
|
updatedAt: '2026-07-03T00:00:00.000Z',
|
|
}),
|
|
streamMessage: vi
|
|
.fn()
|
|
.mockImplementation(async (_conversationId, _payload, options) => {
|
|
options.onEvent?.({
|
|
event: 'stage',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
stage: 'thinking',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'message_delta',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-1',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
textDelta: '我来处理',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'tool_started',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-1',
|
|
toolCallId: 'tool-call-1',
|
|
toolName: 'generate_image',
|
|
taskId: 'task-1',
|
|
model: 'gpt-image-2',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'generation_result',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-1',
|
|
toolCallId: 'tool-call-1',
|
|
toolName: 'generate_image',
|
|
model: null,
|
|
images: [
|
|
{
|
|
resourceId: 'resource-result-1',
|
|
imageSrc: '/result.png',
|
|
thumbnailSrc: '/result-thumb.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'done',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
title: '像素角色',
|
|
},
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe('useEditorAgentConversation', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('loads conversations and applies message stream events', async () => {
|
|
const client = createClient();
|
|
const onGenerationResult = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({
|
|
projectId: 'project-1',
|
|
client,
|
|
onGenerationResult,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.sendMessage('把这个角色改成像素风');
|
|
});
|
|
|
|
expect(client.streamMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
text: '把这个角色改成像素风',
|
|
attachments: [],
|
|
}),
|
|
expect.objectContaining({
|
|
onEvent: expect.any(Function),
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
expect(result.current.stage).toBe('completed');
|
|
expect(result.current.isStreaming).toBe(false);
|
|
expect(onGenerationResult).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolCallId: 'tool-call-1',
|
|
images: [
|
|
expect.objectContaining({
|
|
resourceId: 'resource-result-1',
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(result.current.messages.map((message) => message.text)).toEqual([
|
|
'把这个角色改成像素风',
|
|
'我来处理',
|
|
]);
|
|
expect(result.current.messages[1]?.generations).toEqual([
|
|
expect.objectContaining({
|
|
toolCallId: 'tool-call-1',
|
|
taskId: 'task-1',
|
|
model: 'gpt-image-2',
|
|
status: 'completed',
|
|
images: [
|
|
expect.objectContaining({
|
|
resourceId: 'resource-result-1',
|
|
thumbnailSrc: '/result-thumb.png',
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
expect(result.current.conversations[0]?.title).toBe('像素角色');
|
|
});
|
|
|
|
it('creates a conversation before sending when the project has no history', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoadingConversations).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.sendMessage('新建后发送');
|
|
});
|
|
|
|
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
|
expect(client.streamMessage).toHaveBeenCalledWith(
|
|
'conversation-2',
|
|
expect.objectContaining({ text: '新建后发送' }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('allows sending an attachment-only message', async () => {
|
|
const client = createClient();
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.sendMessage('', [
|
|
{
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-1',
|
|
objectKey: 'generated-editor-assets/resource-1.png',
|
|
imageSrc: '/resource-1.png',
|
|
label: '参考图',
|
|
},
|
|
]);
|
|
});
|
|
|
|
expect(client.streamMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
expect.objectContaining({
|
|
text: '',
|
|
attachments: [
|
|
expect.objectContaining({
|
|
source: 'canvas_resource',
|
|
referenceId: 'resource-1',
|
|
}),
|
|
],
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('keeps LLM planning failures as one failed assistant message', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.streamMessage).mockImplementation(
|
|
async (_conversationId, _payload, options) => {
|
|
options.onEvent?.({
|
|
event: 'message_delta',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-planning-error',
|
|
role: 'assistant',
|
|
kind: 'error',
|
|
textDelta: '画布 Agent 的 LLM 未配置,无法处理这句话。',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'stage',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
stage: 'failed',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'done',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
title: null,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.sendMessage('这是美术素材');
|
|
});
|
|
|
|
expect(result.current.stage).toBe('failed');
|
|
expect(result.current.errorMessage).toBe(
|
|
'画布 Agent 的 LLM 未配置,无法处理这句话。',
|
|
);
|
|
expect(
|
|
result.current.messages.filter((message) => message.kind === 'error'),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
id: 'assistant-planning-error',
|
|
text: '画布 Agent 的 LLM 未配置,无法处理这句话。',
|
|
status: 'failed',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('shows a failed tool completion as a generation record', async () => {
|
|
const client = createClient();
|
|
vi.mocked(client.streamMessage).mockImplementation(
|
|
async (_conversationId, _payload, options) => {
|
|
options.onEvent?.({
|
|
event: 'message_delta',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-failed',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
textDelta: '我先尝试生成。',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'tool_started',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-failed',
|
|
toolCallId: 'tool-call-failed',
|
|
toolName: 'generate_image',
|
|
taskId: null,
|
|
model: 'gpt-image-2',
|
|
status: 'generating',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'tool_completed',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-failed',
|
|
toolCallId: 'tool-call-failed',
|
|
toolName: 'generate_image',
|
|
taskId: null,
|
|
model: 'gpt-image-2',
|
|
status: 'failed',
|
|
error: '泥点余额不足',
|
|
},
|
|
});
|
|
options.onEvent?.({
|
|
event: 'error',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
code: 'INSUFFICIENT_BALANCE',
|
|
message: '泥点余额不足',
|
|
recoverable: true,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
result.current.sendMessage('生成一张图'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
const failedMessage = result.current.messages.find(
|
|
(message) => message.id === 'assistant-failed',
|
|
);
|
|
expect(failedMessage?.status).toBe('failed');
|
|
expect(failedMessage?.generations).toEqual([
|
|
expect.objectContaining({
|
|
toolCallId: 'tool-call-failed',
|
|
status: 'failed',
|
|
model: 'gpt-image-2',
|
|
error: '泥点余额不足',
|
|
}),
|
|
]);
|
|
expect(result.current.errorMessage).toBe('泥点余额不足');
|
|
});
|
|
|
|
it('aborts the active stream and marks streaming messages as stopped', async () => {
|
|
const client = createClient();
|
|
let streamSignal: AbortSignal | null = null;
|
|
vi.mocked(client.streamMessage).mockImplementation(
|
|
(_conversationId, _payload, options) =>
|
|
new Promise<void>((resolve) => {
|
|
streamSignal = options.signal ?? null;
|
|
options.onEvent?.({
|
|
event: 'message_delta',
|
|
data: {
|
|
conversationId: 'conversation-1',
|
|
messageId: 'assistant-streaming',
|
|
role: 'assistant',
|
|
kind: 'chat',
|
|
textDelta: '处理中',
|
|
},
|
|
});
|
|
streamSignal?.addEventListener('abort', () => resolve(), {
|
|
once: true,
|
|
});
|
|
}),
|
|
);
|
|
const { result } = renderHook(() =>
|
|
useEditorAgentConversation({ projectId: 'project-1', client }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeConversation?.conversationId).toBe(
|
|
'conversation-1',
|
|
);
|
|
});
|
|
|
|
void act(() => {
|
|
void result.current.sendMessage('请继续');
|
|
});
|
|
await waitFor(() => {
|
|
expect(result.current.isStreaming).toBe(true);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.stopCurrentTurn();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(streamSignal?.aborted).toBe(true);
|
|
});
|
|
expect(result.current.messages.at(-1)?.status).toBe('stopped');
|
|
expect(result.current.isStreaming).toBe(false);
|
|
});
|
|
});
|