添加全局 Copilot 聊天框组件
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user