import type { ReactNode } from "react"; import type { AgentSpeakOptions, AgentSpeechState } from "../speech"; import { LoaderCircle, Pause, Play, Square, Volume2 } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/ui/cn"; import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME } from "./agent-chat-message-chrome"; import { AgentSpeechVoicePicker } from "./agent-speech-voice-picker"; import { findSpeechSelectionStartOffset, stripSpeechMarkdown } from "../speech"; import type { AgentCommandPanelPresentation } from "./agent-command-panel"; type SpeechSelection = { startOffset: number; left: number; top: number; }; export function AgentSpeechMessage({ presentation, messageId, content, disabled, isSupported, onSpeak, children }: { presentation: AgentCommandPanelPresentation; messageId: string; content: string; disabled: boolean; isSupported: boolean; onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void; children: ReactNode; }) { const contentRef = useRef(null); const lastSelectionRef = useRef(null); const [selection, setSelection] = useState(null); const speechText = useMemo(() => stripSpeechMarkdown(content), [content]); const selectionAnchor = selection ?? lastSelectionRef.current; if (selection) { lastSelectionRef.current = selection; } const captureSelection = useCallback(() => { if (!isSupported || disabled) { setSelection(null); return; } const browserSelection = window.getSelection(); const container = contentRef.current; if ( !browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container ) { setSelection(null); return; } const range = browserSelection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { setSelection(null); return; } const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString()); const rect = range.getBoundingClientRect(); if (startOffset === null || (!rect.width && !rect.height)) { setSelection(null); return; } setSelection({ startOffset, left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88), top: Math.max(rect.top - 10, 48) }); }, [disabled, isSupported, speechText]); useEffect(() => { setSelection(null); }, [messageId, speechText]); useEffect(() => { if (!selection) return; const close = () => setSelection(null); const closeCollapsedSelection = () => { if (window.getSelection()?.isCollapsed) close(); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; document.addEventListener("selectionchange", closeCollapsedSelection); document.addEventListener("keydown", closeOnEscape); window.addEventListener("resize", close); window.addEventListener("scroll", close, true); return () => { document.removeEventListener("selectionchange", closeCollapsedSelection); document.removeEventListener("keydown", closeOnEscape); window.removeEventListener("resize", close); window.removeEventListener("scroll", close, true); }; }, [selection]); const speakFromSelection = () => { if (!selection) return; void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset })); window.getSelection()?.removeAllRanges(); setSelection(null); }; return (
window.requestAnimationFrame(captureSelection)} onKeyUp={() => window.requestAnimationFrame(captureSelection)} > {children}
{selectionAnchor && typeof document !== "undefined" ? createPortal(
{selection ? ( event.preventDefault()} onClick={speakFromSelection} > 从这里开始朗读 ) : null}
, document.body ) : null}
); } export function AgentMessageSpeechControls({ speechState, onSpeak, onPause, onResume, onStop }: { speechState: AgentSpeechState; onSpeak: () => void; onPause: () => void; onResume: () => void; onStop: () => void; }) { return (
{speechState === "idle" ? ( ) : null} {speechState === "loading" ? ( <> ) : null} {speechState === "playing" ? ( <> ) : null} {speechState === "paused" ? ( <> ) : null}
); } function SpeechIconButton({ label, tone = "default", disabled = false, onClick, children }: { label: string; tone?: "default" | "primary" | "danger"; disabled?: boolean; onClick?: () => void; children: ReactNode; }) { return ( ); }