feat: capture reasoning content from thinking models, improve thinking UI

- Backend: intercept reasoning_content from DeepSeek-R1/QwQ streaming
  chunks and emit as thinking events (stage=reasoning)
- RAG chain: show retrieved document titles/previews in thinking steps
- Conversation chain: directly stream from LLM to capture reasoning
- Frontend: collapsible thinking panel with reasoning section,
  document details, and time summary
- Replace relative time with HH:mm format for message timestamps

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:47:14 +08:00
parent 62da8b3c58
commit 51d6fd22c0
5 changed files with 159 additions and 47 deletions
+94 -24
View File
@@ -7,8 +7,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { useChatStore } from "@/store/chat";
import SourceReferences from "./source-references";
@@ -20,28 +19,100 @@ interface MessageItemProps {
selectedModel?: string;
}
const ThinkingProcess = ({ thinking }: { thinking: ThinkingStep[] }) => {
const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[]; isStreaming?: boolean }) => {
const [expanded, setExpanded] = useState(false);
if (!thinking || thinking.length === 0) return null;
const getStageIcon = (stage: string) => {
switch (stage) {
case 'understanding': return <Brain className="h-3.5 w-3.5" />;
case 'retrieving': return <FileSearch className="h-3.5 w-3.5 animate-spin" />;
case 'retrieved': return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />;
case 'generating': return <Sparkles className="h-3.5 w-3.5 animate-pulse" />;
default: return <Loader2 className="h-3.5 w-3.5" />;
}
};
// 分离状态步骤和推理内容
const statusSteps = thinking.filter(s => s.stage !== 'reasoning');
const reasoningSteps = thinking.filter(s => s.stage === 'reasoning');
const hasReasoning = reasoningSteps.length > 0;
// 合并推理文本
const reasoningText = reasoningSteps.map(s => s.message).join('');
// 汇总信息
const retrievedStep = statusSteps.find(s => s.stage === 'retrieved');
const totalTime = statusSteps.reduce((sum, s) => sum + (s.time || 0), 0);
// 流式时显示最后状态
const lastStatusStep = statusSteps[statusSteps.length - 1];
const isReasoningNow = isStreaming && thinking[thinking.length - 1]?.stage === 'reasoning';
// 折叠标题
const collapsedTitle = isStreaming
? isReasoningNow
? '深度思考中...'
: lastStatusStep?.message || '思考中...'
: hasReasoning
? `思考过程 (${reasoningText.length} 字)`
: `思考过程${totalTime > 0 ? ` (${totalTime.toFixed(1)}s)` : ''}`;
return (
<div className="mb-2.5 space-y-1.5 text-xs text-muted-foreground bg-muted/30 rounded-lg p-2.5">
{thinking.map((step, index) => (
<div key={index} className="flex items-center gap-2">
{getStageIcon(step.stage)}
<span>{step.message}</span>
{step.time && <span className="opacity-60">({step.time}s)</span>}
<div className="mb-2.5">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<svg
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<Brain className={cn("h-3 w-3", isReasoningNow && "animate-pulse text-primary")} />
<span>{collapsedTitle}</span>
{retrievedStep?.doc_count != null && (
<span className="opacity-60 ml-1">· {retrievedStep.doc_count} </span>
)}
{totalTime > 0 && !isStreaming && (
<span className="opacity-50 ml-1">· {totalTime.toFixed(1)}s</span>
)}
</button>
{expanded && (
<div className="mt-1.5 ml-4 space-y-2 text-xs border-l-2 border-border pl-3">
{/* 状态步骤 */}
{statusSteps.map((step, index) => (
<div key={`s-${index}`} className="flex items-center gap-1.5 text-muted-foreground">
{step.stage === 'retrieving' ? (
<FileSearch className="h-3 w-3" />
) : step.stage === 'retrieved' ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : step.stage === 'generating' ? (
<Sparkles className="h-3 w-3" />
) : (
<div className="h-1.5 w-1.5 rounded-full bg-current" />
)}
<span>{step.message}</span>
{step.time != null && <span className="opacity-50">{step.time.toFixed(1)}s</span>}
</div>
))}
{/* 检索到的文档详情 */}
{retrievedStep?.details && retrievedStep.details.length > 0 && (
<div className="mt-1 space-y-1">
<div className="text-muted-foreground font-medium"></div>
{retrievedStep.details.map((detail: string, i: number) => (
<div key={i} className="text-muted-foreground/80 pl-2 border-l border-border/50">
{detail}
</div>
))}
</div>
)}
{/* 推理内容 */}
{hasReasoning && (
<div className="mt-1">
<div className="text-muted-foreground font-medium mb-1">
{isReasoningNow ? '推理进行中...' : '推理过程:'}
</div>
<div className="text-foreground/80 whitespace-pre-wrap leading-relaxed bg-muted/30 rounded-lg p-2.5 max-h-80 overflow-y-auto">
{reasoningText}
</div>
</div>
)}
</div>
))}
)}
</div>
);
};
@@ -131,7 +202,7 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
: "rounded-2xl rounded-tl-sm"
)}>
{isAssistant && message.thinking && (
<ThinkingProcess thinking={message.thinking} />
<ThinkingProcess thinking={message.thinking} isStreaming={message.content === ""} />
)}
<div className={cn(
@@ -208,10 +279,9 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
</>
)}
<span className="text-[10px] text-muted-foreground/50 mx-1">
{message.created_at ? formatDistanceToNow(
new Date(new Date(message.created_at).getTime() + 8 * 60 * 60 * 1000),
{ addSuffix: true, locale: zhCN }
) : ""}
{message.created_at
? format(new Date(new Date(message.created_at).getTime() + 8 * 60 * 60 * 1000), "HH:mm")
: ""}
</span>
</div>
</>
+2 -1
View File
@@ -69,10 +69,11 @@ export interface ChatSession {
}
export interface ThinkingStep {
stage: 'understanding' | 'retrieving' | 'retrieved' | 'generating';
stage: 'understanding' | 'retrieving' | 'retrieved' | 'generating' | 'reasoning' | 'preparing';
message: string;
doc_count?: number;
time?: number;
details?: string[];
}
export interface ChatMessage {