添加全局 Copilot 聊天框组件

This commit is contained in:
2026-03-23 18:03:24 +08:00
parent 55362bef8f
commit accf6ad254
4 changed files with 394 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
"use client";
import ChatOutlined from "@mui/icons-material/ChatOutlined";
import Close from "@mui/icons-material/Close";
import Send from "@mui/icons-material/Send";
import {
Box,
CircularProgress,
Drawer,
IconButton,
List,
ListItem,
ListItemText,
Stack,
TextField,
Typography,
} from "@mui/material";
import React, { useMemo, useRef, useState } from "react";
import { streamCopilotChat } from "@/lib/chatStream";
type Message = {
id: string;
role: "user" | "assistant";
content: string;
};
type Props = {
open: boolean;
onClose: () => void;
};
const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const abortRef = useRef<AbortController | null>(null);
const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
const handleSend = async () => {
const prompt = input.trim();
if (!prompt || isStreaming) return;
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);
}
setMessages((prev) =>
prev.map((item) =>
item.id === assistantId
? { ...item, content: `${item.content}${event.content}` }
: item,
),
);
} else if (event.type === "done") {
if (!conversationId && event.conversationId) {
setConversationId(event.conversationId);
}
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((item) =>
item.id === assistantId
? {
...item,
content:
item.content ||
`Error: ${event.message}${event.detail ? ` (${event.detail})` : ""}`,
}
: item,
),
);
setIsStreaming(false);
}
},
});
} catch (error) {
setMessages((prev) =>
prev.map((item) =>
item.id === assistantId
? { ...item, content: `Error: ${String(error)}` }
: item,
),
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
};
const handleAbort = () => {
abortRef.current?.abort();
setIsStreaming(false);
};
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<Box sx={{ width: { xs: "100vw", sm: 420 }, height: "100%", display: "flex", flexDirection: "column" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ p: 2, borderBottom: "1px solid", borderColor: "divider" }}>
<Stack direction="row" alignItems="center" spacing={1}>
<ChatOutlined />
<Typography variant="subtitle1" fontWeight={600}>
Copilot Chat
</Typography>
</Stack>
<IconButton onClick={onClose} size="small">
<Close />
</IconButton>
</Stack>
<List sx={{ flex: 1, overflow: "auto", px: 1.5 }}>
{messages.map((message) => (
<ListItem key={message.id} sx={{ justifyContent: message.role === "user" ? "flex-end" : "flex-start" }}>
<Box
sx={{
maxWidth: "86%",
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: message.role === "user" ? "primary.main" : "grey.100",
color: message.role === "user" ? "primary.contrastText" : "text.primary",
whiteSpace: "pre-wrap",
}}
>
<ListItemText primaryTypographyProps={{ variant: "body2" }} primary={message.content || "..."} />
</Box>
</ListItem>
))}
</List>
<Stack direction="row" spacing={1} sx={{ p: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
<TextField
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
size="small"
multiline
maxRows={4}
fullWidth
placeholder="输入消息..."
/>
{isStreaming ? (
<IconButton color="warning" onClick={handleAbort}>
<CircularProgress size={20} />
</IconButton>
) : (
<IconButton color="primary" disabled={!canSend} onClick={() => void handleSend()}>
<Send />
</IconButton>
)}
</Stack>
</Box>
</Drawer>
);
};
+14
View File
@@ -5,6 +5,7 @@ import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
import Logout from "@mui/icons-material/Logout";
import SwapHoriz from "@mui/icons-material/SwapHoriz";
import ChatOutlined from "@mui/icons-material/ChatOutlined";
import AppBar from "@mui/material/AppBar";
import Avatar from "@mui/material/Avatar";
import ButtonBase from "@mui/material/ButtonBase";
@@ -21,6 +22,7 @@ import { useGetIdentity, useLogout } from "@refinedev/core";
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
import React, { useContext, useState } from "react";
import { ProjectSelector } from "@components/project/ProjectSelector";
import { GlobalChatbox } from "@components/chat/GlobalChatbox";
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
import { useProjectStore } from "@/store/projectStore";
@@ -37,6 +39,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
const { mutate: logout } = useLogout();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [showProjectSelector, setShowProjectSelector] = useState(false);
const [showChatbox, setShowChatbox] = useState(false);
const open = Boolean(anchorEl);
const setCurrentProjectId = useProjectStore(
(state) => state.setCurrentProjectId,
@@ -91,6 +94,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
justifyContent="flex-end"
alignItems="center"
>
<IconButton
color="inherit"
onClick={() => setShowChatbox(true)}
>
<ChatOutlined />
</IconButton>
<IconButton
color="inherit"
onClick={() => {
@@ -214,6 +224,10 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
/>
</>
)}
<GlobalChatbox
open={showChatbox}
onClose={() => setShowChatbox(false)}
/>
</Stack>
</Stack>
</Toolbar>