feat(chat): add selection-based speech playback
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,59 +612,49 @@ 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 }}
|
||||
>
|
||||
<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)}`,
|
||||
<Grow
|
||||
in={isHovered && !isStreaming}
|
||||
timeout={floatingActionTransitionTimeout}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
style={{ transformOrigin: "right bottom" }}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
...floatingActionSurfaceSx,
|
||||
position: "absolute",
|
||||
top: -14,
|
||||
right: 12,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
}}
|
||||
sx={floatingIconButtonSx}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
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) } }}
|
||||
>
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<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) } }}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => onCreateBranch(message.id)}
|
||||
sx={floatingIconButtonSx}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</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 }} />
|
||||
|
||||
Reference in New Issue
Block a user