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:
@@ -295,6 +295,7 @@ async def stream_message(
|
|||||||
# 使用带思考过程的流式输出
|
# 使用带思考过程的流式输出
|
||||||
full_answer = ""
|
full_answer = ""
|
||||||
thinking_steps = []
|
thinking_steps = []
|
||||||
|
first_chunk = True
|
||||||
async for result in conversation_chain.astream_with_thinking(request.message, chat_history=chat_history):
|
async for result in conversation_chain.astream_with_thinking(request.message, chat_history=chat_history):
|
||||||
if result["type"] == "thinking":
|
if result["type"] == "thinking":
|
||||||
# 收集思考过程
|
# 收集思考过程
|
||||||
@@ -305,6 +306,9 @@ async def stream_message(
|
|||||||
})
|
})
|
||||||
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||||
elif result["type"] == "chunk":
|
elif result["type"] == "chunk":
|
||||||
|
if first_chunk:
|
||||||
|
# 第一个实际内容chunk前发送reasoning完成信号
|
||||||
|
first_chunk = False
|
||||||
full_answer += result["content"]
|
full_answer += result["content"]
|
||||||
yield f"data: {json.dumps({'type': 'chunk', 'content': result['content']}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'type': 'chunk', 'content': result['content']}, ensure_ascii=False)}\n\n"
|
||||||
elif result["type"] == "complete":
|
elif result["type"] == "complete":
|
||||||
|
|||||||
+33
-13
@@ -129,38 +129,58 @@ class RAGChain:
|
|||||||
async def astream_with_sources(self, question: str):
|
async def astream_with_sources(self, question: str):
|
||||||
"""流式调用(返回答案流和文档,包含思考过程)"""
|
"""流式调用(返回答案流和文档,包含思考过程)"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# 0. 思考阶段开始
|
# 0. 思考阶段开始
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||||
|
|
||||||
# 1. 检索文档
|
# 1. 检索文档
|
||||||
yield {"type": "thinking", "stage": "retrieving", "message": "正在检索相关知识..."}
|
yield {"type": "thinking", "stage": "retrieving", "message": "正在检索相关知识..."}
|
||||||
retrieval_start = time.time()
|
retrieval_start = time.time()
|
||||||
docs = await self.retriever.ainvoke(question)
|
docs = await self.retriever.ainvoke(question)
|
||||||
retrieval_time = time.time() - retrieval_start
|
retrieval_time = time.time() - retrieval_start
|
||||||
|
|
||||||
# 发送检索结果
|
# 发送检索结果 — 包含文档标题和摘要
|
||||||
|
doc_details = []
|
||||||
|
for i, doc in enumerate(docs[:5]):
|
||||||
|
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||||
|
title = metadata.get("title", metadata.get("filename", f"文档 {i+1}"))
|
||||||
|
preview = doc.page_content[:100].replace('\n', ' ')
|
||||||
|
doc_details.append(f"**{title}**: {preview}...")
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "thinking",
|
"type": "thinking",
|
||||||
"stage": "retrieved",
|
"stage": "retrieved",
|
||||||
"message": f"找到 {len(docs)} 条相关文档",
|
"message": f"检索到 {len(docs)} 篇相关文档",
|
||||||
"doc_count": len(docs),
|
"doc_count": len(docs),
|
||||||
"time": round(retrieval_time, 2)
|
"time": round(retrieval_time, 2),
|
||||||
|
"details": doc_details
|
||||||
}
|
}
|
||||||
|
|
||||||
context = "\n\n".join(doc.page_content for doc in docs)
|
context = "\n\n".join(doc.page_content for doc in docs)
|
||||||
|
|
||||||
# 2. 构建prompt
|
# 2. 构建prompt
|
||||||
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
yield {"type": "thinking", "stage": "generating", "message": f"基于 {len(docs)} 篇文档生成回答..."}
|
||||||
messages = await self.prompt.ainvoke({"context": context, "question": question})
|
messages = await self.prompt.ainvoke({"context": context, "question": question})
|
||||||
|
|
||||||
# 3. 流式生成答案
|
# 3. 流式生成答案(捕获推理内容)
|
||||||
answer_chunks = []
|
answer_chunks = []
|
||||||
|
reasoning_parts = []
|
||||||
async for chunk in self.llm.astream(messages):
|
async for chunk in self.llm.astream(messages):
|
||||||
|
# 捕获推理内容(DeepSeek-R1/QwQ 等推理模型)
|
||||||
|
if hasattr(chunk, 'additional_kwargs') and 'reasoning_content' in chunk.additional_kwargs:
|
||||||
|
reasoning_text = chunk.additional_kwargs['reasoning_content']
|
||||||
|
if reasoning_text:
|
||||||
|
reasoning_parts.append(reasoning_text)
|
||||||
|
yield {"type": "thinking", "stage": "reasoning", "message": reasoning_text}
|
||||||
|
elif hasattr(chunk, 'reasoning_content') and chunk.reasoning_content:
|
||||||
|
reasoning_parts.append(chunk.reasoning_content)
|
||||||
|
yield {"type": "thinking", "stage": "reasoning", "message": chunk.reasoning_content}
|
||||||
|
|
||||||
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
|
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
|
||||||
answer_chunks.append(content)
|
if content:
|
||||||
yield {"type": "chunk", "content": content}
|
answer_chunks.append(content)
|
||||||
|
yield {"type": "chunk", "content": content}
|
||||||
|
|
||||||
# 4. 完成,返回sources
|
# 4. 完成,返回sources
|
||||||
total_time = time.time() - start_time
|
total_time = time.time() - start_time
|
||||||
|
|||||||
@@ -80,24 +80,41 @@ class ConversationChain:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def astream_with_thinking(self, question: str, chat_history: List[Dict] = None):
|
async def astream_with_thinking(self, question: str, chat_history: List[Dict] = None):
|
||||||
"""流式调用(包含思考过程)"""
|
"""流式调用(包含思考过程,捕获推理模型的真实推理内容)"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# 思考阶段
|
# 思考阶段
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||||
|
|
||||||
# 准备历史
|
# 准备历史
|
||||||
history_messages = self._format_history(chat_history or [])
|
history_messages = self._format_history(chat_history or [])
|
||||||
|
history_count = len([m for m in (chat_history or []) if m["role"] == "user"])
|
||||||
|
yield {"type": "thinking", "stage": "preparing", "message": f"加载对话上下文({history_count} 轮历史)..." if history_count > 0 else "准备生成回答..."}
|
||||||
|
|
||||||
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
||||||
|
|
||||||
# 流式生成
|
# 直接流式调用 LLM 以捕获推理内容
|
||||||
async for chunk in self.chain.astream({
|
prompt_messages = await self.prompt.ainvoke({
|
||||||
"question": question,
|
"question": question,
|
||||||
"chat_history": history_messages
|
"chat_history": history_messages
|
||||||
}):
|
})
|
||||||
yield {"type": "chunk", "content": chunk}
|
|
||||||
|
content_started = False
|
||||||
|
async for chunk in self.llm.astream(prompt_messages):
|
||||||
|
# 捕获推理内容(DeepSeek-R1/QwQ 等推理模型)
|
||||||
|
if hasattr(chunk, 'additional_kwargs') and 'reasoning_content' in chunk.additional_kwargs:
|
||||||
|
reasoning_text = chunk.additional_kwargs['reasoning_content']
|
||||||
|
if reasoning_text:
|
||||||
|
yield {"type": "thinking", "stage": "reasoning", "message": reasoning_text}
|
||||||
|
elif hasattr(chunk, 'reasoning_content') and chunk.reasoning_content:
|
||||||
|
yield {"type": "thinking", "stage": "reasoning", "message": chunk.reasoning_content}
|
||||||
|
|
||||||
|
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
|
||||||
|
if content:
|
||||||
|
if not content_started:
|
||||||
|
content_started = True
|
||||||
|
yield {"type": "chunk", "content": content}
|
||||||
|
|
||||||
# 完成
|
# 完成
|
||||||
total_time = time.time() - start_time
|
total_time = time.time() - start_time
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { zhCN } from "date-fns/locale";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useChatStore } from "@/store/chat";
|
import { useChatStore } from "@/store/chat";
|
||||||
import SourceReferences from "./source-references";
|
import SourceReferences from "./source-references";
|
||||||
@@ -20,28 +19,100 @@ interface MessageItemProps {
|
|||||||
selectedModel?: string;
|
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;
|
if (!thinking || thinking.length === 0) return null;
|
||||||
|
|
||||||
const getStageIcon = (stage: string) => {
|
// 分离状态步骤和推理内容
|
||||||
switch (stage) {
|
const statusSteps = thinking.filter(s => s.stage !== 'reasoning');
|
||||||
case 'understanding': return <Brain className="h-3.5 w-3.5" />;
|
const reasoningSteps = thinking.filter(s => s.stage === 'reasoning');
|
||||||
case 'retrieving': return <FileSearch className="h-3.5 w-3.5 animate-spin" />;
|
const hasReasoning = reasoningSteps.length > 0;
|
||||||
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 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 (
|
return (
|
||||||
<div className="mb-2.5 space-y-1.5 text-xs text-muted-foreground bg-muted/30 rounded-lg p-2.5">
|
<div className="mb-2.5">
|
||||||
{thinking.map((step, index) => (
|
<button
|
||||||
<div key={index} className="flex items-center gap-2">
|
onClick={() => setExpanded(!expanded)}
|
||||||
{getStageIcon(step.stage)}
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
<span>{step.message}</span>
|
>
|
||||||
{step.time && <span className="opacity-60">({step.time}s)</span>}
|
<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>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -131,7 +202,7 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
|||||||
: "rounded-2xl rounded-tl-sm"
|
: "rounded-2xl rounded-tl-sm"
|
||||||
)}>
|
)}>
|
||||||
{isAssistant && message.thinking && (
|
{isAssistant && message.thinking && (
|
||||||
<ThinkingProcess thinking={message.thinking} />
|
<ThinkingProcess thinking={message.thinking} isStreaming={message.content === ""} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -208,10 +279,9 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground/50 mx-1">
|
<span className="text-[10px] text-muted-foreground/50 mx-1">
|
||||||
{message.created_at ? formatDistanceToNow(
|
{message.created_at
|
||||||
new Date(new Date(message.created_at).getTime() + 8 * 60 * 60 * 1000),
|
? format(new Date(new Date(message.created_at).getTime() + 8 * 60 * 60 * 1000), "HH:mm")
|
||||||
{ addSuffix: true, locale: zhCN }
|
: ""}
|
||||||
) : ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -69,10 +69,11 @@ export interface ChatSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ThinkingStep {
|
export interface ThinkingStep {
|
||||||
stage: 'understanding' | 'retrieving' | 'retrieved' | 'generating';
|
stage: 'understanding' | 'retrieving' | 'retrieved' | 'generating' | 'reasoning' | 'preparing';
|
||||||
message: string;
|
message: string;
|
||||||
doc_count?: number;
|
doc_count?: number;
|
||||||
time?: number;
|
time?: number;
|
||||||
|
details?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user