合并画布 Agent 侧边栏聊天
# Conflicts: # docs/project-memory/shared-memory/decision-log.md # server-rs/crates/spacetime-client/src/module_bindings.rs
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createEditorAgentConversation,
|
||||
deleteEditorAgentConversation,
|
||||
getEditorAgentConversation,
|
||||
listEditorAgentConversations,
|
||||
streamEditorAgentMessage,
|
||||
} from './editorAgentClient';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
const fetchWithApiAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
fetchWithApiAuth: fetchWithApiAuthMock,
|
||||
}));
|
||||
|
||||
function createSseResponse(payload: string) {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('editorAgentClient', () => {
|
||||
afterEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
fetchWithApiAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it('uses the planned editor agent conversation CRUD routes', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
conversations: [
|
||||
{
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
conversation: {
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '新对话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await listEditorAgentConversations('project-1');
|
||||
await createEditorAgentConversation('project-1', { title: '新对话' });
|
||||
await getEditorAgentConversation('conversation-1');
|
||||
await deleteEditorAgentConversation('conversation-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/editor/projects/project-1/agent-conversations',
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话列表失败',
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/editor/projects/project-1/agent-conversations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '新对话' }),
|
||||
}),
|
||||
'创建画布 Agent 会话失败',
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/editor/agent-conversations/conversation-1',
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话失败',
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/api/editor/agent-conversations/conversation-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除画布 Agent 会话失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('posts editor agent message streams and emits typed SSE events', async () => {
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: stage',
|
||||
'data: {"conversationId":"conversation-1","stage":"thinking"}',
|
||||
'',
|
||||
'event: message_delta',
|
||||
'data: {"conversationId":"conversation-1","messageId":"assistant-1","role":"assistant","kind":"chat","textDelta":"我先看一下"}',
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"conversationId":"conversation-1","title":"生成角色"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
const events: string[] = [];
|
||||
|
||||
await streamEditorAgentMessage(
|
||||
'conversation-1',
|
||||
{
|
||||
clientMessageId: 'client-message-1',
|
||||
text: '帮我把角色改成像素风',
|
||||
attachments: [],
|
||||
},
|
||||
{
|
||||
onEvent: (event) => events.push(event.event),
|
||||
},
|
||||
);
|
||||
|
||||
expect(events).toEqual(['stage', 'message_delta', 'done']);
|
||||
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
|
||||
'/api/editor/agent-conversations/conversation-1/messages/stream',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientMessageId: 'client-message-1',
|
||||
text: '帮我把角色改成像素风',
|
||||
attachments: [],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
authImpact: 'local',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
CreateEditorAgentConversationRequest,
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentConversationListResponse,
|
||||
EditorAgentConversationResponse,
|
||||
EditorAgentConversationSummary,
|
||||
EditorAgentSseEvent,
|
||||
StreamEditorAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import {
|
||||
appendApiErrorRequestId,
|
||||
parseApiErrorMessage,
|
||||
} from '../../../packages/shared/src/http';
|
||||
import { fetchWithApiAuth, requestJson } from '../apiClient';
|
||||
import { readEditorAgentSseEvents } from './editorAgentSse';
|
||||
|
||||
const EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE = '/api/editor/projects';
|
||||
const EDITOR_AGENT_CONVERSATION_API_BASE = '/api/editor/agent-conversations';
|
||||
const EDITOR_AGENT_STREAM_TIMEOUT_MS = 1_200_000;
|
||||
|
||||
export type StreamEditorAgentMessageOptions = {
|
||||
signal?: AbortSignal;
|
||||
onEvent?: (event: EditorAgentSseEvent) => void;
|
||||
};
|
||||
|
||||
type DeleteEditorAgentConversationResponse = {
|
||||
deletedConversationId: string;
|
||||
conversation: EditorAgentConversationSummary;
|
||||
};
|
||||
|
||||
function jsonRequest(method: 'POST', body: Record<string, unknown>) {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function projectAgentConversationsPath(projectId: string) {
|
||||
return `${EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE}/${encodeURIComponent(
|
||||
projectId,
|
||||
)}/agent-conversations`;
|
||||
}
|
||||
|
||||
function agentConversationPath(conversationId: string) {
|
||||
return `${EDITOR_AGENT_CONVERSATION_API_BASE}/${encodeURIComponent(
|
||||
conversationId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
export async function listEditorAgentConversations(
|
||||
projectId: string,
|
||||
): Promise<EditorAgentConversationSummary[]> {
|
||||
const response = await requestJson<EditorAgentConversationListResponse>(
|
||||
projectAgentConversationsPath(projectId),
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话列表失败',
|
||||
);
|
||||
return response.conversations;
|
||||
}
|
||||
|
||||
export async function createEditorAgentConversation(
|
||||
projectId: string,
|
||||
input: CreateEditorAgentConversationRequest = {},
|
||||
): Promise<EditorAgentConversationDetail> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (input.title !== undefined) {
|
||||
body.title = input.title;
|
||||
}
|
||||
const response = await requestJson<EditorAgentConversationResponse>(
|
||||
projectAgentConversationsPath(projectId),
|
||||
jsonRequest('POST', body),
|
||||
'创建画布 Agent 会话失败',
|
||||
);
|
||||
return response.conversation;
|
||||
}
|
||||
|
||||
export async function getEditorAgentConversation(
|
||||
conversationId: string,
|
||||
): Promise<EditorAgentConversationDetail> {
|
||||
const response = await requestJson<EditorAgentConversationResponse>(
|
||||
agentConversationPath(conversationId),
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话失败',
|
||||
);
|
||||
return response.conversation;
|
||||
}
|
||||
|
||||
export async function deleteEditorAgentConversation(
|
||||
conversationId: string,
|
||||
): Promise<EditorAgentConversationSummary> {
|
||||
const response = await requestJson<DeleteEditorAgentConversationResponse>(
|
||||
agentConversationPath(conversationId),
|
||||
{ method: 'DELETE' },
|
||||
'删除画布 Agent 会话失败',
|
||||
);
|
||||
return response.conversation;
|
||||
}
|
||||
|
||||
export async function streamEditorAgentMessage(
|
||||
conversationId: string,
|
||||
payload: StreamEditorAgentMessageRequest,
|
||||
options: StreamEditorAgentMessageOptions = {},
|
||||
) {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${agentConversationPath(conversationId)}/messages/stream`,
|
||||
{
|
||||
...jsonRequest('POST', payload as unknown as Record<string, unknown>),
|
||||
signal: options.signal,
|
||||
},
|
||||
{
|
||||
timeoutMs: EDITOR_AGENT_STREAM_TIMEOUT_MS,
|
||||
authImpact: 'local',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(
|
||||
appendApiErrorRequestId(
|
||||
parseApiErrorMessage(responseText, '发送画布 Agent 消息失败'),
|
||||
response.headers.get('x-request-id'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
await readEditorAgentSseEvents(response, (event) => options.onEvent?.(event));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { readEditorAgentSseEvents } from './editorAgentSse';
|
||||
|
||||
function createSseResponse(chunks: string[]) {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('readEditorAgentSseEvents parses stage, generation result and done events', async () => {
|
||||
const onEvent = vi.fn();
|
||||
await readEditorAgentSseEvents(
|
||||
createSseResponse([
|
||||
'event: stage\r\ndata: {"conversationId":"conversation-1","stage":"generating"}\r\n\r\n',
|
||||
'event: generation_result\r\ndata: {"conversationId":"conversation-1","messageId":"assistant-1","toolCallId":"tool-1","toolName":"generate_image","model":"gpt-image-2","images":[{"resourceId":"resource-1","imageSrc":"/generated/1.png","thumbnailSrc":null,"width":512,"height":512}]}\r\n\r\n',
|
||||
'event: done\r\ndata: {"conversationId":"conversation-1","title":"像素角色"}\r\n\r\n',
|
||||
]),
|
||||
onEvent,
|
||||
);
|
||||
|
||||
expect(onEvent).toHaveBeenNthCalledWith(1, {
|
||||
event: 'stage',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
stage: 'generating',
|
||||
},
|
||||
});
|
||||
expect(onEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
event: 'generation_result',
|
||||
data: expect.objectContaining({
|
||||
toolCallId: 'tool-1',
|
||||
images: [
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
imageSrc: '/generated/1.png',
|
||||
thumbnailSrc: null,
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onEvent).toHaveBeenNthCalledWith(3, {
|
||||
event: 'done',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
title: '像素角色',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('readEditorAgentSseEvents accepts fallback event name from message payload', async () => {
|
||||
const onEvent = vi.fn();
|
||||
|
||||
await readEditorAgentSseEvents(
|
||||
createSseResponse([
|
||||
'data: {"event":"error","data":{"conversationId":"conversation-1","code":"INSUFFICIENT_BALANCE","message":"泥点不足","recoverable":true}}\n\n',
|
||||
]),
|
||||
onEvent,
|
||||
);
|
||||
|
||||
expect(onEvent).toHaveBeenCalledWith({
|
||||
event: 'error',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
message: '泥点不足',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { EditorAgentSseEvent } from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
const EDITOR_AGENT_SSE_EVENT_NAMES = new Set<EditorAgentSseEvent['event']>([
|
||||
'stage',
|
||||
'message_delta',
|
||||
'tool_started',
|
||||
'tool_completed',
|
||||
'generation_result',
|
||||
'error',
|
||||
'done',
|
||||
]);
|
||||
|
||||
function isEditorAgentSseEventName(
|
||||
value: unknown,
|
||||
): value is EditorAgentSseEvent['event'] {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
EDITOR_AGENT_SSE_EVENT_NAMES.has(value as EditorAgentSseEvent['event'])
|
||||
);
|
||||
}
|
||||
|
||||
function readEventNameFromPayload(parsed: Record<string, unknown>) {
|
||||
return isEditorAgentSseEventName(parsed.event) ? parsed.event : null;
|
||||
}
|
||||
|
||||
function readEventDataFromPayload(parsed: Record<string, unknown>) {
|
||||
const data = parsed.data;
|
||||
return typeof data === 'object' && data !== null
|
||||
? (data as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function normalizeEditorAgentSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): EditorAgentSseEvent | null {
|
||||
const payloadEventName = readEventNameFromPayload(parsed);
|
||||
if (payloadEventName) {
|
||||
const payloadData = readEventDataFromPayload(parsed);
|
||||
if (!payloadData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
event: payloadEventName,
|
||||
data: payloadData,
|
||||
} as unknown as EditorAgentSseEvent;
|
||||
}
|
||||
|
||||
if (!isEditorAgentSseEventName(eventName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
data: parsed,
|
||||
} as unknown as EditorAgentSseEvent;
|
||||
}
|
||||
|
||||
export async function readEditorAgentSseEvents(
|
||||
response: Response,
|
||||
onEvent: (event: EditorAgentSseEvent) => void,
|
||||
) {
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const event = normalizeEditorAgentSseEvent(eventName, parsed);
|
||||
if (event) {
|
||||
onEvent(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user