添加全局 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
|||||||
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
||||||
import Logout from "@mui/icons-material/Logout";
|
import Logout from "@mui/icons-material/Logout";
|
||||||
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
||||||
|
import ChatOutlined from "@mui/icons-material/ChatOutlined";
|
||||||
import AppBar from "@mui/material/AppBar";
|
import AppBar from "@mui/material/AppBar";
|
||||||
import Avatar from "@mui/material/Avatar";
|
import Avatar from "@mui/material/Avatar";
|
||||||
import ButtonBase from "@mui/material/ButtonBase";
|
import ButtonBase from "@mui/material/ButtonBase";
|
||||||
@@ -21,6 +22,7 @@ import { useGetIdentity, useLogout } from "@refinedev/core";
|
|||||||
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
|
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
|
||||||
import React, { useContext, useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { ProjectSelector } from "@components/project/ProjectSelector";
|
import { ProjectSelector } from "@components/project/ProjectSelector";
|
||||||
|
import { GlobalChatbox } from "@components/chat/GlobalChatbox";
|
||||||
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
|
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
const { mutate: logout } = useLogout();
|
const { mutate: logout } = useLogout();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const [showProjectSelector, setShowProjectSelector] = useState(false);
|
const [showProjectSelector, setShowProjectSelector] = useState(false);
|
||||||
|
const [showChatbox, setShowChatbox] = useState(false);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
const setCurrentProjectId = useProjectStore(
|
const setCurrentProjectId = useProjectStore(
|
||||||
(state) => state.setCurrentProjectId,
|
(state) => state.setCurrentProjectId,
|
||||||
@@ -91,6 +94,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setShowChatbox(true)}
|
||||||
|
>
|
||||||
|
<ChatOutlined />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -214,6 +224,10 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<GlobalChatbox
|
||||||
|
open={showChatbox}
|
||||||
|
onClose={() => setShowChatbox(false)}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { streamCopilotChat } from "./chatStream";
|
||||||
|
import { ReadableStream } from "stream/web";
|
||||||
|
import { TextEncoder, TextDecoder } from "util";
|
||||||
|
|
||||||
|
if (!globalThis.ReadableStream) {
|
||||||
|
// @ts-expect-error test polyfill
|
||||||
|
globalThis.ReadableStream = ReadableStream;
|
||||||
|
}
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
// @ts-expect-error test polyfill
|
||||||
|
globalThis.TextEncoder = TextEncoder;
|
||||||
|
}
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
// @ts-expect-error test polyfill
|
||||||
|
globalThis.TextDecoder = TextDecoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { apiFetch } = jest.requireMock("@/lib/apiFetch") as {
|
||||||
|
apiFetch: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeStream = (chunks: string[]) =>
|
||||||
|
new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("streamCopilotChat", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses token and done events from chunked SSE", async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: token\ndata: {"conversationId":"c1","content":"he"}\n\n',
|
||||||
|
'event: token\ndata: {"conversationId":"c1","content":"llo"}\n\n',
|
||||||
|
'event: done\ndata: {"conversationId":"c1"}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: Array<{ type: string; content?: string; conversationId?: string }> = [];
|
||||||
|
|
||||||
|
await streamCopilotChat({
|
||||||
|
message: "hi",
|
||||||
|
onEvent: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "token", conversationId: "c1", content: "he" },
|
||||||
|
{ type: "token", conversationId: "c1", content: "llo" },
|
||||||
|
{ type: "done", conversationId: "c1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits error when response is not ok", async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
body: null,
|
||||||
|
text: async () => "bad request",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
||||||
|
await streamCopilotChat({
|
||||||
|
message: "hi",
|
||||||
|
onEvent: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "error", message: "stream request failed", detail: "bad request" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| { type: "token"; conversationId: string; content: string }
|
||||||
|
| { type: "done"; conversationId: string }
|
||||||
|
| { type: "error"; conversationId?: string; message: string; detail?: string };
|
||||||
|
|
||||||
|
type StreamOptions = {
|
||||||
|
message: string;
|
||||||
|
conversationId?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onEvent: (event: StreamEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseEventBlock = (block: string): { event?: string; data?: string } => {
|
||||||
|
const lines = block.split("\n");
|
||||||
|
let event: string | undefined;
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
event = line.slice("event:".length).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice("data:".length).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
data: dataLines.length ? dataLines.join("\n") : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamCopilotChat = async ({
|
||||||
|
message,
|
||||||
|
conversationId,
|
||||||
|
signal,
|
||||||
|
onEvent,
|
||||||
|
}: StreamOptions) => {
|
||||||
|
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/copilot/chat/stream`, {
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const detail = await response.text();
|
||||||
|
onEvent({
|
||||||
|
type: "error",
|
||||||
|
message: "stream request failed",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const blocks = buffer.split("\n\n");
|
||||||
|
buffer = blocks.pop() ?? "";
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const { event, data } = parseEventBlock(block);
|
||||||
|
if (!event || !data) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as {
|
||||||
|
conversationId?: string;
|
||||||
|
content?: string;
|
||||||
|
message?: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
if (event === "token") {
|
||||||
|
onEvent({
|
||||||
|
type: "token",
|
||||||
|
conversationId: parsed.conversationId ?? "",
|
||||||
|
content: parsed.content ?? "",
|
||||||
|
});
|
||||||
|
} else if (event === "done") {
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
conversationId: parsed.conversationId ?? "",
|
||||||
|
});
|
||||||
|
} else if (event === "error") {
|
||||||
|
onEvent({
|
||||||
|
type: "error",
|
||||||
|
conversationId: parsed.conversationId,
|
||||||
|
message: parsed.message ?? "unknown error",
|
||||||
|
detail: parsed.detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onEvent({
|
||||||
|
type: "error",
|
||||||
|
message: "invalid SSE data payload",
|
||||||
|
detail: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user