feat: add reasoning block

This commit is contained in:
hetao
2025-06-12 11:16:11 +08:00
parent a0d458c8eb
commit 87b15168ed
12 changed files with 954 additions and 20 deletions

View File

@@ -3,8 +3,8 @@
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, Brain } 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 +23,7 @@ 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 +295,100 @@ 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("w-full mb-6", className)}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full justify-start px-6 py-4 h-auto text-left rounded-xl border 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 items-center gap-3 w-full">
<Brain
size={18}
className={cn(
"shrink-0 transition-colors duration-200",
isStreaming ? "text-primary" : "text-muted-foreground"
)}
/>
<span className={cn(
"font-semibold leading-none transition-colors duration-200",
isStreaming ? "text-primary" : "text-foreground"
)}>
</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="mt-3 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">
<Card className={cn(
"transition-all duration-200",
isStreaming
? "border-primary/20 bg-primary/5"
: "border-border"
)}>
<CardContent className="py-6">
<Markdown
className={cn(
"prose dark:prose-invert max-w-none transition-colors duration-200",
isStreaming
? "prose-primary"
: "opacity-80"
)}
animated={isStreaming}
>
{content}
</Markdown>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
);
}
const GREETINGS = ["Cool", "Sounds great", "Looks good", "Great", "Awesome"];
function PlanCard({
className,
@@ -320,6 +415,15 @@ 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,20 +435,34 @@ 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>
<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 && (
@@ -352,10 +470,10 @@ function PlanCard({
{plan.steps.map((step, i) => (
<li key={`step-${i}`}>
<h3 className="mb text-lg font-medium">
<Markdown animated>{step.title}</Markdown>
<Markdown animated={message.isStreaming}>{step.title}</Markdown>
</h3>
<div className="text-muted-foreground text-sm">
<Markdown animated>{step.description}</Markdown>
<Markdown animated={message.isStreaming}>{step.description}</Markdown>
</div>
</li>
))}
@@ -390,8 +508,11 @@ function PlanCard({
))}
</motion.div>
)}
</CardFooter>
</Card>
</CardFooter>
</Card>
</motion.div>
)}
</div>
);
}