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 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<HTMLDivElement | null>(null);
const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{
offset: number;
preview: string;
} | null>(null);
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(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(
<Box
ref={answerContentRef}
onMouseUp={handleCaptureSpeechSelection}
onPointerUp={handleCaptureSpeechSelection}
onKeyUp={handleCaptureSpeechSelection}
onTouchEnd={handleCaptureSpeechSelection}
sx={{
p: 1.5,
borderRadius: 4,
@@ -457,6 +553,43 @@ export const AgentTurn = React.memo(
</Stack>
</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) => (
<ChatInlineChart
key={artifact.id}
@@ -479,26 +612,21 @@ export const AgentTurn = React.memo(
))}
</Stack>
<AnimatePresence>
{isHovered && !isStreaming && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 5 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
<Grow
in={isHovered && !isStreaming}
timeout={floatingActionTransitionTimeout}
mountOnEnter
unmountOnExit
style={{ transformOrigin: "right bottom" }}
>
<Paper
elevation={4}
sx={{
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)}`,
...floatingActionSurfaceSx,
position: "absolute",
top: -14,
right: 12,
zIndex: 10,
}}
>
<Tooltip title="复制">
@@ -509,9 +637,8 @@ export const AgentTurn = React.memo(
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) } }}
sx={floatingIconButtonSx}
>
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
@@ -520,18 +647,14 @@ export const AgentTurn = React.memo(
<IconButton
size="small"
aria-label="拆分为新会话"
onClick={() => {
onCreateBranch(message.id);
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
onClick={() => onCreateBranch(message.id)}
sx={floatingIconButtonSx}
>
<TbArrowsSplit2 size={16} />
</IconButton>
</Tooltip>
</Paper>
</motion.div>
)}
</AnimatePresence>
</Grow>
</Paper>
</Stack>
@@ -542,8 +665,8 @@ export const AgentTurn = React.memo(
{messageSpeechState === "idle" ? (
<IconButton
size="small"
onClick={handleSpeakFromCurrentStart}
aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"}
onClick={handleSpeakMessage}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />