diff --git a/backend/apps/tool_config_app.py b/backend/apps/tool_config_app.py index bfc8d5ca0..e0fcda652 100644 --- a/backend/apps/tool_config_app.py +++ b/backend/apps/tool_config_app.py @@ -1,8 +1,8 @@ import logging from http import HTTPStatus -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List -from fastapi import APIRouter, Header, HTTPException, Body +from fastapi import APIRouter, Header, HTTPException, Body, Query from fastapi.responses import JSONResponse from consts.exceptions import MCPConnectionError, NotFoundException @@ -26,14 +26,17 @@ @router.get("/list") -async def list_tools_api(authorization: Optional[str] = Header(None)): +async def list_tools_api( + authorization: Optional[str] = Header(None), + labels: Optional[str] = Query(None, description="Comma-separated label strings to filter tools (OR match)") +): """ - List all system tools from PG dataset + List all system tools from PG dataset, optionally filtered by labels. """ try: _, tenant_id = get_current_user_id(authorization) - # now only admin can modify the tool, user_id is not used - return await list_all_tools(tenant_id=tenant_id) + label_list = [lbl.strip() for lbl in labels.split(",") if lbl.strip()] if labels else None + return await list_all_tools(tenant_id=tenant_id, labels=label_list) except Exception as e: logging.error(f"Failed to get tool info, error in: {str(e)}") raise HTTPException( @@ -278,3 +281,35 @@ async def delete_openapi_service_api( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Failed to delete OpenAPI service: {str(e)}" ) + + +@router.put("/labels") +async def update_tool_labels_api( + tool_id: int = Body(..., embed=True), + labels: List[str] = Body(..., embed=True), + authorization: Optional[str] = Header(None) +): + """ + Update labels for a specific tool. Replaces all labels with the provided list. + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + from database.tool_db import update_tool_labels + updated = update_tool_labels(tool_id, tenant_id, labels, user_id) + if not updated: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Tool not found or access denied" + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={"message": "Labels updated successfully", "status": "success", "labels": labels} + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"Failed to update tool labels: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to update tool labels: {str(e)}" + ) diff --git a/backend/consts/model.py b/backend/consts/model.py index 1a0eacf7d..bdcde905e 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -627,6 +627,7 @@ class ToolInfo(BaseModel): usage: Optional[str] origin_name: Optional[str] = None category: Optional[str] = None + labels: Optional[List[str]] = None # used in Knowledge Summary request diff --git a/backend/consts/tool_labels.py b/backend/consts/tool_labels.py new file mode 100644 index 000000000..2f7228bbe --- /dev/null +++ b/backend/consts/tool_labels.py @@ -0,0 +1,54 @@ +""" +Built-in labels for well-known local tools. + +These are applied when a tool is first created for a tenant via +update_tool_table_from_scan_tool_list() — which runs during the first +API call for a new tenant (init_tool_list_for_tenant). + +Why not in SQL? + - init.sql runs before the backend starts, so the ag_tool_info_t table + is empty and UPDATE statements would hit zero rows. + - Migration SQL (docker/sql/) covers the upgrade path from v2.2.x, + but cannot cover fresh v2.3.0+ installs where tools don't exist yet. + - This module is the only hook that fires at the exact moment tools are + inserted — the earliest lifecycle point where the data exists. + +Keep in sync with: deploy/sql/migrations/v2.3.0_0624_add_labels_to_ag_tool_info.sql +""" + +# tool_name → [label, ...] +# Built per-category to avoid cross-file duplication with the matching SQL seed data. +_category_database = { + "mysql_database": ["database"], "postgres_database": ["database"], "mssql_database": ["database"], +} +_category_file = { + "read_file": ["file"], "create_file": ["file"], "delete_file": ["file"], + "create_directory": ["file"], "delete_directory": ["file"], + "list_directory": ["file"], "move_item": ["file"], +} +_category_search = { + "tavily_search": ["search"], "exa_search": ["search"], "linkup_search": ["search"], + "search_memory": ["search"], "knowledge_base_search": ["search"], +} +_category_kb = { + "dify_search": ["knowledge-base"], "datamate_search": ["knowledge-base"], + "idata_search": ["knowledge-base"], "haotian_search": ["knowledge-base"], + "aidp_search": ["knowledge-base"], +} +_category_multimodal = { + "analyze_image": ["multimodal"], "analyze_audio": ["multimodal"], + "analyze_video": ["multimodal"], "analyze_text_file": ["multimodal"], +} +_category_email = {"get_email": ["email"], "send_email": ["email"]} +_category_memory = {"store_memory": ["memory"]} +_category_terminal = {"terminal": ["terminal"]} + +BUILTIN_LABEL_MAP: dict[str, list[str]] = {} +BUILTIN_LABEL_MAP.update(_category_database) +BUILTIN_LABEL_MAP.update(_category_file) +BUILTIN_LABEL_MAP.update(_category_search) +BUILTIN_LABEL_MAP.update(_category_kb) +BUILTIN_LABEL_MAP.update(_category_multimodal) +BUILTIN_LABEL_MAP.update(_category_email) +BUILTIN_LABEL_MAP.update(_category_memory) +BUILTIN_LABEL_MAP.update(_category_terminal) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 9c981d3c0..bdba2e0bb 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -419,6 +419,7 @@ class ToolInfo(TableBase): inputs = Column(String(2048), doc="Prompt tool inputs description") output_type = Column(String(100), doc="Prompt tool output description") category = Column(String(100), doc="Tool category description") + labels = Column(JSONB, default=[], doc="JSON array of label strings for filtering/grouping tools") is_available = Column( Boolean, doc="Whether the tool can be used under the current main service") diff --git a/backend/database/tool_db.py b/backend/database/tool_db.py index 907dfd012..81ef89eef 100644 --- a/backend/database/tool_db.py +++ b/backend/database/tool_db.py @@ -5,6 +5,7 @@ from database.client import get_db_session, filter_property, as_dict from database.db_models import ToolInstance, ToolInfo from consts.model import ToolSourceEnum +from consts.tool_labels import BUILTIN_LABEL_MAP from utils.tool_utils import get_local_tools_description_zh @@ -136,6 +137,54 @@ def query_tools_by_ids(tool_id_list: List[int]): return [as_dict(tool) for tool in tools] +def query_tools_by_labels(tenant_id: str, labels: List[str]): + """ + Query ToolInfo by labels using OR match (tool has ANY of the requested labels). + + Args: + tenant_id: Tenant ID for filtering + labels: List of label strings to filter by + + Returns: + List of ToolInfo dicts matching any of the given labels + """ + with get_db_session() as session: + query = session.query(ToolInfo).filter( + ToolInfo.delete_flag != 'Y', + ToolInfo.author == tenant_id, + ToolInfo.labels.op('?|')(labels) + ) + tools = query.all() + return [as_dict(tool) for tool in tools] + + +def update_tool_labels(tool_id: int, tenant_id: str, labels: List[str], user_id: str) -> bool: + """ + Update labels for a specific tool. Replaces all existing labels. + + Args: + tool_id: Tool ID to update + tenant_id: Tenant ID for access control + labels: New list of label strings + user_id: User performing the update + + Returns: + True if updated, False if tool not found or access denied + """ + with get_db_session() as session: + tool = session.query(ToolInfo).filter( + ToolInfo.tool_id == tool_id, + ToolInfo.author == tenant_id, + ToolInfo.delete_flag != 'Y' + ).first() + if not tool: + return False + tool.labels = labels + tool.updated_by = user_id + session.flush() + return True + + def query_all_enabled_tool_instances(agent_id: int, tenant_id: str, version_no: int = 0): """ Query enabled ToolInstance in the database. @@ -243,11 +292,17 @@ def update_tool_table_from_scan_tool_list(tenant_id: str, user_id: str, tool_lis # by tool name and source to update the existing tool existing_tool = existing_tool_dict[key] for key, value in filtered_tool_data.items(): + # Preserve user-set labels; only overwrite if new labels are explicitly provided + if key == "labels" and not value: + continue setattr(existing_tool, key, value) existing_tool.updated_by = user_id existing_tool.is_available = is_available else: - # create new tool + # create new tool — apply built-in labels for fresh installs + builtin_labels = BUILTIN_LABEL_MAP.get(tool.name, []) + if builtin_labels: + filtered_tool_data["labels"] = builtin_labels filtered_tool_data.update( {"created_by": user_id, "updated_by": user_id, "author": tenant_id, "is_available": is_available}) new_tool = ToolInfo(**filtered_tool_data) @@ -394,4 +449,4 @@ def search_last_tool_instance_by_tool_id(tool_id: int, tenant_id: str, user_id: ToolInstance.delete_flag != 'Y' ).order_by(ToolInstance.update_time.desc()) tool_instance = query.first() - return as_dict(tool_instance) if tool_instance else None \ No newline at end of file + return as_dict(tool_instance) if tool_instance else None diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index 43e212ec6..32460fcee 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -30,6 +30,7 @@ check_tool_list_initialized, create_or_update_tool_by_tool_info, query_all_tools, + query_tools_by_labels, query_tool_instances_by_id, search_last_tool_instance_by_tool_id, update_tool_table_from_scan_tool_list, @@ -200,6 +201,7 @@ def get_local_tools() -> List[ToolInfo]: inputs=json.dumps(processed_inputs, ensure_ascii=False), output_type=getattr(tool_class, 'output_type'), category=getattr(tool_class, 'category'), + labels=getattr(tool_class, 'labels', None), class_name=tool_class.__name__, usage=None, origin_name=getattr(tool_class, 'name') @@ -245,7 +247,8 @@ def _build_tool_info_from_langchain(obj) -> ToolInfo: class_name=tool_name, usage=None, origin_name=tool_name, - category=None + category=None, + labels=None ) return tool_info @@ -486,11 +489,14 @@ async def update_tool_list(tenant_id: str, user_id: str): tool_list=local_tools+mcp_tools+langchain_tools) -async def list_all_tools(tenant_id: str): +async def list_all_tools(tenant_id: str, labels: Optional[List[str]] = None): """ - List all tools for a given tenant + List all tools for a given tenant, optionally filtered by labels (OR match). """ - tools_info = query_all_tools(tenant_id) + if labels: + tools_info = query_tools_by_labels(tenant_id, labels) + else: + tools_info = query_all_tools(tenant_id) # Get description_zh from SDK for local tools (not persisted to DB) local_tool_descriptions = get_local_tools_description_zh() @@ -555,7 +561,9 @@ async def list_all_tools(tenant_id: str): "usage": tool.get("usage"), "params": tool.get("params", []), "inputs": inputs_str, - "category": tool.get("category") + "category": tool.get("category"), + "labels": tool.get("labels", []), + "updated_by": tool.get("updated_by", "") } formatted_tools.append(formatted_tool) return formatted_tools diff --git a/deploy/sql/migrations/v2.3.0_0624_add_labels_to_ag_tool_info.sql b/deploy/sql/migrations/v2.3.0_0624_add_labels_to_ag_tool_info.sql new file mode 100644 index 000000000..4c1e60723 --- /dev/null +++ b/deploy/sql/migrations/v2.3.0_0624_add_labels_to_ag_tool_info.sql @@ -0,0 +1,32 @@ +-- Add labels column to ag_tool_info_t table for tool filtering/grouping +ALTER TABLE nexent.ag_tool_info_t +ADD COLUMN IF NOT EXISTS labels JSONB DEFAULT '[]'::jsonb; + +COMMENT ON COLUMN nexent.ag_tool_info_t.labels IS 'JSON array of label strings for filtering/grouping tools'; + +-- Seed built-in labels for well-known local tools. +-- These labels serve as suggested defaults and can be modified by users. +-- Keep in sync with: backend/consts/tool_labels.py + +WITH label_map AS ( + SELECT key AS tool_name, value AS label FROM jsonb_each_text('{ + "mysql_database": "database", "postgres_database": "database", "mssql_database": "database", + "read_file": "file", "create_file": "file", "delete_file": "file", + "create_directory": "file", "delete_directory": "file", "list_directory": "file", + "move_item": "file", + "tavily_search": "search", "exa_search": "search", "linkup_search": "search", + "search_memory": "search", "knowledge_base_search": "search", + "dify_search": "knowledge-base", "datamate_search": "knowledge-base", + "idata_search": "knowledge-base", "haotian_search": "knowledge-base", + "aidp_search": "knowledge-base", + "analyze_image": "multimodal", "analyze_audio": "multimodal", + "analyze_video": "multimodal", "analyze_text_file": "multimodal", + "get_email": "email", "send_email": "email", + "store_memory": "memory", + "terminal": "terminal" + }'::jsonb) +) +UPDATE nexent.ag_tool_info_t t +SET labels = to_jsonb(ARRAY[m.label]) +FROM label_map m +WHERE t.name = m.tool_name AND t.labels = '[]'::jsonb; diff --git a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx index 1e750d5eb..0e94f4d6a 100644 --- a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx @@ -7,6 +7,8 @@ import CollaborativeAgent from "./agentConfig/CollaborativeAgent"; import ToolManagement from "./agentConfig/ToolManagement"; import SkillManagement from "./agentConfig/SkillManagement"; import SkillBuildModal from "./agentConfig/SkillBuildModal"; +import SelectToolsDialog from "./agentConfig/tool/SelectToolsDialog"; +import LabelManagementModal from "./agentConfig/tool/LabelManagementModal"; import { updateToolList } from "@/services/mcpService"; import { useAgentConfigStore } from "@/stores/agentConfigStore"; @@ -16,7 +18,7 @@ import { useExternalAgents } from "@/hooks/agent/useExternalAgents"; import McpConfigModal from "./agentConfig/McpConfigModal"; import A2AAgentDiscoveryModal from "./a2a/A2AAgentDiscoveryModal"; -import { RefreshCw, Lightbulb, Plug, BlocksIcon, Globe } from "lucide-react"; +import { Wrench, RefreshCw, Lightbulb, Plug, BlocksIcon, Globe } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; interface AgentConfigCompProps {} @@ -37,10 +39,12 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshingSkill, setIsRefreshingSkill] = useState(false); const [showA2ADiscovery, setShowA2ADiscovery] = useState(false); + const [isToolSelectOpen, setIsToolSelectOpen] = useState(false); + const [labelModalOpen, setLabelModalOpen] = useState(false); const showLegacyMcpConfig = false; // Use tool list hook for data management - const { groupedTools, invalidate } = useToolList(); + const { invalidate, availableTools } = useToolList(); const { groupedSkills, invalidate: invalidateSkills } = useSkillList(); const { invalidate: invalidateExternalAgents } = useExternalAgents(); @@ -165,28 +169,40 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { - - - + + {/* Left: action text links (mirrors demo's Refresh / MCP Config pattern) */} +
+ + +
+ {/* Right: Select Tools button (mirrors demo) */} +
+ +
@@ -194,7 +210,6 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { @@ -247,6 +262,20 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { setIsMcpModalOpen(false)} /> + setIsToolSelectOpen(false)} + onOpenManageLabels={() => setLabelModalOpen(true)} + isCreatingMode={isCreatingMode} + currentAgentId={currentAgentId ?? undefined} + /> + + setLabelModalOpen(false)} + availableTools={availableTools} + /> + setIsSkillModalOpen(false)} diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx index 11b1492bc..af146ec39 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx @@ -1,635 +1,228 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import ToolConfigModal from "./tool/ToolConfigModal"; -import { ToolGroup, Tool, ToolParam } from "@/types/agentConfig"; -import { Tabs, Collapse, message, Tooltip, Badge } from "antd"; +import { Tooltip } from "antd"; import { useAgentConfigStore } from "@/stores/agentConfigStore"; -import { useToolList } from "@/hooks/agent/useToolList"; import { usePrefetchKnowledgeBases } from "@/hooks/useKnowledgeBaseSelector"; import { useConfig } from "@/hooks/useConfig"; -import { useQueryClient } from "@tanstack/react-query"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; - -import { Settings, AlertTriangle } from "lucide-react"; +import { ChevronRight, Settings, X, AlertTriangle } from "lucide-react"; +import type { Tool, ToolParam } from "@/types/agentConfig"; +import { TOOL_SOURCE_TYPES } from "@/const/agentConfig"; +import ToolConfigModal from "./tool/ToolConfigModal"; +import { + TOOLS_REQUIRING_KB_SELECTION, + TOOLS_REQUIRING_EMBEDDING, + TOOLS_REQUIRING_IMAGE_UNDERSTANDING, + TOOLS_REQUIRING_VIDEO_UNDERSTANDING, + getToolKbType, + getToolLabels, +} from "./tool/utils"; import log from "@/lib/logger"; -interface ToolManagementProps { - toolGroups: ToolGroup[]; - isCreatingMode?: boolean; - currentAgentId?: number; -} - -// Tool types that require knowledge base selection -const TOOLS_REQUIRING_KB_SELECTION = [ - "knowledge_base_search", - "dify_search", - "datamate_search", - "idata_search", - "haotian_search", - "aidp_search", -]; - -// Tool types that require Embedding model -const TOOLS_REQUIRING_EMBEDDING = [ - "knowledge_base_search", -]; +// --- Local tool helpers (not in utils) --- -// Tool types that require the image understanding model -const TOOLS_REQUIRING_IMAGE_UNDERSTANDING = [ - "analyze_image", -]; - -// Tool types that require the video understanding model -const TOOLS_REQUIRING_VIDEO_UNDERSTANDING = [ - "analyze_audio", - "analyze_video", -]; - -function getToolKbType( - toolName: string -): "knowledge_base_search" | "dify_search" | "datamate_search" | "idata_search" | "haotian_search" | "aidp_search" | null { - if (!TOOLS_REQUIRING_KB_SELECTION.includes(toolName)) return null; - if (toolName === "dify_search") return "dify_search"; - if (toolName === "datamate_search") return "datamate_search"; - if (toolName === "idata_search") return "idata_search"; - if (toolName === "haotian_search") return "haotian_search"; - if (toolName === "aidp_search") return "aidp_search"; - return "knowledge_base_search"; -} - -/** - * Check if a tool requires VLM model but VLM is not available - */ -function isToolDisabledDueToVlm( - toolName: string, - imageUnderstandingAvailable: boolean, - videoUnderstandingAvailable: boolean -): boolean { - if (TOOLS_REQUIRING_IMAGE_UNDERSTANDING.includes(toolName)) { - return !imageUnderstandingAvailable; - } - if (TOOLS_REQUIRING_VIDEO_UNDERSTANDING.includes(toolName)) { - return !videoUnderstandingAvailable; - } +function isToolDisabledDueToVlm(name: string, img: boolean, vid: boolean): boolean { + if (TOOLS_REQUIRING_IMAGE_UNDERSTANDING.includes(name)) return !img; + if (TOOLS_REQUIRING_VIDEO_UNDERSTANDING.includes(name)) return !vid; return false; } -/** - * Check if a tool requires Embedding model but Embedding is not available - */ -function isToolDisabledDueToEmbedding(toolName: string, embeddingAvailable: boolean): boolean { - if (!TOOLS_REQUIRING_EMBEDDING.includes(toolName)) return false; - return !embeddingAvailable; +function isToolDisabledDueToEmbedding(name: string, emb: boolean): boolean { + if (!TOOLS_REQUIRING_EMBEDDING.includes(name)) return false; + return !emb; } -/** - * ToolManagement - Component for displaying tools in tabs - * Provides a tabbed interface for tool organization - */ -export default function ToolManagement({ - toolGroups, - isCreatingMode, - currentAgentId -}: ToolManagementProps) { - const { t } = useTranslation("common"); - const queryClient = useQueryClient(); - const { confirm } = useConfirmModal(); +type SourceKey = "local" | "mcp" | "langchain"; +const SOURCE_META: Record< + SourceKey, + { sourceValue: string; label: string; dot: string; accentClass: string } +> = { + local: { sourceValue: TOOL_SOURCE_TYPES.LOCAL, label: "toolPool.group.local", dot: "bg-emerald-500", accentClass: "bg-emerald-500/10 text-emerald-600" }, + mcp: { sourceValue: TOOL_SOURCE_TYPES.MCP, label: "toolPool.group.mcp", dot: "bg-sky-500", accentClass: "bg-sky-500/10 text-sky-600" }, + langchain: { sourceValue: TOOL_SOURCE_TYPES.LANGCHAIN, label: "toolPool.group.langchain", dot: "bg-violet-500", accentClass: "bg-violet-500/10 text-violet-600" }, +}; - const isReadOnly = useAgentConfigStore((state) => state.isReadOnly()); +interface ToolManagementProps { + isCreatingMode?: boolean; + currentAgentId?: number; +} - // Get state from store - const originalSelectedTools = useAgentConfigStore( - (state) => state.editedAgent.tools - ); - const originalSelectedToolIdsSet = new Set( - originalSelectedTools.map((tool) => tool.id) - ); +/** Display selected tools as grouped, collapsible cards (demo layout). */ +export default function ToolManagement({ isCreatingMode, currentAgentId }: ToolManagementProps) { + const { t } = useTranslation("common"); + const { prefetchKnowledgeBases } = usePrefetchKnowledgeBases(); + const { isImageUnderstandingAvailable, isVideoUnderstandingAvailable, isEmbeddingAvailable } = useConfig(); + const selectedTools = useAgentConfigStore((state) => state.editedAgent.tools); const updateTools = useAgentConfigStore((state) => state.updateTools); - // Use tool list hook for data management - const { availableTools } = useToolList(); - - const { - isImageUnderstandingAvailable, - isVideoUnderstandingAvailable, - isEmbeddingAvailable, - } = useConfig(); - - // Prefetch knowledge bases for KB tools - const { prefetchKnowledgeBases } = usePrefetchKnowledgeBases(); + const [modalOpen, setModalOpen] = useState(false); + const [configTool, setConfigTool] = useState(null); + const [configParams, setConfigParams] = useState([]); + const [collapsedCats, setCollapsedCats] = useState>({}); - const [activeTabKey, setActiveTabKey] = useState(""); - const [expandedCategories, setExpandedCategories] = useState>( - new Set() - ); - const [isToolModalOpen, setIsToolModalOpen] = useState(false); - const [selectedTool, setSelectedTool] = useState(null); - const [toolParams, setToolParams] = useState([]); + // --- Group by source → category --- + const grouped = groupToolsBySource(selectedTools); - // Helper function to merge tool parameters with instance parameters - const mergeToolParamsWithInstance = async ( - tool: Tool, - defaultTool: Tool, - agentId?: number - ): Promise => { - if (agentId) { + const mergeParams = useCallback( + async (tool: Tool): Promise => { + const params = tool.initParams || []; + if (!currentAgentId) return params; try { - const { searchToolConfig } = - await import("@/services/agentConfigService"); - const tooInstance = await searchToolConfig(parseInt(tool.id), agentId); - - if (tooInstance.success && tooInstance.data) { - // Merge instance params with default params - // Only use instance value if it exists and is not null/undefined - const mergedParams = - defaultTool.initParams?.map((param: ToolParam) => { - const instanceValue = tooInstance.data?.params?.[param.name]; - // Use instance value only if it's not null or undefined - const hasValidInstanceValue = instanceValue !== null && instanceValue !== undefined; - return { - ...param, - value: hasValidInstanceValue ? instanceValue : param.value, - }; - }) || - defaultTool.initParams || - []; - return mergedParams; - } else { - return defaultTool.initParams || []; + const { searchToolConfig } = await import("@/services/agentConfigService"); + const instance = await searchToolConfig(parseInt(tool.id), currentAgentId); + if (instance.success && instance.data) { + return params.map((p) => ({ + ...p, + value: instance.data?.params?.[p.name] !== undefined ? instance.data.params[p.name] : p.value, + })); } - } catch (error) { - log.error("Failed to fetch tool instance params:", error); - return defaultTool.initParams || []; - } - } else { - return defaultTool.initParams || []; - } - }; - - // Set default active tab - useEffect(() => { - if (toolGroups.length > 0 && !activeTabKey) { - setActiveTabKey(toolGroups[0].key); - } - }, [toolGroups, activeTabKey]); - - const handleToolSettingsClick = async (tool: Tool) => { - // Prefetch knowledge bases for KB tools - const kbType = getToolKbType(tool.name); - if (kbType) { - prefetchKnowledgeBases(kbType); - } - - // Get latest tools directly from store to avoid stale closure issues - const currentTools = useAgentConfigStore.getState().editedAgent.tools; - const configuredTool = currentTools.find( - (t) => parseInt(t.id) === parseInt(tool.id) - ); - // Merge configured tool with original tool to ensure all fields are present - const toolToUse = configuredTool ? { ...tool, ...configuredTool, initParams: configuredTool.initParams } : tool; - - // Get merged parameters (for editing mode, merge with instance params) - const mergedParams = await mergeToolParamsWithInstance( - tool, - toolToUse, - isCreatingMode ? undefined : currentAgentId - ); - - setSelectedTool(toolToUse); - setToolParams(mergedParams); - setIsToolModalOpen(true); - }; + } catch (err) { log.error("mergeParams:", err); } + return params; + }, + [currentAgentId] + ); - const handleToolClick = async (toolId: string) => { - const numericId = parseInt(toolId, 10); - const tool = availableTools.find((t) => parseInt(t.id) === numericId); + const openConfig = useCallback( + async (tool: Tool) => { + const kbType = getToolKbType(tool.name); + if (kbType) prefetchKnowledgeBases(kbType); + const current = useAgentConfigStore.getState().editedAgent.tools; + const configured = current.find((t) => parseInt(t.id) === parseInt(tool.id)); + const toolToUse = configured ? { ...tool, ...configured, initParams: configured.initParams } : tool; + const merged = await mergeParams(toolToUse); + setConfigTool(toolToUse); + setConfigParams(merged); + setModalOpen(true); + }, + [mergeParams, prefetchKnowledgeBases] + ); - if (!tool) return; + const removeTool = useCallback( + (toolId: string) => { + const current = useAgentConfigStore.getState().editedAgent.tools; + updateTools(current.filter((t) => t.id !== toolId)); + }, + [updateTools] + ); - // Prefetch knowledge bases for KB tools - const kbType = getToolKbType(tool.name); - if (kbType) { - prefetchKnowledgeBases(kbType); - } + const toggleCat = (cat: string) => setCollapsedCats((p) => ({ ...p, [cat]: !p[cat] })); - // Get latest tools directly from store to avoid stale closure issues - const currentSelectdTools = useAgentConfigStore.getState().editedAgent.tools; - const isCurrentlySelected = currentSelectdTools.some( - (t) => parseInt(t.id) === numericId + if (grouped.length === 0) { + return ( +
+ {t("toolPool.noToolsSelected")} +
); + } - if (isCurrentlySelected) { - // If already selected, deselect it - const newSelectedTools = currentSelectdTools.filter((t) => parseInt(t.id) !== numericId); - updateTools(newSelectedTools); - } else { - // Helper function to proceed with tool selection after duplicate check - async function proceedWithToolSelection() { - // Get latest tools again to ensure we have the most up-to-date list - const currentSelectdTools = - useAgentConfigStore.getState().editedAgent.tools; - - // Determine tool params and check if modal is needed - const configuredTool = currentSelectdTools.find( - (t) => parseInt(t.id) === numericId - ); - // Merge configured tool with original tool to ensure all fields are present - const toolToUse = configuredTool - ? { ...tool, ...configuredTool, initParams: configuredTool.initParams } - : tool; - - // Get merged parameters (for editing mode, merge with instance params) - const mergedParams = await mergeToolParamsWithInstance( - tool, - toolToUse, - isCreatingMode ? undefined : currentAgentId! - ); - - // Check if there are empty required params - const hasEmptyRequiredParams = mergedParams.some( - (param: ToolParam) => - param.required && - (param.value === undefined || - param.value === "" || - param.value === null) - ); - - if (hasEmptyRequiredParams) { - // Need to configure, open modal - setSelectedTool(toolToUse); - setToolParams(mergedParams); - setIsToolModalOpen(true); - } else { - // No required params missing, add directly - const newSelectedTools = [ - ...currentSelectdTools, - { - ...toolToUse, - initParams: mergedParams, - }, - ]; - updateTools(newSelectedTools); - } - } - - // If not selected, check for duplicate tool names first - const duplicateTool = currentSelectdTools.find( - (selectedTool) => selectedTool.name === tool.name - ); - - if (duplicateTool) { - // Show confirmation modal for duplicate tool name - return new Promise((resolve) => { - confirm({ - title: t("toolPool.duplicateToolName.title"), - content: t("toolPool.duplicateToolName.content", { - toolName: tool.name, - }), - okText: t("toolPool.duplicateToolName.confirm"), - cancelText: t("toolPool.duplicateToolName.cancel"), - danger: true, - onOk: async () => { - // User confirmed, proceed with tool selection - await proceedWithToolSelection(); - resolve(); - }, - onCancel: () => { - // User cancelled, do nothing - resolve(); - }, - }); - }); - } - - // No duplicate, proceed with normal tool selection - await proceedWithToolSelection(); - } - }; - - // Generate Tabs configuration - const tabItems = toolGroups.map((group) => { - const label = t(group.label); - const selectedCount = group.subGroups - ? group.subGroups.reduce( - (sum, sg) => sum + sg.tools.filter(t => originalSelectedToolIdsSet.has(t.id)).length, 0) - : group.tools.filter(t => originalSelectedToolIdsSet.has(t.id)).length; + return ( +
+
+ + {t("toolPool.selectedToolsLabel")} + ({selectedTools.length}) + +
+ +
+ {grouped.map((src) => ( +
+
+ + {t(SOURCE_META[src.key].label)}({src.totalCount}) +
- return { - key: group.key, - label: ( - - - - {label} - - {selectedCount > 0 && ( - - )} - - - ), - children: ( -
- {group.subGroups ? ( - <> - {/* Collapsible categories using Ant Design Collapse */} -
- { - const newSet = new Set( - typeof keys === "string" ? [keys] : keys - ); - setExpandedCategories(newSet); - }} - ghost - size="small" - className="tool-categories-collapse mt-1" - items={group.subGroups.map((subGroup, index) => ({ - key: subGroup.key, - label: ( - - - {subGroup.label} - - {subGroup.tools.filter(t => originalSelectedToolIdsSet.has(t.id)).length > 0 && ( - originalSelectedToolIdsSet.has(t.id)).length} - size="small" - color="blue" - /> - )} +
+ {src.categories.map((cat) => { + const catKey = `${src.key}-${cat.category}`; + const isCollapsed = collapsedCats[catKey] ?? false; + const accent = SOURCE_META[src.key].accentClass; + + return ( +
+ + + {!isCollapsed && ( +
+ {cat.tools.map((tool) => { + const labels = getToolLabels(tool); + const disabled = + isToolDisabledDueToVlm(tool.name, isImageUnderstandingAvailable, isVideoUnderstandingAvailable) || + isToolDisabledDueToEmbedding(tool.name, isEmbeddingAvailable); + + return ( +
+
+
+ + {tool.name} + + {labels.slice(0, 2).map((l) => ( + + {l} + + ))} + {labels.length > 2 && ( + + + +{labels.length - 2} + + + )} + {disabled && } +
- { - e.stopPropagation(); - handleToolSettingsClick(tool); - } - : undefined - } - /> + + + +
); - return tooltipTitle ? ( - - {toolCard} - - ) : ( - toolCard - ); })}
- ), - }))} - /> -
- - ) : ( - // Regular layout for non-local tools -
- {group.tools.map((tool) => { - const isSelected = originalSelectedToolIdsSet.has(tool.id); - const isDisabledDueToVlm = isToolDisabledDueToVlm( - tool.name, - isImageUnderstandingAvailable, - isVideoUnderstandingAvailable - ); - const isDisabledDueToEmbedding = isToolDisabledDueToEmbedding(tool.name, isEmbeddingAvailable); - const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding || isReadOnly; - // Tooltip priority: permission > VLM > Embedding - const tooltipTitle = isDisabledDueToVlm - ? t("toolPool.vlmDisabledTooltip") - : isDisabledDueToEmbedding - ? t("toolPool.embeddingDisabledTooltip") - : undefined; - const toolCard = ( -
handleToolClick(tool.id) : undefined - } - > -
- {tool.name} - {isDisabledDueToVlm && ( - - - - )} - {isDisabledDueToEmbedding && ( - - - - )} -
- { - e.stopPropagation(); - handleToolSettingsClick(tool); - } - : undefined - } - /> + )}
); - return tooltipTitle ? ( - - {toolCard} - - ) : ( - toolCard - ); })}
- )} -
- ), - }; - }); +
+ ))} +
- return ( -
- {toolGroups.length === 0 ? ( -
- {t("toolPool.noTools")} -
- ) : ( - - )} - - {isToolModalOpen && ( + {modalOpen && ( { - setIsToolModalOpen(false); - setSelectedTool(null); - setToolParams([]); - }} - tool={selectedTool!} - initialParams={toolParams} - selectedTool={selectedTool} + isOpen={modalOpen} + onCancel={() => { setModalOpen(false); setConfigTool(null); setConfigParams([]); }} + tool={configTool!} + initialParams={configParams} + selectedTool={configTool} isCreatingMode={isCreatingMode} currentAgentId={currentAgentId} /> @@ -637,3 +230,31 @@ export default function ToolManagement({
); } + +// ─── Pure helper ───────────────────────────────────────────────────────────── + +interface CatGroup { category: string; tools: Tool[] } +interface SourceGroup { key: SourceKey; categories: CatGroup[]; totalCount: number } + +function groupToolsBySource(tools: Tool[]): SourceGroup[] { + const result: SourceGroup[] = []; + for (const [key, meta] of Object.entries(SOURCE_META) as [SourceKey, typeof SOURCE_META[SourceKey]][]) { + const srcTools = tools.filter((t: any) => t.source === meta.sourceValue); + if (srcTools.length === 0) continue; + const catMap = new Map(); + for (const tool of srcTools) { + const cat = (tool as any).category?.trim() || "toolPool.category.other"; + if (!catMap.has(cat)) catMap.set(cat, []); + catMap.get(cat)!.push(tool); + } + const categories = Array.from(catMap.entries()) + .map(([cat, ts]) => ({ category: cat, tools: ts })) + .sort((a, b) => { + if (a.category === "toolPool.category.other") return 1; + if (b.category === "toolPool.category.other") return -1; + return a.category.localeCompare(b.category); + }); + result.push({ key, categories, totalCount: srcTools.length }); + } + return result; +} diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/LabelManagementModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/LabelManagementModal.tsx new file mode 100644 index 000000000..2cac4ee67 --- /dev/null +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/LabelManagementModal.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Modal, Table, Select, App } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { useQueryClient } from "@tanstack/react-query"; +import { API_ENDPOINTS } from "@/services/api"; +import { getAuthHeaders } from "@/lib/auth"; +import log from "@/lib/logger"; + +interface LabelManagementModalProps { + open: boolean; + onClose: () => void; + availableTools: any[]; +} + +interface ToolRow { + id: string; + name: string; + source: string; + labels: string[]; + updatedBy: string; +} + +export default function LabelManagementModal({ + open, + onClose, + availableTools, +}: LabelManagementModalProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + + const [dataSource, setDataSource] = useState([]); + const builtRef = useRef(false); + + // Collect all unique labels from dataSource for Select suggestions + const allExistingLabels = useMemo(() => { + const labelSet = new Set(); + dataSource.forEach((row) => row.labels.forEach((l: string) => labelSet.add(l))); + return Array.from(labelSet).sort((a, b) => a.localeCompare(b)); + }, [dataSource]); + + // Build dataSource once per open cycle. Using builtRef lets us + // handle the case where availableTools arrives after the modal is already open. + useEffect(() => { + if (open) { + if (!builtRef.current && availableTools.length > 0) { + const rows: ToolRow[] = availableTools.map((tool: any) => ({ + id: tool.id, + name: tool.name, + source: tool.source || "", + labels: Array.isArray(tool.labels) ? [...tool.labels] : [], + updatedBy: tool.updated_by || "", + })); + setDataSource(rows); + builtRef.current = true; + } + } else { + setDataSource([]); + builtRef.current = false; + } + }, [open, availableTools]); + + const handleLabelsChange = useCallback( + async (toolId: string, newLabels: string[]) => { + // Optimistically update local state + setDataSource((prev) => + prev.map((row) => + row.id === toolId ? { ...row, labels: newLabels } : row + ) + ); + + // Persist to backend, then synchronously update cache so parent sees fresh data + try { + await fetch(API_ENDPOINTS.tool.labels, { + method: "PUT", + headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ tool_id: parseInt(toolId), labels: newLabels }), + }); + // Synchronous cache update — no timing gaps, no refetch race + queryClient.setQueryData(["tools"], (old: any[]) => { + if (!old) return old; + return old.map((tool: any) => + tool.id === toolId ? { ...tool, labels: newLabels } : tool + ); + }); + } catch (err) { + log.warn("Failed to update tool labels:", err); + message.error(t("toolConfig.message.labelsSaveFailed")); + } + }, + [message, t, queryClient] + ); + + const columns: ColumnsType = [ + { + title: t("toolConfig.column.toolName"), + dataIndex: "name", + key: "name", + width: 200, + }, + { + title: t("toolConfig.column.source"), + dataIndex: "source", + key: "source", + width: 100, + }, + { + title: t("toolConfig.column.updatedBy"), + dataIndex: "updatedBy", + key: "updatedBy", + width: 140, + render: (val: string) => ( + {val || "—"} + ), + }, + { + title: t("toolConfig.column.labels"), + dataIndex: "labels", + key: "labels", + render: (labels: string[], record: ToolRow) => ( + setSearch(e.target.value)} + placeholder={t("toolPool.searchToolsPlaceholder")} + className="pl-7" + allowClear + /> +
+