重构 GlobalChatbox 组件,拆分为多个模块
This commit is contained in:
@@ -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";
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
|
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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
|
||||||
|
|
||||||
// MUI
|
// MUI
|
||||||
import {
|
import {
|
||||||
@@ -23,18 +20,13 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
alpha,
|
alpha,
|
||||||
} 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";
|
||||||
import SendRounded from "@mui/icons-material/SendRounded";
|
import SendRounded from "@mui/icons-material/SendRounded";
|
||||||
import StopRounded from "@mui/icons-material/StopRounded";
|
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 ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
|
||||||
import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
|
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 MicRounded from "@mui/icons-material/MicRounded";
|
||||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
@@ -42,591 +34,20 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
|||||||
// Logic
|
// Logic
|
||||||
import { streamCopilotChat } from "@/lib/chatStream";
|
import { streamCopilotChat } from "@/lib/chatStream";
|
||||||
import type { StreamEvent } 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 {
|
import {
|
||||||
useChatToolStore,
|
useChatToolStore,
|
||||||
type ChatToolAction,
|
type ChatToolAction,
|
||||||
} from "@/store/chatToolStore";
|
} from "@/store/chatToolStore";
|
||||||
|
import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types";
|
||||||
// WebKit Speech Recognition compatibility
|
import {
|
||||||
interface SpeechRecognitionEvent extends Event {
|
CHAT_STORAGE_KEY,
|
||||||
readonly resultIndex: number;
|
PRESET_PROMPTS,
|
||||||
readonly results: SpeechRecognitionResultList;
|
createId,
|
||||||
}
|
getInitialChatState,
|
||||||
|
normalizeThoughtTagToken,
|
||||||
interface SpeechRecognition extends EventTarget {
|
} from "./GlobalChatbox.utils";
|
||||||
lang: string;
|
import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts";
|
||||||
continuous: boolean;
|
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
const initialChatStateRef = useRef<PersistedChatState | null>(null);
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user