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 // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import {
DownloadOutlined,
SoundOutlined,
LoadingOutlined,
} from "@ant-design/icons";
import { parse } from "best-effort-json-parser"; import { parse } from "best-effort-json-parser";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
@ -13,6 +18,11 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import type { Message, Option } from "~/core/messages"; import type { Message, Option } from "~/core/messages";
import { import {
openResearch, openResearch,
@ -114,6 +124,7 @@ function MessageListItem({
message.role === "user" || message.role === "user" ||
message.agent === "coordinator" || message.agent === "coordinator" ||
message.agent === "planner" || message.agent === "planner" ||
message.agent === "podcast" ||
startOfResearch startOfResearch
) { ) {
let content: React.ReactNode; let content: React.ReactNode;
@ -128,6 +139,12 @@ function MessageListItem({
/> />
</div> </div>
); );
} else if (message.agent === "podcast") {
content = (
<div className="w-full px-4">
<PodcastCard message={message} />
</div>
);
} else if (startOfResearch) { } else if (startOfResearch) {
content = ( content = (
<div className="w-full px-4"> <div className="w-full px-4">
@ -348,3 +365,63 @@ function PlanCard({
</Card> </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> </div>
<TabsContent className="h-full min-h-0 flex-grow px-8" value="report"> <TabsContent className="h-full min-h-0 flex-grow px-8" value="report">
<ScrollContainer className="px-5pb-20 h-full"> <ScrollContainer className="px-5pb-20 h-full">
{reportId && ( {reportId && researchId && (
<ResearchReportBlock className="mt-4" messageId={reportId} /> <ResearchReportBlock
className="mt-4"
researchId={researchId}
messageId={reportId}
/>
)} )}
</ScrollContainer> </ScrollContainer>
</TabsContent> </TabsContent>

View File

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

View File

@ -64,9 +64,9 @@ async function* chatStreamMock(
const [, event] = eventRaw.split("event: ", 2) as [string, string]; const [, event] = eventRaw.split("event: ", 2) as [string, string];
const [, data] = dataRaw.split("data: ", 2) as [string, string]; const [, data] = dataRaw.split("data: ", 2) as [string, string];
if (event === "message_chunk") { if (event === "message_chunk") {
await sleep(100); await sleep(0);
} else if (event === "tool_call_result") { } else if (event === "tool_call_result") {
await sleep(2000); await sleep(0);
} }
try { try {
yield { yield {

View File

@ -0,0 +1,19 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export async function generatePodcast(content: string) {
const response = await fetch("/api/podcast/generate", {
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: "audio/mp3" });
const audioUrl = URL.createObjectURL(blob);
return audioUrl;
}

View File

@ -6,7 +6,13 @@ export type MessageRole = "user" | "assistant" | "tool";
export interface Message { export interface Message {
id: string; id: string;
threadId: string; threadId: string;
agent?: "coordinator" | "planner" | "researcher" | "coder" | "reporter"; agent?:
| "coordinator"
| "planner"
| "researcher"
| "coder"
| "reporter"
| "podcast";
role: MessageRole; role: MessageRole;
isStreaming?: boolean; isStreaming?: boolean;
content: string; content: string;

View File

@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
import { create } from "zustand"; import { create } from "zustand";
import { chatStream } from "../api"; import { chatStream } from "../api";
import { generatePodcast } from "../api/podcast";
import type { Message } from "../messages"; import type { Message } from "../messages";
import { mergeMessage } from "../messages"; import { mergeMessage } from "../messages";
@ -88,7 +89,7 @@ export async function sendMessage(
}; };
appendMessage(message); appendMessage(message);
} }
message ??= findMessage(messageId); message ??= getMessage(messageId);
if (message) { if (message) {
message = mergeMessage(message, event); message = mergeMessage(message, event);
updateMessage(message); updateMessage(message);
@ -107,7 +108,7 @@ function existsMessage(id: string) {
return useStore.getState().messageIds.includes(id); return useStore.getState().messageIds.includes(id);
} }
function findMessage(id: string) { function getMessage(id: string) {
return useStore.getState().messages.get(id); return useStore.getState().messages.get(id);
} }
@ -175,7 +176,7 @@ function appendResearch(researchId: string) {
let planMessage: Message | undefined; let planMessage: Message | undefined;
const reversedMessageIds = [...useStore.getState().messageIds].reverse(); const reversedMessageIds = [...useStore.getState().messageIds].reverse();
for (const messageId of reversedMessageIds) { for (const messageId of reversedMessageIds) {
const message = findMessage(messageId); const message = getMessage(messageId);
if (message?.agent === "planner") { if (message?.agent === "planner") {
planMessage = message; planMessage = message;
break; break;
@ -224,6 +225,46 @@ export function openResearch(researchId: string | null) {
}); });
} }
export async function listenToPodcast(researchId: string) {
const planMessageId = useStore.getState().researchPlanIds.get(researchId);
const reportMessageId = useStore.getState().researchReportIds.get(researchId);
if (planMessageId && reportMessageId) {
const planMessage = getMessage(planMessageId)!;
const title = (JSON.parse(planMessage.content) as { title: string }).title;
const reportMessage = getMessage(reportMessageId);
if (reportMessage?.content) {
appendMessage({
id: nanoid(),
threadId: THREAD_ID,
role: "user",
content: "Please generate a podcast for the above research.",
contentChunks: [],
});
const podCastMessageId = nanoid();
const podcastObject = { title, researchId };
const podcastMessage: Message = {
id: podCastMessageId,
threadId: THREAD_ID,
role: "assistant",
agent: "podcast",
content: JSON.stringify(podcastObject),
contentChunks: [],
isStreaming: true,
};
appendMessage(podcastMessage);
// Generating podcast...
const audioUrl = await generatePodcast(reportMessage.content);
useStore.setState((state) => ({
messages: new Map(useStore.getState().messages).set(podCastMessageId, {
...state.messages.get(podCastMessageId)!,
content: JSON.stringify({ ...podcastObject, audioUrl }),
isStreaming: false,
}),
}));
}
}
}
export function useResearchTitle(researchId: string) { export function useResearchTitle(researchId: string) {
const planMessage = useMessage( const planMessage = useMessage(
useStore.getState().researchPlanIds.get(researchId), useStore.getState().researchPlanIds.get(researchId),
@ -236,7 +277,3 @@ export function useMessage(messageId: string | null | undefined) {
messageId ? state.messages.get(messageId) : undefined, messageId ? state.messages.get(messageId) : undefined,
); );
} }
// void sendMessage(
// "How many times taller is the Eiffel Tower than the tallest building in the world?",
// );