remove SSE-based implementation for editor agent

This commit is contained in:
2026-07-10 11:23:26 +08:00
parent 084214e2ed
commit 365ada84e3
5 changed files with 157 additions and 344 deletions
+27 -99
View File
@@ -5,34 +5,17 @@ export const EDITOR_AGENT_TITLE_MAX_CHARS = 20;
export const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE = '新对话';
export const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION = 1;
export type EditorAgentStage =
| 'idle'
| 'thinking'
| 'responding'
| 'generating'
export type EditorAgentMessageRole = 'user' | 'assistant' | 'system';
export type EditorAgentToolCallStatus =
| 'pending_confirmation'
| 'executing'
| 'completed'
| 'cancelled'
| 'failed';
export type EditorAgentMessageRole = 'user' | 'assistant';
export type EditorAgentMessageKind = 'chat' | 'stage' | 'error';
export type EditorAgentMessageStatus =
| 'streaming'
| 'generating'
| 'completed'
| 'failed'
| 'stopped';
export type EditorAgentAttachmentSource = 'canvas_resource' | 'library_asset';
export type EditorAgentToolName =
| 'generate_image'
| 'edit_image'
| 'generate_character'
| 'generate_icon_spritesheet'
| 'generate_ui_design';
export interface EditorAgentAttachmentRef {
source: EditorAgentAttachmentSource;
referenceId: string;
@@ -45,49 +28,45 @@ export interface EditorAgentAttachmentRef {
}
export interface EditorAgentGeneratedImage {
resourceId: string | null;
resourceId?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
imageSrc: string;
thumbnailSrc: string | null;
width: number | null;
height: number | null;
thumbnailSrc?: string | null;
width?: number | null;
height?: number | null;
}
export type EditorAgentGenerationStatus = 'generating' | 'completed' | 'failed';
export interface EditorAgentGenerationRecord {
toolCallId: string;
toolName: EditorAgentToolName;
summary?: string | null;
taskId: string | null;
status: EditorAgentGenerationStatus;
model: string | null;
export interface EditorAgentToolCall {
toolName: string;
summary: string;
status: EditorAgentToolCallStatus;
args: unknown;
images: EditorAgentGeneratedImage[];
error?: string | null;
}
export interface EditorAgentMessage {
id: string;
id: number;
role: EditorAgentMessageRole;
kind: EditorAgentMessageKind;
text: string;
attachments: EditorAgentAttachmentRef[];
generations: EditorAgentGenerationRecord[];
status: EditorAgentMessageStatus;
toolCall: EditorAgentToolCall | null;
createdAt: string;
}
export interface EditorAgentConversationSummary {
conversationId: string;
projectId: string;
title: string;
createdAt: string;
updatedAt: string;
}
export interface EditorAgentConversationDetail
extends EditorAgentConversationSummary {
export interface EditorAgentConversationDetail {
conversationId: string;
projectId: string;
title: string;
createdAt: string;
updatedAt: string;
messages: EditorAgentMessage[];
}
@@ -109,63 +88,12 @@ export interface EditorAgentConversationResponse {
conversation: EditorAgentConversationDetail;
}
export interface StreamEditorAgentMessageRequest {
clientMessageId: string;
export interface EditorAgentMessageRequest {
text: string;
attachments?: EditorAgentAttachmentRef[];
}
export interface EditorAgentStageEvent {
conversationId: string;
stage: EditorAgentStage;
export interface EditorAgentMessageResponse {
deltaMessages: EditorAgentMessage[];
errorMessage: string | null;
}
export interface EditorAgentMessageDeltaEvent {
conversationId: string;
messageId: string;
role: EditorAgentMessageRole;
kind: EditorAgentMessageKind;
textDelta: string;
}
export interface EditorAgentToolEvent {
conversationId: string;
messageId: string;
toolCallId: string;
toolName: EditorAgentToolName;
summary?: string | null;
taskId?: string | null;
model?: string | null;
status?: EditorAgentGenerationStatus | null;
error?: string | null;
}
export interface EditorAgentGenerationResultEvent {
conversationId: string;
messageId: string;
toolCallId: string;
toolName: EditorAgentToolName;
model: string | null;
images: EditorAgentGeneratedImage[];
}
export interface EditorAgentErrorEvent {
conversationId: string | null;
code: string;
message: string;
recoverable: boolean;
}
export interface EditorAgentDoneEvent {
conversationId: string;
title: string | null;
}
export type EditorAgentSseEvent =
| { event: 'stage'; data: EditorAgentStageEvent }
| { event: 'message_delta'; data: EditorAgentMessageDeltaEvent }
| { event: 'tool_started'; data: EditorAgentToolEvent }
| { event: 'tool_completed'; data: EditorAgentToolEvent }
| { event: 'generation_result'; data: EditorAgentGenerationResultEvent }
| { event: 'error'; data: EditorAgentErrorEvent }
| { event: 'done'; data: EditorAgentDoneEvent };
@@ -1,41 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
cancelEditorAgentToolCall,
confirmEditorAgentToolCall,
createEditorAgentConversation,
deleteEditorAgentConversation,
getEditorAgentConversation,
listEditorAgentConversations,
streamEditorAgentMessage,
sendEditorAgentMessage,
} 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 () => {
@@ -117,53 +100,96 @@ describe('editorAgentClient', () => {
);
});
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',
it('sends an editor agent message and returns delta messages', async () => {
const responseBody = {
deltaMessages: [
{
clientMessageId: 'client-message-1',
id: 1,
role: 'assistant',
text: '我来处理',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:00.000Z',
},
],
errorMessage: null,
};
requestJsonMock.mockResolvedValueOnce(responseBody);
const result = await sendEditorAgentMessage('conversation-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(result).toEqual(responseBody);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/agent-conversations/conversation-1/messages',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
}),
}),
'发送画布 Agent 消息失败',
expect.objectContaining({
timeoutMs: 1_200_000,
authImpact: 'local',
}),
);
});
it('confirms and cancels a pending tool call by numeric message id', async () => {
const completedMessage = {
id: 7,
role: 'system',
text: 'internal completed tool output',
attachments: [],
toolCall: {
toolName: 'edit-image',
summary: '',
status: 'completed',
args: {},
images: [],
error: null,
},
createdAt: '2026-07-10T00:00:00.000Z',
};
const cancelledMessage = {
...completedMessage,
toolCall: {
...completedMessage.toolCall,
status: 'cancelled',
},
};
requestJsonMock
.mockResolvedValueOnce(completedMessage)
.mockResolvedValueOnce(cancelledMessage);
await expect(confirmEditorAgentToolCall('conversation/1', 7)).resolves.toBe(
completedMessage,
);
await expect(cancelEditorAgentToolCall('conversation/1', 7)).resolves.toBe(
cancelledMessage,
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/editor/agent-conversations/conversation%2F1/messages/7/confirm',
{ method: 'POST' },
'确认画布 Agent 操作失败',
{
timeoutMs: 1_200_000,
authImpact: 'local',
},
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/editor/agent-conversations/conversation%2F1/messages/7/cancel',
{ method: 'POST' },
'取消画布 Agent 操作失败',
{ authImpact: 'local' },
);
});
});
+51 -34
View File
@@ -4,23 +4,18 @@ import type {
EditorAgentConversationListResponse,
EditorAgentConversationResponse,
EditorAgentConversationSummary,
EditorAgentSseEvent,
StreamEditorAgentMessageRequest,
EditorAgentMessage,
EditorAgentMessageRequest,
EditorAgentMessageResponse,
} from '../../../packages/shared/src/contracts/editorAgent';
import {
appendApiErrorRequestId,
parseApiErrorMessage,
} from '../../../packages/shared/src/http';
import { fetchWithApiAuth, requestJson } from '../apiClient';
import { readEditorAgentSseEvents } from './editorAgentSse';
import { requestJson } from '../apiClient';
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;
const EDITOR_AGENT_MESSAGE_TIMEOUT_MS = 1_200_000;
export type StreamEditorAgentMessageOptions = {
export type SendEditorAgentMessageOptions = {
signal?: AbortSignal;
onEvent?: (event: EditorAgentSseEvent) => void;
};
type DeleteEditorAgentConversationResponse = {
@@ -48,6 +43,16 @@ function agentConversationPath(conversationId: string) {
)}`;
}
function agentToolCallActionPath(
conversationId: string,
messageId: number,
action: 'confirm' | 'cancel',
) {
return `${agentConversationPath(conversationId)}/messages/${encodeURIComponent(
String(messageId),
)}/${action}`;
}
export async function listEditorAgentConversations(
projectId: string,
): Promise<EditorAgentConversationSummary[]> {
@@ -97,36 +102,48 @@ export async function deleteEditorAgentConversation(
return response.conversation;
}
export async function streamEditorAgentMessage(
export async function sendEditorAgentMessage(
conversationId: string,
payload: StreamEditorAgentMessageRequest,
options: StreamEditorAgentMessageOptions = {},
) {
const response = await fetchWithApiAuth(
`${agentConversationPath(conversationId)}/messages/stream`,
payload: EditorAgentMessageRequest,
options: SendEditorAgentMessageOptions = {},
): Promise<EditorAgentMessageResponse> {
return requestJson<EditorAgentMessageResponse>(
`${agentConversationPath(conversationId)}/messages`,
{
...jsonRequest('POST', payload as unknown as Record<string, unknown>),
signal: options.signal,
},
'发送画布 Agent 消息失败',
{
timeoutMs: EDITOR_AGENT_STREAM_TIMEOUT_MS,
timeoutMs: EDITOR_AGENT_MESSAGE_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));
}
export async function confirmEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<EditorAgentMessage> {
return requestJson<EditorAgentMessage>(
agentToolCallActionPath(conversationId, messageId, 'confirm'),
{ method: 'POST' },
'确认画布 Agent 操作失败',
{
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
authImpact: 'local',
},
);
}
export async function cancelEditorAgentToolCall(
conversationId: string,
messageId: number,
): Promise<EditorAgentMessage> {
return requestJson<EditorAgentMessage>(
agentToolCallActionPath(conversationId, messageId, 'cancel'),
{ method: 'POST' },
'取消画布 Agent 操作失败',
{ authImpact: 'local' },
);
}
@@ -1,88 +0,0 @@
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,
},
});
});
@@ -1,70 +0,0 @@
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);
}
});
}