diff --git a/src/components/chat/GlobalChatbox.parts.tsx b/src/components/chat/GlobalChatbox.parts.tsx
new file mode 100644
index 0000000..3cd086b
--- /dev/null
+++ b/src/components/chat/GlobalChatbox.parts.tsx
@@ -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 (
+
+ {[0, 1, 2].map((i) => (
+
+
+
+ ))}
+
+ );
+};
+
+export const Blob = ({
+ color,
+ size,
+ top,
+ left,
+ delay,
+}: {
+ color: string;
+ size: number;
+ top: string;
+ left: string;
+ delay: number;
+}) => (
+
+);
+
+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 }>;
+};
+
+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 (
+
+ {!isUser && (
+
+ {isErrorMessage ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {contentSegments.map((segment, segIdx) => {
+ if (segment.type === "text") {
+ const text = segment.content.trim();
+ if (!text && contentSegments.length > 1) return null;
+ return (
+
+
+ {text || "..."}
+
+
+ );
+ }
+ if (segment.type === "tool_call") {
+ if (segment.toolCall.tool === "chart") {
+ return (
+ )}
+ />
+ );
+ }
+ if (segment.toolCall.tool === "show_chart") {
+ const p = segment.toolCall.params;
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+ if (segment.type === "tool_call_pending") {
+ return (
+
+
+
+ 正在准备工具调用...
+
+
+ );
+ }
+ return null;
+ })}
+ {sseChartParams?.map((chart, idx) => (
+
+ ))}
+
+ {!isUser && !isErrorMessage && isTtsSupported && (
+
+ {messageSpeechState === "idle" && (
+ onSpeak(message.id, stripMarkdown(answerContent))}
+ aria-label="朗读消息"
+ sx={{
+ color: "text.secondary",
+ opacity: 0.6,
+ "&:hover": { opacity: 1 },
+ p: 0.5,
+ }}
+ >
+
+
+ )}
+ {messageSpeechState === "playing" && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {messageSpeechState === "paused" && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+
+ );
+ },
+);
+
+ChatMessageItem.displayName = "ChatMessageItem";
diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx
index c7e8d1f..90854e8 100644
--- a/src/components/chat/GlobalChatbox.tsx
+++ b/src/components/chat/GlobalChatbox.tsx
@@ -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 ? "" : "",
- );
-
-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 (
-
- {[0, 1, 2].map((i) => (
-
-
-
- ))}
-
- );
-};
-
-// Animated Background Blob
-const Blob = ({ color, size, top, left, delay }: { color: string; size: number; top: string; left: string; delay: number }) => (
-
-);
-
-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 }>;
-};
-
-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 (
-
- {!isUser && (
-
- {isErrorMessage ? (
-
- ) : (
-
- )}
-
- )}
-
-
-
- {contentSegments.map((segment, segIdx) => {
- if (segment.type === "text") {
- const text = segment.content.trim();
- if (!text && contentSegments.length > 1) return null;
- return (
-
-
- {text || "..."}
-
-
- );
- }
- if (segment.type === "tool_call") {
- if (segment.toolCall.tool === "chart") {
- return (
- )}
- />
- );
- }
- if (segment.toolCall.tool === "show_chart") {
- const p = segment.toolCall.params;
- return (
-
- );
- }
- return (
-
- );
- }
- if (segment.type === "tool_call_pending") {
- return (
-
-
-
- 正在准备工具调用...
-
-
- );
- }
- return null;
- })}
- {/* SSE-sourced inline charts (from show_chart tool_call events) */}
- {sseChartParams?.map((chart, idx) => (
-
- ))}
-
- {!isUser && !isErrorMessage && isTtsSupported && (
-
- {messageSpeechState === "idle" && (
- onSpeak(message.id, stripMarkdown(answerContent))}
- aria-label="朗读消息"
- sx={{ color: "text.secondary", opacity: 0.6, "&:hover": { opacity: 1 }, p: 0.5 }}
- >
-
-
- )}
- {messageSpeechState === "playing" && (
- <>
-
-
-
-
-
-
- >
- )}
- {messageSpeechState === "paused" && (
- <>
-
-
-
-
-
-
- >
- )}
-
- )}
-
-
- );
- },
-);
-ChatMessageItem.displayName = "ChatMessageItem";
-
-// --- Voice Hooks ---
-
-function useSpeechSynthesis() {
- const [speechState, setSpeechState] = useState("idle");
- const [speakingMessageId, setSpeakingMessageId] = useState(null);
- const utteranceRef = useRef(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(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 = ({ open, onClose }) => {
const initialChatStateRef = useRef(null);
diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts
new file mode 100644
index 0000000..d7546ce
--- /dev/null
+++ b/src/components/chat/GlobalChatbox.types.ts
@@ -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;
+};
diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts
new file mode 100644
index 0000000..0bab75b
--- /dev/null
+++ b/src/components/chat/GlobalChatbox.utils.ts
@@ -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 ? "" : "",
+ );
+
+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 };
+ }
+};
diff --git a/src/components/chat/GlobalChatbox.voice.ts b/src/components/chat/GlobalChatbox.voice.ts
new file mode 100644
index 0000000..cce50d9
--- /dev/null
+++ b/src/components/chat/GlobalChatbox.voice.ts
@@ -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("idle");
+ const [speakingMessageId, setSpeakingMessageId] = useState(null);
+ const utteranceRef = useRef(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(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 };
+}