feat: add deep think feature (#311)

* feat: implement backend logic

* feat: implement api/config endpoint

* rename the symbol

* feat: re-implement configuration at client-side

* feat: add client-side of deep thinking

* fix backend bug

* feat: add reasoning block

* docs: update readme

* fix: translate into English

* fix: change icon to lightbulb

* feat: ignore more bad cases

* feat: adjust thinking layout, and implement auto scrolling

* docs: add comments

---------

Co-authored-by: Henry Li <henry1943@163.com>
This commit is contained in:
DanielWalnut
2025-06-14 13:12:43 +08:00
committed by GitHub
parent a7315b46df
commit 19fa1e97c3
40 changed files with 2292 additions and 1102 deletions

View File

@@ -3,8 +3,8 @@
import { MagicWandIcon } from "@radix-ui/react-icons";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowUp, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { ArrowUp, Lightbulb, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Detective } from "~/components/deer-flow/icons/detective";
import MessageInput, {
@@ -15,8 +15,10 @@ import { Tooltip } from "~/components/deer-flow/tooltip";
import { BorderBeam } from "~/components/magicui/border-beam";
import { Button } from "~/components/ui/button";
import { enhancePrompt } from "~/core/api";
import { getConfig } from "~/core/api/config";
import type { Option, Resource } from "~/core/messages";
import {
setEnableDeepThinking,
setEnableBackgroundInvestigation,
useSettingsStore,
} from "~/core/store";
@@ -44,9 +46,13 @@ export function InputBox({
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
const enableDeepThinking = useSettingsStore(
(state) => state.general.enableDeepThinking,
);
const backgroundInvestigation = useSettingsStore(
(state) => state.general.enableBackgroundInvestigation,
);
const reasoningModel = useMemo(() => getConfig().models.reasoning?.[0], []);
const reportStyle = useSettingsStore((state) => state.general.reportStyle);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<MessageInputRef>(null);
@@ -203,6 +209,36 @@ export function InputBox({
</div>
<div className="flex items-center px-4 py-2">
<div className="flex grow gap-2">
{reasoningModel && (
<Tooltip
className="max-w-60"
title={
<div>
<h3 className="mb-2 font-bold">
Deep Thinking Mode: {enableDeepThinking ? "On" : "Off"}
</h3>
<p>
When enabled, DeerFlow will use reasoning model (
{reasoningModel}) to generate more thoughtful plans.
</p>
</div>
}
>
<Button
className={cn(
"rounded-2xl",
enableDeepThinking && "!border-brand !text-brand",
)}
variant="outline"
onClick={() => {
setEnableDeepThinking(!enableDeepThinking);
}}
>
<Lightbulb /> Deep Thinking
</Button>
</Tooltip>
)}
<Tooltip
className="max-w-60"
title={

View File

@@ -3,8 +3,14 @@
import { LoadingOutlined } from "@ant-design/icons";
import { motion } from "framer-motion";
import { Download, Headphones } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Download,
Headphones,
ChevronDown,
ChevronRight,
Lightbulb,
} from "lucide-react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { LoadingAnimation } from "~/components/deer-flow/loading-animation";
import { Markdown } from "~/components/deer-flow/markdown";
@@ -23,6 +29,11 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import type { Message, Option } from "~/core/messages";
import {
closeResearch,
@@ -294,6 +305,114 @@ function ResearchCard({
);
}
function ThoughtBlock({
className,
content,
isStreaming,
hasMainContent,
}: {
className?: string;
content: string;
isStreaming?: boolean;
hasMainContent?: boolean;
}) {
const [isOpen, setIsOpen] = useState(true);
const [hasAutoCollapsed, setHasAutoCollapsed] = useState(false);
React.useEffect(() => {
if (hasMainContent && !hasAutoCollapsed) {
setIsOpen(false);
setHasAutoCollapsed(true);
}
}, [hasMainContent, hasAutoCollapsed]);
if (!content || content.trim() === "") {
return null;
}
return (
<div className={cn("mb-6 w-full", className)}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-auto w-full justify-start rounded-xl border px-6 py-4 text-left transition-all duration-200",
"hover:bg-accent hover:text-accent-foreground",
isStreaming
? "border-primary/20 bg-primary/5 shadow-sm"
: "border-border bg-card",
)}
>
<div className="flex w-full items-center gap-3">
<Lightbulb
size={18}
className={cn(
"shrink-0 transition-colors duration-200",
isStreaming ? "text-primary" : "text-muted-foreground",
)}
/>
<span
className={cn(
"leading-none font-semibold transition-colors duration-200",
isStreaming ? "text-primary" : "text-foreground",
)}
>
Deep Thinking
</span>
{isStreaming && <LoadingAnimation className="ml-2 scale-75" />}
<div className="flex-grow" />
{isOpen ? (
<ChevronDown
size={16}
className="text-muted-foreground transition-transform duration-200"
/>
) : (
<ChevronRight
size={16}
className="text-muted-foreground transition-transform duration-200"
/>
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-up-2 data-[state=open]:slide-down-2 mt-3">
<Card
className={cn(
"transition-all duration-200",
isStreaming ? "border-primary/20 bg-primary/5" : "border-border",
)}
>
<CardContent>
<div className="flex h-40 w-full overflow-y-auto">
<ScrollContainer
className={cn(
"flex h-full w-full flex-col overflow-hidden",
className,
)}
scrollShadow={false}
autoScrollToBottom
>
<Markdown
className={cn(
"prose dark:prose-invert max-w-none transition-colors duration-200",
isStreaming ? "prose-primary" : "opacity-80",
)}
animated={isStreaming}
>
{content}
</Markdown>
</ScrollContainer>
</div>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
);
}
const GREETINGS = ["Cool", "Sounds great", "Looks good", "Great", "Awesome"];
function PlanCard({
className,
@@ -320,6 +439,17 @@ function PlanCard({
}>(() => {
return parseJSON(message.content ?? "", {});
}, [message.content]);
const reasoningContent = message.reasoningContent;
const hasMainContent = Boolean(
message.content && message.content.trim() !== "",
);
// 判断是否正在思考:有推理内容但还没有主要内容
const isThinking = Boolean(reasoningContent && !hasMainContent);
// 判断是否应该显示计划:有主要内容就显示(无论是否还在流式传输)
const shouldShowPlan = hasMainContent;
const handleAccept = useCallback(async () => {
if (onSendMessage) {
onSendMessage(
@@ -331,67 +461,90 @@ function PlanCard({
}
}, [onSendMessage]);
return (
<Card className={cn("w-full", className)}>
<CardHeader>
<CardTitle>
<Markdown animated>
{`### ${
plan.title !== undefined && plan.title !== ""
? plan.title
: "Deep Research"
}`}
</Markdown>
</CardTitle>
</CardHeader>
<CardContent>
<Markdown className="opacity-80" animated>
{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 animated>{step.title}</Markdown>
</h3>
<div className="text-muted-foreground text-sm">
<Markdown animated>{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>
<div className={cn("w-full", className)}>
{reasoningContent && (
<ThoughtBlock
content={reasoningContent}
isStreaming={isThinking}
hasMainContent={hasMainContent}
/>
)}
{shouldShowPlan && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<Card className="w-full">
<CardHeader>
<CardTitle>
<Markdown animated={message.isStreaming}>
{`### ${
plan.title !== undefined && plan.title !== ""
? plan.title
: "Deep Research"
}`}
</Markdown>
</CardTitle>
</CardHeader>
<CardContent>
<Markdown className="opacity-80" animated={message.isStreaming}>
{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 animated={message.isStreaming}>
{step.title}
</Markdown>
</h3>
<div className="text-muted-foreground text-sm">
<Markdown animated={message.isStreaming}>
{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>
</motion.div>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { Geist } from "next/font/google";
import Script from "next/script";
import { ThemeProviderWrapper } from "~/components/deer-flow/theme-provider-wrapper";
import { loadConfig } from "~/core/api/config";
import { env } from "~/env";
import { Toaster } from "../components/deer-flow/toaster";
@@ -24,12 +25,14 @@ const geist = Geist({
variable: "--font-geist-sans",
});
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const conf = await loadConfig();
return (
<html lang="en" className={`${geist.variable}`} suppressHydrationWarning>
<head>
<script>{`window.__deerflowConfig = ${JSON.stringify(conf)}`}</script>
{/* Define isSpace function globally to fix markdown-it issues with Next.js + Turbopack
https://github.com/markdown-it/markdown-it/issues/1082#issuecomment-2749656365 */}
<Script id="markdown-it-fix" strategy="beforeInteractive">

View File

@@ -36,6 +36,7 @@ const generalFormSchema = z.object({
}),
// Others
enableBackgroundInvestigation: z.boolean(),
enableDeepThinking: z.boolean(),
reportStyle: z.enum(["academic", "popular_science", "news", "social_media"]),
});