import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CreateEditorAgentConversationRequest, EditorAgentAttachmentRef, EditorAgentConversationDetail, EditorAgentConversationSummary, EditorAgentMessage, EditorAgentMessageRequest, EditorAgentMessageResponse, } from '@/packages/shared/src/contracts'; import { cancelEditorAgentToolCall, confirmEditorAgentToolCall, createEditorAgentConversation, deleteEditorAgentConversation, getEditorAgentConversation, listEditorAgentConversations, sendEditorAgentMessage, type SendEditorAgentMessageOptions, } from '../../../services/image-editor/editorAgentClient.ts'; import { isAbortError } from '../../../services/apiClient.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; onConfirmSent?: () => void; }; export type EditorAgentToolCallAction = 'confirm' | 'cancel'; export type EditorAgentToolCallActionState = { messageId: number; action: EditorAgentToolCallAction; } | null; type ActiveEditorAgentSend = { requestId: number; conversationId: string; clientMessageId: string; controller: AbortController; }; const defaultEditorAgentConversationClient: EditorAgentConversationClient = { listConversations: listEditorAgentConversations, createConversation: createEditorAgentConversation, getConversation: getEditorAgentConversation, deleteConversation: deleteEditorAgentConversation, sendMessage: sendEditorAgentMessage, confirmToolCall: confirmEditorAgentToolCall, cancelToolCall: cancelEditorAgentToolCall, }; export const EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS = 120_000; function createEditorAgentClientMessageId() { const randomId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; return `editor-agent-${randomId}`; } function createLocalUserMessage(params: { id: number; clientMessageId: string; text: string; attachments: EditorAgentAttachmentRef[]; }): EditorAgentMessage { return { id: params.id, clientMessageId: params.clientMessageId, 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, onConfirmSent, }: 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 [isAborting, setIsAborting] = useState(false); const [patienceNoticeConversationId, setPatienceNoticeConversationId] = useState(null); const [toolCallAction, setToolCallAction] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const normalizedProjectIdRef = useRef(normalizedProjectId); const activeConversationIdRef = useRef(null); const activeToolCallActionRef = useRef(null); const conversationLoadRequestIdRef = useRef(0); const createConversationRequestIdRef = useRef(0); const isWaitingRef = useRef(false); const pendingSendRequestIdRef = useRef(0); const activeSendRef = useRef(null); const stoppedSendRequestIdRef = useRef(null); const patienceNoticeTimerRef = useRef | null>( null, ); useEffect(() => { activeConversationIdRef.current = activeConversationId; }, [activeConversationId]); normalizedProjectIdRef.current = normalizedProjectId; useEffect(() => { return () => { pendingSendRequestIdRef.current += 1; createConversationRequestIdRef.current += 1; isWaitingRef.current = false; activeSendRef.current = null; stoppedSendRequestIdRef.current = null; if (patienceNoticeTimerRef.current !== null) { clearTimeout(patienceNoticeTimerRef.current); patienceNoticeTimerRef.current = null; } }; }, []); useEffect(() => { pendingSendRequestIdRef.current += 1; createConversationRequestIdRef.current += 1; isWaitingRef.current = false; activeSendRef.current = null; stoppedSendRequestIdRef.current = null; if (patienceNoticeTimerRef.current !== null) { clearTimeout(patienceNoticeTimerRef.current); patienceNoticeTimerRef.current = null; } setIsWaiting(false); setIsAborting(false); setIsCreatingConversation(false); setPatienceNoticeConversationId(null); }, [normalizedProjectId]); const activeConversation = useMemo( () => activeConversationId ? (conversations.find( (conversation) => conversation.conversationId === activeConversationId, ) ?? null) : null, [activeConversationId, conversations], ); const applyConversationDetail = useCallback( (detail: EditorAgentConversationDetail) => { activeConversationIdRef.current = detail.conversationId; setConversations((currentConversations) => upsertConversationSummary( currentConversations, summaryFromDetail(detail), ), ); setActiveConversationId(detail.conversationId); setMessages(detail.messages); }, [], ); const loadConversation = useCallback( async ( conversationId: string, options: { showLoading?: boolean; reportError?: boolean } = {}, ) => { const requestId = conversationLoadRequestIdRef.current + 1; conversationLoadRequestIdRef.current = requestId; const showLoading = options.showLoading ?? true; const reportError = options.reportError ?? true; if (showLoading) { setIsLoadingMessages(true); } if (reportError) { setErrorMessage(null); } try { const detail = await client.getConversation(conversationId); if (conversationLoadRequestIdRef.current === requestId) { applyConversationDetail(detail); } return detail; } catch (error) { if ( reportError && conversationLoadRequestIdRef.current === requestId ) { setErrorMessage( error instanceof Error ? error.message : '读取画布 Agent 会话失败', ); } throw error; } finally { if ( showLoading && conversationLoadRequestIdRef.current === requestId ) { setIsLoadingMessages(false); } } }, [applyConversationDetail, client], ); useEffect(() => { conversationLoadRequestIdRef.current += 1; setIsLoadingMessages(false); if (!normalizedProjectId) { setConversations([]); activeConversationIdRef.current = null; 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) { activeConversationIdRef.current = null; 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'); } const requestedProjectId = normalizedProjectId; const requestId = createConversationRequestIdRef.current + 1; createConversationRequestIdRef.current = requestId; setIsCreatingConversation(true); setErrorMessage(null); try { const detail = await client.createConversation(requestedProjectId, {}); if ( createConversationRequestIdRef.current === requestId && normalizedProjectIdRef.current === requestedProjectId ) { applyConversationDetail(detail); } return detail; } catch (error) { if ( createConversationRequestIdRef.current === requestId && normalizedProjectIdRef.current === requestedProjectId ) { setErrorMessage( error instanceof Error ? error.message : '创建画布 Agent 会话失败', ); } throw error; } finally { if ( createConversationRequestIdRef.current === requestId && normalizedProjectIdRef.current === requestedProjectId ) { setIsCreatingConversation(false); } } }, [applyConversationDetail, client, normalizedProjectId]); const selectConversation = useCallback( async (conversationId: string) => { if (conversationId === activeConversationId) { return null; } return loadConversation(conversationId); }, [activeConversationId, loadConversation], ); const refreshActiveConversation = useCallback(async () => { const conversationId = activeConversationIdRef.current; if (!conversationId) { return null; } return loadConversation(conversationId, { showLoading: false }); }, [loadConversation]); const requestCanvasRefreshForMessages = useCallback( (nextMessages: EditorAgentMessage[]) => { if ( nextMessages.some((message) => { const toolCall = message.toolCall; return Boolean( toolCall?.externalJobId && (toolCall.images.length > 0 || (toolCall.videos?.length ?? 0) > 0 || (toolCall.audios?.length ?? 0) > 0), ); }) ) { onCanvasRefreshRequested?.(); } }, [onCanvasRefreshRequested], ); const applyDeltaMessages = useCallback( (deltaMessages: EditorAgentMessage[]) => { setMessages((currentMessages) => { return [...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) || isWaitingRef.current || activeToolCallActionRef.current !== null || isLoadingConversations || isLoadingMessages ) { return; } isWaitingRef.current = true; const requestedProjectId = normalizedProjectId; const requestId = pendingSendRequestIdRef.current + 1; pendingSendRequestIdRef.current = requestId; setErrorMessage(null); setIsWaiting(true); setPatienceNoticeConversationId(null); let conversationId: string | null = null; let optimisticMessage: EditorAgentMessage | null = null; try { conversationId = await ensureConversationForSend(); if ( pendingSendRequestIdRef.current !== requestId || normalizedProjectIdRef.current !== requestedProjectId ) { return; } const clientMessageId = createEditorAgentClientMessageId(); const controller = new AbortController(); const nextOptimisticMessage = createLocalUserMessage({ id: -1, clientMessageId, text, attachments, }); optimisticMessage = nextOptimisticMessage; activeSendRef.current = { requestId, conversationId, clientMessageId, controller, }; setMessages((currentMessages) => [ ...currentMessages, nextOptimisticMessage, ]); const pendingConversationId = conversationId; patienceNoticeTimerRef.current = setTimeout(() => { if (pendingSendRequestIdRef.current === requestId) { setPatienceNoticeConversationId(pendingConversationId); } }, EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS); const response = await client.sendMessage( conversationId, { clientMessageId, text, attachments, }, { signal: controller.signal }, ); if (pendingSendRequestIdRef.current !== requestId) { return; } setConversations((currentConversations) => upsertConversationSummary( currentConversations, response.conversation, ), ); if (activeConversationIdRef.current !== conversationId) { return; } if (response.errorMessage) { setErrorMessage(response.errorMessage); } else { applyDeltaMessages(response.deltaMessages); } } catch (error) { const wasStoppedByUser = stoppedSendRequestIdRef.current === requestId && isAbortError(error); if (wasStoppedByUser) { if ( conversationId && activeConversationIdRef.current === conversationId ) { await loadConversation(conversationId, { showLoading: false, reportError: false, }).catch(() => undefined); } return; } const message = error instanceof Error ? error.message : '发送画布 Agent 消息失败'; const shouldReportError = pendingSendRequestIdRef.current === requestId && (!conversationId || activeConversationIdRef.current === conversationId); if (shouldReportError) { setErrorMessage(message); if (optimisticMessage) { setMessages((currentMessages) => currentMessages.filter( (message) => message !== optimisticMessage, ), ); } throw error; } } finally { if (pendingSendRequestIdRef.current === requestId) { if (patienceNoticeTimerRef.current !== null) { clearTimeout(patienceNoticeTimerRef.current); patienceNoticeTimerRef.current = null; } isWaitingRef.current = false; if (activeSendRef.current?.requestId === requestId) { activeSendRef.current = null; } if (stoppedSendRequestIdRef.current === requestId) { stoppedSendRequestIdRef.current = null; } setIsWaiting(false); setIsAborting(false); setPatienceNoticeConversationId(null); } } }, [ client, ensureConversationForSend, applyDeltaMessages, isLoadingConversations, isLoadingMessages, loadConversation, normalizedProjectId, ], ); const stopCurrentTurn = useCallback(() => { const activeSend = activeSendRef.current; if (!activeSend || activeSend.controller.signal.aborted) { return; } stoppedSendRequestIdRef.current = activeSend.requestId; setIsAborting(true); activeSend.controller.abort(); }, []); const resolveToolCall = useCallback( async (messageId: number, action: EditorAgentToolCallAction) => { const conversationId = activeConversationIdRef.current; if (!conversationId || activeToolCallActionRef.current) { return; } const nextAction = { messageId, action } as const; activeToolCallActionRef.current = nextAction; setToolCallAction(nextAction); setErrorMessage(null); try { await (action === 'confirm' ? client.confirmToolCall(conversationId, messageId) : client.cancelToolCall(conversationId, messageId)); if (activeConversationIdRef.current !== conversationId) { return; } const detail = await loadConversation(conversationId, { showLoading: false, }); const updatedMessage = detail.messages.find( (message) => message.id === messageId, ); if (action === 'confirm' && updatedMessage?.toolCall?.externalJobId) { onConfirmSent?.(); } } catch (error) { if (activeConversationIdRef.current === conversationId) { setErrorMessage( error instanceof Error ? error.message : action === 'confirm' ? '确认画布 Agent 操作失败' : '取消画布 Agent 操作失败', ); } throw error; } finally { if (activeToolCallActionRef.current === nextAction) { activeToolCallActionRef.current = null; setToolCallAction(null); } } }, [client, loadConversation, onConfirmSent], ); 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; } activeConversationIdRef.current = null; 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, isAborting, isPatienceNoticeVisible: isWaiting && patienceNoticeConversationId === activeConversationId, toolCallAction, isToolCallActionPending: toolCallAction !== null, errorMessage, createConversation, selectConversation, refreshActiveConversation, sendMessage, stopCurrentTurn, confirmToolCall, cancelToolCall, deleteActiveConversation, }; }