Agent 初版设计

This commit is contained in:
2026-04-29 17:15:49 +08:00
parent 2c1afdc97c
commit e5ca9e24aa
13 changed files with 1819 additions and 1255 deletions
+77 -818
View File
@@ -1,83 +1,28 @@
"use client";
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Box, Drawer, alpha, useTheme } from "@mui/material";
// 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 { streamAgentChat } 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 { AgentComposer } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader";
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 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 [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(
initialChatStateRef.current.sessionId
);
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,
@@ -99,10 +44,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isSupported: isSttSupported,
} = useSpeechRecognition(handleSpeechResult);
const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
const isHeaderMenuOpen = Boolean(headerMenuAnchorEl);
const handleToolCall = useAgentToolActions();
const {
messages,
isStreaming,
sendPrompt,
abort,
reset,
} = useAgentChatSession({
onToolCall: handleToolCall,
onBeforeSend: stopListening,
});
// Auto-scroll
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
@@ -116,337 +69,49 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
const state: PersistedChatState = { messages, sessionId };
try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
}
}, [messages, sessionId]);
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 streamAgentChat({
message: prompt,
sessionId,
signal: controller.signal,
onEvent: (event) => {
if (event.type === "token") {
if (!sessionId && event.sessionId) setSessionId(event.sessionId);
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 (!sessionId && event.sessionId) setSessionId(event.sessionId);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId && m.content.trim().length === 0
? {
...m,
content: "⚠️ **错误:** Agent 未返回内容,请稍后重试。",
isError: true,
}
: m.id === assistantId
? {
...m,
progress: m.progress?.map((item) =>
item.status === "running"
? { ...item, status: "completed" as const }
: item,
),
}
: m
)
);
setIsStreaming(false);
} else if (event.type === "progress") {
if (!sessionId && event.sessionId) setSessionId(event.sessionId);
setMessages((prev) =>
prev.map((m) => {
if (m.id !== assistantId) return m;
const progress = [...(m.progress ?? [])];
const index = progress.findIndex((item) => item.id === event.id);
const nextProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
};
if (index >= 0) {
progress[index] = nextProgress;
} else {
progress.push(nextProgress);
}
return { ...m, progress };
})
);
} 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);
}
},
[sessionId, isStreaming, stopListening, dispatchToolAction],
);
const handleSend = async () => {
const handleSend = useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming) return;
await sendPrompt(prompt);
};
const handleAbort = () => {
abortRef.current?.abort();
setIsStreaming(false);
};
setInput("");
void sendPrompt(prompt);
}, [input, isStreaming, sendPrompt]);
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 handleHeaderMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>) => {
setHeaderMenuAnchorEl(event.currentTarget);
}, []);
const handleHeaderMenuClose = useCallback(() => {
setHeaderMenuAnchorEl(null);
}, []);
const handleNewConversation = useCallback(() => {
abortRef.current?.abort();
handleStopSpeech();
stopListening();
setMessages([]);
setSessionId(undefined);
reset();
setInput("");
setIsStreaming(false);
handleHeaderMenuClose();
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, [handleHeaderMenuClose, handleStopSpeech, stopListening]);
}, [handleHeaderMenuClose, handleStopSpeech, reset, stopListening]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault();
setIsResizing(true);
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const handleMouseMove = (event: MouseEvent) => {
if (!isResizing) return;
const newWidth = window.innerWidth - e.clientX;
if (newWidth > 320 && newWidth < 1200) {
const newWidth = window.innerWidth - event.clientX;
if (newWidth > 360 && newWidth < 1240) {
setWidth(newWidth);
}
};
@@ -466,26 +131,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
};
}, [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"
@@ -499,9 +144,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
width: { xs: "100%", sm: width },
background: "transparent",
boxShadow: "none",
overflow: "visible", // Changed from "hidden" to show resizer handle if needed, though handle is inside.
overflow: "visible",
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", // Disable transition during resize
transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)",
},
}}
>
@@ -510,12 +155,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#fff", 0.75), // Light glass base
bgcolor: alpha("#fff", 0.76),
backdropFilter: "blur(30px)",
position: "relative",
}}
>
{/* Resize Handle */}
<Box
onMouseDown={handleMouseDown}
sx={{
@@ -539,435 +183,50 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
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} />
<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} />
{/* 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 }}>
Agent
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
AI
</Typography>
</Box>
</Stack>
<AgentHeader
isStreaming={isStreaming}
menuAnchorEl={headerMenuAnchorEl}
onMenuOpen={handleHeaderMenuOpen}
onMenuClose={handleHeaderMenuClose}
onNewConversation={handleNewConversation}
onClose={onClose}
/>
<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>
<AgentWorkspace
messages={messages}
isStreaming={isStreaming}
bottomRef={bottomRef}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={handleSpeak}
onPauseSpeech={handlePauseSpeech}
onResumeSpeech={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
/>
{/* 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="输入消息给 Agent..."
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>
<AgentComposer
input={input}
inputRef={inputRef}
isStreaming={isStreaming}
isListening={isListening}
isSttSupported={isSttSupported}
presets={PRESET_PROMPTS}
onInputChange={setInput}
onSend={handleSend}
onAbort={abort}
onStartListening={startListening}
onStopListening={stopListening}
onPresetSelect={handlePresetPromptSelect}
/>
</Box>
</Drawer>
);