import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CreateEditorAgentConversationRequest, EditorAgentAttachmentRef, EditorAgentConversationDetail, EditorAgentConversationSummary, EditorAgentGenerationResultEvent, EditorAgentGenerationRecord, EditorAgentMessage, EditorAgentSseEvent, EditorAgentStage, StreamEditorAgentMessageRequest, } from '../../../packages/shared/src/contracts/editorAgent'; import { createEditorAgentConversation, deleteEditorAgentConversation, getEditorAgentConversation, listEditorAgentConversations, streamEditorAgentMessage, type StreamEditorAgentMessageOptions, } from '../../services/image-editor/editorAgentClient'; export type EditorAgentConversationClient = { listConversations: ( projectId: string, ) => Promise; createConversation: ( projectId: string, input?: CreateEditorAgentConversationRequest, ) => Promise; getConversation: ( conversationId: string, ) => Promise; deleteConversation: ( conversationId: string, ) => Promise; streamMessage: ( conversationId: string, payload: StreamEditorAgentMessageRequest, options: StreamEditorAgentMessageOptions, ) => Promise; }; type UseEditorAgentConversationOptions = { projectId?: string | null; client?: EditorAgentConversationClient; onGenerationResult?: (event: EditorAgentGenerationResultEvent) => void; }; const defaultEditorAgentConversationClient: EditorAgentConversationClient = { listConversations: listEditorAgentConversations, createConversation: createEditorAgentConversation, getConversation: getEditorAgentConversation, deleteConversation: deleteEditorAgentConversation, streamMessage: streamEditorAgentMessage, }; function createClientId(prefix: string) { if ( typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ) { return `${prefix}-${crypto.randomUUID()}`; } return `${prefix}-${Date.now().toString(36)}-${Math.random() .toString(36) .slice(2)}`; } function isAbortError(error: unknown) { return ( error instanceof Error && (error.name === 'AbortError' || (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError')) ); } function createLocalUserMessage(params: { id: string; text: string; attachments: EditorAgentAttachmentRef[]; }): EditorAgentMessage { return { id: params.id, role: 'user', kind: 'chat', text: params.text, attachments: params.attachments, generations: [], status: 'completed', createdAt: new Date().toISOString(), }; } function createAssistantMessage(params: { id: string; role?: EditorAgentMessage['role']; kind?: EditorAgentMessage['kind']; text?: string; status?: EditorAgentMessage['status']; generations?: EditorAgentGenerationRecord[]; }): EditorAgentMessage { return { id: params.id, role: params.role ?? 'assistant', kind: params.kind ?? 'chat', text: params.text ?? '', attachments: [], generations: params.generations ?? [], status: params.status ?? 'streaming', createdAt: new Date().toISOString(), }; } function upsertMessage( messages: EditorAgentMessage[], messageId: string, createMessage: () => EditorAgentMessage, updateMessage: (message: EditorAgentMessage) => EditorAgentMessage, ) { let updated = false; const nextMessages = messages.map((message) => { if (message.id !== messageId) { return message; } updated = true; return updateMessage(message); }); if (updated) { return nextMessages; } return [...messages, updateMessage(createMessage())]; } function upsertGenerationRecord( records: EditorAgentGenerationRecord[], nextRecord: EditorAgentGenerationRecord, ) { const existingIndex = records.findIndex( (record) => record.toolCallId === nextRecord.toolCallId, ); if (existingIndex === -1) { return [...records, nextRecord]; } return records.map((record, index) => index === existingIndex ? { ...record, ...nextRecord, summary: nextRecord.summary ?? record.summary, taskId: nextRecord.taskId ?? record.taskId, model: nextRecord.model ?? record.model, images: nextRecord.images.length ? nextRecord.images : record.images, } : record, ); } function markStreamingMessages( messages: EditorAgentMessage[], status: EditorAgentMessage['status'], ) { return messages.map((message) => message.status === 'streaming' || message.status === 'generating' ? { ...message, status } : message, ); } function sortConversationsByUpdatedAt( conversations: EditorAgentConversationSummary[], ) { return [...conversations].sort( (left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || left.conversationId.localeCompare(right.conversationId), ); } function summaryFromDetail( detail: EditorAgentConversationDetail, ): EditorAgentConversationSummary { return { conversationId: detail.conversationId, projectId: detail.projectId, title: detail.title, createdAt: detail.createdAt, updatedAt: detail.updatedAt, }; } function upsertConversationSummary( conversations: EditorAgentConversationSummary[], summary: EditorAgentConversationSummary, ) { const nextConversations = conversations.some( (conversation) => conversation.conversationId === summary.conversationId, ) ? conversations.map((conversation) => conversation.conversationId === summary.conversationId ? summary : conversation, ) : [summary, ...conversations]; return sortConversationsByUpdatedAt(nextConversations); } export function useEditorAgentConversation({ projectId, client = defaultEditorAgentConversationClient, onGenerationResult, }: UseEditorAgentConversationOptions) { const normalizedProjectId = projectId?.trim() ?? ''; const [conversations, setConversations] = useState< EditorAgentConversationSummary[] >([]); const [activeConversationId, setActiveConversationId] = useState< string | null >(null); const [messages, setMessages] = useState([]); const [stage, setStage] = useState('idle'); const [isLoadingConversations, setIsLoadingConversations] = useState(false); const [isLoadingMessages, setIsLoadingMessages] = useState(false); const [isCreatingConversation, setIsCreatingConversation] = useState(false); const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const activeStreamAbortControllerRef = useRef(null); const activeConversationIdRef = useRef(null); useEffect(() => { activeConversationIdRef.current = activeConversationId; }, [activeConversationId]); const activeConversation = useMemo( () => activeConversationId ? (conversations.find( (conversation) => conversation.conversationId === activeConversationId, ) ?? null) : null, [activeConversationId, conversations], ); const applyConversationDetail = useCallback( (detail: EditorAgentConversationDetail) => { setConversations((currentConversations) => upsertConversationSummary( currentConversations, summaryFromDetail(detail), ), ); setActiveConversationId(detail.conversationId); setMessages(detail.messages); setStage('idle'); }, [], ); const loadConversation = useCallback( async (conversationId: string) => { setIsLoadingMessages(true); setErrorMessage(null); try { const detail = await client.getConversation(conversationId); applyConversationDetail(detail); return detail; } catch (error) { setErrorMessage( error instanceof Error ? error.message : '读取画布 Agent 会话失败', ); throw error; } finally { setIsLoadingMessages(false); } }, [applyConversationDetail, client], ); useEffect(() => { if (!normalizedProjectId) { setConversations([]); setActiveConversationId(null); setMessages([]); setStage('idle'); setErrorMessage(null); return undefined; } let disposed = false; setIsLoadingConversations(true); setErrorMessage(null); Promise.resolve(client.listConversations(normalizedProjectId) ?? []) .then(async (nextConversations) => { if (disposed) { return; } const sortedConversations = sortConversationsByUpdatedAt(nextConversations); setConversations(sortedConversations); const firstConversation = sortedConversations[0] ?? null; if (!firstConversation) { setActiveConversationId(null); setMessages([]); return; } const detail = await client.getConversation( firstConversation.conversationId, ); if (!disposed) { applyConversationDetail(detail); } }) .catch((error: unknown) => { if (!disposed) { setErrorMessage( error instanceof Error ? error.message : '读取画布 Agent 会话列表失败', ); } }) .finally(() => { if (!disposed) { setIsLoadingConversations(false); } }); return () => { disposed = true; }; }, [applyConversationDetail, client, normalizedProjectId]); const createConversation = useCallback(async () => { if (!normalizedProjectId) { throw new Error('缺少画布项目 ID'); } setIsCreatingConversation(true); setErrorMessage(null); try { const detail = await client.createConversation(normalizedProjectId, {}); applyConversationDetail(detail); return detail; } catch (error) { setErrorMessage( error instanceof Error ? error.message : '创建画布 Agent 会话失败', ); throw error; } finally { setIsCreatingConversation(false); } }, [applyConversationDetail, client, normalizedProjectId]); const selectConversation = useCallback( async (conversationId: string) => { if (conversationId === activeConversationId) { return null; } return loadConversation(conversationId); }, [activeConversationId, loadConversation], ); const handleSseEvent = useCallback((event: EditorAgentSseEvent) => { const eventConversationId = event.data.conversationId; if ( eventConversationId && eventConversationId !== activeConversationIdRef.current ) { return; } if (event.event === 'stage') { setStage(event.data.stage); return; } if (event.event === 'message_delta') { if (event.data.kind === 'error') { const nextErrorMessage = event.data.textDelta.trim(); if (nextErrorMessage) { setErrorMessage(nextErrorMessage); } } setMessages((currentMessages) => upsertMessage( currentMessages, event.data.messageId, () => createAssistantMessage({ id: event.data.messageId, role: event.data.role, kind: event.data.kind, }), (message) => ({ ...message, role: event.data.role, kind: event.data.kind, text: `${message.text}${event.data.textDelta}`, status: event.data.kind === 'error' ? 'failed' : 'streaming', }), ), ); return; } if (event.event === 'tool_started' || event.event === 'tool_completed') { const status = event.event === 'tool_started' ? 'generating' : (event.data.status ?? 'completed'); const nextRecord: EditorAgentGenerationRecord = { toolCallId: event.data.toolCallId, toolName: event.data.toolName, summary: event.data.summary ?? null, taskId: event.data.taskId ?? null, status, model: event.data.model ?? null, images: [], error: event.data.error ?? null, }; setMessages((currentMessages) => upsertMessage( currentMessages, event.data.messageId, () => createAssistantMessage({ id: event.data.messageId, status, generations: [nextRecord], }), (message) => ({ ...message, status, generations: upsertGenerationRecord( message.generations, nextRecord, ), }), ), ); return; } if (event.event === 'generation_result') { onGenerationResult?.(event.data); const nextRecord: EditorAgentGenerationRecord = { toolCallId: event.data.toolCallId, toolName: event.data.toolName, summary: null, taskId: null, status: 'completed', model: event.data.model, images: event.data.images, }; setMessages((currentMessages) => upsertMessage( currentMessages, event.data.messageId, () => createAssistantMessage({ id: event.data.messageId, status: 'completed', generations: [nextRecord], }), (message) => ({ ...message, status: 'completed', generations: upsertGenerationRecord( message.generations, nextRecord, ), }), ), ); return; } if (event.event === 'error') { const messageId = createClientId('agent-error'); setStage('failed'); setErrorMessage(event.data.message); setMessages((currentMessages) => [ ...currentMessages, createAssistantMessage({ id: messageId, kind: 'error', text: event.data.message, status: 'failed', }), ]); return; } if (event.event === 'done') { setStage((currentStage) => currentStage === 'failed' ? 'failed' : 'completed', ); setMessages((currentMessages) => markStreamingMessages(currentMessages, 'completed'), ); const nextTitle = event.data.title?.trim(); if (nextTitle) { setConversations((currentConversations) => currentConversations.map((conversation) => conversation.conversationId === event.data.conversationId ? { ...conversation, title: nextTitle } : conversation, ), ); } } }, [onGenerationResult]); const ensureConversationForSend = useCallback(async () => { if (activeConversationId) { return activeConversationId; } const detail = await createConversation(); return detail.conversationId; }, [activeConversationId, createConversation]); const sendMessage = useCallback( async (rawText: string, attachments: EditorAgentAttachmentRef[] = []) => { const text = rawText.trim(); if ( (!text && !attachments.length) || isStreaming || isLoadingConversations || isLoadingMessages ) { return; } const conversationId = await ensureConversationForSend(); const clientMessageId = createClientId('client-message'); const abortController = new AbortController(); activeStreamAbortControllerRef.current = abortController; setErrorMessage(null); setStage('thinking'); setIsStreaming(true); setMessages((currentMessages) => [ ...currentMessages, createLocalUserMessage({ id: clientMessageId, text, attachments, }), ]); try { await client.streamMessage( conversationId, { clientMessageId, text, attachments, }, { signal: abortController.signal, onEvent: handleSseEvent, }, ); setMessages((currentMessages) => markStreamingMessages(currentMessages, 'completed'), ); } catch (error) { if (isAbortError(error) || abortController.signal.aborted) { return; } const message = error instanceof Error ? error.message : '发送画布 Agent 消息失败'; setErrorMessage(message); setStage('failed'); setMessages((currentMessages) => [ ...markStreamingMessages(currentMessages, 'failed'), createAssistantMessage({ id: createClientId('agent-error'), kind: 'error', text: message, status: 'failed', }), ]); throw error; } finally { if (activeStreamAbortControllerRef.current === abortController) { activeStreamAbortControllerRef.current = null; } setIsStreaming(false); } }, [ client, ensureConversationForSend, handleSseEvent, isLoadingConversations, isLoadingMessages, isStreaming, ], ); const stopCurrentTurn = useCallback(() => { activeStreamAbortControllerRef.current?.abort(); activeStreamAbortControllerRef.current = null; setMessages((currentMessages) => markStreamingMessages(currentMessages, 'stopped'), ); setStage('idle'); setIsStreaming(false); }, []); const deleteActiveConversation = useCallback(async () => { if (!activeConversationId) { return; } const deletingConversationId = activeConversationId; setIsDeletingConversation(true); setErrorMessage(null); try { await client.deleteConversation(deletingConversationId); const remainingConversations = conversations.filter( (conversation) => conversation.conversationId !== deletingConversationId, ); setConversations(remainingConversations); const nextConversation = remainingConversations[0] ?? null; if (nextConversation) { await loadConversation(nextConversation.conversationId); return; } setActiveConversationId(null); setMessages([]); setStage('idle'); } catch (error) { setErrorMessage( error instanceof Error ? error.message : '删除画布 Agent 会话失败', ); throw error; } finally { setIsDeletingConversation(false); } }, [activeConversationId, client, conversations, loadConversation]); return { conversations, activeConversation, activeConversationId, messages, stage, isLoadingConversations, isLoadingMessages, isCreatingConversation, isDeletingConversation, isStreaming, errorMessage, createConversation, selectConversation, sendMessage, stopCurrentTurn, deleteActiveConversation, }; }