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"]),
});

View File

@@ -22,6 +22,7 @@ export async function* chatStream(
max_step_num: number;
max_search_results?: number;
interrupt_feedback?: string;
enable_deep_thinking?: boolean;
enable_background_investigation: boolean;
report_style?: "academic" | "popular_science" | "news" | "social_media";
mcp_settings?: {

View File

@@ -0,0 +1,25 @@
import { type DeerFlowConfig } from "../config/types";
import { resolveServiceURL } from "./resolve-service-url";
declare global {
interface Window {
__deerflowConfig: DeerFlowConfig;
}
}
export async function loadConfig() {
const res = await fetch(resolveServiceURL("./config"));
const config = await res.json();
return config;
}
export function getConfig(): DeerFlowConfig {
if (
typeof window === "undefined" ||
typeof window.__deerflowConfig === "undefined"
) {
throw new Error("Config not loaded");
}
return window.__deerflowConfig;
}

View File

@@ -8,7 +8,7 @@ import { env } from "~/env";
import { useReplay } from "../replay";
import { fetchReplayTitle } from "./chat";
import { getRAGConfig } from "./rag";
import { getConfig } from "./config";
export function useReplayMetadata() {
const { isReplay } = useReplay();
@@ -52,15 +52,8 @@ export function useRAGProvider() {
setLoading(false);
return;
}
getRAGConfig()
.then(setProvider)
.catch((e) => {
setProvider(null);
console.error("Failed to get RAG provider", e);
})
.finally(() => {
setLoading(false);
});
setProvider(getConfig().rag.provider);
setLoading(false);
}, []);
return { provider, loading };

View File

@@ -10,15 +10,7 @@ export function queryRAGResources(query: string) {
.then((res) => {
return res.resources as Array<Resource>;
})
.catch((err) => {
.catch(() => {
return [];
});
}
export function getRAGConfig() {
return fetch(resolveServiceURL(`rag/config`), {
method: "GET",
})
.then((res) => res.json())
.then((res) => res.provider);
}

View File

@@ -38,6 +38,7 @@ export interface MessageChunkEvent
"message_chunk",
{
content?: string;
reasoning_content?: string;
}
> {}

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -0,0 +1,13 @@
export interface ModelConfig {
basic: string[];
reasoning: string[];
}
export interface RagConfig {
provider: string;
}
export interface DeerFlowConfig {
rag: RagConfig;
models: ModelConfig;
}

View File

@@ -43,6 +43,11 @@ function mergeTextMessage(message: Message, event: MessageChunkEvent) {
message.content += event.data.content;
message.contentChunks.push(event.data.content);
}
if (event.data.reasoning_content) {
message.reasoningContent = (message.reasoningContent ?? "") + event.data.reasoning_content;
message.reasoningContentChunks = message.reasoningContentChunks ?? [];
message.reasoningContentChunks.push(event.data.reasoning_content);
}
}
function mergeToolCallMessage(

View File

@@ -17,6 +17,8 @@ export interface Message {
isStreaming?: boolean;
content: string;
contentChunks: string[];
reasoningContent?: string;
reasoningContentChunks?: string[];
toolCalls?: ToolCallRuntime[];
options?: Option[];
finishReason?: "stop" | "interrupt" | "tool_calls";

View File

@@ -10,6 +10,7 @@ const SETTINGS_KEY = "deerflow.settings";
const DEFAULT_SETTINGS: SettingsState = {
general: {
autoAcceptedPlan: false,
enableDeepThinking: false,
enableBackgroundInvestigation: false,
maxPlanIterations: 1,
maxStepNum: 3,
@@ -24,6 +25,7 @@ const DEFAULT_SETTINGS: SettingsState = {
export type SettingsState = {
general: {
autoAcceptedPlan: boolean;
enableDeepThinking: boolean;
enableBackgroundInvestigation: boolean;
maxPlanIterations: number;
maxStepNum: number;
@@ -127,7 +129,9 @@ export const getChatStreamSettings = () => {
};
};
export function setReportStyle(value: "academic" | "popular_science" | "news" | "social_media") {
export function setReportStyle(
value: "academic" | "popular_science" | "news" | "social_media",
) {
useSettingsStore.setState((state) => ({
general: {
...state.general,
@@ -137,6 +141,16 @@ export function setReportStyle(value: "academic" | "popular_science" | "news" |
saveSettings();
}
export function setEnableDeepThinking(value: boolean) {
useSettingsStore.setState((state) => ({
general: {
...state.general,
enableDeepThinking: value,
},
}));
saveSettings();
}
export function setEnableBackgroundInvestigation(value: boolean) {
useSettingsStore.setState((state) => ({
general: {

View File

@@ -104,6 +104,7 @@ export async function sendMessage(
interrupt_feedback: interruptFeedback,
resources,
auto_accepted_plan: settings.autoAcceptedPlan,
enable_deep_thinking: settings.enableDeepThinking ?? false,
enable_background_investigation:
settings.enableBackgroundInvestigation ?? true,
max_plan_iterations: settings.maxPlanIterations,
@@ -132,6 +133,8 @@ export async function sendMessage(
role: data.role,
content: "",
contentChunks: [],
reasoningContent: "",
reasoningContentChunks: [],
isStreaming: true,
interruptFeedback,
};
@@ -296,6 +299,8 @@ export async function listenToPodcast(researchId: string) {
agent: "podcast",
content: JSON.stringify(podcastObject),
contentChunks: [],
reasoningContent: "",
reasoningContentChunks: [],
isStreaming: true,
};
appendMessage(podcastMessage);

View File

@@ -7,7 +7,10 @@ export function parseJSON<T>(json: string | null | undefined, fallback: T) {
try {
const raw = json
.trim()
.replace(/^```js\s*/, "")
.replace(/^```json\s*/, "")
.replace(/^```ts\s*/, "")
.replace(/^```plaintext\s*/, "")
.replace(/^```\s*/, "")
.replace(/\s*```$/, "");
return parse(raw) as T;