添加聊天框消息解析功能;优化请求头处理;更新部分 api base url
This commit is contained in:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user