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 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,10 @@ class Settings(BaseSettings):
|
|||||||
siliconflow_base_url: str = "https://api.siliconflow.cn/v1"
|
siliconflow_base_url: str = "https://api.siliconflow.cn/v1"
|
||||||
siliconflow_model: str = "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B"
|
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"
|
database_url: str = "sqlite:///../data/database/course_agent.db"
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,62 @@
|
|||||||
"""
|
"""
|
||||||
硅基流动大模型API集成
|
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方
|
||||||
"""
|
"""
|
||||||
import os
|
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_openai import ChatOpenAI
|
||||||
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
||||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||||
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
|
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
|
||||||
|
import openai
|
||||||
|
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
|
|
||||||
settings = 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:
|
class SiliconFlowLLM:
|
||||||
"""硅基流动大模型客户端"""
|
"""大模型客户端 — 自动路由 SiliconFlow / DeepSeek 官方"""
|
||||||
|
|
||||||
def __init__(self, model: Optional[str] = None):
|
def __init__(self, model: Optional[str] = None):
|
||||||
"""初始化LLM客户端"""
|
raw_model = model or settings.siliconflow_model
|
||||||
# 设置环境变量
|
api_key, base_url, resolved_model = _resolve_provider(raw_model)
|
||||||
os.environ["OPENAI_API_KEY"] = settings.siliconflow_api_key
|
|
||||||
os.environ["OPENAI_API_BASE"] = settings.siliconflow_base_url
|
|
||||||
|
|
||||||
# 使用传入的模型或默认模型
|
self.model_name = resolved_model
|
||||||
self.model_name = model or settings.siliconflow_model
|
print(f"[LLM] 模型: {resolved_model}, API: {base_url}")
|
||||||
print(f"[DEBUG-LLM] 初始化LLM客户端,使用模型: {self.model_name} (传入参数: {model}, 默认配置: {settings.siliconflow_model})")
|
|
||||||
|
|
||||||
# 创建LLM实例
|
|
||||||
self.llm = ChatOpenAI(
|
self.llm = ChatOpenAI(
|
||||||
model=self.model_name,
|
model=resolved_model,
|
||||||
api_key=settings.siliconflow_api_key,
|
api_key=api_key,
|
||||||
base_url=settings.siliconflow_base_url,
|
base_url=base_url,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=2000,
|
max_tokens=2000,
|
||||||
streaming=True
|
streaming=True
|
||||||
)
|
)
|
||||||
# 检查实际使用的模型名称
|
|
||||||
actual_model = getattr(self.llm, 'model_name', None) or getattr(self.llm, 'model', None) or str(self.llm)
|
self._api_key = api_key
|
||||||
print(f"[DEBUG-LLM] ChatOpenAI实例创建完成,实际模型: {actual_model}")
|
self._base_url = base_url
|
||||||
|
|
||||||
def chat(self, messages: List[BaseMessage], **kwargs) -> str:
|
def chat(self, messages: List[BaseMessage], **kwargs) -> str:
|
||||||
"""同步聊天"""
|
"""同步聊天"""
|
||||||
@@ -64,6 +83,49 @@ class SiliconFlowLLM:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"LLM流式调用失败: {str(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(
|
def create_messages(
|
||||||
self,
|
self,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
@@ -73,11 +135,9 @@ class SiliconFlowLLM:
|
|||||||
"""创建消息列表"""
|
"""创建消息列表"""
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
# 添加系统提示
|
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
messages.append(SystemMessage(content=system_prompt))
|
messages.append(SystemMessage(content=system_prompt))
|
||||||
|
|
||||||
# 添加聊天历史
|
|
||||||
if chat_history:
|
if chat_history:
|
||||||
for msg in chat_history:
|
for msg in chat_history:
|
||||||
if msg["role"] == "user":
|
if msg["role"] == "user":
|
||||||
@@ -85,7 +145,6 @@ class SiliconFlowLLM:
|
|||||||
elif msg["role"] == "assistant":
|
elif msg["role"] == "assistant":
|
||||||
messages.append(AIMessage(content=msg["content"]))
|
messages.append(AIMessage(content=msg["content"]))
|
||||||
|
|
||||||
# 添加当前用户消息
|
|
||||||
messages.append(HumanMessage(content=user_message))
|
messages.append(HumanMessage(content=user_message))
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
@@ -104,5 +163,4 @@ def get_llm_client(model: Optional[str] = None) -> SiliconFlowLLM:
|
|||||||
if model is None:
|
if model is None:
|
||||||
return llm_client
|
return llm_client
|
||||||
else:
|
else:
|
||||||
# 为指定模型创建新实例
|
|
||||||
return SiliconFlowLLM(model=model)
|
return SiliconFlowLLM(model=model)
|
||||||
|
|||||||
+13
-17
@@ -32,7 +32,9 @@ class RAGChain:
|
|||||||
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||||
"""
|
"""
|
||||||
print(f"[DEBUG-RAGChain] 初始化,knowledge_base_ids: {knowledge_base_ids}, model: {model}")
|
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.vector_store = get_vector_store()
|
||||||
self.knowledge_base_ids = knowledge_base_ids
|
self.knowledge_base_ids = knowledge_base_ids
|
||||||
|
|
||||||
@@ -116,7 +118,8 @@ class RAGChain:
|
|||||||
context = "\n\n".join(doc.page_content for doc in docs)
|
context = "\n\n".join(doc.page_content for doc in docs)
|
||||||
|
|
||||||
# 构建prompt
|
# 构建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):
|
async for chunk in self.llm.astream(messages):
|
||||||
@@ -161,24 +164,17 @@ class RAGChain:
|
|||||||
|
|
||||||
# 2. 构建prompt
|
# 2. 构建prompt
|
||||||
yield {"type": "thinking", "stage": "generating", "message": f"基于 {len(docs)} 篇文档生成回答..."}
|
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 = []
|
answer_chunks = []
|
||||||
reasoning_parts = []
|
reasoning_parts = []
|
||||||
async for chunk in self.llm.astream(messages):
|
async for event_type, content in self.client.stream_with_reasoning(messages):
|
||||||
# 捕获推理内容(DeepSeek-R1/QwQ 等推理模型)
|
if event_type == "reasoning":
|
||||||
if hasattr(chunk, 'additional_kwargs') and 'reasoning_content' in chunk.additional_kwargs:
|
reasoning_parts.append(content)
|
||||||
reasoning_text = chunk.additional_kwargs['reasoning_content']
|
yield {"type": "thinking", "stage": "reasoning", "message": content}
|
||||||
if reasoning_text:
|
elif event_type == "content":
|
||||||
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:
|
|
||||||
answer_chunks.append(content)
|
answer_chunks.append(content)
|
||||||
yield {"type": "chunk", "content": content}
|
yield {"type": "chunk", "content": content}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ class ConversationChain:
|
|||||||
system_prompt: 系统提示词
|
system_prompt: 系统提示词
|
||||||
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
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 "你是一个专业的国土空间规划知识问答助手。请基于你的知识回答用户的问题。"
|
self.system_prompt = system_prompt or "你是一个专业的国土空间规划知识问答助手。请基于你的知识回答用户的问题。"
|
||||||
|
|
||||||
# 创建带历史的Prompt模板
|
# 创建带历史的Prompt模板
|
||||||
@@ -94,24 +96,18 @@ class ConversationChain:
|
|||||||
|
|
||||||
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
||||||
|
|
||||||
# 直接流式调用 LLM 以捕获推理内容
|
# 直接使用原始 OpenAI SDK 捕获推理内容
|
||||||
prompt_messages = await self.prompt.ainvoke({
|
prompt_value = await self.prompt.ainvoke({
|
||||||
"question": question,
|
"question": question,
|
||||||
"chat_history": history_messages
|
"chat_history": history_messages
|
||||||
})
|
})
|
||||||
|
prompt_messages = prompt_value.to_messages()
|
||||||
|
|
||||||
content_started = False
|
content_started = False
|
||||||
async for chunk in self.llm.astream(prompt_messages):
|
async for event_type, content in self.client.stream_with_reasoning(prompt_messages):
|
||||||
# 捕获推理内容(DeepSeek-R1/QwQ 等推理模型)
|
if event_type == "reasoning":
|
||||||
if hasattr(chunk, 'additional_kwargs') and 'reasoning_content' in chunk.additional_kwargs:
|
yield {"type": "thinking", "stage": "reasoning", "message": content}
|
||||||
reasoning_text = chunk.additional_kwargs['reasoning_content']
|
elif event_type == "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:
|
if not content_started:
|
||||||
content_started = True
|
content_started = True
|
||||||
yield {"type": "chunk", "content": content}
|
yield {"type": "chunk", "content": content}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ SILICONFLOW_API_KEY=your-api-key-here
|
|||||||
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
|
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
|
||||||
SILICONFLOW_MODEL=Qwen/Qwen3-30B-A3B-Thinking-2507
|
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)
|
# 数据库配置(本地开发默认使用SQLite)
|
||||||
DATABASE_URL=sqlite:///../data/database/course_agent.db
|
DATABASE_URL=sqlite:///../data/database/course_agent.db
|
||||||
# Docker部署使用PostgreSQL
|
# Docker部署使用PostgreSQL
|
||||||
|
|||||||
@@ -322,6 +322,62 @@ button {
|
|||||||
cursor: pointer;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { knowledgeBaseAPI } from "@/lib/api";
|
|||||||
export default function ChatInterface() {
|
export default function ChatInterface() {
|
||||||
const [inputMessage, setInputMessage] = useState("");
|
const [inputMessage, setInputMessage] = useState("");
|
||||||
const [isComposing, setIsComposing] = useState(false);
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState("deepseek-ai/DeepSeek-V3");
|
const [selectedModel, setSelectedModel] = useState("deepseek-chat");
|
||||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
||||||
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
const [userKnowledgeBases, setUserKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [userKnowledgeBases, setUserKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface ModelOption {
|
export interface ModelOption {
|
||||||
@@ -18,6 +19,7 @@ export interface ModelOption {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
providerLabel: string;
|
||||||
icon?: React.ComponentType<{ className?: string }>;
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,31 +29,64 @@ interface ModelSelectorProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models: ModelOption[] = [
|
const modelGroups = [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "siliconflow",
|
||||||
|
label: "SiliconFlow(硅基流动)",
|
||||||
|
models: [
|
||||||
{
|
{
|
||||||
id: "deepseek-ai/DeepSeek-V3",
|
id: "deepseek-ai/DeepSeek-V3",
|
||||||
name: "DeepSeek-V3",
|
name: "DeepSeek-V3",
|
||||||
description: "DeepSeek 最新版本,强大的推理能力",
|
description: "通过硅基流动调用,稳定的推理能力",
|
||||||
provider: "DeepSeek",
|
provider: "siliconflow",
|
||||||
|
providerLabel: "硅基流动",
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Qwen/QwQ-32B",
|
id: "Qwen/QwQ-32B",
|
||||||
name: "QwQ-32B",
|
name: "QwQ-32B",
|
||||||
description: "Qwen 量子化模型,高效推理",
|
description: "Qwen 推理模型,高效准确",
|
||||||
provider: "Qwen",
|
provider: "siliconflow",
|
||||||
|
providerLabel: "硅基流动",
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const allModels = modelGroups.flatMap((g) => g.models);
|
||||||
|
|
||||||
export default function ModelSelector({
|
export default function ModelSelector({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
className
|
className,
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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;
|
const IconComponent = selectedModelData.icon || Cpu;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,16 +103,21 @@ export default function ModelSelector({
|
|||||||
>
|
>
|
||||||
<IconComponent className="w-4 h-4 mr-2" />
|
<IconComponent className="w-4 h-4 mr-2" />
|
||||||
<span className="hidden sm:inline">{selectedModelData.name}</span>
|
<span className="hidden sm:inline">{selectedModelData.name}</span>
|
||||||
<span className="sm:hidden">{selectedModelData.name.split(' ')[0]}</span>
|
<span className="sm:hidden">{selectedModelData.name.split(" ")[0]}</span>
|
||||||
<ChevronDown className="w-3 h-3 ml-1 opacity-50" />
|
<ChevronDown className="w-3 h-3 ml-1 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-64">
|
<DropdownMenuContent align="start" className="w-72">
|
||||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
选择AI模型
|
选择AI模型
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{models.map((model) => {
|
{modelGroups.map((group) => (
|
||||||
|
<div key={group.provider}>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground/70 font-normal px-2 pt-2">
|
||||||
|
{group.label}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{group.models.map((model) => {
|
||||||
const ModelIcon = model.icon || Cpu;
|
const ModelIcon = model.icon || Cpu;
|
||||||
const isSelected = model.id === selectedModel;
|
const isSelected = model.id === selectedModel;
|
||||||
|
|
||||||
@@ -106,15 +146,12 @@ export default function ModelSelector({
|
|||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{model.description}
|
{model.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-1 mt-1">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{model.provider}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
+89
-70
@@ -255,6 +255,36 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
let chunkCount = 0;
|
let chunkCount = 0;
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
let thinkingSteps: ThinkingStep[] = [];
|
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 {
|
try {
|
||||||
await chatAPI.streamMessage(
|
await chatAPI.streamMessage(
|
||||||
@@ -264,59 +294,37 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
knowledgeBaseIds,
|
knowledgeBaseIds,
|
||||||
abortController.signal, // 新增参数
|
abortController.signal, // 新增参数
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
// 解析chunk
|
if (chunk.startsWith('{')) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(chunk);
|
const data = JSON.parse(chunk);
|
||||||
|
|
||||||
if (data.type === 'thinking') {
|
if (data.type === 'thinking') {
|
||||||
// 处理思考过程
|
const step: ThinkingStep = {
|
||||||
thinkingSteps.push({
|
|
||||||
stage: data.stage,
|
stage: data.stage,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
doc_count: data.doc_count,
|
doc_count: data.doc_count,
|
||||||
time: data.time
|
time: data.time
|
||||||
});
|
};
|
||||||
|
thinkingSteps.push(step);
|
||||||
// 立即更新思考过程到UI
|
thinkingBuffer.push(step);
|
||||||
requestAnimationFrame(() => {
|
scheduleFlush();
|
||||||
set((state) => ({
|
return;
|
||||||
messages: state.messages.map(msg =>
|
|
||||||
msg.id === assistantMessage.id
|
|
||||||
? { ...msg, thinking: [...thinkingSteps] }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
} else if (data.type === 'chunk') {
|
} else if (data.type === 'chunk') {
|
||||||
// 处理内容chunk
|
|
||||||
chunkCount++;
|
chunkCount++;
|
||||||
totalChars += data.content.length;
|
totalChars += data.content.length;
|
||||||
|
contentBuffer += data.content;
|
||||||
requestAnimationFrame(() => {
|
scheduleFlush();
|
||||||
set((state) => ({
|
return;
|
||||||
messages: state.messages.map(msg =>
|
|
||||||
msg.id === assistantMessage.id
|
|
||||||
? { ...msg, content: msg.content + data.content }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 向后兼容:纯文本chunk
|
// JSON 解析失败,按纯文本处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
chunkCount++;
|
chunkCount++;
|
||||||
totalChars += chunk.length;
|
totalChars += chunk.length;
|
||||||
|
contentBuffer += chunk;
|
||||||
requestAnimationFrame(() => {
|
scheduleFlush();
|
||||||
set((state) => ({
|
|
||||||
messages: state.messages.map(msg =>
|
|
||||||
msg.id === assistantMessage.id
|
|
||||||
? { ...msg, content: msg.content + chunk }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
(_sessionId: number, messageId?: number, userMessageId?: number) => {
|
(_sessionId: number, messageId?: number, userMessageId?: number) => {
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
@@ -438,10 +446,37 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
abortController,
|
abortController,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 判断模式:检查之前的消息中是否有知识库相关内容
|
|
||||||
const mode = "normal";
|
const mode = "normal";
|
||||||
|
|
||||||
let thinkingSteps: any[] = [];
|
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 {
|
try {
|
||||||
await chatAPI.streamMessage(
|
await chatAPI.streamMessage(
|
||||||
@@ -451,48 +486,32 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
undefined, // knowledgeBaseIds
|
undefined, // knowledgeBaseIds
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
// 处理 chunk
|
if (chunk.startsWith('{')) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(chunk);
|
const data = JSON.parse(chunk);
|
||||||
if (data.type === 'thinking') {
|
if (data.type === 'thinking') {
|
||||||
thinkingSteps.push({
|
const step = {
|
||||||
stage: data.stage,
|
stage: data.stage,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
doc_count: data.doc_count,
|
doc_count: data.doc_count,
|
||||||
time: data.time
|
time: data.time
|
||||||
});
|
};
|
||||||
requestAnimationFrame(() => {
|
thinkingSteps.push(step);
|
||||||
set((state) => ({
|
regenThinkingBuffer.push(step);
|
||||||
messages: state.messages.map(msg =>
|
regenSchedule();
|
||||||
msg.id === assistantPlaceholder.id
|
return;
|
||||||
? { ...msg, thinking: [...thinkingSteps] }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
} else if (data.type === 'chunk') {
|
} else if (data.type === 'chunk') {
|
||||||
requestAnimationFrame(() => {
|
regenContentBuffer += data.content;
|
||||||
set((state) => ({
|
regenSchedule();
|
||||||
messages: state.messages.map(msg =>
|
return;
|
||||||
msg.id === assistantPlaceholder.id
|
|
||||||
? { ...msg, content: msg.content + data.content }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 纯文本 chunk
|
// JSON 解析失败,按纯文本处理
|
||||||
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) => {
|
(_sessionId: number, messageId?: number) => {
|
||||||
// onComplete: 只替换 assistant 占位符 ID,不重新加载
|
// onComplete: 只替换 assistant 占位符 ID,不重新加载
|
||||||
|
|||||||
Reference in New Issue
Block a user