mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 20:55:14 +00:00
Merge pull request #264 from 1014TaoTao/2.2.0
feat(ai): 替换OpenAI客户端为LangChain并添加消息折叠功能
This commit is contained in:
@@ -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)}")
|
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user