"use client"; import React, { useCallback, useEffect, useRef, useState, } from "react"; import { Box, Drawer, alpha, useTheme } from "@mui/material"; import { useNotification } from "@refinedev/core"; import { getAccessToken } from "@/lib/authToken"; import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import { useProjectStore } from "@/store/projectStore"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentWorkspace } from "./AgentWorkspace"; import { Blob } from "./GlobalChatboxParts"; import type { Props } from "./GlobalChatbox.types"; import { PRESET_PROMPTS } from "./globalChatboxUtils"; import { useSpeechRecognition, useSpeechSynthesis } from "./globalChatboxVoice"; import { useAgentChatSession } from "./hooks/useAgentChatSession"; import { useAgentToolActions } from "./hooks/useAgentToolActions"; const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36; export const GlobalChatbox: React.FC = ({ open, onClose }) => { const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(false); const [modelOptions, setModelOptions] = useState([]); const [selectedModel, setSelectedModel] = useState(undefined); const [approvalMode, setApprovalMode] = useState("request"); const bottomRef = useRef(null); const workspaceScrollRef = useRef(null); const isNearBottomRef = useRef(true); const streamingScrollFrameRef = useRef(null); const composerRef = useRef(null); const initializedProjectIdRef = useRef(undefined); const theme = useTheme(); const { open: openNotification } = useNotification(); const currentProjectId = useProjectStore((state) => state.currentProjectId); const { speechState, speakingMessageId, speak: handleSpeak, pause: handlePauseSpeech, resume: handleResumeSpeech, stop: handleStopSpeech, isSupported: isTtsSupported, } = useSpeechSynthesis(); const handleSpeechResult = useCallback((text: string) => { composerRef.current?.append(text); }, []); const { isListening, start: startListening, stop: stopListening, isSupported: isSttSupported, } = useSpeechRecognition(handleSpeechResult); useEffect(() => { let cancelled = false; const loadModels = async () => { try { const modelConfig = await fetchAgentModels(); if (cancelled) return; setModelOptions(modelConfig.models); setSelectedModel((current) => { if (current && modelConfig.models.some((model) => model.id === current)) { return current; } return modelConfig.defaultModel; }); } catch (error) { console.error("[GlobalChatbox] Failed to load agent models:", error); if (!cancelled) { setModelOptions([]); setSelectedModel(undefined); } } }; void loadModels(); return () => { cancelled = true; }; }, []); const handleToolCall = useAgentToolActions(); const { messages, chatSessions, activeSessionId, isHydrating, loadingSessionId, isStreaming, sessionTitle, sendPrompt, createBranch, abort, replyPermission, replyQuestion, rejectQuestion, createSession, renameSession, removeSession, switchSession, } = useAgentChatSession({ projectId: currentProjectId, onToolCall: handleToolCall, onBeforeSend: stopListening, getModel: () => selectedModel, getApprovalMode: () => approvalMode, }); const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { bottomRef.current?.scrollIntoView({ behavior }); }, []); const cancelStreamingScroll = useCallback(() => { if (streamingScrollFrameRef.current === null) return; window.cancelAnimationFrame(streamingScrollFrameRef.current); streamingScrollFrameRef.current = null; }, []); const scheduleStreamingScrollToBottom = useCallback(() => { if (streamingScrollFrameRef.current !== null) return; streamingScrollFrameRef.current = window.requestAnimationFrame(() => { streamingScrollFrameRef.current = null; const container = workspaceScrollRef.current; if (!container || !isNearBottomRef.current) return; const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return; container.scrollTop = container.scrollHeight - container.clientHeight; }); }, []); const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => { isNearBottomRef.current = isNearBottom; }, []); const resetConversationView = useCallback(() => { composerRef.current?.clear(); setIsHistoryOpen(false); window.setTimeout(() => { composerRef.current?.focus(); isNearBottomRef.current = true; cancelStreamingScroll(); scrollToBottom("auto"); }, 0); }, [cancelStreamingScroll, scrollToBottom]); useEffect(() => { if (isStreaming) { if (!isNearBottomRef.current) return; scheduleStreamingScrollToBottom(); return; } cancelStreamingScroll(); scrollToBottom("smooth"); }, [ cancelStreamingScroll, isStreaming, messages, scheduleStreamingScrollToBottom, scrollToBottom, ]); useEffect( () => () => { cancelStreamingScroll(); }, [cancelStreamingScroll], ); useEffect(() => { if ( !open || isHydrating || initializedProjectIdRef.current === currentProjectId ) { return; } initializedProjectIdRef.current = currentProjectId; createSession(); resetConversationView(); }, [createSession, currentProjectId, isHydrating, open, resetConversationView]); const handleSend = useCallback(async (prompt: string) => { if (isStreaming || isCheckingAuth) return; setIsCheckingAuth(true); try { const accessToken = await getAccessToken(); if (!accessToken) { composerRef.current?.setValue(prompt); openNotification?.({ type: "error", message: "登录状态已失效", description: "请重新登录后再发送对话。", }); return; } void sendPrompt(prompt); } catch (error) { composerRef.current?.setValue(prompt); openNotification?.({ type: "error", message: "登录状态校验失败", description: error instanceof Error ? error.message : "请重新登录后再试。", }); } finally { setIsCheckingAuth(false); } }, [isCheckingAuth, isStreaming, openNotification, sendPrompt]); const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); createSession(); resetConversationView(); }, [createSession, handleStopSpeech, resetConversationView, stopListening]); const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); }, []); const handleSelectSession = useCallback( (sessionId: string, title: string) => { composerRef.current?.clear(); void switchSession(sessionId, title); }, [switchSession], ); const handleDeleteSession = useCallback( (sessionId: string) => { void removeSession(sessionId); }, [removeSession], ); const handleRenameSession = useCallback( (sessionId: string, title: string) => { void renameSession(sessionId, title); }, [renameSession], ); const handleRenameActiveSession = useCallback( (title: string) => { if (!activeSessionId) return; void renameSession(activeSessionId, title); }, [activeSessionId, renameSession], ); const handleMouseDown = useCallback((event: React.MouseEvent) => { event.preventDefault(); setIsResizing(true); }, []); useEffect(() => { const handleMouseMove = (event: MouseEvent) => { if (!isResizing) return; const newWidth = window.innerWidth - event.clientX; if (newWidth > 360 && newWidth < 800) { setWidth(newWidth); } }; const handleMouseUp = () => { setIsResizing(false); }; if (isResizing) { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); } return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [isResizing]); return ( muiTheme.zIndex.modal + 100, pointerEvents: "none", }} PaperProps={{ sx: { width: { xs: "100%", sm: width }, background: "transparent", boxShadow: "none", overflow: open ? "visible" : "hidden", zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, pointerEvents: "auto", transition: isResizing ? "none" : undefined, }, }} > setIsHistoryOpen(false)} sx={{ position: "absolute", inset: 0, bgcolor: alpha("#000", 0.05), backdropFilter: "blur(2px)", opacity: isHistoryOpen ? 1 : 0, pointerEvents: isHistoryOpen ? "auto" : "none", transition: "opacity 0.3s ease", zIndex: 10, }} /> { handleNewConversation(); setIsHistoryOpen(false); }} onSelectSession={(id, title) => { handleSelectSession(id, title); setIsHistoryOpen(false); }} onRenameSession={handleRenameSession} onDeleteSession={handleDeleteSession} /> ); };