feat: 重构工具管理并集成新工具API

- 将 `get_tavily_search` 导入移至新的工具包结构中。
- 更新 `skill_service.py` 以获取所有工具而不仅仅是内置工具。
- 引入 `tool_service.py` 用于管理工具元数据和初始化。
- 从 `models.py` 中删除了旧的数据库模型。
- 添加了新的 `tool_api.js` 用于工具管理API端点。
- 创建了 `ToolsManagerComponent.vue` 用于在UI中显示和管理工具。
- 更新了 `ExtensionsView.vue` 以包含工具管理的新标签页。
This commit is contained in:
Wenjie Zhang 2026-03-03 23:44:57 +08:00
parent b19fdcb9f5
commit b4ea2ab948
26 changed files with 830 additions and 621 deletions

View File

@ -12,6 +12,7 @@ from server.routers.mindmap_router import mindmap
from server.routers.skill_router import skills from server.routers.skill_router import skills
from server.routers.system_router import system from server.routers.system_router import system
from server.routers.task_router import tasks from server.routers.task_router import tasks
from server.routers.tool_router import tools
router = APIRouter() router = APIRouter()
@ -28,3 +29,4 @@ router.include_router(graph) # /api/graph/*
router.include_router(tasks) # /api/tasks/* router.include_router(tasks) # /api/tasks/*
router.include_router(mcp) # /api/system/mcp-servers/* router.include_router(mcp) # /api/system/mcp-servers/*
router.include_router(skills) # /api/system/skills/* router.include_router(skills) # /api/system/skills/*
router.include_router(tools) # /api/system/tools/*

View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from src.services.tool_service import get_tool_metadata
from server.utils.auth_middleware import get_admin_user
from src.storage.postgres.models_business import User
tools = APIRouter(prefix="/system/tools", tags=["tools"])
@tools.get("")
async def list_tools(
category: str = None,
user: User = Depends(get_admin_user),
):
"""获取工具列表"""
return {"success": True, "data": get_tool_metadata(category)}
@tools.get("/options")
async def get_tool_options(
user: User = Depends(get_admin_user),
):
"""获取工具选项(前端下拉框用)"""
all_tools = get_tool_metadata()
return {"success": True, "data": [{"label": t["name"], "value": t["id"]} for t in all_tools]}

View File

@ -1,15 +1,3 @@
"""
Common utilities and base classes for agents.
This module provides a unified namespace for commonly used base classes and utilities,
allowing simplified imports like:
from src.agents.common import BaseAgent, BaseContext, BaseState
For other specific functions, use the original import style:
from src.agents.common.tools import query_knowledge_graph
from src.services.mcp_service import MCP_SERVERS
"""
# Base classes - 核心基类 # Base classes - 核心基类
from src.agents.common.base import BaseAgent from src.agents.common.base import BaseAgent
from src.agents.common.context import BaseContext from src.agents.common.context import BaseContext
@ -19,7 +7,8 @@ from src.agents.common.models import load_chat_model
from src.agents.common.state import BaseState from src.agents.common.state import BaseState
# Tools - 核心工具函数 # Tools - 核心工具函数
from src.agents.common.tools import gen_tool_info, get_buildin_tools from src.agents.common.toolkits.buildin import get_buildin_tools
from src.agents.common.toolkits.utils import gen_tool_info
# MCP - Agent 层统一入口(自动过滤 disabled_tools # MCP - Agent 层统一入口(自动过滤 disabled_tools
from src.services.mcp_service import get_enabled_mcp_tools from src.services.mcp_service import get_enabled_mcp_tools

View File

@ -12,7 +12,8 @@ from src import config as sys_config
from src.services.mcp_service import get_mcp_server_names from src.services.mcp_service import get_mcp_server_names
from src.utils import logger from src.utils import logger
from .tools import gen_tool_info, get_buildin_tools from .toolkits.buildin import get_buildin_tools
from .toolkits.utils import gen_tool_info
@dataclass(kw_only=True) @dataclass(kw_only=True)

View File

@ -12,7 +12,8 @@ from langchain_core.messages import SystemMessage, ToolMessage
from langgraph.types import Command from langgraph.types import Command
from src.agents.common import load_chat_model from src.agents.common import load_chat_model
from src.agents.common.tools import get_buildin_tools, get_kb_based_tools from src.agents.common.toolkits.buildin import get_buildin_tools
from src.agents.common.toolkits.kbs import get_kb_based_tools
from src.services.mcp_service import get_enabled_mcp_tools from src.services.mcp_service import get_enabled_mcp_tools
from src.services.skill_resolver import ( from src.services.skill_resolver import (
SkillSessionSnapshot, SkillSessionSnapshot,

View File

@ -1,3 +0,0 @@
from .calc_agent import calc_agent, calc_agent_tool
__all__ = ["calc_agent", "calc_agent_tool"]

View File

@ -1,19 +0,0 @@
from langchain.agents import create_agent
from langchain.tools import tool
from src import config
from src.agents.common import load_chat_model
from src.agents.common.tools import calculator
calc_agent = create_agent(
model=load_chat_model(config.default_model),
tools=[calculator],
system_prompt="你可以使用计算器工具,处理各种数学计算任务。最终仅返回计算结果,不需要任何额外的解释。",
)
@tool(name_or_callable="calc_agent_tool", description="进行计算任务,输入是数学表达式或描述,输出计算结果。")
async def calc_agent_tool(description: str) -> str:
"""CalcAgent 工具 - 使用子智能体 CalcAgent 进行计算任务"""
response = await calc_agent.ainvoke({"messages": [("user", description)]})
return response["messages"][-1].content

View File

@ -0,0 +1,54 @@
# toolkits 包
from .registry import (
ToolExtraMetadata,
get_all_extra_metadata,
get_all_tool_instances,
get_extra_metadata,
register_tool,
tool,
)
# 工具获取函数
from .buildin import get_buildin_tools
from .kbs import get_kb_based_tools
from src.services.mcp_service import get_enabled_mcp_tools
async def get_tools_from_context(context, extra_tools=None):
"""从上下文配置中获取工具列表"""
# 1. 基础工具 (从 context.tools 中筛选)
all_basic_tools = get_buildin_tools() + (extra_tools or [])
selected_tools = []
if context.tools:
# 创建工具映射表
tools_map = {t.name: t for t in all_basic_tools}
for tool_name in context.tools:
if tool_name in tools_map:
selected_tools.append(tools_map[tool_name])
# 2. 知识库工具
if context.knowledges:
kb_tools = get_kb_based_tools(db_names=context.knowledges)
selected_tools.extend(kb_tools)
# 3. MCP 工具(使用统一入口,自动过滤 disabled_tools
if context.mcps:
for server_name in context.mcps:
mcp_tools = await get_enabled_mcp_tools(server_name)
selected_tools.extend(mcp_tools)
return selected_tools
__all__ = [
"register_tool",
"get_extra_metadata",
"get_all_extra_metadata",
"get_all_tool_instances",
"ToolExtraMetadata",
"tool",
"get_buildin_tools",
"get_kb_based_tools",
"get_tools_from_context",
]

View File

@ -0,0 +1,7 @@
# buildin 工具包
from .tools import get_buildin_tools, query_knowledge_graph
__all__ = [
"get_buildin_tools",
"query_knowledge_graph",
]

View File

@ -4,12 +4,10 @@ import uuid
from typing import Annotated, Any from typing import Annotated, Any
import requests import requests
from langchain.tools import tool
from langgraph.types import interrupt from langgraph.types import interrupt
from src import config, graph_base from src import config, graph_base
from src.agents.common.toolkits.kbs import get_kb_based_tools from src.agents.common.toolkits import tool
from src.services.mcp_service import get_enabled_mcp_tools
from src.storage.minio import aupload_file_to_minio from src.storage.minio import aupload_file_to_minio
from src.utils import logger from src.utils import logger
@ -24,12 +22,15 @@ def get_tavily_search():
from langchain_tavily import TavilySearch from langchain_tavily import TavilySearch
_tavily_search_instance = TavilySearch() _tavily_search_instance = TavilySearch()
_tavily_search_instance.metadata = {"name": "Tavily 网页搜索"} _tavily_search_instance.metadata = {"name": "Tavily 网页搜索", "category": "buildin", "tags": ["搜索"]}
# 即使没有配置 API_KEY 也返回实例,调用时会出错
return _tavily_search_instance return _tavily_search_instance
@tool(name_or_callable="calculator", description="可以对给定的2个数字选择进行 add, subtract, multiply, divide 运算") @tool(category="buildin", tags=["计算"], display_name="计算器")
def calculator(a: float, b: float, operation: str) -> float: def calculator(a: float, b: float, operation: str) -> float:
"""计算器对给定的2个数字进行基本数学运算"""
try: try:
if operation == "add": if operation == "add":
return a + b return a + b
@ -48,7 +49,7 @@ def calculator(a: float, b: float, operation: str) -> float:
raise raise
@tool @tool(category="buildin", tags=["图片", "测试"], display_name="文生图测试")
async def text_to_img_demo(text: str) -> str: async def text_to_img_demo(text: str) -> str:
"""【测试用】使用模型生成图片, 会返回图片的URL""" """【测试用】使用模型生成图片, 会返回图片的URL"""
@ -85,7 +86,7 @@ async def text_to_img_demo(text: str) -> str:
return image_url return image_url
@tool(name_or_callable="human_in_the_loop_debug", description="请求人工审批工具,用于在执行重要操作前获得人类确认。") @tool(category="debug", tags=["内置", "审批"], display_name="人工审批")
def get_approved_user_goal( def get_approved_user_goal(
operation_description: str, operation_description: str,
) -> dict: ) -> dict:
@ -129,7 +130,7 @@ KG_QUERY_DESCRIPTION = """
""" """
@tool(name_or_callable="查询知识图谱", description=KG_QUERY_DESCRIPTION) @tool(category="buildin", tags=["图谱"], display_name="查询知识图谱", description=KG_QUERY_DESCRIPTION)
def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]) -> Any: def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]) -> Any:
"""使用这个工具可以查询知识图谱中包含的三元组信息。关键词query使用可能帮助回答这个问题的关键词进行查询不要直接使用用户的原始输入去查询。""" """使用这个工具可以查询知识图谱中包含的三元组信息。关键词query使用可能帮助回答这个问题的关键词进行查询不要直接使用用户的原始输入去查询。"""
try: try:
@ -145,59 +146,8 @@ def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge
return f"知识图谱查询失败: {str(e)}" return f"知识图谱查询失败: {str(e)}"
def gen_tool_info(tools) -> list[dict[str, Any]]:
"""获取所有工具的信息(用于前端展示)"""
tools_info = []
try:
# 获取注册的工具信息
for tool_obj in tools:
try:
metadata = getattr(tool_obj, "metadata", {}) or {}
info = {
"id": tool_obj.name,
"name": metadata.get("name", tool_obj.name),
"description": tool_obj.description,
"metadata": metadata,
"args": [],
# "is_async": is_async # Include async information
}
if hasattr(tool_obj, "args_schema") and tool_obj.args_schema:
if isinstance(tool_obj.args_schema, dict):
schema = tool_obj.args_schema
else:
schema = tool_obj.args_schema.schema()
for arg_name, arg_info in schema.get("properties", {}).items():
info["args"].append(
{
"name": arg_name,
"type": arg_info.get("type", ""),
"description": arg_info.get("description", ""),
}
)
tools_info.append(info)
# logger.debug(f"Successfully processed tool info for {tool_obj.name}")
except Exception as e:
logger.error(
f"Failed to process tool {getattr(tool_obj, 'name', 'unknown')}: {e}\n{traceback.format_exc()}. "
f"Details: {dict(tool_obj.__dict__)}"
)
continue
except Exception as e:
logger.error(f"Failed to get tools info: {e}\n{traceback.format_exc()}")
return []
logger.info(f"Successfully extracted info for {len(tools_info)} tools")
return tools_info
def get_buildin_tools() -> list: def get_buildin_tools() -> list:
"""注册静态工具""" """获取内置工具列表"""
static_tools = [ static_tools = [
query_knowledge_graph, query_knowledge_graph,
get_approved_user_goal, get_approved_user_goal,
@ -205,42 +155,7 @@ def get_buildin_tools() -> list:
text_to_img_demo, text_to_img_demo,
] ]
# subagents 工具 # 始终添加 tavily_search无论是否配置 API_KEY调用时会出错
from .subagents import calc_agent_tool static_tools.append(get_tavily_search())
static_tools.append(calc_agent_tool)
# 检查是否启用网页搜索(即是否配置了 API_KEY
if config.enable_web_search:
tavily_search = get_tavily_search()
if tavily_search:
static_tools.append(tavily_search)
return static_tools return static_tools
async def get_tools_from_context(context, extra_tools=None) -> list:
"""从上下文配置中获取工具列表"""
# 1. 基础工具 (从 context.tools 中筛选)
all_basic_tools = get_buildin_tools() + (extra_tools or [])
selected_tools = []
if context.tools:
# 创建工具映射表
tools_map = {t.name: t for t in all_basic_tools}
for tool_name in context.tools:
if tool_name in tools_map:
selected_tools.append(tools_map[tool_name])
# 2. 知识库工具
if context.knowledges:
kb_tools = get_kb_based_tools(db_names=context.knowledges)
selected_tools.extend(kb_tools)
# 3. MCP 工具(使用统一入口,自动过滤 disabled_tools
if context.mcps:
for server_name in context.mcps:
mcp_tools = await get_enabled_mcp_tools(server_name)
selected_tools.extend(mcp_tools)
return selected_tools

View File

@ -1,3 +1,4 @@
# mysql 工具包
from .tools import get_mysql_tools from .tools import get_mysql_tools
__all__ = ["get_mysql_tools"] __all__ = ["get_mysql_tools"]

View File

@ -1,8 +1,8 @@
from typing import Annotated, Any from typing import Annotated, Any
from langchain.tools import tool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.agents.common.toolkits import tool
from src.utils import logger from src.utils import logger
from .connection import ( from .connection import (
@ -52,7 +52,7 @@ class TableListModel(BaseModel):
pass pass
@tool(name_or_callable="mysql_list_tables", args_schema=TableListModel) @tool(category="mysql", tags=["数据库", "查询"], display_name="列出MySQL表", name_or_callable="mysql_list_tables", args_schema=TableListModel)
def mysql_list_tables() -> str: def mysql_list_tables() -> str:
"""【查询表名及说明】获取数据库中的所有表名 """【查询表名及说明】获取数据库中的所有表名
@ -107,7 +107,7 @@ class TableDescribeModel(BaseModel):
table_name: str = Field(description="要查询的表名", example="users") table_name: str = Field(description="要查询的表名", example="users")
@tool(name_or_callable="mysql_describe_table", args_schema=TableDescribeModel) @tool(category="mysql", tags=["数据库", "结构"], display_name="描述MySQL表结构", name_or_callable="mysql_describe_table", args_schema=TableDescribeModel)
def mysql_describe_table(table_name: Annotated[str, "要查询结构的表名"]) -> str: def mysql_describe_table(table_name: Annotated[str, "要查询结构的表名"]) -> str:
"""【描述表】获取指定表的详细结构信息 """【描述表】获取指定表的详细结构信息
@ -203,7 +203,7 @@ class QueryModel(BaseModel):
timeout: int | None = Field(default=60, description="查询超时时间默认60秒最大600秒", ge=1, le=600) timeout: int | None = Field(default=60, description="查询超时时间默认60秒最大600秒", ge=1, le=600)
@tool(name_or_callable="mysql_query", args_schema=QueryModel) @tool(category="mysql", tags=["数据库", "SQL"], display_name="执行MySQL查询", name_or_callable="mysql_query", args_schema=QueryModel)
def mysql_query( def mysql_query(
sql: Annotated[str, "要执行的SQL查询语句只能是SELECT语句"], sql: Annotated[str, "要执行的SQL查询语句只能是SELECT语句"],
timeout: Annotated[int | None, "查询超时时间默认60秒最大600秒"] = 60, timeout: Annotated[int | None, "查询超时时间默认60秒最大600秒"] = 60,

View File

@ -0,0 +1,123 @@
from collections.abc import Callable
from dataclasses import dataclass, field
@dataclass
class ToolExtraMetadata:
"""附加元数据(用装饰器注册)"""
category: str = "" # 分类: buildin, mysql, subagents, debug
tags: list[str] = field(default_factory=list)
display_name: str = "" # 显示名称(给人看的名字)
icon: str = ""
# 全局注册表: tool_name -> ToolExtraMetadata
_extra_registry: dict[str, ToolExtraMetadata] = {}
# 全局工具实例列表(由 @tool 装饰器自动收集)
_all_tool_instances: list = []
def register_tool(
category: str,
tags: list[str] = None,
display_name: str = "",
icon: str = "",
):
"""装饰器:注册工具附加元数据
使用方式:
@register_tool(category="buildin", tags=["内置"], display_name="计算器")
def calculator(...):
...
"""
def decorator(func_or_tool: Callable) -> Callable:
# 获取工具名称(优先用 metadata.name
tool_name = getattr(func_or_tool, "name", None) or getattr(
func_or_tool, "__name__", ""
)
_extra_registry[tool_name] = ToolExtraMetadata(
category=category,
tags=tags or [],
display_name=display_name,
icon=icon,
)
return func_or_tool
return decorator
def get_extra_metadata(tool_name: str) -> ToolExtraMetadata | None:
"""获取工具附加元数据"""
return _extra_registry.get(tool_name)
def get_all_extra_metadata() -> dict[str, ToolExtraMetadata]:
"""获取所有附加元数据"""
return _extra_registry.copy()
def get_all_tool_instances() -> list:
"""获取所有工具实例(由 @tool 装饰器自动收集)"""
return _all_tool_instances
# 基于 langchain.tool 的拓展装饰器
def tool(
category: str = "",
tags: list[str] = None,
display_name: str = "",
icon: str = "",
name_or_callable: str | Callable | None = None,
description: str | None = None,
args_schema: type | None = None,
return_direct: bool = True,
):
"""基于 langchain.tool 的拓展装饰器,同时注册元数据
使用方式:
@tool(category="buildin", tags=["计算"], display_name="计算器")
def calculator(a: float, b: float, operation: str) -> float:
...
或者保留原有的 name_or_callable description:
@tool(
category="buildin",
display_name="查询知识图谱",
name_or_callable="查询知识图谱",
description=KG_QUERY_DESCRIPTION,
)
def query_knowledge_graph(query: str) -> str:
...
"""
from langchain.tools import tool as langchain_tool
# 先应用 langchain tool 装饰器
langchain_decorator = langchain_tool(
name_or_callable=name_or_callable,
description=description,
args_schema=args_schema,
return_direct=return_direct,
)
def decorator(func: Callable) -> Callable:
# 应用 langchain 装饰器
tool_obj = langchain_decorator(func)
# 注册附加元数据
tool_name = tool_obj.name
_extra_registry[tool_name] = ToolExtraMetadata(
category=category,
tags=tags or [],
display_name=display_name,
icon=icon,
)
# 自动收集工具实例
_all_tool_instances.append(tool_obj)
return tool_obj
return decorator

View File

@ -0,0 +1,55 @@
import traceback
from typing import Any
from src.utils import logger
def gen_tool_info(tools) -> list[dict[str, Any]]:
"""获取所有工具的信息(用于前端展示)"""
tools_info = []
try:
# 获取注册的工具信息
for tool_obj in tools:
try:
metadata = getattr(tool_obj, "metadata", {}) or {}
info = {
"id": tool_obj.name,
"name": metadata.get("name", tool_obj.name),
"description": tool_obj.description,
"metadata": metadata,
"args": [],
# "is_async": is_async # Include async information
}
if hasattr(tool_obj, "args_schema") and tool_obj.args_schema:
if isinstance(tool_obj.args_schema, dict):
schema = tool_obj.args_schema
else:
schema = tool_obj.args_schema.schema()
for arg_name, arg_info in schema.get("properties", {}).items():
info["args"].append(
{
"name": arg_name,
"type": arg_info.get("type", ""),
"description": arg_info.get("description", ""),
}
)
tools_info.append(info)
# logger.debug(f"Successfully processed tool info for {tool_obj.name}")
except Exception as e:
logger.error(
f"Failed to process tool {getattr(tool_obj, 'name', 'unknown')}: {e}\n{traceback.format_exc()}. "
f"Details: {dict(tool_obj.__dict__)}"
)
continue
except Exception as e:
logger.error(f"Failed to get tools info: {e}\n{traceback.format_exc()}")
return []
logger.info(f"Successfully extracted info for {len(tools_info)} tools")
return tools_info

View File

@ -12,7 +12,7 @@ from langchain.agents.middleware import (
from src.agents.common import BaseAgent, load_chat_model from src.agents.common import BaseAgent, load_chat_model
from src.agents.common.backends import create_agent_composite_backend from src.agents.common.backends import create_agent_composite_backend
from src.agents.common.middlewares import RuntimeConfigMiddleware, SummaryOffloadMiddleware, save_attachments_to_fs from src.agents.common.middlewares import RuntimeConfigMiddleware, SummaryOffloadMiddleware, save_attachments_to_fs
from src.agents.common.tools import get_tavily_search from src.agents.common.toolkits.buildin.tools import get_tavily_search
from src.services.mcp_service import get_tools_from_all_servers from src.services.mcp_service import get_tools_from_all_servers
from src.utils import logger from src.utils import logger

View File

@ -80,7 +80,7 @@ def validate_skill_slug(slug: str) -> str:
def _get_buildin_tool_names() -> list[str]: def _get_buildin_tool_names() -> list[str]:
from src.agents.common.tools import get_buildin_tools from src.agents.common.toolkits.buildin import get_buildin_tools
return [tool.name for tool in get_buildin_tools()] return [tool.name for tool in get_buildin_tools()]
@ -91,11 +91,19 @@ def get_skills_root_dir() -> Path:
return root return root
async def get_skill_dependency_options(db: AsyncSession) -> dict[str, list[str]]: async def get_skill_dependency_options(db: AsyncSession) -> dict[str, list[str] | list[dict]]:
repo = SkillRepository(db) repo = SkillRepository(db)
items = await repo.list_all() items = await repo.list_all()
# 获取所有工具(不仅仅是 buildin 工具),返回 id 和 name
from src.services.tool_service import get_tool_metadata
all_tools = get_tool_metadata()
# 返回工具列表,每个包含 id 和 name
tool_list = [{"id": tool["id"], "name": tool.get("name", tool["id"])} for tool in all_tools]
return { return {
"tools": _get_buildin_tool_names(), "tools": tool_list,
"mcps": get_mcp_server_names(), "mcps": get_mcp_server_names(),
"skills": [item.slug for item in items], "skills": [item.slug for item in items],
} }
@ -106,6 +114,14 @@ async def list_skills(db: AsyncSession) -> list[Skill]:
return await repo.list_all() return await repo.list_all()
def _get_all_tool_names() -> list[str]:
"""获取所有工具名称(包括 buildin 和其他来源)"""
from src.services.tool_service import get_tool_metadata
all_tools = get_tool_metadata()
return [tool["id"] for tool in all_tools]
def _validate_dependencies( def _validate_dependencies(
*, *,
slug: str, slug: str,
@ -118,7 +134,8 @@ def _validate_dependencies(
mcps = _normalize_string_list(mcp_dependencies) mcps = _normalize_string_list(mcp_dependencies)
skills = _normalize_string_list(skill_dependencies) skills = _normalize_string_list(skill_dependencies)
available_tools = set(_get_buildin_tool_names()) # 验证所有工具(不仅仅是 buildin
available_tools = set(_get_all_tool_names())
invalid_tools = [name for name in tools if name not in available_tools] invalid_tools = [name for name in tools if name not in available_tools]
if invalid_tools: if invalid_tools:
raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}") raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}")

View File

@ -0,0 +1,77 @@
from src.utils import logger
# 工具元数据缓存
_metadata_cache: list[dict] = []
def _extract_tool_info(tool_obj) -> dict:
"""从 tool_obj 提取基础信息"""
metadata = getattr(tool_obj, "metadata", {}) or {}
info = {
"id": tool_obj.name,
"name": metadata.get("name", tool_obj.name), # 显示名称优先从 metadata 获取
"description": tool_obj.description,
"metadata": metadata,
"args": [],
}
if hasattr(tool_obj, "args_schema") and tool_obj.args_schema:
schema = tool_obj.args_schema
if hasattr(schema, "schema"):
schema = schema.schema()
for arg_name, arg_info in schema.get("properties", {}).items():
info["args"].append(
{
"name": arg_name,
"type": arg_info.get("type", ""),
"description": arg_info.get("description", ""),
}
)
return info
def _ensure_metadata_loaded():
"""延迟加载工具元数据(首次调用时自动触发)"""
global _metadata_cache
if _metadata_cache: # 已加载
return
from src.agents.common.toolkits.registry import (
get_all_extra_metadata,
get_all_tool_instances,
)
# 获取所有工具实例
all_tools = get_all_tool_instances()
extra_meta = get_all_extra_metadata()
for tool in all_tools:
tool_name = tool.name
runtime_info = _extract_tool_info(tool)
# 合并附加元数据
if tool_name in extra_meta:
extra = extra_meta[tool_name]
runtime_info["category"] = extra.category
runtime_info["tags"] = extra.tags
# display_name 优先级高于 tool.name
if extra.display_name:
runtime_info["name"] = extra.display_name
else:
# 未注册,设为默认分类
runtime_info["category"] = "buildin"
runtime_info["tags"] = []
_metadata_cache.append(runtime_info)
logger.info(f"Tool service loaded {len(_metadata_cache)} tools (lazy load)")
def get_tool_metadata(category: str = None) -> list[dict]:
"""获取工具元数据列表(延迟加载)"""
_ensure_metadata_loaded()
if category:
return [t for t in _metadata_cache if t.get("category") == category]
return _metadata_cache

View File

@ -1,430 +0,0 @@
import datetime as dt
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from src.utils.datetime_utils import coerce_datetime, format_utc_datetime, utc_now
Base = declarative_base()
## Removed legacy RDBMS knowledge models (KnowledgeDatabase/KnowledgeFile/KnowledgeNode)
class Department(Base):
"""部门模型"""
__tablename__ = "departments"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False, unique=True, index=True)
description = Column(String(255), nullable=True)
created_at = Column(DateTime, default=utc_now)
# 关联关系
users = relationship("User", back_populates="department")
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"description": self.description,
"created_at": format_utc_datetime(self.created_at),
}
class Conversation(Base):
"""Conversation table - new storage system"""
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
thread_id = Column(String(64), unique=True, index=True, nullable=False, comment="Thread ID (UUID)")
user_id = Column(String(64), index=True, nullable=False, comment="User ID")
agent_id = Column(String(64), index=True, nullable=False, comment="Agent ID")
title = Column(String(255), nullable=True, comment="Conversation title")
status = Column(String(20), default="active", comment="Status: active/archived/deleted")
created_at = Column(DateTime, default=utc_now, comment="Creation time")
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, comment="Update time")
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata")
# Relationships
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
stats = relationship(
"ConversationStats", back_populates="conversation", uselist=False, cascade="all, delete-orphan"
)
def to_dict(self):
return {
"id": self.id,
"thread_id": self.thread_id,
"user_id": self.user_id,
"agent_id": self.agent_id,
"title": self.title,
"status": self.status,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"metadata": self.extra_metadata or {},
}
class Message(Base):
"""Message table - stores conversation messages"""
__tablename__ = "messages"
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
conversation_id = Column(
Integer, ForeignKey("conversations.id"), nullable=False, index=True, comment="Conversation ID"
)
role = Column(String(20), nullable=False, comment="Message role: user/assistant/system/tool")
content = Column(Text, nullable=False, comment="Message content")
message_type = Column(String(30), default="text", comment="Message type: text/tool_call/tool_result")
created_at = Column(DateTime, default=utc_now, comment="Creation time")
token_count = Column(Integer, nullable=True, comment="Token count (optional)")
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata (complete message dump)")
image_content = Column(Text, nullable=True, comment="Base64 encoded image content for multimodal messages")
# Relationships
conversation = relationship("Conversation", back_populates="messages")
tool_calls = relationship("ToolCall", back_populates="message", cascade="all, delete-orphan")
def to_dict(self):
return {
"id": self.id,
"conversation_id": self.conversation_id,
"role": self.role,
"content": self.content,
"message_type": self.message_type,
"created_at": format_utc_datetime(self.created_at),
"token_count": self.token_count,
"metadata": self.extra_metadata or {},
"image_content": self.image_content,
"tool_calls": [tc.to_dict() for tc in self.tool_calls] if self.tool_calls else [],
}
def to_simple_dict(self):
return {
"role": self.role,
"content": self.content,
}
class ToolCall(Base):
"""ToolCall table - stores tool invocations"""
__tablename__ = "tool_calls"
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
message_id = Column(Integer, ForeignKey("messages.id"), nullable=False, index=True, comment="Message ID")
langgraph_tool_call_id = Column(
String(100), nullable=True, index=True, comment="LangGraph tool_call_id for matching"
)
tool_name = Column(String(100), nullable=False, comment="Tool name")
tool_input = Column(JSON, nullable=True, comment="Tool input parameters")
tool_output = Column(Text, nullable=True, comment="Tool execution result")
status = Column(String(20), default="pending", comment="Status: pending/success/error")
error_message = Column(Text, nullable=True, comment="Error message if failed")
created_at = Column(DateTime, default=utc_now, comment="Creation time")
# Relationships
message = relationship("Message", back_populates="tool_calls")
def to_dict(self):
return {
"id": self.id,
"message_id": self.message_id,
"langgraph_tool_call_id": self.langgraph_tool_call_id,
"tool_name": self.tool_name,
"tool_input": self.tool_input or {},
"tool_output": self.tool_output,
"status": self.status,
"error_message": self.error_message,
"created_at": format_utc_datetime(self.created_at),
}
class ConversationStats(Base):
"""ConversationStats table - stores conversation statistics"""
__tablename__ = "conversation_stats"
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
conversation_id = Column(
Integer, ForeignKey("conversations.id"), unique=True, nullable=False, comment="Conversation ID"
)
message_count = Column(Integer, default=0, comment="Total message count")
total_tokens = Column(Integer, default=0, comment="Total tokens used")
model_used = Column(String(100), nullable=True, comment="Model used")
user_feedback = Column(JSON, nullable=True, comment="User feedback")
created_at = Column(DateTime, default=utc_now, comment="Creation time")
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, comment="Update time")
# Relationships
conversation = relationship("Conversation", back_populates="stats")
def to_dict(self):
return {
"id": self.id,
"conversation_id": self.conversation_id,
"message_count": self.message_count,
"total_tokens": self.total_tokens,
"model_used": self.model_used,
"user_feedback": self.user_feedback or {},
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class User(Base):
"""用户模型"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String, nullable=False, unique=True, index=True) # 显示名称2-20字符
user_id = Column(String, nullable=False, unique=True, index=True) # 登录ID根据用户名生成
phone_number = Column(String, nullable=True, unique=True, index=True) # 手机号,可选登录方式
avatar = Column(String, nullable=True) # 头像URL
password_hash = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # 角色: superadmin, admin, user
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True) # 部门ID
created_at = Column(DateTime, default=utc_now)
last_login = Column(DateTime, nullable=True)
# 登录失败限制相关字段
login_failed_count = Column(Integer, nullable=False, default=0) # 登录失败次数
last_failed_login = Column(DateTime, nullable=True) # 最后一次登录失败时间
login_locked_until = Column(DateTime, nullable=True) # 锁定到什么时候
# 软删除相关字段
is_deleted = Column(Integer, nullable=False, default=0, index=True) # 是否已删除0=否1=是
deleted_at = Column(DateTime, nullable=True) # 删除时间
# 关联操作日志
operation_logs = relationship("OperationLog", back_populates="user", cascade="all, delete-orphan")
# 关联部门
department = relationship("Department", back_populates="users")
def to_dict(self, include_password=False):
# SQLite 存储 naive datetime需要标记为 UTC 后再转换
result = {
"id": self.id,
"username": self.username,
"user_id": self.user_id,
"phone_number": self.phone_number,
"avatar": self.avatar,
"role": self.role,
"department_id": self.department_id,
"created_at": format_utc_datetime(self.created_at),
"last_login": format_utc_datetime(self.last_login),
"login_failed_count": self.login_failed_count,
"last_failed_login": format_utc_datetime(self.last_failed_login),
"login_locked_until": format_utc_datetime(self.login_locked_until),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
if include_password:
result["password_hash"] = self.password_hash
return result
def is_login_locked(self):
"""检查用户是否处于登录锁定状态"""
lock_deadline = coerce_datetime(self.login_locked_until)
if lock_deadline is None:
return False
return utc_now() < lock_deadline
def get_remaining_lock_time(self):
"""获取剩余锁定时间(秒)"""
lock_deadline = coerce_datetime(self.login_locked_until)
if lock_deadline is None:
return 0
remaining = int((lock_deadline - utc_now()).total_seconds())
return max(0, remaining)
def calculate_lock_duration(self):
"""根据失败次数计算锁定时长(秒)"""
if self.login_failed_count < 10:
return 0
# 从第10次失败开始等待时间从1秒开始每次翻倍
wait_seconds = 2 ** (self.login_failed_count - 10)
# 最大锁定时间365天
max_seconds = 365 * 24 * 60 * 60
return min(wait_seconds, max_seconds)
def increment_failed_login(self):
"""增加登录失败次数并设置锁定时间"""
self.login_failed_count += 1
self.last_failed_login = utc_now()
lock_duration = self.calculate_lock_duration()
if lock_duration > 0:
self.login_locked_until = utc_now() + dt.timedelta(seconds=lock_duration)
def reset_failed_login(self):
"""重置登录失败相关字段"""
self.login_failed_count = 0
self.last_failed_login = None
self.login_locked_until = None
class OperationLog(Base):
"""操作日志模型"""
__tablename__ = "operation_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
operation = Column(String, nullable=False)
details = Column(Text, nullable=True)
ip_address = Column(String, nullable=True)
timestamp = Column(DateTime, default=utc_now)
# 关联用户
user = relationship("User", back_populates="operation_logs")
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"operation": self.operation,
"details": self.details,
"ip_address": self.ip_address,
"timestamp": format_utc_datetime(self.timestamp),
}
class MessageFeedback(Base):
"""Message feedback table - stores user feedback on AI responses"""
__tablename__ = "message_feedbacks"
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
message_id = Column(
Integer, ForeignKey("messages.id"), nullable=False, index=True, comment="Message ID being rated"
)
user_id = Column(String(64), nullable=False, index=True, comment="User ID who provided feedback")
rating = Column(String(10), nullable=False, comment="Feedback rating: like or dislike")
reason = Column(Text, nullable=True, comment="Optional reason for dislike feedback")
created_at = Column(DateTime, default=utc_now, comment="Feedback creation time")
# Relationships
message = relationship("Message", backref="feedbacks")
def to_dict(self):
return {
"id": self.id,
"message_id": self.message_id,
"user_id": self.user_id,
"rating": self.rating,
"reason": self.reason,
"created_at": format_utc_datetime(self.created_at),
}
class MCPServer(Base):
"""MCP 服务器配置模型"""
__tablename__ = "mcp_servers"
# 核心字段 - name 作为主键
name = Column(String(100), primary_key=True, comment="服务器名称(唯一标识)")
description = Column(String(500), nullable=True, comment="描述")
# 连接配置
transport = Column(String(20), nullable=False, comment="传输类型sse/streamable_http/stdio")
url = Column(String(500), nullable=True, comment="服务器 URLsse/streamable_http")
command = Column(String(500), nullable=True, comment="命令stdio")
args = Column(JSON, nullable=True, comment="命令参数数组stdio")
env = Column(JSON, nullable=True, comment="环境变量stdio")
headers = Column(JSON, nullable=True, comment="HTTP 请求头")
timeout = Column(Integer, nullable=True, comment="HTTP 超时时间(秒)")
sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)")
# UI 增强字段
tags = Column(JSON, nullable=True, comment="标签数组")
icon = Column(String(50), nullable=True, comment="图标emoji")
# 状态字段
enabled = Column(Integer, nullable=False, default=1, comment="是否启用1=是0=否")
disabled_tools = Column(JSON, nullable=True, comment="禁用的工具名称列表")
# 用户追踪
created_by = Column(String(100), nullable=False, comment="创建人用户名")
updated_by = Column(String(100), nullable=False, comment="修改人用户名")
# 时间戳
created_at = Column(DateTime, default=utc_now, comment="创建时间")
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, comment="更新时间")
def to_dict(self):
return {
"name": self.name,
"description": self.description,
"transport": self.transport,
"url": self.url,
"command": self.command,
"args": self.args or [],
"env": self.env or {},
"headers": self.headers or {},
"timeout": self.timeout,
"sse_read_timeout": self.sse_read_timeout,
"tags": self.tags or [],
"icon": self.icon,
"enabled": bool(self.enabled),
"disabled_tools": self.disabled_tools or [],
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
def to_mcp_config(self) -> dict:
"""转换为 MCP 配置格式(用于加载到 MCP_SERVERS 缓存)"""
import json
config = {
"transport": self.transport,
}
if self.url:
config["url"] = self.url
if self.command:
config["command"] = self.command
# args 只用于 stdio 传输类型,必须是列表
if self.transport == "stdio" and self.args:
if isinstance(self.args, list):
config["args"] = self.args
elif isinstance(self.args, str):
try:
config["args"] = json.loads(self.args)
except json.JSONDecodeError:
pass
if self.transport == "stdio" and self.env:
if isinstance(self.env, dict):
config["env"] = self.env
elif isinstance(self.env, str):
try:
config["env"] = json.loads(self.env)
except json.JSONDecodeError:
pass
# headers 只用于 sse/streamable_http 传输类型
if self.transport in ("sse", "streamable_http") and self.headers:
if isinstance(self.headers, dict):
config["headers"] = self.headers
elif isinstance(self.headers, str):
try:
config["headers"] = json.loads(self.headers)
except json.JSONDecodeError:
pass
if self.timeout is not None:
config["timeout"] = self.timeout
if self.sse_read_timeout is not None:
config["sse_read_timeout"] = self.sse_read_timeout
if self.disabled_tools:
config["disabled_tools"] = self.disabled_tools
return config

View File

@ -13,6 +13,7 @@ export * from './mindmap_api' // 思维导图API
export * from './department_api' // 部门管理API export * from './department_api' // 部门管理API
export * from './mcp_api' // MCP API export * from './mcp_api' // MCP API
export * from './skill_api' // Skills API export * from './skill_api' // Skills API
export * from './tool_api' // 工具 API
// 导出基础工具函数 // 导出基础工具函数
export { export {

37
web/src/apis/tool_api.js Normal file
View File

@ -0,0 +1,37 @@
import { apiAdminGet } from './base'
/**
* 工具管理 API 模块
* 包含系统内置工具的查询功能
*/
const BASE_URL = '/api/system/tools'
/**
* 获取工具列表
* @param {string} category - 可选按分类筛选
* @returns {Promise} - 工具列表
*/
export const getTools = async (category = null) => {
const params = category ? { category } : {}
return apiAdminGet(BASE_URL, params)
}
/**
* 获取工具选项列表用于下拉选择
* @returns {Promise} - 工具选项
*/
export const getToolOptions = async () => {
return apiAdminGet(`${BASE_URL}/options`)
}
// =============================================================================
// === 导出为对象形式(兼容现有代码风格)===
// =============================================================================
export const toolApi = {
getTools,
getToolOptions
}
export default toolApi

View File

@ -95,6 +95,14 @@
} }
} }
.list-separator {
height: 1px;
background-color: var(--gray-150);
margin: 2px 12px;
opacity: 0.8; // Deepened from 0.5 to 0.8
flex-shrink: 0;
}
/* Main Panel */ /* Main Panel */
.main-panel { .main-panel {
flex: 1; flex: 1;

View File

@ -373,6 +373,7 @@ import { useAgentStore } from '@/stores/agent'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useDatabaseStore } from '@/stores/database' import { useDatabaseStore } from '@/stores/database'
import { skillApi } from '@/apis/skill_api' import { skillApi } from '@/apis/skill_api'
import { toolApi } from '@/apis/tool_api'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
// Props // Props
@ -401,6 +402,7 @@ watch(
if (val) { if (val) {
databaseStore.loadDatabases().catch(() => {}) databaseStore.loadDatabases().catch(() => {})
loadLiveSkillOptions().catch(() => {}) loadLiveSkillOptions().catch(() => {})
loadToolOptions().catch(() => {})
if (selectedAgentId.value) { if (selectedAgentId.value) {
try { try {
await agentStore.fetchAgentDetail(selectedAgentId.value, true) await agentStore.fetchAgentDetail(selectedAgentId.value, true)
@ -433,6 +435,7 @@ const selectionSearchText = ref('')
const systemPromptEditMode = ref(false) const systemPromptEditMode = ref(false)
const activeTab = ref('basic') const activeTab = ref('basic')
const liveSkillOptions = ref([]) const liveSkillOptions = ref([])
const toolOptionsFromApi = ref([])
const isEmptyConfig = computed(() => { const isEmptyConfig = computed(() => {
return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0 return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0
@ -491,10 +494,30 @@ const loadLiveSkillOptions = async () => {
} }
} }
const loadToolOptions = async () => {
if (!userStore.isAdmin) {
toolOptionsFromApi.value = []
return
}
try {
const result = await toolApi.getTools()
toolOptionsFromApi.value = (result?.data || []).map((item) => ({
id: item.id,
name: item.name || item.id,
description: item.description || ''
}))
} catch (error) {
console.warn('加载工具列表失败:', error)
}
}
// //
const getConfigOptions = (value) => { const getConfigOptions = (value) => {
if (value?.template_metadata?.kind === 'tools') { if (value?.template_metadata?.kind === 'tools') {
return availableTools.value ? Object.values(availableTools.value) : [] // 使 API 退 configurableItems
return toolOptionsFromApi.value.length > 0
? toolOptionsFromApi.value
: (availableTools.value ? Object.values(availableTools.value) : [])
} }
if (value?.template_metadata?.kind === 'knowledges') { if (value?.template_metadata?.kind === 'knowledges') {
return databaseStore.databases || [] return databaseStore.databases || []
@ -644,13 +667,9 @@ const getOptionLabelFromValue = (key, val) => {
const openSelectionModal = async (key) => { const openSelectionModal = async (key) => {
currentConfigKey.value = key currentConfigKey.value = key
// // API
if (configurableItems.value[key]?.template_metadata?.kind === 'tools' && selectedAgentId.value) { if (configurableItems.value[key]?.template_metadata?.kind === 'tools') {
try { await loadToolOptions()
await agentStore.fetchAgentDetail(selectedAgentId.value, true)
} catch (error) {
console.error('刷新工具列表失败:', error)
}
} }
// //
if (configurableItems.value[key]?.template_metadata?.kind === 'knowledges') { if (configurableItems.value[key]?.template_metadata?.kind === 'knowledges') {

View File

@ -25,29 +25,30 @@
<div v-if="filteredServers.length === 0" class="empty-text"> <div v-if="filteredServers.length === 0" class="empty-text">
<a-empty :image="false" :description="searchQuery ? '无匹配服务器' : '暂无服务器'" /> <a-empty :image="false" :description="searchQuery ? '无匹配服务器' : '暂无服务器'" />
</div> </div>
<div <template v-for="(server, index) in filteredServers" :key="server.name">
v-for="server in filteredServers" <div
:key="server.name" class="list-item"
class="list-item" :class="{ active: currentServer?.name === server.name, disabled: !server.enabled }"
:class="{ active: currentServer?.name === server.name, disabled: !server.enabled }" @click="selectServer(server)"
@click="selectServer(server)" >
> <div class="item-header">
<div class="item-header"> <span class="server-icon">{{ server.icon || '🔌' }}</span>
<span class="server-icon">{{ server.icon || '🔌' }}</span> <span class="item-name">{{ server.name }}</span>
<span class="item-name">{{ server.name }}</span> <a-switch
<a-switch size="small"
size="small" :checked="server.enabled"
:checked="server.enabled" @change="handleToggleServer(server)"
@change="handleToggleServer(server)" @click.stop
@click.stop :loading="toggleLoading === server.name"
:loading="toggleLoading === server.name" />
/> </div>
<div class="item-details">
<a-tag size="small" class="transport-tag">{{ server.transport }}</a-tag>
<span class="item-desc">{{ server.description || '暂无描述' }}</span>
</div>
</div> </div>
<div class="item-details"> <div v-if="index < filteredServers.length - 1" class="list-separator"></div>
<a-tag size="small" class="transport-tag">{{ server.transport }}</a-tag> </template>
<span class="item-desc">{{ server.description || '暂无描述' }}</span>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -16,25 +16,26 @@
<div v-if="filteredSkills.length === 0" class="empty-text"> <div v-if="filteredSkills.length === 0" class="empty-text">
<a-empty :image="false" description="无匹配技能" /> <a-empty :image="false" description="无匹配技能" />
</div> </div>
<div <template v-for="(skill, index) in filteredSkills" :key="skill.slug">
v-for="skill in filteredSkills" <div
:key="skill.slug" class="list-item"
class="list-item" :class="{ active: currentSkill?.slug === skill.slug }"
:class="{ active: currentSkill?.slug === skill.slug }" @click="selectSkill(skill)"
@click="selectSkill(skill)" >
> <div class="item-header">
<div class="item-header"> <Box :size="16" class="item-icon" />
<Box :size="16" class="item-icon" /> <span class="item-name">{{ skill.name }}</span>
<span class="item-name">{{ skill.name }}</span> </div>
</div> <div class="item-details">
<div class="item-details"> <span class="item-slug">{{ skill.slug }}</span>
<span class="item-slug">{{ skill.slug }}</span> <div class="item-badges">
<div class="item-badges"> <span v-if="skill.tool_dependencies?.length" class="dot-badge blue" title="工具依赖"></span>
<span v-if="skill.tool_dependencies?.length" class="dot-badge blue" title="工具依赖"></span> <span v-if="skill.mcp_dependencies?.length" class="dot-badge green" title="MCP依赖"></span>
<span v-if="skill.mcp_dependencies?.length" class="dot-badge green" title="MCP依赖"></span> </div>
</div> </div>
</div> </div>
</div> <div v-if="index < filteredSkills.length - 1" class="list-separator"></div>
</template>
</div> </div>
</div> </div>
@ -232,7 +233,9 @@ const canSave = computed(() => {
const formatRelativeTime = (time) => time ? dayjs(time).fromNow() : '-' const formatRelativeTime = (time) => time ? dayjs(time).fromNow() : '-'
const toolDependencyOptions = computed(() => (dependencyOptions.tools || []).map(i => ({ label: i, value: i }))) const toolDependencyOptions = computed(() => (dependencyOptions.tools || []).map(i =>
typeof i === 'object' ? { label: i.name, value: i.id } : { label: i, value: i }
))
const mcpDependencyOptions = computed(() => (dependencyOptions.mcps || []).map(i => ({ label: i, value: i }))) const mcpDependencyOptions = computed(() => (dependencyOptions.mcps || []).map(i => ({ label: i, value: i })))
const skillDependencyOptions = computed(() => (dependencyOptions.skills || []).filter(s => s !== currentSkill.value?.slug).map(i => ({ label: i, value: i }))) const skillDependencyOptions = computed(() => (dependencyOptions.skills || []).filter(s => s !== currentSkill.value?.slug).map(i => ({ label: i, value: i })))

View File

@ -0,0 +1,294 @@
<template>
<div class="tools-manager-container extension-page-root">
<div v-if="loading" class="loading-bar-wrapper">
<div class="loading-bar"></div>
</div>
<div class="layout-wrapper" :class="{ 'content-loading': loading }">
<!-- 左侧工具列表 -->
<div class="sidebar-list">
<div class="search-box">
<a-input v-model:value="searchQuery" placeholder="搜索工具..." allow-clear class="search-input">
<template #prefix><Search :size="14" class="text-muted" /></template>
</a-input>
</div>
<!-- 分类筛选 -->
<div class="category-filter">
<a-select v-model:value="selectedCategory" placeholder="全部分类" allow-clear style="width: 100%">
<a-select-option value="">全部分类</a-select-option>
<a-select-option v-for="cat in categories" :key="cat" :value="cat">
{{ categoryLabels[cat] || cat }}
</a-select-option>
</a-select>
</div>
<div class="list-container">
<div v-if="filteredTools.length === 0" class="empty-text">
<a-empty :image="false" description="无匹配工具" />
</div>
<template v-for="(tool, index) in filteredTools" :key="tool.id">
<div
class="list-item"
:class="{ active: currentTool?.id === tool.id }"
@click="selectTool(tool)"
>
<div class="item-header">
<Wrench :size="16" class="item-icon" />
<span class="item-name">{{ tool.name }}</span>
</div>
<div class="item-details">
<span class="item-category">{{ categoryLabels[tool.category] || tool.category }}</span>
<div class="item-tags">
<a-tag v-for="tag in tool.tags" :key="tag" size="small" class="tool-tag">{{ tag }}</a-tag>
</div>
</div>
</div>
<div v-if="index < filteredTools.length - 1" class="list-separator"></div>
</template>
</div>
</div>
<!-- 右侧详情面板 -->
<div class="main-panel">
<div v-if="!currentTool" class="unselected-state">
<div class="hint-box">
<Wrench :size="40" class="text-muted" />
<p>请在左侧选择工具查看详情</p>
</div>
</div>
<template v-else>
<div class="panel-top-bar">
<div class="tool-summary">
<h2>{{ currentTool.name }}</h2>
<code>{{ currentTool.id }}</code>
</div>
</div>
<div class="tool-detail">
<div class="detail-section">
<div class="section-header">
<FileText :size="14" />
<span>描述</span>
</div>
<div class="section-content description">
{{ currentTool.description || '无描述' }}
</div>
</div>
<div class="detail-section">
<div class="section-header">
<Tag :size="14" />
<span>分类</span>
</div>
<div class="section-content">
<a-tag :color="categoryColors[currentTool.category] || 'default'">
{{ categoryLabels[currentTool.category] || currentTool.category }}
</a-tag>
</div>
</div>
<div class="detail-section">
<div class="section-header">
<Tags :size="14" />
<span>标签</span>
</div>
<div class="section-content">
<a-tag v-for="tag in currentTool.tags" :key="tag">{{ tag }}</a-tag>
<span v-if="!currentTool.tags?.length" class="text-muted"></span>
</div>
</div>
<div class="detail-section" v-if="currentTool.args?.length">
<div class="section-header">
<List :size="14" />
<span>参数</span>
</div>
<div class="section-content">
<a-table
:dataSource="currentTool.args"
:columns="argColumns"
size="small"
:pagination="false"
bordered
class="args-table"
/>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
Search, Wrench, Tag, Tags, FileText, List
} from 'lucide-vue-next'
import { toolApi } from '@/apis/tool_api'
const loading = ref(false)
const searchQuery = ref('')
const selectedCategory = ref('')
const tools = ref([])
const currentTool = ref(null)
const categories = ['buildin', 'mysql', 'debug']
const categoryLabels = {
buildin: '内置工具',
mysql: 'MySQL',
debug: '调试'
}
const categoryColors = {
buildin: 'blue',
mysql: 'green',
debug: 'orange'
}
const argColumns = [
{ title: '参数名', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'type', key: 'type', width: 80 },
{ title: '描述', dataIndex: 'description', key: 'description' }
]
const filteredTools = computed(() => {
let result = tools.value
if (selectedCategory.value) {
result = result.filter(t => t.category === selectedCategory.value)
}
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(t =>
t.name.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q)
)
}
return result
})
const fetchTools = async () => {
loading.value = true
try {
const result = await toolApi.getTools()
tools.value = result?.data || []
//
if (!currentTool.value && tools.value.length > 0) {
currentTool.value = tools.value[0]
}
} catch (error) {
message.error('加载工具失败')
} finally {
loading.value = false
}
}
const selectTool = (record) => {
currentTool.value = record
}
onMounted(fetchTools)
//
defineExpose({
fetchTools
})
</script>
<style scoped lang="less">
@import '@/assets/css/extensions.less';
.category-filter {
padding: 8px 12px;
border-bottom: 1px solid var(--gray-150);
}
.list-item {
.item-details {
display: flex;
justify-content: space-between;
align-items: center;
.item-category {
font-size: 12px;
color: var(--gray-600);
}
.item-tags {
display: flex;
gap: 2px;
flex-wrap: wrap;
max-width: 100px;
justify-content: flex-end;
.tool-tag {
color: var(--gray-500);
font-size: 11px;
padding: 0 4px;
margin: 0;
border: none;
}
}
}
}
/* 右侧面板 */
.main-panel {
.panel-top-bar {
.tool-summary {
min-height: 32px;
display: flex;
flex-direction: column;
justify-content: center;
code {
font-size: 12px;
color: var(--gray-500);
border-radius: 4px;
margin-top: 4px;
display: inline-block;
user-select: text;
}
}
}
}
.tool-detail {
padding: 16px;
flex: 1;
overflow-y: auto;
.detail-section {
margin-bottom: 20px;
.section-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--gray-700);
margin-bottom: 8px;
svg {
color: var(--gray-500);
}
}
.section-content {
&.description {
font-size: 13px;
color: var(--gray-600);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
}
}
}
.args-table {
:deep(.ant-table) {
font-size: 12px;
}
}
</style>

View File

@ -3,6 +3,7 @@
<div class="extensions-header"> <div class="extensions-header">
<a-tabs v-model:activeKey="activeTab" class="extensions-tabs"> <a-tabs v-model:activeKey="activeTab" class="extensions-tabs">
<a-tab-pane key="skills" tab="Skills 管理" /> <a-tab-pane key="skills" tab="Skills 管理" />
<a-tab-pane key="tools" tab="工具" />
<a-tab-pane key="mcp" tab="MCP 服务器" /> <a-tab-pane key="mcp" tab="MCP 服务器" />
</a-tabs> </a-tabs>
<div class="header-actions"> <div class="header-actions">
@ -24,6 +25,13 @@
<span>刷新</span> <span>刷新</span>
</a-button> </a-button>
</template> </template>
<!-- Tools Tab 的按钮 -->
<template v-else-if="activeTab === 'tools'">
<a-button @click="handleToolsRefresh" :disabled="toolsLoading" class="lucide-icon-btn">
<RotateCw :size="14" />
<span>刷新</span>
</a-button>
</template>
<!-- MCP Tab 的按钮 --> <!-- MCP Tab 的按钮 -->
<template v-else-if="activeTab === 'mcp'"> <template v-else-if="activeTab === 'mcp'">
<a-button type="primary" @click="handleMcpAdd" class="lucide-icon-btn"> <a-button type="primary" @click="handleMcpAdd" class="lucide-icon-btn">
@ -46,6 +54,12 @@
@refresh="handleSkillsRefresh" @refresh="handleSkillsRefresh"
/> />
</div> </div>
<div v-show="activeTab === 'tools'" class="tab-panel">
<ToolsManagerComponent
ref="toolsRef"
@refresh="handleToolsRefresh"
/>
</div>
<div v-show="activeTab === 'mcp'" class="tab-panel"> <div v-show="activeTab === 'mcp'" class="tab-panel">
<McpServersComponent <McpServersComponent
ref="mcpRef" ref="mcpRef"
@ -61,15 +75,18 @@
import { ref } from 'vue' import { ref } from 'vue'
import { Upload, RotateCw, Plus } from 'lucide-vue-next' import { Upload, RotateCw, Plus } from 'lucide-vue-next'
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue' import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue'
import McpServersComponent from '@/components/McpServersComponent.vue' import McpServersComponent from '@/components/McpServersComponent.vue'
const activeTab = ref('skills') const activeTab = ref('skills')
const skillsRef = ref(null) const skillsRef = ref(null)
const toolsRef = ref(null)
const mcpRef = ref(null) const mcpRef = ref(null)
// Skills // Skills
const skillsLoading = ref(false) const skillsLoading = ref(false)
const skillsImporting = ref(false) const skillsImporting = ref(false)
const toolsLoading = ref(false)
const mcpLoading = ref(false) const mcpLoading = ref(false)
// //
@ -78,6 +95,10 @@ const updateSkillsState = (loading, importing) => {
skillsImporting.value = importing skillsImporting.value = importing
} }
const updateToolsState = (loading) => {
toolsLoading.value = loading
}
const updateMcpState = (loading) => { const updateMcpState = (loading) => {
mcpLoading.value = loading mcpLoading.value = loading
} }
@ -97,6 +118,16 @@ const handleSkillsRefresh = () => {
} }
} }
// Tools
const handleToolsRefresh = () => {
if (toolsRef.value?.fetchTools) {
updateToolsState(true)
toolsRef.value.fetchTools().finally(() => {
updateToolsState(false)
})
}
}
// MCP // MCP
const handleMcpAdd = () => { const handleMcpAdd = () => {
if (mcpRef.value?.showAddModal) { if (mcpRef.value?.showAddModal) {