refactor: 简化代码逻辑
This commit is contained in:
parent
afdb5fbb02
commit
5322b21c32
@ -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` 分层组织。新增或修改后端行为时,测试应落在最能覆盖风险的那一层。
|
||||
|
||||
10
REFACTOR.md
10
REFACTOR.md
@ -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 里面
|
||||
- [ ] 点开对话的时候要能够自动定位到尾部,而不是最开始。
|
||||
- [ ]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"] = [
|
||||
|
||||
0
backend/package/yuxi/agents/mcp/__init__.py
Normal file
0
backend/package/yuxi/agents/mcp/__init__.py
Normal file
@ -1,5 +1,7 @@
|
||||
"""MCP 服务器数据访问层 - Repository"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
@ -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
|
||||
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(
|
||||
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:
|
||||
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)
|
||||
logger.info(f"Added built-in MCP server '{slug}' to database")
|
||||
else:
|
||||
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:
|
||||
existing.updated_by = "system"
|
||||
# Commit if any new servers were added (check session state)
|
||||
if session.new:
|
||||
await session.commit()
|
||||
elif session.dirty:
|
||||
await session.commit()
|
||||
)
|
||||
any_changed = True
|
||||
logger.info(f"Added built-in MCP server '{slug}' to database")
|
||||
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)
|
||||
server_changed = True
|
||||
if server_changed:
|
||||
existing.updated_by = "system"
|
||||
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 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
0
backend/package/yuxi/agents/skills/__init__.py
Normal file
0
backend/package/yuxi/agents/skills/__init__.py
Normal 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,
|
||||
*,
|
||||
0
backend/package/yuxi/agents/subagents/__init__.py
Normal file
0
backend/package/yuxi/agents/subagents/__init__.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
@ -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 {}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
0
backend/package/yuxi/models/providers/__init__.py
Normal file
0
backend/package/yuxi/models/providers/__init__.py
Normal 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] = {}
|
||||
|
||||
@ -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:
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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] = []
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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}"
|
||||
|
||||
|
||||
18
backend/package/yuxi/services/operation_log_service.py
Normal file
18
backend/package/yuxi/services/operation_log_service.py
Normal 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
|
||||
@ -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 未生成预期的技能目录")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
59
backend/package/yuxi/services/user_identity_service.py
Normal file
59
backend/package/yuxi/services/user_identity_service.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
15
backend/package/yuxi/storage/neo4j/__init__.py
Normal file
15
backend/package/yuxi/storage/neo4j/__init__.py
Normal 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",
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -86,4 +87,4 @@ def get_shared_neo4j_connection() -> Neo4jConnectionManager:
|
||||
with _shared_neo4j_connection_lock:
|
||||
if _shared_neo4j_connection is None or not _shared_neo4j_connection.driver:
|
||||
_shared_neo4j_connection = Neo4jConnectionManager()
|
||||
return _shared_neo4j_connection
|
||||
return _shared_neo4j_connection
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
@ -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]
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}")
|
||||
|
||||
|
||||
|
||||
@ -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)}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,21 +90,19 @@ 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(
|
||||
{
|
||||
"name": getattr(s, "name", ""),
|
||||
"description": getattr(s, "description", None),
|
||||
"icon": getattr(s, "icon", None),
|
||||
"enabled": bool(getattr(s, "enabled", True)),
|
||||
"tags": getattr(s, "tags", None) or [],
|
||||
}
|
||||
)
|
||||
return {"success": True, "data": data}
|
||||
|
||||
data = []
|
||||
for s in servers:
|
||||
data.append(
|
||||
{
|
||||
"name": getattr(s, "name", ""),
|
||||
"description": getattr(s, "description", None),
|
||||
"icon": getattr(s, "icon", None),
|
||||
"enabled": bool(getattr(s, "enabled", True)),
|
||||
"tags": getattr(s, "tags", None) or [],
|
||||
}
|
||||
)
|
||||
return {"success": True, "data": data}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP servers: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
# 使用统一的健康检查接口
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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=[],
|
||||
)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user