mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-21 13:24:44 +08:00
refactor: extract components folder
This commit is contained in:
56
web/src/app/chat/components/conversation-starter.tsx
Normal file
56
web/src/app/chat/components/conversation-starter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
web/src/app/chat/components/input-box.tsx
Normal file
199
web/src/app/chat/components/input-box.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
439
web/src/app/chat/components/message-list-view.tsx
Normal file
439
web/src/app/chat/components/message-list-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
web/src/app/chat/components/messages-block.tsx
Normal file
165
web/src/app/chat/components/messages-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
web/src/app/chat/components/research-activities-block.tsx
Normal file
354
web/src/app/chat/components/research-activities-block.tsx
Normal 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 </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>
|
||||
);
|
||||
}
|
||||
175
web/src/app/chat/components/research-block.tsx
Normal file
175
web/src/app/chat/components/research-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
web/src/app/chat/components/research-report-block.tsx
Normal file
71
web/src/app/chat/components/research-report-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
web/src/app/chat/components/welcome.tsx
Normal file
34
web/src/app/chat/components/welcome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user