475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
// 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, FileText, PencilRuler, Search } from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
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 animated checkLinkCredibility>
|
|
{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.result?.startsWith("Error")) {
|
|
return null;
|
|
}
|
|
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 if (toolCall.name === "local_search_tool") {
|
|
return <RetrieverToolCall 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 t = useTranslations("chat.research");
|
|
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>{t("searchingFor")} </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 t = useTranslations("chat.research");
|
|
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>{t("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 RetrieverToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
|
|
const t = useTranslations("chat.research");
|
|
const searching = useMemo(() => {
|
|
return toolCall.result === undefined;
|
|
}, [toolCall.result]);
|
|
const documents = useMemo<
|
|
Array<{ id: string; title: string; content: string }>
|
|
>(() => {
|
|
return toolCall.result ? parseJSON(toolCall.result, []) : [];
|
|
}, [toolCall.result]);
|
|
return (
|
|
<section className="mt-4 pl-4">
|
|
<div className="font-medium italic">
|
|
<RainbowText className="flex items-center" animated={searching}>
|
|
<Search size={16} className={"mr-2"} />
|
|
<span>{t("retrievingDocuments")} </span>
|
|
<span className="max-w-[500px] overflow-hidden text-ellipsis whitespace-nowrap">
|
|
{(toolCall.args as { keywords: string }).keywords}
|
|
</span>
|
|
</RainbowText>
|
|
</div>
|
|
<div className="pr-4">
|
|
{documents && (
|
|
<ul className="mt-2 flex flex-wrap gap-4">
|
|
{searching &&
|
|
[...Array(2)].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>
|
|
))}
|
|
{documents?.map((doc, 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",
|
|
}}
|
|
>
|
|
<FileText size={32} />
|
|
{doc.title} (chunk-{i},size-{doc.content.length})
|
|
</motion.li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
|
|
const t = useTranslations("chat.research");
|
|
const code = useMemo<string | undefined>(() => {
|
|
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}
|
|
>
|
|
{t("runningPythonCode")}
|
|
</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>
|
|
{toolCall.result && <PythonToolCallResult result={toolCall.result} />}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function PythonToolCallResult({ result }: { result: string }) {
|
|
const t = useTranslations("chat.research");
|
|
const { resolvedTheme } = useTheme();
|
|
const hasError = useMemo(
|
|
() => result.includes("Error executing code:\n"),
|
|
[result],
|
|
);
|
|
const error = useMemo(() => {
|
|
if (hasError) {
|
|
const parts = result.split("```\nError: ");
|
|
if (parts.length > 1) {
|
|
return parts[1]!.trim();
|
|
}
|
|
}
|
|
return null;
|
|
}, [result, hasError]);
|
|
const stdout = useMemo(() => {
|
|
if (!hasError) {
|
|
const parts = result.split("```\nStdout: ");
|
|
if (parts.length > 1) {
|
|
return parts[1]!.trim();
|
|
}
|
|
}
|
|
return null;
|
|
}, [result, hasError]);
|
|
return (
|
|
<>
|
|
<div className="mt-4 font-medium italic">
|
|
{hasError ? t("errorExecutingCode") : t("executionOutput")}
|
|
</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="plaintext"
|
|
style={resolvedTheme === "dark" ? dark : docco}
|
|
customStyle={{
|
|
color: hasError ? "red" : "inherit",
|
|
background: "transparent",
|
|
border: "none",
|
|
boxShadow: "none",
|
|
}}
|
|
>
|
|
{error ?? stdout ?? "(empty)"}
|
|
</SyntaxHighlighter>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|