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 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 }} />
|
||||||
|
|||||||
Reference in New Issue
Block a user