feat: 新增AI模型配置功能,优化前端组件与代码规范

1. 新增Redis AI_MODEL_CONFIG枚举与后端AI模型配置CRUD接口
2. 升级vue-img-cutter到3.1.1版本,更新前端依赖
3. 重构前端多处ElMessage提示逻辑,统一由拦截器处理
4. 替换ElDrawer为FaDrawer组件,统一弹窗组件库
5. 重构文章详情、评论组件,新增租户切换器与AI配置面板
6. 优化代码生成模板、菜单树表格逻辑与代码高亮样式
7. 修复前端路由与组件命名问题,更新快速入口配置
This commit is contained in:
zhangtao
2026-06-25 00:14:51 +08:00
parent 18ac9eda84
commit fa680c03d3
56 changed files with 4066 additions and 1380 deletions
@@ -2,21 +2,25 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path
from fastapi.responses import JSONResponse
from redis.asyncio import Redis
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.base_schema import AuthSchema
from app.core.dependencies import AuthPermission
from app.core.dependencies import AuthPermission, redis_getter
from app.core.router_class import OperationLogRoute
from .schema import (
AiChatRequestSchema,
AiChatResponseSchema,
AiModelConfigListResponse,
AiModelConfigSchema,
AiModelConfigUpdateSchema,
ChatSessionCreateSchema,
ChatSessionQueryParam,
ChatSessionUpdateSchema,
)
from .service import ChatService
from .service import AiModelConfigService, ChatService
ChatRouter = APIRouter(route_class=OperationLogRoute, prefix="/chat", tags=["AI管理", "AI对话"])
@@ -121,3 +125,83 @@ async def ai_chat_controller(
),
msg="对话成功",
)
# ============ AI 模型配置 ============ #
@ChatRouter.get(
"/model",
summary="获取当前用户的 AI 模型配置列表(含当前激活 ID)",
response_model=ResponseSchema[AiModelConfigListResponse],
)
async def list_model_config_controller(
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:query"]))],
) -> JSONResponse:
service = AiModelConfigService(auth, redis)
result = await service.list()
return SuccessResponse(data=result, msg="获取模型配置列表成功")
@ChatRouter.post(
"/model",
summary="新增一个 AI 模型配置",
response_model=ResponseSchema[dict[str, Any]],
)
async def create_model_config_controller(
data: AiModelConfigUpdateSchema,
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:update"]))],
) -> 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(
config_id: Annotated[str, Path(description="配置项 ID")],
data: AiModelConfigUpdateSchema,
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:update"]))],
) -> 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(
config_id: Annotated[str, Path(description="配置项 ID")],
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:update"]))],
) -> JSONResponse:
service = AiModelConfigService(auth, redis)
await service.delete(config_id)
return SuccessResponse(data=None, msg="模型配置已删除")
@ChatRouter.post(
"/model/{config_id}/activate",
summary="切换当前激活的 AI 模型配置(空 ID 表示使用系统默认)",
response_model=ResponseSchema[None],
)
async def activate_model_config_controller(
config_id: Annotated[str, Path(description="配置项 ID;传 __default__ 使用系统默认")],
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:update"]))],
) -> JSONResponse:
service = AiModelConfigService(auth, redis)
await service.set_active(config_id)
return SuccessResponse(data=None, msg="已切换模型")
+46 -1
View File
@@ -10,9 +10,10 @@ from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQuery
class ChatQuerySchema(BaseModel):
"""WebSocket聊天查询模型"""
message: str = Field(..., min_length=1, description="消息内容")
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):
@@ -83,3 +84,47 @@ class AiChatResponseSchema(BaseModel):
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;为空表示使用系统默认")
+212 -7
View File
@@ -1,4 +1,6 @@
import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import datetime
from typing import Any
@@ -6,15 +8,19 @@ 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 app.api.v1.module_system.dept.service import DeptService
from app.common.enums import RedisInitKeyConfig
from app.common.request import PaginationService
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from .crud import ChatSessionCRUD
from .schema import (
AiModelConfigSchema,
ChatQuerySchema,
ChatSessionCreateSchema,
ChatSessionQueryParam,
@@ -132,7 +138,12 @@ class ChatService:
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
async def chat_query(self, query: ChatQuerySchema) -> AsyncGenerator[str, None]:
async def chat_query(
self,
query: ChatQuerySchema,
stop_event: asyncio.Event | None = None,
model_config: dict[str, Any] | None = None,
) -> AsyncGenerator[str, None]:
"""流式 AI 对话"""
try:
crud = ChatSessionCRUD(self.auth)
@@ -154,14 +165,43 @@ class ChatService:
dept_id=dept_id,
session_id=session_id,
db=crud.db,
model_config=model_config,
)
async for chunk in agent.arun(input=query.message, stream=True):
if chunk and chunk.content:
yield chunk.content
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:
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 chunk.content
else:
logger.debug("空 chunk 跳过: {}", type(chunk).__name__ if chunk else None)
else:
# 兼容非流式直接返回结果的场景
logger.warning("agent.arun 未返回异步迭代器,尝试按单次结果处理")
if stream and getattr(stream, "content", None):
chunk_count += 1
yield stream.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}")
logger.error(f"聊天查询失败: {e}", exc_info=True)
yield f"抱歉,处理您的请求时出现错误:{str(e)}"
async def chat_non_stream(self, message: str, session_id: str | None) -> dict[str, Any]:
@@ -194,8 +234,6 @@ class ChatService:
if response and response.content:
response_text = response.content
import json
try:
if response_text.strip().startswith("{") and response_text.strip().endswith("}"):
action = json.loads(response_text)
@@ -309,3 +347,170 @@ class ChatService:
async def delete(self, session_ids: list[str]) -> None:
await ChatSessionCRUD(self.auth).delete_crud(session_ids=session_ids)
# ================================================= #
# ******************* AI 模型配置 ****************** #
# ================================================= #
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),
)
# 若用户尚未激活任何配置,自动激活新增的
if not await get_active_model_id(redis, user_id):
await RedisCURD(redis).set(_ai_model_active_key(user_id), item["id"])
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),
)
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),
)
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)
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:
if not self.auth or not self.auth.user:
raise CustomException(msg="未登录", code=10401, status_code=401)
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)
+29 -5
View File
@@ -16,8 +16,17 @@ class AgnoFactory:
AGENT_EXPECTED_OUTPUT = "中文回答"
AGENT_TEMPERATURE = 0.7
NUM_HISTORY_RUNS = 3
REQUEST_TIMEOUT = 60.0 # LLM 请求总超时(秒),流式响应需放长
CONNECT_TIMEOUT = 10.0 # TCP 连接超时(秒)
def create_agent(self, user_id: str, dept_id: str, session_id: str, db: Any | None = None) -> Team:
def create_agent(
self,
user_id: str,
dept_id: str,
session_id: str,
db: Any | None = None,
model_config: dict[str, Any] | None = None,
) -> Team:
"""
创建带 Agent 的 Team 实例。
@@ -26,10 +35,24 @@ class AgnoFactory:
- dept_id (str): 部门/团队标识。
- session_id (str): 会话 ID。
- db (Any | None): Agno 持久化数据库实例,可选。
- model_config (dict | None): 运行时模型配置,覆盖系统默认。
支持字段:base_url, api_key, model_id, temperature。
返回:
- Team: 配置好的 Team。
"""
# 优先使用运行时配置,否则 fallback 到系统 settings
base_url = settings.OPENAI_BASE_URL
api_key = settings.OPENAI_API_KEY
model_id = settings.OPENAI_MODEL
temperature = self.AGENT_TEMPERATURE
if model_config:
base_url = model_config.get("base_url") or base_url
api_key = model_config.get("api_key") or api_key
model_id = model_config.get("model_id") or model_id
if isinstance(model_config.get("temperature"), (int, float)):
temperature = float(model_config["temperature"])
# 创建 Agent
fastapiadmin_agent = Agent(
@@ -46,10 +69,11 @@ class AgnoFactory:
user_id=user_id,
session_id=session_id,
model=OpenAILike(
id=settings.OPENAI_MODEL,
api_key=settings.OPENAI_API_KEY,
base_url=settings.OPENAI_BASE_URL,
temperature=self.AGENT_TEMPERATURE,
id=model_id,
api_key=api_key,
base_url=base_url,
temperature=temperature,
timeout=self.REQUEST_TIMEOUT,
),
members=[fastapiadmin_agent],
instructions=self.AGENT_INSTRUCTIONS,
+56 -17
View File
@@ -1,3 +1,4 @@
import asyncio
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -9,7 +10,7 @@ from app.core.logger import logger
from app.core.router_class import OperationLogRoute
from .schema import ChatQuerySchema
from .service import ChatService
from .service import ChatService, get_user_model_config
WS_AI = APIRouter(
route_class=OperationLogRoute,
@@ -36,9 +37,9 @@ async def websocket_chat_controller(websocket: WebSocket) -> None:
"""
WebSocket 聊天接口。
支持两种消息格式:
1. 纯文本:直接发送消息内容
2. JSON 格式{"message": "消息内容", "session_id": "会话ID", "files": [...]}
支持消息格式JSON
- 对话:{"message": "...", "session_id": "...", "files": [...]}
- 停止{"action": "stop", "session_id": "..."}
ws://127.0.0.1:8001/api/v1/ai/chat/ws?token=xxx
"""
@@ -49,8 +50,12 @@ async def websocket_chat_controller(websocket: WebSocket) -> None:
await _send_error_and_close(websocket, "未提供认证token,请重新登录")
return
# 跨消息循环共享的停止信号:客户端发送 stop 时 set,生成器检测到后退出
stop_event = asyncio.Event()
# 标记当前是否在生成中,便于 stop 校验
is_generating = asyncio.Event()
try:
# 认证:db 会话需在整个连接生命周期内保持打开(auth.db 供 ChatService 使用)
redis = websocket.app.state.redis
async with async_db_session() as db:
auth = await _authenticate(token, db, redis)
@@ -58,6 +63,8 @@ async def websocket_chat_controller(websocket: WebSocket) -> None:
user = auth.user
logger.info("WebSocket连接已建立: {} - 用户: {}", websocket.client, user.username if user else "未认证")
chat_service = ChatService(auth)
# 消息循环
while True:
try:
@@ -65,22 +72,54 @@ async def websocket_chat_controller(websocket: WebSocket) -> None:
try:
message_data = json.loads(data)
query = ChatQuerySchema(**message_data)
logger.info("收到聊天查询: {} - 会话ID: {}", query, query.session_id)
chat_result = ChatService.chat_query(query=query, auth=auth)
async for chunk in chat_result:
if chunk:
try:
await websocket.send_text(chunk)
except RuntimeError:
logger.warning("WebSocket连接已关闭,停止发送消息")
return
except json.JSONDecodeError:
logger.warning("收到非JSON消息: {}", data)
await websocket.send_text("消息格式错误,请发送JSON格式的消息")
continue
except Exception as e:
logger.error("处理消息时出错: {}", e)
await websocket.send_text(f"处理消息时出错: {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, 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)