feat: enable podcast

This commit is contained in:
Li Xin
2025-04-19 22:11:57 +08:00
parent 4d33aeed6a
commit 2f06f0c433
7 changed files with 172 additions and 33 deletions

View File

@@ -1,9 +1,14 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import {
DownloadOutlined,
SoundOutlined,
LoadingOutlined,
} from "@ant-design/icons";
import { parse } from "best-effort-json-parser";
import { motion } from "framer-motion";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { Button } from "~/components/ui/button";
import {
@@ -13,6 +18,11 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import type { Message, Option } from "~/core/messages";
import {
openResearch,
@@ -114,6 +124,7 @@ function MessageListItem({
message.role === "user" ||
message.agent === "coordinator" ||
message.agent === "planner" ||
message.agent === "podcast" ||
startOfResearch
) {
let content: React.ReactNode;
@@ -128,6 +139,12 @@ function MessageListItem({
/>
</div>
);
} else if (message.agent === "podcast") {
content = (
<div className="w-full px-4">
<PodcastCard message={message} />
</div>
);
} else if (startOfResearch) {
content = (
<div className="w-full px-4">
@@ -348,3 +365,63 @@ function PlanCard({
</Card>
);
}
function PodcastCard({
className,
message,
}: {
className?: string;
message: Message;
}) {
const data = useMemo(() => {
return parse(message.content ?? "");
}, [message.content]);
const title = useMemo(() => data?.title, [data]);
const audioUrl = useMemo(() => data?.audioUrl, [data]);
const isGenerating = useMemo(() => {
return message.isStreaming;
}, [message.isStreaming]);
const [isPlaying, setIsPlaying] = useState(false);
return (
<Card className={cn("w-[508px] bg-white", className)}>
<CardHeader>
<div className="text-muted-foreground flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{isGenerating ? <LoadingOutlined /> : <SoundOutlined />}
<RainbowText animated={isGenerating}>
{isGenerating
? "Generating podcast..."
: isPlaying
? "Now playing podcast..."
: "Podcast"}
</RainbowText>
</div>
<div className="flex">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<DownloadOutlined />
</Button>
</TooltipTrigger>
<TooltipContent>Download podcast</TooltipContent>
</Tooltip>
</div>
</div>
<CardTitle>
<div className="text-lg font-medium">
<RainbowText animated={isGenerating}>{title}</RainbowText>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<audio
className="w-full"
src={audioUrl}
controls
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
</CardContent>
</Card>
);
}

View File

@@ -80,8 +80,12 @@ export function ResearchBlock({
</div>
<TabsContent className="h-full min-h-0 flex-grow px-8" value="report">
<ScrollContainer className="px-5pb-20 h-full">
{reportId && (
<ResearchReportBlock className="mt-4" messageId={reportId} />
{reportId && researchId && (
<ResearchReportBlock
className="mt-4"
researchId={researchId}
messageId={reportId}
/>
)}
</ScrollContainer>
</TabsContent>

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { PauseCircleOutlined, SoundOutlined } from "@ant-design/icons";
import { SoundOutlined } from "@ant-design/icons";
import { useCallback, useRef, useState } from "react";
import { Button } from "~/components/ui/button";
@@ -10,7 +10,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "~/components/ui/tooltip";
import { useMessage } from "~/core/store";
import { listenToPodcast, useMessage } from "~/core/store";
import { cn } from "~/lib/utils";
import { LoadingAnimation } from "./loading-animation";
@@ -18,29 +18,23 @@ import { Markdown } from "./markdown";
export function ResearchReportBlock({
className,
researchId,
messageId,
}: {
className?: string;
researchId: string;
messageId: string;
}) {
const message = useMessage(messageId);
const contentRef = useRef<HTMLDivElement>(null);
const [isTTS, setIsTTS] = useState(false);
const handleTTS = useCallback(() => {
if (contentRef.current) {
if (isTTS) {
window.speechSynthesis.cancel();
setIsTTS(false);
} else {
const text = contentRef.current.textContent;
if (text) {
const utterance = new SpeechSynthesisUtterance(text);
setIsTTS(true);
window.speechSynthesis.speak(utterance);
}
}
const [isGenerated, setGenerated] = useState(false);
const handleListenToReport = useCallback(async () => {
if (isGenerated) {
return;
}
}, [isTTS]);
setGenerated(true);
await listenToPodcast(researchId);
}, [isGenerated, researchId]);
return (
<div
ref={contentRef}
@@ -54,14 +48,16 @@ export function ResearchReportBlock({
variant="outline"
size="icon"
onClick={() => {
handleTTS();
void handleListenToReport();
}}
>
{isTTS ? <PauseCircleOutlined /> : <SoundOutlined />}
<SoundOutlined />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isTTS ? "Pause" : "Listen to the report"}</p>
<p>
{isGenerated ? "The podcast is generated" : "Generate podcast"}
</p>
</TooltipContent>
</Tooltip>
)}