diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 4261894..967e8aa 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo, useRef, useState, useEffect } from "react"; +import React, { useMemo, useRef, useState, useEffect, useCallback } from "react"; import ReactMarkdown from "react-markdown"; import { motion, AnimatePresence } from "framer-motion"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; @@ -11,6 +11,10 @@ import { Box, Drawer, IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, Paper, Stack, TextField, @@ -19,6 +23,7 @@ import { alpha, Tooltip, } from "@mui/material"; +import type { Theme } from "@mui/material/styles"; // Icons import CloseRounded from "@mui/icons-material/CloseRounded"; @@ -27,9 +32,11 @@ import StopRounded from "@mui/icons-material/StopRounded"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI import PersonRounded from "@mui/icons-material/PersonRounded"; import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; +import { parseAssistantMessageSections } from "./chatMessageSections"; // Types type Message = { @@ -47,6 +54,11 @@ type Props = { // Utils const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1"; +const THINK_TAG_ALIAS_PATTERN = /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; +const normalizeThoughtTagToken = (token: string): string => + token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) => + closingSlash ? "" : "", + ); type PersistedChatState = { messages: Message[]; @@ -136,6 +148,143 @@ const Blob = ({ color, size, top, left, delay }: { color: string; size: number; /> ); +type ChatMessageItemProps = { + message: Message; + theme: Theme; +}; + +const ChatMessageItem = React.memo( + ({ message, theme }: ChatMessageItemProps) => { + const isUser = message.role === "user"; + const isErrorMessage = Boolean(message.isError); + const parsedAssistantSections = + !isUser && !isErrorMessage + ? parseAssistantMessageSections(message.content) + : null; + const answerContent = parsedAssistantSections?.answer ?? message.content; + + return ( + + {!isUser && ( + + {isErrorMessage ? ( + + ) : ( + + )} + + )} + + +
+ {answerContent || "..."} +
+
+
+ ); + }, +); +ChatMessageItem.displayName = "ChatMessageItem"; + export const GlobalChatbox: React.FC = ({ open, onClose }) => { const initialChatStateRef = useRef(null); if (initialChatStateRef.current === null) { @@ -148,12 +297,14 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const [conversationId, setConversationId] = useState( initialChatStateRef.current.conversationId ); + const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState(null); const abortRef = useRef(null); const bottomRef = useRef(null); const inputRef = useRef(null); const theme = useTheme(); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); + const isHeaderMenuOpen = Boolean(headerMenuAnchorEl); // Auto-scroll useEffect(() => { @@ -204,10 +355,11 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { 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 + event.content, isError: false } + ? { ...m, content: m.content + normalizedToken, isError: false } : m ) ); @@ -256,6 +408,43 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { setIsStreaming(false); }; + const handleHeaderMenuOpen = useCallback( + (event: React.MouseEvent) => { + setHeaderMenuAnchorEl(event.currentTarget); + }, + [], + ); + + const handleHeaderMenuClose = useCallback(() => { + setHeaderMenuAnchorEl(null); + }, []); + + const handleNewConversation = useCallback(() => { + abortRef.current?.abort(); + setMessages([]); + setConversationId(undefined); + setInput(""); + setIsStreaming(false); + handleHeaderMenuClose(); + + window.setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }, [handleHeaderMenuClose]); + + const renderedMessages = useMemo( + () => + messages.map((message) => ( + + )), + [messages, theme], + ); + + return ( = ({ open, onClose }) => { whileHover={{ rotate: 10, scale: 1.1 }} whileTap={{ scale: 0.95 }} > - + + - + - - + + @@ -340,6 +541,41 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { + + + + + + + + + @@ -387,129 +623,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { )} - {messages.map((message) => { - const isUser = message.role === "user"; - const isErrorMessage = Boolean(message.isError); - return ( - - {!isUser && ( - - {isErrorMessage ? ( - - ) : ( - - )} - - )} - - -
- {message.content || "..."} -
-
-
- ); - })} + {renderedMessages} {isStreaming && ( diff --git a/src/components/chat/chatMessageSections.test.ts b/src/components/chat/chatMessageSections.test.ts new file mode 100644 index 0000000..9034f32 --- /dev/null +++ b/src/components/chat/chatMessageSections.test.ts @@ -0,0 +1,43 @@ +import { parseAssistantMessageSections } from "./chatMessageSections"; + +describe("parseAssistantMessageSections", () => { + it("returns plain assistant content when there is no thought block", () => { + expect(parseAssistantMessageSections("直接回答")).toEqual({ + answer: "直接回答", + thought: null, + thoughtComplete: false, + }); + }); + + it("extracts a completed thought block and keeps the final answer visible", () => { + expect( + parseAssistantMessageSections("先分析需求\n\n最终回答"), + ).toEqual({ + answer: "最终回答", + thought: "先分析需求", + thoughtComplete: true, + }); + }); + + it("supports streaming thought content before the closing tag arrives", () => { + expect( + parseAssistantMessageSections("准备中...\n继续推理中"), + ).toEqual({ + answer: "准备中...", + thought: "继续推理中", + thoughtComplete: false, + }); + }); + + it("merges multiple thought blocks into a single collapsed section", () => { + expect( + parseAssistantMessageSections( + "第一段思考\n答案开头\n第二段思考\n答案结尾", + ), + ).toEqual({ + answer: "答案开头\n\n答案结尾", + thought: "第一段思考\n\n第二段思考", + thoughtComplete: true, + }); + }); +}); diff --git a/src/components/chat/chatMessageSections.ts b/src/components/chat/chatMessageSections.ts new file mode 100644 index 0000000..f5bc7fe --- /dev/null +++ b/src/components/chat/chatMessageSections.ts @@ -0,0 +1,55 @@ +export type AssistantMessageSections = { + answer: string; + thought: string | null; + thoughtComplete: boolean; +}; + +const THINK_BLOCK_PATTERN = /([\s\S]*?)<\/think>/gi; +const THINK_OPEN_TAG = ""; +const THINK_CLOSE_TAG = ""; + +export const parseAssistantMessageSections = ( + content: string, +): AssistantMessageSections => { + if (!content) { + return { answer: "", thought: null, thoughtComplete: false }; + } + + const thoughtParts: string[] = []; + let answer = content; + + answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => { + const trimmedThought = thoughtContent.trim(); + if (trimmedThought) { + thoughtParts.push(trimmedThought); + } + + return "\n"; + }); + + const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG); + const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG); + const hasUnclosedThought = + lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex; + + if (hasUnclosedThought) { + const streamingThought = answer + .slice(lastOpenIndex + THINK_OPEN_TAG.length) + .trim(); + + if (streamingThought) { + thoughtParts.push(streamingThought); + } + + answer = answer.slice(0, lastOpenIndex); + } + + const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim(); + const normalizedThought = thoughtParts.join("\n\n").trim(); + + return { + answer: normalizedAnswer, + thought: normalizedThought || null, + thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought, + }; +}; diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index 00d3b8a..6bfcf88 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -63,6 +63,7 @@ export const ProjectSelector: React.FC = ({ try { const response = await apiFetch( `${config.BACKEND_URL}/api/v1/meta/projects`, + { projectHeaderMode: "omit" }, ); if (!response.ok) { throw new Error(`HTTP ${response.status}`); diff --git a/src/config/config.ts b/src/config/config.ts index 3aae064..ca689f2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,5 +1,6 @@ export const config = { BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", + COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787", MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater", MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 5f575ce..ce5660d 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -53,7 +53,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ try { // Open project backend (simulation model) const openResponse = await apiFetch( - `${config.BACKEND_URL}/openproject/?network=${net}`, + `${config.BACKEND_URL}/api/v1/openproject/?network=${net}`, { method: "POST", }, @@ -64,7 +64,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ // Fetch project metadata const infoResponse = await apiFetch( - `${config.BACKEND_URL}/project_info/?network=${net}`, + `${config.BACKEND_URL}/api/v1/project_info/?network=${net}`, ); if (!infoResponse.ok) { console.warn( diff --git a/src/lib/api.ts b/src/lib/api.ts index 7e0ccf0..88134f9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,9 +1,11 @@ -import axios from "axios"; +import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios"; import { config } from "@config/config"; -import { useProjectStore } from "@/store/projectStore"; -import { getAccessToken } from "@/lib/authToken"; import { signOut } from "next-auth/react"; import { useAuthStore } from "@/store/authStore"; +import { + applyAuthContextHeaders, + type AuthContextHeaderOptions, +} from "@/lib/requestHeaders"; export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL; @@ -13,26 +15,24 @@ export const api = axios.create({ let isSigningOut = false; -const isMetaProjectsRequest = (request: { +const resolveRequestUrl = (request: { baseURL?: string; url?: string; -}) => { - const url = `${request.baseURL ?? ""}${request.url ?? ""}`; - return url.includes("/api/v1/meta/projects"); -}; +}) => `${request.baseURL ?? ""}${request.url ?? ""}`; -api.interceptors.request.use(async (request) => { - const accessToken = await getAccessToken(); - if (accessToken) { - request.headers = request.headers ?? {}; - request.headers.Authorization = `Bearer ${accessToken}`; - } +export interface ApiRequestConfig + extends InternalAxiosRequestConfig, + AuthContextHeaderOptions {} - const projectId = useProjectStore.getState().currentProjectId; - if (projectId && !isMetaProjectsRequest(request)) { - request.headers = request.headers ?? {}; - request.headers["X-Project-Id"] = projectId; - } +api.interceptors.request.use(async (request: ApiRequestConfig) => { + const headers = new Headers( + request.headers + ? AxiosHeaders.from(request.headers).toJSON() as Record + : undefined, + ); + await applyAuthContextHeaders(resolveRequestUrl(request), headers, request); + + request.headers = AxiosHeaders.from(Object.fromEntries(headers.entries())); return request; }); diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts index 7d8dd14..41d4197 100644 --- a/src/lib/apiFetch.ts +++ b/src/lib/apiFetch.ts @@ -1,7 +1,9 @@ -import { useProjectStore } from "@/store/projectStore"; -import { getAccessToken } from "@/lib/authToken"; import { signOut } from "next-auth/react"; import { useAuthStore } from "@/store/authStore"; +import { + applyAuthContextHeaders, + type AuthContextHeaderOptions, +} from "@/lib/requestHeaders"; let isSigningOut = false; @@ -12,10 +14,7 @@ const resolveUrl = (input: RequestInfo | URL) => { return ""; }; -const isMetaProjectsRequest = (input: RequestInfo | URL) => - resolveUrl(input).includes("/api/v1/meta/projects"); - -export interface ApiFetchInit extends RequestInit { +export interface ApiFetchInit extends RequestInit, AuthContextHeaderOptions { skipAuthRedirect?: boolean; } @@ -23,15 +22,8 @@ export const apiFetch = async ( input: RequestInfo | URL, init: ApiFetchInit = {}, ) => { - const projectId = useProjectStore.getState().currentProjectId; const headers = new Headers(init.headers ?? {}); - const accessToken = await getAccessToken(); - if (accessToken) { - headers.set("Authorization", `Bearer ${accessToken}`); - } - if (projectId && !isMetaProjectsRequest(input)) { - headers.set("X-Project-Id", projectId); - } + await applyAuthContextHeaders(resolveUrl(input), headers, init); const response = await fetch(input, { ...init, headers }); diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index d477ce3..c138d8a 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -54,6 +54,15 @@ describe("streamCopilotChat", () => { onEvent: (event) => events.push(event), }); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/copilot/chat/stream"), + expect.objectContaining({ + method: "POST", + projectHeaderMode: "include", + skipAuthRedirect: true, + }), + ); + expect(events).toEqual([ { type: "token", conversationId: "c1", content: "he" }, { type: "token", conversationId: "c1", content: "llo" }, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index b848f6d..9bc6d36 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -4,7 +4,12 @@ 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: "error"; + conversationId?: string; + message: string; + detail?: string; + }; type StreamOptions = { message: string; @@ -40,19 +45,23 @@ export const streamCopilotChat = async ({ }: StreamOptions) => { let response: Response; try { - response = await apiFetch(`${config.BACKEND_URL}/api/v1/copilot/chat/stream`, { - method: "POST", - signal, - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", + response = await apiFetch( + `${config.COPILOT_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, + }), + projectHeaderMode: "include", + skipAuthRedirect: true, }, - body: JSON.stringify({ - message, - conversation_id: conversationId, - }), - skipAuthRedirect: true, - }); + ); } catch (error) { const detail = error instanceof Error ? error.message : String(error); onEvent({ @@ -66,17 +75,18 @@ export const streamCopilotChat = async ({ if (!response.ok || !response.body) { const detail = await response.text(); let message = "stream request failed"; - + if (response.status === 403) { message = "Permission denied. Please contact administrator."; } else if (response.status === 401) { message = "Login expired. Please sign in again."; } - + onEvent({ type: "error", message, - detail: (response.status === 403 || response.status === 401) ? undefined : detail, + detail: + response.status === 403 || response.status === 401 ? undefined : detail, }); return; } diff --git a/src/lib/requestHeaders.ts b/src/lib/requestHeaders.ts new file mode 100644 index 0000000..95e45ac --- /dev/null +++ b/src/lib/requestHeaders.ts @@ -0,0 +1,46 @@ +import { getAccessToken } from "@/lib/authToken"; +import { useProjectStore } from "@/store/projectStore"; + +export type AuthHeaderMode = "include" | "omit"; +export type ProjectHeaderMode = "auto" | "include" | "omit"; + +export interface AuthContextHeaderOptions { + authHeaderMode?: AuthHeaderMode; + projectHeaderMode?: ProjectHeaderMode; +} + +const shouldIncludeProjectHeader = ( + url: string, + projectHeaderMode: ProjectHeaderMode, +) => { + if (projectHeaderMode === "include") { + return true; + } + + if (projectHeaderMode === "omit") { + return false; + } + + return !url.includes("/api/v1/meta/projects"); +}; + +export const applyAuthContextHeaders = async ( + url: string, + headers: Headers, + options: AuthContextHeaderOptions = {}, +) => { + const accessToken = await getAccessToken(); + if (accessToken && options.authHeaderMode !== "omit") { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + const projectId = useProjectStore.getState().currentProjectId; + if ( + projectId && + shouldIncludeProjectHeader(url, options.projectHeaderMode ?? "auto") + ) { + headers.set("X-Project-Id", projectId); + } + + return headers; +};