Merge pull request #264 from 1014TaoTao/2.2.0

feat(ai): 替换OpenAI客户端为LangChain并添加消息折叠功能
This commit is contained in:
fastapiadmin
2025-12-28 12:06:59 +08:00
committed by GitHub
3 changed files with 90 additions and 69 deletions
@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from typing import AsyncGenerator from typing import Any, AsyncGenerator
from openai import AsyncOpenAI, OpenAI from langchain_openai import ChatOpenAI
from openai.types.chat.chat_completion import ChatCompletion from langchain_core.messages import SystemMessage, HumanMessage
import httpx
from app.config.setting import settings from app.config.setting import settings
from app.core.logger import log from app.core.logger import log
@@ -15,18 +14,13 @@ class AIClient:
""" """
def __init__(self): def __init__(self):
self.model = settings.OPENAI_MODEL # 使用LangChain的ChatOpenAI类
# 创建一个不带冲突参数的httpx客户端 self.client = ChatOpenAI(
self.http_client = httpx.AsyncClient(
timeout=30.0,
follow_redirects=True
)
# 使用自定义的http客户端
self.client = AsyncOpenAI(
api_key=settings.OPENAI_API_KEY, api_key=settings.OPENAI_API_KEY,
base_url=settings.OPENAI_BASE_URL, base_url=settings.OPENAI_BASE_URL,
http_client=self.http_client model=settings.OPENAI_MODEL,
temperature=0.7,
streaming=True
) )
def _friendly_error_message(self, e: Exception) -> str: def _friendly_error_message(self, e: Exception) -> str:
@@ -73,7 +67,7 @@ class AIClient:
# 默认兜底 # 默认兜底
return f"处理您的请求时出现错误:{msg}" return f"处理您的请求时出现错误:{msg}"
async def process(self, query: str) -> AsyncGenerator[str, None]: async def process(self, query: str) -> AsyncGenerator[str, Any]:
""" """
处理查询并返回流式响应 处理查询并返回流式响应
@@ -81,53 +75,29 @@ class AIClient:
- query (str): 用户查询。 - query (str): 用户查询。
返回: 返回:
- AsyncGenerator[str, None]: 流式响应内容。 - AsyncGenerator[str, Any]: 流式响应内容。
""" """
system_prompt = """你是一个有用的AI助手,可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。""" system_prompt = """你是一个有用的AI助手,可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。"""
try: try:
# 使用 await 调用异步客户端 # 使用LangChain的异步流式生成
response = await self.client.chat.completions.create( messages = [
model=self.model, SystemMessage(content=system_prompt),
messages=[ HumanMessage(content=query)
{"role": "system", "content": system_prompt}, ]
{"role": "user", "content": query}
],
stream=True
)
# 流式返回响应 # 使用LangChain的流式响应
async for chunk in response: async for chunk in self.client.astream(messages):
if chunk.choices and chunk.choices[0].delta.content: if chunk.content:
yield chunk.choices[0].delta.content # 确保只返回字符串类型
if isinstance(chunk.content, str):
yield chunk.content
elif isinstance(chunk.content, (list, dict)):
# 处理列表或字典类型的内容
import json
yield json.dumps(chunk.content)
except Exception as e: except Exception as e:
# 记录详细错误,返回友好提示 # 记录详细错误,返回友好提示
log.error(f"AI处理查询失败: {str(e)}") log.error(f"AI处理查询失败: {str(e)}")
yield self._friendly_error_message(e) yield self._friendly_error_message(e)
async def close(self) -> None:
"""
关闭客户端连接
"""
import asyncio
# 安全关闭OpenAI客户端
if hasattr(self, 'client'):
try:
# 检查事件循环是否仍在运行
loop = asyncio.get_event_loop()
if loop.is_running():
await self.client.close()
except Exception as e:
log.debug(f"关闭OpenAI客户端时发生异常: {str(e)}")
# 安全关闭HTTP客户端
if hasattr(self, 'http_client'):
try:
# 检查事件循环是否仍在运行
loop = asyncio.get_event_loop()
if loop.is_running():
await self.http_client.aclose()
except Exception as e:
log.debug(f"关闭HTTP客户端时发生异常: {str(e)}")
+3 -1
View File
@@ -40,4 +40,6 @@ fastapi-limiter==0.1.6
# motor==3.6.0 # mongodb 驱动 # motor==3.6.0 # mongodb 驱动
# amqp==5.3.1 # amqp==5.3.1
# python-socketio==5.14.3 # python-socketio==5.14.3
langchain
langchain-openai
@@ -81,8 +81,23 @@
</strong> </strong>
</div> </div>
<div class="message-body"> <div class="message-body">
<!-- 折叠/展开按钮 -->
<el-button
v-if="message.content.length > 200"
text
size="small"
:icon="message.collapsed ? ArrowDown : ArrowUp"
class="fold-button"
@click="toggleMessageFold(message)"
>
{{ message.collapsed ? "展开" : "收起" }}
</el-button>
<!-- 实时显示累积的消息内容 --> <!-- 实时显示累积的消息内容 -->
<div class="message-text" v-html="formatMessage(message.content)"></div> <div
class="message-text"
:class="{ collapsed: message.collapsed }"
v-html="formatMessage(message.content)"
></div>
<!-- 只有内容为空且loading时才显示打字指示器 --> <!-- 只有内容为空且loading时才显示打字指示器 -->
<div <div
v-if="message.type === 'assistant' && message.loading && !message.content" v-if="message.type === 'assistant' && message.loading && !message.content"
@@ -169,6 +184,8 @@ import {
Setting, Setting,
CopyDocument, CopyDocument,
RefreshLeft, RefreshLeft,
ArrowDown,
ArrowUp,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import markdownItHighlightjs from "markdown-it-highlightjs"; import markdownItHighlightjs from "markdown-it-highlightjs";
@@ -182,6 +199,7 @@ interface ChatMessage {
content: string; content: string;
timestamp: number; timestamp: number;
loading?: boolean; loading?: boolean;
collapsed?: boolean;
} }
// 创建MarkdownIt实例并配置插件 // 创建MarkdownIt实例并配置插件
@@ -269,13 +287,8 @@ const connectWebSocket = () => {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { // 直接处理文本消息,因为后端发送的是流式文本而不是JSON
const data = JSON.parse(event.data); handleWebSocketMessage({ content: event.data });
handleWebSocketMessage(data);
} catch (err) {
console.error("解析消息失败:", err);
handleWebSocketMessage({ content: event.data });
}
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
@@ -287,6 +300,8 @@ const connectWebSocket = () => {
messages.value.forEach((message) => { messages.value.forEach((message) => {
if (message.type === "assistant" && message.loading) { if (message.type === "assistant" && message.loading) {
message.loading = false; message.loading = false;
// 检查消息长度并设置折叠状态
message.collapsed = message.content.length > 200;
} }
}); });
}; };
@@ -301,6 +316,8 @@ const connectWebSocket = () => {
messages.value.forEach((message) => { messages.value.forEach((message) => {
if (message.type === "assistant" && message.loading) { if (message.type === "assistant" && message.loading) {
message.loading = false; message.loading = false;
// 检查消息长度并设置折叠状态
message.collapsed = message.content.length > 200;
} }
}); });
}; };
@@ -391,11 +408,8 @@ const sendMessage = async () => {
try { try {
// 发送消息到 WebSocket // 发送消息到 WebSocket
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
const payload = { // 直接发送纯文本消息,因为后端期望接收纯文本
message, ws.send(message);
timestamp: Date.now(),
};
ws.send(JSON.stringify(payload));
} else { } else {
throw new Error("WebSocket 连接未建立"); throw new Error("WebSocket 连接未建立");
} }
@@ -417,6 +431,8 @@ const addMessage = (type: "user" | "assistant", content: string) => {
type, type,
content, content,
timestamp: Date.now(), timestamp: Date.now(),
// 长消息自动折叠
collapsed: content.length > 200,
}; };
messages.value.push(message); messages.value.push(message);
nextTick(() => scrollToBottom()); nextTick(() => scrollToBottom());
@@ -459,6 +475,11 @@ const copyMessage = async (content: string) => {
} }
}; };
// 折叠/展开消息
const toggleMessageFold = (message: ChatMessage) => {
message.collapsed = !message.collapsed;
};
// 滚动到底部 // 滚动到底部
const scrollToBottom = () => { const scrollToBottom = () => {
nextTick(() => { nextTick(() => {
@@ -685,11 +706,39 @@ onUnmounted(() => {
} }
.message-body { .message-body {
.fold-button {
margin-bottom: 8px;
padding: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
&:hover {
color: var(--el-color-primary);
}
}
.message-text { .message-text {
font-size: 15px; font-size: 15px;
line-height: 1.6; line-height: 1.6;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
word-wrap: break-word; word-wrap: break-word;
transition: all 0.3s ease;
&.collapsed {
max-height: 120px;
overflow: hidden;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to bottom, transparent, var(--el-bg-color));
}
}
:deep(p) { :deep(p) {
margin: 0 0 12px; margin: 0 0 12px;