添加常用功能面板

This commit is contained in:
2026-04-02 16:10:23 +08:00
parent 295c959b52
commit a1c8041b11
+206 -71
View File
@@ -36,6 +36,8 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
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";
@@ -113,6 +115,12 @@ type PersistedChatState = {
conversationId?: string;
};
const PRESET_PROMPTS = [
"帮我分析当前管网压力异常点,并按风险等级排序。",
"基于当前状态,给出今天的巡检优先级和建议路线。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
];
const getInitialChatState = (): PersistedChatState => {
if (typeof window === "undefined") {
return { messages: [], conversationId: undefined };
@@ -537,6 +545,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
initialChatStateRef.current.conversationId
);
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -590,79 +599,88 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}
}, [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;
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);
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);
}
},
});
} 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],
);
const handleSend = async () => {
const prompt = input.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;
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);
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);
}
},
});
} 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);
}
await sendPrompt(prompt);
};
const handleAbort = () => {
@@ -670,6 +688,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
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);
@@ -913,7 +939,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
</Box>
{/* Messages - Bouncy List */}
<Box sx={{ flex: 1, overflowY: "auto", px: 2.5, py: 2, display: "flex", flexDirection: "column", gap: 2.5, zIndex: 5 }}>
<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
@@ -980,6 +1017,104 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
{/* 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 }}