refactor: 简化代码逻辑

This commit is contained in:
Wenjie Zhang 2026-05-29 22:19:58 +08:00
parent afdb5fbb02
commit 5322b21c32
120 changed files with 867 additions and 1978 deletions

View File

@ -31,7 +31,7 @@ Yuxi 是一个面向 RAG、知识图谱和多智能体工作流的知识库平
- `repositories` 是数据库访问边界,封装业务对象和知识库元数据的 SQLAlchemy 查询。不要让路由绕过 repository 直接操作模型,除非已有局部模式要求这样做。
- `storage` 放持久化基础设施。`storage/postgres` 管理业务表、知识库表和 LangGraph checkpoint 所需连接池;`storage/minio` 管理对象存储。
- `knowledge` 是知识库和图谱领域。`KnowledgeBaseManager` 根据知识库类型分发到具体实现;`implementations` 放 Milvus、Dify 等知识库实现;`graphs` 放 Milvus 知识库图谱适配与构建服务;`chunking` 放文档分块策略。
- `plugins/parser` 是文档解析插件边界,统一封装 MinerU、PaddleX、RapidOCR、DeepSeek OCR 等解析实现。
- `knowledge/parser` 是文档解析边界,统一封装 MinerU、PaddleX、RapidOCR、DeepSeek OCR 等解析实现。
- `models` 封装 chat、embedding、rerank 模型适配;`config` 维护应用配置和内置模型信息;`utils` 放跨领域但足够通用的工具。
测试代码放在 `backend/test`,按 `unit`、`integration`、`e2e` 分层组织。新增或修改后端行为时,测试应落在最能覆盖风险的那一层。

View File

@ -33,7 +33,13 @@
- [x] 在工作区的文件编辑的时候,保存和取消的按钮应该是悬浮在编辑框的右上角,而不是在 header 上面
- [x] default enable all build in tools / kbs / skills / mcps / subagents
- [x] 链接 Notion 和 feishu 目前来看,都是支持的
- [x] 知识库的权限调整,修改为三个等级,全局共享、部门共享、指定人可访问- [x] 当前的评估基准是最重要的是评估数据集和评估结果都是放在一个文件里面的,这个是绝对不可以的,应该是放在数据库里面,比如评估数据集是一个表,每一个评估的题目是一个表,评估的结果是一个表,每一个评估的 item 也是一个表,但是数据表太多要注意命名规范。现在第一步就是完成原本的评估的功能的重新梳理
- [x] 知识库的权限调整,修改为三个等级,全局共享、部门共享、指定人可访问
- [x] 当前的评估基准是最重要的是评估数据集和评估结果都是放在一个文件里面的,这个是绝对不可以的,应该是放在数据库里面,比如评估数据集是一个表,每一个评估的题目是一个表,评估的结果是一个表,每一个评估的 item 也是一个表,但是数据表太多要注意命名规范。现在第一步就是完成原本的评估的功能的重新梳理
- [ ] 考虑如何将知识库更好的挂载到沙盒,是不是可以使用一个别的后端,但是使用别的后端是否还能读取到数据?应该不能
- [ ] 智能体体系改进。改进子智能体
- [ ] 智能体体系改进。改进子智能体,我觉得子智能体并不是一个子级的智能体,应该是
- [ ] RAG 中的文件的 metadata 包含那些内容?然后 Find 和 Read 的时候要支持展示
- [ ] 检查前端的这些 store 的使用,是否有需要调整优化收敛的地方
- [x] parser 从plugins 移动到 knowledge 里面guard 移动到services 里面
- [x] neo4j 相关的服务,可以移动到 storage 里面
- [ ] 点开对话的时候要能够自动定位到尾部,而不是最开始。
- [ ]

View File

@ -12,7 +12,7 @@ from yuxi.agents.state import BaseState
from yuxi.agents.toolkits.utils import get_tool_info
# MCP - Agent 层统一入口(自动过滤 disabled_tools
from yuxi.services.mcp_service import get_enabled_mcp_tools
from yuxi.agents.mcp.service import get_enabled_mcp_tools
__all__ = [
# Base classes

View File

@ -8,7 +8,7 @@ from deepagents.backends.composite import (
)
from deepagents.backends.protocol import FileInfo
from yuxi.services.skill_service import normalize_string_list
from yuxi.agents.skills.service import normalize_string_list
from .sandbox import ProvisionerSandboxBackend
from .skills_backend import SelectedSkillsReadonlyBackend

View File

@ -17,7 +17,7 @@ from deepagents.backends.protocol import (
from deepagents.backends.sandbox import BaseSandbox
from yuxi import config as conf
from yuxi.services.skill_service import sync_thread_readable_skills
from yuxi.agents.skills.service import sync_thread_readable_skills
from yuxi.utils.logging_config import logger
from .provider import get_sandbox_provider, sandbox_id_for_thread

View File

@ -6,7 +6,7 @@ from typing import Any
from deepagents.backends import FilesystemBackend
from deepagents.backends.protocol import EditResult, FileDownloadResponse, FileInfo, FileUploadResponse, WriteResult
from yuxi.services.skill_service import get_skills_root_dir, is_valid_skill_slug
from yuxi.agents.skills.service import get_skills_root_dir, is_valid_skill_slug
class SelectedSkillsReadonlyBackend(FilesystemBackend):

View File

@ -3,7 +3,7 @@ import importlib
import inspect
from pathlib import Path
from server.utils.singleton import SingletonMeta
from yuxi.utils.singleton import SingletonMeta
from yuxi.agents.base import BaseAgent
from yuxi.utils import logger

View File

@ -13,8 +13,8 @@ from yuxi.agents.middlewares import (
)
from yuxi.agents.middlewares.knowledge_base_middleware import KnowledgeBaseMiddleware
from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware
from yuxi.services.subagent_service import get_subagents_from_slugs
from yuxi.services.tool_service import resolve_configured_runtime_tools
from yuxi.agents.subagents.service import get_subagents_from_slugs
from yuxi.agents.toolkits.service import resolve_configured_runtime_tools
from .prompt import TODO_MID_PROMPT, build_prompt_with_context

View File

@ -19,8 +19,8 @@ from yuxi.agents.middlewares import (
from yuxi.agents.middlewares.knowledge_base_middleware import KnowledgeBaseMiddleware
from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware
from yuxi.agents.toolkits.buildin.tools import _create_tavily_search
from yuxi.services.subagent_service import get_subagents_from_slugs
from yuxi.services.tool_service import resolve_configured_runtime_tools
from yuxi.agents.subagents.service import get_subagents_from_slugs
from yuxi.agents.toolkits.service import resolve_configured_runtime_tools
from yuxi.utils import logger
from yuxi.utils.datetime_utils import shanghai_now

View File

@ -268,7 +268,7 @@ async def resolve_agent_resource_options(
options: dict[str, list[dict[str, str]]] = {}
if "tools" in fields_to_load:
from yuxi.services.tool_service import get_tool_metadata
from yuxi.agents.toolkits.service import get_tool_metadata
options["tools"] = [
_resource_option(tool["slug"], tool.get("name"), tool.get("description"))
@ -285,7 +285,7 @@ async def resolve_agent_resource_options(
if isinstance(item, dict) and item.get("kb_id")
]
if "mcps" in fields_to_load:
from yuxi.services.mcp_service import get_all_mcp_servers
from yuxi.agents.mcp.service import get_all_mcp_servers
servers = await get_all_mcp_servers(db)
options["mcps"] = [
@ -294,14 +294,14 @@ async def resolve_agent_resource_options(
if server.enabled and server.slug
]
if "skills" in fields_to_load:
from yuxi.services.skill_service import list_accessible_skills
from yuxi.agents.skills.service import list_accessible_skills
skills = await list_accessible_skills(db, user)
options["skills"] = [
_resource_option(skill.slug, skill.name, skill.description) for skill in skills if skill.slug
]
if "subagents" in fields_to_load:
from yuxi.services.subagent_service import get_all_subagents
from yuxi.agents.subagents.service import get_all_subagents
subagents = await get_all_subagents(db)
options["subagents"] = [

View File

@ -1,5 +1,7 @@
"""MCP 服务器数据访问层 - Repository"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select

View File

@ -11,12 +11,11 @@ import asyncio
import hashlib
import json
import re
import traceback
from collections.abc import Callable
from typing import Any, cast
from langchain_mcp_adapters.client import MultiServerMCPClient
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.models_business import MCPServer
from yuxi.utils import logger
@ -75,52 +74,18 @@ _SYNCED_MCP_FIELDS = (
async def ensure_builtin_mcp_servers_in_db() -> None:
"""Ensure built-in MCP server definitions exist in the database.
This function only synchronizes code-defined built-ins to the database.
It does not preload runtime configuration into memory.
"""
# Delayed import to avoid circular references
"""Ensure built-in MCP server definitions exist in the database."""
from yuxi.storage.postgres.manager import pg_manager
try:
async with pg_manager.get_async_session_context() as session:
# Check if database has MCP configurations
result = await session.execute(select(func.count(MCPServer.slug)))
count = result.scalar()
if count == 0:
# Database is empty, import default configurations
logger.info("No MCP servers in database, importing default configurations...")
for slug, config in _DEFAULT_MCP_SERVERS.items():
server = MCPServer(
slug=slug,
name=config.get("name", slug),
description=config.get("description"),
transport=config["transport"],
url=config.get("url"),
command=config.get("command"),
args=config.get("args"),
env=config.get("env"),
headers=config.get("headers"),
timeout=config.get("timeout"),
sse_read_timeout=config.get("sse_read_timeout"),
tags=config.get("tags"),
icon=config.get("icon"),
enabled=0,
created_by="system",
updated_by="system",
)
session.add(server)
await session.commit()
logger.info(f"Imported {len(_DEFAULT_MCP_SERVERS)} default MCP servers to database")
else:
# Ensure all built-in MCP servers exist in database
any_changed = False
for slug, config in _DEFAULT_MCP_SERVERS.items():
result = await session.execute(select(MCPServer).filter(MCPServer.slug == slug))
existing = result.scalar_one_or_none()
if not existing:
server = MCPServer(
session.add(
MCPServer(
slug=slug,
name=config.get("name", slug),
description=config.get("description"),
@ -138,25 +103,26 @@ async def ensure_builtin_mcp_servers_in_db() -> None:
created_by="system",
updated_by="system",
)
session.add(server)
)
any_changed = True
logger.info(f"Added built-in MCP server '{slug}' to database")
else:
changed = False
continue
server_changed = False
for field in _SYNCED_MCP_FIELDS:
next_value = config.get(field)
if getattr(existing, field) != next_value:
setattr(existing, field, next_value)
changed = True
if changed:
server_changed = True
if server_changed:
existing.updated_by = "system"
# Commit if any new servers were added (check session state)
if session.new:
await session.commit()
elif session.dirty:
any_changed = True
if any_changed:
await session.commit()
except Exception as e:
logger.error(f"Failed to ensure builtin MCP servers in database: {e}, traceback: {traceback.format_exc()}")
logger.exception(f"Failed to ensure builtin MCP servers in database: {e}")
async def get_mcp_client(
@ -310,9 +276,7 @@ async def get_mcp_tools(
)
except Exception as e:
logger.error(
f"Failed to load tools from MCP server '{server_slug}': {e}, traceback: {traceback.format_exc()}"
)
logger.exception(f"Failed to load tools from MCP server '{server_slug}': {e}")
return []
# 3. Filtering (Apply to Return Value Only)
@ -365,7 +329,7 @@ def get_mcp_tools_stats(server_slug: str) -> dict[str, int] | None:
# =============================================================================
# === Server Config CRUD (Existing in mcp_service.py) ===
# === Server Config CRUD ===
# =============================================================================

View File

@ -3,7 +3,7 @@ from typing import Any
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
from yuxi.services.mcp_service import get_mcp_tools
from yuxi.agents.mcp.service import get_mcp_tools
from yuxi.utils import logger

View File

@ -14,9 +14,9 @@ from langgraph.types import Command
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.agents.toolkits import get_all_tool_instances
from yuxi.repositories.skill_repository import SkillRepository
from yuxi.services.mcp_service import get_enabled_mcp_tools
from yuxi.services.skill_service import is_valid_skill_slug, list_accessible_skills, normalize_string_list
from yuxi.agents.skills.repository import SkillRepository
from yuxi.agents.mcp.service import get_enabled_mcp_tools
from yuxi.agents.skills.service import is_valid_skill_slug, list_accessible_skills, normalize_string_list
from yuxi.storage.postgres.manager import pg_manager
from yuxi.utils.logging_config import logger

View File

@ -1,7 +1,7 @@
from langchain.chat_models import BaseChatModel
from pydantic import SecretStr
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
from yuxi.utils import get_docker_safe_url
from yuxi.utils.logging_config import logger

View File

@ -17,8 +17,8 @@ import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi import config as sys_config
from yuxi.repositories.skill_repository import SkillRepository
from yuxi.services.mcp_service import get_enabled_mcp_server_slugs
from yuxi.agents.skills.repository import SkillRepository
from yuxi.agents.mcp.service import get_enabled_mcp_server_slugs
from yuxi.storage.postgres.models_business import Skill, User
from yuxi.utils.logging_config import logger
@ -420,7 +420,7 @@ async def list_skill_slugs(db: AsyncSession, *, user: User | None = None) -> lis
async def get_skill_dependency_options(
db: AsyncSession, user: User, slug: str | None = None
) -> dict[str, list[str] | list[dict]]:
from yuxi.services.tool_service import get_tool_metadata
from yuxi.agents.toolkits.service import get_tool_metadata
def get_tools():
all_tools = get_tool_metadata()
@ -443,7 +443,7 @@ async def get_skill_dependency_options(
def _get_all_tool_names() -> list[str]:
"""获取所有工具名称(包括 buildin 和其他来源)"""
from yuxi.services.tool_service import get_tool_metadata
from yuxi.agents.toolkits.service import get_tool_metadata
all_tools = get_tool_metadata()
return [tool["slug"] for tool in all_tools]
@ -658,31 +658,6 @@ def _build_default_share_payload(operator: User) -> dict[str, Any]:
}
def _write_skill_draft(
*,
operator: User,
source_type: str,
source: str | None,
items: list[dict[str, Any]],
) -> dict[str, Any]:
_cleanup_expired_skill_drafts()
draft_id = str(uuid.uuid4())
draft_dir = get_skill_drafts_root_dir() / draft_id
draft_dir.mkdir(parents=True, exist_ok=False)
data = {
"draft_id": draft_id,
"created_by": operator.uid,
"source_type": source_type,
"source": source,
"created_at": time.time(),
"expires_at": time.time() + SKILL_DRAFT_TTL_SECONDS,
"items": items,
**_build_default_share_payload(operator),
}
(draft_dir / "metadata.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return data
async def _import_skill_dir_impl(
db: AsyncSession,
*,

View File

@ -7,7 +7,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.repositories.subagent_repository import SubAgentRepository
from yuxi.agents.subagents.repository import SubAgentRepository
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import SubAgent
from yuxi.utils import logger

View File

@ -90,7 +90,7 @@
# ) -> tuple[Path, str]:
# """从 Sandbox 路径准备 skill 目录。返回 (本地目录, 原始 skill name)。"""
# from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend, resolve_virtual_path
# from yuxi.services.skill_service import (
# from yuxi.agents.skills.service import (
# _parse_skill_markdown,
# is_valid_skill_slug,
# )
@ -147,7 +147,7 @@
# ) -> Command:
# """执行异步安装任务的核心逻辑"""
# from yuxi.services.remote_skill_install_service import prepare_remote_skills_batch
# from yuxi.services.skill_service import (
# from yuxi.agents.skills.service import (
# import_skill_dir,
# normalize_string_list,
# sync_thread_readable_skills,

View File

@ -95,7 +95,7 @@ def get_tool_instances_by_category(category: str) -> list[Any]:
async def resolve_configured_runtime_tools(context) -> list[Any]:
from yuxi.services.mcp_service import get_enabled_mcp_tools
from yuxi.agents.mcp.service import get_enabled_mcp_tools
selected_tools = []
selected_tool_names: set[str] = set()

View File

@ -314,7 +314,7 @@ class KnowledgeBase(ABC):
self._add_to_processing_queue(file_id)
try:
from yuxi.plugins.parser.unified import Parser
from yuxi.knowledge.parser.unified import Parser
# Prepare params
params = file_meta.get("processing_params", {}) or {}

View File

@ -15,7 +15,7 @@ from yuxi.knowledge.graphs.graph_utils import (
normalize_entity_name,
)
from yuxi.knowledge.graphs.milvus_graph_vector_store import MilvusGraphVectorStore
from yuxi.knowledge.graphs.neo4j_utils import (
from yuxi.storage.neo4j import (
Neo4jConnectionManager,
get_shared_neo4j_connection,
neo4j_read,

View File

@ -20,7 +20,7 @@ from pymilvus import (
from yuxi.knowledge.graphs.graph_utils import graph_entity_collection_name, graph_triple_collection_name
from yuxi.knowledge.implementations.milvus import CONTENT_ANALYZER_PARAMS, CONTENT_SPARSE_FIELD, VECTOR_METRIC_TYPE
from yuxi.models.embed import select_embedding_model
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
from yuxi.utils import hashstr, logger

View File

@ -24,8 +24,8 @@ from yuxi.knowledge.base import FileStatus, KnowledgeBase
from yuxi.knowledge.chunking.ragflow_like.dispatcher import chunk_markdown
from yuxi.knowledge.utils.kb_utils import resolve_processing_params
from yuxi.repositories.knowledge_chunk_repository import KnowledgeChunkRepository
from yuxi.services.model_cache import model_cache
from yuxi.plugins.parser.unified import Parser
from yuxi.models.providers.cache import model_cache
from yuxi.knowledge.parser.unified import Parser
from yuxi.utils import hashstr, logger
from yuxi.utils.datetime_utils import utc_isoformat

View File

@ -1,11 +1,11 @@
from yuxi.plugins.parser.base import (
from yuxi.knowledge.parser.base import (
BaseDocumentProcessor,
DocumentParserException,
DocumentProcessorException,
OCRException,
)
from yuxi.plugins.parser.factory import DocumentProcessorFactory
from yuxi.plugins.parser.unified import (
from yuxi.knowledge.parser.factory import DocumentProcessorFactory
from yuxi.knowledge.parser.unified import (
SUPPORTED_FILE_EXTENSIONS,
MarkdownParseResult,
Parser,

View File

@ -14,7 +14,7 @@ from typing import Any
import fitz # PyMuPDF
import requests
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.knowledge.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.utils import logger

View File

@ -8,7 +8,7 @@ import asyncio
from importlib import import_module
from typing import Any
from yuxi.plugins.parser.base import BaseDocumentProcessor
from yuxi.knowledge.parser.base import BaseDocumentProcessor
from yuxi.utils import logger
# 处理器实例缓存
@ -20,11 +20,11 @@ class DocumentProcessorFactory:
# 处理器类型映射: processor_type -> (module_path, class_name)
PROCESSOR_TYPES = {
"rapid_ocr": ("yuxi.plugins.parser.rapid_ocr", "RapidOCRParser"),
"mineru_ocr": ("yuxi.plugins.parser.mineru", "MinerUParser"),
"mineru_official": ("yuxi.plugins.parser.mineru_official", "MinerUOfficialParser"),
"pp_structure_v3_ocr": ("yuxi.plugins.parser.pp_structure_v3", "PPStructureV3Parser"),
"deepseek_ocr": ("yuxi.plugins.parser.deepseek_ocr", "DeepSeekOCRParser"),
"rapid_ocr": ("yuxi.knowledge.parser.rapid_ocr", "RapidOCRParser"),
"mineru_ocr": ("yuxi.knowledge.parser.mineru", "MinerUParser"),
"mineru_official": ("yuxi.knowledge.parser.mineru_official", "MinerUOfficialParser"),
"pp_structure_v3_ocr": ("yuxi.knowledge.parser.pp_structure_v3", "PPStructureV3Parser"),
"deepseek_ocr": ("yuxi.knowledge.parser.deepseek_ocr", "DeepSeekOCRParser"),
}
@classmethod

View File

@ -11,8 +11,8 @@ from pathlib import Path
import requests
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.plugins.parser.zip_utils import process_zip_file_sync
from yuxi.knowledge.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.knowledge.parser.zip_utils import process_zip_file_sync
from yuxi.utils import logger

View File

@ -13,8 +13,8 @@ from typing import Any
import requests
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.plugins.parser.zip_utils import process_zip_file_sync
from yuxi.knowledge.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.knowledge.parser.zip_utils import process_zip_file_sync
from yuxi.utils import hashstr, logger

View File

@ -12,7 +12,7 @@ from typing import Any
import requests
from yuxi.plugins.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.knowledge.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.utils import logger

View File

@ -14,7 +14,7 @@ import numpy as np
from PIL import Image
from rapidocr import EngineType, LangDet, LangRec, ModelType, OCRVersion, RapidOCR
from yuxi.plugins.parser.base import BaseDocumentProcessor, OCRException
from yuxi.knowledge.parser.base import BaseDocumentProcessor, OCRException
from yuxi.utils import logger

View File

@ -18,7 +18,7 @@ from docling.document_converter import DocumentConverter
from langchain_community.document_loaders import PyPDFLoader
from markdownify import markdownify as md_convert
from yuxi.plugins.parser.zip_utils import process_zip_file as _process_zip_file
from yuxi.knowledge.parser.zip_utils import process_zip_file as _process_zip_file
from yuxi.storage.minio import get_minio_client
from yuxi.utils import logger
@ -212,8 +212,8 @@ def pdfreader(file_path, params=None):
def parse_pdf(file, params=None):
"""解析 PDF 文件,支持多种 OCR 方式。"""
from yuxi.plugins.parser.base import DocumentProcessorException
from yuxi.plugins.parser.factory import DocumentProcessorFactory
from yuxi.knowledge.parser.base import DocumentProcessorException
from yuxi.knowledge.parser.factory import DocumentProcessorFactory
opt_ocr, processor_params = _resolve_ocr_engine_params(params)
@ -236,8 +236,8 @@ def parse_pdf(file, params=None):
def parse_image(file, params=None):
"""解析图像文件,支持多种 OCR 方式。"""
from yuxi.plugins.parser.base import DocumentProcessorException
from yuxi.plugins.parser.factory import DocumentProcessorFactory
from yuxi.knowledge.parser.base import DocumentProcessorException
from yuxi.knowledge.parser.factory import DocumentProcessorFactory
opt_ocr, processor_params = _resolve_ocr_engine_params(params)

View File

@ -1,7 +1,7 @@
from openai import AsyncOpenAI
from tenacity import before_sleep_log, retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
from yuxi.utils import logger

View File

@ -5,7 +5,7 @@ import httpx
import numpy as np
import requests
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
from yuxi.utils import get_docker_safe_url, hashstr, logger

View File

@ -149,7 +149,7 @@ class ModelCache:
return grouped
def rebuild(self, providers: list[Any]) -> None:
from yuxi.services.model_provider_service import resolve_api_key
from yuxi.models.providers.service import resolve_api_key
new_cache: dict[str, ModelInfo] = {}

View File

@ -7,8 +7,8 @@ from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.config.builtin_providers import BUILTIN_PROVIDERS
from yuxi.repositories.model_provider_repository import (
from yuxi.models.providers.builtin import BUILTIN_PROVIDERS
from yuxi.models.providers.repository import (
create_model_provider,
delete_model_provider,
get_model_provider,
@ -90,6 +90,24 @@ def _validate_models_capabilities(enabled_models: list[dict], capabilities: set[
raise ValueError(f"模型 {model['id']} 的 type={model['type']} 不在 provider 能力 {sorted(capabilities)}")
_FIELD_DEFAULTS: dict[str, Any] = {
"capabilities": [],
"enabled_models": [],
"headers_json": {},
"extra_json": {},
"is_enabled": True,
"is_builtin": False,
}
_FIELD_NORMALIZERS = {
"capabilities": _normalize_list,
"enabled_models": _normalize_model_list,
"headers_json": _normalize_dict,
"extra_json": _normalize_dict,
"is_enabled": bool,
"is_builtin": bool,
}
def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
payload = dict(data)
if not partial or "provider_id" in payload:
@ -125,23 +143,7 @@ def _normalize_payload(data: dict[str, Any], *, partial: bool = False) -> dict[s
if provider_type not in VALID_PROVIDER_TYPES:
raise ValueError(f"provider_type 必须是 {', '.join(sorted(VALID_PROVIDER_TYPES))} 之一")
# 声明式字段默认值partial 模式下仅规范化传入值,非 partial 补全默认值
_FIELD_DEFAULTS: dict[str, Any] = {
"capabilities": [],
"enabled_models": [],
"headers_json": {},
"extra_json": {},
"is_enabled": True,
"is_builtin": False,
}
_FIELD_NORMALIZERS = {
"capabilities": _normalize_list,
"enabled_models": _normalize_model_list,
"headers_json": _normalize_dict,
"extra_json": _normalize_dict,
"is_enabled": bool,
"is_builtin": bool,
}
# partial 模式下仅规范化传入值,非 partial 补全默认值
for field, default in _FIELD_DEFAULTS.items():
if field in payload:
normalizer = _FIELD_NORMALIZERS.get(field)
@ -368,7 +370,7 @@ async def fetch_remote_models(provider: ModelProvider) -> list[dict[str, Any]]:
async def test_model_status_by_spec(spec: str) -> dict:
"""根据 spec 测试模型连接状态。"""
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
info = model_cache.get_model_info(spec)
if not info:

View File

@ -6,7 +6,7 @@ from typing import Any
import aiohttp
import numpy as np
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
from yuxi.utils import get_docker_safe_url, logger

View File

@ -1,16 +1 @@
# 新的统一文档处理器接口
from yuxi.plugins.parser.base import (
BaseDocumentProcessor,
DocumentParserException,
DocumentProcessorException,
OCRException,
)
from yuxi.plugins.parser.factory import DocumentProcessorFactory
__all__ = [
"BaseDocumentProcessor",
"DocumentProcessorException",
"DocumentParserException",
"OCRException",
"DocumentProcessorFactory", # 推荐使用
]
__all__: list[str] = []

View File

@ -1,6 +1,5 @@
import asyncio
import json
import traceback
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
@ -13,7 +12,7 @@ from yuxi.agents.backends.sandbox.paths import sandbox_workspace_agents_prompt_f
from yuxi.agents.buildin import agent_manager
from yuxi.agents.context import normalize_agent_context_config
from yuxi.agents.state import AgentStatePayload
from yuxi.plugins.guard import content_guard
from yuxi.services.guard import content_guard
from yuxi.repositories.agent_repository import AgentRepository
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.services.conversation_service import serialize_attachment
@ -270,8 +269,7 @@ async def save_partial_message(
)
except Exception as e:
logger.error(f"Error saving message: {e}")
logger.error(traceback.format_exc())
logger.exception(f"Error saving message: {e}")
return None
@ -445,8 +443,7 @@ async def check_and_handle_interrupts(
yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload)
except Exception as e:
logger.error(f"Error checking interrupts: {e}")
logger.error(traceback.format_exc())
logger.exception(f"Error checking interrupts: {e}")
async def _ensure_thread_bound_agent(
@ -672,8 +669,7 @@ async def agent_chat(
trace_info=trace_info,
)
except Exception as e:
logger.error(f"Error saving messages from LangGraph state: {e}")
logger.error(traceback.format_exc())
logger.exception(f"Error saving messages from LangGraph state: {e}")
return {
"status": "error",
"error_type": "save_message_error",
@ -691,7 +687,7 @@ async def agent_chat(
}
except Exception as e:
logger.error(f"Error in agent_chat: {e}, {traceback.format_exc()}")
logger.exception(f"Error in agent_chat: {e}")
return {
"status": "error",
"error_type": "unexpected_error",
@ -927,8 +923,7 @@ async def stream_agent_chat(
trace_info=trace_info,
)
except Exception as e:
logger.error(f"Error saving messages from LangGraph state: {e}")
logger.error(traceback.format_exc())
logger.exception(f"Error saving messages from LangGraph state: {e}")
yield make_chunk(status="warning", message=f"消息保存失败: {e}", meta=meta)
yield make_chunk(status="finished", meta=meta)
@ -962,7 +957,7 @@ async def stream_agent_chat(
yield make_chunk(status="interrupted", message="对话已中断", meta=meta)
except Exception as e:
logger.error(f"Error streaming messages: {e}, {traceback.format_exc()}")
logger.exception(f"Error streaming messages: {e}")
error_msg = f"Error streaming messages: {e}"
error_type = "unexpected_error"
@ -1095,8 +1090,7 @@ async def stream_agent_resume(
trace_info=trace_info,
)
except Exception as e:
logger.error(f"Error saving messages from LangGraph state: {e}")
logger.error(traceback.format_exc())
logger.exception(f"Error saving messages from LangGraph state: {e}")
yield make_resume_chunk(status="warning", message=f"消息保存失败: {e}", meta=meta)
yield make_resume_chunk(status="finished", meta=meta)
@ -1117,7 +1111,7 @@ async def stream_agent_resume(
yield make_resume_chunk(status="interrupted", message="对话恢复已中断", meta=meta)
except Exception as e:
logger.error(f"Error during resume: {e}, {traceback.format_exc()}")
logger.exception(f"Error during resume: {e}")
async with pg_manager.get_async_session_context() as new_db:
new_conv_repo = ConversationRepository(new_db)

View File

@ -12,7 +12,7 @@ from yuxi.agents.backends.sandbox import (
)
from yuxi.agents.buildin import agent_manager
from yuxi.config import config as app_config
from yuxi.plugins.parser import Parser
from yuxi.knowledge.parser import Parser
from yuxi.repositories.agent_repository import AgentRepository
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.services.mention_search_service import invalidate_mention_cache
@ -193,12 +193,6 @@ def _parse_user_tmp_object(object_name: str, uid: str) -> tuple[str, str, str]:
return parts[0], parts[1], parts[2]
def _require_user_tmp_object(object_name: str, uid: str) -> str:
"""校验 tmp 对象属于当前用户并返回 tmp_file_id。"""
tmp_file_id, _, _ = _parse_user_tmp_object(object_name, uid)
return tmp_file_id
def _require_tmp_object_section(
object_name: str,
uid: str,

View File

@ -1,5 +1,3 @@
import traceback
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -60,7 +58,7 @@ async def submit_message_feedback_view(
except HTTPException:
raise
except Exception as e:
logger.error(f"Error submitting message feedback: {e}, {traceback.format_exc()}")
logger.exception(f"Error submitting message feedback: {e}")
await db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to submit feedback: {str(e)}")
@ -91,5 +89,5 @@ async def get_message_feedback_view(
}
except Exception as e:
logger.error(f"Error getting message feedback: {e}")
logger.exception(f"Error getting message feedback: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get feedback: {str(e)}")

View File

@ -58,7 +58,7 @@ async def _resolve_filesystem_state(
runtime_context.uid = str(user.uid)
await prepare_agent_runtime_context(runtime_context)
return conversation, runtime_context
return runtime_context
async def list_filesystem_entries_view(
@ -73,7 +73,7 @@ async def list_filesystem_entries_view(
normalized_path = (path or "/").strip() or "/"
_conversation, runtime_context = await _resolve_filesystem_state(
runtime_context = await _resolve_filesystem_state(
thread_id=thread_id,
user=current_user,
db=db,
@ -105,7 +105,7 @@ async def read_file_content_view(
normalized_path = path.strip()
_conversation, runtime_context = await _resolve_filesystem_state(
runtime_context = await _resolve_filesystem_state(
thread_id=thread_id,
user=current_user,
db=db,

View File

@ -43,9 +43,6 @@ def get_langfuse_client() -> Langfuse | None:
if not is_langfuse_enabled():
return None
if Langfuse is None:
return None
kwargs: dict[str, Any] = {
"public_key": os.getenv("LANGFUSE_PUBLIC_KEY"),
"secret_key": os.getenv("LANGFUSE_SECRET_KEY"),

View File

@ -22,8 +22,8 @@ from yuxi.storage.postgres.models_business import Department, User
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from server.utils.auth_utils import AuthUtils
from server.utils.common_utils import log_operation
from yuxi.utils.auth_utils import AuthUtils
from yuxi.services.operation_log_service import log_operation
# 前端 OIDC 回调路由路径(与 web/src/router/index.js 中的路由保持一致)
FRONTEND_CALLBACK_PATH = "/auth/oidc/callback"
@ -563,8 +563,6 @@ async def _create_oidc_binding_placeholder(db, sub: str, target_user: User) -> N
password_hash = AuthUtils.hash_password(random_password)
# username 使用 oidc-binding-{sub_hash} 避免冲突sub_hash 基于完整 sub 生成
import hashlib
sub_hash = hashlib.sha256(sub.encode()).hexdigest()[:8]
username = f"oidc-binding-{sub_hash}"

View File

@ -0,0 +1,18 @@
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.models_business import OperationLog
async def log_operation(
db: AsyncSession,
user_id: int | None,
operation: str,
details: str | None = None,
request: Request | None = None,
) -> None:
try:
ip_address = request.client.host if request and request.client else None
db.add(OperationLog(user_id=user_id, operation=operation, details=details, ip_address=ip_address))
await db.commit()
except Exception:
pass

View File

@ -10,7 +10,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug
from yuxi.agents.skills.service import import_skill_dir, is_valid_skill_slug
if TYPE_CHECKING:
from yuxi.storage.postgres.models_business import Skill
@ -200,16 +200,8 @@ async def install_remote_skill(
cwd=workdir,
)
base_dir = Path(temp_home).resolve()
skills_dir = base_dir / ".agents" / "skills"
# Scan for the installed skill directory rather than constructing the path
# from user input, to avoid path traversal concerns
installed_dir = None
if skills_dir.is_dir():
for candidate in skills_dir.iterdir():
if candidate.name == normalized_skill and candidate.is_dir():
installed_dir = candidate
break
skills_dir = Path(temp_home).resolve() / ".agents" / "skills"
installed_dir = _find_skill_dir(skills_dir, normalized_skill)
if installed_dir is None:
raise ValueError("skills CLI 未生成预期的技能目录")

View File

@ -12,14 +12,14 @@ from sqlalchemy import select
from sqlalchemy.exc import OperationalError
from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository
from yuxi.services.chat_service import stream_agent_chat, stream_agent_resume
from yuxi.services.mcp_service import ensure_builtin_mcp_servers_in_db
from yuxi.agents.mcp.service import ensure_builtin_mcp_servers_in_db
from yuxi.services.run_queue_service import (
append_run_stream_event,
clear_cancel_signal,
has_cancel_signal,
wait_for_cancel_signal,
)
from yuxi.services.skill_service import init_builtin_skills
from yuxi.agents.skills.service import init_builtin_skills
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import User
from yuxi.utils.logging_config import logger

View File

@ -27,6 +27,29 @@ def _get_virtual_root() -> str:
return "/" + prefix.strip("/")
def _thread_file_entry(
thread_id: str,
uid: str,
child: Path,
*,
directory_paths_end_with_slash: bool = False,
) -> dict[str, Any]:
stat = child.stat()
child_virtual_path = virtual_path_for_thread_file(thread_id, child, uid=uid)
if directory_paths_end_with_slash and child.is_dir() and not child_virtual_path.endswith("/"):
child_virtual_path = f"{child_virtual_path}/"
return {
"path": child_virtual_path,
"name": child.name,
"is_dir": child.is_dir(),
"size": stat.st_size if child.is_file() else 0,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime),
"artifact_url": None
if child.is_dir()
else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}",
}
async def list_thread_files_view(
*,
thread_id: str,
@ -61,20 +84,7 @@ async def list_thread_files_view(
entries: list[dict[str, Any]] = []
for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
stat = child.stat()
child_virtual_path = virtual_path_for_thread_file(thread_id, child, uid=uid)
entries.append(
{
"path": child_virtual_path,
"name": child.name,
"is_dir": child.is_dir(),
"size": stat.st_size if child.is_file() else 0,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime),
"artifact_url": None
if child.is_dir()
else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}",
}
)
entries.append(_thread_file_entry(thread_id, uid, child))
return {"path": virtual_path, "files": entries}
@ -84,45 +94,20 @@ def _list_user_data_root_entries(thread_id: str, uid: str, virtual_path: str, re
entries: list[dict[str, Any]] = []
thread_root = sandbox_user_data_dir(thread_id)
for child in sorted(thread_root.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
stat = child.stat()
child_virtual_path = virtual_path_for_thread_file(thread_id, child, uid=uid)
if child.is_dir() and not child_virtual_path.endswith("/"):
child_virtual_path = f"{child_virtual_path}/"
entries.append(
{
"path": child_virtual_path,
"name": child.name,
"is_dir": child.is_dir(),
"size": stat.st_size if child.is_file() else 0,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime),
"artifact_url": None
if child.is_dir()
else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}",
}
)
entry = _thread_file_entry(thread_id, uid, child, directory_paths_end_with_slash=True)
entries.append(entry)
if recursive and child.is_dir():
nested = _list_files_recursive(thread_id, uid, child, child_virtual_path)
nested = _list_files_recursive(thread_id, uid, child, entry["path"])
entries.extend(nested["files"])
workspace_dir = sandbox_workspace_dir(thread_id, uid)
workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir, uid=uid)
if workspace_virtual_path.rstrip("/") not in {str(entry["path"]).rstrip("/") for entry in entries}:
# workspace lives outside the per-thread root, so expose it as a top-level entry.
stat = workspace_dir.stat()
if not workspace_virtual_path.endswith("/"):
workspace_virtual_path = f"{workspace_virtual_path}/"
entries.append(
{
"path": workspace_virtual_path,
"name": workspace_dir.name,
"is_dir": True,
"size": 0,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime),
"artifact_url": None,
}
)
entry = _thread_file_entry(thread_id, uid, workspace_dir, directory_paths_end_with_slash=True)
entries.append(entry)
if recursive:
nested = _list_files_recursive(thread_id, uid, workspace_dir, workspace_virtual_path)
nested = _list_files_recursive(thread_id, uid, workspace_dir, entry["path"])
entries.extend(nested["files"])
return {"path": virtual_path, "files": entries}
@ -131,29 +116,17 @@ def _list_files_recursive(thread_id: str, uid: str, actual_path: Path, virtual_p
"""Recursively scan a directory while preserving viewer virtual paths."""
entries: list[dict[str, Any]] = []
def _scan_dir(base_actual_path: Path, base_virtual_path: str):
def _scan_dir(base_actual_path: Path):
try:
for child in sorted(base_actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
stat = child.stat()
child_virtual_path = virtual_path_for_thread_file(thread_id, child, uid=uid)
entries.append(
{
"path": child_virtual_path,
"name": child.name,
"is_dir": child.is_dir(),
"size": stat.st_size if child.is_file() else 0,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime),
"artifact_url": None
if child.is_dir()
else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}",
}
)
entry = _thread_file_entry(thread_id, uid, child)
entries.append(entry)
if child.is_dir():
_scan_dir(child, child_virtual_path)
_scan_dir(child)
except PermissionError:
pass
_scan_dir(actual_path, virtual_path)
_scan_dir(actual_path)
return {"path": virtual_path, "files": entries}

View File

@ -0,0 +1,59 @@
import re
import time
from pypinyin import Style, lazy_pinyin
def to_pinyin(text: str) -> str:
return "".join(lazy_pinyin(text, style=Style.NORMAL))
def validate_username(username: str) -> tuple[bool, str]:
if not username:
return False, "用户名不能为空"
if len(username) < 2:
return False, "用户名长度不能少于2个字符"
if len(username) > 20:
return False, "用户名长度不能超过20个字符"
if not re.match(r"^[一-龥a-zA-Z0-9_]+$", username):
return False, "用户名只能包含中文、英文、数字和下划线"
return True, ""
def generate_uid(username: str) -> str:
uid = re.sub(r"[^a-zA-Z0-9_]", "", to_pinyin(username.strip()))
if uid and uid[0].isdigit():
uid = f"u{uid}"
if len(uid) < 2:
uid = f"user{hash(username) % 10000:04d}"
return uid[:20].lower()
def generate_unique_uid(username: str, existing_uids: list[str]) -> str:
base_uid = generate_uid(username)
if base_uid not in existing_uids:
return base_uid
counter = 1
while counter <= 9999:
candidate = f"{base_uid}{counter}"
if candidate not in existing_uids:
return candidate
counter += 1
return f"{base_uid}{int(time.time()) % 10000}"
def is_valid_phone_number(phone: str) -> bool:
if not phone:
return False
return bool(re.match(r"^1[3-9]\d{9}$", re.sub(r"[\s\-\(\)]", "", phone)))
def normalize_phone_number(phone: str) -> str:
if not phone:
return ""
phone = re.sub(r"\D", "", phone)
if len(phone) == 11 and phone.startswith("1"):
return phone
return phone

View File

@ -23,7 +23,7 @@ from yuxi.agents.backends.sandbox import (
virtual_path_for_thread_file,
)
from yuxi.agents.backends.skills_backend import SelectedSkillsReadonlyBackend
from yuxi.services.skill_service import normalize_string_list
from yuxi.agents.skills.service import normalize_string_list
from yuxi.services.filesystem_service import _resolve_filesystem_state
from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
@ -213,28 +213,6 @@ def _remap_prefixed_entry(entry: dict, prefix: str) -> dict:
}
def _normalize_entries(entries: list[dict]) -> list[dict]:
normalized: list[dict] = []
for entry in entries or []:
raw_path = str(entry.get("path") or "")
if not raw_path:
continue
is_dir = bool(entry.get("is_dir", False))
display_path = raw_path
if is_dir and not display_path.endswith("/"):
display_path = f"{display_path}/"
normalized.append(
{
"path": display_path,
"name": PurePosixPath(display_path.rstrip("/")).name or display_path,
"is_dir": is_dir,
"size": int(entry.get("size", 0) or 0),
"modified_at": str(entry.get("modified_at", "") or ""),
}
)
return normalized
def _sort_entries(entries: list[dict]) -> list[dict]:
"""Sort entries: folders first, then files alphabetically."""
return sorted(
@ -337,7 +315,7 @@ async def _resolve_viewer_state(
current_user: User,
db: AsyncSession,
):
_conversation, runtime_context = await _resolve_filesystem_state(
runtime_context = await _resolve_filesystem_state(
thread_id=thread_id,
user=current_user,
db=db,

View File

@ -0,0 +1,15 @@
from .manager import (
Neo4jConnectionManager,
get_shared_neo4j_connection,
neo4j_read,
neo4j_write,
safe_neo4j_label,
)
__all__ = [
"Neo4jConnectionManager",
"get_shared_neo4j_connection",
"neo4j_read",
"neo4j_write",
"safe_neo4j_label",
]

View File

@ -3,7 +3,8 @@ from __future__ import annotations
import os
import re
import threading
from typing import Any, Callable
from collections.abc import Callable
from typing import Any
from neo4j import GraphDatabase as GD

View File

@ -12,7 +12,7 @@ from yuxi.storage.postgres.models_business import Base as BusinessBase
from yuxi.storage.postgres.models_knowledge import Base as KnowledgeBase
from yuxi.utils import logger
from server.utils.singleton import SingletonMeta
from yuxi.utils.singleton import SingletonMeta
# 合并两个 Base
CombinedBase = declarative_base()

View File

@ -5,11 +5,11 @@ from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHash, VerifyMismatchError, VerificationError
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
from yuxi.utils.datetime_utils import utc_now
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION = 7 * 24 * 60 * 60 # 7天过期
JWT_EXPIRATION = 7 * 24 * 60 * 60
JWT_AUDIENCE = "yuxi-know-api"
PUBLIC_DEFAULT_JWT_SECRET_KEY = "yuxi_know_secure_key"
PASSWORD_HASHER = PasswordHasher()
@ -45,16 +45,12 @@ def _get_jwt_issuer() -> str:
class AuthUtils:
"""认证工具类"""
@staticmethod
def hash_password(password: str) -> str:
"""使用 Argon2 哈希密码"""
return PASSWORD_HASHER.hash(password)
@staticmethod
def verify_password(stored_password: str, provided_password: str) -> bool:
"""验证密码"""
if not stored_password.startswith("$argon2"):
return False
try:
@ -64,26 +60,15 @@ class AuthUtils:
@staticmethod
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
"""创建JWT访问令牌"""
to_encode = data.copy()
# 设置过期时间
if expires_delta:
expire = utc_now() + expires_delta
else:
expire = utc_now() + timedelta(seconds=JWT_EXPIRATION)
expire = utc_now() + (expires_delta or timedelta(seconds=JWT_EXPIRATION))
to_encode.update({"exp": expire, "iss": _get_jwt_issuer(), "aud": JWT_AUDIENCE})
# 编码JWT
encoded_jwt = jwt.encode(to_encode, _get_jwt_secret_key(), algorithm=JWT_ALGORITHM)
return encoded_jwt
return jwt.encode(to_encode, _get_jwt_secret_key(), algorithm=JWT_ALGORITHM)
@staticmethod
def decode_token(token: str) -> dict[str, Any] | None:
"""解码验证JWT令牌"""
try:
payload = jwt.decode(
return jwt.decode(
token,
_get_jwt_secret_key(),
algorithms=[JWT_ALGORITHM],
@ -91,15 +76,13 @@ class AuthUtils:
audience=JWT_AUDIENCE,
options={"require": ["exp", "sub", "iss", "aud"]},
)
return payload
except (jwt.PyJWTError, ValueError):
return None
@staticmethod
def verify_access_token(token: str) -> dict[str, Any]:
"""验证访问令牌,如果无效则抛出异常"""
try:
payload = jwt.decode(
return jwt.decode(
token,
_get_jwt_secret_key(),
algorithms=[JWT_ALGORITHM],
@ -107,7 +90,6 @@ class AuthUtils:
audience=JWT_AUDIENCE,
options={"require": ["exp", "sub", "iss", "aud"]},
)
return payload
except jwt.ExpiredSignatureError:
raise ValueError("令牌已过期")
except jwt.InvalidTokenError:

View File

@ -2,16 +2,11 @@ from threading import Lock
class SingletonMeta(type):
"""
This is a thread-safe implementation of Singleton.
"""
_instances = {}
_lock: Lock = Lock()
def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]

View File

@ -16,6 +16,8 @@ for import_path in (APP_ROOT, APP_ROOT / "package"):
sys.path.insert(0, import_path_str)
SUPERADMIN_UID = "zwj"
SUPERADMIN_NAME = "张文杰"
SUPERADMIN_PHONE_NUMBER = "15251638888"
SUPERADMIN_PASSWORD = "zwj12138"
DEFAULT_USER_PASSWORD = "yuxi123456"
@ -57,7 +59,7 @@ async def ensure_uninitialized(session) -> None:
async def seed_initial_users() -> None:
from server.utils.auth_utils import AuthUtils
from yuxi.utils.auth_utils import AuthUtils
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import Department, User
from yuxi.utils.datetime_utils import utc_now_naive
@ -83,8 +85,9 @@ async def seed_initial_users() -> None:
users = [
User(
username=SUPERADMIN_UID,
username=SUPERADMIN_NAME,
uid=SUPERADMIN_UID,
phone_number=SUPERADMIN_PHONE_NUMBER,
password_hash=AuthUtils.hash_password(SUPERADMIN_PASSWORD),
role="superadmin",
department_id=departments["dev"].id,
@ -131,7 +134,10 @@ def main() -> int:
print(f"初始化种子用户失败:{exc}", file=sys.stderr)
return 1
print("初始化完成:已创建超级管理员 zwj、3 个部门、6 个部门管理员和 14 个普通用户。")
print(
f"初始化完成:已创建超级管理员 {SUPERADMIN_NAME}{SUPERADMIN_UID})、"
"3 个部门、6 个部门管理员和 14 个普通用户。"
)
print("超级管理员密码zwj12138")
print("部门管理员和普通用户默认密码yuxi123456")
return 0

View File

@ -7,11 +7,15 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from server.routers.auth_router import get_admin_user
from server.utils.auth_middleware import get_db, get_required_user
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.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,
@ -19,9 +23,7 @@ from yuxi.services.agent_run_service import (
get_agent_run_view,
stream_agent_run_events,
)
from yuxi.services.chat_service import agent_chat, stream_agent_chat
from yuxi.storage.postgres.models_business import User
from yuxi.utils.logging_config import logger
agent_router = APIRouter(prefix="/agent", tags=["agent"])
@ -58,14 +60,6 @@ class AgentRunCreate(BaseModel):
resume_request_id: str | None = Field(None, description="可选resume 幂等键")
class AgentChatRequest(BaseModel):
query: str = Field(..., description="用户输入的问题")
agent_id: str = Field(..., description="智能体 ID")
thread_id: str | None = Field(None, description="可选,会话线程 ID不传则自动创建")
meta: dict = Field(default_factory=dict, description="可选,请求追踪信息,例如 request_id")
image_content: str | None = Field(None, description="可选base64 图片内容")
def _backend_info(info: dict) -> dict:
data = dict(info)
data["backend_id"] = data.pop("id", None)
@ -121,9 +115,7 @@ async def list_agents(current_user: User = Depends(get_required_user), db: Async
await repo.ensure_default_agent()
items = await repo.list_visible(user=current_user)
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
]
agents = [await _serialize_agent(repo, item, current_user, backend_info_cache=backend_info_cache) for item in items]
return {"agents": agents}
@ -189,7 +181,7 @@ async def update_agent(
raise HTTPException(status_code=403, detail="不能编辑非自己创建的智能体")
try:
fields_set = getattr(payload, "model_fields_set", getattr(payload, "__fields_set__", set()))
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:
@ -246,44 +238,6 @@ async def set_agent_default(
return {"agent": await _serialize_agent(repo, updated, current_user, include_configurable_items=True)}
@agent_router.post("/chat")
async def chat_agent(
payload: AgentChatRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
logger.info(f"query: {payload.query}, agent_id: {payload.agent_id}, meta: {payload.meta}")
return StreamingResponse(
stream_agent_chat(
query=payload.query,
agent_id=payload.agent_id,
thread_id=payload.thread_id,
meta=dict(payload.meta or {}),
image_content=payload.image_content,
current_user=current_user,
db=db,
),
media_type="application/json",
)
@agent_router.post("/chat/sync")
async def chat_agent_sync(
payload: AgentChatRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
return await agent_chat(
query=payload.query,
agent_id=payload.agent_id,
thread_id=payload.thread_id,
meta=dict(payload.meta or {}),
image_content=payload.image_content,
current_user=current_user,
db=db,
)
@agent_router.post("/runs")
async def create_agent_run(
payload: AgentRunCreate,

View File

@ -14,9 +14,9 @@ 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 server.utils.auth_utils import AuthUtils
from server.utils.common_utils import log_operation
from server.utils.user_utils import is_valid_phone_number
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"])

View File

@ -19,9 +19,9 @@ from server.utils.auth_middleware import (
get_db,
get_required_user,
)
from server.utils.auth_utils import AuthUtils
from server.utils.user_utils import generate_unique_uid, validate_username, is_valid_phone_number
from server.utils.common_utils import log_operation
from yuxi.utils.auth_utils import AuthUtils
from yuxi.services.user_identity_service import generate_unique_uid, validate_username, is_valid_phone_number
from yuxi.services.operation_log_service import log_operation
from yuxi.services.upload_utils import upload_image_to_minio
from yuxi.utils.datetime_utils import utc_now_naive
@ -138,13 +138,6 @@ class OIDCLoginResponse(BaseModel):
# =============================================================================
async def get_default_department_id(db: AsyncSession) -> int | None:
"""获取默认部门的ID"""
result = await db.execute(select(Department).filter(Department.name == "默认部门"))
default_dept = result.scalar_one_or_none()
return default_dept.id if default_dept else None
# 路由:登录获取令牌
# =============================================================================
# === 认证分组 ===
@ -589,18 +582,6 @@ async def read_user(user_id: int, current_user: User = Depends(get_admin_user),
return user.to_dict()
async def check_department_admin_count(db: AsyncSession, department_id: int, exclude_user_id: int) -> int:
"""检查部门中管理员数量(排除指定用户)"""
result = await db.execute(
select(func.count(User.id)).filter(
User.department_id == department_id,
User.role == "admin",
User.id != exclude_user_id,
)
)
return result.scalar()
# 路由:更新用户信息(管理员权限)
@auth.put("/users/{user_id}", response_model=UserResponse)
async def update_user(
@ -668,7 +649,9 @@ async def update_user(
if user_data.role is not None:
# 检查是否将管理员降级为普通用户
if user.role == "admin" and user_data.role == "user" and user.department_id is not None:
admin_count = await check_department_admin_count(db, user.department_id, user_id)
admin_count = await UserRepository().get_admin_count_in_department(
user.department_id, exclude_user_id=user_id
)
if admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -695,7 +678,9 @@ async def update_user(
# 检查该用户是否是当前部门的唯一管理员
if user.role == "admin" and user.department_id is not None:
admin_count = await check_department_admin_count(db, user.department_id, user_id)
admin_count = await UserRepository().get_admin_count_in_department(
user.department_id, exclude_user_id=user_id
)
if admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@ -12,11 +12,10 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import Integer, String, cast, distinct, func, or_, select, text
from sqlalchemy import Integer, String, cast, distinct, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from server.routers.auth_router import get_admin_user
from server.utils.auth_middleware import get_db
from server.utils.auth_middleware import get_admin_user, get_db
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
@ -660,16 +659,11 @@ async def get_all_feedbacks(
from yuxi.storage.postgres.models_business import Conversation, Message, MessageFeedback, User
try:
# Build query with joins including User table
# Try both User.id and User.uid as MessageFeedback.uid might be stored as either
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) | (MessageFeedback.uid == User.uid),
)
.outerjoin(User, MessageFeedback.uid == User.uid)
)
# Apply filters

View File

@ -1,5 +1,3 @@
import traceback
from fastapi import APIRouter, Depends, HTTPException, Query
from server.utils.auth_middleware import get_admin_user
@ -45,8 +43,7 @@ async def get_graphs(current_user: User = Depends(get_admin_user)):
)
return {"success": True, "data": graphs}
except Exception as e:
logger.error(f"Failed to list graphs: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
logger.exception(f"Failed to list graphs: {e}")
raise HTTPException(status_code=500, detail=f"Failed to list graphs: {str(e)}")
@ -64,14 +61,16 @@ async def get_subgraph(
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,
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.error(f"Failed to get subgraph: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
logger.exception(f"Failed to get subgraph: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get subgraph: {str(e)}")

View File

@ -1,4 +1,3 @@
import traceback
from typing import Any, Literal
from urllib.parse import quote
@ -10,6 +9,7 @@ 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
@ -46,8 +46,6 @@ async def upload_evaluation_dataset(
current_user: User = Depends(get_admin_user),
):
"""上传评估数据集"""
from yuxi.knowledge.eval.service import EvaluationService
try:
if not file.filename.endswith(".jsonl"):
raise HTTPException(status_code=400, detail="仅支持JSONL格式文件")
@ -65,21 +63,19 @@ async def upload_evaluation_dataset(
except HTTPException:
raise
except Exception as e:
logger.error(f"上传评估数据集失败: {e}, {traceback.format_exc()}")
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)):
"""获取知识库的评估数据集列表"""
from yuxi.knowledge.eval.service import EvaluationService
try:
service = EvaluationService()
datasets = await service.list_datasets(kb_id)
return {"message": "success", "data": datasets}
except Exception as e:
logger.error(f"获取评估数据集列表失败: {e}, {traceback.format_exc()}")
logger.exception(f"获取评估数据集列表失败: {e}")
raise HTTPException(status_code=500, detail=f"获取评估数据集列表失败: {str(e)}")
@ -88,8 +84,6 @@ 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)
):
"""获取评估数据集详情"""
from yuxi.knowledge.eval.service import EvaluationService
try:
if page < 1:
raise HTTPException(status_code=400, detail="页码必须大于0")
@ -106,15 +100,13 @@ async def get_evaluation_dataset(
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"获取评估数据集详情失败: {e}, {traceback.format_exc()}")
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"""
from yuxi.knowledge.eval.service import EvaluationService
try:
service = EvaluationService()
export_info = await service.export_dataset_jsonl(dataset_id)
@ -129,15 +121,13 @@ async def download_evaluation_dataset(dataset_id: str, current_user: User = Depe
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"导出评估数据集失败: {e}, {traceback.format_exc()}")
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)):
"""删除评估数据集"""
from yuxi.knowledge.eval.service import EvaluationService
try:
service = EvaluationService()
await service.delete_dataset(dataset_id)
@ -147,7 +137,7 @@ async def delete_evaluation_dataset(dataset_id: str, current_user: User = Depend
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"删除评估数据集失败: {e}, {traceback.format_exc()}")
logger.exception(f"删除评估数据集失败: {e}")
raise HTTPException(status_code=500, detail=f"删除评估数据集失败: {str(e)}")
@ -156,8 +146,6 @@ async def generate_evaluation_dataset(
kb_id: str, request: GenerateDatasetRequest, current_user: User = Depends(get_admin_user)
):
"""自动生成评估数据集"""
from yuxi.knowledge.eval.service import EvaluationService
try:
service = EvaluationService()
result = await service.generate_dataset(
@ -176,15 +164,13 @@ async def generate_evaluation_dataset(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"生成评估数据集失败: {e}, {traceback.format_exc()}")
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评估"""
from yuxi.knowledge.eval.service import EvaluationService
try:
service = EvaluationService()
run_id = await service.run_evaluation(
@ -199,21 +185,19 @@ async def run_evaluation(kb_id: str, request: RunEvaluationRequest, current_user
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"启动评估失败: {e}, {traceback.format_exc()}")
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)):
"""获取知识库评估运行历史"""
from yuxi.knowledge.eval.service import EvaluationService
try:
service = EvaluationService()
runs = await service.list_runs(kb_id)
return {"message": "success", "data": runs}
except Exception as e:
logger.error(f"获取评估运行历史失败: {e}, {traceback.format_exc()}")
logger.exception(f"获取评估运行历史失败: {e}")
raise HTTPException(status_code=500, detail=f"获取评估运行历史失败: {str(e)}")
@ -227,8 +211,6 @@ async def get_evaluation_run_results(
current_user: User = Depends(get_admin_user),
):
"""获取评估运行结果"""
from yuxi.knowledge.eval.service import EvaluationService
try:
if page < 1:
raise HTTPException(status_code=400, detail="页码必须大于0")
@ -245,15 +227,13 @@ async def get_evaluation_run_results(
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"获取评估运行结果失败: {e}, {traceback.format_exc()}")
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)):
"""删除评估运行"""
from yuxi.knowledge.eval.service import EvaluationService
try:
service = EvaluationService()
await service.delete_run(kb_id, run_id)
@ -263,5 +243,5 @@ async def delete_evaluation_run(kb_id: str, run_id: str, current_user: User = De
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"删除评估运行失败: {e}, {traceback.format_exc()}")
logger.exception(f"删除评估运行失败: {e}")
raise HTTPException(status_code=500, detail=f"删除评估运行失败: {str(e)}")

View File

@ -15,7 +15,7 @@ from server.utils.auth_middleware import get_admin_user, get_required_user
from yuxi import config, knowledge_base
from yuxi.knowledge.factory import KnowledgeBaseFactory
from yuxi.knowledge.graphs.milvus_graph_service import GRAPH_TASK_TYPE, MilvusGraphService
from yuxi.plugins.parser import Parser, SUPPORTED_FILE_EXTENSIONS, is_supported_file_extension
from yuxi.knowledge.parser import Parser, SUPPORTED_FILE_EXTENSIONS, is_supported_file_extension
from yuxi.knowledge.utils import calculate_content_hash, is_minio_url, parse_minio_url
from yuxi.knowledge.utils.mindmap_utils import (
generate_database_mindmap,
@ -28,7 +28,7 @@ from yuxi.knowledge.utils.sample_question_utils import (
get_database_sample_questions,
)
from yuxi.knowledge.utils.url_fetcher import fetch_url_content
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
from yuxi.services.upload_utils import MAX_UPLOAD_SIZE_BYTES, read_upload_with_limit, write_upload_to_path
from yuxi.services.workspace_service import MAX_WORKSPACE_UPLOAD_SIZE_BYTES, resolve_workspace_file_path
from yuxi.storage.postgres.models_business import User

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.services.mcp_service import (
from yuxi.agents.mcp.service import (
create_mcp_server,
get_mcp_tools_stats,
delete_mcp_server,
@ -90,9 +90,7 @@ async def get_mcp_servers(
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]}
else:
# NOTE: 针对普通用户采用高安全显式白名单字段准入投影,使用 getattr 兼容 Mock
# 仿真对象和历史数据,避免未来新增敏感字段或审计信息越权泄露
data = []
for s in servers:
data.append(
@ -185,7 +183,7 @@ async def update_mcp_server_route(
raise HTTPException(status_code=400, detail=f"传输类型必须是 {', '.join(valid_transports)} 之一")
try:
fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set()))
fields_set = request.model_fields_set
update_kwargs = {}
if "env" in fields_set:
update_kwargs["env"] = request.env
@ -399,7 +397,7 @@ async def toggle_mcp_server_tool_route(
):
"""切换单个工具的启用状态"""
try:
enabled, server = await toggle_tool_enabled(db, slug, tool_name, current_user.username)
enabled, _ = await toggle_tool_enabled(db, slug, tool_name, current_user.username)
return {
"success": True,
"tool_name": tool_name,

View File

@ -8,7 +8,7 @@ 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.services.model_provider_service import (
from yuxi.models.providers.service import (
check_credential_status,
create_provider_config,
delete_provider_config,
@ -27,7 +27,7 @@ model_providers = APIRouter(prefix="/system/model-providers", tags=["model-provi
async def _refresh_model_cache() -> None:
"""刷新模型缓存CRUD 操作后调用)。"""
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
try:
async with pg_manager.get_async_session_context() as session:
@ -196,7 +196,7 @@ async def refresh_model_cache(
):
"""强制刷新模型缓存,从数据库重新加载所有供应商配置到 Redis。"""
await _refresh_model_cache()
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
return {"success": True, "message": "缓存已刷新", "model_count": len(model_cache.get_all_specs())}
@ -211,7 +211,7 @@ async def get_v2_models(
v2 模型 spec 格式: provider_id:model_id冒号分隔
返回数据供前端模型选择器使用
"""
from yuxi.services.model_cache import model_cache
from yuxi.models.providers.cache import model_cache
grouped = model_cache.get_specs_grouped_by_provider(model_type)

View File

@ -10,8 +10,7 @@ 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.services.remote_skill_install_service import list_remote_skills, search_remote_skills
from yuxi.services.skill_service import (
from yuxi.agents.skills.service import (
confirm_skill_install_draft,
create_skill_node,
delete_skill,
@ -35,6 +34,7 @@ from yuxi.services.skill_service import (
update_skill_file,
update_skill_share_config,
)
from yuxi.services.remote_skill_install_service import list_remote_skills, search_remote_skills
from yuxi.storage.postgres.models_business import User
from yuxi.utils.logging_config import logger

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException
from server.utils.auth_middleware import get_admin_user, get_db
from yuxi.services import subagent_service as service
from yuxi.agents.subagents import service
from yuxi.storage.postgres.models_business import User
from yuxi.utils import logger

View File

@ -161,7 +161,7 @@ async def check_ocr_services_health(current_user: User = Depends(get_admin_user)
检查所有OCR服务的健康状态
返回各个OCR服务的可用性信息
"""
from yuxi.plugins.parser.factory import DocumentProcessorFactory
from yuxi.knowledge.parser.factory import DocumentProcessorFactory
try:
# 使用统一的健康检查接口

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends
from yuxi.services.tool_service import get_tool_metadata
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

View File

@ -10,7 +10,7 @@ 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 server.utils.auth_utils import AuthUtils
from yuxi.utils.auth_utils import AuthUtils
# 定义OAuth2密码承载器指定token URL
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)

View File

@ -1,61 +1,17 @@
"""通用工具函数"""
import logging
from fastapi import Request
from sqlalchemy.orm import Session
from yuxi.storage.postgres.models_business import OperationLog, User
def setup_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
level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
force=True,
)
# 确保uvicorn的日志也使用相同格式
uvicorn_logger = logging.getLogger("uvicorn")
uvicorn_access_logger = logging.getLogger("uvicorn.access")
logging.getLogger("uvicorn.access").handlers.clear()
# 禁用默认的uvicorn访问日志因为我们使用自定义中间件
uvicorn_access_logger.handlers.clear()
# 创建格式化器
formatter = logging.Formatter(fmt="%(asctime)s %(levelname)s: %(message)s", datefmt="%m-%d %H:%M:%S")
# 为uvicorn主日志设置格式化器
for handler in uvicorn_logger.handlers:
handler.setFormatter(formatter)
async def log_operation(db: Session, user_id: int, operation: str, details: str = None, request: Request = None):
"""记录用户操作日志"""
try:
ip_address = None
if request:
ip_address = request.client.host if request.client else None
log = OperationLog(user_id=user_id, operation=operation, details=details, ip_address=ip_address)
db.add(log)
await db.commit()
except Exception:
# 日志写入失败不影响主业务
pass
def get_user_dict(user: User, include_password: bool = False) -> dict:
"""获取用户字典表示"""
return user.to_dict(include_password)
def convert_serializable(obj):
"""将对象转换为可序列化的格式"""
if isinstance(obj, list | tuple):
return [convert_serializable(item) for item in obj]
if isinstance(obj, dict):
return {k: convert_serializable(v) for k, v in obj.items()}
if hasattr(obj, "__dict__"):
return convert_serializable(vars(obj))
return obj

View File

@ -5,9 +5,9 @@ from fastapi import FastAPI
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from yuxi.services.task_service import tasker
from yuxi.services.mcp_service import ensure_builtin_mcp_servers_in_db
from yuxi.services.model_provider_service import ensure_builtin_model_providers_in_db
from yuxi.services.subagent_service import init_builtin_subagents
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.agents.subagents.service import init_builtin_subagents
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
@ -35,7 +35,7 @@ async def lifespan(app: FastAPI):
logger.error(f"Failed to ensure builtin MCP servers during startup: {e}")
try:
from yuxi.services.skill_service import init_builtin_skills
from yuxi.agents.skills.service import init_builtin_skills
async with pg_manager.get_async_session_context() as session:
await init_builtin_skills(session)
@ -59,8 +59,8 @@ async def lifespan(app: FastAPI):
# 初始化模型缓存v2 模型选择使用)
try:
from yuxi.services.model_cache import model_cache
from yuxi.services.model_provider_service import get_all_model_providers
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)

View File

@ -1,158 +0,0 @@
"""
用户ID生成工具
提供用户名验证和user_id自动生成功能
"""
import re
from pypinyin import lazy_pinyin, Style
def to_pinyin(text: str) -> str:
"""
将中文转换为拼音
使用pypinyin库进行转换
"""
# 使用pypinyin进行转换
pinyin_list = lazy_pinyin(text, style=Style.NORMAL)
return "".join(pinyin_list)
def validate_username(username: str) -> tuple[bool, str]:
"""
验证用户名格式
Args:
username: 用户名
Returns:
Tuple[bool, str]: (是否有效, 错误信息)
"""
if not username:
return False, "用户名不能为空"
if len(username) < 2:
return False, "用户名长度不能少于2个字符"
if len(username) > 20:
return False, "用户名长度不能超过20个字符"
# 检查是否包含不允许的字符
# 允许中文、英文、数字、下划线
if not re.match(r"^[\u4e00-\u9fa5a-zA-Z0-9_]+$", username):
return False, "用户名只能包含中文、英文、数字和下划线"
return True, ""
def generate_uid(username: str) -> str:
"""
根据用户名生成user_id
Args:
username: 用户名
Returns:
str: 生成的user_id
"""
# 1. 基本清理
username = username.strip()
# 2. 转换为拼音(如果包含中文)
uid = to_pinyin(username)
# 3. 处理特殊字符,只保留字母、数字和下划线
uid = re.sub(r"[^a-zA-Z0-9_]", "", uid)
# 4. 确保不以数字开头
if uid and uid[0].isdigit():
uid = "u" + uid
# 5. 如果为空或太短,使用默认前缀
if len(uid) < 2:
uid = "user" + str(hash(username) % 10000).zfill(4)
# 6. 长度限制
if len(uid) > 20:
uid = uid[:20]
return uid.lower()
def generate_unique_uid(username: str, existing_uids: list[str]) -> str:
"""
生成唯一的user_id如果重复则添加数字后缀
Args:
username: 用户名
existing_uids: 已存在的user_id列表
Returns:
str: 唯一的user_id
"""
base_uid = generate_uid(username)
# 如果不重复,直接返回
if base_uid not in existing_uids:
return base_uid
# 如果重复,添加数字后缀
counter = 1
while True:
candidate = f"{base_uid}{counter}"
if candidate not in existing_uids:
return candidate
counter += 1
# 防止无限循环
if counter > 9999:
# 使用时间戳作为后缀
import time
candidate = f"{base_uid}{int(time.time()) % 10000}"
return candidate
def is_valid_phone_number(phone: str) -> bool:
"""
验证手机号格式支持中国大陆手机号
Args:
phone: 手机号字符串
Returns:
bool: 是否为有效手机号
"""
if not phone:
return False
# 移除空格和特殊字符
phone = re.sub(r"[\s\-\(\)]", "", phone)
# 中国大陆手机号格式1开头第二位是3-9总共11位
pattern = r"^1[3-9]\d{9}$"
return bool(re.match(pattern, phone))
def normalize_phone_number(phone: str) -> str:
"""
标准化手机号格式
Args:
phone: 原始手机号
Returns:
str: 标准化后的手机号
"""
if not phone:
return ""
# 移除所有非数字字符
phone = re.sub(r"\D", "", phone)
# 如果是中国大陆手机号,确保格式正确
if len(phone) == 11 and phone.startswith("1"):
return phone
return phone

View File

@ -1,200 +1,42 @@
"""
Integration tests for chat_agent_sync non-streaming endpoint.
Integration tests for current agent run endpoints.
"""
from __future__ import annotations
import uuid
import pytest
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def _get_agent_and_config_id(test_client, headers):
agents_response = await test_client.get("/api/chat/agent", headers=headers)
assert agents_response.status_code == 200, agents_response.text
agents = agents_response.json().get("agents", [])
async def test_agent_run_endpoints_require_authentication(test_client):
run_id = str(uuid.uuid4())
if not agents:
pytest.skip("No agents are registered in the system.")
agent_id = agents[0].get("id")
if not agent_id:
pytest.skip("Agent payload missing id field.")
configs_response = await test_client.get(f"/api/chat/agent/{agent_id}/configs", headers=headers)
assert configs_response.status_code == 200, configs_response.text
configs = configs_response.json().get("configs", [])
if not configs:
pytest.skip("No agent configs are available in the system.")
config_id = configs[0].get("id")
if not config_id:
pytest.skip("Agent config payload missing id field.")
return agent_id, config_id
create_response = await test_client.post(
"/api/agent/runs",
json={"query": "hello", "agent_id": "default-chatbot", "thread_id": str(uuid.uuid4())},
)
assert create_response.status_code == 401
assert (await test_client.get(f"/api/agent/runs/{run_id}")).status_code == 401
assert (await test_client.post(f"/api/agent/runs/{run_id}/cancel")).status_code == 401
async def test_chat_agent_sync_requires_authentication(test_client):
"""非流式端点需要认证"""
response = await test_client.post("/api/chat/agent/sync", json={"query": "hello", "agent_config_id": 1})
assert response.status_code == 401
async def test_chat_agent_sync_basic_conversation(test_client, admin_headers):
"""测试非流式对话基本功能"""
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
# 调用非流式端点
async def test_agent_run_create_rejects_empty_input(test_client, admin_headers):
response = await test_client.post(
"/api/chat/agent/sync",
json={"query": "Hello, say 'Hi' back to me", "agent_config_id": agent_config_id},
"/api/agent/runs",
json={"query": "", "agent_id": "default-chatbot", "thread_id": str(uuid.uuid4())},
headers=admin_headers,
)
assert response.status_code == 200, response.text
payload = response.json()
# 验证响应结构
assert "status" in payload, f"Missing 'status' in response: {payload}"
assert payload["status"] in ("finished", "error", "interrupted"), f"Unexpected status: {payload['status']}"
assert "request_id" in payload, f"Missing 'request_id' in response: {payload}"
# 如果成功完成,验证响应内容
if payload["status"] == "finished":
assert "response" in payload, f"Missing 'response' in finished status: {payload}"
assert isinstance(payload["response"], str), f"response should be str, got: {type(payload['response'])}"
assert len(payload["response"]) > 0, "response should not be empty"
# thread_id 应该存在
assert "thread_id" in payload, f"Missing 'thread_id' in response: {payload}"
# time_cost 应该存在
assert "time_cost" in payload, f"Missing 'time_cost' in response: {payload}"
assert isinstance(payload["time_cost"], float), f"time_cost should be float: {type(payload['time_cost'])}"
assert response.status_code == 422
async def test_chat_agent_sync_with_thread_id(test_client, admin_headers):
"""测试非流式对话指定 thread_id"""
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
async def test_agent_run_missing_resource_returns_not_found(test_client, admin_headers):
run_id = str(uuid.uuid4())
import uuid
get_response = await test_client.get(f"/api/agent/runs/{run_id}", headers=admin_headers)
assert get_response.status_code == 404
thread_id = str(uuid.uuid4())
response = await test_client.post(
"/api/chat/agent/sync",
json={
"query": "Hello",
"agent_config_id": agent_config_id,
"thread_id": thread_id,
},
headers=admin_headers,
)
assert response.status_code == 200, response.text
payload = response.json()
# 验证 thread_id 是否保持一致
if payload["status"] == "finished":
assert payload.get("thread_id") == thread_id, (
f"thread_id mismatch: expected {thread_id}, got {payload.get('thread_id')}"
)
threads_response = await test_client.get("/api/chat/threads", headers=admin_headers)
assert threads_response.status_code == 200, threads_response.text
threads = threads_response.json()
target_thread = next((item for item in threads if item.get("id") == thread_id), None)
assert target_thread is not None, f"thread not found in thread list: {thread_id}"
assert (target_thread.get("metadata") or {}).get("agent_config_id") == agent_config_id, (
"agent_config_id mismatch: "
f"expected {agent_config_id}, got {(target_thread.get('metadata') or {}).get('agent_config_id')}"
)
async def test_chat_agent_sync_with_meta(test_client, admin_headers):
"""测试非流式对话传递 meta 参数"""
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
import uuid
request_id = str(uuid.uuid4())
response = await test_client.post(
"/api/chat/agent/sync",
json={
"query": "Hello",
"agent_config_id": agent_config_id,
"meta": {"request_id": request_id},
},
headers=admin_headers,
)
assert response.status_code == 200, response.text
payload = response.json()
# 验证 request_id 是否保持一致
assert payload.get("request_id") == request_id, (
f"request_id mismatch: expected {request_id}, got {payload.get('request_id')}"
)
async def test_chat_agent_sync_vs_streaming_consistency(test_client, admin_headers):
"""对比测试:非流式与流式端点行为一致性"""
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
query = "What is 1+1?"
# 调用流式端点
import uuid
thread_id = str(uuid.uuid4())
request_id = str(uuid.uuid4())
streaming_response = await test_client.post(
"/api/chat/agent",
json={
"query": query,
"agent_config_id": agent_config_id,
"thread_id": thread_id,
"meta": {"request_id": request_id},
},
headers=admin_headers,
)
assert streaming_response.status_code == 200, streaming_response.text
# 收集流式响应
streaming_content = []
async for line in streaming_response.aiter_lines():
if line:
import json as json_lib
try:
data = json_lib.loads(line)
if data.get("response"):
streaming_content.append(data["response"])
except Exception:
pass
# 调用非流式端点
thread_id2 = str(uuid.uuid4())
request_id2 = str(uuid.uuid4())
sync_response = await test_client.post(
"/api/chat/agent/sync",
json={
"query": query,
"agent_config_id": agent_config_id,
"thread_id": thread_id2,
"meta": {"request_id": request_id2},
},
headers=admin_headers,
)
assert sync_response.status_code == 200, sync_response.text
sync_payload = sync_response.json()
# 两者都应该成功
assert sync_payload["status"] == "finished", f"Sync failed: {sync_payload}"
# 非流式响应应该有内容
assert "response" in sync_payload, f"Missing response in sync payload: {sync_payload}"
assert len(streaming_content) > 0, "Streaming should have collected content"
cancel_response = await test_client.post(f"/api/agent/runs/{run_id}/cancel", headers=admin_headers)
assert cancel_response.status_code == 404

View File

@ -13,20 +13,20 @@ pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def test_chat_endpoints_require_authentication(test_client):
assert (await test_client.get("/api/chat/default_agent")).status_code == 401
assert (await test_client.get("/api/chat/agent")).status_code == 401
assert (await test_client.get("/api/chat/threads")).status_code == 401
assert (await test_client.get("/api/agent")).status_code == 401
async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str:
agents_resp = await test_client.get("/api/chat/agent", headers=headers)
agents_resp = await test_client.get("/api/agent", headers=headers)
assert agents_resp.status_code == 200, agents_resp.text
agents = agents_resp.json().get("agents", [])
if not agents:
pytest.skip("No agents available for chat router integration tests.")
agent_id = agents[0].get("id")
agent_id = agents[0].get("agent_id") or agents[0].get("slug")
if not agent_id:
pytest.skip("Agent payload missing id field.")
pytest.skip("Agent payload missing slug field.")
create_resp = await test_client.post(
"/api/chat/thread",
@ -45,120 +45,74 @@ async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str:
async def test_admin_can_list_agents(test_client, admin_headers):
response = await test_client.get("/api/chat/agent", headers=admin_headers)
response = await test_client.get("/api/agent", headers=admin_headers)
assert response.status_code == 200, response.text
payload = response.json()
assert isinstance(payload["agents"], list)
if payload["agents"]:
assert "id" in payload["agents"][0]
assert "agent_id" in payload["agents"][0]
async def test_admin_can_read_default_agent(test_client, admin_headers):
response = await test_client.get("/api/chat/default_agent", headers=admin_headers)
response = await test_client.get("/api/agent/default", headers=admin_headers)
assert response.status_code == 200, response.text
assert "default_agent_id" in response.json()
agent = response.json()["agent"]
assert agent["is_default"] is True
assert agent["agent_id"]
async def test_standard_user_manages_own_agent_config_with_role_filtered_fields(
async def test_agent_detail_filters_configurable_items_by_role(
test_client,
admin_headers,
standard_user,
):
agents_response = await test_client.get("/api/chat/agent", headers=standard_user["headers"])
agents_response = await test_client.get("/api/agent", headers=standard_user["headers"])
assert agents_response.status_code == 200, agents_response.text
agents = agents_response.json().get("agents", [])
if not agents:
pytest.skip("No agents are registered in the system.")
agent_id = agents[0]["id"]
agent_id = agents[0].get("agent_id") or agents[0].get("slug")
if not agent_id:
pytest.skip("Agent payload missing slug field.")
user_agent_response = await test_client.get(f"/api/chat/agent/{agent_id}", headers=standard_user["headers"])
user_agent_response = await test_client.get(f"/api/agent/{agent_id}", headers=standard_user["headers"])
assert user_agent_response.status_code == 200, user_agent_response.text
user_items = user_agent_response.json().get("configurable_items", {})
user_items = user_agent_response.json()["agent"].get("configurable_items", {})
assert "summary_threshold" not in user_items
admin_agent_response = await test_client.get(f"/api/chat/agent/{agent_id}", headers=admin_headers)
admin_agent_response = await test_client.get(f"/api/agent/{agent_id}", headers=admin_headers)
assert admin_agent_response.status_code == 200, admin_agent_response.text
admin_items = admin_agent_response.json().get("configurable_items", {})
admin_items = admin_agent_response.json()["agent"].get("configurable_items", {})
assert "summary_threshold" in admin_items
config_name = f"pytest-config-{uuid.uuid4().hex[:8]}"
create_response = await test_client.post(
f"/api/chat/agent/{agent_id}/configs",
json={
"name": config_name,
"config_json": {"context": {"system_prompt": "user visible", "summary_threshold": 1}},
"set_default": True,
},
headers=standard_user["headers"],
)
assert create_response.status_code == 200, create_response.text
created = create_response.json()["config"]
config_id = created["id"]
assert created["uid"] == standard_user["user"]["uid"]
assert created["is_default"] is True
assert created["config_json"]["context"]["system_prompt"] == "user visible"
assert "summary_threshold" not in created["config_json"]["context"]
try:
admin_list_response = await test_client.get(f"/api/chat/agent/{agent_id}/configs", headers=admin_headers)
assert admin_list_response.status_code == 200, admin_list_response.text
admin_config_ids = {item["id"] for item in admin_list_response.json().get("configs", [])}
assert config_id not in admin_config_ids
update_response = await test_client.put(
f"/api/chat/agent/{agent_id}/configs/{config_id}",
json={"config_json": {"context": {"system_prompt": "updated", "summary_threshold": 2}}},
headers=standard_user["headers"],
)
assert update_response.status_code == 200, update_response.text
updated_context = update_response.json()["config"]["config_json"]["context"]
assert updated_context == {"system_prompt": "updated"}
default_response = await test_client.post(
f"/api/chat/agent/{agent_id}/configs/{config_id}/set_default",
json={},
headers=standard_user["headers"],
)
assert default_response.status_code == 200, default_response.text
assert default_response.json()["config"]["is_default"] is True
finally:
delete_response = await test_client.delete(
f"/api/chat/agent/{agent_id}/configs/{config_id}",
headers=standard_user["headers"],
)
assert delete_response.status_code in (200, 404), delete_response.text
async def test_setting_default_agent_requires_admin(test_client, admin_headers, standard_user):
# Attempt as admin first to obtain a candidate agent id.
agents_response = await test_client.get("/api/chat/agent", headers=admin_headers)
agents_response = await test_client.get("/api/agent", headers=admin_headers)
assert agents_response.status_code == 200, agents_response.text
agents = agents_response.json().get("agents", [])
if not agents:
pytest.skip("No agents are registered in the system.")
candidate_agent_id = agents[0].get("id")
candidate_agent_id = agents[0].get("agent_id") or agents[0].get("slug")
if not candidate_agent_id:
pytest.skip("Agent payload missing id field.")
pytest.skip("Agent payload missing slug field.")
# Normal user should not be able to update the default agent.
forbidden_response = await test_client.post(
"/api/chat/set_default_agent",
json={"agent_id": candidate_agent_id},
f"/api/agent/{candidate_agent_id}/set_default",
headers=standard_user["headers"],
)
assert forbidden_response.status_code == 403
# Admin should succeed.
update_response = await test_client.post(
"/api/chat/set_default_agent",
json={"agent_id": candidate_agent_id},
f"/api/agent/{candidate_agent_id}/set_default",
headers=admin_headers,
)
assert update_response.status_code == 200, update_response.text
assert update_response.json()["default_agent_id"] == candidate_agent_id
agent = update_response.json()["agent"]
assert agent["agent_id"] == candidate_agent_id
assert agent["is_default"] is True
async def test_save_thread_artifact_to_workspace_copies_output_file(test_client, standard_user):

View File

@ -10,7 +10,7 @@ import pytest
from yuxi.models.chat import OpenAIBase
from yuxi.models.embed import OtherEmbedding
from yuxi.models.rerank import DashscopeReranker, OpenAIReranker
from yuxi.services.model_provider_service import (
from yuxi.models.providers.service import (
resolve_api_key,
ensure_builtin_model_providers_in_db,
get_model_provider_by_id,

View File

@ -118,7 +118,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_
monkeypatch.setitem(
sys.modules,
"yuxi.services.tool_service",
"yuxi.agents.toolkits.service",
types.SimpleNamespace(
get_tool_metadata=lambda category=None: [
{"slug": "ask_user_question", "name": "Ask User", "description": ""},
@ -133,17 +133,17 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_
)
monkeypatch.setitem(
sys.modules,
"yuxi.services.mcp_service",
"yuxi.agents.mcp.service",
types.SimpleNamespace(get_all_mcp_servers=fake_get_all_mcp_servers),
)
monkeypatch.setitem(
sys.modules,
"yuxi.services.skill_service",
"yuxi.agents.skills.service",
types.SimpleNamespace(list_accessible_skills=fake_list_skills),
)
monkeypatch.setitem(
sys.modules,
"yuxi.services.subagent_service",
"yuxi.agents.subagents.service",
types.SimpleNamespace(get_all_subagents=fake_get_all_subagents),
)
@ -236,7 +236,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
)
monkeypatch.setitem(
sys.modules,
"yuxi.services.tool_service",
"yuxi.agents.toolkits.service",
types.SimpleNamespace(
get_tool_metadata=lambda category=None: [
{"slug": "ask_user_question", "name": "Ask User", "description": ""}
@ -250,17 +250,17 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
)
monkeypatch.setitem(
sys.modules,
"yuxi.services.mcp_service",
"yuxi.agents.mcp.service",
types.SimpleNamespace(get_all_mcp_servers=fake_get_all_mcp_servers),
)
monkeypatch.setitem(
sys.modules,
"yuxi.services.skill_service",
"yuxi.agents.skills.service",
types.SimpleNamespace(list_accessible_skills=fake_list_skills),
)
monkeypatch.setitem(
sys.modules,
"yuxi.services.subagent_service",
"yuxi.agents.subagents.service",
types.SimpleNamespace(get_all_subagents=fake_get_all_subagents),
)

View File

@ -341,7 +341,7 @@ async def test_install_skill_git_with_skill_names_passes_admin_check(mock_pg):
with patch("yuxi.services.remote_skill_install_service.prepare_remote_skills_batch") as mock_prepare:
mock_prepare.return_value = preparation
with patch("yuxi.services.skill_service.import_skill_dir") as mock_import:
with patch("yuxi.agents.skills.service.import_skill_dir") as mock_import:
mock_import.return_value = SimpleNamespace(slug="test-skill")
with patch(
@ -350,7 +350,7 @@ async def test_install_skill_git_with_skill_names_passes_admin_check(mock_pg):
# Mock enabling skill in config
mock_enable.return_value = True
with patch("yuxi.services.skill_service.sync_thread_readable_skills"):
with patch("yuxi.agents.skills.service.sync_thread_readable_skills"):
result = await _install_skill_func(
source="owner/repo",
skill_names=["test-skill"],
@ -379,7 +379,7 @@ async def test_install_skill_sandbox_success(mock_pg):
with patch("yuxi.agents.toolkits.buildin.install_skill._prepare_skill_from_sandbox") as mock_prepare:
mock_prepare.return_value = (Path("/tmp/my-skill"), "my-skill")
with patch("yuxi.services.skill_service.import_skill_dir") as mock_import:
with patch("yuxi.agents.skills.service.import_skill_dir") as mock_import:
mock_import.return_value = SimpleNamespace(slug="my-skill")
with patch(
@ -387,7 +387,7 @@ async def test_install_skill_sandbox_success(mock_pg):
) as mock_enable:
mock_enable.return_value = True
with patch("yuxi.services.skill_service.sync_thread_readable_skills"):
with patch("yuxi.agents.skills.service.sync_thread_readable_skills"):
result = await _install_skill_func(
source="/home/gem/user-data/workspace/my-skill",
runtime=runtime,
@ -467,7 +467,7 @@ async def test_install_skill_partial_config_failure(mock_pg):
with patch("yuxi.agents.toolkits.buildin.install_skill._prepare_skill_from_sandbox") as mock_prepare:
mock_prepare.return_value = (Path("/tmp/my-skill"), "my-skill")
with patch("yuxi.services.skill_service.import_skill_dir") as mock_import:
with patch("yuxi.agents.skills.service.import_skill_dir") as mock_import:
mock_import.return_value = SimpleNamespace(slug="my-skill")
with patch(
@ -476,7 +476,7 @@ async def test_install_skill_partial_config_failure(mock_pg):
# Simulate config persistence failure
mock_enable.return_value = False
with patch("yuxi.services.skill_service.sync_thread_readable_skills"):
with patch("yuxi.agents.skills.service.sync_thread_readable_skills"):
result = await _install_skill_func(
source="/home/gem/user-data/workspace/my-skill",
runtime=runtime,
@ -506,7 +506,7 @@ async def test_install_skill_slug_warning_for_renamed(mock_pg):
# Simulate skill being renamed during installation
mock_prepare.return_value = (Path("/tmp/my-skill"), "my-skill")
with patch("yuxi.services.skill_service.import_skill_dir") as mock_import:
with patch("yuxi.agents.skills.service.import_skill_dir") as mock_import:
mock_import.return_value = SimpleNamespace(slug="my-skill-v2")
with patch(
@ -514,7 +514,7 @@ async def test_install_skill_slug_warning_for_renamed(mock_pg):
) as mock_enable:
mock_enable.return_value = True
with patch("yuxi.services.skill_service.sync_thread_readable_skills"):
with patch("yuxi.agents.skills.service.sync_thread_readable_skills"):
result = await _install_skill_func(
source="/home/gem/user-data/workspace/my-skill",
runtime=runtime,

View File

@ -7,12 +7,12 @@ from types import SimpleNamespace
import fitz
import pytest
import yuxi.plugins.parser.unified as parser_unified
import yuxi.knowledge.parser.unified as parser_unified
from docx import Document
from PIL import Image
from yuxi.plugins.parser import Parser
from yuxi.plugins.parser.factory import DocumentProcessorFactory
from yuxi.knowledge.parser import Parser
from yuxi.knowledge.parser.factory import DocumentProcessorFactory
DATA_DIR = Path(__file__).resolve().parents[2] / "data"

View File

@ -30,7 +30,7 @@ def _build_app(*, allow_admin: bool = True) -> FastAPI:
async def fake_required_user():
return User(
username="admin" if allow_admin else "user",
user_id="admin" if allow_admin else "user",
uid="admin" if allow_admin else "user",
password_hash="x",
role="admin" if allow_admin else "user",
)

View File

@ -327,7 +327,7 @@ def test_update_subagent_status_not_found(monkeypatch):
class TestSubAgentRepository:
@pytest.mark.asyncio
async def test_list_all(self):
from yuxi.repositories.subagent_repository import SubAgentRepository
from yuxi.agents.subagents.repository import SubAgentRepository
mock_db = AsyncMock()
mock_result = MagicMock()
@ -357,7 +357,7 @@ class TestSubAgentRepository:
@pytest.mark.asyncio
async def test_get_by_slug_found(self):
from yuxi.repositories.subagent_repository import SubAgentRepository
from yuxi.agents.subagents.repository import SubAgentRepository
mock_db = AsyncMock()
mock_result = MagicMock()
@ -384,7 +384,7 @@ class TestSubAgentRepository:
@pytest.mark.asyncio
async def test_get_by_slug_not_found(self):
from yuxi.repositories.subagent_repository import SubAgentRepository
from yuxi.agents.subagents.repository import SubAgentRepository
mock_db = AsyncMock()
mock_result = MagicMock()
@ -398,7 +398,7 @@ class TestSubAgentRepository:
@pytest.mark.asyncio
async def test_update_can_clear_model_when_provided(self):
from yuxi.repositories.subagent_repository import SubAgentRepository
from yuxi.agents.subagents.repository import SubAgentRepository
mock_db = AsyncMock()
repo = SubAgentRepository(mock_db)
@ -438,7 +438,7 @@ class TestSubAgentRepository:
class TestSubAgentService:
@pytest.mark.asyncio
async def test_init_builtin_subagents_creates_agents(self, monkeypatch):
from yuxi.services import subagent_service as service_module
from yuxi.agents.subagents import service as service_module
created_agents = []
@ -479,7 +479,7 @@ class TestSubAgentService:
@pytest.mark.asyncio
async def test_get_subagent_specs_returns_list(self, monkeypatch):
from yuxi.services import subagent_service as service_module
from yuxi.agents.subagents import service as service_module
mock_spec = {
"slug": "test-agent",
@ -513,7 +513,7 @@ class TestSubAgentService:
@pytest.mark.asyncio
async def test_get_subagent_specs_returns_defensive_copy(self, monkeypatch):
from yuxi.services import subagent_service as service_module
from yuxi.agents.subagents import service as service_module
service_module._subagent_specs_cache = [
{
@ -611,7 +611,7 @@ class TestSubAgentModel:
class TestDeepAgentSubagentSelection:
@pytest.mark.asyncio
async def test_get_subagents_from_slugs_filters_and_resolves_tools(self, monkeypatch):
from yuxi.services import subagent_service as service_module
from yuxi.agents.subagents import service as service_module
async def fake_get_specs(_db=None):
return [
@ -644,7 +644,7 @@ class TestDeepAgentSubagentSelection:
@pytest.mark.asyncio
async def test_get_subagents_from_slugs_none_selects_none(self, monkeypatch):
from yuxi.services import subagent_service as service_module
from yuxi.agents.subagents import service as service_module
async def fake_get_specs(_db=None):
return [

View File

@ -13,23 +13,24 @@ async def _fake_normalize_agent_context_config(context, **_kwargs):
return dict(context or {})
class _FakeAgentConfigRepo:
def __init__(self, _db):
pass
async def get_by_id(self, config_id: int):
return SimpleNamespace(id=config_id, uid="user-1", agent_id="test-agent")
async def get_or_create_default(self, *, uid: str, agent_id: str, created_by: str):
return SimpleNamespace(id=999, uid=uid, agent_id=agent_id, created_by=created_by)
class _FakeConvRepo:
def __init__(self, _db):
self.saved_messages: list[dict] = []
self.bound_agent_configs: list[tuple[str, int]] = []
self.conversations: dict[str, SimpleNamespace] = {}
def _conversation(self, thread_id: str) -> SimpleNamespace:
return self.conversations.setdefault(
thread_id,
SimpleNamespace(
id=1,
uid="user-1",
agent_id="test-agent",
thread_id=thread_id,
status="active",
extra_metadata={},
),
)
async def add_message_by_thread_id(
self,
*,
@ -53,25 +54,25 @@ class _FakeConvRepo:
return SimpleNamespace(id=1)
async def get_conversation_by_thread_id(self, thread_id: str):
return self.conversations.get(thread_id)
return self._conversation(thread_id)
async def create_conversation(self, *, uid: str, agent_id: str, thread_id: str):
async def create_conversation(self, *, uid: str, agent_id: str, thread_id: str, metadata: dict | None = None):
conversation = SimpleNamespace(
id=1,
uid=uid,
agent_id=agent_id,
thread_id=thread_id,
extra_metadata={},
status="active",
extra_metadata=metadata or {},
)
self.conversations[thread_id] = conversation
return conversation
async def bind_agent_config(self, thread_id: str, agent_config_id: int):
conversation = self.conversations.setdefault(
thread_id,
SimpleNamespace(uid="user-1", agent_id="test-agent", thread_id=thread_id, extra_metadata={}),
)
conversation.extra_metadata["agent_config_id"] = agent_config_id
self.bound_agent_configs.append((thread_id, agent_config_id))
async def get_attachments_by_request_id(self, conversation_id: int, request_id: str):
return []
async def bind_attachments_to_request(self, conversation_id: int, request_id: str, file_ids: list[str]):
return []
@pytest.mark.asyncio
@ -94,8 +95,8 @@ async def test_stream_agent_chat_passes_langfuse_callbacks_and_persists_trace_in
return FakeGraph()
async def fake_get_agent_config_by_id(db, user, agent_config_id):
return SimpleNamespace(agent_id="test-agent", config_json={"context": {"temperature": 0.1}})
async def fake_resolve_agent_runtime(**_kwargs):
return SimpleNamespace(slug="test-agent", backend_id="ChatbotAgent"), FakeAgent(), {"temperature": 0.1}
async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict, trace_info):
calls["saved_state"] = {
@ -115,11 +116,9 @@ async def test_stream_agent_chat_passes_langfuse_callbacks_and_persists_trace_in
yield None
return
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime)
monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config)
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo)
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
monkeypatch.setattr(svc.content_guard, "check_with_keywords", fake_guard_check_with_keywords)
@ -147,7 +146,7 @@ async def test_stream_agent_chat_passes_langfuse_callbacks_and_persists_trace_in
chunks = []
async for chunk in svc.stream_agent_chat(
query="hello",
agent_config_id=123,
agent_id="test-agent",
thread_id="thread-1",
meta={"request_id": "req-1"},
image_content=None,
@ -192,8 +191,8 @@ async def test_stream_agent_chat_emits_realtime_agent_state_from_values(monkeypa
async def get_graph(self):
return FakeGraph()
async def fake_get_agent_config_by_id(db, user, agent_config_id):
return SimpleNamespace(agent_id="test-agent", config_json={"context": {}})
async def fake_resolve_agent_runtime(**_kwargs):
return SimpleNamespace(slug="test-agent", backend_id="ChatbotAgent"), FakeAgent(), {}
async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict, trace_info):
return None
@ -209,11 +208,9 @@ async def test_stream_agent_chat_emits_realtime_agent_state_from_values(monkeypa
yield None
return
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime)
monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config)
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo)
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
monkeypatch.setattr(svc.content_guard, "check_with_keywords", fake_guard_check_with_keywords)
@ -229,7 +226,7 @@ async def test_stream_agent_chat_emits_realtime_agent_state_from_values(monkeypa
chunks = []
async for chunk in svc.stream_agent_chat(
query="hello",
agent_config_id=123,
agent_id="test-agent",
thread_id="thread-1",
meta={"request_id": "req-1"},
image_content=None,

View File

@ -16,23 +16,24 @@ async def _fake_normalize_agent_context_config(context, **_kwargs):
return dict(context or {})
class _FakeAgentConfigRepo:
def __init__(self, _db):
pass
async def get_by_id(self, config_id: int):
return SimpleNamespace(id=config_id, uid="user-1", agent_id="test-agent")
async def get_or_create_default(self, *, uid: str, agent_id: str, created_by: str):
return SimpleNamespace(id=999, uid=uid, agent_id=agent_id, created_by=created_by)
class _FakeConvRepo:
def __init__(self, _db):
self.saved_messages: list[dict] = []
self.bound_agent_configs: list[tuple[str, int]] = []
self.conversations: dict[str, SimpleNamespace] = {}
def _conversation(self, thread_id: str) -> SimpleNamespace:
return self.conversations.setdefault(
thread_id,
SimpleNamespace(
id=1,
uid="user-1",
agent_id="test-agent",
thread_id=thread_id,
status="active",
extra_metadata={},
),
)
async def add_message_by_thread_id(
self,
*,
@ -56,25 +57,25 @@ class _FakeConvRepo:
return SimpleNamespace(id=1)
async def get_conversation_by_thread_id(self, thread_id: str):
return self.conversations.get(thread_id)
return self._conversation(thread_id)
async def create_conversation(self, *, uid: str, agent_id: str, thread_id: str):
async def create_conversation(self, *, uid: str, agent_id: str, thread_id: str, metadata: dict | None = None):
conversation = SimpleNamespace(
id=1,
uid=uid,
agent_id=agent_id,
thread_id=thread_id,
extra_metadata={},
status="active",
extra_metadata=metadata or {},
)
self.conversations[thread_id] = conversation
return conversation
async def bind_agent_config(self, thread_id: str, agent_config_id: int):
conversation = self.conversations.setdefault(
thread_id,
SimpleNamespace(uid="user-1", agent_id="test-agent", thread_id=thread_id, extra_metadata={}),
)
conversation.extra_metadata["agent_config_id"] = agent_config_id
self.bound_agent_configs.append((thread_id, agent_config_id))
async def get_attachments_by_request_id(self, conversation_id: int, request_id: str):
return []
async def bind_attachments_to_request(self, conversation_id: int, request_id: str, file_ids: list[str]):
return []
@pytest.mark.asyncio
@ -101,10 +102,8 @@ async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monk
async def get_graph(self):
return FakeGraph()
async def fake_get_agent_config_by_id(db, user, agent_config_id):
assert user.uid == "user-1"
assert agent_config_id == 123
return SimpleNamespace(agent_id="test-agent", config_json={"context": {"temperature": 0.1}})
async def fake_resolve_agent_runtime(**_kwargs):
return SimpleNamespace(slug="test-agent", backend_id="ChatbotAgent"), FakeAgent(), {"temperature": 0.1}
async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict, trace_info):
calls["saved_state"] = {
@ -135,17 +134,15 @@ async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monk
monkeypatch.setattr(svc, "flush_langfuse", lambda: calls.setdefault("flushed", True))
monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt)
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime)
monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config)
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo)
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
result = await svc.agent_chat(
query="hello",
agent_config_id=123,
agent_id="test-agent",
thread_id="thread-1",
meta={"request_id": "req-1"},
image_content=None,
@ -176,7 +173,6 @@ async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monk
"langfuse_trace_id": "trace-runtime",
"langfuse_session_id": "thread-1",
}
assert calls["saved_state"]["conv_repo"].bound_agent_configs == [("thread-1", 123)]
assert calls["flushed"] is True
@ -203,8 +199,8 @@ async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(mo
async def get_graph(self):
return FakeGraph()
async def fake_get_agent_config_by_id(db, user, agent_config_id):
return SimpleNamespace(agent_id="test-agent", config_json={"context": {}})
async def fake_resolve_agent_runtime(**_kwargs):
return SimpleNamespace(slug="test-agent", backend_id="ChatbotAgent"), FakeAgent(), {}
async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict, trace_info):
return None
@ -221,17 +217,15 @@ async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(mo
monkeypatch.setattr(svc, "flush_langfuse", lambda: None)
monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt)
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime)
monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config)
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo)
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
result = await svc.agent_chat(
query="hello",
agent_config_id=456,
agent_id="test-agent",
thread_id="thread-2",
meta={"request_id": "req-2"},
image_content=None,

View File

@ -76,6 +76,7 @@ async def test_sync_thread_attachment_state_updates_graph(monkeypatch: pytest.Mo
thread_id="thread-1",
uid="u1",
agent_id="ChatbotAgent",
backend_id=None,
attachments=attachments,
)
@ -97,6 +98,7 @@ async def test_sync_thread_attachment_state_skips_when_agent_missing(monkeypatch
thread_id="thread-1",
uid="u1",
agent_id="MissingAgent",
backend_id=None,
attachments=[],
)

View File

@ -41,7 +41,7 @@ def test_build_run_context_includes_trace_metadata(monkeypatch):
agent_id="agent-a",
request_id="req-1",
operation="agent_chat_stream",
agent_config_id=42,
backend_id="ChatbotAgent",
message_type="text",
username="alice",
login_user_id="alice-login",
@ -53,7 +53,7 @@ def test_build_run_context_includes_trace_metadata(monkeypatch):
assert run_context.callbacks[0].trace_context == {"trace_id": "trace-req-1"}
assert run_context.metadata["langfuse_user_id"] == "user-1"
assert run_context.metadata["langfuse_session_id"] == "thread-1"
assert run_context.metadata["agent_config_id"] == "42"
assert run_context.metadata["backend_id"] == "ChatbotAgent"
assert run_context.metadata["department_id"] == "7"
assert run_context.tags == [
"yuxi",

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from types import SimpleNamespace
from yuxi.services import mcp_service
from yuxi.agents.mcp import service as mcp_service
class _FakeClient:

View File

@ -1,4 +1,4 @@
from yuxi.services.model_cache import ModelCache
from yuxi.models.providers.cache import ModelCache
def test_model_cache_prefers_model_base_url_override(monkeypatch):

View File

@ -4,8 +4,8 @@ import pytest
os.environ.setdefault("OPENAI_API_KEY", "test-key")
from yuxi.config.builtin_providers import BUILTIN_PROVIDERS
from yuxi.services.model_provider_service import (
from yuxi.models.providers.builtin import BUILTIN_PROVIDERS
from yuxi.models.providers.service import (
check_credential_status,
_normalize_payload,
_normalize_remote_model,
@ -96,7 +96,7 @@ async def test_fetch_remote_models_loads_embedding_only_when_capability_enabled(
calls.append((endpoint, model_type))
return [{"id": f"{model_type}-model", "type": model_type}]
monkeypatch.setattr("yuxi.services.model_provider_service._fetch_models_from_endpoint", fake_fetch)
monkeypatch.setattr("yuxi.models.providers.service._fetch_models_from_endpoint", fake_fetch)
class Provider:
base_url = "https://example.com/v1"
@ -167,12 +167,13 @@ def test_builtin_dashscope_provider_includes_default_embedding_and_rerank_models
assert "embedding_models_endpoint" not in provider
assert "rerank_models_endpoint" not in provider
assert models["text-embedding-v4"]["type"] == "embedding"
assert models["text-embedding-v4"]["dimension"] == 2048
assert models["text-embedding-v4"]["dimension"] == 1024
assert models["qwen3-rerank"]["type"] == "rerank"
def testcheck_credential_status_disabled_provider_always_ok():
"""未启用的 provider 无论凭证如何配置,状态始终为 ok。"""
class Provider:
is_enabled = False
api_key = None
@ -183,6 +184,7 @@ def testcheck_credential_status_disabled_provider_always_ok():
def testcheck_credential_status_direct_api_key_ok():
"""直接配置了 api_key 的启用 provider 状态为 ok。"""
class Provider:
is_enabled = True
api_key = "sk-test"
@ -217,6 +219,7 @@ def testcheck_credential_status_env_key_missing_warning(monkeypatch):
def testcheck_credential_status_both_empty_warning():
"""api_key 和 api_key_env 都未配置时状态为 warning。"""
class Provider:
is_enabled = True
api_key = None
@ -250,9 +253,7 @@ def test_normalize_payload_accepts_manual_source():
"display_name": "Custom Local",
"base_url": "https://example.com/v1",
"capabilities": ["chat"],
"enabled_models": [
{"id": "my-chat-model", "type": "chat", "source": "manual"}
],
"enabled_models": [{"id": "my-chat-model", "type": "chat", "source": "manual"}],
}
)
@ -267,9 +268,7 @@ def test_normalize_payload_rejects_invalid_source():
"provider_id": "custom-local",
"display_name": "Custom Local",
"base_url": "https://example.com/v1",
"enabled_models": [
{"id": "x", "type": "chat", "source": "custom"}
],
"enabled_models": [{"id": "x", "type": "chat", "source": "custom"}],
}
)
@ -283,9 +282,7 @@ def test_normalize_payload_rejects_model_type_not_in_capabilities():
"display_name": "Chat Only",
"base_url": "https://example.com/v1",
"capabilities": ["chat"],
"enabled_models": [
{"id": "rogue-embedding", "type": "embedding", "dimension": 1024}
],
"enabled_models": [{"id": "rogue-embedding", "type": "embedding", "dimension": 1024}],
}
)

View File

@ -4,7 +4,7 @@ from yuxi.agents.models import load_chat_model
from yuxi.models.chat import select_model
from yuxi.models.embed import OtherEmbedding, select_embedding_model
from yuxi.models.rerank import OpenAIReranker, get_reranker
from yuxi.services.model_cache import ModelInfo
from yuxi.models.providers.cache import ModelInfo
def _model_info(model_type: str) -> ModelInfo:

View File

@ -8,8 +8,8 @@ from types import SimpleNamespace
import pytest
from yuxi.services import skill_service as svc
from yuxi.services import tool_service
from yuxi.agents.skills import service as svc
from yuxi.agents.toolkits import service as tool_service
from yuxi.storage.postgres.models_business import Skill, User

Some files were not shown because too many files have changed in this diff Show More