From d763876f86c3066c8f407f6e7f4d6fb04a7c3f86 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 3 Apr 2026 14:07:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20GlobalChatbox=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=8B=86=E5=88=86=E4=B8=BA=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.parts.tsx | 426 ++++++++++++++ src/components/chat/GlobalChatbox.tsx | 599 +------------------- src/components/chat/GlobalChatbox.types.ts | 18 + src/components/chat/GlobalChatbox.utils.ts | 54 ++ src/components/chat/GlobalChatbox.voice.ts | 158 ++++++ 5 files changed, 666 insertions(+), 589 deletions(-) create mode 100644 src/components/chat/GlobalChatbox.parts.tsx create mode 100644 src/components/chat/GlobalChatbox.types.ts create mode 100644 src/components/chat/GlobalChatbox.utils.ts create mode 100644 src/components/chat/GlobalChatbox.voice.ts 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 }; +}