944 lines
35 KiB
TypeScript
944 lines
35 KiB
TypeScript
"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>
|
||
);
|
||
};
|