From accf6ad254fe537000ecaf183e257e7f9cf0659a Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 23 Mar 2026 18:03:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=A8=E5=B1=80=20Copilot?= =?UTF-8?q?=20=E8=81=8A=E5=A4=A9=E6=A1=86=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 184 ++++++++++++++++++++++++++ src/components/header/index.tsx | 14 ++ src/lib/chatStream.test.ts | 81 ++++++++++++ src/lib/chatStream.ts | 115 ++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 src/components/chat/GlobalChatbox.tsx create mode 100644 src/lib/chatStream.test.ts create mode 100644 src/lib/chatStream.ts diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx new file mode 100644 index 0000000..7153a20 --- /dev/null +++ b/src/components/chat/GlobalChatbox.tsx @@ -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 = ({ open, onClose }) => { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [conversationId, setConversationId] = useState(undefined); + const abortRef = useRef(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 ( + + + + + + + Copilot Chat + + + + + + + + + {messages.map((message) => ( + + + + + + ))} + + + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }} + size="small" + multiline + maxRows={4} + fullWidth + placeholder="输入消息..." + /> + {isStreaming ? ( + + + + ) : ( + void handleSend()}> + + + )} + + + + ); +}; diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index dfb23f9..14d14d5 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -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 = ({ const { mutate: logout } = useLogout(); const [anchorEl, setAnchorEl] = useState(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 = ({ justifyContent="flex-end" alignItems="center" > + setShowChatbox(true)} + > + + + { @@ -214,6 +224,10 @@ export const Header: React.FC = ({ /> )} + setShowChatbox(false)} + /> diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts new file mode 100644 index 0000000..9956de2 --- /dev/null +++ b/src/lib/chatStream.test.ts @@ -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({ + 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" }, + ]); + }); +}); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts new file mode 100644 index 0000000..a29feeb --- /dev/null +++ b/src/lib/chatStream.ts @@ -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, + }); + } + } + } +};