From 402ff527396c3d4ed1a09fc5cab84a1b2ec3ae76 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 24 Aug 2025 20:12:15 +0800 Subject: [PATCH] =?UTF-8?q?refactor(tools=5Ffactory):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=B7=A5=E5=8E=82=E6=A8=A1=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=A5=E5=85=B7=E7=AE=A1=E7=90=86=E5=92=8C?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重命名工具获取函数为 get_buildin_tools 和 get_buildin_tools_info - 改进工具ID生成和描述构建逻辑 - 添加详细的日志记录和错误处理 - 优化知识库工具创建流程,避免闭包问题 - 分离静态工具和知识库工具获取逻辑 - 增强计算器和知识图谱查询工具的错误处理 --- docs/changelog/update.md | 2 + docs/vibe/AGENT.md | 40 ++++ server/routers/chat_router.py | 4 +- server/routers/tool_router.py | 4 +- src/agents/chatbot/configuration.py | 4 +- src/agents/chatbot/graph.py | 6 +- src/agents/tools_factory.py | 260 +++++++++++++--------- web/src/components/AgentConfigSidebar.vue | 63 +++--- 8 files changed, 234 insertions(+), 149 deletions(-) create mode 100644 docs/vibe/AGENT.md diff --git a/docs/changelog/update.md b/docs/changelog/update.md index 73fc635d..1b8c0b0f 100644 --- a/docs/changelog/update.md +++ b/docs/changelog/update.md @@ -25,6 +25,8 @@ 下面的功能会放在后续版本实现,暂时未定 + +- [ ] 使用其他的聊天记录管理方法(现在是基于 LangGraph 的 Memory 实现的) - [ ] 封装现有工具为 mcp(stdio)调用 - [ ] 支持额外 mcp 配置(代码端) - [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息 diff --git a/docs/vibe/AGENT.md b/docs/vibe/AGENT.md new file mode 100644 index 00000000..caefd2b5 --- /dev/null +++ b/docs/vibe/AGENT.md @@ -0,0 +1,40 @@ + +# 项目目录结构 + +``` +Yuxi-Know/ +├── docker/ # Docker 配置文件 +├── docs/ # 项目文档 +├── scripts/ # 脚本文件,如批量上传等 +├── server/ # 服务端代码(部分) +├── src/ # 主要源代码目录 +│ ├── agents/ # 智能体应用 +│ ├── config/ # 配置文件 +│ ├── knowledge/ # 知识库相关 +│ ├── models/ # 数据模型 +│ ├── plugins/ # 插件 +│ ├── static/ # 静态资源 +│ └── utils/ # 工具函数 +├── web/ # 前端代码 +└── docker-compose.yml # Docker Compose 配置 +``` + +# 主要容器名称 + +项目使用 Docker Compose 管理多个服务,主要容器名称如下: +- `api-dev` - FastAPI 后端服务 +- `web-dev` - Vue.js 前端开发服务器 +- `graph` - Neo4j 图数据库 +- `milvus` - 向量数据库,包含 etcd 和 MinIO 依赖 +- `mineru` - 可选的 MinerU OCR 服务(需要 GPU) +- `paddlex` - 可选的 PaddleX OCR 服务(需要 GPU) + +## 项目调试 + +此项目是使用 Docker 进行部署的,使用 `docker compose up -d` 命令进行构建和启动。因此当进行任何修改的时候,不要尝试启动这个项目,应该先检查项目是否已经在后台启动(`docker ps`),具体的可以阅读 [docker-compose.yml](docker-compose.yml). + +前端和后端都是配置了自动启动的,因此当修改完成后,会自动更新,可以使用 docker logs 查看日志。对于部分场景可以创建一个 test_router.py 在 [server/routers](server/routers) 中,然后通过 API 测试功能场景。对于前端的 UI 修改,则不用测试。 + +## 风格说明 + +UI风格要简洁,同时要保持一致性,颜色要尽量参考 [base.css](web/src/assets/css/base.css) 中的颜色。不要悬停位移,不要过度使用阴影以及渐变色。 diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index 1ae4904b..ca744938 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -14,7 +14,7 @@ from src import executor, config from src.agents import agent_manager from src.models import select_model from src.utils.logging_config import logger -from src.agents.tools_factory import get_runnable_tools +from src.agents.tools_factory import get_buildin_tools from server.routers.auth_router import get_admin_user from server.utils.auth_middleware import get_required_user, get_db from server.models.user_model import User @@ -181,7 +181,7 @@ async def update_chat_models(model_provider: str, model_names: list[str], curren @chat.get("/tools") async def get_tools(current_user: User = Depends(get_admin_user)): """获取所有可用工具(需要登录)""" - return {"tools": list(get_runnable_tools().keys())} + return {"tools": list(get_buildin_tools().keys())} @chat.post("/agent/{agent_id}/config") async def save_agent_config( diff --git a/server/routers/tool_router.py b/server/routers/tool_router.py index 9fc88de6..5bebd0dc 100644 --- a/server/routers/tool_router.py +++ b/server/routers/tool_router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends -from src.agents.tools_factory import get_all_tools_info +from src.agents.tools_factory import get_buildin_tools_info from server.models.user_model import User from server.utils.auth_middleware import get_required_user @@ -9,7 +9,7 @@ tool = chat = APIRouter(prefix="/tool", tags=["tool"]) async def get_tools(current_user: User = Depends(get_required_user)): """获取所有可用工具的信息""" try: - tools_info = get_all_tools_info() + tools_info = get_buildin_tools_info() return {"tools": tools_info} except Exception as e: return {"error": str(e)} diff --git a/src/agents/chatbot/configuration.py b/src/agents/chatbot/configuration.py index 8e8772c4..d112f10b 100644 --- a/src/agents/chatbot/configuration.py +++ b/src/agents/chatbot/configuration.py @@ -3,7 +3,7 @@ import uuid from dataclasses import dataclass, field from src.agents.registry import Configuration -from src.agents.tools_factory import get_runnable_tools +from src.agents.tools_factory import get_buildin_tools @dataclass(kw_only=True) class ChatbotConfiguration(Configuration): @@ -37,7 +37,7 @@ class ChatbotConfiguration(Configuration): default_factory=list, metadata={ "name": "工具", - "options": list(get_runnable_tools().keys()), # 这里的选择是所有的工具 + "options": list(get_buildin_tools().keys()), # 这里的选择是所有的工具 "description": "工具列表" }, ) diff --git a/src/agents/chatbot/graph.py b/src/agents/chatbot/graph.py index 139d082a..1a374b56 100644 --- a/src/agents/chatbot/graph.py +++ b/src/agents/chatbot/graph.py @@ -15,7 +15,7 @@ from src.utils import logger from src.agents.registry import State, BaseAgent from src.agents.utils import load_chat_model, get_cur_time_with_utc from src.agents.chatbot.configuration import ChatbotConfiguration -from src.agents.tools_factory import get_runnable_tools +from src.agents.tools_factory import get_buildin_tools class ChatbotAgent(BaseAgent): name = "智能体助手" @@ -33,7 +33,7 @@ class ChatbotAgent(BaseAgent): 默认不使用任何工具。 如果配置为列表,则使用列表中的工具。 """ - platform_tools = get_runnable_tools() + platform_tools = get_buildin_tools() if tools is None or not isinstance(tools, list) or len(tools) == 0: # 默认不使用任何工具 logger.info("未配置工具或配置为空,不使用任何工具") @@ -65,7 +65,7 @@ class ChatbotAgent(BaseAgent): if self.graph: return self.graph - runnable_tools = get_runnable_tools() + runnable_tools = get_buildin_tools() tools = list(runnable_tools.values()) tools_name = list(runnable_tools.keys()) logger.debug(f"build graph `{self.id}` with tools: {tools_name}") diff --git a/src/agents/tools_factory.py b/src/agents/tools_factory.py index 3d1c2e08..11926c32 100644 --- a/src/agents/tools_factory.py +++ b/src/agents/tools_factory.py @@ -1,8 +1,6 @@ -import json import asyncio -import inspect -import types -from collections.abc import Callable +import hashlib +import os from typing import Annotated, Any from pydantic import BaseModel, Field @@ -13,10 +11,6 @@ from src import config, graph_base, knowledge_base from src.utils import logger -# 工具注册表 - 移到前面以避免NameError -_TOOLS_REGISTRY = {} - - class KnowledgeRetrieverModel(BaseModel): query_text: str = Field( description=( @@ -25,128 +19,176 @@ class KnowledgeRetrieverModel(BaseModel): ) ) -def get_runnable_tools(): + +def _create_retriever_wrapper(db_id: str, retriever_info: dict[str, Any]): + """创建检索器包装函数的工厂函数,避免闭包变量捕获问题""" + async def async_retriever_wrapper(query_text: str) -> Any: + """异步检索器包装函数""" + retriever = retriever_info["retriever"] + try: + logger.debug(f"Retrieving from database {db_id} with query: {query_text}") + if asyncio.iscoroutinefunction(retriever): + result = await retriever(query_text) + else: + result = retriever(query_text) + logger.debug(f"Retrieved {len(result) if isinstance(result, list) else 'N/A'} results from {db_id}") + return result + except Exception as e: + logger.error(f"Error in retriever {db_id}: {e}") + return f"检索失败: {str(e)}" + + return async_retriever_wrapper + +def get_buildin_tools() -> dict[str, Any]: """获取所有可运行的工具(给大模型使用)""" - tools = _TOOLS_REGISTRY.copy() + tools = {} - # 获取所有知识库 - for db_Id, retrieve_info in knowledge_base.get_retrievers().items(): - _id = f"retrieve_{db_Id[:8]}" # Deepseek does not support non-alphanumeric characters in tool names - description = ( - f"使用 {retrieve_info['name']} 知识库进行检索。\n" - f"下面是这个知识库的描述:\n{retrieve_info['description']}" - ) + try: + # 获取所有知识库基于的工具 + kb_tools = get_kb_based_tools() + static_tools = get_static_tools() - # 创建异步工具,确保正确处理异步检索器 - async def async_retriever_wrapper(query_text: str, db_id=db_Id, retriever_info=retrieve_info): - """异步检索器包装函数""" - retriever = retriever_info["retriever"] - try: - if asyncio.iscoroutinefunction(retriever): - result = await retriever(query_text) - else: - result = retriever(query_text) - return result - except Exception as e: - logger.error(f"Error in retriever {db_id}: {e}") - return f"检索失败: {str(e)}" + tools.update(kb_tools) + tools.update(static_tools) - # 使用 StructuredTool.from_function 创建异步工具 - tools[_id] = StructuredTool.from_function( - coroutine=async_retriever_wrapper, # 指定为协程 - name=_id, - description=description, - args_schema=KnowledgeRetrieverModel, - metadata=retrieve_info - ) + except Exception as e: + logger.error(f"Failed to get knowledge base retrievers: {e}") + logger.info(f"Total tools available: {len(tools)}") return tools -def get_all_tools_info(): +def get_kb_based_tools() -> dict[str, Any]: + """获取所有知识库基于的工具""" + # 获取所有知识库 + kb_tools = {} + retrievers = knowledge_base.get_retrievers() + logger.debug(f"Found {len(retrievers)} knowledge base retrievers") + + for db_id, retrieve_info in retrievers.items(): + try: + # 使用改进的工具ID生成策略 + tool_id = f"query_{db_id[:8]}" + + # 构建工具描述 + description = ( + f"使用 {retrieve_info['name']} 知识库进行检索。\n" + f"下面是这个知识库的描述:\n{retrieve_info['description'] or '没有描述。'}" + ) + + # 使用工厂函数创建检索器包装函数,避免闭包问题 + retriever_wrapper = _create_retriever_wrapper(db_id, retrieve_info) + + # 使用 StructuredTool.from_function 创建异步工具 + tool = StructuredTool.from_function( + coroutine=retriever_wrapper, + name=tool_id, + description=description, + args_schema=KnowledgeRetrieverModel, + metadata=retrieve_info | { + "tag": "knowledgebase" + } + ) + + kb_tools[tool_id] = tool + logger.debug(f"Successfully created tool {tool_id} for database {db_id}") + + except Exception as e: + logger.error(f"Failed to create tool for database {db_id}: {e}") + continue + + return kb_tools + +def get_buildin_tools_info() -> dict[str, dict[str, Any]]: """获取所有工具的信息(用于前端展示)""" tools_info = {} - tools = get_runnable_tools() + try: + tools = get_buildin_tools() + logger.debug(f"Processing {len(tools)} tools for info extraction") - # 获取注册的工具信息 - for _id, tool_obj in tools.items(): + # 获取注册的工具信息 + for tool_id, tool_obj in tools.items(): + try: + metadata = getattr(tool_obj, 'metadata', {}) or {} + info = { + "id": tool_id, + "name": metadata.get('name', tool_id), + "description": metadata.get('description') or getattr(tool_obj, 'description', ''), + 'metadata': metadata, + "args": [] + } - metadata = getattr(tool_obj, 'metadata', {}) or {} - info = { - "id": _id, - "name": metadata.get('name', _id), - "description": metadata.get('description') or getattr(tool_obj, 'description', ''), - 'metadata': metadata, - "args": [] - } + # 获取工具参数信息 + try: + if hasattr(tool_obj, 'args_schema') and tool_obj.args_schema: + schema = tool_obj.args_schema.schema() + if 'properties' in schema: + for arg_name, arg_info in schema['properties'].items(): + info["args"].append({ + "name": arg_name, + "type": arg_info.get('type', ''), + "description": arg_info.get('description', '') + }) + except Exception as e: + logger.warning(f"Failed to extract args schema for tool {tool_id}: {e}") - # 获取工具参数信息 - if hasattr(tool_obj, 'args_schema') and tool_obj.args_schema: - schema = tool_obj.args_schema.schema() - if 'properties' in schema: - for arg_name, arg_info in schema['properties'].items(): - info["args"].append({ - "name": arg_name, - "type": arg_info.get('type', ''), - "description": arg_info.get('description', '') - }) - tools_info[info['id']] = info + tools_info[tool_id] = info + logger.debug(f"Successfully processed tool info for {tool_id}") + except Exception as e: + logger.error(f"Failed to process tool {tool_id}: {e}") + continue + + except Exception as e: + logger.error(f"Failed to get tools info: {e}") + return {} + + logger.info(f"Successfully extracted info for {len(tools_info)} tools") return tools_info -class BaseToolOutput: - """ - LLM 要求 Tool 的输出为 str,但 Tool 用在别处时希望它正常返回结构化数据。 - 只需要将 Tool 返回值用该类封装,能同时满足两者的需要。 - 基类简单的将返回值字符串化,或指定 format="json" 将其转为 json。 - 用户也可以继承该类定义自己的转换方法。 - """ - - def __init__( - self, - data: Any, - format: str | Callable | None = None, - data_alias: str = "", - **extras: Any, - ) -> None: - self.data = data - self.format = format - self.extras = extras - if data_alias: - setattr(self, data_alias, property(lambda obj: obj.data)) - - def __str__(self) -> str: - if self.format == "json": - return json.dumps(self.data, ensure_ascii=False, indent=2) - elif callable(self.format): - return self.format(self) - else: - return str(self.data) - @tool def calculator(a: float, b: float, operation: str) -> float: """Calculate two numbers. operation: add, subtract, multiply, divide""" - if operation == "add": - return a + b - elif operation == "subtract": - return a - b - elif operation == "multiply": - return a * b - elif operation == "divide": - return a / b - else: - raise ValueError(f"Invalid operation: {operation}, only support add, subtract, multiply, divide") + try: + if operation == "add": + return a + b + elif operation == "subtract": + return a - b + elif operation == "multiply": + return a * b + elif operation == "divide": + if b == 0: + raise ZeroDivisionError("除数不能为零") + return a / b + else: + raise ValueError(f"不支持的运算类型: {operation},仅支持 add, subtract, multiply, divide") + except Exception as e: + logger.error(f"Calculator error: {e}") + raise + @tool -def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]): +def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]) -> Any: """Use this to query knowledge graph, which include some food domain knowledge.""" - return graph_base.query_node(query, hops=2, return_format='triples') + try: + logger.debug(f"Querying knowledge graph with: {query}") + result = graph_base.query_node(query, hops=2, return_format='triples') + logger.debug(f"Knowledge graph query returned {len(result.get('triples', [])) if isinstance(result, d) else 'N/A'} triples") + return result + except Exception as e: + logger.error(f"Knowledge graph query error: {e}") + return f"知识图谱查询失败: {str(e)}" -# 更新工具注册表 -_TOOLS_REGISTRY.update({ - "Calculator": calculator, - "QueryKnowledgeGraph": query_knowledge_graph, -}) +def get_static_tools() -> dict[str, Any]: + """注册静态工具""" + static_tools = { + "Calculator": calculator, + "QueryKnowledgeGraph": query_knowledge_graph, + } -if config.enable_web_search: - _TOOLS_REGISTRY["WebSearchWithTavily"] = TavilySearch(max_results=10) + # 检查是否启用网页搜索 + if config.enable_web_search: + static_tools["WebSearchWithTavily"] = TavilySearch(max_results=10) + + + return static_tools diff --git a/web/src/components/AgentConfigSidebar.vue b/web/src/components/AgentConfigSidebar.vue index 4ab58da0..8bae4a13 100644 --- a/web/src/components/AgentConfigSidebar.vue +++ b/web/src/components/AgentConfigSidebar.vue @@ -894,7 +894,7 @@ watch(() => props.isOpen, (newVal) => { &:focus-within { border-color: var(--main-color); box-shadow: 0 0 0 2px rgba(var(--main-color-rgb), 0.1); - + .search-icon { color: var(--main-color); } @@ -907,13 +907,19 @@ watch(() => props.isOpen, (newVal) => { } .tools-list { - max-height: 300px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + max-height: max(60vh, 800px); overflow-y: auto; - border: 1px solid var(--gray-200); border-radius: 8px; margin-bottom: 16px; background: white; - padding: 4px; + + // 在小屏幕下调整为单列布局 + @media (max-width: 480px) { + grid-template-columns: 1fr; + } &::-webkit-scrollbar { width: 6px; @@ -943,38 +949,14 @@ watch(() => props.isOpen, (newVal) => { background: white; border: 1px solid var(--gray-200); - &:last-child { - margin-bottom: 0; - } - &:hover { - border-color: var(--main-color); - background: var(--gray-50); + border-color: var(--gray-300); + background: var(--gray-20); } - - &.selected { - background: var(--main-10); - border-color: var(--main-color); - - .tool-name { - color: var(--main-color); - font-weight: 600; - } - - .tool-description { - color: var(--gray-700); - } - - .tool-indicator { - color: var(--main-color); - } - } - .tool-content { .tool-header { display: flex; - justify-content: space-between; - align-items: flex-start; + align-items: center; margin-bottom: 6px; gap: 8px; @@ -1005,6 +987,25 @@ watch(() => props.isOpen, (newVal) => { text-overflow: ellipsis; } } + + &.selected { + background: var(--main-30); + // border-color: var(--main-color); + + .tool-content { + .tool-name { + color: var(--main-color); + } + .tool-indicator { + color: var(--main-color); + } + } + .tool-description { + color: var(--gray-700); + } + + } + } }