feat(chat): add selection-based speech playback

This commit is contained in:
2026-07-10 14:29:27 +08:00
parent 14c76231d5
commit adb53d9a13
2 changed files with 311 additions and 73 deletions
+115
View File
@@ -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<HTMLImageElement>) => (
<img {...props} alt={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<HTMLDivElement> & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
span: ({
children,
animate: _animate,
transition: _transition,
...props
}: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => (
<span {...props}>{children}</span>
),
},
}));
jest.mock("./AgentMarkdownBlock", () => ({
MarkdownBlock: ({ children }: { children: string }) => (
<div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div>
),
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(
<AgentTurn
message={{ id: "assistant-1", role: "assistant", content }}
isStreaming={false}
messageSpeechState="idle"
onSpeak={onSpeak}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
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();
});
});
});
+170 -47
View File
@@ -2,13 +2,16 @@
import Image from "next/image"; import Image from "next/image";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
Avatar, Avatar,
Box, Box,
CircularProgress, CircularProgress,
Button,
Grow,
IconButton, IconButton,
Paper, Paper,
Popper,
Stack, Stack,
Tooltip, Tooltip,
Typography, Typography,
@@ -41,6 +44,48 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded"; 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 = { type AgentTurnProps = {
message: Message; message: Message;
isStreaming: boolean; isStreaming: boolean;
@@ -180,10 +225,7 @@ export const AgentTurn = React.memo(
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
const [isHovered, setIsHovered] = React.useState(false); const [isHovered, setIsHovered] = React.useState(false);
const answerContentRef = React.useRef<HTMLDivElement | null>(null); const answerContentRef = React.useRef<HTMLDivElement | null>(null);
const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{ const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
offset: number;
preview: string;
} | null>(null);
const isProgressComplete = message.progress?.some( const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed", (item) => item.phase === "complete" && item.status === "completed",
) ?? false; ) ?? false;
@@ -203,39 +245,94 @@ export const AgentTurn = React.memo(
() => stripMarkdown(answerContent), () => stripMarkdown(answerContent),
[answerContent], [answerContent],
); );
const handleCaptureSpeechSelection = React.useCallback(() => { const captureSpeechSelection = React.useCallback(() => {
if (!isTtsSupported || isStreamingAssistant) {
setSpeechSelection(null);
return;
}
const selection = window.getSelection(); const selection = window.getSelection();
const container = answerContentRef.current; const container = answerContentRef.current;
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) { if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
setSpeechSelection(null);
return; return;
} }
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) { if (!container.contains(range.commonAncestorContainer)) {
setSpeechSelection(null);
return; return;
} }
const selectedText = selection.toString(); const selectedText = selection.toString();
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText); const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
if (startOffset === null) { if (startOffset === null) {
setSelectedSpeechStart(null); setSpeechSelection(null);
return; return;
} }
const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24); const anchorRect = range.getBoundingClientRect();
setSelectedSpeechStart({ if (anchorRect.width === 0 && anchorRect.height === 0) {
offset: startOffset, setSpeechSelection(null);
preview: preview.length === 24 ? `${preview}...` : preview, return;
}); }
}, [speechText]);
setSpeechSelection({ startOffset, anchorRect });
}, [isStreamingAssistant, isTtsSupported, speechText]);
const handleCaptureSpeechSelection = React.useCallback(() => {
window.requestAnimationFrame(captureSpeechSelection);
}, [captureSpeechSelection]);
React.useEffect(() => { React.useEffect(() => {
setSelectedSpeechStart(null); setSpeechSelection(null);
}, [message.id, speechText]); }, [message.id, speechText]);
const handleSpeakFromCurrentStart = () => { React.useEffect(() => {
onSpeak(message.id, speechText, { if (!speechSelection) return;
startOffset: selectedSpeechStart?.offset ?? 0,
}); 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( const contentSegments: ContentSegment[] = useMemo(
() => () =>
!isUser && !isErrorMessage !isUser && !isErrorMessage
@@ -385,9 +482,8 @@ export const AgentTurn = React.memo(
<Box <Box
ref={answerContentRef} ref={answerContentRef}
onMouseUp={handleCaptureSpeechSelection} onPointerUp={handleCaptureSpeechSelection}
onKeyUp={handleCaptureSpeechSelection} onKeyUp={handleCaptureSpeechSelection}
onTouchEnd={handleCaptureSpeechSelection}
sx={{ sx={{
p: 1.5, p: 1.5,
borderRadius: 4, borderRadius: 4,
@@ -457,6 +553,43 @@ export const AgentTurn = React.memo(
</Stack> </Stack>
</Box> </Box>
<Popper
open={isSpeechSelectionOpen}
anchorEl={speechSelectionAnchor}
placement="top"
transition
modifiers={[
{ name: "offset", options: { offset: [0, 8] } },
{ name: "flip", enabled: true },
{ name: "preventOverflow", options: { padding: 8 } },
]}
sx={{ zIndex: theme.zIndex.tooltip }}
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
timeout={floatingActionTransitionTimeout}
style={{
transformOrigin: placement.startsWith("bottom")
? "center top"
: "center bottom",
}}
>
<Paper elevation={4} sx={floatingActionSurfaceSx}>
<Button
size="small"
startIcon={<VolumeUpRounded sx={{ fontSize: 16 }} />}
onMouseDown={(event) => event.preventDefault()}
onClick={handleSpeakFromSelection}
sx={floatingSpeechButtonSx}
>
</Button>
</Paper>
</Grow>
)}
</Popper>
{visibleChartArtifacts.map((artifact) => ( {visibleChartArtifacts.map((artifact) => (
<ChatInlineChart <ChatInlineChart
key={artifact.id} key={artifact.id}
@@ -479,26 +612,21 @@ export const AgentTurn = React.memo(
))} ))}
</Stack> </Stack>
<AnimatePresence> <Grow
{isHovered && !isStreaming && ( in={isHovered && !isStreaming}
<motion.div timeout={floatingActionTransitionTimeout}
initial={{ opacity: 0, scale: 0.9, y: 5 }} mountOnEnter
animate={{ opacity: 1, scale: 1, y: 0 }} unmountOnExit
exit={{ opacity: 0, scale: 0.9, y: 5 }} style={{ transformOrigin: "right bottom" }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
> >
<Paper <Paper
elevation={4} elevation={4}
sx={{ sx={{
display: "flex", ...floatingActionSurfaceSx,
gap: 0.5, position: "absolute",
p: 0.5, top: -14,
borderRadius: "16px", right: 12,
bgcolor: alpha("#fff", 0.8), zIndex: 10,
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}} }}
> >
<Tooltip title="复制"> <Tooltip title="复制">
@@ -509,9 +637,8 @@ export const AgentTurn = React.memo(
navigator.clipboard.writeText( navigator.clipboard.writeText(
normalizeClipboardText(message.content), normalizeClipboardText(message.content),
); );
// Could add a toast here
}} }}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} sx={floatingIconButtonSx}
> >
<ContentCopyRounded sx={{ fontSize: 16 }} /> <ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
@@ -520,18 +647,14 @@ export const AgentTurn = React.memo(
<IconButton <IconButton
size="small" size="small"
aria-label="拆分为新会话" aria-label="拆分为新会话"
onClick={() => { onClick={() => onCreateBranch(message.id)}
onCreateBranch(message.id); sx={floatingIconButtonSx}
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
> >
<TbArrowsSplit2 size={16} /> <TbArrowsSplit2 size={16} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Paper> </Paper>
</motion.div> </Grow>
)}
</AnimatePresence>
</Paper> </Paper>
</Stack> </Stack>
@@ -542,8 +665,8 @@ export const AgentTurn = React.memo(
{messageSpeechState === "idle" ? ( {messageSpeechState === "idle" ? (
<IconButton <IconButton
size="small" size="small"
onClick={handleSpeakFromCurrentStart} onClick={handleSpeakMessage}
aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"} aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
> >
<VolumeUpRounded sx={{ fontSize: 16 }} /> <VolumeUpRounded sx={{ fontSize: 16 }} />