import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CreateEditorAgentConversationRequest, EditorAgentAttachmentRef, EditorAgentConversationDetail, EditorAgentConversationSummary, EditorAgentMessage, EditorAgentMessageRequest, EditorAgentMessageResponse, } from '../../../../packages/shared/src/contracts/editorAgent.ts'; import { cancelEditorAgentToolCall, confirmEditorAgentToolCall, createEditorAgentConversation, deleteEditorAgentConversation, getEditorAgentConversation, listEditorAgentConversations, sendEditorAgentMessage, type SendEditorAgentMessageOptions, } from '../../../services/image-editor/editorAgentClient.ts'; export type EditorAgentConversationClient = { listConversations: ( projectId: string, ) => Promise; createConversation: ( projectId: string, input?: CreateEditorAgentConversationRequest, ) => Promise; getConversation: ( conversationId: string, ) => Promise; deleteConversation: ( conversationId: string, ) => Promise; sendMessage: ( conversationId: string, payload: EditorAgentMessageRequest, options: SendEditorAgentMessageOptions, ) => Promise; confirmToolCall: ( conversationId: string, messageId: number, ) => Promise; cancelToolCall: ( conversationId: string, messageId: number, ) => Promise; }; type UseEditorAgentConversationOptions = { projectId?: string | null; client?: EditorAgentConversationClient; onCanvasRefreshRequested?: () => void; }; export type EditorAgentToolCallAction = 'confirm' | 'cancel'; export type EditorAgentToolCallActionState = { messageId: number; action: EditorAgentToolCallAction; } | null; const defaultEditorAgentConversationClient: EditorAgentConversationClient = { listConversations: listEditorAgentConversations, createConversation: createEditorAgentConversation, getConversation: getEditorAgentConversation, deleteConversation: deleteEditorAgentConversation, sendMessage: sendEditorAgentMessage, confirmToolCall: confirmEditorAgentToolCall, cancelToolCall: cancelEditorAgentToolCall, }; function isAbortError(error: unknown) { return ( error instanceof Error && (error.name === 'AbortError' || (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError')) ); } function createLocalUserMessage(params: { id: number; text: string; attachments: EditorAgentAttachmentRef[]; }): EditorAgentMessage { return { id: params.id, role: 'user', text: params.text, attachments: params.attachments, toolCall: null, createdAt: new Date().toISOString(), }; } 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, 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, onCanvasRefreshRequested, }: UseEditorAgentConversationOptions) { const normalizedProjectId = projectId?.trim() ?? ''; const [conversations, setConversations] = useState< EditorAgentConversationSummary[] >([]); const [activeConversationId, setActiveConversationId] = useState< string | null >(null); const [messages, setMessages] = useState([]); const [isLoadingConversations, setIsLoadingConversations] = useState(false); const [isLoadingMessages, setIsLoadingMessages] = useState(false); const [isCreatingConversation, setIsCreatingConversation] = useState(false); const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [isWaiting, setIsWaiting] = useState(false); const [toolCallAction, setToolCallAction] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const activeRequestAbortControllerRef = useRef(null); const activeConversationIdRef = useRef(null); const activeToolCallActionRef = useRef(null); const nextLocalMessageIdRef = useRef(-1); 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); }, [], ); 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([]); 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 requestCanvasRefreshForMessages = useCallback( (nextMessages: EditorAgentMessage[]) => { if ( nextMessages.some( (message) => message.toolCall?.status === 'completed' && (message.toolCall.images.length > 0 || (message.toolCall.videos?.length ?? 0) > 0 || (message.toolCall.audios?.length ?? 0) > 0), ) ) { onCanvasRefreshRequested?.(); } }, [onCanvasRefreshRequested], ); const applyDeltaMessages = useCallback( (deltaMessages: EditorAgentMessage[]) => { setMessages((currentMessages) => [...currentMessages, ...deltaMessages]); requestCanvasRefreshForMessages(deltaMessages); }, [requestCanvasRefreshForMessages], ); 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) || isWaiting || activeToolCallActionRef.current !== null || isLoadingConversations || isLoadingMessages ) { return; } const conversationId = await ensureConversationForSend(); const abortController = new AbortController(); activeRequestAbortControllerRef.current = abortController; setErrorMessage(null); setIsWaiting(true); const localMessageId = nextLocalMessageIdRef.current; nextLocalMessageIdRef.current -= 1; setMessages((currentMessages) => [ ...currentMessages, createLocalUserMessage({ id: localMessageId, text, attachments, }), ]); try { const response = await client.sendMessage( conversationId, { text, attachments, }, { signal: abortController.signal, }, ); setConversations((currentConversations) => upsertConversationSummary( currentConversations, response.conversation, ), ); if (response.errorMessage) { setErrorMessage(response.errorMessage); } else { applyDeltaMessages(response.deltaMessages); } } catch (error) { if (isAbortError(error) || abortController.signal.aborted) { return; } const message = error instanceof Error ? error.message : '发送画布 Agent 消息失败'; setErrorMessage(message); } finally { if (activeRequestAbortControllerRef.current === abortController) { activeRequestAbortControllerRef.current = null; } setIsWaiting(false); } }, [ client, ensureConversationForSend, applyDeltaMessages, isLoadingConversations, isLoadingMessages, isWaiting, ], ); const stopCurrentTurn = useCallback(() => { activeRequestAbortControllerRef.current?.abort(); activeRequestAbortControllerRef.current = null; setIsWaiting(false); }, []); const resolveToolCall = useCallback( async (messageId: number, action: EditorAgentToolCallAction) => { const conversationId = activeConversationIdRef.current; if (!conversationId || activeToolCallActionRef.current) { return null; } const nextAction = { messageId, action } as const; activeToolCallActionRef.current = nextAction; setToolCallAction(nextAction); setErrorMessage(null); if (action === 'confirm') { setMessages((currentMessages) => currentMessages.map((message) => message.id === messageId && message.toolCall?.status === 'pending_confirmation' ? { ...message, toolCall: { ...message.toolCall, status: 'executing', }, } : message, ), ); } try { const updatedMessage = await (action === 'confirm' ? client.confirmToolCall(conversationId, messageId) : client.cancelToolCall(conversationId, messageId)); if (activeConversationIdRef.current !== conversationId) { return updatedMessage; } setMessages((currentMessages) => currentMessages.map((message) => message.id === updatedMessage.id ? updatedMessage : message, ), ); if (action === 'confirm') { requestCanvasRefreshForMessages([updatedMessage]); } return updatedMessage; } catch (error) { if (activeConversationIdRef.current === conversationId) { setErrorMessage( error instanceof Error ? error.message : action === 'confirm' ? '确认画布 Agent 操作失败' : '取消画布 Agent 操作失败', ); // The server may have accepted the action even when its response was lost. void loadConversation(conversationId).catch(() => undefined); } return null; } finally { if (activeToolCallActionRef.current === nextAction) { activeToolCallActionRef.current = null; setToolCallAction(null); } } }, [client, loadConversation, requestCanvasRefreshForMessages], ); const confirmToolCall = useCallback( (messageId: number) => resolveToolCall(messageId, 'confirm'), [resolveToolCall], ); const cancelToolCall = useCallback( (messageId: number) => resolveToolCall(messageId, 'cancel'), [resolveToolCall], ); 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([]); } catch (error) { setErrorMessage( error instanceof Error ? error.message : '删除画布 Agent 会话失败', ); throw error; } finally { setIsDeletingConversation(false); } }, [activeConversationId, client, conversations, loadConversation]); return { conversations, activeConversation, activeConversationId, messages, isLoadingConversations, isLoadingMessages, isCreatingConversation, isDeletingConversation, isWaiting, toolCallAction, isToolCallActionPending: toolCallAction !== null, errorMessage, createConversation, selectConversation, sendMessage, stopCurrentTurn, confirmToolCall, cancelToolCall, deleteActiveConversation, }; }