diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cc969617..3f8a6997 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` 分层组织。新增或修改后端行为时,测试应落在最能覆盖风险的那一层。 diff --git a/REFACTOR.md b/REFACTOR.md index 2c4eff51..498640d1 100644 --- a/REFACTOR.md +++ b/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 里面 +- [ ] 点开对话的时候要能够自动定位到尾部,而不是最开始。 +- [ ] diff --git a/backend/package/yuxi/agents/__init__.py b/backend/package/yuxi/agents/__init__.py index dd7174a4..7011018f 100644 --- a/backend/package/yuxi/agents/__init__.py +++ b/backend/package/yuxi/agents/__init__.py @@ -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 diff --git a/backend/package/yuxi/agents/backends/composite.py b/backend/package/yuxi/agents/backends/composite.py index 0ac1e37d..bb9a9977 100644 --- a/backend/package/yuxi/agents/backends/composite.py +++ b/backend/package/yuxi/agents/backends/composite.py @@ -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 diff --git a/backend/package/yuxi/agents/backends/sandbox/backend.py b/backend/package/yuxi/agents/backends/sandbox/backend.py index f0d92987..9e489dfd 100644 --- a/backend/package/yuxi/agents/backends/sandbox/backend.py +++ b/backend/package/yuxi/agents/backends/sandbox/backend.py @@ -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 diff --git a/backend/package/yuxi/agents/backends/skills_backend.py b/backend/package/yuxi/agents/backends/skills_backend.py index 56d7d9fd..832fb396 100644 --- a/backend/package/yuxi/agents/backends/skills_backend.py +++ b/backend/package/yuxi/agents/backends/skills_backend.py @@ -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): diff --git a/backend/package/yuxi/agents/buildin/__init__.py b/backend/package/yuxi/agents/buildin/__init__.py index af375efc..b93a6d1a 100644 --- a/backend/package/yuxi/agents/buildin/__init__.py +++ b/backend/package/yuxi/agents/buildin/__init__.py @@ -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 diff --git a/backend/package/yuxi/agents/buildin/chatbot/graph.py b/backend/package/yuxi/agents/buildin/chatbot/graph.py index 520a850b..67cb9416 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/graph.py +++ b/backend/package/yuxi/agents/buildin/chatbot/graph.py @@ -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 diff --git a/backend/package/yuxi/agents/buildin/deep_agent/graph.py b/backend/package/yuxi/agents/buildin/deep_agent/graph.py index e4036a08..3208cb18 100644 --- a/backend/package/yuxi/agents/buildin/deep_agent/graph.py +++ b/backend/package/yuxi/agents/buildin/deep_agent/graph.py @@ -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 diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index 0306e5c9..c8106fbd 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -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"] = [ diff --git a/backend/package/yuxi/agents/mcp/__init__.py b/backend/package/yuxi/agents/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/package/yuxi/repositories/mcp_server_repository.py b/backend/package/yuxi/agents/mcp/repository.py similarity index 98% rename from backend/package/yuxi/repositories/mcp_server_repository.py rename to backend/package/yuxi/agents/mcp/repository.py index 6ada7695..d58b3be1 100644 --- a/backend/package/yuxi/repositories/mcp_server_repository.py +++ b/backend/package/yuxi/agents/mcp/repository.py @@ -1,5 +1,7 @@ """MCP 服务器数据访问层 - Repository""" +from __future__ import annotations + from typing import Any from sqlalchemy import select diff --git a/backend/package/yuxi/services/mcp_service.py b/backend/package/yuxi/agents/mcp/service.py similarity index 85% rename from backend/package/yuxi/services/mcp_service.py rename to backend/package/yuxi/agents/mcp/service.py index be050bea..27ded5a6 100644 --- a/backend/package/yuxi/services/mcp_service.py +++ b/backend/package/yuxi/agents/mcp/service.py @@ -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 === # ============================================================================= diff --git a/backend/package/yuxi/agents/middlewares/dynamic_tool_middleware.py b/backend/package/yuxi/agents/middlewares/dynamic_tool_middleware.py index 4f8c3aac..f96f613c 100644 --- a/backend/package/yuxi/agents/middlewares/dynamic_tool_middleware.py +++ b/backend/package/yuxi/agents/middlewares/dynamic_tool_middleware.py @@ -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 diff --git a/backend/package/yuxi/agents/middlewares/skills_middleware.py b/backend/package/yuxi/agents/middlewares/skills_middleware.py index 98e8e91c..6856b573 100644 --- a/backend/package/yuxi/agents/middlewares/skills_middleware.py +++ b/backend/package/yuxi/agents/middlewares/skills_middleware.py @@ -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 diff --git a/backend/package/yuxi/agents/models.py b/backend/package/yuxi/agents/models.py index a6788027..be86bc4e 100644 --- a/backend/package/yuxi/agents/models.py +++ b/backend/package/yuxi/agents/models.py @@ -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 diff --git a/backend/package/yuxi/agents/skills/__init__.py b/backend/package/yuxi/agents/skills/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/package/yuxi/repositories/skill_repository.py b/backend/package/yuxi/agents/skills/repository.py similarity index 100% rename from backend/package/yuxi/repositories/skill_repository.py rename to backend/package/yuxi/agents/skills/repository.py diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/agents/skills/service.py similarity index 97% rename from backend/package/yuxi/services/skill_service.py rename to backend/package/yuxi/agents/skills/service.py index 0c19c170..2d18abf2 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/agents/skills/service.py @@ -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, *, diff --git a/backend/package/yuxi/agents/subagents/__init__.py b/backend/package/yuxi/agents/subagents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/package/yuxi/repositories/subagent_repository.py b/backend/package/yuxi/agents/subagents/repository.py similarity index 100% rename from backend/package/yuxi/repositories/subagent_repository.py rename to backend/package/yuxi/agents/subagents/repository.py diff --git a/backend/package/yuxi/services/subagent_service.py b/backend/package/yuxi/agents/subagents/service.py similarity index 99% rename from backend/package/yuxi/services/subagent_service.py rename to backend/package/yuxi/agents/subagents/service.py index 0d5d7943..c31729e4 100644 --- a/backend/package/yuxi/services/subagent_service.py +++ b/backend/package/yuxi/agents/subagents/service.py @@ -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 diff --git a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py index 67759e06..fc1567ac 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py +++ b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py @@ -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, diff --git a/backend/package/yuxi/services/tool_service.py b/backend/package/yuxi/agents/toolkits/service.py similarity index 98% rename from backend/package/yuxi/services/tool_service.py rename to backend/package/yuxi/agents/toolkits/service.py index 3b14cecb..73b606a1 100644 --- a/backend/package/yuxi/services/tool_service.py +++ b/backend/package/yuxi/agents/toolkits/service.py @@ -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() diff --git a/backend/package/yuxi/knowledge/base.py b/backend/package/yuxi/knowledge/base.py index 0d693729..b20860e5 100644 --- a/backend/package/yuxi/knowledge/base.py +++ b/backend/package/yuxi/knowledge/base.py @@ -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 {} diff --git a/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py b/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py index fb7a2367..57ff2372 100644 --- a/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py +++ b/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py @@ -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, diff --git a/backend/package/yuxi/knowledge/graphs/milvus_graph_vector_store.py b/backend/package/yuxi/knowledge/graphs/milvus_graph_vector_store.py index 0729fd57..d27ebc7c 100644 --- a/backend/package/yuxi/knowledge/graphs/milvus_graph_vector_store.py +++ b/backend/package/yuxi/knowledge/graphs/milvus_graph_vector_store.py @@ -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 diff --git a/backend/package/yuxi/knowledge/implementations/milvus.py b/backend/package/yuxi/knowledge/implementations/milvus.py index 5bda99bd..3869a2b1 100644 --- a/backend/package/yuxi/knowledge/implementations/milvus.py +++ b/backend/package/yuxi/knowledge/implementations/milvus.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/__init__.py b/backend/package/yuxi/knowledge/parser/__init__.py similarity index 78% rename from backend/package/yuxi/plugins/parser/__init__.py rename to backend/package/yuxi/knowledge/parser/__init__.py index ddc49fc6..7637852b 100644 --- a/backend/package/yuxi/plugins/parser/__init__.py +++ b/backend/package/yuxi/knowledge/parser/__init__.py @@ -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, diff --git a/backend/package/yuxi/plugins/parser/base.py b/backend/package/yuxi/knowledge/parser/base.py similarity index 100% rename from backend/package/yuxi/plugins/parser/base.py rename to backend/package/yuxi/knowledge/parser/base.py diff --git a/backend/package/yuxi/plugins/parser/deepseek_ocr.py b/backend/package/yuxi/knowledge/parser/deepseek_ocr.py similarity index 98% rename from backend/package/yuxi/plugins/parser/deepseek_ocr.py rename to backend/package/yuxi/knowledge/parser/deepseek_ocr.py index dc7bacbd..a0f5a7a4 100644 --- a/backend/package/yuxi/plugins/parser/deepseek_ocr.py +++ b/backend/package/yuxi/knowledge/parser/deepseek_ocr.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/factory.py b/backend/package/yuxi/knowledge/parser/factory.py similarity index 90% rename from backend/package/yuxi/plugins/parser/factory.py rename to backend/package/yuxi/knowledge/parser/factory.py index d6ab1e97..0d45369b 100644 --- a/backend/package/yuxi/plugins/parser/factory.py +++ b/backend/package/yuxi/knowledge/parser/factory.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/mineru.py b/backend/package/yuxi/knowledge/parser/mineru.py similarity index 98% rename from backend/package/yuxi/plugins/parser/mineru.py rename to backend/package/yuxi/knowledge/parser/mineru.py index 6648f72d..e47c796b 100644 --- a/backend/package/yuxi/plugins/parser/mineru.py +++ b/backend/package/yuxi/knowledge/parser/mineru.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/mineru_official.py b/backend/package/yuxi/knowledge/parser/mineru_official.py similarity index 99% rename from backend/package/yuxi/plugins/parser/mineru_official.py rename to backend/package/yuxi/knowledge/parser/mineru_official.py index 2e530b71..e5d0d7c7 100644 --- a/backend/package/yuxi/plugins/parser/mineru_official.py +++ b/backend/package/yuxi/knowledge/parser/mineru_official.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/pp_structure_v3.py b/backend/package/yuxi/knowledge/parser/pp_structure_v3.py similarity index 99% rename from backend/package/yuxi/plugins/parser/pp_structure_v3.py rename to backend/package/yuxi/knowledge/parser/pp_structure_v3.py index 44951ac5..223ed9d1 100644 --- a/backend/package/yuxi/plugins/parser/pp_structure_v3.py +++ b/backend/package/yuxi/knowledge/parser/pp_structure_v3.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/rapid_ocr.py b/backend/package/yuxi/knowledge/parser/rapid_ocr.py similarity index 99% rename from backend/package/yuxi/plugins/parser/rapid_ocr.py rename to backend/package/yuxi/knowledge/parser/rapid_ocr.py index 071041e6..5f0a9a72 100644 --- a/backend/package/yuxi/plugins/parser/rapid_ocr.py +++ b/backend/package/yuxi/knowledge/parser/rapid_ocr.py @@ -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 diff --git a/backend/package/yuxi/plugins/parser/unified.py b/backend/package/yuxi/knowledge/parser/unified.py similarity index 97% rename from backend/package/yuxi/plugins/parser/unified.py rename to backend/package/yuxi/knowledge/parser/unified.py index b25e1aa1..aca5cc85 100644 --- a/backend/package/yuxi/plugins/parser/unified.py +++ b/backend/package/yuxi/knowledge/parser/unified.py @@ -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) diff --git a/backend/package/yuxi/plugins/parser/zip_utils.py b/backend/package/yuxi/knowledge/parser/zip_utils.py similarity index 100% rename from backend/package/yuxi/plugins/parser/zip_utils.py rename to backend/package/yuxi/knowledge/parser/zip_utils.py diff --git a/backend/package/yuxi/models/chat.py b/backend/package/yuxi/models/chat.py index 288c39ba..4270bb34 100644 --- a/backend/package/yuxi/models/chat.py +++ b/backend/package/yuxi/models/chat.py @@ -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 diff --git a/backend/package/yuxi/models/embed.py b/backend/package/yuxi/models/embed.py index b9f31a5a..d4204a91 100644 --- a/backend/package/yuxi/models/embed.py +++ b/backend/package/yuxi/models/embed.py @@ -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 diff --git a/backend/package/yuxi/models/providers/__init__.py b/backend/package/yuxi/models/providers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/package/yuxi/config/builtin_providers.py b/backend/package/yuxi/models/providers/builtin.py similarity index 100% rename from backend/package/yuxi/config/builtin_providers.py rename to backend/package/yuxi/models/providers/builtin.py diff --git a/backend/package/yuxi/services/model_cache.py b/backend/package/yuxi/models/providers/cache.py similarity index 99% rename from backend/package/yuxi/services/model_cache.py rename to backend/package/yuxi/models/providers/cache.py index b7f3cb9b..8bb4fce7 100644 --- a/backend/package/yuxi/services/model_cache.py +++ b/backend/package/yuxi/models/providers/cache.py @@ -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] = {} diff --git a/backend/package/yuxi/repositories/model_provider_repository.py b/backend/package/yuxi/models/providers/repository.py similarity index 100% rename from backend/package/yuxi/repositories/model_provider_repository.py rename to backend/package/yuxi/models/providers/repository.py diff --git a/backend/package/yuxi/services/model_provider_service.py b/backend/package/yuxi/models/providers/service.py similarity index 95% rename from backend/package/yuxi/services/model_provider_service.py rename to backend/package/yuxi/models/providers/service.py index 92a45f43..0b8d1c8b 100644 --- a/backend/package/yuxi/services/model_provider_service.py +++ b/backend/package/yuxi/models/providers/service.py @@ -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: diff --git a/backend/package/yuxi/models/rerank.py b/backend/package/yuxi/models/rerank.py index 1b2c8275..6c9e7447 100644 --- a/backend/package/yuxi/models/rerank.py +++ b/backend/package/yuxi/models/rerank.py @@ -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 diff --git a/backend/package/yuxi/plugins/__init__.py b/backend/package/yuxi/plugins/__init__.py index e8cf5a43..c9c2ef67 100644 --- a/backend/package/yuxi/plugins/__init__.py +++ b/backend/package/yuxi/plugins/__init__.py @@ -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] = [] diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index 3b5ed5e8..aeac4c0c 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -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) diff --git a/backend/package/yuxi/services/conversation_service.py b/backend/package/yuxi/services/conversation_service.py index 6a46c89f..a8f636cb 100644 --- a/backend/package/yuxi/services/conversation_service.py +++ b/backend/package/yuxi/services/conversation_service.py @@ -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, diff --git a/backend/package/yuxi/services/feedback_service.py b/backend/package/yuxi/services/feedback_service.py index 3541962c..62124a04 100644 --- a/backend/package/yuxi/services/feedback_service.py +++ b/backend/package/yuxi/services/feedback_service.py @@ -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)}") diff --git a/backend/package/yuxi/services/filesystem_service.py b/backend/package/yuxi/services/filesystem_service.py index a6a1169b..95664375 100644 --- a/backend/package/yuxi/services/filesystem_service.py +++ b/backend/package/yuxi/services/filesystem_service.py @@ -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, diff --git a/backend/package/yuxi/plugins/guard.py b/backend/package/yuxi/services/guard.py similarity index 100% rename from backend/package/yuxi/plugins/guard.py rename to backend/package/yuxi/services/guard.py diff --git a/backend/package/yuxi/services/langfuse_service.py b/backend/package/yuxi/services/langfuse_service.py index 842df93a..3eef9649 100644 --- a/backend/package/yuxi/services/langfuse_service.py +++ b/backend/package/yuxi/services/langfuse_service.py @@ -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"), diff --git a/backend/package/yuxi/services/oidc_service.py b/backend/package/yuxi/services/oidc_service.py index a4972234..5a8f953b 100644 --- a/backend/package/yuxi/services/oidc_service.py +++ b/backend/package/yuxi/services/oidc_service.py @@ -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}" diff --git a/backend/package/yuxi/services/operation_log_service.py b/backend/package/yuxi/services/operation_log_service.py new file mode 100644 index 00000000..571a4927 --- /dev/null +++ b/backend/package/yuxi/services/operation_log_service.py @@ -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 diff --git a/backend/package/yuxi/services/remote_skill_install_service.py b/backend/package/yuxi/services/remote_skill_install_service.py index c8780035..1b3bada1 100644 --- a/backend/package/yuxi/services/remote_skill_install_service.py +++ b/backend/package/yuxi/services/remote_skill_install_service.py @@ -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 未生成预期的技能目录") diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index 0d3d1cf6..05594074 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -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 diff --git a/backend/package/yuxi/services/thread_files_service.py b/backend/package/yuxi/services/thread_files_service.py index d704aea8..05e5a6d6 100644 --- a/backend/package/yuxi/services/thread_files_service.py +++ b/backend/package/yuxi/services/thread_files_service.py @@ -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} diff --git a/backend/package/yuxi/services/user_identity_service.py b/backend/package/yuxi/services/user_identity_service.py new file mode 100644 index 00000000..71b30de3 --- /dev/null +++ b/backend/package/yuxi/services/user_identity_service.py @@ -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 diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index fb28c911..035dd564 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -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, diff --git a/backend/package/yuxi/storage/neo4j/__init__.py b/backend/package/yuxi/storage/neo4j/__init__.py new file mode 100644 index 00000000..7282290f --- /dev/null +++ b/backend/package/yuxi/storage/neo4j/__init__.py @@ -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", +] diff --git a/backend/package/yuxi/knowledge/graphs/neo4j_utils.py b/backend/package/yuxi/storage/neo4j/manager.py similarity index 96% rename from backend/package/yuxi/knowledge/graphs/neo4j_utils.py rename to backend/package/yuxi/storage/neo4j/manager.py index 1ebddefa..138e9aab 100644 --- a/backend/package/yuxi/knowledge/graphs/neo4j_utils.py +++ b/backend/package/yuxi/storage/neo4j/manager.py @@ -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 \ No newline at end of file + return _shared_neo4j_connection diff --git a/backend/package/yuxi/storage/postgres/manager.py b/backend/package/yuxi/storage/postgres/manager.py index 80e53da8..b9a77502 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -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() diff --git a/backend/server/utils/auth_utils.py b/backend/package/yuxi/utils/auth_utils.py similarity index 79% rename from backend/server/utils/auth_utils.py rename to backend/package/yuxi/utils/auth_utils.py index c71eb200..7b2adc5c 100644 --- a/backend/server/utils/auth_utils.py +++ b/backend/package/yuxi/utils/auth_utils.py @@ -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: diff --git a/backend/server/utils/singleton.py b/backend/package/yuxi/utils/singleton.py similarity index 57% rename from backend/server/utils/singleton.py rename to backend/package/yuxi/utils/singleton.py index 0bfccb07..133c278f 100644 --- a/backend/server/utils/singleton.py +++ b/backend/package/yuxi/utils/singleton.py @@ -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] diff --git a/backend/scripts/seed_initial_users.py b/backend/scripts/seed_initial_users.py index 3094327d..e6afcee4 100644 --- a/backend/scripts/seed_initial_users.py +++ b/backend/scripts/seed_initial_users.py @@ -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 diff --git a/backend/server/routers/agent_router.py b/backend/server/routers/agent_router.py index 46264162..c9ab2e17 100644 --- a/backend/server/routers/agent_router.py +++ b/backend/server/routers/agent_router.py @@ -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, diff --git a/backend/server/routers/auth_dept_router.py b/backend/server/routers/auth_dept_router.py index fa84e809..91aab2f6 100644 --- a/backend/server/routers/auth_dept_router.py +++ b/backend/server/routers/auth_dept_router.py @@ -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"]) diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index 147b4ba3..5e10a8e9 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -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, diff --git a/backend/server/routers/dashboard_router.py b/backend/server/routers/dashboard_router.py index 98cb034a..c0703a37 100644 --- a/backend/server/routers/dashboard_router.py +++ b/backend/server/routers/dashboard_router.py @@ -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 diff --git a/backend/server/routers/graph_router.py b/backend/server/routers/graph_router.py index 3b409df6..5959a226 100644 --- a/backend/server/routers/graph_router.py +++ b/backend/server/routers/graph_router.py @@ -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)}") diff --git a/backend/server/routers/knowledge_eval_router.py b/backend/server/routers/knowledge_eval_router.py index e244d5de..f3fbedb1 100644 --- a/backend/server/routers/knowledge_eval_router.py +++ b/backend/server/routers/knowledge_eval_router.py @@ -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)}") diff --git a/backend/server/routers/knowledge_router.py b/backend/server/routers/knowledge_router.py index 40c1171e..57669f01 100644 --- a/backend/server/routers/knowledge_router.py +++ b/backend/server/routers/knowledge_router.py @@ -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 diff --git a/backend/server/routers/mcp_router.py b/backend/server/routers/mcp_router.py index 8c253f8f..34295486 100644 --- a/backend/server/routers/mcp_router.py +++ b/backend/server/routers/mcp_router.py @@ -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, diff --git a/backend/server/routers/model_provider_router.py b/backend/server/routers/model_provider_router.py index c06ef1bd..9ddbddf3 100644 --- a/backend/server/routers/model_provider_router.py +++ b/backend/server/routers/model_provider_router.py @@ -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) diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index 382afae3..43439ee2 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -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 diff --git a/backend/server/routers/subagent_router.py b/backend/server/routers/subagent_router.py index 7b6c0c03..6b4dfb06 100644 --- a/backend/server/routers/subagent_router.py +++ b/backend/server/routers/subagent_router.py @@ -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 diff --git a/backend/server/routers/system_router.py b/backend/server/routers/system_router.py index 637809db..59702ea0 100644 --- a/backend/server/routers/system_router.py +++ b/backend/server/routers/system_router.py @@ -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: # 使用统一的健康检查接口 diff --git a/backend/server/routers/tool_router.py b/backend/server/routers/tool_router.py index cee32c42..7602ea0f 100644 --- a/backend/server/routers/tool_router.py +++ b/backend/server/routers/tool_router.py @@ -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 diff --git a/backend/server/utils/auth_middleware.py b/backend/server/utils/auth_middleware.py index 15f891de..526f8fec 100644 --- a/backend/server/utils/auth_middleware.py +++ b/backend/server/utils/auth_middleware.py @@ -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) diff --git a/backend/server/utils/common_utils.py b/backend/server/utils/common_utils.py index 2eca72d3..bfb96b91 100644 --- a/backend/server/utils/common_utils.py +++ b/backend/server/utils/common_utils.py @@ -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 diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index 40f044a6..2f5852b1 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -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) diff --git a/backend/server/utils/user_utils.py b/backend/server/utils/user_utils.py deleted file mode 100644 index c7639ac1..00000000 --- a/backend/server/utils/user_utils.py +++ /dev/null @@ -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 diff --git a/backend/test/integration/api/test_chat_agent_sync.py b/backend/test/integration/api/test_chat_agent_sync.py index 47b97094..adfd5ec1 100644 --- a/backend/test/integration/api/test_chat_agent_sync.py +++ b/backend/test/integration/api/test_chat_agent_sync.py @@ -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 diff --git a/backend/test/integration/api/test_chat_router.py b/backend/test/integration/api/test_chat_router.py index 36e9acae..b73c10b6 100644 --- a/backend/test/integration/api/test_chat_router.py +++ b/backend/test/integration/api/test_chat_router.py @@ -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): diff --git a/backend/test/integration/services/test_model_provider_runtime_connectivity.py b/backend/test/integration/services/test_model_provider_runtime_connectivity.py index 461aaead..c207a2aa 100644 --- a/backend/test/integration/services/test_model_provider_runtime_connectivity.py +++ b/backend/test/integration/services/test_model_provider_runtime_connectivity.py @@ -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, diff --git a/backend/test/unit/agents/test_context_auth.py b/backend/test/unit/agents/test_context_auth.py index 9fb283fb..d32e4276 100644 --- a/backend/test/unit/agents/test_context_auth.py +++ b/backend/test/unit/agents/test_context_auth.py @@ -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), ) diff --git a/backend/test/unit/agents/toolkits/buildin/test_install_skill.py b/backend/test/unit/agents/toolkits/buildin/test_install_skill.py index b64fc0d2..9195e448 100644 --- a/backend/test/unit/agents/toolkits/buildin/test_install_skill.py +++ b/backend/test/unit/agents/toolkits/buildin/test_install_skill.py @@ -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, diff --git a/backend/test/unit/plugins/test_parser_facade.py b/backend/test/unit/knowledge/test_parser_facade.py similarity index 97% rename from backend/test/unit/plugins/test_parser_facade.py rename to backend/test/unit/knowledge/test_parser_facade.py index b07563b2..9d4ce1f0 100644 --- a/backend/test/unit/plugins/test_parser_facade.py +++ b/backend/test/unit/knowledge/test_parser_facade.py @@ -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" diff --git a/backend/test/unit/routers/test_mcp_router.py b/backend/test/unit/routers/test_mcp_router.py index c2101603..b9704035 100644 --- a/backend/test/unit/routers/test_mcp_router.py +++ b/backend/test/unit/routers/test_mcp_router.py @@ -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", ) diff --git a/backend/test/unit/routers/test_subagent.py b/backend/test/unit/routers/test_subagent.py index e38f3ba9..8ef32c90 100644 --- a/backend/test/unit/routers/test_subagent.py +++ b/backend/test/unit/routers/test_subagent.py @@ -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 [ diff --git a/backend/test/unit/services/test_chat_service_langfuse_stream.py b/backend/test/unit/services/test_chat_service_langfuse_stream.py index b44bb13d..4dd84ef1 100644 --- a/backend/test/unit/services/test_chat_service_langfuse_stream.py +++ b/backend/test/unit/services/test_chat_service_langfuse_stream.py @@ -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, diff --git a/backend/test/unit/services/test_chat_service_sync.py b/backend/test/unit/services/test_chat_service_sync.py index 692c8053..ccaede29 100644 --- a/backend/test/unit/services/test_chat_service_sync.py +++ b/backend/test/unit/services/test_chat_service_sync.py @@ -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, diff --git a/backend/test/unit/services/test_conversation_service_attachment_state.py b/backend/test/unit/services/test_conversation_service_attachment_state.py index afb34c18..4c00fc53 100644 --- a/backend/test/unit/services/test_conversation_service_attachment_state.py +++ b/backend/test/unit/services/test_conversation_service_attachment_state.py @@ -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=[], ) diff --git a/backend/test/unit/services/test_langfuse_service.py b/backend/test/unit/services/test_langfuse_service.py index 2d63e6fd..e98f0fdf 100644 --- a/backend/test/unit/services/test_langfuse_service.py +++ b/backend/test/unit/services/test_langfuse_service.py @@ -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", diff --git a/backend/test/unit/services/test_mcp_service.py b/backend/test/unit/services/test_mcp_service.py index bd38ba83..6482725f 100644 --- a/backend/test/unit/services/test_mcp_service.py +++ b/backend/test/unit/services/test_mcp_service.py @@ -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: diff --git a/backend/test/unit/services/test_model_cache.py b/backend/test/unit/services/test_model_cache.py index 2a6762b0..196f30de 100644 --- a/backend/test/unit/services/test_model_cache.py +++ b/backend/test/unit/services/test_model_cache.py @@ -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): diff --git a/backend/test/unit/services/test_model_provider_service.py b/backend/test/unit/services/test_model_provider_service.py index 0cda2179..bb511dac 100644 --- a/backend/test/unit/services/test_model_provider_service.py +++ b/backend/test/unit/services/test_model_provider_service.py @@ -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}], } ) diff --git a/backend/test/unit/services/test_model_selectors.py b/backend/test/unit/services/test_model_selectors.py index d2d05915..de1b740b 100644 --- a/backend/test/unit/services/test_model_selectors.py +++ b/backend/test/unit/services/test_model_selectors.py @@ -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: diff --git a/backend/test/unit/services/test_skill_service.py b/backend/test/unit/services/test_skill_service.py index 6e756c88..bbcc3df0 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/test_skill_service.py @@ -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 diff --git a/backend/test/unit/services/test_tool_service.py b/backend/test/unit/services/test_tool_service.py index 084bbe31..b2aa9805 100644 --- a/backend/test/unit/services/test_tool_service.py +++ b/backend/test/unit/services/test_tool_service.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import SimpleNamespace -from yuxi.services import tool_service +from yuxi.agents.toolkits import service as tool_service def test_get_tool_metadata_includes_config_guide(monkeypatch): diff --git a/backend/test/unit/storage/test_neo4j_manager.py b/backend/test/unit/storage/test_neo4j_manager.py new file mode 100644 index 00000000..70f39613 --- /dev/null +++ b/backend/test/unit/storage/test_neo4j_manager.py @@ -0,0 +1,30 @@ +import pytest + +from yuxi.storage.neo4j import Neo4jConnectionManager, safe_neo4j_label + + +def test_storage_neo4j_exports_manager(): + import yuxi.storage.neo4j as neo4j_storage + + assert neo4j_storage.Neo4jConnectionManager is Neo4jConnectionManager + assert neo4j_storage.safe_neo4j_label is safe_neo4j_label + + +@pytest.mark.parametrize("label", ["kb_test", "MilvusKB", "_internal_1"]) +def test_safe_neo4j_label_accepts_valid_labels(label): + assert safe_neo4j_label(label) == label + + +@pytest.mark.parametrize("label", ["", "1invalid", "has-dash", "has space", "中文"]) +def test_safe_neo4j_label_rejects_invalid_labels(label): + with pytest.raises(ValueError, match="非法 Neo4j 标签"): + safe_neo4j_label(label) + + +def test_neo4j_connection_manager_skips_connection_in_lite_mode(monkeypatch): + monkeypatch.setenv("LITE_MODE", "true") + + manager = Neo4jConnectionManager() + + assert manager.driver is None + assert manager.status == "closed" diff --git a/backend/test/unit/test_auth_utils.py b/backend/test/unit/test_auth_utils.py index 92588cd0..399ead77 100644 --- a/backend/test/unit/test_auth_utils.py +++ b/backend/test/unit/test_auth_utils.py @@ -7,7 +7,7 @@ import jwt import pytest from yuxi.utils.datetime_utils import utc_now -from server.utils.auth_utils import JWT_ALGORITHM, JWT_AUDIENCE, AuthUtils +from yuxi.utils.auth_utils import JWT_ALGORITHM, JWT_AUDIENCE, AuthUtils def test_hash_password_uses_argon2(): diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 0ac5b67d..259ed3ca 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -68,6 +68,7 @@ - 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。 - 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。 - 标准化 Agent run/SSE 执行链路:run 创建时持久化输入消息并提交后入队,worker 统一写入 Redis Stream envelope,SSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。 +- 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`。 --- diff --git a/web/src/apis/system_api.js b/web/src/apis/system_api.js index 6e7916ae..67b4b71a 100644 --- a/web/src/apis/system_api.js +++ b/web/src/apis/system_api.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' +import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' /** * 系统管理API模块 @@ -14,13 +14,7 @@ export const healthApi = { * 系统健康检查(公开接口) * @returns {Promise} - 健康检查结果 */ - checkHealth: () => apiGet('/api/system/health', {}, false), - - /** - * OCR服务健康检查 - * @returns {Promise} - OCR服务健康状态 - */ - checkOcrHealth: async () => apiAdminGet('/api/system/health/ocr') + checkHealth: () => apiGet('/api/system/health', {}, false) } // ============================================================================= @@ -71,13 +65,7 @@ export const brandApi = { * 获取系统信息配置(公开接口) * @returns {Promise} - 系统信息配置 */ - getInfoConfig: () => apiGet('/api/system/info', {}, false), - - /** - * 重新加载信息配置 - * @returns {Promise} - 重新加载结果 - */ - reloadInfoConfig: async () => apiPost('/api/system/info/reload', {}, {}, false) + getInfoConfig: () => apiGet('/api/system/info', {}, false) } // ============================================================================= @@ -111,10 +99,6 @@ export const modelProviderApi = { return apiGet(`/api/system/model-providers/models/v2?model_type=${modelType}`) }, - getCacheStatus: async () => { - return apiAdminGet('/api/system/model-providers/models/cache-status') - }, - refreshModelCache: async () => { return apiAdminPost('/api/system/model-providers/models/cache/refresh') }, diff --git a/web/src/assets/css/extensions.less b/web/src/assets/css/extensions.less index f3bf1dee..ecced36e 100644 --- a/web/src/assets/css/extensions.less +++ b/web/src/assets/css/extensions.less @@ -438,7 +438,7 @@ &.extension-panel-action-secondary { border-color: var(--gray-200); - background: var(--gray-25); + background: var(--gray-0); color: var(--gray-700); &:hover, diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index d832b318..50c5bb12 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -12,16 +12,34 @@
+
-
+
@@ -110,8 +128,6 @@ :mention="mentionConfig" :thread-id="currentChatId" :supports-file-upload="supportsFileUpload" - :has-active-thread="!!currentChatId" - :todos="currentTodos" :attachments="currentPendingThreadAttachments" @send="handleSendOrStop" @upload-attachment="handleAttachmentUpload" @@ -142,18 +158,18 @@
+ +
+
+
+ 当前任务 + {{ todoProgress }}% +
+ +
+
+
+
+ + + + + +
+
+ {{ todo.content }} +
+
+
+
+
+
@@ -188,6 +240,14 @@ import { onDeactivated } from 'vue' import { message } from 'ant-design-vue' +import { SquareCheck } from 'lucide-vue-next' +import { + CheckCircleOutlined, + ClockCircleOutlined, + CloseCircleOutlined, + QuestionCircleOutlined, + SyncOutlined +} from '@ant-design/icons-vue' import AgentInputArea from '@/components/AgentInputArea.vue' import AgentMessageComponent from '@/components/AgentMessageComponent.vue' import RefsComponent from '@/components/RefsComponent.vue' @@ -284,7 +344,7 @@ const localUIState = reactive({ }) // Agent Panel State -const isAgentPanelOpen = ref(false) +const sideActive = ref('') const isResizing = ref(false) const defaultPanelRatio = 0.3 const previewPanelRatio = 0.65 @@ -295,14 +355,15 @@ const panelRatio = ref(defaultPanelRatio) // 面板宽度比例 (0-1) const agentPanelPreviewTabs = ref([]) const agentPanelActivePreviewPath = ref('') const agentPanelViewMode = ref('tree') +const chatContentContainerRef = ref(null) const panelWrapperRef = ref(null) // 直接操作 DOM let resizeStartX = 0 let resizeStartWidth = 0 let panelContainerWidth = 0 const getPanelContainerWidth = () => { - const container = document.querySelector('.chat-content-container') - return container ? container.clientWidth : window.innerWidth + const container = chatContentContainerRef.value || panelWrapperRef.value?.parentElement + return container?.clientWidth || (typeof window !== 'undefined' ? window.innerWidth : 0) } const getMaxPanelRatio = (containerWidth = getPanelContainerWidth()) => { @@ -317,6 +378,24 @@ const clampPanelRatio = (ratio, containerWidth = getPanelContainerWidth()) => { return Math.max(minPanelRatio, Math.min(ratio, getMaxPanelRatio(containerWidth))) } +const setPanelRatioForViewMode = () => { + const hasPreview = Boolean(agentPanelActivePreviewPath.value) + panelRatio.value = clampPanelRatio(hasPreview ? previewPanelRatio : defaultPanelRatio) +} + +const showFilePanel = (mode = 'tree') => { + sideActive.value = 'file' + agentPanelViewMode.value = mode === 'preview' && agentPanelActivePreviewPath.value ? 'preview' : 'tree' + setPanelRatioForViewMode() +} + +const showFileTreePanel = () => { + sideActive.value = 'file' + agentPanelActivePreviewPath.value = '' + agentPanelViewMode.value = 'tree' + setPanelRatioForViewMode() +} + const getPanelFileName = (file) => { if (file?.name) return file.name if (file?.path) return String(file.path).split('/').pop() || String(file.path) @@ -335,7 +414,7 @@ const isSameOrChildPanelPath = (path, targetPath) => { } const resetAgentPanelState = () => { - isAgentPanelOpen.value = false + sideActive.value = '' panelRatio.value = defaultPanelRatio agentPanelPreviewTabs.value = [] agentPanelActivePreviewPath.value = '' @@ -345,17 +424,13 @@ const resetAgentPanelState = () => { const setAgentPanelViewMode = (mode) => { agentPanelViewMode.value = mode === 'preview' && agentPanelActivePreviewPath.value ? 'preview' : 'tree' - - if (agentPanelActivePreviewPath.value) { - panelRatio.value = clampPanelRatio(previewPanelRatio) - } + setPanelRatioForViewMode() } const activatePanelPreview = (path) => { if (!path) return agentPanelActivePreviewPath.value = path - agentPanelViewMode.value = 'preview' - panelRatio.value = clampPanelRatio(previewPanelRatio) + showFilePanel('preview') } const openPanelPreview = (file, keepTreeOpen = false) => { @@ -376,10 +451,8 @@ const openPanelPreview = (file, keepTreeOpen = false) => { agentPanelPreviewTabs.value = [...agentPanelPreviewTabs.value, tab] } - isAgentPanelOpen.value = true - panelRatio.value = clampPanelRatio(previewPanelRatio) agentPanelActivePreviewPath.value = tab.path - agentPanelViewMode.value = keepTreeOpen ? 'tree' : 'preview' + showFilePanel(keepTreeOpen ? 'tree' : 'preview') } const closePanelPreviewTab = (path) => { @@ -394,6 +467,7 @@ const closePanelPreviewTab = (path) => { const nextActiveTab = nextTabs[Math.min(closingIndex, nextTabs.length - 1)] agentPanelActivePreviewPath.value = nextActiveTab?.path || '' agentPanelViewMode.value = nextActiveTab ? 'preview' : 'tree' + setPanelRatioForViewMode() } const closePanelPreviewPath = (targetPath) => { @@ -410,6 +484,7 @@ const closePanelPreviewPath = (targetPath) => { const nextActiveTab = nextTabs[0] agentPanelActivePreviewPath.value = nextActiveTab?.path || '' agentPanelViewMode.value = nextActiveTab ? 'preview' : 'tree' + setPanelRatioForViewMode() } // ==================== COMPUTED PROPERTIES ==================== @@ -473,6 +548,15 @@ const currentTodos = computed(() => { const todos = currentAgentState.value?.todos return Array.isArray(todos) ? todos : [] }) +const totalTodoCount = computed(() => currentTodos.value.length) +const completedTodoCount = computed( + () => currentTodos.value.filter((todo) => todo?.status === 'completed').length +) +const showTodoEntry = computed(() => Boolean(currentChatId.value && totalTodoCount.value > 0)) +const todoProgress = computed(() => { + if (!totalTodoCount.value) return 0 + return Math.round((completedTodoCount.value / totalTodoCount.value) * 100) +}) const { mentionConfig } = useAgentMentionConfig({ currentAgentState, @@ -997,7 +1081,7 @@ const refreshThreadFilesAndAttachments = async (threadId) => { const handleArtifactSaved = async () => { if (!currentChatId.value) return await refreshThreadFilesAndAttachments(currentChatId.value) - isAgentPanelOpen.value = true + showFileTreePanel() } const fetchAgentState = async (agentId, threadId) => { @@ -1057,7 +1141,7 @@ const handleTmpAttachmentsAdded = async () => { fetchAgentState(currentAgentId.value, threadId), refreshThreadFilesAndAttachments(threadId) ]) - isAgentPanelOpen.value = true + showFileTreePanel() } const handleAttachmentRemove = async (attachment) => { @@ -1421,17 +1505,20 @@ const handleAgentStateRefresh = async (threadId = null) => { ]) } -const toggleAgentPanel = async () => { - const nextOpen = !isAgentPanelOpen.value - isAgentPanelOpen.value = nextOpen +const toggleTodoPanel = () => { + sideActive.value = sideActive.value === 'todo' ? '' : 'todo' +} - if (nextOpen) { - agentPanelViewMode.value = agentPanelActivePreviewPath.value ? 'preview' : 'tree' - panelRatio.value = agentPanelActivePreviewPath.value - ? clampPanelRatio(previewPanelRatio) - : clampPanelRatio(defaultPanelRatio) - await handleAgentStateRefresh() +const toggleAgentPanel = async () => { + const nextOpen = sideActive.value !== 'file' + + if (!nextOpen) { + sideActive.value = '' + return } + + showFileTreePanel() + await handleAgentStateRefresh() } // 处理面板宽度调整(使用比例) @@ -1678,6 +1765,12 @@ onMounted(async () => { scrollController.enableAutoScroll() }) +watch(showTodoEntry, (visible) => { + if (!visible && sideActive.value === 'todo') { + sideActive.value = '' + } +}) + watch( currentAgentId, async (newAgentId, oldAgentId) => { @@ -1841,38 +1934,65 @@ watch(currentChatId, (threadId, oldThreadId) => { scrollbar-width: none; } -.agent-panel-wrapper { +.side-panel { flex: 0 0 auto; - align-self: stretch; - height: auto; overflow: hidden; + background: var(--gray-0); + border: 1px solid var(--gray-150); + border-radius: 16px; + box-shadow: + 0 16px 40px rgba(15, 23, 42, 0.1), + 0 2px 10px rgba(15, 23, 42, 0.06); z-index: 20; margin: 0 8px 8px; - margin-left: 0; - background: var(--gray-0); - border-radius: 16px; - border: 1px solid var(--gray-150); + margin-left: -16px; min-width: 0; - will-change: flex-basis; -} - -/* Workbench transition animations */ -.agent-panel-wrapper { - transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1); opacity: 0; transform: translateX(10px); - margin-left: -16px; + will-change: flex-basis; + transition: + flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); } -.agent-panel-wrapper.is-visible { +.side-panel.is-visible { opacity: 1; transform: translateX(0); margin-left: 0; +} + +.side-panel.no-transition { + transition: none !important; +} + +.side-panel--file { + align-self: stretch; + height: auto; +} + +.side-panel--file.is-visible { min-width: 320px; } -.agent-panel-wrapper.no-transition { - transition: none !important; +.side-panel--todo { + align-self: flex-start; + height: min(420px, calc(100% - 8px)); + min-height: 240px; + max-width: min(320px, calc(100vw - 24px)); + box-shadow: 0 4px 16px var(--shadow-0); +} + +.side-panel--todo.is-visible { + min-width: 280px; +} + +.todo-panel { + height: 100%; + display: flex; + flex-direction: column; + background: var(--gray-0); } .chat-greeting-input { @@ -2168,25 +2288,33 @@ watch(currentChatId, (threadId, oldThreadId) => { } @media (max-width: 1024px) { - .chat-content-container.has-agent-panel .chat-main { + .chat-content-container.has-file-panel .chat-main, + .chat-content-container.has-todo-panel .chat-main { min-width: 350px; } - .agent-panel-wrapper.is-visible { + .side-panel--file.is-visible, + .side-panel--todo.is-visible { max-width: calc(100% - 350px); } } @media (max-width: 768px) { - .chat-content-container.has-agent-panel .chat-main { + .chat-content-container.has-file-panel .chat-main, + .chat-content-container.has-todo-panel .chat-main { min-width: 0; } - .agent-panel-wrapper.is-visible { + .side-panel--file.is-visible { min-width: 280px; max-width: 80%; } + .side-panel--todo.is-visible { + min-width: 260px; + max-width: 80%; + } + .agent-segment-wrapper { margin-bottom: 8px; @@ -2270,6 +2398,105 @@ watch(currentChatId, (threadId, oldThreadId) => { } } +.side-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 44px; + padding: 4px 12px; + background: var(--gray-25); + border-bottom: 1px solid var(--gray-100); + flex-shrink: 0; +} + +.todo-state-btn.active { + color: var(--main-700); + background-color: var(--main-20); +} + +.todo-panel-header { + padding: 10px 14px; +} + +.todo-panel-title { + min-width: 0; + font-size: 15px; + font-weight: 600; + color: var(--gray-900); +} + +.todo-panel-body { + flex: 1; + min-height: 0; + padding: 12px 14px 14px; + display: flex; + flex-direction: column; +} + +.todo-panel-list { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: auto; +} + +.todo-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 0; + border-bottom: 1px solid var(--gray-100); +} + +.todo-item:last-child { + border-bottom: none; +} + +.todo-item-icon { + width: 22px; + height: 22px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: var(--gray-50); + color: var(--gray-500); + + &.completed { + background: var(--color-success-10); + color: var(--color-success-700); + } + + &.in_progress { + background: var(--color-info-10); + color: var(--color-info-700); + } + + &.pending { + background: var(--color-warning-10); + color: var(--color-warning-700); + } + + &.cancelled { + background: var(--color-error-10); + color: var(--color-error-700); + } +} + +.todo-item-body { + min-width: 0; +} + +.todo-item-text { + font-size: 14px; + line-height: 1.5; + color: var(--gray-800); + word-break: break-word; +} + .hide-text { display: none; } @@ -2282,12 +2509,12 @@ watch(currentChatId, (threadId, oldThreadId) => { /* AgentState 按钮有内容时的样式 */ .agent-nav-btn.agent-state-btn.has-content:hover:not(.is-disabled) { - color: var(--main-700); - background-color: var(--main-20); + color: var(--gray-900); + background-color: var(--gray-100); } .agent-nav-btn.agent-state-btn.active { - color: var(--main-700); - background-color: var(--main-20); + color: var(--gray-900); + background-color: var(--gray-100); } diff --git a/web/src/components/AgentInputArea.vue b/web/src/components/AgentInputArea.vue index a04a96ae..d6107726 100644 --- a/web/src/components/AgentInputArea.vue +++ b/web/src/components/AgentInputArea.vue @@ -78,58 +78,6 @@ - - diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue index c883c0fa..d4fa8698 100644 --- a/web/src/components/AgentPanel.vue +++ b/web/src/components/AgentPanel.vue @@ -1,7 +1,7 @@ -