重构 GlobalChatbox 组件,拆分为多个模块

This commit is contained in:
2026-04-03 14:07:27 +08:00
parent 56b4777dbd
commit d763876f86
5 changed files with 666 additions and 589 deletions
+426
View File
@@ -0,0 +1,426 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Paper,
Stack,
Typography,
alpha,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import { ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
export const TypingIndicator = () => {
return (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ y: 0 }}
animate={{ y: [-4, 4, -4] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
}}
/>
</motion.div>
))}
</Stack>
);
};
export const Blob = ({
color,
size,
top,
left,
delay,
}: {
color: string;
size: number;
top: string;
left: string;
delay: number;
}) => (
<motion.div
initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }}
animate={{
scale: [0.8, 1.2, 0.8],
opacity: [0.3, 0.5, 0.3],
x: [0, 30, 0],
y: [0, -30, 0],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay,
}}
style={{
position: "absolute",
top,
left,
width: size,
height: size,
borderRadius: "50%",
background: color,
filter: "blur(60px)",
zIndex: 0,
pointerEvents: "none",
}}
/>
);
type ChatMessageItemProps = {
message: Message;
theme: Theme;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
};
export const ChatMessageItem = React.memo(
({
message,
theme,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
sseChartParams,
}: 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;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
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>
)}
<Box>
<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",
}}
>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<div key={segIdx} className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text || "..."}
</ReactMarkdown>
</div>
);
}
if (segment.type === "tool_call") {
if (segment.toolCall.tool === "chart") {
return (
<ChatInlineChart
key={segment.toolCall.id}
{...(segment.toolCall.params as Record<string, unknown>)}
/>
);
}
if (segment.toolCall.tool === "show_chart") {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={
(p.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<motion.div
key="tool-pending"
initial={{ opacity: 0 }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} />
<Typography variant="caption" color="text.secondary">
...
</Typography>
</motion.div>
);
}
return null;
})}
{sseChartParams?.map((chart, idx) => (
<ChatInlineChart
key={`sse-chart-${idx}`}
title={(chart.params.title as string) ?? undefined}
chart_type={
(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(chart.params.x_data as string[]) ?? []}
series={
(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
/>
))}
</Paper>
{!isUser && !isErrorMessage && isTtsSupported && (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
{messageSpeechState === "idle" && (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{
color: "text.secondary",
opacity: 0.6,
"&:hover": { opacity: 1 },
p: 0.5,
}}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
)}
{messageSpeechState === "playing" && (
<>
<IconButton
size="small"
onClick={onPause}
aria-label="暂停朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
{messageSpeechState === "paused" && (
<>
<IconButton
size="small"
onClick={onResume}
aria-label="继续朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
</Stack>
)}
</Box>
</motion.div>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
+10 -589
View File
@@ -1,10 +1,7 @@
"use client";
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion, AnimatePresence } from "framer-motion";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
// MUI
import {
@@ -23,18 +20,13 @@ import {
useTheme,
alpha,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
// Icons
import CloseRounded from "@mui/icons-material/CloseRounded";
import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
@@ -42,591 +34,20 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
// Logic
import { streamCopilotChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import { ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
// WebKit Speech Recognition compatibility
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
declare global {
interface Window {
SpeechRecognition?: { new (): SpeechRecognition; prototype: SpeechRecognition };
webkitSpeechRecognition?: { new (): SpeechRecognition; prototype: SpeechRecognition };
}
}
// Types
type Message = {
id: string;
role: "user" | "assistant";
content: string;
isError?: boolean;
};
type Props = {
open: boolean;
onClose: () => void;
};
// 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 SpeechState = "idle" | "playing" | "paused";
const stripMarkdown = (md: string): string =>
md
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[.*?\]\(.*?\)/g, "")
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
.replace(/#{1,6}\s+/g, "")
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/~~(.+?)~~/g, "$1")
.replace(/>\s+/g, "")
.replace(/[-*+]\s+/g, "")
.replace(/\d+\.\s+/g, "")
.replace(/\n{2,}/g, "\n")
.replace(/<[^>]+>/g, "")
.trim();
type PersistedChatState = {
messages: Message[];
conversationId?: string;
};
const PRESET_PROMPTS = [
"帮我分析当前管网压力异常点,并按风险等级排序。",
"基于当前状态,给出今天的巡检优先级和建议路线。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
];
const getInitialChatState = (): PersistedChatState => {
if (typeof window === "undefined") {
return { messages: [], conversationId: undefined };
}
try {
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!storedRaw) return { messages: [], conversationId: undefined };
const parsed = JSON.parse(storedRaw) as PersistedChatState;
if (!Array.isArray(parsed.messages)) {
console.error("[GlobalChatbox] Invalid persisted messages format.");
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
return { messages: parsed.messages, conversationId: parsed.conversationId };
} catch (error) {
console.error("[GlobalChatbox] Failed to read persisted chat state:", error);
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
};
// --- Components ---
const TypingIndicator = () => {
return (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ y: 0 }}
animate={{ y: [-4, 4, -4] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut", // Smooth sine wave
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)", // Warm gradient dots
}}
/>
</motion.div>
))}
</Stack>
);
};
// Animated Background Blob
const Blob = ({ color, size, top, left, delay }: { color: string; size: number; top: string; left: string; delay: number }) => (
<motion.div
initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }}
animate={{
scale: [0.8, 1.2, 0.8],
opacity: [0.3, 0.5, 0.3],
x: [0, 30, 0],
y: [0, -30, 0],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: delay,
}}
style={{
position: "absolute",
top,
left,
width: size,
height: size,
borderRadius: "50%",
background: color,
filter: "blur(60px)",
zIndex: 0,
pointerEvents: "none",
}}
/>
);
type ChatMessageItemProps = {
message: Message;
theme: Theme;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
};
const ChatMessageItem = React.memo(
({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, sseChartParams }: 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;
// Parse tool_call blocks from the answer for inline rendering
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
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>
)}
<Box>
<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",
}}
>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<div key={segIdx} className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text || "..."}
</ReactMarkdown>
</div>
);
}
if (segment.type === "tool_call") {
if (segment.toolCall.tool === "chart") {
return (
<ChatInlineChart
key={segment.toolCall.id}
{...(segment.toolCall.params as Record<string, unknown>)}
/>
);
}
if (segment.toolCall.tool === "show_chart") {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={(p.chart_type as "line" | "bar" | "pie") ?? "line"}
x_data={(p.x_data as string[]) ?? []}
series={(p.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<motion.div
key="tool-pending"
initial={{ opacity: 0 }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AutoAwesome
sx={{ fontSize: 14, color: "primary.main" }}
/>
<Typography variant="caption" color="text.secondary">
...
</Typography>
</motion.div>
);
}
return null;
})}
{/* SSE-sourced inline charts (from show_chart tool_call events) */}
{sseChartParams?.map((chart, idx) => (
<ChatInlineChart
key={`sse-chart-${idx}`}
title={(chart.params.title as string) ?? undefined}
chart_type={(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"}
x_data={(chart.params.x_data as string[]) ?? []}
series={(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []}
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
/>
))}
</Paper>
{!isUser && !isErrorMessage && isTtsSupported && (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
{messageSpeechState === "idle" && (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.6, "&:hover": { opacity: 1 }, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
)}
{messageSpeechState === "playing" && (
<>
<IconButton
size="small"
onClick={onPause}
aria-label="暂停朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
{messageSpeechState === "paused" && (
<>
<IconButton
size="small"
onClick={onResume}
aria-label="继续朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
</Stack>
)}
</Box>
</motion.div>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
// --- Voice Hooks ---
function useSpeechSynthesis() {
const [speechState, setSpeechState] = useState<SpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const isSupported = typeof window !== "undefined" && "speechSynthesis" in window;
const stop = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.cancel();
utteranceRef.current = null;
setSpeechState("idle");
setSpeakingMessageId(null);
}, [isSupported]);
const speak = useCallback(
(messageId: string, text: string) => {
if (!isSupported || !text) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "zh-CN";
utterance.rate = 1;
utterance.onend = () => {
setSpeechState("idle");
setSpeakingMessageId(null);
utteranceRef.current = null;
};
utterance.onerror = () => {
setSpeechState("idle");
setSpeakingMessageId(null);
utteranceRef.current = null;
};
utterance.onpause = () => setSpeechState("paused");
utterance.onresume = () => setSpeechState("playing");
utteranceRef.current = utterance;
setSpeakingMessageId(messageId);
setSpeechState("playing");
window.speechSynthesis.speak(utterance);
},
[isSupported],
);
const pause = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.pause();
}, [isSupported]);
const resume = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.resume();
}, [isSupported]);
useEffect(() => {
return () => {
if (typeof window !== "undefined" && "speechSynthesis" in window) {
window.speechSynthesis.cancel();
}
};
}, []);
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
}
function useSpeechRecognition(onResult: (text: string) => void) {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const onResultRef = useRef(onResult);
useEffect(() => {
onResultRef.current = onResult;
}, [onResult]);
const isSupported =
typeof window !== "undefined" &&
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
const start = useCallback(() => {
if (!isSupported || recognitionRef.current) return;
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = "zh-CN";
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = (event: SpeechRecognitionEvent) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
onResultRef.current(event.results[i][0].transcript);
}
}
};
recognition.onerror = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognition.onend = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
recognition.start();
setIsListening(true);
}, [isSupported]);
const stop = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
setIsListening(false);
}, []);
useEffect(() => {
return () => {
recognitionRef.current?.stop();
};
}, []);
return { isListening, start, stop, isSupported };
}
import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types";
import {
CHAT_STORAGE_KEY,
PRESET_PROMPTS,
createId,
getInitialChatState,
normalizeThoughtTagToken,
} from "./GlobalChatbox.utils";
import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts";
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const initialChatStateRef = useRef<PersistedChatState | null>(null);
@@ -0,0 +1,18 @@
export type Message = {
id: string;
role: "user" | "assistant";
content: string;
isError?: boolean;
};
export type Props = {
open: boolean;
onClose: () => void;
};
export type SpeechState = "idle" | "playing" | "paused";
export type PersistedChatState = {
messages: Message[];
conversationId?: string;
};
@@ -0,0 +1,54 @@
import type { PersistedChatState } from "./GlobalChatbox.types";
export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1";
const THINK_TAG_ALIAS_PATTERN = /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi;
export const PRESET_PROMPTS = [
"帮我分析当前管网压力异常点,并按风险等级排序。",
"基于当前状态,给出今天的巡检优先级和建议路线。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
];
export const normalizeThoughtTagToken = (token: string): string =>
token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) =>
closingSlash ? "</think>" : "<think>",
);
export const stripMarkdown = (md: string): string =>
md
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[.*?\]\(.*?\)/g, "")
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
.replace(/#{1,6}\s+/g, "")
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/~~(.+?)~~/g, "$1")
.replace(/>\s+/g, "")
.replace(/[-*+]\s+/g, "")
.replace(/\d+\.\s+/g, "")
.replace(/\n{2,}/g, "\n")
.replace(/<[^>]+>/g, "")
.trim();
export const getInitialChatState = (): PersistedChatState => {
if (typeof window === "undefined") {
return { messages: [], conversationId: undefined };
}
try {
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!storedRaw) return { messages: [], conversationId: undefined };
const parsed = JSON.parse(storedRaw) as PersistedChatState;
if (!Array.isArray(parsed.messages)) {
console.error("[GlobalChatbox] Invalid persisted messages format.");
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
return { messages: parsed.messages, conversationId: parsed.conversationId };
} catch (error) {
console.error("[GlobalChatbox] Failed to read persisted chat state:", error);
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
};
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SpeechState } from "./GlobalChatbox.types";
// WebKit Speech Recognition compatibility
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
declare global {
interface Window {
SpeechRecognition?: {
new (): SpeechRecognition;
prototype: SpeechRecognition;
};
webkitSpeechRecognition?: {
new (): SpeechRecognition;
prototype: SpeechRecognition;
};
}
}
export function useSpeechSynthesis() {
const [speechState, setSpeechState] = useState<SpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const isSupported = typeof window !== "undefined" && "speechSynthesis" in window;
const stop = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.cancel();
utteranceRef.current = null;
setSpeechState("idle");
setSpeakingMessageId(null);
}, [isSupported]);
const speak = useCallback(
(messageId: string, text: string) => {
if (!isSupported || !text) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "zh-CN";
utterance.rate = 1;
utterance.onend = () => {
setSpeechState("idle");
setSpeakingMessageId(null);
utteranceRef.current = null;
};
utterance.onerror = () => {
setSpeechState("idle");
setSpeakingMessageId(null);
utteranceRef.current = null;
};
utterance.onpause = () => setSpeechState("paused");
utterance.onresume = () => setSpeechState("playing");
utteranceRef.current = utterance;
setSpeakingMessageId(messageId);
setSpeechState("playing");
window.speechSynthesis.speak(utterance);
},
[isSupported],
);
const pause = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.pause();
}, [isSupported]);
const resume = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.resume();
}, [isSupported]);
useEffect(() => {
return () => {
if (typeof window !== "undefined" && "speechSynthesis" in window) {
window.speechSynthesis.cancel();
}
};
}, []);
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
}
export function useSpeechRecognition(onResult: (text: string) => void) {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const onResultRef = useRef(onResult);
useEffect(() => {
onResultRef.current = onResult;
}, [onResult]);
const isSupported =
typeof window !== "undefined" &&
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
const start = useCallback(() => {
if (!isSupported || recognitionRef.current) return;
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = "zh-CN";
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = (event: SpeechRecognitionEvent) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
onResultRef.current(event.results[i][0].transcript);
}
}
};
recognition.onerror = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognition.onend = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
recognition.start();
setIsListening(true);
}, [isSupported]);
const stop = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
setIsListening(false);
}, []);
useEffect(() => {
return () => {
recognitionRef.current?.stop();
};
}, []);
return { isListening, start, stop, isSupported };
}