mirror of
https://git.mirrors.martin98.com/https://github.com/bytedance/deer-flow
synced 2025-08-18 03:35:53 +08:00
feat: enable podcast
This commit is contained in:
parent
4d33aeed6a
commit
2f06f0c433
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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 {
|
||||||
|
19
web/src/core/api/podcast.ts
Normal file
19
web/src/core/api/podcast.ts
Normal 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;
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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?",
|
|
||||||
// );
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user