feat(chat): add Edge TTS playback
Build Push and Deploy / docker-image (push) Failing after 1m17s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s

This commit is contained in:
2026-07-08 18:39:49 +08:00
parent 0dea655f68
commit cf6386d209
10 changed files with 609 additions and 489 deletions
+74 -4
View File
@@ -6,6 +6,7 @@ import { AnimatePresence, motion } from "framer-motion";
import {
Avatar,
Box,
CircularProgress,
IconButton,
Paper,
Stack,
@@ -22,8 +23,12 @@ import {
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import type {
Message,
SpeechState,
} from "./GlobalChatbox.types";
import { stripMarkdown } from "./globalChatboxUtils";
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
@@ -40,7 +45,11 @@ type AgentTurnProps = {
message: Message;
isStreaming: boolean;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onSpeak: (
messageId: string,
text: string,
options?: { startOffset?: number },
) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
@@ -170,6 +179,11 @@ export const AgentTurn = React.memo(
const isErrorMessage = Boolean(message.isError);
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 isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed",
) ?? false;
@@ -185,6 +199,43 @@ export const AgentTurn = React.memo(
[isErrorMessage, isUser, message.content],
);
const answerContent = parsedAssistantSections?.answer ?? message.content;
const speechText = useMemo(
() => stripMarkdown(answerContent),
[answerContent],
);
const handleCaptureSpeechSelection = React.useCallback(() => {
const selection = window.getSelection();
const container = answerContentRef.current;
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
return;
}
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
return;
}
const selectedText = selection.toString();
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
if (startOffset === null) {
setSelectedSpeechStart(null);
return;
}
const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24);
setSelectedSpeechStart({
offset: startOffset,
preview: preview.length === 24 ? `${preview}...` : preview,
});
}, [speechText]);
React.useEffect(() => {
setSelectedSpeechStart(null);
}, [message.id, speechText]);
const handleSpeakFromCurrentStart = () => {
onSpeak(message.id, speechText, {
startOffset: selectedSpeechStart?.offset ?? 0,
});
};
const contentSegments: ContentSegment[] = useMemo(
() =>
!isUser && !isErrorMessage
@@ -333,6 +384,10 @@ export const AgentTurn = React.memo(
) : null}
<Box
ref={answerContentRef}
onMouseUp={handleCaptureSpeechSelection}
onKeyUp={handleCaptureSpeechSelection}
onTouchEnd={handleCaptureSpeechSelection}
sx={{
p: 1.5,
borderRadius: 4,
@@ -487,13 +542,28 @@ export const AgentTurn = React.memo(
{messageSpeechState === "idle" ? (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
onClick={handleSpeakFromCurrentStart}
aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"}
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{messageSpeechState === "loading" ? (
<>
<IconButton
size="small"
disabled
aria-label="正在生成语音"
sx={{ color: "primary.main", p: 0.5 }}
>
<CircularProgress size={16} thickness={5} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "playing" ? (
<>
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>