添加聊天框消息解析功能;优化请求头处理;更新部分 api base url
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 ReactMarkdown from "react-markdown";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Drawer,
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -19,6 +23,7 @@ import {
|
|||||||
alpha,
|
alpha,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import CloseRounded from "@mui/icons-material/CloseRounded";
|
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 AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
|
||||||
import PersonRounded from "@mui/icons-material/PersonRounded";
|
import PersonRounded from "@mui/icons-material/PersonRounded";
|
||||||
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
|
||||||
|
|
||||||
// Logic
|
// Logic
|
||||||
import { streamCopilotChat } from "@/lib/chatStream";
|
import { streamCopilotChat } from "@/lib/chatStream";
|
||||||
|
import { parseAssistantMessageSections } from "./chatMessageSections";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type Message = {
|
type Message = {
|
||||||
@@ -47,6 +54,11 @@ type Props = {
|
|||||||
// Utils
|
// Utils
|
||||||
const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1";
|
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 = {
|
type PersistedChatState = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -136,263 +148,23 @@ const Blob = ({ color, size, top, left, delay }: { color: string; size: number;
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
type ChatMessageItemProps = {
|
||||||
const initialChatStateRef = useRef<PersistedChatState | null>(null);
|
message: Message;
|
||||||
if (initialChatStateRef.current === null) {
|
theme: Theme;
|
||||||
initialChatStateRef.current = getInitialChatState();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages);
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [conversationId, setConversationId] = useState<string | undefined>(
|
|
||||||
initialChatStateRef.current.conversationId
|
|
||||||
);
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Auto-scroll
|
|
||||||
useEffect(() => {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, [messages, isStreaming]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const state: PersistedChatState = { messages, conversationId };
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
|
||||||
}
|
|
||||||
}, [messages, conversationId]);
|
|
||||||
|
|
||||||
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((m) =>
|
|
||||||
m.id === assistantId
|
|
||||||
? { ...m, content: m.content + event.content, isError: false }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (event.type === "done") {
|
|
||||||
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
|
||||||
setIsStreaming(false);
|
|
||||||
} else if (event.type === "error") {
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantId
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
content: m.content || `⚠️ **错误:** ${event.message}`,
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (abortRef.current?.signal.aborted) {
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantId
|
|
||||||
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setIsStreaming(false);
|
|
||||||
} finally {
|
|
||||||
abortRef.current = null;
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAbort = () => {
|
const ChatMessageItem = React.memo(
|
||||||
abortRef.current?.abort();
|
({ message, theme }: ChatMessageItemProps) => {
|
||||||
setIsStreaming(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
anchor="right"
|
|
||||||
variant="persistent"
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
hideBackdrop
|
|
||||||
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }}
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
width: { xs: "100%", sm: 480 },
|
|
||||||
background: "transparent",
|
|
||||||
boxShadow: "none",
|
|
||||||
overflow: "hidden", // Clip blobs
|
|
||||||
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
bgcolor: alpha("#fff", 0.75), // Light glass base
|
|
||||||
backdropFilter: "blur(30px)",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Ambient Blobs */}
|
|
||||||
<Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} />
|
|
||||||
<Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} />
|
|
||||||
<Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} />
|
|
||||||
|
|
||||||
{/* Header - Transparent & Floating */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 3,
|
|
||||||
zIndex: 10,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={2}>
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ rotate: 10, scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Box sx={{ position: "relative" }}>
|
|
||||||
<Avatar
|
|
||||||
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" }} />
|
|
||||||
</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>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}>
|
|
||||||
Copilot
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
|
||||||
你的 AI 助手
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<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" } }}>
|
|
||||||
<CloseRounded />
|
|
||||||
</IconButton>
|
|
||||||
</motion.div>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Messages - Bouncy List */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: "auto", px: 2.5, py: 2, display: "flex", flexDirection: "column", gap: 2.5, zIndex: 5 }}>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
|
||||||
style={{ margin: "auto", width: "100%" }}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 4,
|
|
||||||
borderRadius: 6,
|
|
||||||
bgcolor: alpha("#fff", 0.6),
|
|
||||||
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
|
||||||
maxWidth: 320,
|
|
||||||
mx: "auto",
|
|
||||||
textAlign: "center",
|
|
||||||
backdropFilter: "blur(10px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{ y: [-5, 5, -5] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} />
|
|
||||||
</motion.div>
|
|
||||||
<Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom>
|
|
||||||
你好呀!👋
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
|
|
||||||
我已准备好为你提供帮助,尽管问我吧!
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.map((message) => {
|
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isErrorMessage = Boolean(message.isError);
|
const isErrorMessage = Boolean(message.isError);
|
||||||
|
const parsedAssistantSections =
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseAssistantMessageSections(message.content)
|
||||||
|
: null;
|
||||||
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={message.id}
|
|
||||||
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
|
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
|
||||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
@@ -504,12 +276,354 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={markdownStyles.markdown}>
|
<div className={markdownStyles.markdown}>
|
||||||
<ReactMarkdown>{message.content || "..."}</ReactMarkdown>
|
<ReactMarkdown>{answerContent || "..."}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</Paper>
|
</Paper>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
);
|
||||||
|
ChatMessageItem.displayName = "ChatMessageItem";
|
||||||
|
|
||||||
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const initialChatStateRef = useRef<PersistedChatState | null>(null);
|
||||||
|
if (initialChatStateRef.current === null) {
|
||||||
|
initialChatStateRef.current = getInitialChatState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
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(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages, isStreaming]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state: PersistedChatState = { messages, conversationId };
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
||||||
|
}
|
||||||
|
}, [messages, conversationId]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const normalizedToken = normalizeThoughtTagToken(event.content);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantId
|
||||||
|
? { ...m, content: m.content + normalizedToken, isError: false }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (event.type === "done") {
|
||||||
|
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
||||||
|
setIsStreaming(false);
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
content: m.content || `⚠️ **错误:** ${event.message}`,
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (abortRef.current?.signal.aborted) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantId
|
||||||
|
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setIsStreaming(false);
|
||||||
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAbort = () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
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"
|
||||||
|
variant="persistent"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
hideBackdrop
|
||||||
|
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
width: { xs: "100%", sm: 480 },
|
||||||
|
background: "transparent",
|
||||||
|
boxShadow: "none",
|
||||||
|
overflow: "hidden", // Clip blobs
|
||||||
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
bgcolor: alpha("#fff", 0.75), // Light glass base
|
||||||
|
backdropFilter: "blur(30px)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Ambient Blobs */}
|
||||||
|
<Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} />
|
||||||
|
<Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} />
|
||||||
|
<Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} />
|
||||||
|
|
||||||
|
{/* Header - Transparent & Floating */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
zIndex: 10,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2}>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ rotate: 10, scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<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={{
|
||||||
|
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" }} />
|
||||||
|
</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>
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}>
|
||||||
|
Copilot
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||||
|
你的 AI 助手
|
||||||
|
</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" } }}>
|
||||||
|
<CloseRounded />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Messages - Bouncy List */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: "auto", px: 2.5, py: 2, display: "flex", flexDirection: "column", gap: 2.5, zIndex: 5 }}>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||||
|
style={{ margin: "auto", width: "100%" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
||||||
|
maxWidth: 320,
|
||||||
|
mx: "auto",
|
||||||
|
textAlign: "center",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [-5, 5, -5] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} />
|
||||||
|
</motion.div>
|
||||||
|
<Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom>
|
||||||
|
你好呀!👋
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
|
||||||
|
我已准备好为你提供帮助,尽管问我吧!
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderedMessages}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{isStreaming && (
|
{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 {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/meta/projects`,
|
`${config.BACKEND_URL}/api/v1/meta/projects`,
|
||||||
|
{ projectHeaderMode: "omit" },
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const config = {
|
export const config = {
|
||||||
BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000",
|
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_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater",
|
MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater",
|
||||||
MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT
|
MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
try {
|
try {
|
||||||
// Open project backend (simulation model)
|
// Open project backend (simulation model)
|
||||||
const openResponse = await apiFetch(
|
const openResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/openproject/?network=${net}`,
|
`${config.BACKEND_URL}/api/v1/openproject/?network=${net}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
@@ -64,7 +64,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
|
|
||||||
// Fetch project metadata
|
// Fetch project metadata
|
||||||
const infoResponse = await apiFetch(
|
const infoResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/project_info/?network=${net}`,
|
`${config.BACKEND_URL}/api/v1/project_info/?network=${net}`,
|
||||||
);
|
);
|
||||||
if (!infoResponse.ok) {
|
if (!infoResponse.ok) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
+19
-19
@@ -1,9 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
|
||||||
import { getAccessToken } from "@/lib/authToken";
|
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
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;
|
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;
|
let isSigningOut = false;
|
||||||
|
|
||||||
const isMetaProjectsRequest = (request: {
|
const resolveRequestUrl = (request: {
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
}) => {
|
}) => `${request.baseURL ?? ""}${request.url ?? ""}`;
|
||||||
const url = `${request.baseURL ?? ""}${request.url ?? ""}`;
|
|
||||||
return url.includes("/api/v1/meta/projects");
|
|
||||||
};
|
|
||||||
|
|
||||||
api.interceptors.request.use(async (request) => {
|
export interface ApiRequestConfig
|
||||||
const accessToken = await getAccessToken();
|
extends InternalAxiosRequestConfig,
|
||||||
if (accessToken) {
|
AuthContextHeaderOptions {}
|
||||||
request.headers = request.headers ?? {};
|
|
||||||
request.headers.Authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = useProjectStore.getState().currentProjectId;
|
api.interceptors.request.use(async (request: ApiRequestConfig) => {
|
||||||
if (projectId && !isMetaProjectsRequest(request)) {
|
const headers = new Headers(
|
||||||
request.headers = request.headers ?? {};
|
request.headers
|
||||||
request.headers["X-Project-Id"] = projectId;
|
? 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;
|
return request;
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-14
@@ -1,7 +1,9 @@
|
|||||||
import { useProjectStore } from "@/store/projectStore";
|
|
||||||
import { getAccessToken } from "@/lib/authToken";
|
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import {
|
||||||
|
applyAuthContextHeaders,
|
||||||
|
type AuthContextHeaderOptions,
|
||||||
|
} from "@/lib/requestHeaders";
|
||||||
|
|
||||||
let isSigningOut = false;
|
let isSigningOut = false;
|
||||||
|
|
||||||
@@ -12,10 +14,7 @@ const resolveUrl = (input: RequestInfo | URL) => {
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMetaProjectsRequest = (input: RequestInfo | URL) =>
|
export interface ApiFetchInit extends RequestInit, AuthContextHeaderOptions {
|
||||||
resolveUrl(input).includes("/api/v1/meta/projects");
|
|
||||||
|
|
||||||
export interface ApiFetchInit extends RequestInit {
|
|
||||||
skipAuthRedirect?: boolean;
|
skipAuthRedirect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,15 +22,8 @@ export const apiFetch = async (
|
|||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init: ApiFetchInit = {},
|
init: ApiFetchInit = {},
|
||||||
) => {
|
) => {
|
||||||
const projectId = useProjectStore.getState().currentProjectId;
|
|
||||||
const headers = new Headers(init.headers ?? {});
|
const headers = new Headers(init.headers ?? {});
|
||||||
const accessToken = await getAccessToken();
|
await applyAuthContextHeaders(resolveUrl(input), headers, init);
|
||||||
if (accessToken) {
|
|
||||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
||||||
}
|
|
||||||
if (projectId && !isMetaProjectsRequest(input)) {
|
|
||||||
headers.set("X-Project-Id", projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(input, { ...init, headers });
|
const response = await fetch(input, { ...init, headers });
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ describe("streamCopilotChat", () => {
|
|||||||
onEvent: (event) => events.push(event),
|
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([
|
expect(events).toEqual([
|
||||||
{ type: "token", conversationId: "c1", content: "he" },
|
{ type: "token", conversationId: "c1", content: "he" },
|
||||||
{ type: "token", conversationId: "c1", content: "llo" },
|
{ type: "token", conversationId: "c1", content: "llo" },
|
||||||
|
|||||||
+14
-4
@@ -4,7 +4,12 @@ import { config } from "@config/config";
|
|||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: "token"; conversationId: string; content: string }
|
| { type: "token"; conversationId: string; content: string }
|
||||||
| { type: "done"; conversationId: string }
|
| { type: "done"; conversationId: string }
|
||||||
| { type: "error"; conversationId?: string; message: string; detail?: string };
|
| {
|
||||||
|
type: "error";
|
||||||
|
conversationId?: string;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type StreamOptions = {
|
type StreamOptions = {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -40,7 +45,9 @@ export const streamCopilotChat = async ({
|
|||||||
}: StreamOptions) => {
|
}: StreamOptions) => {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await apiFetch(`${config.BACKEND_URL}/api/v1/copilot/chat/stream`, {
|
response = await apiFetch(
|
||||||
|
`${config.COPILOT_URL}/api/v1/copilot/chat/stream`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -51,8 +58,10 @@ export const streamCopilotChat = async ({
|
|||||||
message,
|
message,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
}),
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -76,7 +85,8 @@ export const streamCopilotChat = async ({
|
|||||||
onEvent({
|
onEvent({
|
||||||
type: "error",
|
type: "error",
|
||||||
message,
|
message,
|
||||||
detail: (response.status === 403 || response.status === 401) ? undefined : detail,
|
detail:
|
||||||
|
response.status === 403 || response.status === 401 ? undefined : detail,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user