386 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|