import { useCallback, useEffect, useRef, useState } from 'react'; import { ApiError, cancelRequest, closeMindSpacePageEditSession, confirmTool, forkMindSpacePageEditSession, getMindSpace, loadSessionDetail, resumeSession, uploadMindSpaceAsset, sendReply, subscribeSessionEvents, } from '../api/client'; import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent } from '../types'; import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildUserAddressPrefix } from '../utils/userAddress'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, CHAT_IMAGE_MAX_SIDE, compressImageForUpload, } from '../utils/imageUpload'; import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards'; import { buildUserMessage, normalizeConversationMessages, getDisplayText, getToolConfirmation, pushMessage, } from '../utils/message'; function buildPageEditSummary(messages: Message[], pageTitle: string): string { const lines = messages .map((message) => { const text = getDisplayText(message).trim(); if (!text) return null; return `${message.role === 'user' ? '用户' : 'Agent'}: ${text}`; }) .filter(Boolean) .slice(-12); if (lines.length === 0) return ''; return [`[全屏页面编辑摘要 · ${pageTitle}]`, '用户在预览模式下的子 Agent 对话:', ...lines] .join('\n') .slice(0, 6000); } export function usePageEditSubChat({ pageId, pageTitle, parentSessionId, h5ApiBase, user, enabled, onSessionChange, onForkUnavailable, }: { pageId: string; pageTitle: string; parentSessionId?: string | null; h5ApiBase?: string | null; user?: PortalUser | null; enabled: boolean; onSessionChange?: (sessionId: string | null) => void; onForkUnavailable?: () => void; }) { const [session, setSession] = useState(null); const [messages, setMessages] = useState([]); const [chatState, setChatState] = useState('idle'); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [pendingTool, setPendingTool] = useState>(null); const activeRequestId = useRef(null); const unsubscribeRef = useRef<(() => void) | null>(null); const messagesRef = useRef([]); const sessionRef = useRef(null); const closingRef = useRef(false); const chatImageCategoryIdRef = useRef(null); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => { sessionRef.current = session; }, [session]); const dismissNotice = useCallback(() => setNotice(null), []); const resolveChatImageUploadCategoryId = useCallback(async () => { if (chatImageCategoryIdRef.current) return chatImageCategoryIdRef.current; const space = await getMindSpace(); const category = space.categories.find((item) => item.code === 'private') ?? space.categories.find((item) => item.code === 'oa') ?? space.categories.find((item) => item.code === 'public') ?? space.categories[0]; if (!category) { throw new Error('当前空间暂无可用的图片上传分类'); } chatImageCategoryIdRef.current = category.id; return category.id; }, []); const uploadChatImage = useCallback(async (file: File): Promise => { if (!file.type.startsWith('image/')) { throw new Error('只支持图片文件'); } const compressed = await compressImageForUpload(file, { maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, maxDimension: CHAT_IMAGE_MAX_SIDE, }); const categoryId = await resolveChatImageUploadCategoryId(); const asset = await uploadMindSpaceAsset(categoryId, compressed); return buildAbsoluteAssetImageUrl({ id: asset.id, updatedAt: asset.updatedAt }); }, [resolveChatImageUploadCategoryId]); const processEvent = useCallback((event: SessionEvent, requestId: string) => { const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string }; const eventRequestId = raw.chat_request_id ?? raw.request_id; if (eventRequestId && eventRequestId !== requestId) return; switch (event.type) { case 'Message': { if (!event.message.metadata?.userVisible) return; setMessages((prev) => { const next = pushMessage(prev, event.message); messagesRef.current = next; return next; }); const confirmation = getToolConfirmation(event.message); if (confirmation) { setPendingTool(confirmation); setChatState('waiting'); } else { setChatState('streaming'); } return; } case 'UpdateConversation': messagesRef.current = normalizeConversationMessages( event.conversation.filter((message) => message.metadata?.userVisible), ); setMessages(messagesRef.current); return; case 'Error': setError(event.error); setChatState('error'); activeRequestId.current = null; return; case 'Finish': setChatState('idle'); setPendingTool(null); activeRequestId.current = null; return; default: return; } }, []); const connectSubSession = useCallback( async (sessionId: string) => { unsubscribeRef.current?.(); unsubscribeRef.current = null; setChatState('loading'); setError(null); setPendingTool(null); activeRequestId.current = null; await resumeSession(sessionId); const { session: detail, messages: history } = await loadSessionDetail(sessionId); const nextSession = { ...detail, id: sessionId }; sessionRef.current = nextSession; setSession(nextSession); messagesRef.current = history; setMessages(history); setChatState('idle'); unsubscribeRef.current = subscribeSessionEvents( sessionId, (event) => { const rid = activeRequestId.current; if (rid) processEvent(event, rid); else if (event.type === 'ActiveRequests' && event.request_ids.length > 0) { activeRequestId.current = event.request_ids[0]; setChatState('streaming'); } }, (err) => { if (err instanceof ApiError && (err.status === 401 || err.status === 402)) return; setError(err.message); }, { pauseWhenHidden: false }, ); }, [processEvent], ); const start = useCallback(async () => { if (!enabled || !parentSessionId || !pageId || closingRef.current) return; setChatState('loading'); setError(null); try { const forked = await forkMindSpacePageEditSession(pageId, parentSessionId, h5ApiBase); onSessionChange?.(forked.sessionId); await connectSubSession(forked.sessionId); } catch (err) { if ( err instanceof ApiError && (err.status === 404 || err.code === 'not_found') ) { onForkUnavailable?.(); setChatState('idle'); setError(null); onSessionChange?.(null); return; } setError(err instanceof Error ? err.message : '无法启动页面编辑 Agent'); setChatState('error'); onSessionChange?.(null); } }, [ connectSubSession, enabled, h5ApiBase, onForkUnavailable, onSessionChange, pageId, parentSessionId, ]); const close = useCallback( async (options?: { merge?: boolean }) => { const currentSession = sessionRef.current; if (!currentSession || closingRef.current) return; closingRef.current = true; unsubscribeRef.current?.(); unsubscribeRef.current = null; try { if (options?.merge !== false) { const summary = buildPageEditSummary(messagesRef.current, pageTitle); if (summary) { await closeMindSpacePageEditSession(pageId, { sessionId: currentSession.id, parentSessionId, summary, }); } else { await closeMindSpacePageEditSession(pageId, { sessionId: currentSession.id, parentSessionId, summary: '', }); } } } catch { // best-effort merge on close } finally { onSessionChange?.(null); sessionRef.current = null; setSession(null); messagesRef.current = []; setMessages([]); setChatState('idle'); setPendingTool(null); activeRequestId.current = null; closingRef.current = false; } }, [pageId, pageTitle, parentSessionId, onSessionChange], ); const submit = useCallback( async (text: string, context: MindSpaceChatContext, imageUrls?: string[]) => { const currentSession = sessionRef.current; const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim()); if (!currentSession || (!text.trim() && normalizedImageUrls.length === 0)) return; if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; const trimmed = text.trim(); const mindspacePrefix = buildContextPrefix(context); const userPrefix = buildUserAddressPrefix(user); const agentPrefix = `${userPrefix}${mindspacePrefix}`; const userMessage = buildUserMessage(trimmed, { agentText: `${agentPrefix}${trimmed}`, displayText: trimmed, imageUrls: normalizedImageUrls, }); const requestId = crypto.randomUUID(); activeRequestId.current = requestId; messagesRef.current = [...messagesRef.current, userMessage]; setMessages(messagesRef.current); setChatState('streaming'); setError(null); setPendingTool(null); try { await sendReply(currentSession.id, requestId, userMessage); } catch (err) { if (err instanceof ApiError && err.status === 402) { setNotice('余额不足,请充值后继续使用'); setChatState('idle'); } else { setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } activeRequestId.current = null; } }, [chatState, user], ); const stop = useCallback(async () => { const currentSession = sessionRef.current; if (!currentSession || !activeRequestId.current) return; try { await cancelRequest(currentSession.id, activeRequestId.current); } finally { activeRequestId.current = null; setChatState('idle'); } }, []); const approveTool = useCallback( async (allow: boolean) => { const currentSession = sessionRef.current; if (!currentSession || !pendingTool) return; await confirmTool(currentSession.id, pendingTool.id, allow ? 'allow_once' : 'deny_once'); setPendingTool(null); setChatState('streaming'); }, [pendingTool], ); const retryConnect = useCallback(async () => { const currentSession = sessionRef.current; if (!currentSession) { await start(); return; } try { await connectSubSession(currentSession.id); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } }, [connectSubSession, start]); const startedRef = useRef(false); useEffect(() => { if (!enabled) { startedRef.current = false; return; } if (startedRef.current) return; startedRef.current = true; void start(); return () => { startedRef.current = false; void close({ merge: true }); }; }, [close, enabled, pageId, parentSessionId, start]); return { session, messages, chatState, error, notice, pendingTool, submit, stop, approveTool, uploadChatImage, dismissNotice, retryConnect, openRecharge: () => setNotice('余额不足,请充值后继续使用'), close, }; } export type PageEditSubChatBridge = ReturnType;