mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-23 13:13:09 +00:00
533 lines
21 KiB
Python
533 lines
21 KiB
Python
import asyncio
|
|
import json
|
|
import uuid
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from agno.run.team import TeamRunOutput
|
|
from agno.session.team import TeamSession
|
|
from redis.asyncio import Redis
|
|
|
|
from app.common.enums import RedisInitKeyConfig
|
|
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 app.utils.crypto_util import CryptoUtil
|
|
|
|
from .crud import TEAM_ID, ChatSessionCRUD
|
|
from .schema import (
|
|
AiModelConfigSchema,
|
|
ChatQuerySchema,
|
|
ChatSessionCreateSchema,
|
|
ChatSessionOutSchema,
|
|
ChatSessionQueryParam,
|
|
ChatSessionUpdateSchema,
|
|
)
|
|
|
|
# 导航建议:页面关键词/匹配词 -> 路由
|
|
_NAVIGATION_ROUTES: list[tuple[tuple[str, ...], str, str]] = [
|
|
(("用户管理", "用户"), "/system/user", "用户管理"),
|
|
(("角色管理", "角色"), "/system/role", "角色管理"),
|
|
(("菜单管理", "菜单"), "/system/menu", "菜单管理"),
|
|
(("部门管理", "部门"), "/system/dept", "部门管理"),
|
|
(("字典管理", "字典"), "/system/dict", "字典管理"),
|
|
(("系统日志", "日志"), "/system/log", "系统日志"),
|
|
]
|
|
_NAVIGATION_KEYWORDS = ("跳转", "打开", "进入", "前往", "去", "浏览", "查看")
|
|
|
|
|
|
def _session_to_dict(session: TeamSession | dict[str, Any]) -> dict[str, Any]:
|
|
"""将 TeamSession 对象或原始字典统一为会话字典"""
|
|
if isinstance(session, dict):
|
|
return session
|
|
if hasattr(session, "to_dict"):
|
|
return session.to_dict()
|
|
return {
|
|
"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),
|
|
"runs": getattr(session, "runs", []),
|
|
"summary": getattr(session, "summary", None),
|
|
"created_at": getattr(session, "created_at", None),
|
|
"updated_at": getattr(session, "updated_at", None),
|
|
}
|
|
|
|
|
|
def _normalize_runs(runs: Any) -> list[dict[str, Any]]:
|
|
"""deserialize=False 时 runs 可能是 JSON 字符串,统一为字典列表"""
|
|
if isinstance(runs, str):
|
|
try:
|
|
runs = json.loads(runs)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
return runs if isinstance(runs, list) else []
|
|
|
|
|
|
def _extract_messages(runs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""从 runs 中提取用户与助手消息"""
|
|
messages = []
|
|
for run in runs:
|
|
if not isinstance(run, dict):
|
|
continue
|
|
run_messages = run.get("messages", [])
|
|
if isinstance(run_messages, list):
|
|
for msg in run_messages:
|
|
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant"):
|
|
messages.append(
|
|
{
|
|
"id": msg.get("id"),
|
|
"role": msg["role"],
|
|
"content": msg.get("content", ""),
|
|
"created_at": msg.get("created_at"),
|
|
},
|
|
)
|
|
return messages
|
|
|
|
|
|
def _unix_to_datetime(timestamp: int | None) -> str | None:
|
|
"""将Unix时间戳转换为日期时间字符串"""
|
|
if timestamp is None:
|
|
return None
|
|
try:
|
|
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
|
except (ValueError, TypeError, OSError):
|
|
return None
|
|
|
|
|
|
def _format_session_data(session: TeamSession | dict[str, Any], with_messages: bool = True) -> dict[str, Any]:
|
|
"""格式化会话数据,补充前端需要的字段。
|
|
|
|
列表场景 with_messages=False:不展开消息正文,仅返回消息条数;
|
|
详情场景 with_messages=True:返回完整消息列表。
|
|
"""
|
|
session_dict = _session_to_dict(session)
|
|
runs = _normalize_runs(session_dict.get("runs"))
|
|
messages = _extract_messages(runs) if with_messages else []
|
|
|
|
session_data = session_dict.get("session_data")
|
|
if isinstance(session_data, str):
|
|
try:
|
|
session_data = json.loads(session_data)
|
|
except (json.JSONDecodeError, TypeError):
|
|
session_data = {}
|
|
session_name = session_data.get("session_name") if isinstance(session_data, dict) else None
|
|
|
|
result = {
|
|
**session_dict,
|
|
"session_data": session_data,
|
|
"id": session_dict.get("session_id"),
|
|
"title": session_name or str(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) if with_messages else len(runs),
|
|
"messages": messages,
|
|
}
|
|
|
|
# summary 可能是 SessionSummary 对象,提取其 summary 字段
|
|
summary = session_dict.get("summary")
|
|
if isinstance(summary, dict):
|
|
result["summary"] = summary.get("summary")
|
|
elif summary is not None and not isinstance(summary, str):
|
|
result["summary"] = str(summary)
|
|
|
|
return result
|
|
|
|
|
|
async def _ensure_session(crud: ChatSessionCRUD, session_id: str | None) -> str:
|
|
"""会话 ID 为空时创建新会话,返回可用的会话 ID"""
|
|
if session_id:
|
|
return session_id
|
|
session = await crud.create(data=ChatSessionCreateSchema(title="新对话"))
|
|
return session.session_id
|
|
|
|
|
|
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 对话"""
|
|
crud = ChatSessionCRUD(self.auth)
|
|
# 会话创建失败属于业务异常,直接抛出由连接层处理
|
|
session_id = await _ensure_session(crud, query.session_id)
|
|
|
|
agno_factory = AgnoFactory()
|
|
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)
|
|
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:
|
|
# 兼容非流式直接返回结果的场景
|
|
result: Any = stream
|
|
if result and getattr(result, "content", None):
|
|
yield str(result.content)
|
|
except asyncio.CancelledError:
|
|
logger.info("生成任务被取消: session_id={}", session_id)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"流式生成失败: {e}", exc_info=True)
|
|
yield "抱歉,AI 服务暂时不可用,请稍后重试"
|
|
return
|
|
|
|
logger.info("流式生成结束: session_id={} chunk_count={}", session_id, chunk_count)
|
|
|
|
async def chat_non_stream(
|
|
self,
|
|
message: str,
|
|
session_id: str | None,
|
|
model_config: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""非流式 AI 对话"""
|
|
crud = ChatSessionCRUD(self.auth)
|
|
sid = await _ensure_session(crud, session_id)
|
|
|
|
agno_factory = AgnoFactory()
|
|
agent = agno_factory.create_agent(
|
|
user_id=self.auth.user.username or "user",
|
|
team_id=TEAM_ID,
|
|
session_id=sid,
|
|
db=crud.db,
|
|
model_config=model_config,
|
|
)
|
|
|
|
try:
|
|
response: TeamRunOutput = await agent.arun(input=message)
|
|
except Exception as e:
|
|
logger.error(f"非流式对话失败: {e}", exc_info=True)
|
|
return {
|
|
"response": "抱歉,AI 服务暂时不可用,请稍后重试",
|
|
"session_id": sid,
|
|
"function_calls": None,
|
|
"action": None,
|
|
}
|
|
|
|
response_text = response.content if response and response.content else ""
|
|
action = self._extract_action(response_text) if response_text else None
|
|
|
|
return {
|
|
"response": response_text,
|
|
"session_id": sid,
|
|
"function_calls": None,
|
|
"action": action,
|
|
}
|
|
|
|
@staticmethod
|
|
def _extract_action(response_text: str) -> dict[str, Any] | None:
|
|
"""从响应文本中提取结构化动作:优先解析 JSON 指令,其次解析页面导航建议"""
|
|
text = response_text.strip()
|
|
try:
|
|
if text.startswith("{") and text.endswith("}"):
|
|
return json.loads(text)
|
|
if "```json" in text:
|
|
json_start = text.find("```json") + 7
|
|
json_end = text.find("```", json_start)
|
|
if json_end > json_start:
|
|
return json.loads(text[json_start:json_end].strip())
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
return ChatService._parse_action_from_response(response_text)
|
|
|
|
@staticmethod
|
|
def _parse_action_from_response(response_text: str) -> dict[str, Any] | None:
|
|
"""从响应文本中解析页面导航建议"""
|
|
if not any(keyword in response_text for keyword in _NAVIGATION_KEYWORDS):
|
|
return None
|
|
|
|
for words, path, name in _NAVIGATION_ROUTES:
|
|
if any(word in response_text for word in words):
|
|
return {"type": "navigate", "path": path, "name": name}
|
|
return None
|
|
|
|
async def get_session(self, session_id: str) -> ChatSessionOutSchema:
|
|
crud = ChatSessionCRUD(self.auth)
|
|
session = await crud.get_by_id(session_id=session_id)
|
|
if not session:
|
|
raise CustomException(msg="会话不存在", code=10404, status_code=404)
|
|
return ChatSessionOutSchema.model_validate(_format_session_data(session))
|
|
|
|
async def create(self, data: ChatSessionCreateSchema) -> ChatSessionOutSchema:
|
|
crud = ChatSessionCRUD(self.auth)
|
|
session = await crud.create(data=data)
|
|
return ChatSessionOutSchema.model_validate(_format_session_data(session))
|
|
|
|
async def page(
|
|
self,
|
|
page_no: int,
|
|
page_size: int,
|
|
search: ChatSessionQueryParam,
|
|
order_by: list[dict[str, str]] | None = None,
|
|
) -> PageResultSchema[ChatSessionOutSchema]:
|
|
crud = ChatSessionCRUD(self.auth)
|
|
rows, total = await crud.list_page(
|
|
page_no=page_no,
|
|
page_size=page_size,
|
|
title=search.title,
|
|
order_by=order_by,
|
|
)
|
|
items = [ChatSessionOutSchema.model_validate(_format_session_data(row, with_messages=False)) for row in rows]
|
|
return PageResultSchema[ChatSessionOutSchema](
|
|
page_no=page_no,
|
|
page_size=page_size,
|
|
total=total,
|
|
has_next=page_no * page_size < total,
|
|
items=items,
|
|
)
|
|
|
|
async def update(self, session_id: str, data: ChatSessionUpdateSchema) -> None:
|
|
crud = ChatSessionCRUD(self.auth)
|
|
if not await crud.get_by_id(session_id=session_id):
|
|
raise CustomException(msg="会话不存在", code=10404, status_code=404)
|
|
await crud.rename(session_id=session_id, data=data)
|
|
|
|
async def delete(self, session_ids: list[str]) -> None:
|
|
if not session_ids:
|
|
raise CustomException(msg="删除失败,删除对象不能为空")
|
|
crud = ChatSessionCRUD(self.auth)
|
|
for session_id in session_ids:
|
|
if not await crud.get_by_id(session_id=session_id):
|
|
raise CustomException(msg=f"会话不存在: {session_id}", code=10404, status_code=404)
|
|
await crud.delete(session_ids=session_ids)
|
|
|
|
|
|
# ================================================= #
|
|
# ******************* AI 模型配置 ****************** #
|
|
# ================================================= #
|
|
|
|
|
|
_AI_MODEL_TTL = 604800 # AI 模型配置缓存 7 天,不活跃用户自动清理
|
|
_AI_MODEL_LOCK_TTL = 10 # 读改写锁最长持有时间(秒)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _model_config_lock(redis: Redis, user_id: int) -> AsyncGenerator[None, None]:
|
|
"""用户模型配置读改写的分布式锁:配置以 JSON list 整体存取,并发写会互相覆盖。"""
|
|
crud = RedisCURD(redis)
|
|
key = f"{RedisInitKeyConfig.AI_MODEL_CONFIG.key}:lock:{user_id}"
|
|
acquired, token = await crud.lock(key=key, expire=_AI_MODEL_LOCK_TTL)
|
|
if not acquired:
|
|
raise CustomException(msg="模型配置正在被修改,请稍后重试")
|
|
try:
|
|
yield
|
|
finally:
|
|
await crud.unlock(key=key, value=token)
|
|
|
|
|
|
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}"
|
|
|
|
|
|
# 配置项中需要静态加密的敏感字段:Redis 中的 api_key 一律以密文存储
|
|
_SECRET_FIELD = "api_key"
|
|
|
|
|
|
def _seal_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
"""落盘前加密 api_key,其余字段保持明文(用于展示与检索)。"""
|
|
sealed = dict(item)
|
|
if sealed.get(_SECRET_FIELD):
|
|
sealed[_SECRET_FIELD] = CryptoUtil.encrypt(sealed[_SECRET_FIELD])
|
|
return sealed
|
|
|
|
|
|
def _open_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
"""读取后解密 api_key;加密能力上线前的历史明文原样返回。"""
|
|
opened = dict(item)
|
|
if opened.get(_SECRET_FIELD):
|
|
opened[_SECRET_FIELD] = CryptoUtil.decrypt_or_keep(opened[_SECRET_FIELD])
|
|
return opened
|
|
|
|
|
|
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 _read_raw_items(redis: Redis, user_id: int) -> list[dict[str, Any]]:
|
|
"""读取存储层的原始配置项(api_key 为密文)。"""
|
|
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 _write_raw_items(redis: Redis, user_id: int, items: list[dict[str, Any]]) -> None:
|
|
"""写入配置项:加密敏感字段后整体落盘(调用方需持有读改写锁)。"""
|
|
await RedisCURD(redis).set(
|
|
_ai_model_items_key(user_id),
|
|
json.dumps([_seal_item(it) for it in items], ensure_ascii=False),
|
|
expire=_AI_MODEL_TTL,
|
|
)
|
|
|
|
|
|
async def list_user_model_configs(redis: Redis, user_id: int) -> list[dict[str, Any]]:
|
|
"""列出用户的所有模型配置项(api_key 已解密为明文)。"""
|
|
return [_open_item(it) for it in await _read_raw_items(redis, user_id)]
|
|
|
|
|
|
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]:
|
|
"""新增一个模型配置项。"""
|
|
async with _model_config_lock(redis, user_id):
|
|
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 _write_raw_items(redis, user_id, items)
|
|
|
|
# 若用户尚未激活任何配置,自动激活新增的
|
|
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。"""
|
|
async with _model_config_lock(redis, user_id):
|
|
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 _write_raw_items(redis, user_id, items)
|
|
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 是当前激活则清空激活。"""
|
|
async with _model_config_lock(redis, user_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 _write_raw_items(redis, user_id, new_items)
|
|
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_configs(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]:
|
|
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)
|