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

944 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
// MUI
import {
Avatar,
Box,
Drawer,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Paper,
Stack,
TextField,
Typography,
useTheme,
alpha,
} from "@mui/material";
// Icons
import CloseRounded from "@mui/icons-material/CloseRounded";
import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
// Logic
import { streamCopilotChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
import {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types";
import {
CHAT_STORAGE_KEY,
PRESET_PROMPTS,
createId,
getInitialChatState,
normalizeThoughtTagToken,
} from "./GlobalChatbox.utils";
import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts";
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const initialChatStateRef = useRef<PersistedChatState | null>(null);
if (initialChatStateRef.current === null) {
initialChatStateRef.current = getInitialChatState();
}
const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [width, setWidth] = useState(480);
const [isResizing, setIsResizing] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(
initialChatStateRef.current.conversationId
);
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
// SSE tool_call → inline chart data (keyed by assistantMessageId)
const [sseCharts, setSseCharts] = useState<
Record<string, Array<{ tool: string; params: Record<string, unknown> }>>
>({});
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const theme = useTheme();
// --- Voice Features ---
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 canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
const isHeaderMenuOpen = Boolean(headerMenuAnchorEl);
// Auto-scroll
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]);
useEffect(() => {
const state: PersistedChatState = { messages, conversationId };
try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
}
}, [messages, conversationId]);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming) return;
stopListening();
const userId = createId();
const assistantId = createId();
setInput("");
setIsStreaming(true);
setMessages((prev) => [
...prev,
{ id: userId, role: "user", content: prompt },
{ id: assistantId, role: "assistant", content: "" },
]);
const controller = new AbortController();
abortRef.current = controller;
// Track SSE tool_call hashes to deduplicate against text-parsed tool_calls
const sseToolHashes = new Set<string>();
const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => {
const { tool, params } = event;
const hash = `${tool}:${JSON.stringify(params)}`;
sseToolHashes.add(hash);
const startTime =
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined);
const endTime =
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined);
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
// show_chart → store as inline chart for rendering
if (tool === "show_chart") {
setSseCharts((prev) => ({
...prev,
[assistantId]: [
...(prev[assistantId] ?? []),
{ tool, params },
],
}));
return;
}
// Other frontend tools → dispatch to chatToolStore immediately
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds
.map((id) => String(id).trim())
.filter(Boolean);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const buildLocateFeaturesAction = (
layer: string,
geometryKind: "point" | "line",
): ChatToolAction => ({
type: "locate_features" as const,
ids: normalizeIds(),
layer,
geometryKind,
});
const buildLocateByFeatureType = (): ChatToolAction | null => {
const rawType = params.feature_type;
const featureType =
typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
const featureTypeMap: Record<
string,
{ layer: string; geometryKind: "point" | "line" }
> = {
junction: { layer: "geo_junctions_mat", geometryKind: "point" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line" },
valve: { layer: "geo_valves", geometryKind: "point" },
valves: { layer: "geo_valves", geometryKind: "point" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point" },
pump: { layer: "geo_pumps", geometryKind: "point" },
pumps: { layer: "geo_pumps", geometryKind: "point" },
tank: { layer: "geo_tanks", geometryKind: "point" },
tanks: { layer: "geo_tanks", geometryKind: "point" },
};
const config = featureTypeMap[featureType];
if (!config) return null;
return buildLocateFeaturesAction(config.layer, config.geometryKind);
};
const actionMap: Record<string, () => ChatToolAction | null> = {
locate_features: buildLocateByFeatureType,
locate_pipes: () => buildLocateFeaturesAction("geo_pipes_mat", "line"),
locate_junctions: () =>
buildLocateFeaturesAction("geo_junctions_mat", "point"),
locate_valves: () => buildLocateFeaturesAction("geo_valves", "point"),
locate_reservoirs: () =>
buildLocateFeaturesAction("geo_reservoirs", "point"),
locate_pumps: () => buildLocateFeaturesAction("geo_pumps", "point"),
locate_tanks: () => buildLocateFeaturesAction("geo_tanks", "point"),
view_history: () => ({
type: "view_history" as const,
featureInfos: (params.feature_infos as [string, string][]) ?? [],
dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime",
startTime,
endTime,
}),
view_scada: () => ({
type: "view_scada" as const,
featureInfos: resolveScadaFeatureInfos(),
startTime,
endTime,
}),
};
const buildAction = actionMap[tool];
if (buildAction) {
const action = buildAction();
if (action) dispatchToolAction(action);
}
};
try {
await streamCopilotChat({
message: prompt,
conversationId,
signal: controller.signal,
onEvent: (event) => {
if (event.type === "token") {
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
const normalizedToken = normalizeThoughtTagToken(event.content);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: m.content + normalizedToken, isError: false }
: m
)
);
} else if (event.type === "done") {
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId && m.content.trim().length === 0
? {
...m,
content: "⚠️ **错误:** Copilot 未返回内容,请稍后重试。",
isError: true,
}
: m
)
);
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: m.content || `⚠️ **错误:** ${event.message}`,
isError: true,
}
: m
)
);
setIsStreaming(false);
} else if (event.type === "tool_call") {
handleSseToolCall(event);
}
},
});
} catch (error) {
if (abortRef.current?.signal.aborted) {
setMessages((prev) =>
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
);
return;
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
: m
)
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
},
[conversationId, isStreaming, stopListening, dispatchToolAction],
);
const handleSend = async () => {
const prompt = input.trim();
if (!prompt || isStreaming) return;
await sendPrompt(prompt);
};
const handleAbort = () => {
abortRef.current?.abort();
setIsStreaming(false);
};
const handlePresetPromptSelect = useCallback((prompt: string) => {
setInput(prompt);
setIsPresetPanelOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, []);
const handleHeaderMenuOpen = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
setHeaderMenuAnchorEl(event.currentTarget);
},
[],
);
const handleHeaderMenuClose = useCallback(() => {
setHeaderMenuAnchorEl(null);
}, []);
const handleNewConversation = useCallback(() => {
abortRef.current?.abort();
handleStopSpeech();
stopListening();
setMessages([]);
setConversationId(undefined);
setInput("");
setIsStreaming(false);
handleHeaderMenuClose();
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, [handleHeaderMenuClose, handleStopSpeech, stopListening]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const newWidth = window.innerWidth - e.clientX;
if (newWidth > 320 && newWidth < 1200) {
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]);
const renderedMessages = useMemo(
() =>
messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
theme={theme}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={handleSpeak}
onPause={handlePauseSpeech}
onResume={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
sseChartParams={sseCharts[message.id]}
/>
)),
[messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts],
);
return (
<Drawer
anchor="right"
variant="persistent"
open={open}
onClose={onClose}
hideBackdrop
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }}
PaperProps={{
sx: {
width: { xs: "100%", sm: width },
background: "transparent",
boxShadow: "none",
overflow: "visible", // Changed from "hidden" to show resizer handle if needed, though handle is inside.
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", // Disable transition during resize
},
}}
>
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#fff", 0.75), // Light glass base
backdropFilter: "blur(30px)",
position: "relative",
}}
>
{/* Resize Handle */}
<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",
}
}}
/>
{/* Ambient Blobs */}
<Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} />
<Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} />
<Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} />
{/* Header - Transparent & Floating */}
<Box
sx={{
p: 3,
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<motion.div
whileHover={{ rotate: 10, scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<IconButton
onClick={handleHeaderMenuOpen}
aria-label="打开聊天菜单"
aria-controls={isHeaderMenuOpen ? "global-chatbox-header-menu" : undefined}
aria-expanded={isHeaderMenuOpen ? "true" : undefined}
aria-haspopup="menu"
sx={{
p: 0,
borderRadius: "50%",
}}
>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`,
boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`,
width: 48,
height: 48,
}}
>
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} />
</Avatar>
<Box
sx={{
position: "absolute",
bottom: 2,
right: 2,
width: 12,
height: 12,
bgcolor: "success.main",
borderRadius: "50%",
border: "2px solid #fff",
boxShadow: "0 0 0 2px rgba(255,255,255,0.5)"
}}
/>
</Box>
</IconButton>
</motion.div>
<Box>
<Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}>
Copilot
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
AI
</Typography>
</Box>
</Stack>
<Menu
id="global-chatbox-header-menu"
anchorEl={headerMenuAnchorEl}
open={isHeaderMenuOpen}
onClose={handleHeaderMenuClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
elevation: 8,
sx: {
mt: 1,
minWidth: 180,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
backdropFilter: "blur(12px)",
bgcolor: alpha("#fff", 0.92),
boxShadow: `0 16px 40px -16px ${alpha(theme.palette.common.black, 0.28)}`,
},
},
}}
>
<MenuItem onClick={handleNewConversation}>
<ListItemIcon>
<AddCommentRounded fontSize="small" />
</ListItemIcon>
<ListItemText
primary="新建对话"
secondary="清空当前会话"
primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 600 } }}
secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }}
/>
</MenuItem>
</Menu>
<motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}>
<CloseRounded />
</IconButton>
</motion.div>
</Box>
{/* Messages - Bouncy List */}
<Box
sx={{
flex: 1,
overflowY: "auto",
px: 2.5,
py: 2,
display: "flex",
flexDirection: "column",
gap: 2.5,
zIndex: 5,
}}
>
<AnimatePresence initial={false}>
{messages.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%" }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 6,
bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
maxWidth: 320,
mx: "auto",
textAlign: "center",
backdropFilter: "blur(10px)",
}}
>
<motion.div
animate={{ y: [-5, 5, -5] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
>
<AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} />
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom>
👋
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
</Typography>
</Paper>
</motion.div>
)}
{renderedMessages}
</AnimatePresence>
{isStreaming && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 40 }}
>
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`
}}
>
<TypingIndicator />
</Paper>
</motion.div>
)}
<div ref={bottomRef} style={{ height: 1 }} />
</Box>
{/* Input Area - Floating Capsule */}
<Box sx={{ p: 3, zIndex: 10 }}>
<Box sx={{ mb: 1.25, display: "flex", justifyContent: "flex-end" }}>
<Box sx={{ position: "relative", width: "100%", maxWidth: 520, display: "flex", justifyContent: "flex-end" }}>
<AnimatePresence initial={false}>
{isPresetPanelOpen && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ position: "absolute", right: 0, bottom: "calc(100% + 10px)", width: "100%", zIndex: 3 }}
>
<Paper
elevation={12}
sx={{
p: 1.2,
borderRadius: 3,
bgcolor: alpha("#fff", 0.92),
backdropFilter: "blur(12px)",
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
boxShadow: `0 20px 48px -20px ${alpha(theme.palette.common.black, 0.3)}`,
}}
>
<Stack spacing={0.8}>
{PRESET_PROMPTS.map((prompt, index) => (
<Box
key={`preset-${index}`}
component="button"
type="button"
onClick={() => handlePresetPromptSelect(prompt)}
sx={{
textAlign: "left",
width: "100%",
px: 1.1,
py: 0.9,
borderRadius: 2,
border: `1px solid ${alpha(theme.palette.divider, 0.24)}`,
bgcolor: alpha("#fff", 0.72),
color: "text.secondary",
fontSize: "0.84rem",
lineHeight: 1.45,
cursor: "pointer",
transition: "all 0.18s ease",
"&:hover": {
borderColor: alpha(theme.palette.primary.main, 0.45),
color: "text.primary",
transform: "translateY(-1px)",
boxShadow: `0 8px 24px -16px ${alpha(theme.palette.primary.main, 0.6)}`,
},
}}
>
{prompt}
</Box>
))}
</Stack>
</Paper>
</motion.div>
)}
</AnimatePresence>
<motion.div whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }}>
<Paper
elevation={10}
sx={{
borderRadius: 99,
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(10px)",
boxShadow: `0 14px 40px -14px ${alpha(theme.palette.primary.main, 0.35)}`,
overflow: "hidden",
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ pl: 1.2, pr: 0.5, py: 0.5 }}>
<Avatar
sx={{
width: 28,
height: 28,
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`,
}}
>
<AutoAwesome sx={{ fontSize: 16, color: "#fff" }} />
</Avatar>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.2 }}>
</Typography>
<IconButton
size="small"
onClick={() => setIsPresetPanelOpen((prev) => !prev)}
aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"}
sx={{ color: "text.secondary" }}
>
{isPresetPanelOpen ? <KeyboardArrowDownRounded /> : <KeyboardArrowUpRounded />}
</IconButton>
</Stack>
</Paper>
</motion.div>
</Box>
</Box>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Stack
direction="row"
alignItems="center"
component={Paper}
elevation={12}
sx={{
p: "6px 8px",
borderRadius: 50, // Full capsule
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(10px)",
border: `1px solid ${alpha("#fff", 0.6)}`,
boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`,
transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: `0 16px 48px -8px ${alpha(theme.palette.primary.main, 0.25)}`,
}
}}
>
<TextField
inputRef={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder="输入消息给 Copilot..."
fullWidth
multiline
maxRows={3}
variant="standard"
InputProps={{
disableUnderline: true,
sx: { px: 2.5, py: 1.5, fontSize: "1rem" },
}}
/>
{isSttSupported && (
<Box sx={{ display: "flex", alignItems: "center", mr: 1 }}>
{isListening ? (
<motion.div
animate={{ scale: [1, 1.15, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={stopListening}
aria-label="停止语音输入"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.1),
width: 44,
height: 44,
"&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) },
}}
>
<MicRounded />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={startListening}
disabled={isStreaming}
aria-label="语音输入"
sx={{
color: "text.secondary",
width: 44,
height: 44,
"&:hover": { color: "primary.main" },
}}
>
<MicRounded />
</IconButton>
)}
</Box>
)}
<Box sx={{ pr: 0.5 }}>
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div
key="stop"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, rotate: 180 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
onClick={handleAbort}
sx={{
bgcolor: alpha(theme.palette.error.main, 0.1),
color: "error.main",
width: 44, height: 44,
"&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) }
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div
key="send"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
disabled={!canSend}
onClick={() => void handleSend()}
sx={{
bgcolor: canSend ? "primary.main" : "action.disabledBackground",
color: "#fff",
width: 44, height: 44,
transition: "background-color 0.2s",
"&:hover": {
bgcolor: "primary.dark",
boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.5)}`
}
}}
>
<SendRounded sx={{ ml: 0.5 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Box>
</Stack>
</motion.div>
</Box>
</Box>
</Drawer>
);
};