From adb53d9a13e8a41e9b01b0d86cb44be80ceac886 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 10 Jul 2026 14:29:27 +0800 Subject: [PATCH] feat(chat): add selection-based speech playback --- src/components/chat/AgentTurn.test.tsx | 115 +++++++++++ src/components/chat/AgentTurn.tsx | 269 ++++++++++++++++++------- 2 files changed, 311 insertions(+), 73 deletions(-) create mode 100644 src/components/chat/AgentTurn.test.tsx diff --git a/src/components/chat/AgentTurn.test.tsx b/src/components/chat/AgentTurn.test.tsx new file mode 100644 index 0000000..d84cfc2 --- /dev/null +++ b/src/components/chat/AgentTurn.test.tsx @@ -0,0 +1,115 @@ +/* eslint-disable @next/next/no-img-element */ +import "@testing-library/jest-dom"; +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { AgentTurn } from "./AgentTurn"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes) => ( + {props.alt + ), +})); + +jest.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + motion: { + div: ({ + children, + animate: _animate, + exit: _exit, + initial: _initial, + transition: _transition, + ...props + }: React.HTMLAttributes & Record) => ( +
{children}
+ ), + span: ({ + children, + animate: _animate, + transition: _transition, + ...props + }: React.HTMLAttributes & Record) => ( + {children} + ), + }, +})); + +jest.mock("./AgentMarkdownBlock", () => ({ + MarkdownBlock: ({ children }: { children: string }) => ( +
{children.split(/\n+/u).map((line) =>

{line}

)}
+ ), + normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""), +})); + +describe("AgentTurn speech selection", () => { + it("shows a floating action and reads from the selected text", async () => { + const content = "第一段内容。\n\n第二段内容。"; + const speechText = "第一段内容。\n第二段内容。"; + const onSpeak = jest.fn(); + const removeAllRanges = jest.fn(); + + render( + , + ); + + const selectedParagraph = screen.getByText("第二段内容。"); + const selectedTextNode = selectedParagraph.firstChild as Text; + const range = { + commonAncestorContainer: selectedTextNode, + getBoundingClientRect: () => ({ + width: 72, + height: 20, + top: 120, + right: 172, + bottom: 140, + left: 100, + x: 100, + y: 120, + toJSON: () => ({}), + }), + } as unknown as Range; + const selection = { + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => range, + toString: () => "第二段", + removeAllRanges, + } as unknown as Selection; + + jest.spyOn(window, "getSelection").mockReturnValue(selection); + jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 1; + }); + + fireEvent.pointerUp(selectedParagraph); + + const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" }); + fireEvent.click(speechAction); + + await waitFor(() => { + expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, { + startOffset: speechText.indexOf("第二段"), + }); + }); + expect(removeAllRanges).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 0e0965e..c4c78d4 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -2,13 +2,16 @@ import Image from "next/image"; import React, { useMemo } from "react"; -import { AnimatePresence, motion } from "framer-motion"; +import { motion } from "framer-motion"; import { Avatar, Box, CircularProgress, + Button, + Grow, IconButton, Paper, + Popper, Stack, Tooltip, Typography, @@ -41,6 +44,48 @@ import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import StopRounded from "@mui/icons-material/StopRounded"; +const floatingActionSurfaceSx = { + display: "flex", + gap: 0.5, + p: 0.5, + borderRadius: "16px", + bgcolor: alpha("#fff", 0.8), + backdropFilter: "blur(16px)", + border: `1px solid ${alpha("#fff", 0.9)}`, + boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`, + overflow: "hidden", +} as const; + +const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const; +const floatingIconButtonSx = { + width: 28, + height: 28, + color: "text.secondary", + "&:hover": { + color: "#00acc1", + bgcolor: alpha("#00acc1", 0.1), + }, +} as const; +const floatingSpeechButtonSx = { + minHeight: 34, + px: 1.25, + color: "text.primary", + fontSize: 13, + fontWeight: 700, + letterSpacing: 0, + whiteSpace: "nowrap", + borderRadius: "12px", + "&:hover": { + bgcolor: alpha("#00acc1", 0.1), + color: "#00acc1", + }, +} as const; + +type SpeechSelection = { + startOffset: number; + anchorRect: DOMRect; +}; + type AgentTurnProps = { message: Message; isStreaming: boolean; @@ -180,10 +225,7 @@ export const AgentTurn = React.memo( const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const [isHovered, setIsHovered] = React.useState(false); const answerContentRef = React.useRef(null); - const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{ - offset: number; - preview: string; - } | null>(null); + const [speechSelection, setSpeechSelection] = React.useState(null); const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", ) ?? false; @@ -203,39 +245,94 @@ export const AgentTurn = React.memo( () => stripMarkdown(answerContent), [answerContent], ); - const handleCaptureSpeechSelection = React.useCallback(() => { + const captureSpeechSelection = React.useCallback(() => { + if (!isTtsSupported || isStreamingAssistant) { + setSpeechSelection(null); + return; + } + const selection = window.getSelection(); const container = answerContentRef.current; if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) { + setSpeechSelection(null); return; } const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { + setSpeechSelection(null); return; } const selectedText = selection.toString(); const startOffset = findSpeechSelectionStartOffset(speechText, selectedText); if (startOffset === null) { - setSelectedSpeechStart(null); + setSpeechSelection(null); return; } - const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24); - setSelectedSpeechStart({ - offset: startOffset, - preview: preview.length === 24 ? `${preview}...` : preview, - }); - }, [speechText]); + const anchorRect = range.getBoundingClientRect(); + if (anchorRect.width === 0 && anchorRect.height === 0) { + setSpeechSelection(null); + return; + } + + setSpeechSelection({ startOffset, anchorRect }); + }, [isStreamingAssistant, isTtsSupported, speechText]); + const handleCaptureSpeechSelection = React.useCallback(() => { + window.requestAnimationFrame(captureSpeechSelection); + }, [captureSpeechSelection]); React.useEffect(() => { - setSelectedSpeechStart(null); + setSpeechSelection(null); }, [message.id, speechText]); - const handleSpeakFromCurrentStart = () => { - onSpeak(message.id, speechText, { - startOffset: selectedSpeechStart?.offset ?? 0, - }); + React.useEffect(() => { + if (!speechSelection) return; + + const closeSpeechSelection = () => setSpeechSelection(null); + const handleSelectionChange = () => { + if (window.getSelection()?.isCollapsed) { + closeSpeechSelection(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeSpeechSelection(); + } + }; + + document.addEventListener("selectionchange", handleSelectionChange); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("resize", closeSpeechSelection); + window.addEventListener("scroll", closeSpeechSelection, true); + + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("resize", closeSpeechSelection); + window.removeEventListener("scroll", closeSpeechSelection, true); + }; + }, [speechSelection]); + const handleSpeakMessage = () => { + onSpeak(message.id, speechText); }; + const handleSpeakFromSelection = () => { + if (!speechSelection) return; + + onSpeak(message.id, speechText, { + startOffset: speechSelection.startOffset, + }); + window.getSelection()?.removeAllRanges(); + setSpeechSelection(null); + }; + const speechSelectionAnchor = React.useMemo( + () => speechSelection + ? { + getBoundingClientRect: () => speechSelection.anchorRect, + } + : null, + [speechSelection], + ); + const isSpeechSelectionOpen = Boolean(speechSelection); const contentSegments: ContentSegment[] = useMemo( () => !isUser && !isErrorMessage @@ -385,9 +482,8 @@ export const AgentTurn = React.memo( + + {({ TransitionProps, placement }) => ( + + + + + + )} + + {visibleChartArtifacts.map((artifact) => ( - - {isHovered && !isStreaming && ( - - + + + { + navigator.clipboard.writeText( + normalizeClipboardText(message.content), + ); }} + sx={floatingIconButtonSx} > - - { - navigator.clipboard.writeText( - normalizeClipboardText(message.content), - ); - // Could add a toast here - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - - - - - { - onCreateBranch(message.id); - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - - - - - - )} - + + + + + onCreateBranch(message.id)} + sx={floatingIconButtonSx} + > + + + + + @@ -542,8 +665,8 @@ export const AgentTurn = React.memo( {messageSpeechState === "idle" ? (