添加聊天框消息解析功能;优化请求头处理;更新部分 api base url

This commit is contained in:
2026-03-27 18:00:30 +08:00
parent 8713e5a468
commit a101e79750
11 changed files with 464 additions and 193 deletions
+256 -142
View File
@@ -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 ? "</think>" : "<think>",
);
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 (
<motion.div
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "85%",
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
gap: 12,
alignItems: "flex-end",
}}
>
{!isUser && (
<Avatar sx={{ width: 28, height: 28, bgcolor: isErrorMessage ? alpha(theme.palette.error.main, 0.12) : alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
)}
</Avatar>
)}
<Paper
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
sx={{
p: 2.5,
borderRadius: 4,
borderBottomRightRadius: isUser ? 4 : 24,
borderBottomLeftRadius: !isUser ? 4 : 24,
bgcolor: isUser ? "primary.main" : isErrorMessage ? alpha(theme.palette.error.light, 0.18) : "#fff",
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
background: isUser
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
: isErrorMessage
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
: undefined,
border: isErrorMessage ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` : "none",
boxShadow: isUser
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
: isErrorMessage
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
"--chat-md-text": isUser
? alpha("#fff", 0.96)
: isErrorMessage
? theme.palette.error.dark
: "#1f2937",
"--chat-md-heading": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#111827",
"--chat-md-link": isUser
? "#E3F2FD"
: isErrorMessage
? theme.palette.error.main
: "#7C3AED",
"--chat-md-link-hover": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#6D28D9",
"--chat-md-inline-code-bg": isUser
? "rgba(255,255,255,0.2)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#EEF2FF",
"--chat-md-inline-code-border": isUser
? alpha("#fff", 0.16)
: isErrorMessage
? alpha(theme.palette.error.main, 0.25)
: "#CBD5E1",
"--chat-md-inline-code-text": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#334155",
"--chat-md-pre-bg": isUser
? "rgba(11, 18, 32, 0.56)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isUser
? alpha("#fff", 0.12)
: isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isUser
? "#F8FAFC"
: isErrorMessage
? theme.palette.error.dark
: "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: isUser
? alpha("#fff", 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isUser
? alpha("#fff", 0.08)
: isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isUser
? alpha("#fff", 0.9)
: isErrorMessage
? theme.palette.error.dark
: "#475569",
}}
>
<div className={markdownStyles.markdown}>
<ReactMarkdown>{answerContent || "..."}</ReactMarkdown>
</div>
</Paper>
</motion.div>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const initialChatStateRef = useRef<PersistedChatState | null>(null);
if (initialChatStateRef.current === null) {
@@ -148,12 +297,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [conversationId, setConversationId] = useState<string | undefined>(
initialChatStateRef.current.conversationId
);
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(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<Props> = ({ 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<Props> = ({ open, onClose }) => {
setIsStreaming(false);
};
const handleHeaderMenuOpen = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
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) => (
<ChatMessageItem
key={message.id}
message={message}
theme={theme}
/>
)),
[messages, theme],
);
return (
<Drawer
anchor="right"
@@ -304,31 +493,43 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
whileHover={{ rotate: 10, scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Box sx={{ position: "relative" }}>
<IconButton
onClick={handleHeaderMenuOpen}
aria-label="打开聊天菜单"
aria-controls={isHeaderMenuOpen ? "global-chatbox-header-menu" : undefined}
aria-expanded={isHeaderMenuOpen ? "true" : undefined}
aria-haspopup="menu"
sx={{
p: 0,
borderRadius: "50%",
}}
>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
sx={{
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`,
boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`,
width: 48,
height: 48,
}}
}}
>
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} />
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} />
</Avatar>
<Box
sx={{
position: "absolute",
bottom: 2,
right: 2,
width: 12,
height: 12,
bgcolor: "success.main",
borderRadius: "50%",
border: "2px solid #fff",
boxShadow: "0 0 0 2px rgba(255,255,255,0.5)"
}}
<Box
sx={{
position: "absolute",
bottom: 2,
right: 2,
width: 12,
height: 12,
bgcolor: "success.main",
borderRadius: "50%",
border: "2px solid #fff",
boxShadow: "0 0 0 2px rgba(255,255,255,0.5)"
}}
/>
</Box>
</Box>
</IconButton>
</motion.div>
<Box>
@@ -340,6 +541,41 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
</Typography>
</Box>
</Stack>
<Menu
id="global-chatbox-header-menu"
anchorEl={headerMenuAnchorEl}
open={isHeaderMenuOpen}
onClose={handleHeaderMenuClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
elevation: 8,
sx: {
mt: 1,
minWidth: 180,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
backdropFilter: "blur(12px)",
bgcolor: alpha("#fff", 0.92),
boxShadow: `0 16px 40px -16px ${alpha(theme.palette.common.black, 0.28)}`,
},
},
}}
>
<MenuItem onClick={handleNewConversation}>
<ListItemIcon>
<AddCommentRounded fontSize="small" />
</ListItemIcon>
<ListItemText
primary="新建对话"
secondary="清空当前会话"
primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 600 } }}
secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }}
/>
</MenuItem>
</Menu>
<motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}>
@@ -387,129 +623,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
</motion.div>
)}
{messages.map((message) => {
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
return (
<motion.div
key={message.id}
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "85%",
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
gap: 12,
alignItems: "flex-end",
}}
>
{!isUser && (
<Avatar sx={{ width: 28, height: 28, bgcolor: isErrorMessage ? alpha(theme.palette.error.main, 0.12) : alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
)}
</Avatar>
)}
<Paper
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
sx={{
p: 2.5,
borderRadius: 4,
borderBottomRightRadius: isUser ? 4 : 24,
borderBottomLeftRadius: !isUser ? 4 : 24,
bgcolor: isUser ? "primary.main" : isErrorMessage ? alpha(theme.palette.error.light, 0.18) : "#fff",
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
background: isUser
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
: isErrorMessage
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
: undefined,
border: isErrorMessage ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` : "none",
boxShadow: isUser
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
: isErrorMessage
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
"--chat-md-text": isUser
? alpha("#fff", 0.96)
: isErrorMessage
? theme.palette.error.dark
: "#1f2937",
"--chat-md-heading": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#111827",
"--chat-md-link": isUser
? "#E3F2FD"
: isErrorMessage
? theme.palette.error.main
: "#7C3AED",
"--chat-md-link-hover": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#6D28D9",
"--chat-md-inline-code-bg": isUser
? "rgba(255,255,255,0.2)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#EEF2FF",
"--chat-md-inline-code-border": isUser
? alpha("#fff", 0.16)
: isErrorMessage
? alpha(theme.palette.error.main, 0.25)
: "#CBD5E1",
"--chat-md-inline-code-text": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#334155",
"--chat-md-pre-bg": isUser
? "rgba(11, 18, 32, 0.56)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isUser
? alpha("#fff", 0.12)
: isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isUser
? "#F8FAFC"
: isErrorMessage
? theme.palette.error.dark
: "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: isUser
? alpha("#fff", 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isUser
? alpha("#fff", 0.08)
: isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isUser
? alpha("#fff", 0.9)
: isErrorMessage
? theme.palette.error.dark
: "#475569",
}}
>
<div className={markdownStyles.markdown}>
<ReactMarkdown>{message.content || "..."}</ReactMarkdown>
</div>
</Paper>
</motion.div>
);
})}
{renderedMessages}
</AnimatePresence>
{isStreaming && (
@@ -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("<think>先分析需求</think>\n\n最终回答"),
).toEqual({
answer: "最终回答",
thought: "先分析需求",
thoughtComplete: true,
});
});
it("supports streaming thought content before the closing tag arrives", () => {
expect(
parseAssistantMessageSections("准备中...\n<think>继续推理中"),
).toEqual({
answer: "准备中...",
thought: "继续推理中",
thoughtComplete: false,
});
});
it("merges multiple thought blocks into a single collapsed section", () => {
expect(
parseAssistantMessageSections(
"<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\n答案结尾",
),
).toEqual({
answer: "答案开头\n\n答案结尾",
thought: "第一段思考\n\n第二段思考",
thoughtComplete: true,
});
});
});
@@ -0,0 +1,55 @@
export type AssistantMessageSections = {
answer: string;
thought: string | null;
thoughtComplete: boolean;
};
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
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,
};
};
@@ -63,6 +63,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
try {
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/meta/projects`,
{ projectHeaderMode: "omit" },
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
+1
View File
@@ -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
+2 -2
View File
@@ -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(
+19 -19
View File
@@ -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<string, string>
: undefined,
);
await applyAuthContextHeaders(resolveRequestUrl(request), headers, request);
request.headers = AxiosHeaders.from(Object.fromEntries(headers.entries()));
return request;
});
+6 -14
View File
@@ -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 });
+9
View File
@@ -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" },
+26 -16
View File
@@ -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;
}
+46
View File
@@ -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;
};