重构 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";
|
||||
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -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