Agent 初版设计
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user