315 lines
9.4 KiB
TypeScript
315 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
|
|
|
import type { AgentModel } from "@/lib/chatStream";
|
|
import { AgentComposer } 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 [input, setInput] = useState("");
|
|
const [width, setWidth] = useState(520);
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
|
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
|
"deepseek/deepseek-v4-pro",
|
|
);
|
|
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const theme = useTheme();
|
|
|
|
const {
|
|
speechState,
|
|
speakingMessageId,
|
|
speak: handleSpeak,
|
|
pause: handlePauseSpeech,
|
|
resume: handleResumeSpeech,
|
|
stop: handleStopSpeech,
|
|
isSupported: isTtsSupported,
|
|
} = useSpeechSynthesis();
|
|
|
|
const handleSpeechResult = useCallback((text: string) => {
|
|
setInput((prev) => prev + text);
|
|
}, []);
|
|
|
|
const {
|
|
isListening,
|
|
start: startListening,
|
|
stop: stopListening,
|
|
isSupported: isSttSupported,
|
|
} = useSpeechRecognition(handleSpeechResult);
|
|
|
|
const handleToolCall = useAgentToolActions();
|
|
const {
|
|
messages,
|
|
chatSessions,
|
|
activeStorageSessionId,
|
|
branchGroups,
|
|
branchTransition,
|
|
isHydrating,
|
|
isStreaming,
|
|
sendPrompt,
|
|
regenerate,
|
|
editAndResubmit,
|
|
cycleBranch,
|
|
abort,
|
|
createSession,
|
|
removeSession,
|
|
switchSession,
|
|
} = useAgentChatSession({
|
|
onToolCall: handleToolCall,
|
|
onBeforeSend: stopListening,
|
|
getModel: () => selectedModel,
|
|
});
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, isStreaming]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const timer = window.setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
|
}, 0);
|
|
return () => window.clearTimeout(timer);
|
|
}, [open]);
|
|
|
|
const handleSend = useCallback(() => {
|
|
const prompt = input.trim();
|
|
if (!prompt || isStreaming) return;
|
|
setInput("");
|
|
void sendPrompt(prompt);
|
|
}, [input, isStreaming, sendPrompt]);
|
|
|
|
const handlePresetPromptSelect = useCallback((prompt: string) => {
|
|
setInput(prompt);
|
|
window.setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
}, 0);
|
|
}, []);
|
|
|
|
const handleNewConversation = useCallback(() => {
|
|
handleStopSpeech();
|
|
stopListening();
|
|
void createSession();
|
|
setInput("");
|
|
window.setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
}, 0);
|
|
}, [createSession, handleStopSpeech, stopListening]);
|
|
|
|
const handleHistoryToggle = useCallback(() => {
|
|
setIsHistoryOpen((prev) => !prev);
|
|
}, []);
|
|
|
|
const handleSelectSession = useCallback(
|
|
(storageSessionId: string) => {
|
|
setInput("");
|
|
void switchSession(storageSessionId);
|
|
},
|
|
[switchSession],
|
|
);
|
|
|
|
const handleDeleteSession = useCallback(
|
|
(storageSessionId: string) => {
|
|
void removeSession(storageSessionId);
|
|
},
|
|
[removeSession],
|
|
);
|
|
|
|
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 < 1240) {
|
|
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 }}
|
|
PaperProps={{
|
|
sx: {
|
|
width: { xs: "100%", sm: width },
|
|
background: "transparent",
|
|
boxShadow: "none",
|
|
overflow: open ? "visible" : "hidden",
|
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
|
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
|
|
isStreaming={isStreaming}
|
|
isHistoryOpen={isHistoryOpen}
|
|
onHistoryToggle={handleHistoryToggle}
|
|
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={activeStorageSessionId}
|
|
isHydrating={isHydrating}
|
|
onNewSession={() => {
|
|
handleNewConversation();
|
|
setIsHistoryOpen(false);
|
|
}}
|
|
onSelectSession={(id) => {
|
|
handleSelectSession(id);
|
|
setIsHistoryOpen(false);
|
|
}}
|
|
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}
|
|
/>
|
|
|
|
<AgentComposer
|
|
input={input}
|
|
inputRef={inputRef}
|
|
isHydrating={isHydrating}
|
|
isStreaming={isStreaming}
|
|
isListening={isListening}
|
|
isSttSupported={isSttSupported}
|
|
presets={PRESET_PROMPTS}
|
|
onInputChange={setInput}
|
|
onSend={handleSend}
|
|
onAbort={abort}
|
|
onStartListening={startListening}
|
|
onStopListening={stopListening}
|
|
onPresetSelect={handlePresetPromptSelect}
|
|
selectedModel={selectedModel}
|
|
onModelChange={setSelectedModel}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Drawer>
|
|
);
|
|
};
|