chore: import upstream snapshot with attribution
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:26 +08:00
commit 1443d3fdf9
732 changed files with 196602 additions and 0 deletions
+147
View File
@@ -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"],
)
+55
View File
@@ -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,
)
+320
View File
@@ -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 IDresume 时为被恢复的 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)
+244
View File
@@ -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
+603
View File
@@ -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)}")
+989
View File
@@ -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 + INTERVALSQLite 使用 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)}")
+122
View File
@@ -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,
)
+108
View File
@@ -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
+415
View File
@@ -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="服务器 URLsse/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))
+58
View File
@@ -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)}}
+507
View File
@@ -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="批量删除技能失败")
+229
View File
@@ -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"}
+25
View File
@@ -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]}
+327
View File
@@ -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))
+260
View File
@@ -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)
+1
View File
@@ -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
+140
View File
@@ -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
+17
View File
@@ -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)
+123
View File
@@ -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}")
# 预热 Redisrun 队列)
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()
+15
View File
@@ -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"]