Files
TJWaterFrontend_Refine/src/components/chat/GlobalChatbox.tsx
T

386 lines
12 KiB
TypeScript

"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 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 "./GlobalChatbox.parts";
import type { Props } from "./GlobalChatbox.types";
import { PRESET_PROMPTS } from "./GlobalChatbox.utils";
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>(
"deepseek/deepseek-v4-pro",
);
const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request");
const bottomRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false);
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);
const handleToolCall = useAgentToolActions();
const {
messages,
chatSessions,
activeSessionId,
branchGroups,
branchTransition,
isHydrating,
isStreaming,
sessionTitle,
sendPrompt,
regenerate,
editAndResubmit,
cycleBranch,
abort,
replyPermission,
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 });
}, []);
useEffect(() => {
scrollToBottom(isStreaming ? "auto" : "smooth");
}, [isStreaming, messages, scrollToBottom]);
useEffect(() => {
if (!open) {
hasResetForOpenRef.current = false;
return;
}
if (hasResetForOpenRef.current || isHydrating) return;
hasResetForOpenRef.current = true;
const timer = window.setTimeout(() => {
createSession();
composerRef.current?.clear();
setIsHistoryOpen(false);
composerRef.current?.focus();
scrollToBottom("auto");
}, 0);
return () => window.clearTimeout(timer);
}, [createSession, isHydrating, open, scrollToBottom]);
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();
composerRef.current?.clear();
window.setTimeout(() => {
composerRef.current?.focus();
scrollToBottom("auto");
}, 0);
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev);
}, []);
const handleSelectSession = useCallback(
(sessionId: string) => {
composerRef.current?.clear();
void switchSession(sessionId);
},
[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 (
<Drawer
anchor="right"
variant="temporary"
open={open}
onClose={onClose}
hideBackdrop
disableScrollLock
disableEnforceFocus
sx={{
zIndex: (muiTheme) => 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,
},
}}
>
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#fff", 0.76),
backdropFilter: "blur(30px)",
position: "relative",
}}
>
<Box
onMouseDown={handleMouseDown}
sx={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "6px",
cursor: "col-resize",
zIndex: 200,
"&:hover": {
bgcolor: alpha(theme.palette.primary.main, 0.2),
},
"&::after": {
content: '""',
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
width: "2px",
height: "40px",
bgcolor: alpha(theme.palette.divider, 0.4),
borderRadius: "1px",
},
}}
/>
<Blob color={alpha(theme.palette.primary.main, 0.28)} size={300} top="-10%" left="-20%" delay={0} />
<Blob color={alpha(theme.palette.secondary.main, 0.24)} size={250} top="40%" left="60%" delay={2} />
<Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} />
<AgentHeader
sessionTitle={sessionTitle}
canRenameSessionTitle={Boolean(activeSessionId)}
isHydrating={isHydrating}
isStreaming={isStreaming}
isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle}
onRenameSessionTitle={handleRenameActiveSession}
onNewConversation={handleNewConversation}
onClose={onClose}
/>
<Box sx={{ flex: 1, display: "flex", minHeight: 0, position: "relative", overflow: "hidden" }}>
<Box
onClick={() => 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,
}}
/>
<Box
sx={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
width: 268,
zIndex: 20,
transform: isHistoryOpen ? "translateX(0)" : "translateX(-100%)",
transition: "transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)",
boxShadow: isHistoryOpen ? `4px 0 24px ${alpha("#000", 0.08)}` : "none",
}}
>
<AgentHistoryPanel
sessions={chatSessions}
activeSessionId={activeSessionId}
isHydrating={isHydrating}
onNewSession={() => {
handleNewConversation();
setIsHistoryOpen(false);
}}
onSelectSession={(id) => {
handleSelectSession(id);
setIsHistoryOpen(false);
}}
onRenameSession={handleRenameSession}
onDeleteSession={handleDeleteSession}
/>
</Box>
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
<AgentWorkspace
messages={messages}
branchGroups={branchGroups}
branchTransition={branchTransition}
isStreaming={isStreaming}
bottomRef={bottomRef}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={handleSpeak}
onPauseSpeech={handlePauseSpeech}
onResumeSpeech={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={regenerate}
onEditResubmit={editAndResubmit}
onCycleBranch={cycleBranch}
onReplyPermission={replyPermission}
/>
<AgentComposer
ref={composerRef}
isHydrating={isHydrating || isCheckingAuth}
isStreaming={isStreaming}
isListening={isListening}
isSttSupported={isSttSupported}
presets={PRESET_PROMPTS}
onSend={handleSend}
onAbort={abort}
onStartListening={startListening}
onStopListening={stopListening}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
approvalMode={approvalMode}
onApprovalModeChange={setApprovalMode}
/>
</Box>
</Box>
</Box>
</Drawer>
);
};