diff --git a/packages/shared/src/contracts/editorAgent.ts b/packages/shared/src/contracts/editorAgent.ts index a7daaeb33..483ec85bc 100644 --- a/packages/shared/src/contracts/editorAgent.ts +++ b/packages/shared/src/contracts/editorAgent.ts @@ -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 }; diff --git a/src/services/image-editor/editorAgentClient.test.ts b/src/services/image-editor/editorAgentClient.test.ts index 32c0f140f..e043226a7 100644 --- a/src/services/image-editor/editorAgentClient.test.ts +++ b/src/services/image-editor/editorAgentClient.test.ts @@ -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({ - 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[] = []; + it('sends an editor agent message and returns delta messages', async () => { + const responseBody = { + deltaMessages: [ + { + id: 1, + role: 'assistant', + text: '我来处理', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:00:00.000Z', + }, + ], + errorMessage: null, + }; + requestJsonMock.mockResolvedValueOnce(responseBody); - await streamEditorAgentMessage( - 'conversation-1', - { - clientMessageId: 'client-message-1', - text: '帮我把角色改成像素风', - attachments: [], - }, - { - onEvent: (event) => events.push(event.event), - }, - ); + const result = await sendEditorAgentMessage('conversation-1', { + text: '帮我把角色改成像素风', + attachments: [], + }); - 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' }, + ); + }); }); diff --git a/src/services/image-editor/editorAgentClient.ts b/src/services/image-editor/editorAgentClient.ts index 252bc5d30..fb225965c 100644 --- a/src/services/image-editor/editorAgentClient.ts +++ b/src/services/image-editor/editorAgentClient.ts @@ -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 { @@ -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 { + return requestJson( + `${agentConversationPath(conversationId)}/messages`, { ...jsonRequest('POST', payload as unknown as Record), 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 { + return requestJson( + agentToolCallActionPath(conversationId, messageId, 'confirm'), + { method: 'POST' }, + '确认画布 Agent 操作失败', + { + timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS, + authImpact: 'local', + }, + ); +} + +export async function cancelEditorAgentToolCall( + conversationId: string, + messageId: number, +): Promise { + return requestJson( + agentToolCallActionPath(conversationId, messageId, 'cancel'), + { method: 'POST' }, + '取消画布 Agent 操作失败', + { authImpact: 'local' }, + ); } diff --git a/src/services/image-editor/editorAgentSse.test.ts b/src/services/image-editor/editorAgentSse.test.ts deleted file mode 100644 index 7fd222503..000000000 --- a/src/services/image-editor/editorAgentSse.test.ts +++ /dev/null @@ -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({ - 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, - }, - }); -}); diff --git a/src/services/image-editor/editorAgentSse.ts b/src/services/image-editor/editorAgentSse.ts deleted file mode 100644 index 7eefd3120..000000000 --- a/src/services/image-editor/editorAgentSse.ts +++ /dev/null @@ -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([ - '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) { - return isEditorAgentSseEventName(parsed.event) ? parsed.event : null; -} - -function readEventDataFromPayload(parsed: Record) { - const data = parsed.data; - return typeof data === 'object' && data !== null - ? (data as Record) - : null; -} - -export function normalizeEditorAgentSseEvent( - eventName: string, - parsed: Record, -): 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); - } - }); -}