合并画布 Agent 侧边栏聊天

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/spacetime-client/src/module_bindings.rs
This commit is contained in:
2026-07-04 18:39:40 +08:00
90 changed files with 8559 additions and 285 deletions
@@ -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);
}
});
}