chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ==============================================================================
|
||||
# 解决 Windows 下 psycopg 异步模式不支持 ProactorEventLoop 的问题
|
||||
# 注意:这段代码必须放在应用的极早期,最好在导入 FastAPI 或初始化数据库之前
|
||||
# ==============================================================================
|
||||
if sys.platform == "win32":
|
||||
# 把当前文件 (main.py) 的上一级的上一级 (即根目录 Yuxi) 加入到 sys.path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from server.routers import router
|
||||
from server.utils.lifespan import lifespan
|
||||
from server.utils.common_utils import setup_logging
|
||||
from server.utils.access_log_middleware import AccessLogMiddleware
|
||||
|
||||
# 设置日志配置
|
||||
setup_logging()
|
||||
|
||||
RATE_LIMIT_MAX_ATTEMPTS = 10
|
||||
RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
RATE_LIMIT_ENDPOINTS = {("/api/auth/token", "POST")}
|
||||
DEFAULT_DEVELOPMENT_CORS_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173")
|
||||
EXPLICIT_CORS_METHODS = ("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT")
|
||||
EXPLICIT_CORS_HEADERS = ("Accept", "Authorization", "Content-Type", "Last-Event-ID", "X-Requested-With")
|
||||
|
||||
# In-memory login attempt tracker to reduce brute-force exposure per worker
|
||||
_login_attempts: defaultdict[str, deque[float]] = defaultdict(deque)
|
||||
_attempt_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _parse_cors_origins() -> list[str]:
|
||||
value = os.getenv("YUXI_CORS_ORIGINS")
|
||||
origins = [origin.strip() for origin in (value or "").split(",") if origin.strip()]
|
||||
if origins:
|
||||
return origins
|
||||
|
||||
environment = (os.getenv("YUXI_ENV") or "development").strip().lower()
|
||||
if environment in {"production", "prod"}:
|
||||
return []
|
||||
|
||||
return list(DEFAULT_DEVELOPMENT_CORS_ORIGINS)
|
||||
|
||||
|
||||
def _build_cors_options(origins: list[str] | None = None) -> dict[str, object]:
|
||||
allow_origins = _parse_cors_origins() if origins is None else origins
|
||||
if "*" in allow_origins:
|
||||
return {
|
||||
"allow_origins": ["*"],
|
||||
"allow_credentials": False,
|
||||
"allow_methods": ["*"],
|
||||
"allow_headers": ["*"],
|
||||
}
|
||||
|
||||
return {
|
||||
"allow_origins": allow_origins,
|
||||
"allow_credentials": True,
|
||||
"allow_methods": list(EXPLICIT_CORS_METHODS),
|
||||
"allow_headers": list(EXPLICIT_CORS_HEADERS),
|
||||
"expose_headers": ["Content-Disposition", "X-Lock-Remaining"],
|
||||
}
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
# 所有业务接口统一挂载到 /api,具体分组在 server.routers 中集中注册。
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
# CORS 设置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
**_build_cors_options(),
|
||||
)
|
||||
|
||||
|
||||
def _extract_client_ip(request: Request) -> str:
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
class LoginRateLimitMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
normalized_path = request.url.path.rstrip("/") or "/"
|
||||
request_signature = (normalized_path, request.method.upper())
|
||||
|
||||
if request_signature in RATE_LIMIT_ENDPOINTS:
|
||||
client_ip = _extract_client_ip(request)
|
||||
now = time.monotonic()
|
||||
|
||||
async with _attempt_lock:
|
||||
attempt_history = _login_attempts[client_ip]
|
||||
|
||||
while attempt_history and now - attempt_history[0] > RATE_LIMIT_WINDOW_SECONDS:
|
||||
attempt_history.popleft()
|
||||
|
||||
if len(attempt_history) >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||
retry_after = int(max(1, RATE_LIMIT_WINDOW_SECONDS - (now - attempt_history[0])))
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content={"detail": "登录尝试过于频繁,请稍后再试"},
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
attempt_history.append(now)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
if response.status_code < 400:
|
||||
async with _attempt_lock:
|
||||
_login_attempts.pop(client_ip, None)
|
||||
|
||||
return response
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# 添加访问日志中间件(记录请求处理时间)
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
# 添加登录限流中间件
|
||||
app.add_middleware(LoginRateLimitMiddleware)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# uvicorn.run(app, host="0.0.0.0", port=5050, threads=10, workers=10, reload=True)
|
||||
|
||||
uvicorn.run(
|
||||
"server.main:app",
|
||||
host="0.0.0.0",
|
||||
port=5050,
|
||||
reload=True,
|
||||
# 与 docker-compose 开发环境保持一致,避免 package 下代码变更不触发热重载。
|
||||
reload_dirs=["server", "package"],
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from server.routers.agent_invocation_router import agent_invocation_router
|
||||
from server.routers.agent_router import agent_router
|
||||
from server.routers.auth_dept_router import department
|
||||
from server.routers.auth_router import auth
|
||||
from server.routers.chat_router import chat
|
||||
from server.routers.dashboard_router import dashboard
|
||||
from server.routers.filesystem_router import filesystem_router
|
||||
from server.routers.mcp_router import mcp
|
||||
from server.routers.mention_router import mention_router
|
||||
from server.routers.model_provider_router import model_providers
|
||||
from server.routers.skill_router import skills, user_skills
|
||||
from server.routers.system_router import system
|
||||
from server.routers.system_task_router import tasks
|
||||
from server.routers.tool_router import tools
|
||||
from server.routers.user_router import user_router
|
||||
from server.routers.workspace_router import workspace
|
||||
|
||||
_LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 基础系统接口:健康检查、配置、认证与聊天主链路。
|
||||
router.include_router(system) # /api/system/* 系统状态与全局配置
|
||||
router.include_router(auth) # /api/auth/* 登录、用户信息与 CLI 浏览器登录授权
|
||||
router.include_router(agent_router) # /api/agent/* 智能体管理与运行态
|
||||
router.include_router(agent_invocation_router) # /api/agent-invocation/* 外部 Agent 调用与评估
|
||||
router.include_router(chat) # /api/chat/* 对话线程、消息历史与附件
|
||||
|
||||
# 管理与工作台接口:后台任务、权限域以及工具体系配置。
|
||||
router.include_router(dashboard) # /api/dashboard/* 仪表盘聚合数据
|
||||
router.include_router(department) # /api/departments/* 部门与权限相关数据
|
||||
router.include_router(tasks) # /api/tasks/* 后台任务查询与管理
|
||||
router.include_router(mcp) # /api/system/mcp-servers/* MCP 服务管理
|
||||
router.include_router(model_providers) # /api/system/model-providers/* 独立模型配置
|
||||
router.include_router(skills) # /api/system/skills/* Skills 管理
|
||||
router.include_router(user_skills) # /api/skills/* 用户可用 Skills
|
||||
router.include_router(tools) # /api/system/tools/* 工具列表与配置
|
||||
router.include_router(user_router) # /api/user/* 用户级配置与凭据
|
||||
router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图
|
||||
router.include_router(workspace) # /api/workspace/* 用户个人工作区
|
||||
router.include_router(mention_router) # /api/mention/* 提及文件搜索接口
|
||||
|
||||
if not _LITE_MODE:
|
||||
from server.routers.graph_router import graph
|
||||
from server.routers.knowledge_eval_router import evaluation
|
||||
from server.routers.knowledge_router import knowledge
|
||||
|
||||
# 知识库与图谱能力依赖较重,LITE 模式下跳过这组接口。
|
||||
router.include_router(knowledge) # /api/knowledge/* 知识库管理与检索
|
||||
router.include_router(evaluation) # /api/evaluation/* 知识库评估
|
||||
router.include_router(graph) # /api/graph/* 图谱查询与管理
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from yuxi.services.agent_invocation_service import (
|
||||
create_agent_call_run_view,
|
||||
create_agent_eval_run_view,
|
||||
get_agent_call_run_result_view,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
from server.utils.auth_middleware import get_db, get_required_user
|
||||
|
||||
agent_invocation_router = APIRouter(prefix="/agent-invocation", tags=["agent-invocation"])
|
||||
|
||||
|
||||
class AgentCallRunCreate(BaseModel):
|
||||
agent_slug: str = Field(..., description="要调用的智能体 slug")
|
||||
messages: list[dict[str, Any]] = Field(..., description="消息列表,取最后一条 user 消息作为输入")
|
||||
stream: bool = Field(False, description="暂不支持流式,传 true 会返回 422")
|
||||
agent_call_meta: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Agent Call 元数据;不允许通过 context 覆盖 Agent 运行上下文",
|
||||
)
|
||||
thread_id: str | None = Field(None, description="可选会话线程 ID,不传则自动创建临时线程")
|
||||
request_id: str | None = Field(None, description="可选请求幂等 ID,不传则自动生成")
|
||||
model_spec: str | None = Field(None, description="可选模型覆盖")
|
||||
async_mode: bool = Field(False, description="是否只创建运行并立即返回 run_id")
|
||||
|
||||
|
||||
class AgentCallRunResultRequest(BaseModel):
|
||||
run_id: str = Field(..., description="AgentRun ID")
|
||||
agent_slug: str | None = Field(None, description="可选,传入时校验 run 归属")
|
||||
|
||||
|
||||
class AgentEvaluationContext(BaseModel):
|
||||
dataset_name: str | None = Field(None, description="Langfuse dataset 名称")
|
||||
dataset_item_id: str | None = Field(None, description="Langfuse dataset item ID")
|
||||
experiment_name: str | None = Field(None, description="Langfuse experiment/run 名称")
|
||||
|
||||
|
||||
class AgentEvalRunCreate(BaseModel):
|
||||
query: str = Field(..., description="评估样例输入")
|
||||
agent_slug: str = Field(..., description="要运行的智能体 slug")
|
||||
evaluation: AgentEvaluationContext = Field(default_factory=AgentEvaluationContext, description="评估上下文")
|
||||
meta: dict = Field(default_factory=dict, description="可选,请求追踪信息,例如 request_id、attachment_file_ids")
|
||||
image_content: str | None = Field(None, description="可选,base64 图片内容")
|
||||
model_spec: str | None = Field(None, description="可选,对话级模型覆盖,优先级高于智能体配置")
|
||||
include_trajectory_summary: bool = Field(False, description="是否返回轻量工具调用轨迹摘要")
|
||||
|
||||
|
||||
@agent_invocation_router.post("/agent-call/runs")
|
||||
async def create_agent_call_run(
|
||||
payload: AgentCallRunCreate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建外部系统 Agent 调用 run,并按 async_mode 决定是否等待最终结果。"""
|
||||
return await create_agent_call_run_view(
|
||||
agent_slug=payload.agent_slug,
|
||||
messages=payload.messages,
|
||||
agent_call_meta=payload.agent_call_meta,
|
||||
requested_thread_id=payload.thread_id,
|
||||
request_id=payload.request_id,
|
||||
model_spec=payload.model_spec,
|
||||
async_mode=payload.async_mode,
|
||||
stream=payload.stream,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@agent_invocation_router.post("/agent-call/runs/result")
|
||||
async def get_agent_call_run_result(
|
||||
payload: AgentCallRunResultRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""读取外部 Agent 调用 run 的 OpenAI-compatible 结果结构。"""
|
||||
return await get_agent_call_run_result_view(
|
||||
run_id=payload.run_id,
|
||||
agent_slug=payload.agent_slug,
|
||||
current_uid=str(current_user.uid),
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@agent_invocation_router.post("/eval/runs")
|
||||
async def create_agent_eval_run(
|
||||
payload: AgentEvalRunCreate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""运行一次 CLI/Langfuse Agent 评估样例,并阻塞等待最终输出。"""
|
||||
return await create_agent_eval_run_view(
|
||||
query=payload.query,
|
||||
agent_slug=payload.agent_slug,
|
||||
evaluation=payload.evaluation.model_dump(exclude_none=True),
|
||||
meta=dict(payload.meta or {}),
|
||||
image_content=payload.image_content,
|
||||
model_spec=payload.model_spec,
|
||||
include_trajectory_summary=payload.include_trajectory_summary,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
||||
from yuxi.agents.buildin import agent_manager
|
||||
from yuxi.agents.context import filter_config_by_role
|
||||
from yuxi.repositories.agent_repository import (
|
||||
AgentRepository,
|
||||
is_builtin_agent,
|
||||
user_can_access_agent,
|
||||
user_can_manage_agent,
|
||||
)
|
||||
from yuxi.services.agent_run_service import (
|
||||
cancel_agent_run_view,
|
||||
create_agent_run_view,
|
||||
get_active_run_by_thread,
|
||||
get_agent_run_result,
|
||||
get_agent_run_view,
|
||||
stream_agent_run_events,
|
||||
)
|
||||
from yuxi.services.input_message_service import build_chat_input_message
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
agent_router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
name: str
|
||||
backend_id: str = "ChatbotAgent"
|
||||
slug: str | None = None
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
pics: list[str] | None = None
|
||||
config_json: dict | None = None
|
||||
share_config: dict | None = None
|
||||
is_subagent: bool | None = None
|
||||
set_default: bool = False
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
pics: list[str] | None = None
|
||||
config_json: dict | None = None
|
||||
share_config: dict | None = None
|
||||
is_subagent: bool | None = None
|
||||
|
||||
|
||||
class AgentRunCreate(BaseModel):
|
||||
query: str | None = Field(None, description="用户输入的问题")
|
||||
agent_slug: str = Field(..., description="智能体 slug")
|
||||
thread_id: str = Field(..., description="会话线程 ID")
|
||||
meta: dict = Field(default_factory=dict, description="可选,请求追踪信息,例如 request_id")
|
||||
image_content: str | None = Field(None, description="可选,base64 图片内容")
|
||||
model_spec: str | None = Field(None, description="可选,对话级模型覆盖,优先级高于智能体配置")
|
||||
resume: Any | None = Field(None, description="可选,恢复时传给 LangGraph 的输入载荷,非布尔值")
|
||||
created_by_run_id: str | None = Field(None, description="可选,创建本 run 的父 run ID;resume 时为被恢复的 run ID")
|
||||
|
||||
|
||||
def _backend_info(info: dict) -> dict:
|
||||
data = dict(info)
|
||||
data["backend_id"] = data.pop("id", None)
|
||||
data["type"] = "agent_backend"
|
||||
return data
|
||||
|
||||
|
||||
def _filter_agent_config_json(backend_id: str, config_json: dict | None, role: str | None) -> dict:
|
||||
backend = agent_manager.get_agent(backend_id)
|
||||
context_schema = backend.context_schema if backend else None
|
||||
return filter_config_by_role(config_json or {}, role, context_schema=context_schema)
|
||||
|
||||
|
||||
async def _serialize_agent(
|
||||
repo: AgentRepository,
|
||||
item,
|
||||
user: User,
|
||||
*,
|
||||
include_configurable_items: bool = False,
|
||||
backend_info_cache: dict[tuple[str, bool, str], dict] | None = None,
|
||||
) -> dict:
|
||||
data = await repo.serialize(
|
||||
item,
|
||||
user=user,
|
||||
include_configurable_items=include_configurable_items,
|
||||
backend_info_cache=backend_info_cache,
|
||||
)
|
||||
data["config_json"] = _filter_agent_config_json(item.backend_id, data.get("config_json"), user.role)
|
||||
return data
|
||||
|
||||
|
||||
@agent_router.get("/backends")
|
||||
async def list_agent_backends(current_user: User = Depends(get_required_user)):
|
||||
infos = await agent_manager.get_agents_info(include_configurable_items=False)
|
||||
return {"backends": [_backend_info(info) for info in infos]}
|
||||
|
||||
|
||||
@agent_router.get("/backends/{backend_id}")
|
||||
async def get_agent_backend(
|
||||
backend_id: str,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
backend = agent_manager.get_agent(backend_id)
|
||||
if not backend:
|
||||
raise HTTPException(status_code=404, detail=f"智能体后端 {backend_id} 不存在")
|
||||
return _backend_info(await backend.get_info(user_role=current_user.role, db=db, user=current_user))
|
||||
|
||||
|
||||
@agent_router.get("")
|
||||
async def list_agents(
|
||||
include_subagents: bool = Query(False),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = AgentRepository(db)
|
||||
await repo.ensure_default_agent()
|
||||
items = await repo.list_visible(user=current_user, include_subagent_definitions=include_subagents)
|
||||
backend_info_cache: dict[tuple[str, bool, str], dict] = {}
|
||||
agents = [await _serialize_agent(repo, item, current_user, backend_info_cache=backend_info_cache) for item in items]
|
||||
return {"agents": agents}
|
||||
|
||||
|
||||
@agent_router.get("/default")
|
||||
async def get_default_agent(current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)):
|
||||
repo = AgentRepository(db)
|
||||
item = await repo.ensure_default_agent()
|
||||
if not item or not user_can_access_agent(current_user, item):
|
||||
raise HTTPException(status_code=404, detail="默认智能体不可访问")
|
||||
return {"agent": await _serialize_agent(repo, item, current_user, include_configurable_items=True)}
|
||||
|
||||
|
||||
@agent_router.post("")
|
||||
async def create_agent(
|
||||
payload: AgentCreate, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
if not agent_manager.get_agent(payload.backend_id):
|
||||
raise HTTPException(status_code=404, detail=f"智能体后端 {payload.backend_id} 不存在")
|
||||
if payload.set_default:
|
||||
raise HTTPException(status_code=422, detail="默认智能体已固定为内置智能助手")
|
||||
|
||||
repo = AgentRepository(db)
|
||||
try:
|
||||
item = await repo.create(
|
||||
name=payload.name,
|
||||
slug=payload.slug,
|
||||
backend_id=payload.backend_id,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
pics=payload.pics,
|
||||
config_json=_filter_agent_config_json(payload.backend_id, payload.config_json, current_user.role),
|
||||
share_config=payload.share_config,
|
||||
is_default=payload.set_default,
|
||||
is_subagent=payload.is_subagent,
|
||||
created_by=str(current_user.uid),
|
||||
creator=current_user,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return {"agent": await _serialize_agent(repo, item, current_user, include_configurable_items=True)}
|
||||
|
||||
|
||||
@agent_router.get("/{agent_id}")
|
||||
async def get_agent(agent_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)):
|
||||
repo = AgentRepository(db)
|
||||
agent_slug = agent_id # 兼容既有路径参数名;这里实际是 Agent.slug。
|
||||
item = await repo.get_visible_by_slug(slug=agent_slug, user=current_user, kind="any")
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="智能体不存在")
|
||||
return {"agent": await _serialize_agent(repo, item, current_user, include_configurable_items=True)}
|
||||
|
||||
|
||||
@agent_router.put("/{agent_id}")
|
||||
async def update_agent(
|
||||
agent_id: str,
|
||||
payload: AgentUpdate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = AgentRepository(db)
|
||||
agent_slug = agent_id # 兼容既有路径参数名;这里实际是 Agent.slug。
|
||||
item = await repo.get_visible_by_slug(slug=agent_slug, user=current_user, kind="any")
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="智能体不存在")
|
||||
if not user_can_manage_agent(current_user, item):
|
||||
raise HTTPException(status_code=403, detail="不能编辑非自己创建的智能体")
|
||||
|
||||
try:
|
||||
fields_set = payload.model_fields_set
|
||||
if "description" in fields_set and payload.description is None:
|
||||
item.description = None
|
||||
if "icon" in fields_set and payload.icon is None:
|
||||
item.icon = None
|
||||
|
||||
updated = await repo.update(
|
||||
item,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
pics=payload.pics,
|
||||
config_json=_filter_agent_config_json(item.backend_id, payload.config_json, current_user.role)
|
||||
if payload.config_json is not None
|
||||
else None,
|
||||
share_config=payload.share_config,
|
||||
is_subagent=payload.is_subagent,
|
||||
updated_by=str(current_user.uid),
|
||||
updater=current_user,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return {"agent": await _serialize_agent(repo, updated, current_user, include_configurable_items=True)}
|
||||
|
||||
|
||||
@agent_router.delete("/{agent_id}")
|
||||
async def delete_agent(
|
||||
agent_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
repo = AgentRepository(db)
|
||||
agent_slug = agent_id # 兼容既有路径参数名;这里实际是 Agent.slug。
|
||||
item = await repo.get_visible_by_slug(slug=agent_slug, user=current_user, kind="any")
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="智能体不存在")
|
||||
if not user_can_manage_agent(current_user, item):
|
||||
raise HTTPException(status_code=403, detail="不能删除非自己创建的智能体")
|
||||
if is_builtin_agent(item):
|
||||
raise HTTPException(status_code=409, detail="内置智能体不能删除")
|
||||
await repo.delete(agent=item)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@agent_router.post("/{agent_id}/set_default")
|
||||
async def set_agent_default(
|
||||
agent_id: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = AgentRepository(db)
|
||||
agent_slug = agent_id # 兼容既有路径参数名;这里实际是 Agent.slug。
|
||||
item = await repo.get_by_slug(agent_slug)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="智能体不存在")
|
||||
try:
|
||||
updated = await repo.set_default(agent=item, updated_by=str(current_user.uid))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return {"agent": await _serialize_agent(repo, updated, current_user, include_configurable_items=True)}
|
||||
|
||||
|
||||
@agent_router.post("/runs")
|
||||
async def create_agent_run(
|
||||
payload: AgentRunCreate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
input_message = None
|
||||
if payload.resume is None and payload.query:
|
||||
input_message = build_chat_input_message(payload.query, payload.image_content)
|
||||
return await create_agent_run_view(
|
||||
input_message=input_message,
|
||||
agent_slug=payload.agent_slug,
|
||||
thread_id=payload.thread_id,
|
||||
meta=dict(payload.meta or {}),
|
||||
model_spec=payload.model_spec,
|
||||
current_uid=str(current_user.uid),
|
||||
db=db,
|
||||
resume=payload.resume,
|
||||
created_by_run_id=payload.created_by_run_id,
|
||||
)
|
||||
|
||||
|
||||
@agent_router.get("/runs/{run_id}")
|
||||
async def get_agent_run(
|
||||
run_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
return await get_agent_run_view(run_id=run_id, current_uid=str(current_user.uid), db=db)
|
||||
|
||||
|
||||
@agent_router.get("/runs/{run_id}/result")
|
||||
async def get_agent_run_result_route(
|
||||
run_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
return await get_agent_run_result(run_id=run_id, current_uid=str(current_user.uid), db=db)
|
||||
|
||||
|
||||
@agent_router.post("/runs/{run_id}/cancel")
|
||||
async def cancel_agent_run(
|
||||
run_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
return await cancel_agent_run_view(run_id=run_id, current_uid=str(current_user.uid), db=db)
|
||||
|
||||
|
||||
@agent_router.get("/runs/{run_id}/events")
|
||||
async def stream_run_events(
|
||||
run_id: str,
|
||||
after_seq: str = "0-0",
|
||||
verbose: bool = Query(default=True, description="是否返回完整事件载荷;false 时仅返回 UI/客户端消费所需字段"),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
cursor = last_event_id or after_seq
|
||||
return StreamingResponse(
|
||||
stream_agent_run_events(run_id=run_id, after_seq=cursor, current_uid=str(current_user.uid), verbose=verbose),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@agent_router.get("/thread/{thread_id}/active_run")
|
||||
async def get_thread_active_run(
|
||||
thread_id: str,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await get_active_run_by_thread(thread_id=thread_id, current_uid=str(current_user.uid), db=db)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
部门管理路由
|
||||
提供部门的增删改查接口,仅超级管理员可访问
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete as sqlalchemy_delete, select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from yuxi.storage.postgres.models_business import APIKey, Department, User
|
||||
from yuxi.repositories.department_repository import DepartmentRepository
|
||||
from yuxi.repositories.user_repository import UserRepository
|
||||
from server.utils.auth_middleware import get_superadmin_user, get_admin_user, get_db
|
||||
from yuxi.utils.auth_utils import AuthUtils
|
||||
from yuxi.services.operation_log_service import log_operation
|
||||
from yuxi.services.user_identity_service import is_valid_phone_number
|
||||
|
||||
# 创建路由器
|
||||
department = APIRouter(prefix="/departments", tags=["department"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 请求和响应模型 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DepartmentCreate(BaseModel):
|
||||
"""创建部门请求"""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
# 必需的管理员信息
|
||||
admin_uid: str
|
||||
admin_password: str
|
||||
admin_phone: str | None = None
|
||||
|
||||
|
||||
class DepartmentUpdate(BaseModel):
|
||||
"""更新部门请求"""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class DepartmentResponse(BaseModel):
|
||||
"""部门响应"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
created_at: str
|
||||
user_count: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 部门管理路由 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@department.get("", response_model=list[DepartmentResponse])
|
||||
async def get_departments(current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有部门列表(管理员可访问)"""
|
||||
dept_repo = DepartmentRepository()
|
||||
return await dept_repo.list_with_user_count()
|
||||
|
||||
|
||||
@department.get("/{department_id}", response_model=DepartmentResponse)
|
||||
async def get_department(
|
||||
department_id: int, current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取指定部门详情"""
|
||||
result = await db.execute(select(Department).filter(Department.id == department_id))
|
||||
department = result.scalar_one_or_none()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部门不存在")
|
||||
|
||||
# 获取部门下用户数量
|
||||
user_count_result = await db.execute(
|
||||
select(func.count(User.id)).filter(User.department_id == department_id, User.is_deleted == 0)
|
||||
)
|
||||
user_count = user_count_result.scalar()
|
||||
|
||||
return {**department.to_dict(), "user_count": user_count}
|
||||
|
||||
|
||||
@department.post("", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_department(
|
||||
department_data: DepartmentCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建新部门,同时创建该部门的管理员"""
|
||||
dept_repo = DepartmentRepository()
|
||||
user_repo = UserRepository()
|
||||
|
||||
# 检查部门名称是否已存在
|
||||
if await dept_repo.exists_by_name(department_data.name):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="部门名称已存在")
|
||||
|
||||
# 验证管理员 uid 格式
|
||||
admin_uid = department_data.admin_uid
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", admin_uid):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户ID只能包含字母、数字和下划线",
|
||||
)
|
||||
|
||||
if len(admin_uid) < 3 or len(admin_uid) > 20:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户ID长度必须在3-20个字符之间",
|
||||
)
|
||||
|
||||
# 检查 uid 是否已存在
|
||||
if await user_repo.exists_by_uid(admin_uid):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户ID已存在",
|
||||
)
|
||||
|
||||
# 检查手机号是否已存在(如果提供了)
|
||||
admin_phone = department_data.admin_phone
|
||||
if admin_phone:
|
||||
if not is_valid_phone_number(admin_phone):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号格式不正确")
|
||||
if await user_repo.exists_by_phone(admin_phone):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="手机号已存在",
|
||||
)
|
||||
|
||||
# 创建部门
|
||||
new_department = await dept_repo.create(
|
||||
{
|
||||
"name": department_data.name,
|
||||
"description": department_data.description,
|
||||
}
|
||||
)
|
||||
|
||||
# 创建管理员用户
|
||||
hashed_password = AuthUtils.hash_password(department_data.admin_password)
|
||||
await user_repo.create(
|
||||
{
|
||||
"username": admin_uid,
|
||||
"uid": admin_uid,
|
||||
"phone_number": admin_phone,
|
||||
"password_hash": hashed_password,
|
||||
"role": "admin",
|
||||
"department_id": new_department.id,
|
||||
}
|
||||
)
|
||||
|
||||
# 记录操作
|
||||
await log_operation(
|
||||
db, current_user.id, "创建部门", f"创建部门: {department_data.name},并创建管理员: {admin_uid}", request
|
||||
)
|
||||
|
||||
return {**new_department.to_dict(), "user_count": 1}
|
||||
|
||||
|
||||
@department.put("/{department_id}", response_model=DepartmentResponse)
|
||||
async def update_department(
|
||||
department_id: int,
|
||||
department_data: DepartmentUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新部门信息"""
|
||||
result = await db.execute(select(Department).filter(Department.id == department_id))
|
||||
department = result.scalar_one_or_none()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部门不存在")
|
||||
|
||||
# 如果要修改名称,检查新名称是否已存在
|
||||
if department_data.name and department_data.name != department.name:
|
||||
result = await db.execute(select(Department).filter(Department.name == department_data.name))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="部门名称已存在")
|
||||
department.name = department_data.name
|
||||
|
||||
if department_data.description is not None:
|
||||
department.description = department_data.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(department)
|
||||
|
||||
# 记录操作
|
||||
await log_operation(db, current_user.id, "更新部门", f"更新部门: {department.name}", request)
|
||||
|
||||
# 获取部门下用户数量
|
||||
user_count_result = await db.execute(
|
||||
select(func.count(User.id)).filter(User.department_id == department_id, User.is_deleted == 0)
|
||||
)
|
||||
user_count = user_count_result.scalar()
|
||||
|
||||
return {**department.to_dict(), "user_count": user_count}
|
||||
|
||||
|
||||
@department.delete("/{department_id}", status_code=status.HTTP_200_OK)
|
||||
async def delete_department(
|
||||
department_id: int,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除部门"""
|
||||
# 检查部门是否存在
|
||||
result = await db.execute(select(Department).filter(Department.id == department_id))
|
||||
department = result.scalar_one_or_none()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部门不存在")
|
||||
|
||||
if department.id == 1: # 默认部门的ID为1
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="默认部门不允许删除")
|
||||
|
||||
department_name = department.name
|
||||
result = await db.execute(select(User).filter(User.department_id == department_id))
|
||||
department_users = result.scalars().all()
|
||||
|
||||
if department_users:
|
||||
for user in department_users:
|
||||
user.department_id = 1 # 将被删除部门的用户移至默认部门
|
||||
|
||||
await db.execute(sqlalchemy_delete(APIKey).where(APIKey.department_id == department_id))
|
||||
await db.delete(department)
|
||||
await db.commit()
|
||||
|
||||
# 记录操作
|
||||
if department_users:
|
||||
detail = f"删除部门: {department_name},迁移 {len(department_users)} 个用户到默认部门"
|
||||
else:
|
||||
detail = f"删除部门: {department_name}"
|
||||
await log_operation(db, current_user.id, "删除部门", detail, request)
|
||||
|
||||
return {"success": True, "message": "部门已删除"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,603 @@
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from server.utils.auth_middleware import get_db, get_required_user
|
||||
from yuxi import config as conf
|
||||
from yuxi.models import select_model
|
||||
from yuxi.services.chat_service import get_agent_state_view
|
||||
from yuxi.services.conversation_service import (
|
||||
confirm_tmp_thread_attachments_view,
|
||||
create_thread_view,
|
||||
delete_thread_attachment_view,
|
||||
delete_thread_view,
|
||||
get_thread_history_view,
|
||||
list_thread_attachments_view,
|
||||
list_threads_view,
|
||||
parse_tmp_attachment_view,
|
||||
search_threads_view,
|
||||
update_thread_view,
|
||||
upload_thread_attachment_view,
|
||||
upload_tmp_attachment_view,
|
||||
)
|
||||
from yuxi.services.file_preview import detect_media_type
|
||||
from yuxi.services.thread_files_service import (
|
||||
list_thread_files_view,
|
||||
read_thread_file_content_view,
|
||||
resolve_thread_artifact_view,
|
||||
save_thread_artifact_to_workspace_view,
|
||||
)
|
||||
from yuxi.services.feedback_service import get_message_feedback_view, submit_message_feedback_view
|
||||
from yuxi.utils.logging_config import logger
|
||||
from yuxi.utils.image_processor import process_uploaded_image
|
||||
from yuxi.utils.paths import VIRTUAL_PATH_PREFIX
|
||||
|
||||
|
||||
# TODO:当前文件的功能过于庞杂,路由标签混乱
|
||||
|
||||
|
||||
# 图片上传响应模型
|
||||
class ImageUploadResponse(BaseModel):
|
||||
success: bool
|
||||
image_content: str | None = None
|
||||
thumbnail_content: str | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
format: str | None = None
|
||||
mime_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
chat = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
@chat.post("/call")
|
||||
async def call(query: str = Body(...), meta: dict = Body(None), current_user: User = Depends(get_required_user)):
|
||||
"""调用模型进行简单问答(需要登录)"""
|
||||
meta = meta or {}
|
||||
|
||||
# 确保 request_id 存在
|
||||
if "request_id" not in meta or not meta.get("request_id"):
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
|
||||
model = select_model(model_spec=meta.get("model_spec") or meta.get("model") or conf.default_model)
|
||||
|
||||
response = await model.call(query)
|
||||
logger.debug({"query": query, "response": response.content})
|
||||
|
||||
return {"response": response.content, "request_id": meta["request_id"]}
|
||||
|
||||
|
||||
@chat.get("/thread/{thread_id}/history")
|
||||
async def get_thread_history(
|
||||
thread_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取对话历史消息(需要登录)- 包含用户反馈状态"""
|
||||
try:
|
||||
return await get_thread_history_view(
|
||||
thread_id=thread_id,
|
||||
current_uid=str(current_user.uid),
|
||||
db=db,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取对话历史消息出错: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"获取对话历史消息出错: {str(e)}")
|
||||
|
||||
|
||||
@chat.get("/thread/{thread_id}/state")
|
||||
async def get_thread_state(
|
||||
thread_id: str,
|
||||
include_messages: bool = Query(False),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取对话当前状态(需要登录)"""
|
||||
try:
|
||||
return await get_agent_state_view(
|
||||
thread_id=thread_id,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
include_messages=include_messages,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取对话状态出错: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"获取对话状态出错: {str(e)}")
|
||||
|
||||
|
||||
# ==================== 线程管理 API ====================
|
||||
|
||||
|
||||
class ThreadCreate(BaseModel):
|
||||
title: str | None = None
|
||||
agent_id: str
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
class ThreadResponse(BaseModel):
|
||||
id: str
|
||||
uid: str
|
||||
agent_id: str
|
||||
title: str | None = None
|
||||
is_pinned: bool = False
|
||||
created_at: str
|
||||
updated_at: str
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ThreadSearchSnippet(BaseModel):
|
||||
message_id: int | None = None
|
||||
content: str
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class ThreadSearchItem(ThreadResponse):
|
||||
thread_id: str
|
||||
matched_count: int
|
||||
message_id: int | None = None
|
||||
latest_match_at: str | None = None
|
||||
snippets: list[ThreadSearchSnippet] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ThreadSearchResponse(BaseModel):
|
||||
items: list[ThreadSearchItem]
|
||||
has_more: bool
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class AttachmentResponse(BaseModel):
|
||||
file_id: str
|
||||
file_name: str
|
||||
file_type: str | None = None
|
||||
file_size: int
|
||||
status: str
|
||||
uploaded_at: str
|
||||
path: str
|
||||
artifact_url: str | None = None
|
||||
original_path: str | None = None
|
||||
original_artifact_url: str | None = None
|
||||
minio_url: str | None = None
|
||||
request_id: str | None = None
|
||||
|
||||
|
||||
class AttachmentLimits(BaseModel):
|
||||
allowed_extensions: list[str]
|
||||
max_size_bytes: int
|
||||
|
||||
|
||||
class AttachmentListResponse(BaseModel):
|
||||
attachments: list[AttachmentResponse]
|
||||
limits: AttachmentLimits
|
||||
|
||||
|
||||
class TmpAttachmentResponse(BaseModel):
|
||||
tmp_file_id: str
|
||||
file_name: str
|
||||
file_type: str | None = None
|
||||
file_size: int
|
||||
bucket_name: str
|
||||
object_name: str
|
||||
minio_url: str
|
||||
uploaded_at: str
|
||||
parse_supported: bool = False
|
||||
parse_methods: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TmpAttachmentParseRequest(BaseModel):
|
||||
object_name: str
|
||||
file_name: str
|
||||
parse_method: str | None = None
|
||||
bucket_name: str | None = None
|
||||
|
||||
|
||||
class TmpAttachmentParseResponse(BaseModel):
|
||||
tmp_file_id: str
|
||||
file_name: str
|
||||
bucket_name: str
|
||||
object_name: str
|
||||
parsed_object_name: str
|
||||
parsed_minio_url: str
|
||||
parse_method: str
|
||||
status: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class TmpAttachmentConfirmItem(BaseModel):
|
||||
file_name: str
|
||||
file_type: str | None = None
|
||||
bucket_name: str
|
||||
object_name: str
|
||||
parsed_object_name: str | None = None
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class TmpAttachmentConfirmRequest(BaseModel):
|
||||
attachments: list[TmpAttachmentConfirmItem]
|
||||
|
||||
|
||||
class TmpAttachmentConfirmResponse(BaseModel):
|
||||
attachments: list[AttachmentResponse]
|
||||
|
||||
|
||||
class ThreadFileEntry(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int
|
||||
modified_at: str | None = None
|
||||
artifact_url: str | None = None
|
||||
|
||||
|
||||
class ThreadFileListResponse(BaseModel):
|
||||
path: str
|
||||
files: list[ThreadFileEntry]
|
||||
|
||||
|
||||
class ThreadFileContentResponse(BaseModel):
|
||||
path: str
|
||||
content: list[str]
|
||||
offset: int
|
||||
limit: int
|
||||
total_lines: int
|
||||
artifact_url: str
|
||||
|
||||
|
||||
class SaveThreadArtifactRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class SaveThreadArtifactResponse(BaseModel):
|
||||
name: str
|
||||
source_path: str
|
||||
saved_path: str
|
||||
saved_artifact_url: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# > === 会话管理分组 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@chat.post("/thread", response_model=ThreadResponse)
|
||||
async def create_thread(
|
||||
thread: ThreadCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
|
||||
):
|
||||
"""创建新对话线程 (使用新存储系统)"""
|
||||
return await create_thread_view(
|
||||
agent_slug=thread.agent_id,
|
||||
title=thread.title,
|
||||
metadata=thread.metadata,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
@chat.get("/threads", response_model=list[ThreadResponse])
|
||||
async def list_threads(
|
||||
agent_id: str | None = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""获取用户的所有对话线程 (使用新存储系统)"""
|
||||
return await list_threads_view(
|
||||
agent_slug=agent_id, db=db, current_uid=str(current_user.uid), limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@chat.get("/threads/search", response_model=ThreadSearchResponse)
|
||||
async def search_threads(
|
||||
q: str = Query(..., min_length=1, max_length=200),
|
||||
agent_id: str | None = Query(None),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""搜索当前用户的历史对话。"""
|
||||
return await search_threads_view(
|
||||
query=q,
|
||||
agent_id=agent_id,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@chat.delete("/thread/{thread_id}")
|
||||
async def delete_thread(
|
||||
thread_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
|
||||
):
|
||||
"""删除对话线程 (使用新存储系统)"""
|
||||
return await delete_thread_view(thread_id=thread_id, db=db, current_uid=str(current_user.uid))
|
||||
|
||||
|
||||
class ThreadUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
is_pinned: bool | None = None
|
||||
|
||||
|
||||
@chat.put("/thread/{thread_id}", response_model=ThreadResponse)
|
||||
async def update_thread(
|
||||
thread_id: str,
|
||||
thread_update: ThreadUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""更新对话线程信息 (使用新存储系统)"""
|
||||
return await update_thread_view(
|
||||
thread_id=thread_id,
|
||||
title=thread_update.title,
|
||||
is_pinned=thread_update.is_pinned,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
# ================================
|
||||
# > === 附件管理分组 ===
|
||||
# ================================
|
||||
|
||||
|
||||
@chat.post("/attachments/tmp", response_model=TmpAttachmentResponse)
|
||||
async def upload_tmp_attachment(file: UploadFile = File(...), current_user: User = Depends(get_required_user)):
|
||||
"""上传附件到 MinIO tmp,暂不关联线程。"""
|
||||
return await upload_tmp_attachment_view(file=file, current_uid=str(current_user.uid))
|
||||
|
||||
|
||||
@chat.post("/attachments/tmp/parse", response_model=TmpAttachmentParseResponse)
|
||||
async def parse_tmp_attachment(
|
||||
request: TmpAttachmentParseRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""解析 tmp 附件并返回解析后的 tmp URL。"""
|
||||
return await parse_tmp_attachment_view(
|
||||
object_name=request.object_name,
|
||||
file_name=request.file_name,
|
||||
parse_method=request.parse_method,
|
||||
bucket_name=request.bucket_name,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
@chat.post("/thread/{thread_id}/attachments/confirm", response_model=TmpAttachmentConfirmResponse)
|
||||
async def confirm_tmp_thread_attachments(
|
||||
thread_id: str,
|
||||
request: TmpAttachmentConfirmRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""将 tmp 附件正式加入线程附件列表。"""
|
||||
return await confirm_tmp_thread_attachments_view(
|
||||
thread_id=thread_id,
|
||||
attachments=[item.model_dump() for item in request.attachments],
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
@chat.post("/thread/{thread_id}/attachments", response_model=AttachmentResponse)
|
||||
async def upload_thread_attachment(
|
||||
thread_id: str,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""上传原始附件并关联到指定对话线程。"""
|
||||
return await upload_thread_attachment_view(
|
||||
thread_id=thread_id,
|
||||
file=file,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
@chat.get("/thread/{thread_id}/attachments", response_model=AttachmentListResponse)
|
||||
async def list_thread_attachments(
|
||||
thread_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""列出当前对话线程的所有附件元信息。"""
|
||||
return await list_thread_attachments_view(
|
||||
thread_id=thread_id,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
@chat.delete("/thread/{thread_id}/attachments/{file_id}")
|
||||
async def delete_thread_attachment(
|
||||
thread_id: str,
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""移除指定附件。"""
|
||||
return await delete_thread_attachment_view(
|
||||
thread_id=thread_id,
|
||||
file_id=file_id,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
@chat.get("/thread/{thread_id}/files", response_model=ThreadFileListResponse)
|
||||
async def list_thread_files(
|
||||
thread_id: str,
|
||||
path: str = Query(f"{VIRTUAL_PATH_PREFIX}"),
|
||||
recursive: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""列出线程文件目录。"""
|
||||
return await list_thread_files_view(
|
||||
thread_id=thread_id,
|
||||
current_uid=str(current_user.uid),
|
||||
db=db,
|
||||
path=path,
|
||||
recursive=recursive,
|
||||
)
|
||||
|
||||
|
||||
@chat.get("/thread/{thread_id}/files/content", response_model=ThreadFileContentResponse)
|
||||
async def read_thread_file_content(
|
||||
thread_id: str,
|
||||
path: str = Query(...),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(2000, ge=1, le=5000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""读取线程文本文件(按行分页)。"""
|
||||
return await read_thread_file_content_view(
|
||||
thread_id=thread_id,
|
||||
current_uid=str(current_user.uid),
|
||||
db=db,
|
||||
path=path,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@chat.get("/thread/{thread_id}/artifacts/{path:path}")
|
||||
async def get_thread_artifact(
|
||||
thread_id: str,
|
||||
path: str,
|
||||
download: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""下载或预览线程文件。"""
|
||||
file_path = await resolve_thread_artifact_view(
|
||||
thread_id=thread_id,
|
||||
current_uid=str(current_user.uid),
|
||||
db=db,
|
||||
path=path,
|
||||
)
|
||||
|
||||
media_type = detect_media_type(file_path.name, file_path.read_bytes())
|
||||
headers = {"Content-Disposition": f'attachment; filename="{file_path.name}"'} if download else None
|
||||
return FileResponse(path=file_path, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
@chat.post("/thread/{thread_id}/artifacts/save", response_model=SaveThreadArtifactResponse)
|
||||
async def save_thread_artifact_to_workspace(
|
||||
thread_id: str,
|
||||
request: SaveThreadArtifactRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""保存交付物到共享 workspace/saved_artifacts 目录。"""
|
||||
return await save_thread_artifact_to_workspace_view(
|
||||
thread_id=thread_id,
|
||||
current_uid=str(current_user.uid),
|
||||
db=db,
|
||||
path=request.path,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# > === 消息反馈分组 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MessageFeedbackRequest(BaseModel):
|
||||
rating: str # 'like' or 'dislike'
|
||||
reason: str | None = None # Optional reason for dislike
|
||||
|
||||
|
||||
class MessageFeedbackResponse(BaseModel):
|
||||
id: int
|
||||
message_id: int
|
||||
rating: str
|
||||
reason: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
@chat.post("/message/{message_id}/feedback", response_model=MessageFeedbackResponse)
|
||||
async def submit_message_feedback(
|
||||
message_id: int,
|
||||
feedback_data: MessageFeedbackRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""提交消息反馈(需要登录)"""
|
||||
result = await submit_message_feedback_view(
|
||||
message_id=message_id,
|
||||
rating=feedback_data.rating,
|
||||
reason=feedback_data.reason,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
return MessageFeedbackResponse(**result)
|
||||
|
||||
|
||||
@chat.get("/message/{message_id}/feedback")
|
||||
async def get_message_feedback(
|
||||
message_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""获取指定消息的用户反馈(需要登录)"""
|
||||
return await get_message_feedback_view(
|
||||
message_id=message_id,
|
||||
db=db,
|
||||
current_uid=str(current_user.uid),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# > === 多模态图片支持分组 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@chat.post("/image/upload", response_model=ImageUploadResponse)
|
||||
async def upload_image(file: UploadFile = File(...), current_user: User = Depends(get_required_user)):
|
||||
"""
|
||||
上传并处理图片,返回base64编码的图片数据
|
||||
"""
|
||||
try:
|
||||
# 验证文件类型
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="只支持图片文件上传")
|
||||
|
||||
# 读取文件内容
|
||||
image_data = await file.read()
|
||||
|
||||
# 检查文件大小(10MB限制,超过后会压缩到5MB)
|
||||
if len(image_data) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="图片文件过大,请上传小于10MB的图片")
|
||||
|
||||
# 处理图片
|
||||
result = process_uploaded_image(image_data, file.filename)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=f"图片处理失败: {result['error']}")
|
||||
|
||||
logger.info(
|
||||
f"用户 {current_user.id} 成功上传图片: {file.filename}, "
|
||||
f"尺寸: {result['width']}x{result['height']}, "
|
||||
f"格式: {result['format']}, "
|
||||
f"大小: {result['size_bytes']} bytes"
|
||||
)
|
||||
|
||||
return ImageUploadResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"图片上传处理失败: {str(e)}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"图片处理失败: {str(e)}")
|
||||
@@ -0,0 +1,989 @@
|
||||
"""
|
||||
Dashboard Router - Statistics and monitoring endpoints
|
||||
仪表板 - 统计和监控端点
|
||||
|
||||
Provides centralized dashboard APIs for monitoring system-wide statistics.
|
||||
提供系统级统计和监控的API接口,用于监控系统运行状态、用户活动、工具调用、知识库使用等。
|
||||
"""
|
||||
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Integer, String, cast, distinct, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_db, get_superadmin_user
|
||||
from yuxi.repositories.agent_repository import AgentRepository
|
||||
from yuxi.repositories.conversation_repository import ConversationRepository
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils.datetime_utils import UTC, ensure_shanghai, shanghai_now, utc_now
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
dashboard = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
def _get_time_group_format(column, time_range: str) -> Any:
|
||||
"""
|
||||
根据数据库类型生成时间分组格式化表达式。
|
||||
PostgreSQL 使用 to_char + INTERVAL,SQLite 使用 datetime + strftime。
|
||||
"""
|
||||
# 检查是否是 PostgreSQL(通过检测 engine 或使用方言)
|
||||
# 这里直接使用 PostgreSQL 语法,因为所有业务数据现在都在 PostgreSQL 上
|
||||
if time_range == "14hours":
|
||||
# 每小时: YYYY-MM-DD HH:00
|
||||
time_expr = func.to_char(column + text("INTERVAL '8 hours'"), "YYYY-MM-DD HH24:00")
|
||||
elif time_range == "14weeks":
|
||||
# 每周: YYYY-WW
|
||||
time_expr = func.to_char(column + text("INTERVAL '8 hours'"), "YYYY-IW")
|
||||
else: # 14days
|
||||
# 每天: YYYY-MM-DD
|
||||
time_expr = func.to_char(column + text("INTERVAL '8 hours'"), "YYYY-MM-DD")
|
||||
return time_expr
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class UserActivityStats(BaseModel):
|
||||
"""用户活跃度统计"""
|
||||
|
||||
total_users: int
|
||||
active_users_24h: int
|
||||
active_users_30d: int
|
||||
daily_active_users: list[dict] # 最近7天每日活跃用户
|
||||
|
||||
|
||||
class ToolCallStats(BaseModel):
|
||||
"""工具调用统计"""
|
||||
|
||||
total_calls: int
|
||||
successful_calls: int
|
||||
failed_calls: int
|
||||
success_rate: float
|
||||
most_used_tools: list[dict]
|
||||
tool_error_distribution: dict
|
||||
daily_tool_calls: list[dict] # 最近7天每日工具调用数
|
||||
|
||||
|
||||
class KnowledgeStats(BaseModel):
|
||||
"""知识库统计"""
|
||||
|
||||
total_databases: int
|
||||
total_files: int
|
||||
total_nodes: int
|
||||
total_storage_size: int # 字节
|
||||
databases_by_type: dict
|
||||
file_type_distribution: dict
|
||||
|
||||
|
||||
class AgentAnalytics(BaseModel):
|
||||
"""AI智能体分析"""
|
||||
|
||||
total_agents: int
|
||||
agent_conversation_counts: list[dict]
|
||||
agent_satisfaction_rates: list[dict]
|
||||
agent_tool_usage: list[dict]
|
||||
top_performing_agents: list[dict]
|
||||
agent_names: dict[str, str] = {} # agent_id -> agent_name 映射
|
||||
|
||||
|
||||
class ConversationListItem(BaseModel):
|
||||
"""Conversation list item"""
|
||||
|
||||
thread_id: str
|
||||
uid: str
|
||||
agent_id: str
|
||||
title: str
|
||||
status: str
|
||||
message_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ConversationDetailResponse(BaseModel):
|
||||
"""Conversation detail"""
|
||||
|
||||
thread_id: str
|
||||
uid: str
|
||||
agent_id: str
|
||||
title: str
|
||||
status: str
|
||||
message_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
total_tokens: int
|
||||
messages: list[dict]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Conversation Management - 对话管理
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/conversations", response_model=list[ConversationListItem])
|
||||
async def get_all_conversations(
|
||||
uid: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
status: str = "active",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取所有对话(超级管理员权限)"""
|
||||
from yuxi.storage.postgres.models_business import Conversation, ConversationStats
|
||||
|
||||
try:
|
||||
# Build query
|
||||
query = select(Conversation, ConversationStats).outerjoin(
|
||||
ConversationStats, Conversation.id == ConversationStats.conversation_id
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if uid:
|
||||
query = query.filter(Conversation.uid == uid)
|
||||
if agent_id:
|
||||
query = query.filter(Conversation.agent_id == agent_id)
|
||||
if status != "all":
|
||||
query = query.filter(Conversation.status == status)
|
||||
|
||||
# Order and paginate
|
||||
query = query.order_by(Conversation.updated_at.desc()).limit(limit).offset(offset)
|
||||
|
||||
result = await db.execute(query)
|
||||
results = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"thread_id": conv.thread_id,
|
||||
"uid": conv.uid,
|
||||
"agent_id": conv.agent_id,
|
||||
"title": conv.title,
|
||||
"status": conv.status,
|
||||
"message_count": stats.message_count if stats else 0,
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"updated_at": conv.updated_at.isoformat(),
|
||||
}
|
||||
for conv, stats in results
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting conversations: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get conversations: {str(e)}")
|
||||
|
||||
|
||||
@dashboard.get("/conversations/{thread_id}", response_model=ConversationDetailResponse)
|
||||
async def get_conversation_detail(
|
||||
thread_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取指定对话详情(超级管理员权限)"""
|
||||
try:
|
||||
conv_manager = ConversationRepository(db)
|
||||
conversation = await conv_manager.get_conversation_by_thread_id(thread_id)
|
||||
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
# Get messages and stats
|
||||
messages = await conv_manager.get_messages(conversation.id)
|
||||
stats = await conv_manager.get_stats(conversation.id)
|
||||
|
||||
# Format messages
|
||||
message_list = []
|
||||
for msg in messages:
|
||||
msg_dict = {
|
||||
"id": msg.id,
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"message_type": msg.message_type,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
}
|
||||
|
||||
# Include tool calls if present
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"tool_name": tc.tool_name,
|
||||
"tool_input": tc.tool_input,
|
||||
"tool_output": tc.tool_output,
|
||||
"status": tc.status,
|
||||
}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
|
||||
message_list.append(msg_dict)
|
||||
|
||||
return {
|
||||
"thread_id": conversation.thread_id,
|
||||
"uid": conversation.uid,
|
||||
"agent_id": conversation.agent_id,
|
||||
"title": conversation.title,
|
||||
"status": conversation.status,
|
||||
"message_count": stats.message_count if stats else len(message_list),
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
"updated_at": conversation.updated_at.isoformat(),
|
||||
"total_tokens": stats.total_tokens if stats else 0,
|
||||
"messages": message_list,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting conversation detail: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get conversation detail: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 用户活动统计(超级管理员权限)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/stats/users", response_model=UserActivityStats)
|
||||
async def get_user_activity_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取用户活动统计(超级管理员权限)"""
|
||||
try:
|
||||
from yuxi.storage.postgres.models_business import Conversation, User
|
||||
|
||||
now = utc_now()
|
||||
# PostgreSQL with asyncpg requires naive datetime for naive DateTime columns
|
||||
naive_now = now.replace(tzinfo=None)
|
||||
|
||||
# Conversations may store either the numeric user primary key or the login uid string.
|
||||
# Join condition accounts for both representations.
|
||||
user_join_condition = Conversation.uid == User.uid
|
||||
|
||||
# 基础用户统计(排除已删除用户)
|
||||
total_users_result = await db.execute(select(func.count(User.id)).filter(User.is_deleted == 0))
|
||||
total_users = total_users_result.scalar() or 0
|
||||
|
||||
# 不同时间段的活跃用户数(基于对话活动,排除已删除用户)
|
||||
active_users_24h_result = await db.execute(
|
||||
select(func.count(distinct(User.id)))
|
||||
.select_from(Conversation)
|
||||
.join(User, user_join_condition)
|
||||
.filter(Conversation.updated_at >= naive_now - timedelta(days=1), User.is_deleted == 0)
|
||||
)
|
||||
active_users_24h = active_users_24h_result.scalar() or 0
|
||||
|
||||
active_users_30d_result = await db.execute(
|
||||
select(func.count(distinct(User.id)))
|
||||
.select_from(Conversation)
|
||||
.join(User, user_join_condition)
|
||||
.filter(Conversation.updated_at >= naive_now - timedelta(days=30), User.is_deleted == 0)
|
||||
)
|
||||
active_users_30d = active_users_30d_result.scalar() or 0
|
||||
# 最近7天每日活跃用户(排除已删除用户)
|
||||
daily_active_users = []
|
||||
for i in range(7):
|
||||
day_start = naive_now - timedelta(days=i + 1)
|
||||
day_end = naive_now - timedelta(days=i)
|
||||
|
||||
active_count_result = await db.execute(
|
||||
select(func.count(distinct(User.id)))
|
||||
.select_from(Conversation)
|
||||
.join(User, user_join_condition)
|
||||
.filter(Conversation.updated_at >= day_start, Conversation.updated_at < day_end, User.is_deleted == 0)
|
||||
)
|
||||
active_count = active_count_result.scalar() or 0
|
||||
|
||||
daily_active_users.append({"date": day_start.strftime("%Y-%m-%d"), "active_users": active_count})
|
||||
|
||||
return UserActivityStats(
|
||||
total_users=total_users,
|
||||
active_users_24h=active_users_24h,
|
||||
active_users_30d=active_users_30d,
|
||||
daily_active_users=list(reversed(daily_active_users)), # 按时间正序
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user activity stats: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get user activity stats: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tool Call Statistics - 工具调用统计
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/stats/tools", response_model=ToolCallStats)
|
||||
async def get_tool_call_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取工具调用统计(超级管理员权限)"""
|
||||
try:
|
||||
from yuxi.storage.postgres.models_business import ToolCall
|
||||
|
||||
now = utc_now()
|
||||
# PostgreSQL with asyncpg requires naive datetime for naive DateTime columns
|
||||
naive_now = now.replace(tzinfo=None)
|
||||
|
||||
# 基础工具调用统计
|
||||
total_calls_result = await db.execute(select(func.count(ToolCall.id)))
|
||||
total_calls = total_calls_result.scalar() or 0
|
||||
|
||||
successful_calls_result = await db.execute(select(func.count(ToolCall.id)).filter(ToolCall.status == "success"))
|
||||
successful_calls = successful_calls_result.scalar() or 0
|
||||
failed_calls = total_calls - successful_calls
|
||||
success_rate = round((successful_calls / total_calls * 100), 2) if total_calls > 0 else 0
|
||||
|
||||
# 最常用工具
|
||||
most_used_tools_result = await db.execute(
|
||||
select(ToolCall.tool_name, func.count(ToolCall.id).label("count"))
|
||||
.group_by(ToolCall.tool_name)
|
||||
.order_by(func.count(ToolCall.id).desc())
|
||||
.limit(10)
|
||||
)
|
||||
most_used_tools = most_used_tools_result.all()
|
||||
most_used_tools = [{"tool_name": name, "count": count} for name, count in most_used_tools]
|
||||
|
||||
# 工具错误分布
|
||||
tool_errors_result = await db.execute(
|
||||
select(ToolCall.tool_name, func.count(ToolCall.id).label("error_count"))
|
||||
.filter(ToolCall.status == "error")
|
||||
.group_by(ToolCall.tool_name)
|
||||
)
|
||||
tool_errors = tool_errors_result.all()
|
||||
tool_error_distribution = {name: count for name, count in tool_errors}
|
||||
|
||||
# 最近7天每日工具调用数
|
||||
daily_tool_calls = []
|
||||
for i in range(7):
|
||||
day_start = naive_now - timedelta(days=i + 1)
|
||||
day_end = naive_now - timedelta(days=i)
|
||||
|
||||
daily_count_result = await db.execute(
|
||||
select(func.count(ToolCall.id)).filter(ToolCall.created_at >= day_start, ToolCall.created_at < day_end)
|
||||
)
|
||||
daily_count = daily_count_result.scalar() or 0
|
||||
|
||||
daily_tool_calls.append({"date": day_start.strftime("%Y-%m-%d"), "call_count": daily_count})
|
||||
|
||||
return ToolCallStats(
|
||||
total_calls=total_calls,
|
||||
successful_calls=successful_calls,
|
||||
failed_calls=failed_calls,
|
||||
success_rate=success_rate,
|
||||
most_used_tools=most_used_tools,
|
||||
tool_error_distribution=tool_error_distribution,
|
||||
daily_tool_calls=list(reversed(daily_tool_calls)),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tool call stats: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get tool call stats: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 知识库统计(超级管理员权限)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/stats/knowledge", response_model=KnowledgeStats)
|
||||
async def get_knowledge_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取知识库统计(超级管理员权限)"""
|
||||
try:
|
||||
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||||
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||||
|
||||
kb_repo = KnowledgeBaseRepository()
|
||||
file_repo = KnowledgeFileRepository()
|
||||
|
||||
kb_rows = await kb_repo.get_all()
|
||||
total_databases = len(kb_rows)
|
||||
|
||||
databases_by_type: dict[str, int] = {}
|
||||
files_by_type: dict[str, int] = {}
|
||||
total_files = 0
|
||||
total_nodes = 0
|
||||
total_storage_size = 0
|
||||
|
||||
file_type_mapping = {
|
||||
"txt": "文本文件",
|
||||
"pdf": "PDF文档",
|
||||
"docx": "Word文档",
|
||||
"doc": "Word文档",
|
||||
"md": "Markdown",
|
||||
"html": "HTML网页",
|
||||
"htm": "HTML网页",
|
||||
"json": "JSON数据",
|
||||
"csv": "CSV表格",
|
||||
"xlsx": "Excel表格",
|
||||
"xls": "Excel表格",
|
||||
"pptx": "PowerPoint",
|
||||
"ppt": "PowerPoint",
|
||||
"png": "PNG图片",
|
||||
"jpg": "JPEG图片",
|
||||
"jpeg": "JPEG图片",
|
||||
"gif": "GIF图片",
|
||||
"svg": "SVG图片",
|
||||
"mp4": "MP4视频",
|
||||
"mp3": "MP3音频",
|
||||
"zip": "ZIP压缩包",
|
||||
"rar": "RAR压缩包",
|
||||
"7z": "7Z压缩包",
|
||||
}
|
||||
|
||||
for kb in kb_rows:
|
||||
kb_type = (kb.kb_type or "unknown").lower()
|
||||
display_type = {
|
||||
"faiss": "FAISS",
|
||||
"milvus": "Milvus",
|
||||
"dify": "Dify",
|
||||
"qdrant": "Qdrant",
|
||||
"elasticsearch": "Elasticsearch",
|
||||
"unknown": "未知类型",
|
||||
}.get(kb_type, kb.kb_type or "未知类型")
|
||||
databases_by_type[display_type] = databases_by_type.get(display_type, 0) + 1
|
||||
|
||||
files = await file_repo.list_by_kb_id(kb.kb_id)
|
||||
total_files += len(files)
|
||||
for record in files:
|
||||
file_ext = (record.file_type or "").lower()
|
||||
display_name = file_type_mapping.get(file_ext, file_ext.upper() + "文件" if file_ext else "其他")
|
||||
files_by_type[display_name] = files_by_type.get(display_name, 0) + 1
|
||||
total_storage_size += int(record.file_size or 0)
|
||||
|
||||
return KnowledgeStats(
|
||||
total_databases=total_databases,
|
||||
total_files=total_files,
|
||||
total_nodes=total_nodes,
|
||||
total_storage_size=total_storage_size,
|
||||
databases_by_type=databases_by_type,
|
||||
file_type_distribution=files_by_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting knowledge stats: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get knowledge stats: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 智能体分析(超级管理员权限)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/stats/agents", response_model=AgentAnalytics)
|
||||
async def get_agent_analytics(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取智能体分析(超级管理员权限)"""
|
||||
try:
|
||||
from yuxi.storage.postgres.models_business import Conversation, Message, MessageFeedback, ToolCall
|
||||
|
||||
# 获取所有智能体
|
||||
agents_result = await db.execute(
|
||||
select(Conversation.agent_id, func.count(Conversation.id).label("conversation_count")).group_by(
|
||||
Conversation.agent_id
|
||||
)
|
||||
)
|
||||
agents = agents_result.all()
|
||||
|
||||
total_agents = len(agents)
|
||||
agent_conversation_counts = [{"agent_id": agent_id, "conversation_count": count} for agent_id, count in agents]
|
||||
|
||||
# 智能体满意度统计
|
||||
agent_satisfaction = []
|
||||
for agent_id, _ in agents:
|
||||
total_feedbacks_result = await db.execute(
|
||||
select(func.count(MessageFeedback.id))
|
||||
.join(Message, MessageFeedback.message_id == Message.id)
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.filter(Conversation.agent_id == agent_id)
|
||||
)
|
||||
total_feedbacks = total_feedbacks_result.scalar() or 0
|
||||
|
||||
positive_feedbacks_result = await db.execute(
|
||||
select(func.count(MessageFeedback.id))
|
||||
.join(Message, MessageFeedback.message_id == Message.id)
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.filter(Conversation.agent_id == agent_id, MessageFeedback.rating == "like")
|
||||
)
|
||||
positive_feedbacks = positive_feedbacks_result.scalar() or 0
|
||||
|
||||
satisfaction_rate = round((positive_feedbacks / total_feedbacks * 100), 2) if total_feedbacks > 0 else 100
|
||||
|
||||
agent_satisfaction.append(
|
||||
{"agent_id": agent_id, "satisfaction_rate": satisfaction_rate, "total_feedbacks": total_feedbacks}
|
||||
)
|
||||
|
||||
# 智能体工具使用统计
|
||||
agent_tool_usage = []
|
||||
for agent_id, _ in agents:
|
||||
tool_usage_count_result = await db.execute(
|
||||
select(func.count(ToolCall.id))
|
||||
.join(Message, ToolCall.message_id == Message.id)
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.filter(Conversation.agent_id == agent_id)
|
||||
)
|
||||
tool_usage_count = tool_usage_count_result.scalar() or 0
|
||||
|
||||
agent_tool_usage.append({"agent_id": agent_id, "tool_usage_count": tool_usage_count})
|
||||
|
||||
# 表现最佳的智能体(按对话数排序)
|
||||
top_performing_agents = []
|
||||
for i, (agent_id, conv_count) in enumerate(agents):
|
||||
# 获取满意度数据
|
||||
satisfaction_data = next(
|
||||
(s for s in agent_satisfaction if s["agent_id"] == agent_id), {"satisfaction_rate": 0}
|
||||
)
|
||||
|
||||
top_performing_agents.append(
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"conversation_count": conv_count,
|
||||
"satisfaction_rate": satisfaction_data["satisfaction_rate"],
|
||||
}
|
||||
)
|
||||
|
||||
# 按对话数排序,取前5名
|
||||
top_performing_agents.sort(key=lambda x: x["conversation_count"], reverse=True)
|
||||
top_performing_agents = top_performing_agents[:5]
|
||||
|
||||
agent_slugs = [agent_id for agent_id, _ in agents if agent_id]
|
||||
agent_names = {}
|
||||
if agent_slugs:
|
||||
agent_repo = AgentRepository(db)
|
||||
agent_names = {agent.slug: agent.name for agent in await agent_repo.list_by_slugs(agent_slugs)}
|
||||
|
||||
return AgentAnalytics(
|
||||
total_agents=total_agents,
|
||||
agent_conversation_counts=agent_conversation_counts,
|
||||
agent_satisfaction_rates=agent_satisfaction,
|
||||
agent_tool_usage=agent_tool_usage,
|
||||
top_performing_agents=top_performing_agents,
|
||||
agent_names=agent_names,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting agent analytics: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get agent analytics: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 基础统计(超级管理员权限)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/stats")
|
||||
async def get_dashboard_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取基础统计(超级管理员权限)"""
|
||||
from yuxi.storage.postgres.models_business import Conversation, Message, MessageFeedback
|
||||
|
||||
try:
|
||||
# Basic counts
|
||||
total_conversations_result = await db.execute(select(func.count(Conversation.id)))
|
||||
total_conversations = total_conversations_result.scalar() or 0
|
||||
|
||||
active_conversations_result = await db.execute(
|
||||
select(func.count(Conversation.id)).filter(Conversation.status == "active")
|
||||
)
|
||||
active_conversations = active_conversations_result.scalar() or 0
|
||||
|
||||
total_messages_result = await db.execute(select(func.count(Message.id)))
|
||||
total_messages = total_messages_result.scalar() or 0
|
||||
|
||||
total_users_result = await db.execute(select(func.count(User.id)).filter(User.is_deleted == 0))
|
||||
total_users = total_users_result.scalar() or 0
|
||||
|
||||
# Feedback statistics
|
||||
total_feedbacks_result = await db.execute(select(func.count(MessageFeedback.id)))
|
||||
total_feedbacks = total_feedbacks_result.scalar() or 0
|
||||
|
||||
like_count_result = await db.execute(
|
||||
select(func.count(MessageFeedback.id)).filter(MessageFeedback.rating == "like")
|
||||
)
|
||||
like_count = like_count_result.scalar() or 0
|
||||
|
||||
# Calculate satisfaction rate
|
||||
satisfaction_rate = round((like_count / total_feedbacks * 100), 2) if total_feedbacks > 0 else 100
|
||||
|
||||
return {
|
||||
"total_conversations": total_conversations,
|
||||
"active_conversations": active_conversations,
|
||||
"total_messages": total_messages,
|
||||
"total_users": total_users,
|
||||
"feedback_stats": {
|
||||
"total_feedbacks": total_feedbacks,
|
||||
"satisfaction_rate": satisfaction_rate,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting dashboard stats: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get dashboard stats: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 反馈管理(超级管理员权限)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FeedbackListItem(BaseModel):
|
||||
"""反馈列表项"""
|
||||
|
||||
id: int
|
||||
uid: str
|
||||
username: str | None
|
||||
avatar: str | None
|
||||
rating: str
|
||||
reason: str | None
|
||||
created_at: str
|
||||
message_content: str
|
||||
conversation_title: str | None
|
||||
agent_id: str
|
||||
|
||||
|
||||
@dashboard.get("/feedbacks", response_model=list[FeedbackListItem])
|
||||
async def get_all_feedbacks(
|
||||
rating: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取所有反馈记录(超级管理员权限)"""
|
||||
from yuxi.storage.postgres.models_business import Conversation, Message, MessageFeedback, User
|
||||
|
||||
try:
|
||||
query = (
|
||||
select(MessageFeedback, Message, Conversation, User)
|
||||
.join(Message, MessageFeedback.message_id == Message.id)
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.outerjoin(User, MessageFeedback.uid == User.uid)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if rating and rating in ["like", "dislike"]:
|
||||
query = query.filter(MessageFeedback.rating == rating)
|
||||
if agent_id:
|
||||
query = query.filter(Conversation.agent_id == agent_id)
|
||||
|
||||
# Order by creation time (most recent first)
|
||||
query = query.order_by(MessageFeedback.created_at.desc())
|
||||
|
||||
results = await db.execute(query)
|
||||
results = results.all()
|
||||
|
||||
# Debug logging (privacy-safe)
|
||||
logger.info(f"Found {len(results)} feedback records")
|
||||
# Removed sensitive user data from logs for privacy compliance
|
||||
|
||||
return [
|
||||
{
|
||||
"id": feedback.id,
|
||||
"message_id": feedback.message_id,
|
||||
"uid": feedback.uid,
|
||||
"username": user.username if user else None,
|
||||
"avatar": user.avatar if user else None,
|
||||
"rating": feedback.rating,
|
||||
"reason": feedback.reason,
|
||||
"created_at": feedback.created_at.isoformat(),
|
||||
"message_content": message.content,
|
||||
"conversation_title": conversation.title,
|
||||
"agent_id": conversation.agent_id,
|
||||
}
|
||||
for feedback, message, conversation, user in results
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting feedbacks: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get feedbacks: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 调用分析时间序列统计(超级管理员权限)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TimeSeriesStats(BaseModel):
|
||||
"""时间序列统计数据"""
|
||||
|
||||
data: list[dict] # [{"date": "2024-01-01", "data": {"item1": 50, "item2": 30}, "total": 80}, ...]
|
||||
categories: list[str] # 所有类别名称
|
||||
total_count: int
|
||||
average_count: float
|
||||
peak_count: int
|
||||
peak_date: str
|
||||
agent_names: dict[str, str] | None = None # agent_id -> agent_name 映射(仅 type=agents)
|
||||
|
||||
|
||||
@dashboard.get("/stats/calls/timeseries", response_model=TimeSeriesStats)
|
||||
async def get_call_timeseries_stats(
|
||||
type: str = "models", # models/agents/tokens/tools
|
||||
time_range: str = "14days", # 14hours/14days/14weeks
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取调用分析时间序列统计(超级管理员权限)"""
|
||||
try:
|
||||
from yuxi.storage.postgres.models_business import Conversation, Message, ToolCall
|
||||
|
||||
# 计算时间范围(使用北京时间 UTC+8)
|
||||
now = utc_now()
|
||||
local_now = shanghai_now()
|
||||
|
||||
if time_range == "14hours":
|
||||
intervals = 14
|
||||
# 包含当前小时:从13小时前开始
|
||||
start_time = now - timedelta(hours=intervals - 1)
|
||||
group_format = _get_time_group_format(Message.created_at, time_range)
|
||||
base_local_time = ensure_shanghai(start_time)
|
||||
elif time_range == "14weeks":
|
||||
intervals = 14
|
||||
# 包含当前周:从13周前开始,并对齐到当周周一 00:00
|
||||
local_start = local_now - timedelta(weeks=intervals - 1)
|
||||
local_start = local_start - timedelta(days=local_start.weekday())
|
||||
local_start = local_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_time = local_start.astimezone(UTC)
|
||||
group_format = _get_time_group_format(Message.created_at, time_range)
|
||||
base_local_time = local_start
|
||||
else: # 14days (default)
|
||||
intervals = 14
|
||||
# 包含当前天:从13天前开始
|
||||
start_time = now - timedelta(days=intervals - 1)
|
||||
group_format = _get_time_group_format(Message.created_at, time_range)
|
||||
base_local_time = ensure_shanghai(start_time)
|
||||
|
||||
# Convert start_time to naive UTC datetime for PostgreSQL query
|
||||
# PostgreSQL with asyncpg and naive DateTime columns requires naive datetime objects
|
||||
query_start_time = start_time.replace(tzinfo=None)
|
||||
|
||||
# 根据类型查询数据
|
||||
if type == "models":
|
||||
# 模型调用统计(基于消息数量,按模型分组)
|
||||
# 从message的extra_metadata中提取模型信息
|
||||
category_expr = cast(Message.extra_metadata["response_metadata"]["model_name"], String)
|
||||
query_result = await db.execute(
|
||||
select(
|
||||
group_format.label("date"),
|
||||
func.count(Message.id).label("count"),
|
||||
category_expr.label("category"),
|
||||
)
|
||||
.filter(Message.role == "assistant", Message.created_at >= query_start_time)
|
||||
.filter(Message.extra_metadata.isnot(None))
|
||||
.group_by(group_format, category_expr)
|
||||
.order_by(group_format)
|
||||
)
|
||||
query = query_result.all()
|
||||
elif type == "agents":
|
||||
# 智能体调用统计(基于对话更新时间,按智能体分组)
|
||||
# 为对话创建独立的时间格式化器(使用 PostgreSQL 兼容的 to_char + INTERVAL)
|
||||
conv_group_format = _get_time_group_format(Conversation.updated_at, time_range)
|
||||
|
||||
query_result = await db.execute(
|
||||
select(
|
||||
conv_group_format.label("date"),
|
||||
func.count(Conversation.id).label("count"),
|
||||
Conversation.agent_id.label("category"),
|
||||
)
|
||||
.filter(Conversation.updated_at.isnot(None))
|
||||
.filter(Conversation.updated_at >= query_start_time)
|
||||
.group_by(conv_group_format, Conversation.agent_id)
|
||||
.order_by(conv_group_format)
|
||||
)
|
||||
query = query_result.all()
|
||||
elif type == "tokens":
|
||||
# Token消耗统计(区分input/output tokens)
|
||||
# 先查询input tokens
|
||||
from sqlalchemy import literal
|
||||
|
||||
input_query_result = await db.execute(
|
||||
select(
|
||||
group_format.label("date"),
|
||||
func.sum(
|
||||
func.coalesce(
|
||||
cast(cast(Message.extra_metadata["usage_metadata"]["input_tokens"], String), Integer), 0
|
||||
)
|
||||
).label("count"),
|
||||
literal("input_tokens").label("category"),
|
||||
)
|
||||
.filter(
|
||||
Message.created_at >= query_start_time,
|
||||
Message.extra_metadata.isnot(None),
|
||||
Message.extra_metadata["usage_metadata"].isnot(None),
|
||||
)
|
||||
.group_by(group_format)
|
||||
.order_by(group_format)
|
||||
)
|
||||
input_query = input_query_result.all()
|
||||
|
||||
# 查询output tokens
|
||||
output_query_result = await db.execute(
|
||||
select(
|
||||
group_format.label("date"),
|
||||
func.sum(
|
||||
func.coalesce(
|
||||
cast(cast(Message.extra_metadata["usage_metadata"]["output_tokens"], String), Integer), 0
|
||||
)
|
||||
).label("count"),
|
||||
literal("output_tokens").label("category"),
|
||||
)
|
||||
.filter(
|
||||
Message.created_at >= query_start_time,
|
||||
Message.extra_metadata.isnot(None),
|
||||
Message.extra_metadata["usage_metadata"].isnot(None),
|
||||
)
|
||||
.group_by(group_format)
|
||||
.order_by(group_format)
|
||||
)
|
||||
output_query = output_query_result.all()
|
||||
|
||||
# 合并两个查询结果
|
||||
input_results = input_query
|
||||
output_results = output_query
|
||||
results = input_results + output_results
|
||||
elif type == "tools":
|
||||
# 工具调用统计(按工具名称分组)
|
||||
# 为工具调用创建独立的时间格式化器(使用 PostgreSQL 兼容的 to_char + INTERVAL)
|
||||
tool_group_format = _get_time_group_format(ToolCall.created_at, time_range)
|
||||
|
||||
query_result = await db.execute(
|
||||
select(
|
||||
tool_group_format.label("date"),
|
||||
func.count(ToolCall.id).label("count"),
|
||||
ToolCall.tool_name.label("category"),
|
||||
)
|
||||
.filter(ToolCall.created_at >= query_start_time)
|
||||
.group_by(tool_group_format, ToolCall.tool_name)
|
||||
.order_by(tool_group_format)
|
||||
)
|
||||
query = query_result.all()
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid type: {type}")
|
||||
|
||||
if type != "tokens":
|
||||
results = query
|
||||
|
||||
# 处理堆叠数据格式
|
||||
# 首先收集所有类别
|
||||
categories = set()
|
||||
for result in results:
|
||||
if hasattr(result, "category") and result.category:
|
||||
categories.add(result.category)
|
||||
|
||||
# 如果没有类别数据,提供默认类别
|
||||
if not categories:
|
||||
if type == "models":
|
||||
categories.add("unknown_model")
|
||||
elif type == "agents":
|
||||
categories.add("unknown_agent")
|
||||
elif type == "tokens":
|
||||
categories.update(["input_tokens", "output_tokens"])
|
||||
elif type == "tools":
|
||||
categories.add("unknown_tool")
|
||||
|
||||
categories = sorted(list(categories))
|
||||
|
||||
agent_names = None
|
||||
if type == "agents" and categories:
|
||||
agent_slugs = [c for c in categories if c]
|
||||
if agent_slugs:
|
||||
agent_repo = AgentRepository(db)
|
||||
agent_names = {agent.slug: agent.name for agent in await agent_repo.list_by_slugs(agent_slugs)}
|
||||
|
||||
# 重新组织数据:按时间点分组每个类别的数据
|
||||
time_data = {}
|
||||
|
||||
def normalize_week_key(raw_key: str) -> str:
|
||||
base_date = datetime.strptime(f"{raw_key}-1", "%Y-%W-%w")
|
||||
iso_year, iso_week, _ = base_date.isocalendar()
|
||||
return f"{iso_year}-{iso_week:02d}"
|
||||
|
||||
for result in results:
|
||||
date_key = result.date
|
||||
if time_range == "14weeks":
|
||||
date_key = normalize_week_key(date_key)
|
||||
category = getattr(result, "category", "unknown")
|
||||
count = result.count
|
||||
|
||||
if date_key not in time_data:
|
||||
time_data[date_key] = {}
|
||||
|
||||
time_data[date_key][category] = count
|
||||
|
||||
# 填充缺失的时间点(使用北京时间)
|
||||
data = []
|
||||
# 从起始点开始(北京时间)
|
||||
current_time = base_local_time
|
||||
|
||||
if time_range == "14hours":
|
||||
delta = timedelta(hours=1)
|
||||
elif time_range == "14weeks":
|
||||
delta = timedelta(weeks=1)
|
||||
else:
|
||||
delta = timedelta(days=1)
|
||||
|
||||
for i in range(intervals):
|
||||
if time_range == "14hours":
|
||||
date_key = current_time.strftime("%Y-%m-%d %H:00")
|
||||
elif time_range == "14weeks":
|
||||
iso_year, iso_week, _ = current_time.isocalendar()
|
||||
date_key = f"{iso_year}-{iso_week:02d}"
|
||||
else:
|
||||
date_key = current_time.strftime("%Y-%m-%d")
|
||||
|
||||
# 获取该时间点的数据
|
||||
day_data = time_data.get(date_key, {})
|
||||
day_total = sum(day_data.values())
|
||||
|
||||
# 确保所有类别都有值(缺失的补0)
|
||||
for category in categories:
|
||||
if category not in day_data:
|
||||
day_data[category] = 0
|
||||
|
||||
data.append({"date": date_key, "data": day_data, "total": day_total})
|
||||
current_time += delta
|
||||
|
||||
# 计算统计指标
|
||||
if type == "tools":
|
||||
# 对于工具调用,显示所有时间的总数(与ToolStatsComponent保持一致)
|
||||
from yuxi.storage.postgres.models_business import ToolCall
|
||||
|
||||
total_count_result = await db.execute(select(func.count(ToolCall.id)))
|
||||
total_count = total_count_result.scalar() or 0
|
||||
else:
|
||||
# 其他类型使用时间序列数据的总和
|
||||
total_count = sum(item["total"] for item in data)
|
||||
|
||||
average_count = round(total_count / intervals, 2) if intervals > 0 else 0
|
||||
peak_data = max(data, key=lambda x: x["total"]) if data else {"total": 0, "date": ""}
|
||||
|
||||
return TimeSeriesStats(
|
||||
data=data,
|
||||
categories=categories,
|
||||
total_count=total_count,
|
||||
average_count=average_count,
|
||||
peak_count=peak_data["total"],
|
||||
peak_date=peak_data["date"],
|
||||
agent_names=agent_names,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting call timeseries stats: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get call timeseries stats: {str(e)}")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Viewer 文件系统路由
|
||||
|
||||
提供 Viewer UI 使用的文件系统 API 端点。
|
||||
- /viewer/filesystem/* - Viewer UI 使用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_db, get_required_user
|
||||
from yuxi.services.viewer_filesystem_service import (
|
||||
create_viewer_directory,
|
||||
delete_viewer_file,
|
||||
download_viewer_file,
|
||||
list_viewer_filesystem_tree,
|
||||
read_viewer_file_content,
|
||||
upload_viewer_files,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
filesystem_router = APIRouter(prefix="/viewer/filesystem", tags=["viewer-filesystem"])
|
||||
|
||||
|
||||
class CreateViewerDirectoryRequest(BaseModel):
|
||||
thread_id: str
|
||||
parent_path: str
|
||||
name: str
|
||||
|
||||
|
||||
@filesystem_router.get("/tree", response_model=dict)
|
||||
async def get_viewer_tree(
|
||||
thread_id: str = Query(..., description="线程 ID"),
|
||||
path: str = Query("/", description="目录路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_viewer_filesystem_tree(
|
||||
thread_id=thread_id,
|
||||
path=path,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@filesystem_router.get("/file")
|
||||
async def get_viewer_file(
|
||||
thread_id: str = Query(..., description="线程 ID"),
|
||||
path: str = Query(..., description="文件路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await read_viewer_file_content(
|
||||
thread_id=thread_id,
|
||||
path=path,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@filesystem_router.delete("/file", response_model=dict)
|
||||
async def delete_viewer_file_route(
|
||||
thread_id: str = Query(..., description="线程 ID"),
|
||||
path: str = Query(..., description="文件路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await delete_viewer_file(
|
||||
thread_id=thread_id,
|
||||
path=path,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@filesystem_router.post("/directory", response_model=dict)
|
||||
async def create_viewer_directory_route(
|
||||
payload: CreateViewerDirectoryRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await create_viewer_directory(
|
||||
thread_id=payload.thread_id,
|
||||
parent_path=payload.parent_path,
|
||||
name=payload.name,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@filesystem_router.post("/upload", response_model=dict)
|
||||
async def upload_viewer_files_route(
|
||||
thread_id: str = Form(..., description="线程 ID"),
|
||||
parent_path: str = Form(..., description="父目录路径"),
|
||||
files: list[UploadFile] = File(..., description="上传文件列表"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await upload_viewer_files(
|
||||
thread_id=thread_id,
|
||||
parent_path=parent_path,
|
||||
files=files,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@filesystem_router.get("/download")
|
||||
async def download_viewer(
|
||||
thread_id: str = Query(..., description="线程 ID"),
|
||||
path: str = Query(..., description="文件路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await download_viewer_file(
|
||||
thread_id=thread_id,
|
||||
path=path,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
from yuxi import knowledge_base
|
||||
from yuxi.knowledge.graphs.milvus_graph_service import MilvusGraphService
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
graph = APIRouter(prefix="/graph", tags=["graph"])
|
||||
|
||||
|
||||
async def _get_graph_service(kb_id: str) -> MilvusGraphService:
|
||||
db_info = await knowledge_base.get_database_info(kb_id)
|
||||
if not db_info:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
|
||||
kb_type = (db_info.get("kb_type") or "").lower()
|
||||
if kb_type != "milvus":
|
||||
raise HTTPException(status_code=404, detail="Graph API only supports Milvus knowledge bases")
|
||||
|
||||
return MilvusGraphService(kb_id=kb_id)
|
||||
|
||||
|
||||
@graph.get("/list")
|
||||
async def get_graphs(current_user: User = Depends(get_admin_user)):
|
||||
"""获取支持图谱能力的 Milvus 知识库列表"""
|
||||
try:
|
||||
databases = (await knowledge_base.get_databases_by_uid(current_user.uid)).get("databases", [])
|
||||
graphs = []
|
||||
for db in databases:
|
||||
if (db.get("kb_type") or "").lower() != "milvus":
|
||||
continue
|
||||
graphs.append(
|
||||
{
|
||||
"id": db.get("kb_id"),
|
||||
"name": db.get("name"),
|
||||
"type": "milvus",
|
||||
"description": db.get("description"),
|
||||
"status": db.get("status", "active"),
|
||||
"created_at": db.get("created_at"),
|
||||
"metadata": db,
|
||||
}
|
||||
)
|
||||
return {"success": True, "data": graphs}
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to list graphs: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list graphs: {str(e)}")
|
||||
|
||||
|
||||
@graph.get("/subgraph")
|
||||
async def get_subgraph(
|
||||
kb_id: str = Query(..., description="Milvus 知识库ID"),
|
||||
node_label: str = Query("*", description="节点标签或查询关键词"),
|
||||
max_depth: int = Query(2, description="最大深度", ge=1, le=5),
|
||||
max_nodes: int = Query(100, description="最大节点数", ge=1, le=1000),
|
||||
exclude_chunk: bool = Query(False, description="是否排除 Chunk 节点"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""查询 Milvus 知识库图谱子图"""
|
||||
try:
|
||||
logger.info(f"Querying subgraph - kb_id: {kb_id}, label: {node_label}")
|
||||
service = await _get_graph_service(kb_id)
|
||||
result_data = await service.query_nodes(
|
||||
keyword=node_label,
|
||||
max_depth=max_depth,
|
||||
max_nodes=max_nodes,
|
||||
exclude_chunk=exclude_chunk,
|
||||
)
|
||||
return {"success": True, "data": result_data}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get subgraph: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get subgraph: {str(e)}")
|
||||
|
||||
|
||||
@graph.get("/labels")
|
||||
async def get_graph_labels(
|
||||
kb_id: str = Query(..., description="Milvus 知识库ID"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""获取 Milvus 知识库图谱的所有标签"""
|
||||
try:
|
||||
service = await _get_graph_service(kb_id)
|
||||
labels = await service.get_labels()
|
||||
return {"success": True, "data": {"labels": labels}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get labels: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get labels: {str(e)}")
|
||||
|
||||
|
||||
@graph.get("/stats")
|
||||
async def get_graph_stats(
|
||||
kb_id: str = Query(..., description="Milvus 知识库ID"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""获取 Milvus 知识库图谱统计信息"""
|
||||
try:
|
||||
service = await _get_graph_service(kb_id)
|
||||
stats_data = await service.get_stats()
|
||||
return {"success": True, "data": stats_data}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get stats: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
|
||||
@@ -0,0 +1,249 @@
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
from yuxi.knowledge.eval.benchmark_generation import (
|
||||
DEFAULT_BENCHMARK_GENERATION_CONCURRENCY,
|
||||
MAX_BENCHMARK_GENERATION_CONCURRENCY,
|
||||
)
|
||||
from yuxi.knowledge.eval.service import EvaluationService
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils import logger
|
||||
|
||||
|
||||
evaluation = APIRouter(prefix="/evaluation", tags=["evaluation"])
|
||||
|
||||
|
||||
class GenerateDatasetRequest(BaseModel):
|
||||
name: str = Field(default="自动生成评估数据集", min_length=1, max_length=100)
|
||||
description: str = ""
|
||||
count: int = Field(default=10, ge=1, le=100)
|
||||
neighbors_count: int = Field(default=1, ge=0, le=10)
|
||||
concurrency_count: int = Field(
|
||||
default=DEFAULT_BENCHMARK_GENERATION_CONCURRENCY,
|
||||
ge=1,
|
||||
le=MAX_BENCHMARK_GENERATION_CONCURRENCY,
|
||||
)
|
||||
llm_model_spec: str = Field(..., min_length=1)
|
||||
generation_mode: Literal["vector", "graph_enhanced"] = "vector"
|
||||
graph_expand_top_k: int = Field(default=1, ge=1, le=3)
|
||||
|
||||
|
||||
class RunEvaluationRequest(BaseModel):
|
||||
dataset_id: str = Field(..., min_length=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
retrieval_config: dict[str, Any] = Field(default_factory=dict, alias="model_config")
|
||||
|
||||
|
||||
@evaluation.post("/databases/{kb_id}/datasets/upload")
|
||||
async def upload_evaluation_dataset(
|
||||
kb_id: str,
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""上传评估数据集"""
|
||||
try:
|
||||
if not file.filename.endswith(".jsonl"):
|
||||
raise HTTPException(status_code=400, detail="仅支持JSONL格式文件")
|
||||
|
||||
service = EvaluationService()
|
||||
result = await service.upload_dataset(
|
||||
kb_id=kb_id,
|
||||
file_content=await file.read(),
|
||||
filename=file.filename,
|
||||
name=name,
|
||||
description=description,
|
||||
created_by=current_user.uid,
|
||||
)
|
||||
return {"message": "success", "data": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"上传评估数据集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"上传评估数据集失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.get("/databases/{kb_id}/datasets")
|
||||
async def list_evaluation_datasets(kb_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""获取知识库的评估数据集列表"""
|
||||
try:
|
||||
service = EvaluationService()
|
||||
datasets = await service.list_datasets(kb_id)
|
||||
return {"message": "success", "data": datasets}
|
||||
except Exception as e:
|
||||
logger.exception(f"获取评估数据集列表失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取评估数据集列表失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.get("/databases/{kb_id}/datasets/{dataset_id}")
|
||||
async def get_evaluation_dataset(
|
||||
kb_id: str, dataset_id: str, page: int = 1, page_size: int = 10, current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""获取评估数据集详情"""
|
||||
try:
|
||||
if page < 1:
|
||||
raise HTTPException(status_code=400, detail="页码必须大于0")
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(status_code=400, detail="每页大小必须在1-100之间")
|
||||
|
||||
service = EvaluationService()
|
||||
dataset = await service.get_dataset_detail(kb_id, dataset_id, page, page_size)
|
||||
return {"message": "success", "data": dataset}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"获取评估数据集详情失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取评估数据集详情失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.get("/datasets/{dataset_id}/download")
|
||||
async def download_evaluation_dataset(dataset_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""导出评估数据集 JSONL"""
|
||||
try:
|
||||
service = EvaluationService()
|
||||
export_info = await service.export_dataset_jsonl(dataset_id)
|
||||
filename = export_info["filename"]
|
||||
return Response(
|
||||
content=export_info["content"].encode("utf-8"),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"导出评估数据集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"导出评估数据集失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.delete("/datasets/{dataset_id}")
|
||||
async def delete_evaluation_dataset(dataset_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""删除评估数据集"""
|
||||
try:
|
||||
service = EvaluationService()
|
||||
await service.delete_dataset(dataset_id)
|
||||
return {"message": "success", "data": None}
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"删除评估数据集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"删除评估数据集失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.post("/databases/{kb_id}/datasets/generate")
|
||||
async def generate_evaluation_dataset(
|
||||
kb_id: str, request: GenerateDatasetRequest, current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""自动生成评估数据集"""
|
||||
try:
|
||||
service = EvaluationService()
|
||||
result = await service.generate_dataset(
|
||||
kb_id=kb_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
count=request.count,
|
||||
neighbors_count=request.neighbors_count,
|
||||
concurrency_count=request.concurrency_count,
|
||||
llm_model_spec=request.llm_model_spec,
|
||||
generation_mode=request.generation_mode,
|
||||
graph_expand_top_k=request.graph_expand_top_k,
|
||||
created_by=current_user.uid,
|
||||
)
|
||||
return {"message": "success", "data": result}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"生成评估数据集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"生成评估数据集失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.post("/databases/{kb_id}/runs")
|
||||
async def run_evaluation(kb_id: str, request: RunEvaluationRequest, current_user: User = Depends(get_admin_user)):
|
||||
"""运行RAG评估"""
|
||||
try:
|
||||
service = EvaluationService()
|
||||
run_id = await service.run_evaluation(
|
||||
kb_id=kb_id,
|
||||
dataset_id=request.dataset_id,
|
||||
name=request.name,
|
||||
model_config=request.retrieval_config,
|
||||
created_by=current_user.uid,
|
||||
)
|
||||
return {"message": "success", "data": {"run_id": run_id}}
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"启动评估失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"启动评估失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.get("/databases/{kb_id}/runs")
|
||||
async def list_evaluation_runs(kb_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""获取知识库评估运行历史"""
|
||||
try:
|
||||
service = EvaluationService()
|
||||
runs = await service.list_runs(kb_id)
|
||||
return {"message": "success", "data": runs}
|
||||
except Exception as e:
|
||||
logger.exception(f"获取评估运行历史失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取评估运行历史失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.get("/databases/{kb_id}/runs/{run_id}")
|
||||
async def get_evaluation_run_results(
|
||||
kb_id: str,
|
||||
run_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
error_only: bool = False,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""获取评估运行结果"""
|
||||
try:
|
||||
if page < 1:
|
||||
raise HTTPException(status_code=400, detail="页码必须大于0")
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(status_code=400, detail="每页大小必须在1-100之间")
|
||||
|
||||
service = EvaluationService()
|
||||
results = await service.get_run_results(kb_id, run_id, page=page, page_size=page_size, error_only=error_only)
|
||||
return {"message": "success", "data": results}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"获取评估运行结果失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取评估运行结果失败: {str(e)}")
|
||||
|
||||
|
||||
@evaluation.delete("/databases/{kb_id}/runs/{run_id}")
|
||||
async def delete_evaluation_run(kb_id: str, run_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""删除评估运行"""
|
||||
try:
|
||||
service = EvaluationService()
|
||||
await service.delete_run(kb_id, run_id)
|
||||
return {"message": "success", "data": None}
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception(f"删除评估运行失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"删除评估运行失败: {str(e)}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
"""MCP 服务器管理路由"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from yuxi.agents.mcp.service import (
|
||||
create_mcp_server,
|
||||
get_mcp_tools_stats,
|
||||
delete_mcp_server,
|
||||
get_all_mcp_servers,
|
||||
get_all_mcp_tools,
|
||||
get_mcp_server,
|
||||
set_server_enabled,
|
||||
toggle_tool_enabled,
|
||||
update_mcp_server,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils import logger
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
||||
|
||||
mcp = APIRouter(prefix="/system/mcp-servers", tags=["mcp"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === DTOs ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CreateMcpServerRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str = Field(..., description="稳定标识")
|
||||
name: str = Field(..., description="展示名称")
|
||||
transport: str = Field(..., description="传输类型:sse/streamable_http/stdio")
|
||||
url: str | None = Field(None, description="服务器 URL(sse/streamable_http)")
|
||||
command: str | None = Field(None, description="命令(stdio)")
|
||||
args: list | None = Field(None, description="命令参数数组(stdio)")
|
||||
env: dict | None = Field(None, description="环境变量(stdio)")
|
||||
description: str | None = Field(None, description="描述")
|
||||
headers: dict | None = Field(None, description="HTTP 请求头")
|
||||
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
|
||||
sse_read_timeout: int | None = Field(None, description="SSE 读取超时(秒)")
|
||||
tags: list | None = Field(None, description="标签数组")
|
||||
icon: str | None = Field(None, description="图标(emoji)")
|
||||
|
||||
|
||||
class UpdateMcpServerRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(None, description="展示名称")
|
||||
transport: str | None = Field(None, description="传输类型")
|
||||
url: str | None = Field(None, description="服务器 URL")
|
||||
command: str | None = Field(None, description="命令(stdio)")
|
||||
args: list | None = Field(None, description="命令参数数组(stdio)")
|
||||
env: dict | None = Field(None, description="环境变量(stdio)")
|
||||
description: str | None = Field(None, description="描述")
|
||||
headers: dict | None = Field(None, description="HTTP 请求头")
|
||||
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
|
||||
sse_read_timeout: int | None = Field(None, description="SSE 读取超时(秒)")
|
||||
tags: list | None = Field(None, description="标签数组")
|
||||
icon: str | None = Field(None, description="图标(emoji)")
|
||||
|
||||
|
||||
class UpdateMcpServerStatusRequest(BaseModel):
|
||||
enabled: bool = Field(..., description="是否启用")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === Helpers ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_server_or_404(db: AsyncSession, slug: str):
|
||||
"""Helper to get server or raise 404."""
|
||||
server = await get_mcp_server(db, slug)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{slug}' 不存在")
|
||||
return server
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === MCP 服务器 CRUD ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.get("")
|
||||
async def get_mcp_servers(
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取所有 MCP 服务器配置(普通用户仅获取脱敏的基础信息)"""
|
||||
try:
|
||||
servers = await get_all_mcp_servers(db)
|
||||
if current_user.role in ["admin", "superadmin"]:
|
||||
return {"success": True, "data": [s.to_dict() for s in servers]}
|
||||
|
||||
data = []
|
||||
for s in servers:
|
||||
data.append(
|
||||
{
|
||||
"name": getattr(s, "name", ""),
|
||||
"description": getattr(s, "description", None),
|
||||
"icon": getattr(s, "icon", None),
|
||||
"enabled": bool(getattr(s, "enabled", True)),
|
||||
"tags": getattr(s, "tags", None) or [],
|
||||
}
|
||||
)
|
||||
return {"success": True, "data": data}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP servers: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.post("")
|
||||
async def create_mcp_server_route(
|
||||
request: CreateMcpServerRequest,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建新的 MCP 服务器"""
|
||||
# 校验传输类型
|
||||
valid_transports = ("sse", "streamable_http", "stdio")
|
||||
if request.transport not in valid_transports:
|
||||
raise HTTPException(status_code=400, detail=f"传输类型必须是 {', '.join(valid_transports)} 之一")
|
||||
|
||||
# 根据传输类型校验必填字段
|
||||
if request.transport in ("sse", "streamable_http") and not request.url:
|
||||
raise HTTPException(status_code=400, detail=f"传输类型为 {request.transport} 时,url 必填")
|
||||
if request.transport == "stdio" and not request.command:
|
||||
raise HTTPException(status_code=400, detail="传输类型为 stdio 时,command 必填")
|
||||
|
||||
try:
|
||||
server = await create_mcp_server(
|
||||
db,
|
||||
slug=request.slug,
|
||||
name=request.name,
|
||||
transport=request.transport,
|
||||
url=request.url,
|
||||
command=request.command,
|
||||
args=request.args,
|
||||
env=request.env,
|
||||
description=request.description,
|
||||
headers=request.headers,
|
||||
timeout=request.timeout,
|
||||
sse_read_timeout=request.sse_read_timeout,
|
||||
tags=request.tags,
|
||||
icon=request.icon,
|
||||
created_by=current_user.username,
|
||||
)
|
||||
return {"success": True, "data": server.to_dict()}
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.get("/{slug}")
|
||||
async def get_mcp_server_route(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取单个 MCP 服务器配置"""
|
||||
try:
|
||||
server = await get_server_or_404(db, slug)
|
||||
return {"success": True, "data": server.to_dict()}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.put("/{slug}")
|
||||
async def update_mcp_server_route(
|
||||
slug: str,
|
||||
request: UpdateMcpServerRequest,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新 MCP 服务器配置"""
|
||||
# 校验传输类型
|
||||
valid_transports = ("sse", "streamable_http", "stdio")
|
||||
if request.transport is not None and request.transport not in valid_transports:
|
||||
raise HTTPException(status_code=400, detail=f"传输类型必须是 {', '.join(valid_transports)} 之一")
|
||||
|
||||
try:
|
||||
fields_set = request.model_fields_set
|
||||
update_kwargs = {}
|
||||
if "env" in fields_set:
|
||||
update_kwargs["env"] = request.env
|
||||
|
||||
server = await update_mcp_server(
|
||||
db,
|
||||
slug=slug,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
transport=request.transport,
|
||||
url=request.url,
|
||||
command=request.command,
|
||||
args=request.args,
|
||||
headers=request.headers,
|
||||
timeout=request.timeout,
|
||||
sse_read_timeout=request.sse_read_timeout,
|
||||
tags=request.tags,
|
||||
icon=request.icon,
|
||||
updated_by=current_user.username,
|
||||
**update_kwargs,
|
||||
)
|
||||
return {"success": True, "data": server.to_dict()}
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=404, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.delete("/{slug}")
|
||||
async def delete_mcp_server_route(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除 MCP 服务器"""
|
||||
try:
|
||||
# 检查是否为系统内置服务器
|
||||
server = await get_mcp_server(db, slug)
|
||||
if server and server.created_by == "system":
|
||||
raise HTTPException(status_code=403, detail="系统内置的 MCP 服务器无法删除")
|
||||
|
||||
deleted = await delete_mcp_server(db, slug)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{slug}' 不存在")
|
||||
return {"success": True, "message": f"服务器 '{slug}' 已删除"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === MCP 服务器操作 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.post("/{slug}/test")
|
||||
async def test_mcp_server(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""测试 MCP 服务器连接"""
|
||||
try:
|
||||
await get_server_or_404(db, slug)
|
||||
|
||||
try:
|
||||
tools = await get_all_mcp_tools(slug)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"连接成功,共发现 {len(tools)} 个工具",
|
||||
"tool_count": len(tools),
|
||||
}
|
||||
except Exception as test_error:
|
||||
raise HTTPException(status_code=500, detail=f"连接失败: {str(test_error)}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.put("/{slug}/status")
|
||||
async def update_mcp_server_status_route(
|
||||
slug: str,
|
||||
request: UpdateMcpServerStatusRequest,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新 MCP 服务器启用状态"""
|
||||
try:
|
||||
is_enabled, server = await set_server_enabled(db, slug, request.enabled, current_user.username)
|
||||
return {
|
||||
"success": True,
|
||||
"enabled": is_enabled,
|
||||
"data": server.to_dict(),
|
||||
"message": f"MCP '{slug}' 已{'添加' if is_enabled else '移除'}",
|
||||
}
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=404, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === MCP 工具管理 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.get("/{slug}/tools")
|
||||
async def get_mcp_server_tools(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 MCP 服务器的工具列表"""
|
||||
try:
|
||||
server = await get_server_or_404(db, slug)
|
||||
disabled_tools = server.disabled_tools or []
|
||||
|
||||
try:
|
||||
# 获取所有工具(不过滤 disabled_tools)
|
||||
tools = await get_all_mcp_tools(slug)
|
||||
tool_list = []
|
||||
|
||||
for tool in tools:
|
||||
original_name = tool.name
|
||||
unique_id = tool.metadata.get("id") if tool.metadata else original_name
|
||||
|
||||
tool_info = {
|
||||
"name": original_name,
|
||||
"id": unique_id,
|
||||
"description": getattr(tool, "description", ""),
|
||||
"enabled": original_name not in disabled_tools,
|
||||
}
|
||||
# 提取参数信息
|
||||
if hasattr(tool, "args_schema") and tool.args_schema:
|
||||
schema = tool.args_schema.schema() if hasattr(tool.args_schema, "schema") else {}
|
||||
tool_info["parameters"] = schema.get("properties", {})
|
||||
tool_info["required"] = schema.get("required", [])
|
||||
else:
|
||||
tool_info["parameters"] = {}
|
||||
tool_info["required"] = []
|
||||
tool_list.append(tool_info)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": tool_list,
|
||||
"total": len(tool_list),
|
||||
}
|
||||
except Exception as tool_error:
|
||||
logger.error(f"Failed to get tools from MCP server '{slug}': {tool_error}")
|
||||
raise HTTPException(status_code=500, detail=f"获取工具失败: {str(tool_error)}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP server tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.post("/{slug}/tools/refresh")
|
||||
async def refresh_mcp_server_tools(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""刷新 MCP 服务器的工具列表(清除缓存重新获取)"""
|
||||
try:
|
||||
await get_server_or_404(db, slug)
|
||||
|
||||
try:
|
||||
# 获取所有工具(不过滤 disabled_tools)
|
||||
tools = await get_all_mcp_tools(slug)
|
||||
|
||||
# 获取统计信息
|
||||
stats = get_mcp_tools_stats(slug)
|
||||
enabled_count = stats.get("enabled", len(tools)) if stats else len(tools)
|
||||
disabled_count = stats.get("disabled", 0) if stats else 0
|
||||
|
||||
message = "工具列表已刷新"
|
||||
if disabled_count > 0:
|
||||
message += f",{enabled_count} 个已启用,{disabled_count} 个已禁用"
|
||||
else:
|
||||
message += f",共发现 {enabled_count} 个工具"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"tool_count": enabled_count,
|
||||
"enabled_count": enabled_count,
|
||||
"disabled_count": disabled_count,
|
||||
}
|
||||
except Exception as tool_error:
|
||||
raise HTTPException(status_code=500, detail=f"刷新失败: {str(tool_error)}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh MCP server tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.put("/{slug}/tools/{tool_name}/toggle")
|
||||
async def toggle_mcp_server_tool_route(
|
||||
slug: str,
|
||||
tool_name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换单个工具的启用状态"""
|
||||
try:
|
||||
enabled, _ = await toggle_tool_enabled(db, slug, tool_name, current_user.username)
|
||||
return {
|
||||
"success": True,
|
||||
"tool_name": tool_name,
|
||||
"enabled": enabled,
|
||||
"message": f"工具 '{tool_name}' 已{'启用' if enabled else '禁用'}",
|
||||
}
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=404, detail=str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle MCP server tool: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from server.utils.auth_middleware import get_db, get_required_user
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from yuxi.repositories.conversation_repository import ConversationRepository
|
||||
from yuxi.services.mention_search_service import search_mention_files_in_index
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
mention_router = APIRouter(prefix="/mention", tags=["mention"])
|
||||
|
||||
|
||||
class MentionFileItem(BaseModel):
|
||||
"""提及文件搜索结果条目"""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
is_dir: bool
|
||||
source: str
|
||||
|
||||
|
||||
@mention_router.get("/search", response_model=list[MentionFileItem])
|
||||
async def search_mention_files(
|
||||
thread_id: str | None = Query(None, description="当前聊天会话 ID;为空时仅搜索用户工作区"),
|
||||
query: str = Query("", description="模糊搜索关键字"),
|
||||
sources: str | None = Query(None, description="搜索来源:workspace,thread;为空时自动选择"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
提及文件模糊搜索接口:未创建 thread 时只搜索用户 workspace;已有 thread 时可搜索当前对话文件。
|
||||
"""
|
||||
uid = str(current_user.uid)
|
||||
effective_thread_id: str | None = None
|
||||
|
||||
if thread_id:
|
||||
conv_repo = ConversationRepository(db)
|
||||
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
||||
if conversation:
|
||||
if conversation.uid != uid or conversation.status == "deleted":
|
||||
raise HTTPException(status_code=404, detail="对话线程不存在")
|
||||
effective_thread_id = thread_id
|
||||
else:
|
||||
try:
|
||||
from yuxi.agents.backends.sandbox.paths import validate_thread_id
|
||||
|
||||
validate_thread_id(thread_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="非法的 thread_id 格式")
|
||||
|
||||
source_list = [item.strip() for item in sources.split(",")] if sources else None
|
||||
return await search_mention_files_in_index(
|
||||
thread_id=effective_thread_id,
|
||||
uid=uid,
|
||||
query=query,
|
||||
sources=source_list,
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""独立模型供应商配置路由。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
||||
from yuxi.models.providers.service import (
|
||||
check_credential_status,
|
||||
create_provider_config,
|
||||
delete_provider_config,
|
||||
fetch_remote_models,
|
||||
get_all_model_providers,
|
||||
get_model_provider_by_id,
|
||||
test_model_status_by_spec,
|
||||
update_provider_config,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.utils import logger
|
||||
|
||||
model_providers = APIRouter(prefix="/system/model-providers", tags=["model-providers"])
|
||||
|
||||
|
||||
async def _refresh_model_cache() -> None:
|
||||
"""刷新模型缓存(CRUD 操作后调用)。"""
|
||||
from yuxi.models.providers.cache import model_cache
|
||||
|
||||
try:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
providers = await get_all_model_providers(session)
|
||||
model_cache.rebuild(providers)
|
||||
logger.info(f"Model cache refreshed: {len(model_cache.get_all_specs())} models loaded")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh model cache: {e}")
|
||||
|
||||
|
||||
class ModelProviderPayload(BaseModel):
|
||||
provider_id: str | None = Field(None, description="供应商稳定标识")
|
||||
display_name: str | None = Field(None, description="展示名称")
|
||||
provider_type: str | None = Field(None, description="供应商适配类型,默认 openai")
|
||||
default_protocol: str | None = Field(None, description="默认协议")
|
||||
base_url: str | None = Field(None, description="API 基础 URL")
|
||||
embedding_base_url: str | None = Field(None, description="Embedding 模型请求基础 URL")
|
||||
rerank_base_url: str | None = Field(None, description="Rerank 模型请求基础 URL")
|
||||
models_endpoint: str | None = Field(None, description="聊天/通用模型列表端点")
|
||||
embedding_models_endpoint: str | None = Field(None, description="Embedding 模型列表端点")
|
||||
rerank_models_endpoint: str | None = Field(None, description="Rerank 模型列表端点")
|
||||
api_key_env: str | None = Field(None, description="API Key 环境变量名")
|
||||
api_key: str | None = Field(None, description="直接配置的 API Key")
|
||||
capabilities: list[str] | None = Field(None, description="支持能力")
|
||||
enabled_models: list[dict[str, Any]] | None = Field(None, description="已启用模型配置")
|
||||
headers_json: dict[str, Any] | None = Field(None, description="额外请求头")
|
||||
extra_json: dict[str, Any] | None = Field(None, description="扩展配置")
|
||||
is_enabled: bool | None = Field(None, description="是否启用")
|
||||
is_builtin: bool | None = Field(None, description="是否内置")
|
||||
|
||||
|
||||
@model_providers.get("")
|
||||
async def list_providers(
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取独立模型供应商配置列表。"""
|
||||
providers = await get_all_model_providers(db)
|
||||
data = []
|
||||
for p in providers:
|
||||
d = p.to_dict()
|
||||
d["credential_status"] = check_credential_status(p)
|
||||
data.append(d)
|
||||
return {"success": True, "data": data}
|
||||
|
||||
|
||||
@model_providers.post("")
|
||||
async def create_provider(
|
||||
payload: ModelProviderPayload,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建独立模型供应商配置。"""
|
||||
try:
|
||||
provider = await create_provider_config(
|
||||
db,
|
||||
payload.model_dump(exclude_none=True),
|
||||
current_user.username,
|
||||
)
|
||||
await db.commit()
|
||||
await _refresh_model_cache()
|
||||
return {"success": True, "data": provider.to_dict()}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"创建模型供应商失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="创建模型供应商失败")
|
||||
|
||||
|
||||
@model_providers.get("/{provider_id}")
|
||||
async def get_provider(
|
||||
provider_id: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取单个独立模型供应商配置。"""
|
||||
provider = await get_model_provider_by_id(db, provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
|
||||
data = provider.to_dict()
|
||||
data["credential_status"] = check_credential_status(provider)
|
||||
return {"success": True, "data": data}
|
||||
|
||||
|
||||
@model_providers.put("/{provider_id}")
|
||||
async def update_provider(
|
||||
provider_id: str,
|
||||
payload: ModelProviderPayload,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新独立模型供应商配置。"""
|
||||
try:
|
||||
# 获取用户显式设置过的字段(即使值为 None),以便正确处理清空操作
|
||||
unset_fields = payload.model_fields_set
|
||||
data = payload.model_dump(exclude_none=True)
|
||||
for nullable_field in (
|
||||
"api_key_env",
|
||||
"api_key",
|
||||
"default_protocol",
|
||||
"embedding_base_url",
|
||||
"rerank_base_url",
|
||||
"models_endpoint",
|
||||
"embedding_models_endpoint",
|
||||
"rerank_models_endpoint",
|
||||
):
|
||||
if nullable_field in unset_fields and getattr(payload, nullable_field) is None:
|
||||
data[nullable_field] = None
|
||||
provider = await update_provider_config(db, provider_id, data, current_user.username)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
|
||||
await db.commit()
|
||||
await _refresh_model_cache()
|
||||
return {"success": True, "data": provider.to_dict()}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"更新模型供应商失败 {provider_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="更新模型供应商失败")
|
||||
|
||||
|
||||
@model_providers.delete("/{provider_id}")
|
||||
async def delete_provider(
|
||||
provider_id: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除独立模型供应商配置。"""
|
||||
deleted = await delete_provider_config(db, provider_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
|
||||
await db.commit()
|
||||
await _refresh_model_cache()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@model_providers.get("/{provider_id}/remote-models")
|
||||
async def get_remote_models(
|
||||
provider_id: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""实时拉取远端 /models,不落库。"""
|
||||
provider = await get_model_provider_by_id(db, provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
|
||||
try:
|
||||
models = await fetch_remote_models(provider)
|
||||
return {"success": True, "data": models}
|
||||
except httpx.HTTPStatusError as e:
|
||||
# 远程 API 返回的错误,不透传状态码避免前端误判为系统认证失败
|
||||
detail = e.response.text
|
||||
if e.response.status_code == 401:
|
||||
raise HTTPException(status_code=502, detail="远端 API 认证失败,请检查 API Key 配置")
|
||||
raise HTTPException(status_code=e.response.status_code, detail=f"Models 请求失败: {detail}")
|
||||
except Exception as e:
|
||||
logger.error(f"拉取远端模型失败 {provider_id}: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"拉取远端模型失败: {e}")
|
||||
|
||||
|
||||
@model_providers.post("/models/cache/refresh")
|
||||
async def refresh_model_cache(
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""强制刷新模型缓存,从数据库重新加载所有供应商配置到 Redis。"""
|
||||
await _refresh_model_cache()
|
||||
from yuxi.models.providers.cache import model_cache
|
||||
|
||||
return {"success": True, "message": "缓存已刷新", "model_count": len(model_cache.get_all_specs())}
|
||||
|
||||
|
||||
@model_providers.get("/models/v2")
|
||||
async def get_v2_models(
|
||||
model_type: str = "chat",
|
||||
_current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 v2 格式的模型列表,按 provider 分组。
|
||||
|
||||
v2 模型 spec 格式: provider_id:model_id(冒号分隔)
|
||||
返回数据供前端模型选择器使用。
|
||||
"""
|
||||
from yuxi.models.providers.cache import model_cache
|
||||
|
||||
grouped = model_cache.get_specs_grouped_by_provider(model_type)
|
||||
providers = await get_all_model_providers(db)
|
||||
provider_name_by_id = {
|
||||
provider.provider_id: provider.display_name or provider.provider_id for provider in providers
|
||||
}
|
||||
|
||||
result = {}
|
||||
for provider_id, models in grouped.items():
|
||||
result[provider_id] = {
|
||||
"provider_id": provider_id,
|
||||
"provider_display_name": provider_name_by_id.get(provider_id, provider_id),
|
||||
"models": [
|
||||
{
|
||||
"spec": m.spec,
|
||||
"model_id": m.model_id,
|
||||
"display_name": m.display_name,
|
||||
"dimension": m.dimension,
|
||||
"batch_size": m.batch_size,
|
||||
}
|
||||
for m in models
|
||||
]
|
||||
}
|
||||
|
||||
return {"success": True, "data": result}
|
||||
|
||||
|
||||
@model_providers.get("/models/status")
|
||||
async def get_model_status_by_spec(
|
||||
spec: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""根据 full spec 检查模型状态(自动识别 V1/V2、Chat/Embedding)。"""
|
||||
try:
|
||||
result = await test_model_status_by_spec(spec)
|
||||
return {"success": True, "data": result}
|
||||
except Exception as e:
|
||||
logger.error(f"测试模型状态失败 {spec}: {e}")
|
||||
return {"success": False, "data": {"spec": spec, "status": "error", "message": str(e)}}
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Skills 管理路由"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
||||
from yuxi.agents.skills.service import (
|
||||
confirm_skill_install_draft,
|
||||
create_skill_node,
|
||||
delete_skill,
|
||||
delete_skill_node,
|
||||
delete_skills_batch,
|
||||
discard_skill_install_draft,
|
||||
export_skill_zip,
|
||||
get_allowed_skill_access_levels,
|
||||
get_manageable_skill_or_raise,
|
||||
get_management_readable_skill_or_raise,
|
||||
get_skill_dependency_options,
|
||||
get_skill_tree,
|
||||
init_builtin_skills,
|
||||
is_builtin_skill,
|
||||
list_accessible_skills,
|
||||
list_skills,
|
||||
list_visible_skills_for_management,
|
||||
prepare_remote_skill_install,
|
||||
prepare_skill_upload,
|
||||
read_skill_file,
|
||||
update_skill_dependencies,
|
||||
update_skill_enabled,
|
||||
update_skill_file,
|
||||
update_skill_share_config,
|
||||
user_can_manage_skill,
|
||||
)
|
||||
from yuxi.agents.skills.remote_install import list_remote_skills, search_remote_skills
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
skills = APIRouter(prefix="/system/skills", tags=["skills"])
|
||||
user_skills = APIRouter(prefix="/skills", tags=["skills"])
|
||||
|
||||
|
||||
class ShareConfigPayload(BaseModel):
|
||||
share_config: dict | None = Field(None, description="共享权限配置")
|
||||
|
||||
|
||||
class SkillEnabledUpdateRequest(BaseModel):
|
||||
enabled: bool = Field(..., description="是否启用")
|
||||
|
||||
|
||||
class SkillNodeCreateRequest(BaseModel):
|
||||
path: str = Field(..., description="相对 skill 根目录的路径")
|
||||
is_dir: bool = Field(False, description="是否创建目录")
|
||||
content: str | None = Field("", description="文件内容(仅文件创建时生效)")
|
||||
|
||||
|
||||
class SkillFileUpdateRequest(BaseModel):
|
||||
path: str = Field(..., description="相对 skill 根目录的路径")
|
||||
content: str = Field(..., description="文件内容")
|
||||
|
||||
|
||||
class SkillDependenciesUpdateRequest(BaseModel):
|
||||
tool_dependencies: list[str] = Field(default_factory=list, description="依赖的内置工具列表")
|
||||
mcp_dependencies: list[str] = Field(default_factory=list, description="依赖的 MCP 服务列表")
|
||||
skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表")
|
||||
|
||||
|
||||
class RemoteSkillSourceRequest(BaseModel):
|
||||
source: str = Field(..., description="skills 仓库来源,如 owner/repo 或 GitHub URL")
|
||||
|
||||
|
||||
class RemoteSkillPrepareRequest(RemoteSkillSourceRequest):
|
||||
skills: list[str] = Field(..., description="需要安装的 skill 名称列表")
|
||||
|
||||
|
||||
class RemoteSkillSearchRequest(BaseModel):
|
||||
query: str = Field(..., description="搜索关键字")
|
||||
|
||||
|
||||
class SkillBatchDeleteRequest(BaseModel):
|
||||
slugs: list[str] = Field(..., max_length=50, description="需要批量删除的 skill slug 列表,最多支持 50 个")
|
||||
|
||||
|
||||
class SkillDraftConfirmRequest(BaseModel):
|
||||
share_config: dict | None = Field(None, description="共享权限配置")
|
||||
|
||||
|
||||
def _raise_from_value_error(e: ValueError) -> None:
|
||||
message = str(e)
|
||||
status_code = 404 if "不存在" in message or "无权" in message else 400
|
||||
raise HTTPException(status_code=status_code, detail=message)
|
||||
|
||||
|
||||
def _cleanup_export_file(path: str) -> None:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}")
|
||||
|
||||
|
||||
def _summarize_results(results: list[dict]) -> dict[str, int]:
|
||||
return {
|
||||
"total": len(results),
|
||||
"success": sum(1 for item in results if item.get("success")),
|
||||
"failed": sum(1 for item in results if not item.get("success")),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_skill_for_user(item, user: User) -> dict:
|
||||
data = item.to_dict()
|
||||
data["can_manage"] = user_can_manage_skill(user, item)
|
||||
data["is_builtin"] = is_builtin_skill(item)
|
||||
return data
|
||||
|
||||
|
||||
@user_skills.get("/accessible")
|
||||
async def list_accessible_skills_route(
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
items = await list_accessible_skills(db, current_user)
|
||||
return {"success": True, "data": [_serialize_skill_for_user(item, current_user) for item in items]}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list accessible skills: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取可访问 Skills 失败")
|
||||
|
||||
|
||||
@user_skills.post("/import/prepare")
|
||||
async def prepare_skill_upload_route(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
data = await prepare_skill_upload(
|
||||
db,
|
||||
filename=file.filename or "",
|
||||
file_bytes=await file.read(),
|
||||
operator=current_user,
|
||||
)
|
||||
return {"success": True, "data": data}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to prepare skill upload: {e}")
|
||||
raise HTTPException(status_code=500, detail="解析上传 Skill 失败")
|
||||
|
||||
|
||||
@user_skills.post("/remote/list")
|
||||
async def list_remote_skills_route(payload: RemoteSkillSourceRequest, _current_user: User = Depends(get_required_user)):
|
||||
try:
|
||||
return {"success": True, "data": await list_remote_skills(payload.source)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list remote skills from '{payload.source}': {e}")
|
||||
raise HTTPException(status_code=500, detail="获取远程 skills 列表失败")
|
||||
|
||||
|
||||
@user_skills.post("/remote/search")
|
||||
async def search_remote_skills_route(
|
||||
payload: RemoteSkillSearchRequest, _current_user: User = Depends(get_required_user)
|
||||
):
|
||||
try:
|
||||
return {"success": True, "data": await search_remote_skills(payload.query)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search remote skills with query '{payload.query}': {e}")
|
||||
raise HTTPException(status_code=500, detail="搜索远程 skills 失败")
|
||||
|
||||
|
||||
@user_skills.post("/remote/prepare")
|
||||
async def prepare_remote_skills_route(
|
||||
payload: RemoteSkillPrepareRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
data = await prepare_remote_skill_install(
|
||||
db,
|
||||
source=payload.source,
|
||||
skills=payload.skills,
|
||||
operator=current_user,
|
||||
)
|
||||
return {"success": True, "data": data}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to prepare remote skills from '{payload.source}': {e}")
|
||||
raise HTTPException(status_code=500, detail="解析远程 Skills 失败")
|
||||
|
||||
|
||||
@user_skills.post("/install-drafts/{draft_id}/confirm")
|
||||
async def confirm_skill_install_draft_route(
|
||||
draft_id: str,
|
||||
payload: SkillDraftConfirmRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
results = await confirm_skill_install_draft(
|
||||
db,
|
||||
draft_id=draft_id,
|
||||
share_config=payload.share_config,
|
||||
operator=current_user,
|
||||
)
|
||||
return {"success": True, "data": results, "summary": _summarize_results(results)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to confirm skill install draft '{draft_id}': {e}")
|
||||
raise HTTPException(status_code=500, detail="确认安装 Skill 失败")
|
||||
|
||||
|
||||
@user_skills.delete("/install-drafts/{draft_id}")
|
||||
async def discard_skill_install_draft_route(draft_id: str, current_user: User = Depends(get_required_user)):
|
||||
try:
|
||||
await discard_skill_install_draft(draft_id=draft_id, operator=current_user)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to discard skill install draft '{draft_id}': {e}")
|
||||
raise HTTPException(status_code=500, detail="取消安装 Skill 失败")
|
||||
|
||||
|
||||
@skills.get("")
|
||||
async def list_skills_route(
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
items = await list_visible_skills_for_management(db, current_user)
|
||||
return {
|
||||
"success": True,
|
||||
"data": [_serialize_skill_for_user(item, current_user) for item in items],
|
||||
"allowed_access_levels": get_allowed_skill_access_levels(current_user),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list manageable skills: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取技能列表失败")
|
||||
|
||||
|
||||
@skills.get("/dependency-options")
|
||||
async def get_skill_dependency_options_route(
|
||||
slug: str | None = Query(None, description="当前 Skill slug"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
if slug:
|
||||
await get_manageable_skill_or_raise(db, current_user, slug)
|
||||
return {"success": True, "data": await get_skill_dependency_options(db, current_user, slug)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get skill dependency options: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
|
||||
|
||||
|
||||
@skills.get("/builtin")
|
||||
async def list_builtin_skills_route(
|
||||
_current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
items = [item for item in await list_skills(db) if item.source_type == "builtin"]
|
||||
return {"success": True, "data": [item.to_dict() for item in items]}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list builtin skills: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取内置 skill 列表失败")
|
||||
|
||||
|
||||
@skills.post("/builtin/sync")
|
||||
async def sync_builtin_skills_route(
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
items = await init_builtin_skills(db, created_by=current_user.uid)
|
||||
return {"success": True, "data": [item.to_dict() for item in items]}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync builtin skills: {e}")
|
||||
raise HTTPException(status_code=500, detail="同步内置 skill 失败")
|
||||
|
||||
|
||||
@skills.put("/{slug}/share-config")
|
||||
async def update_skill_share_config_route(
|
||||
slug: str,
|
||||
payload: ShareConfigPayload,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
item = await update_skill_share_config(db, slug=slug, share_config=payload.share_config, operator=current_user)
|
||||
return {"success": True, "data": _serialize_skill_for_user(item, current_user)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill share config '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="更新 Skill 共享范围失败")
|
||||
|
||||
|
||||
@skills.put("/{slug}/enabled")
|
||||
async def update_skill_enabled_route(
|
||||
slug: str,
|
||||
payload: SkillEnabledUpdateRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
item = await update_skill_enabled(db, slug=slug, enabled=payload.enabled, operator=current_user)
|
||||
return {"success": True, "data": _serialize_skill_for_user(item, current_user)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill enabled '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="更新 Skill 启用状态失败")
|
||||
|
||||
|
||||
@skills.get("/{slug}/tree")
|
||||
async def get_skill_tree_route(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await get_management_readable_skill_or_raise(db, current_user, slug)
|
||||
return {"success": True, "data": await get_skill_tree(db, slug)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get skill tree '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="获取技能目录树失败")
|
||||
|
||||
|
||||
@skills.get("/{slug}/file")
|
||||
async def get_skill_file_route(
|
||||
slug: str,
|
||||
path: str = Query(..., description="相对 skill 根目录路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await get_management_readable_skill_or_raise(db, current_user, slug)
|
||||
return {"success": True, "data": await read_skill_file(db, slug, path)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read skill file '{slug}/{path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="读取技能文件失败")
|
||||
|
||||
|
||||
@skills.post("/{slug}/file")
|
||||
async def create_skill_file_route(
|
||||
slug: str,
|
||||
payload: SkillNodeCreateRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await get_manageable_skill_or_raise(db, current_user, slug)
|
||||
await create_skill_node(
|
||||
db,
|
||||
slug=slug,
|
||||
relative_path=payload.path,
|
||||
is_dir=payload.is_dir,
|
||||
content=payload.content,
|
||||
updated_by=current_user.uid,
|
||||
)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create skill node '{slug}/{payload.path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="创建技能文件失败")
|
||||
|
||||
|
||||
@skills.put("/{slug}/file")
|
||||
async def update_skill_file_route(
|
||||
slug: str,
|
||||
payload: SkillFileUpdateRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await get_manageable_skill_or_raise(db, current_user, slug)
|
||||
await update_skill_file(
|
||||
db,
|
||||
slug=slug,
|
||||
relative_path=payload.path,
|
||||
content=payload.content,
|
||||
updated_by=current_user.uid,
|
||||
)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill file '{slug}/{payload.path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="更新技能文件失败")
|
||||
|
||||
|
||||
@skills.put("/{slug}/dependencies")
|
||||
async def update_skill_dependencies_route(
|
||||
slug: str,
|
||||
payload: SkillDependenciesUpdateRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
item = await update_skill_dependencies(
|
||||
db,
|
||||
slug=slug,
|
||||
tool_dependencies=payload.tool_dependencies,
|
||||
mcp_dependencies=payload.mcp_dependencies,
|
||||
skill_dependencies=payload.skill_dependencies,
|
||||
operator=current_user,
|
||||
)
|
||||
return {"success": True, "data": _serialize_skill_for_user(item, current_user)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill dependencies '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="更新 skill 依赖失败")
|
||||
|
||||
|
||||
@skills.delete("/{slug}/file")
|
||||
async def delete_skill_file_route(
|
||||
slug: str,
|
||||
path: str = Query(..., description="相对 skill 根目录路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await get_manageable_skill_or_raise(db, current_user, slug)
|
||||
await delete_skill_node(db, slug=slug, relative_path=path)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete skill file '{slug}/{path}': {e}")
|
||||
raise HTTPException(status_code=500, detail="删除技能文件失败")
|
||||
|
||||
|
||||
@skills.get("/{slug}/export")
|
||||
async def export_skill_route(
|
||||
slug: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await get_manageable_skill_or_raise(db, current_user, slug)
|
||||
export_path, download_name = await export_skill_zip(db, slug)
|
||||
background_tasks.add_task(_cleanup_export_file, export_path)
|
||||
return FileResponse(path=export_path, media_type="application/zip", filename=download_name)
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export skill '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="导出技能失败")
|
||||
|
||||
|
||||
@skills.delete("/{slug}")
|
||||
async def delete_skill_route(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await get_manageable_skill_or_raise(db, current_user, slug)
|
||||
await delete_skill(db, slug=slug)
|
||||
return {"success": True}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete skill '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="删除技能失败")
|
||||
|
||||
|
||||
@skills.post("/delete-batch")
|
||||
async def delete_skills_batch_route(
|
||||
payload: SkillBatchDeleteRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
for slug in payload.slugs:
|
||||
await get_manageable_skill_or_raise(db, current_user, slug)
|
||||
results = await delete_skills_batch(db, slugs=payload.slugs)
|
||||
return {"success": True, "data": results, "summary": _summarize_results(results)}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete skills batch: {e}")
|
||||
raise HTTPException(status_code=500, detail="批量删除技能失败")
|
||||
@@ -0,0 +1,229 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import yaml
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from yuxi import config, get_version
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_required_user
|
||||
|
||||
system = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
# =============================================================================
|
||||
# === 健康检查分组 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@system.get("/health")
|
||||
async def health_check():
|
||||
"""系统健康检查接口(公开接口)"""
|
||||
return {"status": "ok", "message": "服务正常运行", "version": get_version()}
|
||||
|
||||
|
||||
@system.get("/discovery")
|
||||
async def discovery():
|
||||
"""系统能力发现接口(公开接口)"""
|
||||
return {
|
||||
"name": "Yuxi",
|
||||
"version": get_version(),
|
||||
"api_prefix": "/api",
|
||||
"capabilities": {
|
||||
"cli": {
|
||||
"min_cli_version": "0.1.0",
|
||||
"browser_login": True,
|
||||
"api_key_auth": True,
|
||||
"remote_config": True,
|
||||
"kb_upload": True,
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
"health": "/api/system/health",
|
||||
"auth_me": "/api/auth/me",
|
||||
"cli_auth_sessions": "/api/auth/cli/sessions",
|
||||
"cli_auth_authorize": "/auth/cli/authorize",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 配置管理分组 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@system.get("/config")
|
||||
async def get_config(current_user: User = Depends(get_required_user)):
|
||||
"""获取系统配置"""
|
||||
return config.dump_config()
|
||||
|
||||
|
||||
@system.post("/config")
|
||||
async def update_config_single(key=Body(...), value=Body(...), current_user: User = Depends(get_admin_user)) -> dict:
|
||||
"""更新单个配置项"""
|
||||
if not isinstance(key, str) or key not in type(config).model_fields:
|
||||
raise HTTPException(status_code=400, detail=f"未知配置项: {key}")
|
||||
if not config.can_update(key):
|
||||
raise HTTPException(status_code=400, detail=f"配置项不可修改: {key}")
|
||||
try:
|
||||
config.set_value(key, value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
config.save()
|
||||
return config.dump_config()
|
||||
|
||||
|
||||
@system.post("/config/update")
|
||||
async def update_config_batch(items: dict = Body(...), current_user: User = Depends(get_admin_user)) -> dict:
|
||||
"""批量更新配置项"""
|
||||
try:
|
||||
config.update(items)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
config.save()
|
||||
return config.dump_config()
|
||||
|
||||
|
||||
@system.get("/logs")
|
||||
async def get_system_logs(levels: str | None = None, current_user: User = Depends(get_admin_user)):
|
||||
"""获取系统日志
|
||||
|
||||
Args:
|
||||
levels: 可选的日志级别过滤,多个级别用逗号分隔,如 "INFO,ERROR,DEBUG,WARNING"
|
||||
"""
|
||||
try:
|
||||
from yuxi.utils.logging_config import LOG_FILE
|
||||
|
||||
# 解析日志级别过滤条件
|
||||
level_filter = None
|
||||
if levels:
|
||||
level_filter = set(level.strip().upper() for level in levels.split(",") if level.strip())
|
||||
|
||||
# 修复 GBK 编码报错:强制 utf-8 读取,忽略错误
|
||||
async with aiofiles.open(LOG_FILE, encoding="utf-8", errors="ignore") as f:
|
||||
# 读取最后1000行
|
||||
lines = []
|
||||
async for line in f:
|
||||
filtered_line = line.rstrip("\n\r")
|
||||
# 如果指定了日志级别过滤,则按级别过滤
|
||||
if level_filter:
|
||||
# 日志格式: 2025-03-10 08:26:37,269 - INFO - module - message
|
||||
# 提取日志级别
|
||||
parts = filtered_line.split(" - ")
|
||||
if len(parts) >= 2 and parts[1].strip() in level_filter:
|
||||
lines.append(filtered_line + "\n")
|
||||
# 继续读取以保持行数统计准确
|
||||
if len(lines) > 1000:
|
||||
lines.pop(0)
|
||||
else:
|
||||
lines.append(filtered_line + "\n")
|
||||
if len(lines) > 1000:
|
||||
lines.pop(0)
|
||||
|
||||
log = "".join(lines)
|
||||
return {"log": log, "message": "success", "log_file": LOG_FILE}
|
||||
except Exception as e:
|
||||
logger.error(f"获取系统日志失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取系统日志失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 信息管理分组 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def load_info_config():
|
||||
"""加载信息配置文件"""
|
||||
try:
|
||||
# 配置文件路径
|
||||
brand_file_path = os.environ.get("YUXI_BRAND_FILE_PATH", "package/yuxi/config/static/info.local.yaml")
|
||||
config_path = Path(brand_file_path)
|
||||
|
||||
# 检查文件是否存在
|
||||
if not config_path.exists():
|
||||
logger.debug(f"The config file {config_path} does not exist, using default config")
|
||||
config_path = Path("package/yuxi/config/static/info.template.yaml")
|
||||
|
||||
# 异步读取配置文件
|
||||
async with aiofiles.open(config_path, encoding="utf-8") as file:
|
||||
content = await file.read()
|
||||
|
||||
# 注入版本号占位符
|
||||
content = content.replace("{{YUXI_VERSION}}", get_version())
|
||||
|
||||
config = yaml.safe_load(content)
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load info config: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
@system.get("/info")
|
||||
async def get_info_config():
|
||||
"""获取系统信息配置(公开接口,无需认证)"""
|
||||
try:
|
||||
config = await load_info_config()
|
||||
return {"success": True, "data": config}
|
||||
except Exception as e:
|
||||
logger.error(f"获取信息配置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取信息配置失败")
|
||||
|
||||
|
||||
@system.post("/info/reload")
|
||||
async def reload_info_config(current_user: User = Depends(get_admin_user)):
|
||||
"""重新加载信息配置"""
|
||||
try:
|
||||
config = await load_info_config()
|
||||
return {"success": True, "message": "配置重新加载成功", "data": config}
|
||||
except Exception as e:
|
||||
logger.error(f"重新加载信息配置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="重新加载信息配置失败")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === OCR服务分组 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@system.get("/ocr/health")
|
||||
async def check_ocr_services_health(current_user: User = Depends(get_admin_user)):
|
||||
"""
|
||||
检查所有OCR服务的健康状态
|
||||
返回各个OCR服务的可用性信息
|
||||
"""
|
||||
from yuxi.knowledge.parser.factory import DocumentProcessorFactory
|
||||
|
||||
try:
|
||||
# 使用统一的健康检查接口
|
||||
health_status = await DocumentProcessorFactory.check_all_health_async()
|
||||
|
||||
# 格式化健康检查响应
|
||||
formatted_status = {}
|
||||
for service_name, health_info in health_status.items():
|
||||
formatted_status[service_name] = {
|
||||
"status": health_info.get("status", "unknown"),
|
||||
"message": health_info.get("message", ""),
|
||||
"details": health_info.get("details", {}),
|
||||
}
|
||||
|
||||
# 计算整体健康状态
|
||||
overall_status = (
|
||||
"healthy" if any(svc["status"] == "healthy" for svc in formatted_status.values()) else "unhealthy"
|
||||
)
|
||||
|
||||
return {
|
||||
"overall_status": overall_status,
|
||||
"services": formatted_status,
|
||||
"message": "OCR服务健康检查完成",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OCR健康检查失败: {str(e)}")
|
||||
return {
|
||||
"overall_status": "error",
|
||||
"services": {},
|
||||
"message": f"OCR健康检查失败: {str(e)}",
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.services.task_service import tasker
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
|
||||
tasks = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@tasks.get("")
|
||||
async def list_tasks(
|
||||
status: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""List tasks, optionally filtered by status."""
|
||||
return await tasker.list_tasks(status=status, limit=limit)
|
||||
|
||||
|
||||
@tasks.get("/{task_id}")
|
||||
async def get_task(task_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""Retrieve a single task by id."""
|
||||
task = await tasker.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return {"task": task}
|
||||
|
||||
|
||||
@tasks.post("/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""Request cancellation of a task."""
|
||||
success = await tasker.cancel_task(task_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Task cannot be cancelled")
|
||||
return {"task_id": task_id, "status": "cancelled"}
|
||||
|
||||
|
||||
@tasks.delete("/{task_id}")
|
||||
async def delete_task(task_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""Delete a task by id."""
|
||||
success = await tasker.delete_task(task_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return {"task_id": task_id, "status": "deleted"}
|
||||
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from yuxi.agents.toolkits.service import get_tool_metadata
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
tools = APIRouter(prefix="/system/tools", tags=["tools"])
|
||||
|
||||
|
||||
@tools.get("")
|
||||
async def list_tools(
|
||||
category: str = None,
|
||||
user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""获取工具列表"""
|
||||
return {"success": True, "data": get_tool_metadata(category)}
|
||||
|
||||
|
||||
@tools.get("/options")
|
||||
async def get_tool_options(
|
||||
user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""获取工具选项(前端下拉框用)"""
|
||||
all_tools = get_tool_metadata()
|
||||
return {"success": True, "data": [{"label": t["name"], "value": t["slug"]} for t in all_tools]}
|
||||
@@ -0,0 +1,327 @@
|
||||
"""用户级配置与凭据路由"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_current_user, get_db, get_required_user
|
||||
from yuxi.config import UserConfig, UserConfigSchema
|
||||
from yuxi.storage.minio import upload_image_to_minio
|
||||
from yuxi.storage.postgres.models_business import APIKey, AgentEnv, User
|
||||
from yuxi.utils.auth_utils import AuthUtils
|
||||
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, format_utc_datetime, utc_now_naive
|
||||
|
||||
user_router = APIRouter(prefix="/user", tags=["user"])
|
||||
|
||||
ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
MAX_ENV_COUNT = 200
|
||||
MAX_ENV_KEY_LENGTH = 128
|
||||
MAX_ENV_VALUE_LENGTH = 32768
|
||||
MAX_USER_IMAGE_SIZE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
class APIKeyCreate(BaseModel):
|
||||
name: str
|
||||
user_id: int | None = None
|
||||
department_id: int | None = None
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
class APIKeyUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
expires_at: str | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class APIKeyResponse(BaseModel):
|
||||
id: int
|
||||
key_prefix: str
|
||||
name: str
|
||||
user_id: int
|
||||
department_id: int | None
|
||||
expires_at: str | None
|
||||
is_enabled: bool
|
||||
last_used_at: str | None
|
||||
created_by: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class APIKeyCreateResponse(BaseModel):
|
||||
api_key: APIKeyResponse
|
||||
secret: str
|
||||
|
||||
|
||||
class AgentEnvUpdate(BaseModel):
|
||||
env: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentEnvResponse(BaseModel):
|
||||
env: dict[str, str]
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
async def get_logged_in_user(user: User | None = Depends(get_current_user)) -> User:
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请登录后再访问",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@user_router.get("/config", response_model=dict)
|
||||
async def get_user_config(
|
||||
current_user: User = Depends(get_logged_in_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_config = await UserConfig.load(db, current_user.uid)
|
||||
return user_config.dump_config()
|
||||
|
||||
|
||||
@user_router.put("/config", response_model=dict)
|
||||
async def update_user_config(
|
||||
data: UserConfigSchema,
|
||||
current_user: User = Depends(get_logged_in_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_config = await UserConfig(uid=current_user.uid, schema=data).save(db)
|
||||
return user_config.dump_config()
|
||||
|
||||
|
||||
@user_router.post("/upload-image", response_model=dict)
|
||||
async def upload_user_image(file: UploadFile = File(...), current_user: User = Depends(get_required_user)):
|
||||
try:
|
||||
image_url = await upload_image_to_minio(
|
||||
file,
|
||||
object_prefix=f"images/{current_user.uid}",
|
||||
max_size_bytes=MAX_USER_IMAGE_SIZE_BYTES,
|
||||
too_large_message="图片大小不能超过 5MB",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
return {"success": True, "image_url": image_url, "url": image_url}
|
||||
|
||||
|
||||
def validate_agent_env(env: dict[str, Any]) -> dict[str, str]:
|
||||
if len(env) > MAX_ENV_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"环境变量数量不能超过 {MAX_ENV_COUNT} 个")
|
||||
|
||||
normalized: dict[str, str] = {}
|
||||
for key, value in env.items():
|
||||
if not isinstance(key, str):
|
||||
raise HTTPException(status_code=400, detail="环境变量名必须是字符串")
|
||||
name = key.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="环境变量名不能为空")
|
||||
if len(name) > MAX_ENV_KEY_LENGTH:
|
||||
raise HTTPException(status_code=400, detail=f"环境变量名长度不能超过 {MAX_ENV_KEY_LENGTH}")
|
||||
if not ENV_KEY_PATTERN.match(name):
|
||||
raise HTTPException(status_code=400, detail=f"环境变量名 {name} 格式不正确")
|
||||
if name in normalized:
|
||||
raise HTTPException(status_code=400, detail=f"环境变量名 {name} 重复")
|
||||
if not isinstance(value, str):
|
||||
raise HTTPException(status_code=400, detail=f"环境变量 {name} 的值必须是字符串")
|
||||
if len(value) > MAX_ENV_VALUE_LENGTH:
|
||||
raise HTTPException(status_code=400, detail=f"环境变量 {name} 的值过长")
|
||||
normalized[name] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_api_key_owner(api_key: APIKey, current_user: User) -> None:
|
||||
if api_key.user_id != current_user.id and current_user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="无权操作此 API Key")
|
||||
|
||||
|
||||
async def get_accessible_api_key(db: AsyncSession, api_key_id: int, current_user: User) -> APIKey:
|
||||
result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id))
|
||||
api_key = result.scalar_one_or_none()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
||||
ensure_api_key_owner(api_key, current_user)
|
||||
return api_key
|
||||
|
||||
|
||||
@user_router.get("/apikey/", response_model=dict)
|
||||
async def list_api_keys(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(APIKey).order_by(APIKey.created_at.desc()).offset(skip).limit(limit)
|
||||
count_query = select(func.count(APIKey.id))
|
||||
if current_user.role != "superadmin":
|
||||
query = query.filter(APIKey.user_id == current_user.id)
|
||||
count_query = count_query.filter(APIKey.user_id == current_user.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
api_keys = result.scalars().all()
|
||||
total_result = await db.execute(count_query)
|
||||
|
||||
return {
|
||||
"api_keys": [key.to_dict() for key in api_keys],
|
||||
"total": total_result.scalar(),
|
||||
}
|
||||
|
||||
|
||||
@user_router.post("/apikey/", response_model=APIKeyCreateResponse)
|
||||
async def create_api_key(
|
||||
data: APIKeyCreate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if data.user_id and data.user_id != current_user.id and current_user.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="无权为其他用户创建 API Key")
|
||||
|
||||
target_user = current_user
|
||||
if data.user_id:
|
||||
result = await db.execute(select(User).filter(User.id == data.user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or user.is_deleted:
|
||||
raise HTTPException(status_code=404, detail="关联的用户不存在")
|
||||
target_user = user
|
||||
|
||||
if data.department_id is not None and data.department_id != target_user.department_id:
|
||||
raise HTTPException(status_code=403, detail="API Key 部门必须与关联用户部门一致")
|
||||
|
||||
full_key, key_hash, key_prefix = AuthUtils.generate_api_key()
|
||||
expires_at = None
|
||||
if data.expires_at:
|
||||
aware_dt = coerce_any_to_utc_datetime(data.expires_at)
|
||||
if aware_dt:
|
||||
expires_at = aware_dt.replace(tzinfo=None)
|
||||
|
||||
api_key = APIKey(
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
name=data.name,
|
||||
user_id=target_user.id,
|
||||
department_id=data.department_id,
|
||||
expires_at=expires_at,
|
||||
created_by=str(current_user.id),
|
||||
)
|
||||
|
||||
db.add(api_key)
|
||||
await db.commit()
|
||||
await db.refresh(api_key)
|
||||
|
||||
return APIKeyCreateResponse(
|
||||
api_key=APIKeyResponse(**api_key.to_dict()),
|
||||
secret=full_key,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/apikey/{api_key_id}", response_model=dict)
|
||||
async def get_api_key(
|
||||
api_key_id: int,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
api_key = await get_accessible_api_key(db, api_key_id, current_user)
|
||||
return {"api_key": api_key.to_dict()}
|
||||
|
||||
|
||||
@user_router.put("/apikey/{api_key_id}", response_model=dict)
|
||||
async def update_api_key(
|
||||
api_key_id: int,
|
||||
data: APIKeyUpdate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
api_key = await get_accessible_api_key(db, api_key_id, current_user)
|
||||
|
||||
if data.name is not None:
|
||||
api_key.name = data.name
|
||||
if data.expires_at is not None:
|
||||
aware_dt = coerce_any_to_utc_datetime(data.expires_at)
|
||||
api_key.expires_at = aware_dt.replace(tzinfo=None) if aware_dt else None
|
||||
if data.is_enabled is not None:
|
||||
api_key.is_enabled = data.is_enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(api_key)
|
||||
return {"api_key": api_key.to_dict()}
|
||||
|
||||
|
||||
@user_router.delete("/apikey/{api_key_id}", response_model=dict)
|
||||
async def delete_api_key(
|
||||
api_key_id: int,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
api_key = await get_accessible_api_key(db, api_key_id, current_user)
|
||||
|
||||
await db.delete(api_key)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@user_router.post("/apikey/{api_key_id}/regenerate", response_model=APIKeyCreateResponse)
|
||||
async def regenerate_api_key(
|
||||
api_key_id: int,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
api_key = await get_accessible_api_key(db, api_key_id, current_user)
|
||||
|
||||
full_key, key_hash, key_prefix = AuthUtils.generate_api_key()
|
||||
api_key.key_hash = key_hash
|
||||
api_key.key_prefix = key_prefix
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(api_key)
|
||||
|
||||
return APIKeyCreateResponse(
|
||||
api_key=APIKeyResponse(**api_key.to_dict()),
|
||||
secret=full_key,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/agent-env", response_model=AgentEnvResponse)
|
||||
async def get_agent_env(
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(AgentEnv).filter(AgentEnv.uid == current_user.uid))
|
||||
agent_env = result.scalar_one_or_none()
|
||||
if agent_env is None:
|
||||
return AgentEnvResponse(env={})
|
||||
return AgentEnvResponse(env=agent_env.env or {}, updated_at=format_utc_datetime(agent_env.updated_at))
|
||||
|
||||
|
||||
@user_router.put("/agent-env", response_model=AgentEnvResponse)
|
||||
async def update_agent_env(
|
||||
data: AgentEnvUpdate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
env = validate_agent_env(data.env)
|
||||
result = await db.execute(select(AgentEnv).filter(AgentEnv.uid == current_user.uid))
|
||||
current_agent_env = result.scalar_one_or_none()
|
||||
if current_agent_env is not None and (current_agent_env.env or {}) == env:
|
||||
return AgentEnvResponse(
|
||||
env=current_agent_env.env or {},
|
||||
updated_at=format_utc_datetime(current_agent_env.updated_at),
|
||||
)
|
||||
|
||||
now = utc_now_naive()
|
||||
stmt = (
|
||||
pg_insert(AgentEnv)
|
||||
.values(uid=current_user.uid, env=env, updated_at=now)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[AgentEnv.uid],
|
||||
set_={"env": env, "updated_at": now},
|
||||
)
|
||||
.returning(AgentEnv)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
# 直接返回刚写入的 env/now,避免身份映射中的旧实例属性导致返回陈旧值
|
||||
return AgentEnvResponse(env=env, updated_at=format_utc_datetime(now))
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.utils.auth_middleware import get_required_user
|
||||
from yuxi import knowledge_base
|
||||
from yuxi.knowledge.factory import KnowledgeBaseFactory
|
||||
from yuxi.services.workspace_service import (
|
||||
create_workspace_directory,
|
||||
delete_workspace_path,
|
||||
download_workspace_file,
|
||||
list_workspace_tree,
|
||||
read_workspace_file_content,
|
||||
upload_workspace_files,
|
||||
write_workspace_file_content,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
workspace = APIRouter(prefix="/workspace", tags=["workspace"])
|
||||
|
||||
|
||||
class CreateWorkspaceDirectoryRequest(BaseModel):
|
||||
parent_path: str
|
||||
name: str
|
||||
|
||||
|
||||
class UpdateWorkspaceFileContentRequest(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
async def _ensure_knowledge_read_access(current_user: User, kb_id: str) -> None:
|
||||
allowed = await knowledge_base.check_accessible(
|
||||
{
|
||||
"uid": current_user.uid,
|
||||
"role": current_user.role,
|
||||
"department_id": current_user.department_id,
|
||||
},
|
||||
kb_id,
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
async def _ensure_knowledge_supports_documents(kb_id: str) -> None:
|
||||
db_info = await knowledge_base.get_database_info(kb_id)
|
||||
if not db_info:
|
||||
raise HTTPException(status_code=404, detail=f"知识库 {kb_id} 不存在")
|
||||
kb_type = (db_info.get("kb_type") or "").lower()
|
||||
kb_class = KnowledgeBaseFactory.get_kb_class(kb_type)
|
||||
if not kb_class.supports_documents:
|
||||
raise HTTPException(status_code=501, detail=f"{db_info.get('name') or kb_type} 不支持文件浏览")
|
||||
|
||||
|
||||
def _raise_knowledge_read_error(error: ValueError) -> None:
|
||||
message = str(error) or "知识库文件读取失败"
|
||||
if message.startswith("Dify 知识库不支持"):
|
||||
raise HTTPException(status_code=501, detail=message) from error
|
||||
raise HTTPException(status_code=400, detail=message) from error
|
||||
|
||||
|
||||
def _workspace_knowledge_entry(kb_id: str, item: dict) -> dict:
|
||||
is_dir = bool(item.get("is_folder"))
|
||||
is_virtual_folder = bool(item.get("is_virtual_folder"))
|
||||
file_id = item.get("file_id")
|
||||
path_prefix = item.get("path_prefix") or ""
|
||||
if is_virtual_folder:
|
||||
path = f"/knowledge/{kb_id}/virtual/{quote(path_prefix, safe='')}"
|
||||
elif is_dir:
|
||||
path = f"/knowledge/{kb_id}/folder/{file_id}/"
|
||||
else:
|
||||
path = f"/knowledge/{kb_id}/file/{file_id}"
|
||||
|
||||
return {
|
||||
"source": "knowledge",
|
||||
"kb_id": kb_id,
|
||||
"file_id": file_id,
|
||||
"parent_id": item.get("parent_id"),
|
||||
"path": path,
|
||||
"virtual_path": path,
|
||||
"name": item.get("filename") or file_id,
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else int(item.get("file_size") or 0),
|
||||
"modified_at": item.get("updated_at") or item.get("created_at") or "",
|
||||
"readonly": True,
|
||||
"status": item.get("status") or "done",
|
||||
"has_original_file": bool(item.get("has_original_file")),
|
||||
"has_parsed_markdown": bool(item.get("has_parsed_markdown")),
|
||||
"is_virtual_folder": is_virtual_folder,
|
||||
"path_prefix": path_prefix,
|
||||
}
|
||||
|
||||
|
||||
@workspace.get("/tree", response_model=dict)
|
||||
async def get_workspace_tree(
|
||||
path: str = Query("/", description="工作区目录路径"),
|
||||
recursive: bool = Query(False, description="是否递归返回子目录文件"),
|
||||
files_only: bool = Query(False, description="是否仅返回文件"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await list_workspace_tree(
|
||||
path=path,
|
||||
recursive=recursive,
|
||||
files_only=files_only,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
def _binary_preview_response(data: dict) -> StreamingResponse:
|
||||
filename = data.get("filename") or "preview"
|
||||
preview_type = data.get("preview_type") or "unsupported"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data.get("content") or b""),
|
||||
media_type=data.get("media_type") or "application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
|
||||
"X-Yuxi-Preview-Type": preview_type,
|
||||
"X-Yuxi-Preview-Filename": quote(filename),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _preview_response(data):
|
||||
if isinstance(data, dict) and data.get("binary"):
|
||||
return _binary_preview_response(data)
|
||||
return data
|
||||
|
||||
|
||||
@workspace.get("/file")
|
||||
async def get_workspace_file(
|
||||
path: str = Query(..., description="工作区文件路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await read_workspace_file_content(path=path, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.get("/knowledge/tree", response_model=dict)
|
||||
async def get_workspace_knowledge_tree(
|
||||
kb_id: str = Query(..., description="知识库 ID"),
|
||||
parent_id: str | None = Query(None, description="父文件夹 ID"),
|
||||
path_prefix: str | None = Query(None, description="虚拟目录路径前缀"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(100, ge=1, le=500, description="每页数量"),
|
||||
recursive: bool = Query(False, description="是否递归返回子目录文件"),
|
||||
files_only: bool = Query(False, description="是否仅返回文件"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
await _ensure_knowledge_read_access(current_user, kb_id)
|
||||
await _ensure_knowledge_supports_documents(kb_id)
|
||||
try:
|
||||
data = await knowledge_base.list_document_files(
|
||||
kb_id=kb_id,
|
||||
parent_id=parent_id,
|
||||
path_prefix=path_prefix,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
recursive=recursive,
|
||||
files_only=files_only,
|
||||
include_stats=False,
|
||||
)
|
||||
return {
|
||||
"entries": [_workspace_knowledge_entry(kb_id, item) for item in data.get("items", [])],
|
||||
"readonly": True,
|
||||
"page": data.get("page", page),
|
||||
"page_size": data.get("page_size", page_size),
|
||||
"total": data.get("total", 0),
|
||||
"has_more": bool(data.get("has_more")),
|
||||
"parent_id": data.get("parent_id"),
|
||||
"path_prefix": data.get("path_prefix", ""),
|
||||
}
|
||||
except ValueError as error:
|
||||
_raise_knowledge_read_error(error)
|
||||
|
||||
|
||||
@workspace.get("/knowledge/file")
|
||||
async def get_workspace_knowledge_file(
|
||||
kb_id: str = Query(..., description="知识库 ID"),
|
||||
file_id: str = Query(..., description="知识库文件 ID"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
await _ensure_knowledge_read_access(current_user, kb_id)
|
||||
try:
|
||||
return _preview_response(await knowledge_base.read_file_preview(kb_id=kb_id, file_id=file_id))
|
||||
except ValueError as error:
|
||||
_raise_knowledge_read_error(error)
|
||||
|
||||
|
||||
@workspace.get("/knowledge/download")
|
||||
async def download_workspace_knowledge_file(
|
||||
kb_id: str = Query(..., description="知识库 ID"),
|
||||
file_id: str = Query(..., description="知识库文件 ID"),
|
||||
variant: str = Query("original", description="下载模式:original 或 parsed"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
await _ensure_knowledge_read_access(current_user, kb_id)
|
||||
try:
|
||||
data = await knowledge_base.get_file_download(kb_id=kb_id, file_id=file_id, variant=variant)
|
||||
except ValueError as error:
|
||||
_raise_knowledge_read_error(error)
|
||||
|
||||
filename = data["filename"]
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data["content"]),
|
||||
media_type=data["media_type"],
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@workspace.put("/file", response_model=dict)
|
||||
async def update_workspace_file(
|
||||
payload: UpdateWorkspaceFileContentRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await write_workspace_file_content(
|
||||
path=payload.path,
|
||||
content=payload.content,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@workspace.delete("/file", response_model=dict)
|
||||
async def delete_workspace_file_route(
|
||||
path: str = Query(..., description="工作区文件或目录路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await delete_workspace_path(path=path, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.post("/directory", response_model=dict)
|
||||
async def create_workspace_directory_route(
|
||||
payload: CreateWorkspaceDirectoryRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await create_workspace_directory(
|
||||
parent_path=payload.parent_path,
|
||||
name=payload.name,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@workspace.post("/upload", response_model=dict)
|
||||
async def upload_workspace_files_route(
|
||||
parent_path: str = Form(..., description="父目录路径"),
|
||||
files: list[UploadFile] = File(..., description="上传文件列表"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await upload_workspace_files(parent_path=parent_path, files=files, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.get("/download")
|
||||
async def download_workspace(
|
||||
path: str = Query(..., description="工作区文件路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await download_workspace_file(path=path, current_user=current_user)
|
||||
@@ -0,0 +1 @@
|
||||
# utils包初始化文件
|
||||
@@ -0,0 +1,67 @@
|
||||
"""访问日志中间件 - 记录请求处理时间"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# 创建专用的访问日志记录器
|
||||
access_logger = logging.getLogger("access_logger")
|
||||
|
||||
# 设置访问日志记录器
|
||||
if not access_logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(fmt="%(asctime)s %(levelname)s: %(message)s", datefmt="%m-%d %H:%M:%S")
|
||||
handler.setFormatter(formatter)
|
||||
access_logger.addHandler(handler)
|
||||
access_logger.setLevel(logging.INFO)
|
||||
# 避免传播到根日志记录器,防止重复日志
|
||||
access_logger.propagate = False
|
||||
|
||||
|
||||
def _extract_client_ip(request: Request) -> str:
|
||||
"""提取客户端IP地址"""
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"""访问日志中间件 - 记录请求处理时间"""
|
||||
|
||||
def __init__(self, app, logger: logging.Logger = None):
|
||||
super().__init__(app)
|
||||
self.logger = logger or access_logger
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""处理请求并记录访问日志"""
|
||||
# 记录请求开始时间
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# 获取客户端IP
|
||||
client_ip = _extract_client_ip(request)
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 计算处理时间
|
||||
process_time = time.perf_counter() - start_time
|
||||
process_time_ms = int(process_time * 1000) # 转换为毫秒
|
||||
|
||||
# 格式化日志消息,添加处理时间
|
||||
log_message = (
|
||||
f"{client_ip}:{request.client.port if request.client else 'unknown'} - "
|
||||
f'"{request.method} {request.url.path}{"?" + request.url.query if request.url.query else ""} '
|
||||
f'HTTP/{request.scope["http_version"]}" '
|
||||
f"{response.status_code} - {process_time_ms}ms"
|
||||
)
|
||||
|
||||
# 记录日志
|
||||
self.logger.info(log_message)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,140 @@
|
||||
import hashlib
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_business import APIKey, User
|
||||
from yuxi.utils.datetime_utils import utc_now_naive
|
||||
|
||||
from yuxi.utils.auth_utils import AuthUtils
|
||||
|
||||
# 定义OAuth2密码承载器,指定token URL
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
|
||||
|
||||
|
||||
# 获取数据库会话(异步版本)
|
||||
async def get_db():
|
||||
async with pg_manager.get_async_session_context() as db:
|
||||
yield db
|
||||
|
||||
|
||||
async def _verify_api_key(key: str, db: AsyncSession) -> tuple[User | None, APIKey | None]:
|
||||
"""验证 API Key 并返回关联用户和 APIKey 对象"""
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
result = await db.execute(select(APIKey).filter(APIKey.key_hash == key_hash))
|
||||
api_key = result.scalar_one_or_none()
|
||||
|
||||
if api_key is None:
|
||||
return None, None
|
||||
|
||||
if not api_key.is_enabled:
|
||||
return None, None
|
||||
|
||||
if api_key.expires_at and utc_now_naive() > api_key.expires_at:
|
||||
return None, None
|
||||
|
||||
if not api_key.user_id:
|
||||
return None, None
|
||||
|
||||
result = await db.execute(select(User).filter(User.id == api_key.user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and not user.is_deleted:
|
||||
return user, api_key
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
# 获取当前用户(异步版本)
|
||||
async def get_current_user(
|
||||
authorization: str | None = Header(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的凭证",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if authorization is None:
|
||||
return None
|
||||
|
||||
if not authorization.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
token = authorization.split("Bearer ")[1]
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# 根据 token 前缀判断认证方式
|
||||
if token.startswith("yxkey_"):
|
||||
# API Key 认证
|
||||
user, api_key_obj = await _verify_api_key(token, db)
|
||||
if user is not None and api_key_obj is not None:
|
||||
api_key_obj.last_used_at = utc_now_naive()
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
# JWT Token 认证
|
||||
try:
|
||||
payload = AuthUtils.verify_access_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).filter(User.id == int(user_id), User.is_deleted == 0))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if user.is_login_locked():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail="登录被锁定,请稍后重试",
|
||||
headers={"X-Lock-Remaining": str(user.get_remaining_lock_time())},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
# 获取已登录用户(抛出401如果未登录)
|
||||
async def get_required_user(user: User | None = Depends(get_current_user)):
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请登录后再访问",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not user.department_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前用户未绑定部门",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
# 获取管理员用户
|
||||
async def get_admin_user(current_user: User = Depends(get_required_user)):
|
||||
if current_user.role not in ["admin", "superadmin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# 获取超级管理员用户
|
||||
async def get_superadmin_user(current_user: User = Depends(get_required_user)):
|
||||
if current_user.role != "superadmin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要超级管理员权限",
|
||||
)
|
||||
return current_user
|
||||
@@ -0,0 +1,17 @@
|
||||
import logging
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
force=True,
|
||||
)
|
||||
|
||||
uvicorn_logger = logging.getLogger("uvicorn")
|
||||
logging.getLogger("uvicorn.access").handlers.clear()
|
||||
|
||||
formatter = logging.Formatter(fmt="%(asctime)s %(levelname)s: %(message)s", datefmt="%m-%d %H:%M:%S")
|
||||
for handler in uvicorn_logger.handlers:
|
||||
handler.setFormatter(formatter)
|
||||
@@ -0,0 +1,123 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
|
||||
from yuxi.services.task_service import tasker
|
||||
from yuxi.agents.mcp.service import ensure_builtin_mcp_servers_in_db
|
||||
from yuxi.models.providers.service import ensure_builtin_model_providers_in_db
|
||||
from yuxi.services.run_queue_service import close_queue_clients, get_redis_client
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.knowledge import knowledge_base
|
||||
from yuxi.utils import logger
|
||||
from yuxi.agents.backends.sandbox import init_sandbox_provider, shutdown_sandbox_provider
|
||||
from yuxi import get_version
|
||||
from yuxi.config import config
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""FastAPI lifespan事件管理器"""
|
||||
# 初始化数据库连接
|
||||
try:
|
||||
pg_manager.initialize()
|
||||
await pg_manager.create_tables()
|
||||
await pg_manager.ensure_business_schema()
|
||||
await pg_manager.ensure_knowledge_schema()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database during startup: {e}")
|
||||
|
||||
# 确保内置 MCP 服务器定义存在于数据库
|
||||
try:
|
||||
await ensure_builtin_mcp_servers_in_db()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure builtin MCP servers during startup: {e}")
|
||||
|
||||
try:
|
||||
from yuxi.agents.skills.service import init_builtin_skills
|
||||
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
await init_builtin_skills(session)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize builtin skills during startup: {e}")
|
||||
|
||||
try:
|
||||
from yuxi.repositories.agent_repository import AgentRepository
|
||||
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
repository = AgentRepository(session)
|
||||
await repository.ensure_default_agent()
|
||||
await repository.ensure_general_purpose_subagent()
|
||||
await repository.ensure_web_search_subagent()
|
||||
await repository.ensure_deep_research_agents()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure default agent during startup: {e}")
|
||||
|
||||
# 初始化内置模型供应商配置
|
||||
try:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
await ensure_builtin_model_providers_in_db(session)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure builtin model providers during startup: {e}")
|
||||
|
||||
# 初始化模型缓存(v2 模型选择使用)
|
||||
try:
|
||||
from yuxi.models.providers.cache import model_cache
|
||||
from yuxi.models.providers.service import get_all_model_providers
|
||||
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
providers = await get_all_model_providers(session)
|
||||
model_cache.rebuild(providers)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize model cache during startup: {e}")
|
||||
|
||||
# 初始化知识库管理器
|
||||
if os.environ.get("LITE_MODE", "").lower() in ("true", "1"):
|
||||
logger.info("LITE_MODE enabled, skipping knowledge base initialization")
|
||||
else:
|
||||
try:
|
||||
await knowledge_base.initialize()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize knowledge base manager: {e}")
|
||||
|
||||
# 预热 Redis(run 队列)
|
||||
try:
|
||||
redis = await get_redis_client()
|
||||
await redis.ping()
|
||||
except Exception as e:
|
||||
logger.warning(f"Run queue redis unavailable on startup: {e}")
|
||||
|
||||
# 启动运行时配置同步线程(周期性从 Redis 拉取管理员保存的配置快照)
|
||||
config.start_runtime_sync()
|
||||
|
||||
try:
|
||||
init_sandbox_provider()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize sandbox provider during startup: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 2. 核心修复:在这里执行一次 setup(),建完表就拉倒
|
||||
# =========================================================
|
||||
checkpointer = AsyncPostgresSaver(pg_manager.langgraph_pool)
|
||||
await checkpointer.setup()
|
||||
print("LangGraph Checkpoint tables verified/created!")
|
||||
|
||||
await tasker.start()
|
||||
logger.info(f"""
|
||||
|
||||
░██ ░██ ░██
|
||||
░██ ░██
|
||||
░██ ░██ ░██ ░██ ░██ ░██ ░██
|
||||
░████ ░██ ░██ ░██ ░██ ░██
|
||||
░██ ░██ ░██ ░█████ ░██
|
||||
░██ ░██ ░███ ░██ ░██ ░██
|
||||
░██ ░█████░██ ░██ ░██ ░██ v{get_version()}
|
||||
|
||||
""")
|
||||
logger.info("Yuxi backend startup complete")
|
||||
yield
|
||||
await tasker.shutdown()
|
||||
shutdown_sandbox_provider()
|
||||
await close_queue_clients()
|
||||
await pg_manager.close()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""ARQ worker entrypoint."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 必须放在最顶层!
|
||||
if sys.platform == "win32":
|
||||
# 把当前文件 (main.py) 的上一级的上一级 (即根目录 Yuxi) 加入到 sys.path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
from yuxi.services.run_worker import WorkerSettings
|
||||
|
||||
__all__ = ["WorkerSettings"]
|
||||
Reference in New Issue
Block a user