From ecd5c7ff3190b4f3eddea80c8eea971c40823b85 Mon Sep 17 00:00:00 2001 From: xiaopeng <1509442308@qq.com> Date: Wed, 27 May 2026 12:37:31 +0800 Subject: [PATCH] feat: add DeepSeek official API support and capture reasoning content - Add DeepSeek provider routing: deepseek-chat/deepseek-reasoner use api.deepseek.com, other models use SiliconFlow - Add stream_with_reasoning() using raw OpenAI SDK to capture reasoning_content (langchain_openai strips this field) - RAG chain and conversation chain both use stream_with_reasoning for proper reasoning display in thinking models - Frontend model selector: grouped by provider (DeepSeek official + SiliconFlow), default changed to deepseek-chat - Regenerate message converted to streaming with reasoning capture - Minor UI: globals.css additions, chat store refactoring Co-Authored-By: Claude Opus 4.7 --- backend/src/core/config.py | 4 + backend/src/llm/siliconflow.py | 128 +++++++++---- backend/src/rag/chains.py | 30 ++- backend/src/rag/conversation_chains.py | 24 +-- env.example | 4 + web/src/app/globals.css | 56 ++++++ web/src/components/chat/chat-interface.tsx | 2 +- web/src/components/chat/model-selector.tsx | 151 ++++++++++------ web/src/store/chat.ts | 201 +++++++++++---------- 9 files changed, 385 insertions(+), 215 deletions(-) diff --git a/backend/src/core/config.py b/backend/src/core/config.py index f3adc7f..665d9d7 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -22,6 +22,10 @@ class Settings(BaseSettings): siliconflow_api_key: str siliconflow_base_url: str = "https://api.siliconflow.cn/v1" siliconflow_model: str = "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B" + + # DeepSeek官方API配置 + deepseek_api_key: str = "" + deepseek_base_url: str = "https://api.deepseek.com" # 数据库配置 database_url: str = "sqlite:///../data/database/course_agent.db" diff --git a/backend/src/llm/siliconflow.py b/backend/src/llm/siliconflow.py index 764fede..4d66b64 100644 --- a/backend/src/llm/siliconflow.py +++ b/backend/src/llm/siliconflow.py @@ -1,44 +1,63 @@ """ -硅基流动大模型API集成 +大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方 """ import os -from typing import List, Dict, Any, Optional, AsyncGenerator +from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple from langchain_openai import ChatOpenAI from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage from langchain_core.callbacks.base import BaseCallbackHandler from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler +import openai from ..core.config import get_settings settings = get_settings() +# DeepSeek 官方模型 ID 前缀(用于自动路由) +DEEPSEEK_OFFICIAL_MODELS = { + "deepseek-chat", + "deepseek-reasoner", +} + +# 模型 ID → (api_key, base_url) 的路由映射 +def _resolve_provider(model: str) -> tuple[str, str]: + """根据模型 ID 选择 API provider,返回 (api_key, base_url)""" + # DeepSeek 官方模型(不带 siliconflow 前缀的纯 deepseek-xxx) + if model in DEEPSEEK_OFFICIAL_MODELS or model.startswith("deepseek/"): + if not settings.deepseek_api_key: + raise ValueError( + f"模型 {model} 需要 DeepSeek 官方 API Key," + "请在 .env 中配置 DEEPSEEK_API_KEY" + ) + actual_model = model.replace("deepseek/", "") + return settings.deepseek_api_key, settings.deepseek_base_url, actual_model + + # 默认走 SiliconFlow + return settings.siliconflow_api_key, settings.siliconflow_base_url, model + class SiliconFlowLLM: - """硅基流动大模型客户端""" - + """大模型客户端 — 自动路由 SiliconFlow / DeepSeek 官方""" + def __init__(self, model: Optional[str] = None): - """初始化LLM客户端""" - # 设置环境变量 - os.environ["OPENAI_API_KEY"] = settings.siliconflow_api_key - os.environ["OPENAI_API_BASE"] = settings.siliconflow_base_url - - # 使用传入的模型或默认模型 - self.model_name = model or settings.siliconflow_model - print(f"[DEBUG-LLM] 初始化LLM客户端,使用模型: {self.model_name} (传入参数: {model}, 默认配置: {settings.siliconflow_model})") - - # 创建LLM实例 + raw_model = model or settings.siliconflow_model + api_key, base_url, resolved_model = _resolve_provider(raw_model) + + self.model_name = resolved_model + print(f"[LLM] 模型: {resolved_model}, API: {base_url}") + self.llm = ChatOpenAI( - model=self.model_name, - api_key=settings.siliconflow_api_key, - base_url=settings.siliconflow_base_url, + model=resolved_model, + api_key=api_key, + base_url=base_url, temperature=0.7, max_tokens=2000, streaming=True ) - # 检查实际使用的模型名称 - actual_model = getattr(self.llm, 'model_name', None) or getattr(self.llm, 'model', None) or str(self.llm) - print(f"[DEBUG-LLM] ChatOpenAI实例创建完成,实际模型: {actual_model}") - + + self._api_key = api_key + self._base_url = base_url + def chat(self, messages: List[BaseMessage], **kwargs) -> str: """同步聊天""" try: @@ -46,7 +65,7 @@ class SiliconFlowLLM: return response.content except Exception as e: raise Exception(f"LLM调用失败: {str(e)}") - + async def achat(self, messages: List[BaseMessage], **kwargs) -> str: """异步聊天""" try: @@ -54,7 +73,7 @@ class SiliconFlowLLM: return response.content except Exception as e: raise Exception(f"LLM异步调用失败: {str(e)}") - + async def stream_chat(self, messages: List[BaseMessage], **kwargs) -> AsyncGenerator[str, None]: """流式聊天""" try: @@ -63,31 +82,71 @@ class SiliconFlowLLM: yield chunk.content except Exception as e: raise Exception(f"LLM流式调用失败: {str(e)}") - + + async def stream_with_reasoning( + self, messages: List[BaseMessage], **kwargs + ) -> AsyncGenerator[Tuple[str, str], None]: + """流式调用(直接使用 OpenAI SDK,捕获推理内容) + + Yields: + (type, content) — type 为 "reasoning" 或 "content" + """ + client = openai.AsyncOpenAI( + api_key=self._api_key, + base_url=self._base_url + ) + + openai_messages = [] + for msg in messages: + if isinstance(msg, SystemMessage): + openai_messages.append({"role": "system", "content": msg.content}) + elif isinstance(msg, HumanMessage): + openai_messages.append({"role": "user", "content": msg.content}) + elif isinstance(msg, AIMessage): + openai_messages.append({"role": "assistant", "content": msg.content}) + else: + openai_messages.append({"role": "user", "content": str(msg.content)}) + + stream = await client.chat.completions.create( + model=self.model_name, + messages=openai_messages, + stream=True, + **kwargs + ) + + async for chunk in stream: + if not chunk.choices: + continue + delta = chunk.choices[0].delta + + rc = getattr(delta, 'reasoning_content', None) + if rc: + yield ("reasoning", rc) + + if delta.content: + yield ("content", delta.content) + def create_messages( - self, - user_message: str, + self, + user_message: str, system_prompt: Optional[str] = None, chat_history: Optional[List[Dict[str, str]]] = None ) -> List[BaseMessage]: """创建消息列表""" messages = [] - - # 添加系统提示 + if system_prompt: messages.append(SystemMessage(content=system_prompt)) - - # 添加聊天历史 + if chat_history: for msg in chat_history: if msg["role"] == "user": messages.append(HumanMessage(content=msg["content"])) elif msg["role"] == "assistant": messages.append(AIMessage(content=msg["content"])) - - # 添加当前用户消息 + messages.append(HumanMessage(content=user_message)) - + return messages @@ -97,12 +156,11 @@ llm_client = SiliconFlowLLM() def get_llm_client(model: Optional[str] = None) -> SiliconFlowLLM: """获取LLM客户端实例 - + Args: model: 可选的模型名称,如果提供则创建新的实例,否则返回默认实例 """ if model is None: return llm_client else: - # 为指定模型创建新实例 return SiliconFlowLLM(model=model) diff --git a/backend/src/rag/chains.py b/backend/src/rag/chains.py index bc89d46..ba3a95d 100644 --- a/backend/src/rag/chains.py +++ b/backend/src/rag/chains.py @@ -32,7 +32,9 @@ class RAGChain: model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B """ print(f"[DEBUG-RAGChain] 初始化,knowledge_base_ids: {knowledge_base_ids}, model: {model}") - self.llm = get_llm_client(model=model).llm + _sf_client = get_llm_client(model=model) + self.llm = _sf_client.llm + self.client = _sf_client self.vector_store = get_vector_store() self.knowledge_base_ids = knowledge_base_ids @@ -116,7 +118,8 @@ class RAGChain: context = "\n\n".join(doc.page_content for doc in docs) # 构建prompt - messages = await self.prompt.ainvoke({"context": context, "question": question}) + prompt_value = await self.prompt.ainvoke({"context": context, "question": question}) + messages = prompt_value.to_messages() # 流式生成答案 async for chunk in self.llm.astream(messages): @@ -161,24 +164,17 @@ class RAGChain: # 2. 构建prompt yield {"type": "thinking", "stage": "generating", "message": f"基于 {len(docs)} 篇文档生成回答..."} - messages = await self.prompt.ainvoke({"context": context, "question": question}) + prompt_value = await self.prompt.ainvoke({"context": context, "question": question}) + messages = prompt_value.to_messages() - # 3. 流式生成答案(捕获推理内容) + # 3. 流式生成答案(使用原始 OpenAI SDK 捕获推理内容) answer_chunks = [] reasoning_parts = [] - 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) - if content: + async for event_type, content in self.client.stream_with_reasoning(messages): + if event_type == "reasoning": + reasoning_parts.append(content) + yield {"type": "thinking", "stage": "reasoning", "message": content} + elif event_type == "content": answer_chunks.append(content) yield {"type": "chunk", "content": content} diff --git a/backend/src/rag/conversation_chains.py b/backend/src/rag/conversation_chains.py index 09de09f..06f8bd0 100644 --- a/backend/src/rag/conversation_chains.py +++ b/backend/src/rag/conversation_chains.py @@ -24,7 +24,9 @@ class ConversationChain: system_prompt: 系统提示词 model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B """ - self.llm = get_llm_client(model=model).llm + _sf_client = get_llm_client(model=model) + self.llm = _sf_client.llm + self.client = _sf_client self.system_prompt = system_prompt or "你是一个专业的国土空间规划知识问答助手。请基于你的知识回答用户的问题。" # 创建带历史的Prompt模板 @@ -94,24 +96,18 @@ class ConversationChain: yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."} - # 直接流式调用 LLM 以捕获推理内容 - prompt_messages = await self.prompt.ainvoke({ + # 直接使用原始 OpenAI SDK 捕获推理内容 + prompt_value = await self.prompt.ainvoke({ "question": question, "chat_history": history_messages }) + prompt_messages = prompt_value.to_messages() 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: + async for event_type, content in self.client.stream_with_reasoning(prompt_messages): + if event_type == "reasoning": + yield {"type": "thinking", "stage": "reasoning", "message": content} + elif event_type == "content": if not content_started: content_started = True yield {"type": "chunk", "content": content} diff --git a/env.example b/env.example index 6494f7f..63057a8 100644 --- a/env.example +++ b/env.example @@ -3,6 +3,10 @@ SILICONFLOW_API_KEY=your-api-key-here SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1 SILICONFLOW_MODEL=Qwen/Qwen3-30B-A3B-Thinking-2507 +# DeepSeek官方API配置(可选,不配则走硅基流动) +# DEEPSEEK_API_KEY=your-deepseek-api-key-here +DEEPSEEK_BASE_URL=https://api.deepseek.com + # 数据库配置(本地开发默认使用SQLite) DATABASE_URL=sqlite:///../data/database/course_agent.db # Docker部署使用PostgreSQL diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 5ba42de..132bd6a 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -322,6 +322,62 @@ button { cursor: pointer; } +/* Prose 修复 — 确保有序/无序列表样式正确 */ +.prose ol { + list-style-type: decimal !important; + list-style-position: outside !important; + padding-left: 1.625em !important; + margin-top: 0; + margin-bottom: 0; +} +.prose ol li { + display: list-item !important; + margin-top: 0.25em; + margin-bottom: 0.25em; +} +.prose ol li::before { + content: none !important; +} +.prose ul { + list-style-type: disc !important; + list-style-position: outside !important; + padding-left: 1.625em !important; +} +.prose ul li { + display: list-item !important; + margin-top: 0.25em; + margin-bottom: 0.25em; +} +.prose ul li::before { + content: none !important; +} +.prose p { + margin-top: 0.5em; + margin-bottom: 0.5em; +} +.prose h1, .prose h2, .prose h3, .prose h4 { + font-family: inherit; + letter-spacing: normal; + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: 600; +} +.prose h2 { font-size: 1.25em; } +.prose h3 { font-size: 1.1em; } +.prose h4 { font-size: 1em; } +.prose strong { font-weight: 700; } +.prose em { font-style: italic; } +.prose blockquote { + border-left: 3px solid hsl(var(--border)); + padding-left: 1em; + color: hsl(var(--muted-foreground)); + font-style: italic; +} +.prose hr { + border-color: hsl(var(--border)); + margin: 1.5em 0; +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; diff --git a/web/src/components/chat/chat-interface.tsx b/web/src/components/chat/chat-interface.tsx index 9c970bc..613c60c 100644 --- a/web/src/components/chat/chat-interface.tsx +++ b/web/src/components/chat/chat-interface.tsx @@ -15,7 +15,7 @@ import { knowledgeBaseAPI } from "@/lib/api"; export default function ChatInterface() { const [inputMessage, setInputMessage] = useState(""); const [isComposing, setIsComposing] = useState(false); - const [selectedModel, setSelectedModel] = useState("deepseek-ai/DeepSeek-V3"); + const [selectedModel, setSelectedModel] = useState("deepseek-chat"); const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]); const [systemKnowledgeBases, setSystemKnowledgeBases] = useState([]); const [userKnowledgeBases, setUserKnowledgeBases] = useState([]); diff --git a/web/src/components/chat/model-selector.tsx b/web/src/components/chat/model-selector.tsx index 220ea11..c566d08 100644 --- a/web/src/components/chat/model-selector.tsx +++ b/web/src/components/chat/model-selector.tsx @@ -8,9 +8,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, + DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; import { Badge } from "@/components/ui/badge"; -import { ChevronDown, Cpu, Zap, Sparkles } from "lucide-react"; +import { ChevronDown, Cpu, Zap, Sparkles, Brain } from "lucide-react"; import { cn } from "@/lib/utils"; export interface ModelOption { @@ -18,6 +19,7 @@ export interface ModelOption { name: string; description: string; provider: string; + providerLabel: string; icon?: React.ComponentType<{ className?: string }>; } @@ -27,31 +29,64 @@ interface ModelSelectorProps { className?: string; } -const models: ModelOption[] = [ +const modelGroups = [ { - id: "deepseek-ai/DeepSeek-V3", - name: "DeepSeek-V3", - description: "DeepSeek 最新版本,强大的推理能力", - provider: "DeepSeek", - icon: Sparkles, + provider: "deepseek-official", + label: "DeepSeek 官方", + models: [ + { + id: "deepseek-chat", + name: "DeepSeek-V3", + description: "DeepSeek 最新通用模型,速度快、能力强", + provider: "deepseek-official", + providerLabel: "DeepSeek", + icon: Sparkles, + }, + { + id: "deepseek-reasoner", + name: "DeepSeek-R1", + description: "深度推理模型,适合复杂分析任务", + provider: "deepseek-official", + providerLabel: "DeepSeek", + icon: Brain, + }, + ], }, { - id: "Qwen/QwQ-32B", - name: "QwQ-32B", - description: "Qwen 量子化模型,高效推理", - provider: "Qwen", - icon: Zap, + provider: "siliconflow", + label: "SiliconFlow(硅基流动)", + models: [ + { + id: "deepseek-ai/DeepSeek-V3", + name: "DeepSeek-V3", + description: "通过硅基流动调用,稳定的推理能力", + provider: "siliconflow", + providerLabel: "硅基流动", + icon: Sparkles, + }, + { + id: "Qwen/QwQ-32B", + name: "QwQ-32B", + description: "Qwen 推理模型,高效准确", + provider: "siliconflow", + providerLabel: "硅基流动", + icon: Zap, + }, + ], }, ]; -export default function ModelSelector({ - selectedModel, - onModelChange, - className +const allModels = modelGroups.flatMap((g) => g.models); + +export default function ModelSelector({ + selectedModel, + onModelChange, + className, }: ModelSelectorProps) { const [isOpen, setIsOpen] = useState(false); - - const selectedModelData = models.find(model => model.id === selectedModel) || models[0]; + + const selectedModelData = + allModels.find((model) => model.id === selectedModel) || allModels[0]; const IconComponent = selectedModelData.icon || Cpu; return ( @@ -68,53 +103,55 @@ export default function ModelSelector({ > {selectedModelData.name} - {selectedModelData.name.split(' ')[0]} + {selectedModelData.name.split(" ")[0]} - +
选择AI模型
- {models.map((model) => { - const ModelIcon = model.icon || Cpu; - const isSelected = model.id === selectedModel; - - return ( - { - onModelChange(model.id); - setIsOpen(false); - }} - className={cn( - "flex items-start space-x-3 p-3 cursor-pointer", - isSelected && "bg-muted/50" - )} - > - -
-
- {model.name} - {isSelected && ( - - 已选择 - + {modelGroups.map((group) => ( +
+ + {group.label} + + {group.models.map((model) => { + const ModelIcon = model.icon || Cpu; + const isSelected = model.id === selectedModel; + + return ( + { + onModelChange(model.id); + setIsOpen(false); + }} + className={cn( + "flex items-start space-x-3 p-3 cursor-pointer", + isSelected && "bg-muted/50" )} -
-

- {model.description} -

-
- - {model.provider} - -
-
- - ); - })} + > + +
+
+ {model.name} + {isSelected && ( + + 已选择 + + )} +
+

+ {model.description} +

+
+ + ); + })} +
+ ))}
); diff --git a/web/src/store/chat.ts b/web/src/store/chat.ts index ce4db14..5cf7da7 100644 --- a/web/src/store/chat.ts +++ b/web/src/store/chat.ts @@ -255,6 +255,36 @@ export const useChatStore = create((set, get) => ({ let chunkCount = 0; let totalChars = 0; let thinkingSteps: ThinkingStep[] = []; + let contentBuffer = ''; + let thinkingBuffer: ThinkingStep[] = []; + let rafId: number | null = null; + + const flushBuffer = () => { + rafId = null; + const bufferedContent = contentBuffer; + const bufferedThinking = thinkingBuffer.length > 0 ? [...thinkingBuffer] : null; + contentBuffer = ''; + thinkingBuffer = []; + + if (!bufferedContent && !bufferedThinking) return; + + set((state) => ({ + messages: state.messages.map(msg => { + if (msg.id !== assistantMessage.id) return msg; + return { + ...msg, + ...(bufferedContent ? { content: msg.content + bufferedContent } : {}), + ...(bufferedThinking ? { thinking: [...(msg.thinking || []), ...bufferedThinking] } : {}), + }; + }), + })); + }; + + const scheduleFlush = () => { + if (rafId === null) { + rafId = requestAnimationFrame(flushBuffer); + } + }; try { await chatAPI.streamMessage( @@ -264,59 +294,37 @@ export const useChatStore = create((set, get) => ({ knowledgeBaseIds, abortController.signal, // 新增参数 (chunk: string) => { - // 解析chunk - try { - const data = JSON.parse(chunk); - - if (data.type === 'thinking') { - // 处理思考过程 - thinkingSteps.push({ - stage: data.stage, - message: data.message, - doc_count: data.doc_count, - time: data.time - }); - - // 立即更新思考过程到UI - requestAnimationFrame(() => { - set((state) => ({ - messages: state.messages.map(msg => - msg.id === assistantMessage.id - ? { ...msg, thinking: [...thinkingSteps] } - : msg - ), - })); - }); - } else if (data.type === 'chunk') { - // 处理内容chunk - chunkCount++; - totalChars += data.content.length; + if (chunk.startsWith('{')) { + try { + const data = JSON.parse(chunk); - requestAnimationFrame(() => { - set((state) => ({ - messages: state.messages.map(msg => - msg.id === assistantMessage.id - ? { ...msg, content: msg.content + data.content } - : msg - ), - })); - }); + if (data.type === 'thinking') { + const step: ThinkingStep = { + stage: data.stage, + message: data.message, + doc_count: data.doc_count, + time: data.time + }; + thinkingSteps.push(step); + thinkingBuffer.push(step); + scheduleFlush(); + return; + } else if (data.type === 'chunk') { + chunkCount++; + totalChars += data.content.length; + contentBuffer += data.content; + scheduleFlush(); + return; + } + } catch (e) { + // JSON 解析失败,按纯文本处理 } - } catch (e) { - // 向后兼容:纯文本chunk - chunkCount++; - totalChars += chunk.length; - - requestAnimationFrame(() => { - set((state) => ({ - messages: state.messages.map(msg => - msg.id === assistantMessage.id - ? { ...msg, content: msg.content + chunk } - : msg - ), - })); - }); } + + chunkCount++; + totalChars += chunk.length; + contentBuffer += chunk; + scheduleFlush(); }, (_sessionId: number, messageId?: number, userMessageId?: number) => { if (messageId) { @@ -438,10 +446,37 @@ export const useChatStore = create((set, get) => ({ abortController, }); - // 判断模式:检查之前的消息中是否有知识库相关内容 const mode = "normal"; let thinkingSteps: any[] = []; + let regenContentBuffer = ''; + let regenThinkingBuffer: any[] = []; + let regenRafId: number | null = null; + + const regenFlush = () => { + regenRafId = null; + const bc = regenContentBuffer; + const bt = regenThinkingBuffer.length > 0 ? [...regenThinkingBuffer] : null; + regenContentBuffer = ''; + regenThinkingBuffer = []; + if (!bc && !bt) return; + set((state) => ({ + messages: state.messages.map(msg => { + if (msg.id !== assistantPlaceholder.id) return msg; + return { + ...msg, + ...(bc ? { content: msg.content + bc } : {}), + ...(bt ? { thinking: [...(msg.thinking || []), ...bt] } : {}), + }; + }), + })); + }; + + const regenSchedule = () => { + if (regenRafId === null) { + regenRafId = requestAnimationFrame(regenFlush); + } + }; try { await chatAPI.streamMessage( @@ -451,48 +486,32 @@ export const useChatStore = create((set, get) => ({ undefined, // knowledgeBaseIds abortController.signal, (chunk: string) => { - // 处理 chunk - try { - const data = JSON.parse(chunk); - if (data.type === 'thinking') { - thinkingSteps.push({ - stage: data.stage, - message: data.message, - doc_count: data.doc_count, - time: data.time - }); - requestAnimationFrame(() => { - set((state) => ({ - messages: state.messages.map(msg => - msg.id === assistantPlaceholder.id - ? { ...msg, thinking: [...thinkingSteps] } - : msg - ), - })); - }); - } else if (data.type === 'chunk') { - requestAnimationFrame(() => { - set((state) => ({ - messages: state.messages.map(msg => - msg.id === assistantPlaceholder.id - ? { ...msg, content: msg.content + data.content } - : msg - ), - })); - }); + if (chunk.startsWith('{')) { + try { + const data = JSON.parse(chunk); + if (data.type === 'thinking') { + const step = { + stage: data.stage, + message: data.message, + doc_count: data.doc_count, + time: data.time + }; + thinkingSteps.push(step); + regenThinkingBuffer.push(step); + regenSchedule(); + return; + } else if (data.type === 'chunk') { + regenContentBuffer += data.content; + regenSchedule(); + return; + } + } catch { + // JSON 解析失败,按纯文本处理 } - } catch { - // 纯文本 chunk - requestAnimationFrame(() => { - set((state) => ({ - messages: state.messages.map(msg => - msg.id === assistantPlaceholder.id - ? { ...msg, content: msg.content + chunk } - : msg - ), - })); - }); } + + regenContentBuffer += chunk; + regenSchedule(); }, (_sessionId: number, messageId?: number) => { // onComplete: 只替换 assistant 占位符 ID,不重新加载