init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, PaginationQueryParam
|
||||
from app.core.database import async_db_session
|
||||
from app.core.dependencies import AuthPermission, _authenticate, redis_getter
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import (
|
||||
AiChatRequestSchema,
|
||||
AiChatResponseSchema,
|
||||
AiModelConfigListResponse,
|
||||
AiModelConfigSchema,
|
||||
AiModelConfigUpdateSchema,
|
||||
ChatQuerySchema,
|
||||
ChatSessionCreateSchema,
|
||||
ChatSessionQueryParam,
|
||||
ChatSessionUpdateSchema,
|
||||
)
|
||||
from .service import AiModelConfigService, ChatService, get_user_model_config
|
||||
|
||||
ChatRouter = APIRouter(route_class=OperationLogRoute, prefix="/chat", tags=["AI管理"])
|
||||
|
||||
|
||||
@ChatRouter.get("/detail/{session_id}", summary="获取会话详情", response_model=ResponseSchema[dict[str, Any]])
|
||||
async def get_session_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:detail"]))],
|
||||
session_id: Annotated[str, Path(description="会话ID")],
|
||||
) -> JSONResponse:
|
||||
service = ChatService(auth)
|
||||
result = await service.get_session(session_id=session_id)
|
||||
return SuccessResponse(data=result, msg="获取会话详情成功")
|
||||
|
||||
|
||||
@ChatRouter.get("/list", summary="查询会话列表", response_model=ResponseSchema[dict])
|
||||
async def get_session_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[ChatSessionQueryParam, Query()],
|
||||
) -> JSONResponse:
|
||||
service = ChatService(auth)
|
||||
result_dict = await service.page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询会话列表成功")
|
||||
|
||||
|
||||
@ChatRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建会话", response_model=ResponseSchema[dict[str, Any]])
|
||||
async def create_session_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:create"]))],
|
||||
data: Annotated[ChatSessionCreateSchema, Body(description="会话创建参数")],
|
||||
) -> JSONResponse:
|
||||
service = ChatService(auth)
|
||||
result = await service.create(data=data)
|
||||
return SuccessResponse(data=result, msg="创建会话成功")
|
||||
|
||||
|
||||
@ChatRouter.put("/update/{session_id}", summary="更新会话", response_model=ResponseSchema[None])
|
||||
async def update_session_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:update"]))],
|
||||
session_id: Annotated[str, Path(description="会话ID")],
|
||||
data: Annotated[ChatSessionUpdateSchema, Body(description="会话更新参数")],
|
||||
) -> JSONResponse:
|
||||
service = ChatService(auth)
|
||||
await service.update(session_id=session_id, data=data)
|
||||
return SuccessResponse(data=None, msg="更新会话成功")
|
||||
|
||||
|
||||
@ChatRouter.delete("/delete", summary="删除会话", response_model=ResponseSchema[None])
|
||||
async def delete_session_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:delete"]))],
|
||||
session_ids: Annotated[list[str], Body(description="会话ID列表")],
|
||||
) -> JSONResponse:
|
||||
service = ChatService(auth)
|
||||
await service.delete(session_ids=session_ids)
|
||||
return SuccessResponse(data=None, msg="删除会话成功")
|
||||
|
||||
|
||||
@ChatRouter.post("/ai-chat", summary="AI 对话(非流式)", response_model=ResponseSchema[AiChatResponseSchema])
|
||||
async def ai_chat_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:query"]))],
|
||||
data: Annotated[AiChatRequestSchema, Body(description="对话请求")],
|
||||
) -> JSONResponse:
|
||||
service = ChatService(auth)
|
||||
result = await service.chat_non_stream(
|
||||
message=data.message,
|
||||
session_id=data.session_id,
|
||||
)
|
||||
return SuccessResponse(
|
||||
data=AiChatResponseSchema(
|
||||
response=result["response"],
|
||||
session_id=result["session_id"],
|
||||
function_calls=result.get("function_calls"),
|
||||
action=result.get("action"),
|
||||
),
|
||||
msg="对话成功",
|
||||
)
|
||||
|
||||
|
||||
@ChatRouter.get("/model", summary="获取 AI 模型配置列表", response_model=ResponseSchema[AiModelConfigListResponse])
|
||||
async def list_model_config_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:query"]))],
|
||||
) -> JSONResponse:
|
||||
service = AiModelConfigService(auth, redis)
|
||||
result = await service.list()
|
||||
return SuccessResponse(data=result, msg="获取模型配置列表成功")
|
||||
|
||||
|
||||
@ChatRouter.post("/model", status_code=status.HTTP_201_CREATED, summary="新增一个 AI 模型配置", response_model=ResponseSchema[dict[str, Any]])
|
||||
async def create_model_config_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:update"]))],
|
||||
data: Annotated[AiModelConfigUpdateSchema, Body(description="模型配置参数")],
|
||||
) -> JSONResponse:
|
||||
service = AiModelConfigService(auth, redis)
|
||||
payload = AiModelConfigSchema(**data.model_dump())
|
||||
result = await service.create(payload)
|
||||
return SuccessResponse(data=result, msg="模型配置已新增")
|
||||
|
||||
|
||||
@ChatRouter.put("/model/{config_id}", summary="更新指定 ID 的 AI 模型配置", response_model=ResponseSchema[dict[str, Any]])
|
||||
async def update_model_config_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:update"]))],
|
||||
config_id: Annotated[str, Path(description="配置项 ID")],
|
||||
data: Annotated[AiModelConfigUpdateSchema, Body(description="模型配置参数")],
|
||||
) -> JSONResponse:
|
||||
service = AiModelConfigService(auth, redis)
|
||||
payload = AiModelConfigSchema(**data.model_dump())
|
||||
result = await service.update(config_id, payload)
|
||||
return SuccessResponse(data=result, msg="模型配置已更新")
|
||||
|
||||
|
||||
@ChatRouter.delete("/model/{config_id}", summary="删除指定 ID 的 AI 模型配置", response_model=ResponseSchema[None])
|
||||
async def delete_model_config_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:update"]))],
|
||||
config_id: Annotated[str, Path(description="配置项 ID")],
|
||||
) -> JSONResponse:
|
||||
service = AiModelConfigService(auth, redis)
|
||||
await service.delete(config_id)
|
||||
return SuccessResponse(data=None, msg="模型配置已删除")
|
||||
|
||||
|
||||
@ChatRouter.post("/model/{config_id}/activate", summary="切换激活的 AI 模型配置", response_model=ResponseSchema[None])
|
||||
async def activate_model_config_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:update"]))],
|
||||
config_id: Annotated[str, Path(description="配置项 ID;传 __default__ 使用系统默认")],
|
||||
) -> JSONResponse:
|
||||
service = AiModelConfigService(auth, redis)
|
||||
await service.set_active(config_id)
|
||||
return SuccessResponse(data=None, msg="已切换模型")
|
||||
|
||||
|
||||
async def _send_error_and_close(websocket: WebSocket, message: str) -> None:
|
||||
"""发送错误消息并关闭连接"""
|
||||
try:
|
||||
await websocket.send_text(f"错误: {message}")
|
||||
except RuntimeError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
@ChatRouter.websocket("/ws", name="WebSocket聊天")
|
||||
async def websocket_chat_controller(websocket: WebSocket) -> None:
|
||||
"""WebSocket 聊天接口。
|
||||
|
||||
支持的消息格式(JSON):
|
||||
- 对话:{"message": "...", "session_id": "...", "files": [...]}
|
||||
- 停止:{"action": "stop", "session_id": "..."}
|
||||
|
||||
ws://127.0.0.1:8001/api/v1/ai/chat/ws?token=xxx
|
||||
"""
|
||||
# 接收客户端 subprotocol:约定客户端在 Sec-WebSocket-Protocol 中以 "access_token.<jwt>" 携带
|
||||
# 推荐方式:subprotocol 不会进 URL,不出现在 Nginx access log / 浏览器历史 / 抓包日志
|
||||
# 同时兼容旧版:用 query_params 传 token(不推荐,仅作向后兼容)
|
||||
#
|
||||
# 浏览器侧示例:
|
||||
# new WebSocket(url, ["access_token", "access_token." + jwt])
|
||||
# Python websocket-client 示例:
|
||||
# websockets.connect(url, subprotocols=["access_token", f"access_token.{jwt}"])
|
||||
token = None
|
||||
use_subprotocol = False
|
||||
if websocket.headers.get("sec-websocket-protocol"):
|
||||
for proto in websocket.headers["sec-websocket-protocol"].split(","):
|
||||
proto = proto.strip()
|
||||
if proto.startswith("access_token."):
|
||||
token = proto[len("access_token.") :]
|
||||
use_subprotocol = True
|
||||
break
|
||||
if not token:
|
||||
# 旧版/非浏览器客户端兼容:保留 query ?token=
|
||||
token = websocket.query_params.get("token")
|
||||
|
||||
if not token:
|
||||
await _send_error_and_close(websocket, "未提供认证token,请重新登录")
|
||||
return
|
||||
|
||||
if use_subprotocol:
|
||||
await websocket.accept(subprotocol="access_token")
|
||||
else:
|
||||
await websocket.accept()
|
||||
|
||||
# 跨消息循环共享的停止信号:客户端发送 stop 时 set,生成器检测到后退出
|
||||
stop_event = asyncio.Event()
|
||||
# 标记当前是否在生成中,便于 stop 校验
|
||||
is_generating = asyncio.Event()
|
||||
|
||||
try:
|
||||
redis = websocket.app.state.redis
|
||||
async with async_db_session() as db:
|
||||
auth = await _authenticate(token, db, redis)
|
||||
|
||||
logger.info("WebSocket连接已建立: {} - 用户: {}", websocket.client, auth.user.username or "未认证")
|
||||
|
||||
chat_service = ChatService(auth)
|
||||
|
||||
# 消息循环
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_text()
|
||||
try:
|
||||
message_data = json.loads(data)
|
||||
query = ChatQuerySchema(**message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("收到非JSON消息: {}", data)
|
||||
await websocket.send_text("消息格式错误,请发送JSON格式的消息")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning("消息校验失败: {}", e)
|
||||
await websocket.send_text(f"消息格式错误: {e}")
|
||||
continue
|
||||
|
||||
# 处理停止指令
|
||||
if query.action == "stop":
|
||||
if is_generating.is_set():
|
||||
stop_event.set()
|
||||
logger.info("收到停止指令: session={}", query.session_id)
|
||||
await websocket.send_text("[STOPPED]")
|
||||
else:
|
||||
await websocket.send_text("当前没有正在进行的生成任务")
|
||||
continue
|
||||
|
||||
# 对话指令
|
||||
logger.info("收到聊天查询: session_id={}", query.session_id)
|
||||
|
||||
is_generating.set()
|
||||
stop_event.clear()
|
||||
# 读取用户的 AI 模型配置(每次可动态切换)
|
||||
model_config = await get_user_model_config(redis, auth.user.id)
|
||||
try:
|
||||
async for chunk in chat_service.chat_query(
|
||||
query=query,
|
||||
stop_event=stop_event,
|
||||
model_config=model_config,
|
||||
):
|
||||
if not chunk:
|
||||
continue
|
||||
try:
|
||||
await websocket.send_text(chunk)
|
||||
except RuntimeError:
|
||||
logger.warning("WebSocket连接已关闭,停止发送消息")
|
||||
return
|
||||
finally:
|
||||
is_generating.clear()
|
||||
stop_event.clear()
|
||||
|
||||
# 告知前端生成结束
|
||||
try:
|
||||
await websocket.send_text("[DONE]")
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket连接已断开: {}", websocket.client)
|
||||
return
|
||||
|
||||
except CustomException as e:
|
||||
# 认证失败等业务异常
|
||||
logger.warning("WebSocket认证失败: {}", e.msg)
|
||||
await _send_error_and_close(websocket, e.msg)
|
||||
except Exception as e:
|
||||
# 未知异常
|
||||
logger.exception("WebSocket未知异常: {}", e)
|
||||
await _send_error_and_close(websocket, "服务器内部错误")
|
||||
@@ -0,0 +1,158 @@
|
||||
from typing import Any
|
||||
|
||||
from agno.db.base import SessionType
|
||||
from agno.db.mysql import MySQLDb
|
||||
from agno.db.postgres import PostgresDb
|
||||
from agno.db.sqlite import SqliteDb
|
||||
from agno.session.team import TeamSession
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.logger import logger
|
||||
|
||||
from .schema import ChatSessionCreateSchema, ChatSessionUpdateSchema
|
||||
|
||||
|
||||
class ChatSessionCRUD:
|
||||
"""聊天会话数据层 - 使用 agno 数据库存储"""
|
||||
|
||||
# 会话类型配置 - 使用 TEAM 类型因为创建的是 Team
|
||||
SESSION_TYPE = SessionType.TEAM
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
"""初始化CRUD数据层"""
|
||||
self.auth = auth
|
||||
self.user_id = auth.user.username or "user"
|
||||
self.team_id = "default"
|
||||
self.db = self._get_db()
|
||||
|
||||
def _get_db(self) -> Any:
|
||||
"""获取数据库连接"""
|
||||
db_type = settings.DATABASE_TYPE
|
||||
db_uri = settings.DB_URI
|
||||
|
||||
db_mapping = {
|
||||
"mysql": lambda: MySQLDb(db_url=db_uri, db_schema=settings.DATABASE_NAME, create_schema=False),
|
||||
"postgres": lambda: PostgresDb(db_url=db_uri, db_schema="public", create_schema=False),
|
||||
"sqlite": lambda: SqliteDb(db_file=db_uri.replace("sqlite:///", "")),
|
||||
}
|
||||
|
||||
if db_type not in db_mapping:
|
||||
raise ValueError(f"不支持的数据库类型: {db_type}")
|
||||
|
||||
return db_mapping[db_type]()
|
||||
|
||||
async def get_by_id_crud(self, session_id: str) -> TeamSession | None:
|
||||
"""获取会话详情。
|
||||
|
||||
参数:
|
||||
- session_id (str): 会话 ID。
|
||||
|
||||
返回:
|
||||
- TeamSession | None: 会话对象;失败或不存在时为 None。
|
||||
"""
|
||||
try:
|
||||
return self.db.get_session(session_id=session_id, session_type=self.SESSION_TYPE, user_id=self.user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"获取会话详情失败: {e}")
|
||||
return None
|
||||
|
||||
async def list_crud(
|
||||
self,
|
||||
search: dict[str, Any] | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[TeamSession]:
|
||||
"""列表查询,获取当前用户的所有会话。
|
||||
|
||||
参数:
|
||||
- search (dict[str, Any] | None): 预留查询条件(当前实现未使用)。
|
||||
- order_by (list[dict[str, str]] | None): 预留排序(当前实现未使用)。
|
||||
|
||||
返回:
|
||||
- list[TeamSession]: 会话列表;失败时为空列表。
|
||||
"""
|
||||
try:
|
||||
result = self.db.get_sessions(session_type=self.SESSION_TYPE, user_id=self.user_id)
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
return result[0]
|
||||
return result if isinstance(result, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"获取会话列表失败: {e}")
|
||||
return []
|
||||
|
||||
async def create_crud(self, data: ChatSessionCreateSchema) -> TeamSession | None:
|
||||
"""创建会话(Team 在运行时自动创建并管理 session)。
|
||||
|
||||
参数:
|
||||
- data (ChatSessionCreateSchema): 创建参数(如标题)。
|
||||
|
||||
返回:
|
||||
- TeamSession | None: 新建会话;失败时为 None。
|
||||
"""
|
||||
import time
|
||||
import uuid
|
||||
|
||||
try:
|
||||
session_id = str(uuid.uuid4())
|
||||
now = int(time.time())
|
||||
|
||||
# 创建 session_data,包含 session_name
|
||||
session_data = {}
|
||||
if data.title:
|
||||
session_data["session_name"] = data.title
|
||||
|
||||
# 创建 TeamSession 对象
|
||||
session = TeamSession(
|
||||
session_id=session_id,
|
||||
user_id=self.user_id,
|
||||
team_id=self.team_id,
|
||||
session_data=session_data,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
# 保存会话
|
||||
result = self.db.upsert_session(session=session)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception(f"创建会话失败: {e}")
|
||||
return None
|
||||
|
||||
async def update_crud(self, session_id: str, data: ChatSessionUpdateSchema) -> bool:
|
||||
"""更新会话(如重命名)。
|
||||
|
||||
参数:
|
||||
- session_id (str): 会话 ID。
|
||||
- data (ChatSessionUpdateSchema): 更新数据。
|
||||
|
||||
返回:
|
||||
- bool: 是否成功。
|
||||
"""
|
||||
try:
|
||||
self.db.rename_session(
|
||||
session_id=session_id,
|
||||
session_type=self.SESSION_TYPE,
|
||||
session_name=data.title,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"更新会话失败: {e}")
|
||||
return False
|
||||
|
||||
async def delete_crud(self, session_ids: list[str]) -> bool:
|
||||
"""批量删除会话。
|
||||
|
||||
参数:
|
||||
- session_ids (list[str]): 会话 ID 列表。
|
||||
|
||||
返回:
|
||||
- bool: 是否全部处理成功(任一出错则记日志并返回 False)。
|
||||
"""
|
||||
try:
|
||||
for session_id in session_ids:
|
||||
self.db.delete_session(session_id=session_id, user_id=self.user_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"删除会话失败: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,129 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, UserByQueryParam
|
||||
|
||||
|
||||
class ChatQuerySchema(BaseModel):
|
||||
"""WebSocket聊天查询模型"""
|
||||
|
||||
message: str | None = Field("", description="消息内容(停止时可为空)")
|
||||
session_id: str | None = Field(None, description="会话ID")
|
||||
files: list[dict[str, Any]] | None = Field(None, description="文件信息")
|
||||
action: str | None = Field(None, description="动作类型:stop=停止生成 | None=对话")
|
||||
|
||||
|
||||
class ChatSessionCreateSchema(BaseModel):
|
||||
"""创建会话模型"""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=200, description="会话标题")
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def validate_title(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 1 or len(v) > 200:
|
||||
raise ValueError("会话标题长度必须在1-200个字符之间")
|
||||
return v
|
||||
|
||||
|
||||
class ChatSessionUpdateSchema(BaseModel):
|
||||
"""更新会话模型"""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=200, description="会话标题")
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def validate_title(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 1 or len(v) > 200:
|
||||
raise ValueError("会话标题长度必须在1-200个字符之间")
|
||||
return v
|
||||
|
||||
|
||||
class ChatSessionMessageSchema(BaseModel):
|
||||
"""会话消息模型"""
|
||||
|
||||
id: str = Field(..., description="消息ID")
|
||||
role: str = Field(..., description="消息角色")
|
||||
content: str = Field(..., description="消息内容")
|
||||
created_at: int | None = Field(None, description="创建时间(Unix时间戳)")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ChatSessionQueryParam(BaseQueryParam, UserByQueryParam):
|
||||
"""会话查询参数"""
|
||||
|
||||
title: str | None = Field(None, description="会话标题")
|
||||
|
||||
|
||||
|
||||
|
||||
class AiChatRequestSchema(BaseModel):
|
||||
"""AI 对话请求模型(非流式)"""
|
||||
|
||||
message: str = Field(..., min_length=1, description="用户消息内容")
|
||||
session_id: str | None = Field(None, description="会话ID,不传则创建新会话")
|
||||
|
||||
@field_validator("message")
|
||||
@classmethod
|
||||
def validate_message(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 1:
|
||||
raise ValueError("用户消息内容不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiChatResponseSchema(BaseModel):
|
||||
"""AI 对话响应模型(非流式)"""
|
||||
|
||||
response: str = Field(..., description="AI 回复内容")
|
||||
session_id: str = Field(..., description="会话ID")
|
||||
function_calls: list[dict[str, Any]] | None = Field(None, description="函数调用信息")
|
||||
action: dict[str, Any] | None = Field(None, description="建议执行的操作")
|
||||
|
||||
|
||||
class AiModelConfigSchema(BaseModel):
|
||||
"""AI 模型配置项"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=50, description="配置名称(用户可读)")
|
||||
base_url: str = Field(..., min_length=1, max_length=500, description="API Base URL,如 https://api.openai.com/v1")
|
||||
api_key: str = Field(..., min_length=1, max_length=500, description="API 密钥")
|
||||
model_id: str = Field(..., min_length=1, max_length=100, description="模型 ID")
|
||||
temperature: float = Field(0.7, ge=0.0, le=2.0, description="温度参数")
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def validate_base_url(cls, v: str) -> str:
|
||||
v = v.strip().rstrip("/")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("Base URL 必须以 http:// 或 https:// 开头")
|
||||
return v
|
||||
|
||||
@field_validator("model_id")
|
||||
@classmethod
|
||||
def validate_model_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("模型 ID 不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiModelConfigItemSchema(AiModelConfigSchema):
|
||||
"""带 ID 的模型配置项(存储与返回)"""
|
||||
|
||||
id: str = Field(..., min_length=1, max_length=64, description="配置项唯一 ID")
|
||||
created_time: str | None = Field(None, description="创建时间(ISO 字符串)")
|
||||
|
||||
|
||||
class AiModelConfigUpdateSchema(AiModelConfigSchema):
|
||||
"""更新 AI 模型配置(与创建结构相同,不含 id)"""
|
||||
|
||||
|
||||
class AiModelConfigListResponse(BaseModel):
|
||||
"""模型配置列表响应"""
|
||||
|
||||
items: list[AiModelConfigItemSchema] = Field(default_factory=list, description="配置项列表")
|
||||
active_id: str | None = Field(None, description="当前激活的配置项 ID;为空表示使用系统默认")
|
||||
@@ -0,0 +1,513 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from agno.run.team import TeamRunOutput
|
||||
from agno.session.team import TeamSession
|
||||
from agno.team.team import Team
|
||||
from redis.asyncio import Redis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.common.request import PaginationService
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.utils.ai_factory import AgnoFactory
|
||||
|
||||
from .crud import ChatSessionCRUD
|
||||
from .schema import (
|
||||
AiModelConfigSchema,
|
||||
ChatQuerySchema,
|
||||
ChatSessionCreateSchema,
|
||||
ChatSessionQueryParam,
|
||||
ChatSessionUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
async def _format_session_data(session: TeamSession, auth: AuthSchema | None = None, db: AsyncSession | None = None) -> dict[str, Any]:
|
||||
"""格式化会话数据,添加前端需要的字段"""
|
||||
if hasattr(session, "to_dict"):
|
||||
session_dict = session.to_dict()
|
||||
else:
|
||||
session_dict = {
|
||||
"session_id": getattr(session, "session_id", ""),
|
||||
"agent_id": getattr(session, "agent_id", None),
|
||||
"team_id": getattr(session, "team_id", None),
|
||||
"workflow_id": getattr(session, "workflow_id", None),
|
||||
"user_id": getattr(session, "user_id", None),
|
||||
"session_data": getattr(session, "session_data", None),
|
||||
"agent_data": getattr(session, "agent_data", None),
|
||||
"team_data": getattr(session, "team_data", None),
|
||||
"workflow_data": getattr(session, "workflow_data", None),
|
||||
"metadata": getattr(session, "metadata", None),
|
||||
"runs": getattr(session, "runs", []),
|
||||
"summary": getattr(session, "summary", None),
|
||||
"created_at": getattr(session, "created_at", None),
|
||||
"updated_at": getattr(session, "updated_at", None),
|
||||
}
|
||||
|
||||
session_data = session_dict.get("session_data") or {}
|
||||
runs = session_dict.get("runs") or []
|
||||
messages = _extract_messages(runs)
|
||||
|
||||
# 从 session_data 中获取 session_name 作为标题
|
||||
session_name = session_data.get("session_name") if session_data else None
|
||||
|
||||
result = {
|
||||
**session_dict,
|
||||
"id": session_dict.get("session_id"),
|
||||
"title": session_name or session_dict.get("session_id", "")[:8] or "未命名会话",
|
||||
"created_time": _unix_to_datetime(session_dict.get("created_at")),
|
||||
"updated_time": _unix_to_datetime(session_dict.get("updated_at")),
|
||||
"message_count": len(messages),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# 如果有 auth 和 db,查询部门名称
|
||||
if auth and db and session_dict.get("team_id"):
|
||||
try:
|
||||
team_id_str = session_dict.get("team_id")
|
||||
if team_id_str:
|
||||
result["team_name"] = None
|
||||
except Exception:
|
||||
result["team_name"] = None
|
||||
else:
|
||||
result["team_name"] = None
|
||||
|
||||
# 如果 summary 是 SessionSummary 对象,提取 summary 字段
|
||||
summary = session_dict.get("summary")
|
||||
if summary:
|
||||
if isinstance(summary, dict):
|
||||
result["summary"] = summary.get("summary")
|
||||
else:
|
||||
result["summary"] = str(summary)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _unix_to_datetime(timestamp: int | None) -> str | None:
|
||||
"""将Unix时间戳转换为日期时间字符串"""
|
||||
if timestamp is None:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_messages(runs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""从 runs 中提取消息"""
|
||||
messages = []
|
||||
if not runs:
|
||||
return messages
|
||||
for run in runs:
|
||||
if not isinstance(run, dict):
|
||||
continue
|
||||
run_messages = run.get("messages", [])
|
||||
if run_messages and isinstance(run_messages, list):
|
||||
for msg in run_messages:
|
||||
if isinstance(msg, dict):
|
||||
role = msg.get("role")
|
||||
if role in ("user", "assistant"):
|
||||
messages.append(
|
||||
{
|
||||
"id": msg.get("id"),
|
||||
"role": role,
|
||||
"content": msg.get("content", ""),
|
||||
"created_at": msg.get("created_at"),
|
||||
},
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""聊天会话管理模块服务层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
|
||||
async def chat_query(
|
||||
self,
|
||||
query: ChatQuerySchema,
|
||||
stop_event: asyncio.Event | None = None,
|
||||
model_config: dict[str, Any] | None = None,
|
||||
) -> AsyncGenerator[str | None, Any]:
|
||||
"""流式 AI 对话"""
|
||||
try:
|
||||
crud = ChatSessionCRUD(self.auth)
|
||||
|
||||
session_id = query.session_id
|
||||
if not session_id:
|
||||
import uuid
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
session: TeamSession | None = await crud.create_crud(data=ChatSessionCreateSchema(title="新对话"))
|
||||
if not session:
|
||||
raise CustomException(msg="创建会话失败")
|
||||
session_id = session.session_id
|
||||
|
||||
agno_factory = AgnoFactory()
|
||||
team_id = "default"
|
||||
agent = agno_factory.create_agent(
|
||||
user_id=self.auth.user.username or "user",
|
||||
team_id=team_id,
|
||||
session_id=session_id,
|
||||
db=crud.db,
|
||||
model_config=model_config,
|
||||
)
|
||||
|
||||
message = (query.message or "").strip()
|
||||
if not message:
|
||||
yield "请输入消息内容"
|
||||
return
|
||||
|
||||
logger.info("开始流式生成: session_id={} message={!r}", session_id, message[:80])
|
||||
chunk_count = 0
|
||||
try:
|
||||
stream = agent.arun(input=message, stream=True)
|
||||
logger.info("agent.arun 返回对象类型: {}", type(stream).__name__)
|
||||
if hasattr(stream, "__aiter__"):
|
||||
async for chunk in stream: # type: ignore[union-attr]
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
logger.info("用户主动停止生成: session_id={}", session_id)
|
||||
return
|
||||
if chunk and getattr(chunk, "content", None):
|
||||
chunk_count += 1
|
||||
yield str(chunk.content)
|
||||
else:
|
||||
logger.debug("空 chunk 跳过: {}", type(chunk).__name__ if chunk else None)
|
||||
else:
|
||||
# 兼容非流式直接返回结果的场景
|
||||
logger.warning("agent.arun 未返回异步迭代器,尝试按单次结果处理")
|
||||
result: Any = stream
|
||||
if result and getattr(result, "content", None):
|
||||
chunk_count += 1
|
||||
yield str(result.content)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("生成任务被取消: session_id={}", session_id)
|
||||
return
|
||||
|
||||
logger.info("流式生成结束: session_id={} chunk_count={}", session_id, chunk_count)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"聊天查询失败: {e}", exc_info=True)
|
||||
yield f"抱歉,处理您的请求时出现错误:{e!s}"
|
||||
|
||||
async def chat_non_stream(self, message: str, session_id: str | None) -> dict[str, Any]:
|
||||
"""非流式 AI 对话"""
|
||||
try:
|
||||
crud = ChatSessionCRUD(self.auth)
|
||||
|
||||
if not session_id:
|
||||
import uuid
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
session: TeamSession | None = await crud.create_crud(data=ChatSessionCreateSchema(title="新对话"))
|
||||
if not session:
|
||||
raise CustomException(msg="创建会话失败")
|
||||
session_id = session.session_id
|
||||
|
||||
agno_factory = AgnoFactory()
|
||||
team_id = "default"
|
||||
agent: Team = agno_factory.create_agent(
|
||||
user_id=self.auth.user.username or "user",
|
||||
team_id=team_id,
|
||||
session_id=session_id,
|
||||
db=crud.db,
|
||||
)
|
||||
|
||||
response: TeamRunOutput = await agent.arun(input=message)
|
||||
|
||||
response_text = ""
|
||||
action = None
|
||||
|
||||
if response and response.content:
|
||||
response_text = response.content
|
||||
try:
|
||||
if response_text.strip().startswith("{") and response_text.strip().endswith("}"):
|
||||
action = json.loads(response_text)
|
||||
elif "```json" in response_text:
|
||||
json_start = response_text.find("```json") + 7
|
||||
json_end = response_text.find("```", json_start)
|
||||
if json_end > json_start:
|
||||
json_str = response_text[json_start:json_end].strip()
|
||||
action = json.loads(json_str)
|
||||
except (json.JSONDecodeError, Exception):
|
||||
pass
|
||||
|
||||
if not action:
|
||||
action = self._parse_action_from_response(response_text)
|
||||
|
||||
return {
|
||||
"response": response_text,
|
||||
"session_id": session_id,
|
||||
"function_calls": None,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"聊天查询失败: {e}")
|
||||
return {
|
||||
"response": f"抱歉,处理您的请求时出现错误:{e!s}",
|
||||
"session_id": session_id,
|
||||
"function_calls": None,
|
||||
"action": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_action_from_response(response_text: str) -> dict[str, Any] | None:
|
||||
"""从响应文本中解析操作建议"""
|
||||
route_config = {
|
||||
"用户管理": {"path": "/system/user", "name": "用户管理"},
|
||||
"角色管理": {"path": "/system/role", "name": "角色管理"},
|
||||
"菜单管理": {"path": "/system/menu", "name": "菜单管理"},
|
||||
"部门管理": {"path": "/system/dept", "name": "部门管理"},
|
||||
"字典管理": {"path": "/system/dict", "name": "字典管理"},
|
||||
"系统日志": {"path": "/system/log", "name": "系统日志"},
|
||||
}
|
||||
|
||||
navigation_keywords = ["跳转", "打开", "进入", "前往", "去", "浏览", "查看"]
|
||||
has_navigation = any(keyword in response_text for keyword in navigation_keywords)
|
||||
|
||||
if not has_navigation:
|
||||
return None
|
||||
|
||||
for page_name, route_info in route_config.items():
|
||||
if page_name in response_text:
|
||||
return {
|
||||
"type": "navigate",
|
||||
"path": route_info["path"],
|
||||
"name": route_info["name"],
|
||||
}
|
||||
|
||||
keyword_mapping = {
|
||||
"用户": {"path": "/system/user", "name": "用户管理"},
|
||||
"角色": {"path": "/system/role", "name": "角色管理"},
|
||||
"菜单": {"path": "/system/menu", "name": "菜单管理"},
|
||||
"部门": {"path": "/system/dept", "name": "部门管理"},
|
||||
"字典": {"path": "/system/dict", "name": "字典管理"},
|
||||
"日志": {"path": "/system/log", "name": "系统日志"},
|
||||
}
|
||||
|
||||
for keyword, route_info in keyword_mapping.items():
|
||||
if keyword in response_text:
|
||||
return {
|
||||
"type": "navigate",
|
||||
"path": route_info["path"],
|
||||
"name": route_info["name"],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def get_session(self, session_id: str) -> dict[str, Any] | None:
|
||||
crud = ChatSessionCRUD(self.auth)
|
||||
session: TeamSession | None = await crud.get_by_id_crud(session_id=session_id)
|
||||
if session:
|
||||
return await _format_session_data(session, self.auth)
|
||||
return None
|
||||
|
||||
async def create(self, data: ChatSessionCreateSchema) -> dict[str, Any] | None:
|
||||
crud = ChatSessionCRUD(self.auth)
|
||||
session = await crud.create_crud(data=data)
|
||||
if session:
|
||||
return await _format_session_data(session, self.auth)
|
||||
return None
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: ChatSessionQueryParam,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[Any]:
|
||||
crud = ChatSessionCRUD(self.auth)
|
||||
sessions = await crud.list_crud()
|
||||
items = [await _format_session_data(s, self.auth) for s in sessions]
|
||||
return await PaginationService.paginate(
|
||||
data_list=items,
|
||||
page_no=page_no,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
async def update(self, session_id: str, data: ChatSessionUpdateSchema) -> bool:
|
||||
crud = ChatSessionCRUD(self.auth)
|
||||
return await crud.update_crud(session_id=session_id, data=data)
|
||||
|
||||
async def delete(self, session_ids: list[str]) -> None:
|
||||
await ChatSessionCRUD(self.auth).delete_crud(session_ids=session_ids)
|
||||
|
||||
|
||||
# ================================================= #
|
||||
# ******************* AI 模型配置 ****************** #
|
||||
# ================================================= #
|
||||
|
||||
|
||||
_AI_MODEL_TTL = 604800 # AI 模型配置缓存 7 天,不活跃用户自动清理
|
||||
|
||||
|
||||
def _ai_model_items_key(user_id: int) -> str:
|
||||
return f"{RedisInitKeyConfig.AI_MODEL_CONFIG.key}:items:{user_id}"
|
||||
|
||||
|
||||
def _ai_model_active_key(user_id: int) -> str:
|
||||
return f"{RedisInitKeyConfig.AI_MODEL_CONFIG.key}:active:{user_id}"
|
||||
|
||||
|
||||
async def get_user_model_config(redis: Redis, user_id: int) -> dict[str, Any] | None:
|
||||
"""读取当前激活的 AI 模型配置;不存在或未激活返回 None。"""
|
||||
active_id = await RedisCURD(redis).get(_ai_model_active_key(user_id))
|
||||
if not active_id:
|
||||
return None
|
||||
items = await list_user_model_configs(redis, user_id)
|
||||
for item in items:
|
||||
if item.get("id") == active_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
async def list_user_model_configs(redis: Redis, user_id: int) -> list[dict[str, Any]]:
|
||||
"""列出用户的所有模型配置项。"""
|
||||
raw = await RedisCURD(redis).get(_ai_model_items_key(user_id))
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("AI 模型配置列表 JSON 解析失败: user_id={}", user_id)
|
||||
return []
|
||||
|
||||
|
||||
async def get_active_model_id(redis: Redis, user_id: int) -> str | None:
|
||||
"""读取当前激活的模型配置 ID;为空表示使用系统默认。"""
|
||||
return await RedisCURD(redis).get(_ai_model_active_key(user_id))
|
||||
|
||||
|
||||
async def create_user_model_config(
|
||||
redis: Redis,
|
||||
user_id: int,
|
||||
config: AiModelConfigSchema,
|
||||
) -> dict[str, Any]:
|
||||
"""新增一个模型配置项。"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
items = await list_user_model_configs(redis, user_id)
|
||||
item = {
|
||||
**config.model_dump(),
|
||||
"id": uuid.uuid4().hex,
|
||||
"created_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
items.append(item)
|
||||
await RedisCURD(redis).set(
|
||||
_ai_model_items_key(user_id),
|
||||
json.dumps(items, ensure_ascii=False),
|
||||
expire=_AI_MODEL_TTL,
|
||||
)
|
||||
|
||||
# 若用户尚未激活任何配置,自动激活新增的
|
||||
if not await get_active_model_id(redis, user_id):
|
||||
await RedisCURD(redis).set(_ai_model_active_key(user_id), item["id"], expire=_AI_MODEL_TTL)
|
||||
|
||||
logger.info("已新增 AI 模型配置: user_id={} name={} id={}", user_id, config.name, item["id"])
|
||||
return item
|
||||
|
||||
|
||||
async def update_user_model_config(
|
||||
redis: Redis,
|
||||
user_id: int,
|
||||
config_id: str,
|
||||
config: AiModelConfigSchema,
|
||||
) -> dict[str, Any] | None:
|
||||
"""更新指定 ID 的模型配置项;不存在返回 None。"""
|
||||
items = await list_user_model_configs(redis, user_id)
|
||||
target = next((it for it in items if it.get("id") == config_id), None)
|
||||
if not target:
|
||||
return None
|
||||
target.update(config.model_dump())
|
||||
await RedisCURD(redis).set(
|
||||
_ai_model_items_key(user_id),
|
||||
json.dumps(items, ensure_ascii=False),
|
||||
expire=_AI_MODEL_TTL,
|
||||
)
|
||||
logger.info("已更新 AI 模型配置: user_id={} id={}", user_id, config_id)
|
||||
return target
|
||||
|
||||
|
||||
async def delete_user_model_config(redis: Redis, user_id: int, config_id: str) -> bool:
|
||||
"""删除指定 ID 的模型配置项;若该 ID 是当前激活则清空激活。"""
|
||||
items = await list_user_model_configs(redis, user_id)
|
||||
new_items = [it for it in items if it.get("id") != config_id]
|
||||
if len(new_items) == len(items):
|
||||
return False
|
||||
await RedisCURD(redis).set(
|
||||
_ai_model_items_key(user_id),
|
||||
json.dumps(new_items, ensure_ascii=False),
|
||||
expire=_AI_MODEL_TTL,
|
||||
)
|
||||
active_id = await get_active_model_id(redis, user_id)
|
||||
if active_id == config_id:
|
||||
await RedisCURD(redis).delete(_ai_model_active_key(user_id))
|
||||
logger.info("已删除 AI 模型配置: user_id={} id={}", user_id, config_id)
|
||||
return True
|
||||
|
||||
|
||||
async def set_active_model_config(redis: Redis, user_id: int, config_id: str) -> bool:
|
||||
"""设置当前激活的模型配置项;id 为空字符串或 "__default__" 表示使用系统默认。"""
|
||||
if config_id in ("", "__default__"):
|
||||
await RedisCURD(redis).delete(_ai_model_active_key(user_id))
|
||||
logger.info("已切换到系统默认模型: user_id={}", user_id)
|
||||
return True
|
||||
items = await list_user_model_configs(redis, user_id)
|
||||
if not any(it.get("id") == config_id for it in items):
|
||||
return False
|
||||
await RedisCURD(redis).set(_ai_model_active_key(user_id), config_id, expire=_AI_MODEL_TTL)
|
||||
logger.info("已切换 AI 模型: user_id={} id={}", user_id, config_id)
|
||||
return True
|
||||
|
||||
|
||||
class AiModelConfigService:
|
||||
"""AI 模型配置业务服务(多配置 + 激活切换)"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, redis: Redis) -> None:
|
||||
self.auth = auth
|
||||
self.redis = redis
|
||||
|
||||
@property
|
||||
def _user_id(self) -> int:
|
||||
return self.auth.user.id
|
||||
|
||||
async def list(self) -> dict[str, Any]:
|
||||
"""获取配置列表 + 当前激活 ID。"""
|
||||
items = await list_user_model_configs(self.redis, self._user_id)
|
||||
active_id = await get_active_model_id(self.redis, self._user_id)
|
||||
return {"items": items, "active_id": active_id}
|
||||
|
||||
async def get_active(self) -> dict[str, Any] | None:
|
||||
return await get_user_model_config(self.redis, self._user_id)
|
||||
|
||||
async def create(self, config: AiModelConfigSchema) -> dict[str, Any]:
|
||||
return await create_user_model_config(self.redis, self._user_id, config)
|
||||
|
||||
async def update(self, config_id: str, config: AiModelConfigSchema) -> dict[str, Any] | None:
|
||||
result = await update_user_model_config(self.redis, self._user_id, config_id, config)
|
||||
if result is None:
|
||||
raise CustomException(msg="模型配置不存在", code=10404, status_code=404)
|
||||
return result
|
||||
|
||||
async def delete(self, config_id: str) -> None:
|
||||
ok = await delete_user_model_config(self.redis, self._user_id, config_id)
|
||||
if not ok:
|
||||
raise CustomException(msg="模型配置不存在", code=10404, status_code=404)
|
||||
|
||||
async def set_active(self, config_id: str) -> None:
|
||||
ok = await set_active_model_config(self.redis, self._user_id, config_id)
|
||||
if not ok:
|
||||
raise CustomException(msg="模型配置不存在", code=10404, status_code=404)
|
||||
Reference in New Issue
Block a user