refactor: extract components folder

This commit is contained in:
Li Xin
2025-05-02 10:43:14 +08:00
parent 18d896d15d
commit fdfc607747
44 changed files with 44 additions and 44 deletions

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { cn } from "~/lib/utils";
import { Welcome } from "./welcome";
const questions = [
"How many times taller is the Eiffel Tower than the tallest building in the world?",
"How many years does an average Tesla battery last compared to a gasoline engine?",
"How many liters of water are required to produce 1 kg of beef?",
"How many times faster is the speed of light compared to the speed of sound?",
];
export function ConversationStarter({
className,
onSend,
}: {
className?: string;
onSend?: (message: string) => void;
}) {
return (
<div className={cn("flex flex-col items-center", className)}>
<div className="pointer-events-none fixed inset-0 flex items-center justify-center">
<Welcome className="pointer-events-auto mb-15 w-[75%] -translate-y-24" />
</div>
<ul className="flex flex-wrap">
{questions.map((question, index) => (
<motion.li
key={question}
className="flex w-1/2 shrink-0 p-2 active:scale-105"
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.2,
delay: index * 0.1 + 0.5,
ease: "easeOut",
}}
>
<div
className="bg-card text-muted-foreground cursor-pointer rounded-2xl border px-4 py-4 opacity-75 transition-all duration-300 hover:opacity-100 hover:shadow-md"
onClick={() => {
onSend?.(question);
}}
>
{question}
</div>
</motion.li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,199 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { AnimatePresence, motion } from "framer-motion";
import { ArrowUp, X } from "lucide-react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Detective } from "~/components/deer-flow/icons/detective";
import { Tooltip } from "~/components/deer-flow/tooltip";
import { Button } from "~/components/ui/button";
import type { Option } from "~/core/messages";
import {
setEnableBackgroundInvestigation,
useSettingsStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
export function InputBox({
className,
size,
responding,
feedback,
onSend,
onCancel,
onRemoveFeedback,
}: {
className?: string;
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
onSend?: (message: string, options?: { interruptFeedback?: string }) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
const [message, setMessage] = useState("");
const [imeStatus, setImeStatus] = useState<"active" | "inactive">("inactive");
const [indent, setIndent] = useState(0);
const backgroundInvestigation = useSettingsStore(
(state) => state.general.enableBackgroundInvestigation,
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const feedbackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (feedback) {
setMessage("");
setTimeout(() => {
if (feedbackRef.current) {
setIndent(feedbackRef.current.offsetWidth);
}
}, 200);
}
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}, [feedback]);
const handleSendMessage = useCallback(() => {
if (responding) {
onCancel?.();
} else {
if (message.trim() === "") {
return;
}
if (onSend) {
onSend(message, {
interruptFeedback: feedback?.option.value,
});
setMessage("");
onRemoveFeedback?.();
}
}
}, [responding, onCancel, message, onSend, feedback, onRemoveFeedback]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (responding) {
return;
}
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
imeStatus === "inactive"
) {
event.preventDefault();
handleSendMessage();
}
},
[responding, imeStatus, handleSendMessage],
);
return (
<div className={cn("bg-card relative rounded-[24px] border", className)}>
<div className="w-full">
<AnimatePresence>
{feedback && (
<motion.div
ref={feedbackRef}
className="bg-background border-brand absolute top-0 left-0 mt-3 ml-2 flex items-center justify-center gap-1 rounded-2xl border px-2 py-0.5"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="text-brand flex h-full w-full items-center justify-center text-sm opacity-90">
{feedback.option.text}
</div>
<X
className="cursor-pointer opacity-60"
size={16}
onClick={onRemoveFeedback}
/>
</motion.div>
)}
</AnimatePresence>
<textarea
ref={textareaRef}
className={cn(
"m-0 w-full resize-none border-none px-4 py-3 text-lg",
size === "large" ? "min-h-32" : "min-h-4",
)}
style={{ textIndent: feedback ? `${indent}px` : 0 }}
placeholder={
feedback
? `Describe how you ${feedback.option.text.toLocaleLowerCase()}?`
: "What can I do for you?"
}
value={message}
onCompositionStart={() => setImeStatus("active")}
onCompositionEnd={() => setImeStatus("inactive")}
onKeyDown={handleKeyDown}
onChange={(event) => {
setMessage(event.target.value);
}}
/>
</div>
<div className="flex items-center px-4 py-2">
<div className="flex grow">
<Tooltip
className="max-w-60"
title={
<div>
<h3 className="mb-2 font-bold">
Investigation Mode: {backgroundInvestigation ? "On" : "Off"}
</h3>
<p>
When enabled, DeerFlow will perform a quick search before
planning. This is useful for researches related to ongoing
events and news.
</p>
</div>
}
>
<Button
className={cn(
"rounded-2xl",
backgroundInvestigation && "!border-brand !text-brand",
)}
variant="outline"
size="lg"
onClick={() =>
setEnableBackgroundInvestigation(!backgroundInvestigation)
}
>
<Detective /> Investigation
</Button>
</Tooltip>
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip title={responding ? "Stop" : "Send"}>
<Button
variant="outline"
size="icon"
className={cn("h-10 w-10 rounded-full")}
onClick={handleSendMessage}
>
{responding ? (
<div className="flex h-10 w-10 items-center justify-center">
<div className="bg-foreground h-4 w-4 rounded-sm opacity-70" />
</div>
) : (
<ArrowUp />
)}
</Button>
</Tooltip>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,439 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { LoadingOutlined } from "@ant-design/icons";
import { motion } from "framer-motion";
import { Download, Headphones } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { LoadingAnimation } from "~/components/deer-flow/loading-animation";
import { Markdown } from "~/components/deer-flow/markdown";
import { RainbowText } from "~/components/deer-flow/rainbow-text";
import { RollingText } from "~/components/deer-flow/rolling-text";
import { ScrollContainer } from "~/components/deer-flow/scroll-container";
import { Tooltip } from "~/components/deer-flow/tooltip";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { Message, Option } from "~/core/messages";
import {
closeResearch,
openResearch,
useMessage,
useResearchTitle,
useStore,
} from "~/core/store";
import { parseJSON } from "~/core/utils";
import { cn } from "~/lib/utils";
export function MessageListView({
className,
onFeedback,
onSendMessage,
}: {
className?: string;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
}) {
const messageIds = useStore((state) => state.messageIds);
const interruptMessage = useStore((state) => {
if (messageIds.length >= 2) {
const lastMessage = state.messages.get(
messageIds[messageIds.length - 1]!,
);
return lastMessage?.finishReason === "interrupt" ? lastMessage : null;
}
return null;
});
const waitingForFeedbackMessageId = useStore((state) => {
if (messageIds.length >= 2) {
const lastMessage = state.messages.get(
messageIds[messageIds.length - 1]!,
);
if (lastMessage && lastMessage.finishReason === "interrupt") {
return state.messageIds[state.messageIds.length - 2];
}
}
return null;
});
const responding = useStore((state) => state.responding);
const noOngoingResearch = useStore(
(state) => state.ongoingResearchId === null,
);
const ongoingResearchIsOpen = useStore(
(state) => state.ongoingResearchId === state.openResearchId,
);
return (
<ScrollContainer
className={cn("flex h-full w-full flex-col overflow-hidden", className)}
scrollShadowColor="var(--app-background)"
autoScrollToBottom
>
<ul className="flex flex-col">
{messageIds.map((messageId) => (
<MessageListItem
key={messageId}
messageId={messageId}
waitForFeedback={waitingForFeedbackMessageId === messageId}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
/>
))}
<div className="flex h-8 w-full shrink-0"></div>
</ul>
{responding && (noOngoingResearch || !ongoingResearchIsOpen) && (
<LoadingAnimation className="ml-4" />
)}
</ScrollContainer>
);
}
function MessageListItem({
className,
messageId,
waitForFeedback,
interruptMessage,
onFeedback,
onSendMessage,
}: {
className?: string;
messageId: string;
waitForFeedback?: boolean;
onFeedback?: (feedback: { option: Option }) => void;
interruptMessage?: Message | null;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
}) {
const message = useMessage(messageId);
const startOfResearch = useStore((state) =>
state.researchIds.includes(messageId),
);
if (message) {
if (
message.role === "user" ||
message.agent === "coordinator" ||
message.agent === "planner" ||
message.agent === "podcast" ||
startOfResearch
) {
let content: React.ReactNode;
if (message.agent === "planner") {
content = (
<div className="w-full px-4">
<PlanCard
message={message}
waitForFeedback={waitForFeedback}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
/>
</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">
<ResearchCard researchId={message.id} />
</div>
);
} else {
content = message.content ? (
<div
className={cn(
"flex w-full px-4",
message.role === "user" && "justify-end",
className,
)}
>
<MessageBubble message={message}>
<div className="flex w-full flex-col">
<Markdown>{message?.content}</Markdown>
</div>
</MessageBubble>
</div>
) : null;
}
if (content) {
return (
<motion.li
className="mt-10"
key={messageId}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
style={{ transition: "all 0.2s ease-out" }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
>
{content}
</motion.li>
);
}
}
return null;
}
function MessageBubble({
className,
message,
children,
}: {
className?: string;
message: Message;
children: React.ReactNode;
}) {
return (
<div
className={cn(
`flex w-fit max-w-[85%] flex-col rounded-2xl px-4 py-3 shadow`,
message.role === "user" &&
"text-primary-foreground bg-brand rounded-ee-none",
message.role === "assistant" && "bg-card rounded-es-none",
className,
)}
>
{children}
</div>
);
}
function ResearchCard({
className,
researchId,
}: {
className?: string;
researchId: string;
}) {
const reportId = useStore((state) =>
state.researchReportIds.get(researchId),
);
const hasReport = useStore((state) =>
state.researchReportIds.has(researchId),
);
const reportGenerating = useStore(
(state) => hasReport && state.messages.get(reportId!)!.isStreaming,
);
const openResearchId = useStore((state) => state.openResearchId);
const state = useMemo(() => {
if (hasReport) {
return reportGenerating ? "Generating report..." : "Report generated";
}
return "Researching...";
}, [hasReport, reportGenerating]);
const title = useResearchTitle(researchId);
const handleOpen = useCallback(() => {
if (openResearchId === researchId) {
closeResearch();
} else {
openResearch(researchId);
}
}, [openResearchId, researchId]);
return (
<Card className={cn("w-full", className)}>
<CardHeader>
<CardTitle>
<RainbowText animated={state !== "Report generated"}>
{title !== undefined && title !== "" ? title : "Deep Research"}
</RainbowText>
</CardTitle>
</CardHeader>
<CardFooter>
<div className="flex w-full">
<RollingText className="text-muted-foreground flex-grow text-sm">
{state}
</RollingText>
<Button
variant={!openResearchId ? "default" : "outline"}
onClick={handleOpen}
>
{researchId !== openResearchId ? "Open" : "Close"}
</Button>
</div>
</CardFooter>
</Card>
);
}
}
const GREETINGS = ["Cool", "Sounds great", "Looks good", "Great", "Awesome"];
function PlanCard({
className,
message,
interruptMessage,
onFeedback,
waitForFeedback,
onSendMessage,
}: {
className?: string;
message: Message;
interruptMessage?: Message | null;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
waitForFeedback?: boolean;
}) {
const plan = useMemo<{
title?: string;
thought?: string;
steps?: { title?: string; description?: string }[];
}>(() => {
return parseJSON(message.content ?? "", {});
}, [message.content]);
const handleAccept = useCallback(async () => {
if (onSendMessage) {
onSendMessage(
`${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
{
interruptFeedback: "accepted",
},
);
}
}, [onSendMessage]);
return (
<Card className={cn("w-full", className)}>
<CardHeader>
<CardTitle>
<Markdown animate>
{`### ${
plan.title !== undefined && plan.title !== ""
? plan.title
: "Deep Research"
}`}
</Markdown>
</CardTitle>
</CardHeader>
<CardContent>
<Markdown className="opacity-80" animate>
{plan.thought}
</Markdown>
{plan.steps && (
<ul className="my-2 flex list-decimal flex-col gap-4 border-l-[2px] pl-8">
{plan.steps.map((step, i) => (
<li key={`step-${i}`}>
<h3 className="mb text-lg font-medium">
<Markdown animate>{step.title}</Markdown>
</h3>
<div className="text-muted-foreground text-sm">
<Markdown animate>{step.description}</Markdown>
</div>
</li>
))}
</ul>
)}
</CardContent>
<CardFooter className="flex justify-end">
{!message.isStreaming && interruptMessage?.options?.length && (
<motion.div
className="flex gap-2"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
{interruptMessage?.options.map((option) => (
<Button
key={option.value}
variant={option.value === "accepted" ? "default" : "outline"}
disabled={!waitForFeedback}
onClick={() => {
if (option.value === "accepted") {
void handleAccept();
} else {
onFeedback?.({
option,
});
}
}}
>
{option.text}
</Button>
))}
</motion.div>
)}
</CardFooter>
</Card>
);
}
function PodcastCard({
className,
message,
}: {
className?: string;
message: Message;
}) {
const data = useMemo(() => {
return JSON.parse(message.content ?? "");
}, [message.content]);
const title = useMemo<string | undefined>(() => data?.title, [data]);
const audioUrl = useMemo<string | undefined>(() => data?.audioUrl, [data]);
const isGenerating = useMemo(() => {
return message.isStreaming;
}, [message.isStreaming]);
const [isPlaying, setIsPlaying] = useState(false);
return (
<Card className={cn("w-[508px]", className)}>
<CardHeader>
<div className="text-muted-foreground flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{isGenerating ? <LoadingOutlined /> : <Headphones size={16} />}
<RainbowText animated={isGenerating}>
{isGenerating
? "Generating podcast..."
: isPlaying
? "Now playing podcast..."
: "Podcast"}
</RainbowText>
</div>
{!isGenerating && (
<div className="flex">
<Tooltip title="Download podcast">
<Button variant="ghost" size="icon" asChild>
<a
href={audioUrl}
download={`${(title ?? "podcast").replaceAll(" ", "-")}.mp3`}
>
<Download size={16} />
</a>
</Button>
</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

@@ -0,0 +1,165 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { FastForward, Play } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { RainbowText } from "~/components/deer-flow/rainbow-text";
import { Button } from "~/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { fastForwardReplay } from "~/core/api";
import type { Option } from "~/core/messages";
import { useReplay } from "~/core/replay";
import { sendMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { ConversationStarter } from "./conversation-starter";
import { InputBox } from "./input-box";
import { MessageListView } from "./message-list-view";
import { Welcome } from "./welcome";
export function MessagesBlock({ className }: { className?: string }) {
const messageCount = useStore((state) => state.messageIds.length);
const responding = useStore((state) => state.responding);
const { isReplay } = useReplay();
const [replayStarted, setReplayStarted] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback(
async (message: string, options?: { interruptFeedback?: string }) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
await sendMessage(
message,
{
interruptFeedback:
options?.interruptFeedback ?? feedback?.option.value,
},
{
abortSignal: abortController.signal,
},
);
} catch {}
},
[feedback],
);
const handleCancel = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handleFeedback = useCallback(
(feedback: { option: Option }) => {
setFeedback(feedback);
},
[setFeedback],
);
const handleRemoveFeedback = useCallback(() => {
setFeedback(null);
}, [setFeedback]);
const handleStartReplay = useCallback(() => {
setReplayStarted(true);
void sendMessage();
}, [setReplayStarted]);
const [fastForwarding, setFastForwarding] = useState(false);
const handleFastForwardReplay = useCallback(() => {
setFastForwarding(!fastForwarding);
fastForwardReplay(!fastForwarding);
}, [fastForwarding]);
return (
<div className={cn("flex h-full flex-col", className)}>
<MessageListView
className="flex flex-grow"
onFeedback={handleFeedback}
onSendMessage={handleSend}
/>
{!isReplay ? (
<div className="relative flex h-42 shrink-0 pb-4">
{!responding && messageCount === 0 && (
<ConversationStarter
className="absolute top-[-218px] left-0"
onSend={handleSend}
/>
)}
<InputBox
className="h-full w-full"
responding={responding}
feedback={feedback}
onSend={handleSend}
onCancel={handleCancel}
onRemoveFeedback={handleRemoveFeedback}
/>
</div>
) : (
<>
<div
className={cn(
"fixed bottom-[calc(50vh+80px)] left-0 transition-all duration-500 ease-out",
replayStarted && "pointer-events-none scale-150 opacity-0",
)}
>
<Welcome />
</div>
<motion.div
className="mb-4 h-fit w-full items-center justify-center"
initial={{ opacity: 0, y: "20vh" }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card
className={cn(
"w-full transition-all duration-300",
!replayStarted && "translate-y-[-40vh]",
)}
>
<div className="flex items-center justify-between">
<div className="flex-grow">
<CardHeader>
<CardTitle>
<RainbowText animated={responding}>
{responding ? "Replaying" : "Replay Mode"}
</RainbowText>
</CardTitle>
<CardDescription>
<RainbowText animated={responding}>
{responding
? "DeerFlow is now replaying the conversation..."
: replayStarted
? "The replay has been stopped."
: `You're now in DeerFlow's replay mode. Click the "Play" button on the right to start.`}
</RainbowText>
</CardDescription>
</CardHeader>
</div>
<div className="pr-4">
{responding && (
<Button
className={cn(fastForwarding && "animate-pulse")}
variant={fastForwarding ? "default" : "outline"}
onClick={handleFastForwardReplay}
>
<FastForward size={16} />
Fast Forward
</Button>
)}
{!replayStarted && (
<Button className="w-24" onClick={handleStartReplay}>
<Play size={16} />
Play
</Button>
)}
</div>
</div>
</Card>
</motion.div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,354 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { PythonOutlined } from "@ant-design/icons";
import { motion } from "framer-motion";
import { LRUCache } from "lru-cache";
import { BookOpenText, PencilRuler, Search } from "lucide-react";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { FavIcon } from "~/components/deer-flow/fav-icon";
import Image from "~/components/deer-flow/image";
import { LoadingAnimation } from "~/components/deer-flow/loading-animation";
import { Markdown } from "~/components/deer-flow/markdown";
import { RainbowText } from "~/components/deer-flow/rainbow-text";
import { Tooltip } from "~/components/deer-flow/tooltip";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
import { Skeleton } from "~/components/ui/skeleton";
import { findMCPTool } from "~/core/mcp";
import type { ToolCallRuntime } from "~/core/messages";
import { useMessage, useStore } from "~/core/store";
import { parseJSON } from "~/core/utils";
import { cn } from "~/lib/utils";
export function ResearchActivitiesBlock({
className,
researchId,
}: {
className?: string;
researchId: string;
}) {
const activityIds = useStore((state) =>
state.researchActivityIds.get(researchId),
)!;
const ongoing = useStore((state) => state.ongoingResearchId === researchId);
return (
<>
<ul className={cn("flex flex-col py-4", className)}>
{activityIds.map(
(activityId, i) =>
i !== 0 && (
<motion.li
key={activityId}
style={{ transition: "all 0.4s ease-out" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: "easeOut",
}}
>
<ActivityMessage messageId={activityId} />
<ActivityListItem messageId={activityId} />
{i !== activityIds.length - 1 && <hr className="my-8" />}
</motion.li>
),
)}
</ul>
{ongoing && <LoadingAnimation className="mx-4 my-12" />}
</>
);
}
function ActivityMessage({ messageId }: { messageId: string }) {
const message = useMessage(messageId);
if (message?.agent && message.content) {
if (message.agent !== "reporter" && message.agent !== "planner") {
return (
<div className="px-4 py-2">
<Markdown animate>{message.content}</Markdown>
</div>
);
}
}
return null;
}
function ActivityListItem({ messageId }: { messageId: string }) {
const message = useMessage(messageId);
if (message) {
if (!message.isStreaming && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
if (toolCall.name === "web_search") {
return <WebSearchToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "crawl_tool") {
return <CrawlToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "python_repl_tool") {
return <PythonToolCall key={toolCall.id} toolCall={toolCall} />;
} else {
return <MCPToolCall key={toolCall.id} toolCall={toolCall} />;
}
}
}
}
return null;
}
const __pageCache = new LRUCache<string, string>({ max: 100 });
type SearchResult =
| {
type: "page";
title: string;
url: string;
content: string;
}
| {
type: "image";
image_url: string;
image_description: string;
};
function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const searching = useMemo(() => {
return toolCall.result === undefined;
}, [toolCall.result]);
const searchResults = useMemo<SearchResult[]>(() => {
let results: SearchResult[] | undefined = undefined;
try {
results = toolCall.result ? parseJSON(toolCall.result, []) : undefined;
} catch {
results = undefined;
}
if (Array.isArray(results)) {
results.forEach((result) => {
if (result.type === "page") {
__pageCache.set(result.url, result.title);
}
});
} else {
results = [];
}
return results;
}, [toolCall.result]);
const pageResults = useMemo(
() => searchResults?.filter((result) => result.type === "page"),
[searchResults],
);
const imageResults = useMemo(
() => searchResults?.filter((result) => result.type === "image"),
[searchResults],
);
return (
<section className="mt-4 pl-4">
<div className="font-medium italic">
<RainbowText
className="flex items-center"
animated={searchResults === undefined}
>
<Search size={16} className={"mr-2"} />
<span>Searching for&nbsp;</span>
<span className="max-w-[500px] overflow-hidden text-ellipsis whitespace-nowrap">
{(toolCall.args as { query: string }).query}
</span>
</RainbowText>
</div>
<div className="pr-4">
{pageResults && (
<ul className="mt-2 flex flex-wrap gap-4">
{searching &&
[...Array(6)].map((_, i) => (
<li
key={`search-result-${i}`}
className="flex h-40 w-40 gap-2 rounded-md text-sm"
>
<Skeleton
className="to-accent h-full w-full rounded-md bg-gradient-to-tl from-slate-400"
style={{ animationDelay: `${i * 0.2}s` }}
/>
</li>
))}
{pageResults
.filter((result) => result.type === "page")
.map((searchResult, i) => (
<motion.li
key={`search-result-${i}`}
className="text-muted-foreground bg-accent flex max-w-40 gap-2 rounded-md px-2 py-1 text-sm"
initial={{ opacity: 0, y: 10, scale: 0.66 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.2,
delay: i * 0.1,
ease: "easeOut",
}}
>
<FavIcon
className="mt-1"
url={searchResult.url}
title={searchResult.title}
/>
<a href={searchResult.url} target="_blank">
{searchResult.title}
</a>
</motion.li>
))}
{imageResults.map((searchResult, i) => (
<motion.li
key={`search-result-${i}`}
initial={{ opacity: 0, y: 10, scale: 0.66 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.2,
delay: i * 0.1,
ease: "easeOut",
}}
>
<a
className="flex flex-col gap-2 overflow-hidden rounded-md opacity-75 transition-opacity duration-300 hover:opacity-100"
href={searchResult.image_url}
target="_blank"
>
<Image
src={searchResult.image_url}
alt={searchResult.image_description}
className="bg-accent h-40 w-40 max-w-full rounded-md bg-cover bg-center bg-no-repeat"
imageClassName="hover:scale-110"
imageTransition
/>
</a>
</motion.li>
))}
</ul>
)}
</div>
</section>
);
}
function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const url = useMemo(
() => (toolCall.args as { url: string }).url,
[toolCall.args],
);
const title = useMemo(() => __pageCache.get(url), [url]);
return (
<section className="mt-4 pl-4">
<div>
<RainbowText
className="flex items-center text-base font-medium italic"
animated={toolCall.result === undefined}
>
<BookOpenText size={16} className={"mr-2"} />
<span>Reading</span>
</RainbowText>
</div>
<ul className="mt-2 flex flex-wrap gap-4">
<motion.li
className="text-muted-foreground bg-accent flex h-40 w-40 gap-2 rounded-md px-2 py-1 text-sm"
initial={{ opacity: 0, y: 10, scale: 0.66 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
>
<FavIcon className="mt-1" url={url} title={title} />
<a
className="h-full flex-grow overflow-hidden text-ellipsis whitespace-nowrap"
href={url}
target="_blank"
>
{title ?? url}
</a>
</motion.li>
</ul>
</section>
);
}
function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const code = useMemo<string>(() => {
return (toolCall.args as { code: string }).code;
}, [toolCall.args]);
const { resolvedTheme } = useTheme();
return (
<section className="mt-4 pl-4">
<div className="flex items-center">
<PythonOutlined className={"mr-2"} />
<RainbowText
className="text-base font-medium italic"
animated={toolCall.result === undefined}
>
Running Python code
</RainbowText>
</div>
<div>
<div className="bg-accent mt-2 max-h-[400px] max-w-[calc(100%-120px)] overflow-y-auto rounded-md p-2 text-sm">
<SyntaxHighlighter
language="python"
style={resolvedTheme === "dark" ? dark : docco}
customStyle={{
background: "transparent",
border: "none",
boxShadow: "none",
}}
>
{code.trim()}
</SyntaxHighlighter>
</div>
</div>
</section>
);
}
function MCPToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const tool = useMemo(() => findMCPTool(toolCall.name), [toolCall.name]);
const { resolvedTheme } = useTheme();
return (
<section className="mt-4 pl-4">
<div className="w-fit overflow-y-auto rounded-md py-0">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>
<Tooltip title={tool?.description}>
<div className="flex items-center font-medium italic">
<PencilRuler size={16} className={"mr-2"} />
<RainbowText
className="pr-0.5 text-base font-medium italic"
animated={toolCall.result === undefined}
>
Running {toolCall.name ? toolCall.name + "()" : "MCP tool"}
</RainbowText>
</div>
</Tooltip>
</AccordionTrigger>
<AccordionContent>
{toolCall.result && (
<div className="bg-accent max-h-[400px] max-w-[560px] overflow-y-auto rounded-md text-sm">
<SyntaxHighlighter
language="json"
style={resolvedTheme === "dark" ? dark : docco}
customStyle={{
background: "transparent",
border: "none",
boxShadow: "none",
}}
>
{toolCall.result.trim()}
</SyntaxHighlighter>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</section>
);
}

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Check, Copy, Headphones, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ScrollContainer } from "~/components/deer-flow/scroll-container";
import { Tooltip } from "~/components/deer-flow/tooltip";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { useReplay } from "~/core/replay";
import { closeResearch, listenToPodcast, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { ResearchActivitiesBlock } from "./research-activities-block";
import { ResearchReportBlock } from "./research-report-block";
export function ResearchBlock({
className,
researchId = null,
}: {
className?: string;
researchId: string | null;
}) {
const reportId = useStore((state) =>
researchId ? state.researchReportIds.get(researchId) : undefined,
);
const [activeTab, setActiveTab] = useState("activities");
const hasReport = useStore((state) =>
researchId ? state.researchReportIds.has(researchId) : false,
);
const reportStreaming = useStore((state) =>
reportId ? (state.messages.get(reportId)?.isStreaming ?? false) : false,
);
const { isReplay } = useReplay();
useEffect(() => {
if (hasReport) {
setActiveTab("report");
}
}, [hasReport]);
const handleGeneratePodcast = useCallback(async () => {
if (!researchId) {
return;
}
await listenToPodcast(researchId);
}, [researchId]);
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
if (!reportId) {
return;
}
const report = useStore.getState().messages.get(reportId);
if (!report) {
return;
}
void navigator.clipboard.writeText(report.content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
}, [reportId]);
// When the research id changes, set the active tab to activities
useEffect(() => {
setActiveTab("activities");
}, [researchId]);
return (
<div className={cn("h-full w-full", className)}>
<Card className={cn("relative h-full w-full pt-4", className)}>
<div className="absolute right-4 flex h-9 items-center justify-center">
{hasReport && !reportStreaming && (
<>
<Tooltip title="Generate podcast">
<Button
className="text-gray-400"
size="icon"
variant="ghost"
disabled={isReplay}
onClick={handleGeneratePodcast}
>
<Headphones />
</Button>
</Tooltip>
<Tooltip title="Copy">
<Button
className="text-gray-400"
size="icon"
variant="ghost"
onClick={handleCopy}
>
{copied ? <Check /> : <Copy />}
</Button>
</Tooltip>
</>
)}
<Tooltip title="Close">
<Button
className="text-gray-400"
size="sm"
variant="ghost"
onClick={() => {
closeResearch();
}}
>
<X />
</Button>
</Tooltip>
</div>
<Tabs
className="flex h-full w-full flex-col"
value={activeTab}
onValueChange={(value) => setActiveTab(value)}
>
<div className="flex w-full justify-center">
<TabsList className="">
<TabsTrigger
className="px-8"
value="report"
disabled={!hasReport}
>
Report
</TabsTrigger>
<TabsTrigger className="px-8" value="activities">
Activities
</TabsTrigger>
</TabsList>
</div>
<TabsContent
className="h-full min-h-0 flex-grow px-8"
value="report"
forceMount
hidden={activeTab !== "report"}
>
<ScrollContainer
className="px-5pb-20 h-full"
scrollShadowColor="var(--card)"
autoScrollToBottom={!hasReport || reportStreaming}
>
{reportId && researchId && (
<ResearchReportBlock
className="mt-4"
researchId={researchId}
messageId={reportId}
/>
)}
</ScrollContainer>
</TabsContent>
<TabsContent
className="h-full min-h-0 flex-grow px-8"
value="activities"
forceMount
hidden={activeTab !== "activities"}
>
<ScrollContainer
className="h-full"
scrollShadowColor="var(--card)"
autoScrollToBottom={!hasReport || reportStreaming}
>
{researchId && (
<ResearchActivitiesBlock
className="mt-4"
researchId={researchId}
/>
)}
</ScrollContainer>
</TabsContent>
</Tabs>
</Card>
</div>
);
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useCallback, useRef } from "react";
import { LoadingAnimation } from "~/components/deer-flow/loading-animation";
import { Markdown } from "~/components/deer-flow/markdown";
import ReportEditor from "~/components/editor";
import { useReplay } from "~/core/replay";
import { useMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
export function ResearchReportBlock({
className,
messageId,
}: {
className?: string;
researchId: string;
messageId: string;
}) {
const message = useMessage(messageId);
const { isReplay } = useReplay();
const handleMarkdownChange = useCallback(
(markdown: string) => {
if (message) {
message.content = markdown;
useStore.setState({
messages: new Map(useStore.getState().messages).set(
message.id,
message,
),
});
}
},
[message],
);
const contentRef = useRef<HTMLDivElement>(null);
const isCompleted = message?.isStreaming === false && message?.content !== "";
// TODO: scroll to top when completed, but it's not working
// useEffect(() => {
// if (isCompleted && contentRef.current) {
// setTimeout(() => {
// contentRef
// .current!.closest("[data-radix-scroll-area-viewport]")
// ?.scrollTo({
// top: 0,
// behavior: "smooth",
// });
// }, 500);
// }
// }, [isCompleted]);
return (
<div
ref={contentRef}
className={cn("relative flex flex-col pt-4 pb-8", className)}
>
{!isReplay && isCompleted ? (
<ReportEditor
content={message?.content}
onMarkdownChange={handleMarkdownChange}
/>
) : (
<>
<Markdown animate>{message?.content}</Markdown>
{message?.isStreaming && <LoadingAnimation className="my-12" />}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { cn } from "~/lib/utils";
export function Welcome({ className }: { className?: string }) {
return (
<motion.div
className={cn("flex flex-col", className)}
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
>
<h3 className="mb-2 text-center text-3xl font-medium">
👋 Hello, there!
</h3>
<div className="text-muted-foreground px-4 text-center text-lg">
Welcome to{" "}
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
🦌 DeerFlow
</a>
, a research tool built on cutting-edge language models, helps you
search on web, browse information, and handle complex tasks.
</div>
</motion.div>
);
}