diff --git a/backend/prompts/nl2agent_en.yaml b/backend/prompts/nl2agent_en.yaml index 8c50ee198..9efb563a7 100644 --- a/backend/prompts/nl2agent_en.yaml +++ b/backend/prompts/nl2agent_en.yaml @@ -31,7 +31,7 @@ system_prompt: |- ### Resource Revisions - In revision mode, search only for the newly requested capability. Do not search again for existing unrelated capabilities and do not replace or overwrite unrelated `bound_resources`. - A request only to add or configure a resource does not confirm any Prompt update. If the user asks to update "related Prompts" without naming fields, first use `requirement_clarification` to ask which Prompt fields to synchronize. - - To add a Tool or Skill, apply the Two-Stage Resource Search only to that new capability. Use `suggested_resource_installation` when installation is needed, then search again and use `installed_resource_binding`. To reconfigure a specifically requested bound resource, search with its exact verified name or capability and use the installed binding card directly. + - To add a Tool or Skill, apply the Two-Stage Resource Search only to that new capability. Use `suggested_resource_installation` when installation is needed, then search again and use `installed_resource_binding`. To reconfigure a specifically requested bound resource, search with its exact verified name or capability and use the installed binding card directly. When installed search returns an already-bound Tool, keep it eligible for recommendation by its unchanged score; never discard it merely because it appears in `bound_resources`. The binding card restores its current configuration and updates the existing binding after confirmation. - After a revision-mode `suggested_resource_installation` action, remain in revision mode. Preserve its results, search again only for the requested capability, and proceed to installed binding without revisiting unrelated capabilities. - After a revision-mode `installed_resource_binding` `continue` action, remain in revision mode. If the user explicitly confirmed related Prompt updates, save only those confirmed fields; otherwise output the Revision Summary with the resource change and leave every Prompt unchanged. Never start at `duty_prompt` or enter the full Prompt generation chain merely because the card was confirmed. - Conversational removal is unsupported. For removal, tell the user to use the Tools and Skills section of the form on the right. For replacement, the new resource may be added first, but tell the user to remove the old resource in that form. @@ -63,7 +63,7 @@ system_prompt: |- 4. Decode every JSON text result with `json.loads` before indexing or forwarding it. A requirement is uncovered only when its ID occurs in both installed and installable-resource `uncovered_requirement_ids`. Use only the real candidates returned by those searches and never invent an unavailable source. 5. Select only the smallest uninstalled candidate set needed to close coverage gaps, call `{{ recommend_tool_name }}`, then call `{{ wrapper_name }}` with subtype `suggested_resource_installation`. When no installation is needed, select the smallest installed coverage set and proceed directly to `installed_resource_binding`. 6. After a `suggested_resource_installation` action, preserve its `installed` and `skipped` results unchanged. Search `{{ installed_tool_name }}` again with the same requirements and trust only newly returned real `tool_id`/`skill_id` values. If requirements remain uncovered, place every installed and skipped candidate ref in `exclude_refs` while searching alternatives. When no alternative exists, use a clarification card requiring the user to revise, explicitly abandon, or end; never generate an incomplete Agent silently. - 7. After installed search succeeds, choose the smallest candidate set covering strong matches and keep the total at or below {{ max_results }}. Pass unchanged candidates to `{{ recommend_tool_name }}`, decode its result, then pass that unchanged dictionary and the same `agent_id` to `{{ wrapper_name }}` with subtype `installed_resource_binding`. + 7. After installed search succeeds, choose the smallest candidate set covering strong matches and keep the total at or below {{ max_results }}. Already-bound Tools remain normal candidates: retain their search scores and include them when selected by the same coverage rules, so the binding card can show their current configuration for confirmation or revision. Pass unchanged candidates to `{{ recommend_tool_name }}`, decode its result, then pass that unchanged dictionary and the same `agent_id` to `{{ wrapper_name }}` with subtype `installed_resource_binding`. 8. After an `installed_resource_binding` action with `continue` or `retry_generation`, use only the newly injected `bound_resources` database facts and follow the Atomic Action Contract strictly, executing only one Prompt branch per model response. 9. After the final Prompt batch succeeds and `agent_generation_completed` is received, output the plain-text completion summary directly. Do not call another tool or wrapper. For tool errors, use only `code` and `retryable`; retry at most once. diff --git a/backend/prompts/nl2agent_zh.yaml b/backend/prompts/nl2agent_zh.yaml index 3e6ea9921..0354608be 100644 --- a/backend/prompts/nl2agent_zh.yaml +++ b/backend/prompts/nl2agent_zh.yaml @@ -31,7 +31,7 @@ system_prompt: |- ### 资源修订 - 修订模式只搜索用户新请求的能力,不得重新搜索已有无关能力,也不得替换或覆盖无关的 `bound_resources`。 - 仅要求新增或配置资源,不代表用户确认更新任何 Prompt。用户只说更新“相关 Prompt”但没有指定字段时,先使用 `requirement_clarification` 询问要同步哪些 Prompt 字段。 - - 新增 Tool 或 Skill 时,只针对该新能力执行“两阶段资源搜索”。需要安装时使用 `suggested_resource_installation`,安装后重新搜索并进入 `installed_resource_binding`。重新配置用户明确指定的已绑定资源时,使用权威状态中的准确资源名称或能力进行搜索,并直接使用已安装资源绑定卡。 + - 新增 Tool 或 Skill 时,只针对该新能力执行“两阶段资源搜索”。需要安装时使用 `suggested_resource_installation`,安装后重新搜索并进入 `installed_resource_binding`。重新配置用户明确指定的已绑定资源时,使用权威状态中的准确资源名称或能力进行搜索,并直接使用已安装资源绑定卡。已安装搜索返回已绑定 Tool 时,仍按其未改写的分数参与推荐,不得仅因它出现在 `bound_resources` 中就丢弃;绑定卡会恢复当前配置,并在用户确认后更新原绑定。 - 修订模式收到 `suggested_resource_installation` action 后,必须继续修订模式。原样保留 action 结果,只重新搜索用户请求的能力并进入已安装资源绑定,不得重新处理无关能力。 - 修订模式收到 `installed_resource_binding` 的 `continue` action 后,必须继续修订模式。仅当用户明确确认同步相关 Prompt 时,才保存这些确认字段;否则直接输出“修订总结”并说明资源变更,所有 Prompt 保持不变。不得因卡片已确认就从 `duty_prompt` 开始或进入完整 Prompt 生成链。 - 不支持通过对话移除资源。用户要求移除时,引导其在右侧表单的工具与技能区域操作。替换资源时可以先新增资源,但必须提示用户在该表单中移除旧资源。 @@ -63,7 +63,7 @@ system_prompt: |- 4. MCP JSON 文本在索引或继续传递前必须使用 `json.loads` 解码。只有同时出现在 installed 与 installable-resource 的 `uncovered_requirement_ids` 中的需求才算未覆盖;只能使用这些搜索真实返回的候选,不得编造不存在的来源。 5. 从未安装结果中只选择补齐缺口所必需的最小候选集,调用 `{{ recommend_tool_name }}` 后再调用 `{{ wrapper_name }}` 的 `suggested_resource_installation` subtype。若没有必要安装项,则从已安装结果选择最小覆盖集并直接进入 `installed_resource_binding`。 6. 收到 `suggested_resource_installation` action 后,原样保留 `installed` 与 `skipped` 结果。使用相同 requirements 重新调用 `{{ installed_tool_name }}`,只相信新返回的真实 `tool_id`/`skill_id`;仍未覆盖时把全部已安装和已跳过 candidate refs 放入 `exclude_refs` 搜索替代资源。没有替代资源时使用澄清卡要求用户明确修改需求、放弃需求或结束,禁止生成能力不完整的 Agent。 - 7. 已安装搜索成功后选择覆盖强匹配需求的最小候选集,总数不超过 {{ max_results }}。把未改写的候选传给 `{{ recommend_tool_name }}`,解码其结果,再将该字典和同一 `agent_id` 原样传给 `{{ wrapper_name }}` 的 `installed_resource_binding` subtype。 + 7. 已安装搜索成功后选择覆盖强匹配需求的最小候选集,总数不超过 {{ max_results }}。已绑定 Tool 仍是普通候选:保留搜索分数,并在相同覆盖规则选中它时继续推荐,让绑定卡展示当前配置供用户确认或修改。把未改写的候选传给 `{{ recommend_tool_name }}`,解码其结果,再将该字典和同一 `agent_id` 原样传给 `{{ wrapper_name }}` 的 `installed_resource_binding` subtype。 8. 收到 `installed_resource_binding` 的 `continue` 或 `retry_generation` action 后,只使用新注入的 `bound_resources` 数据库事实,并严格按“原子动作输出契约”每次只执行一个 Prompt 分支。 9. 最后一批 Prompt 保存成功并收到 `agent_generation_completed` 后,直接输出普通文本完成总结,不再调用任何 Tool 或 wrapper。Tool 出错时只依据 `code` 和 `retryable`,最多重试一次。 diff --git a/backend/services/nl2agent_service.py b/backend/services/nl2agent_service.py index 38680f687..87bd46ebc 100644 --- a/backend/services/nl2agent_service.py +++ b/backend/services/nl2agent_service.py @@ -443,7 +443,6 @@ async def _load_installed_resource_catalog( if ( source not in {ToolSourceEnum.LOCAL.value, ToolSourceEnum.MCP.value} or tool.get("is_available") is not True - or tool.get("is_user_selectable") is False or name in internal_names ): continue @@ -989,6 +988,7 @@ def _recommended_resource( actual: dict[str, Any], supplied: ResourceCandidate, recommended_refs: set[str], + is_bound: bool = False, ) -> RecommendedResource: return RecommendedResource( candidate=_verified_resource_candidate(actual, supplied), @@ -997,6 +997,7 @@ def _recommended_resource( if supplied.candidate_ref in recommended_refs else "optional" ), + is_bound=is_bound, form_kind=actual.get("form_kind") or ( "TOOL_CONFIG" if actual["resource_type"] == "tool" @@ -1038,6 +1039,7 @@ async def recommend_uninstalled_resources_impl( async def recommend_installed_resources_impl( *, + agent_id: int, candidates: list[ResourceCandidate], recommended_refs: list[str], tenant_id: str, @@ -1050,6 +1052,15 @@ async def recommend_installed_resources_impl( user_id=user_id, ) by_ref = {item["candidate_ref"]: item for item in catalog} + bound_tool_refs = { + f"tool:{instance['tool_id']}" + for instance in query_all_enabled_tool_instances( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=0, + ) + if isinstance(instance.get("tool_id"), int) + } recommended = set(recommended_refs) resources: list[RecommendedResource] = [] for supplied in candidates: @@ -1060,12 +1071,14 @@ async def recommend_installed_resources_impl( actual=actual, supplied=supplied, recommended_refs=recommended, + is_bound=supplied.candidate_ref in bound_tool_refs, )) return RecommendResourcesOutput(resources=resources) async def recommend_resources_impl( *, + agent_id: int, candidates: list[ResourceCandidate], recommended_refs: list[str], tenant_id: str, @@ -1076,6 +1089,7 @@ async def recommend_resources_impl( sources = {candidate.source for candidate in candidates} if sources and sources.issubset(INSTALLED_RESOURCE_SOURCES): return await recommend_installed_resources_impl( + agent_id=agent_id, candidates=candidates, recommended_refs=recommended_refs, tenant_id=tenant_id, diff --git a/backend/tool_collection/mcp/nl2agent_mcp_tools.py b/backend/tool_collection/mcp/nl2agent_mcp_tools.py index 049867e35..98d28545b 100644 --- a/backend/tool_collection/mcp/nl2agent_mcp_tools.py +++ b/backend/tool_collection/mcp/nl2agent_mcp_tools.py @@ -261,6 +261,7 @@ class RecommendedResource(BaseModel): model_config = ConfigDict(extra="forbid") candidate: ResourceCandidate recommendation: Literal["recommended", "optional"] + is_bound: bool = False form_kind: Literal[ "TOOL_CONFIG", "SKILL_CONFIG", @@ -943,6 +944,7 @@ async def recommend_resources( user_id=user_id, ) result = await recommend_resources_impl( + agent_id=resolved_agent_id, candidates=payload.candidates, recommended_refs=payload.recommended_refs, tenant_id=tenant_id, @@ -1017,6 +1019,7 @@ async def nl2a_wrapper( if not sources or not sources.issubset(required_sources): raise ValueError(f"invalid resources for {subtype}") verified = await recommend_resources_impl( + agent_id=resolved_agent_id, candidates=[resource.candidate for resource in supplied.resources], recommended_refs=[ resource.candidate.candidate_ref diff --git a/frontend/app/[locale]/agents/agent-config.tsx b/frontend/app/[locale]/agents/agent-config.tsx index 6a21b54fb..40c1f0c57 100644 --- a/frontend/app/[locale]/agents/agent-config.tsx +++ b/frontend/app/[locale]/agents/agent-config.tsx @@ -146,6 +146,7 @@ export default function AgentConfig({ const displayInfoSectionRef = useRef(null); const roleModelSectionRef = useRef(null); const toolsSkillsSectionRef = useRef(null); + const knowledgeBaseSectionRef = useRef(null); const conversationGuideSectionRef = useRef(null); const lastScrolledRequestRef = useRef(null); const { configFocusRequest } = useNl2AgentFlow(); @@ -181,7 +182,10 @@ export default function AgentConfig({ const { requestId, target } = configFocusRequest; setActiveConfigTab( - target.section === "conversation_guide" ? "advanced" : "basic" + target.section === "conversation_guide" || + target.section === "knowledge_base" + ? "advanced" + : "basic" ); setOpenSections((current) => current[target.section] ? current : { ...current, [target.section]: true } @@ -198,7 +202,9 @@ export default function AgentConfig({ ? roleModelSectionRef.current : target.section === "tools_skills" ? toolsSkillsSectionRef.current - : conversationGuideSectionRef.current; + : target.section === "knowledge_base" + ? knowledgeBaseSectionRef.current + : conversationGuideSectionRef.current; if (!sectionElement) return; const prefersReducedMotion = window.matchMedia( @@ -410,6 +416,7 @@ export default function AgentConfig({ onOpenChange={(open) => handleSectionOpenChange("knowledge_base", open) } + containerRef={knowledgeBaseSectionRef} headerActions={} > diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx index ec4a637db..6a338fd16 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx @@ -12,6 +12,7 @@ import type { Tool, ToolParam } from "@/types/agentConfig"; import { TOOL_SOURCE_TYPES } from "@/const/agentConfig"; import { isManagedKnowledgeTool } from "@/lib/managedKnowledgeTools"; import ToolConfigModal from "./tool/ToolConfigModal"; +import { useMergedToolParams } from "./tool/useMergedToolParams"; import { TOOLS_REQUIRING_EMBEDDING, TOOLS_REQUIRING_IMAGE_UNDERSTANDING, @@ -21,7 +22,6 @@ import { getToolLabels, mergeCanonicalTool, } from "./tool/utils"; -import log from "@/lib/logger"; // --- Local tool helpers (not in utils) --- @@ -122,42 +122,7 @@ export default function ToolManagement({ // --- Group by source → category --- const grouped = groupToolsBySource(visibleSelectedTools); - const mergeParams = useCallback( - async (tool: Tool, forceFetch?: boolean): Promise => { - const params = tool.initParams || []; - // If tool already has stored params in the agent config store, the user's - // unsaved modifications are already reflected in those params — skip the - // API call to avoid overwriting them with stale server data. - const hasStoredParams = params.some( - (p) => p.value !== undefined && p.value !== null && p.value !== "" - ); - if (!forceFetch && hasStoredParams) { - return params; - } - if (!currentAgentId) return params; - try { - 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 (err) { - log.error("mergeParams:", err); - } - return params; - }, - [currentAgentId] - ); + const mergeParams = useMergedToolParams(currentAgentId); const openConfig = useCallback( async (tool: Tool) => { @@ -172,6 +137,7 @@ export default function ToolManagement({ : tool; const toolToUse = mergeCanonicalTool(configuredTool, availableTools); const merged = await mergeParams(toolToUse); + if (!merged) return; setConfigTool(toolToUse); setConfigParams(merged); setModalOpen(true); diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx index 5f67630c6..f27bb0046 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx @@ -2,7 +2,15 @@ import { useState, useMemo, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Modal, Tabs, Input, Checkbox, Button, Select, Tooltip } from "antd"; +import { + Modal, + Tabs, + Input, + Checkbox, + Button, + Select, + Tooltip, +} from "antd"; import type { TabsProps } from "antd"; import { Search, Settings, Wrench, Tag } from "lucide-react"; import i18n from "i18next"; @@ -17,6 +25,7 @@ import { useConfirmModal } from "@/hooks/useConfirmModal"; import { TOOL_SOURCE_TYPES } from "@/const/agentConfig"; import type { Tool, ToolParam } from "@/types/agentConfig"; import ToolConfigModal from "./ToolConfigModal"; +import { useMergedToolParams } from "./useMergedToolParams"; import { TOOLS_REQUIRING_KB_SELECTION, TOOLS_REQUIRING_EMBEDDING, @@ -26,7 +35,6 @@ import { getToolKbType, getToolLabels, } from "./utils"; -import log from "@/lib/logger"; function isToolDisabled( name: string, @@ -263,44 +271,7 @@ export default function SelectToolsDialog({ [activeCategory, currentGroups] ); - // --- Merge instance params for a tool --- - const mergeInstanceParams = useCallback( - async (tool: any, forceFetch?: boolean): Promise => { - const params = tool.initParams || []; - // If tool already has stored params with non-empty values, the user's - // unsaved modifications are already reflected in those params — skip the - // API call to avoid overwriting them with stale server data. - const hasStoredParams = params.some( - (p: ToolParam) => - p.value !== undefined && p.value !== null && p.value !== "" - ); - if (!forceFetch && hasStoredParams) { - return params; - } - if (!currentAgentId) return params; - try { - const { searchToolConfig } = - await import("@/services/agentConfigService"); - const instance = await searchToolConfig( - parseInt(tool.id), - currentAgentId - ); - if (instance.success && instance.data) { - return params.map((p: ToolParam) => ({ - ...p, - value: - instance.data?.params?.[p.name] !== undefined - ? instance.data.params[p.name] - : p.value, - })); - } - } catch (err) { - log.error("Failed to fetch tool instance params:", err); - } - return params; - }, - [currentAgentId] - ); + const mergeInstanceParams = useMergedToolParams(currentAgentId); // --- Check if tool has missing required params --- const hasMissingRequired = useCallback( @@ -370,6 +341,7 @@ export default function SelectToolsDialog({ : tool; const mergedParams = await mergeInstanceParams(toolToUse); + if (!mergedParams) return; setConfigTool(toolToUse); setConfigParams(mergedParams); setConfigModalOpen(true); @@ -400,6 +372,7 @@ export default function SelectToolsDialog({ const dup = currentSelected.find((s) => s.name === tool.name); const doAdd = async () => { const mergedParams = await mergeInstanceParams(tool); + if (!mergedParams) return; const toolToUse = { ...tool, initParams: mergedParams }; if (hasMissingRequired(mergedParams)) { setConfigTool(toolToUse); @@ -451,17 +424,18 @@ export default function SelectToolsDialog({ setIsSelectingAll(true); try { const toolsWithParams = await Promise.all( - toolsToAdd.map(async (tool: any) => ({ - ...tool, - initParams: await mergeInstanceParams(tool), - })) + toolsToAdd.map(async (tool: any) => { + const initParams = await mergeInstanceParams(tool); + return initParams ? { ...tool, initParams } : null; + }) ); const latestSelected = useAgentStore.getState().editedAgent?.tools ?? []; const latestIds = new Set( latestSelected.map((tool) => parseInt(tool.id)) ); const names = new Set(latestSelected.map((tool) => tool.name)); - const additions = toolsWithParams.filter((tool) => { + const additions = toolsWithParams.filter((tool): tool is Tool => { + if (!tool) return false; if ( latestIds.has(parseInt(tool.id)) || names.has(tool.name) || diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx index 023532877..0e865357b 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx @@ -1132,13 +1132,14 @@ export default function ToolConfigModal({ } }, [knowledgeBases, selectedKbIds]); - // Filter selected KB IDs to the current accessible list. For AIDP, an - // successfully loaded empty list is meaningful: the current user cannot - // read any of the KBs saved by the agent creator. + // Filter selected KB IDs to the current accessible list. For managed + // knowledge tools, a loaded empty list means no saved KB remains readable. useEffect(() => { const canValidateSelection = knowledgeBases.length > 0 || - ((toolKbType === "aidp_search" || toolKbType === "ind_aidp_search") && + ((isKnowledgeBaseSearchTool || + toolKbType === "aidp_search" || + toolKbType === "ind_aidp_search") && isKbListLoaded); if (selectedKbIds.length > 0 && canValidateSelection) { @@ -1156,18 +1157,26 @@ export default function ToolConfigModal({ }); setSelectedKbDisplayNames(displayNames); - if (toolKbType === "aidp_search") { + if ( + isKnowledgeBaseSearchTool || + toolKbType === "aidp_search" || + toolKbType === "ind_aidp_search" + ) { setTestPanelKbIds(validKbIds); setTestPanelKbDisplayNames(displayNames); const fieldIndex = currentParams.findIndex( - (p) => p.name === "kds_list" + (p) => + p.name === + (isKnowledgeBaseSearchTool ? "index_names" : "kds_list") ); if (fieldIndex !== -1) { form.setFieldValue(`param_${fieldIndex}`, validKbIds); } setCurrentParams((prevParams) => { const prevFieldIndex = prevParams.findIndex( - (p) => p.name === "kds_list" + (p) => + p.name === + (isKnowledgeBaseSearchTool ? "index_names" : "kds_list") ); if (prevFieldIndex === -1) return prevParams; const updatedParams = [...prevParams]; @@ -1183,6 +1192,7 @@ export default function ToolConfigModal({ }, [ knowledgeBases, isKbListLoaded, + isKnowledgeBaseSearchTool, toolKbType, selectedKbIds, currentParams, diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/useMergedToolParams.ts b/frontend/app/[locale]/agents/components/agentConfig/tool/useMergedToolParams.ts new file mode 100644 index 000000000..12711470e --- /dev/null +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/useMergedToolParams.ts @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { App } from "antd"; +import { useTranslation } from "react-i18next"; + +import { searchToolConfig } from "@/services/agentConfigService"; +import { useAgentStore } from "@/stores/agentStore"; +import type { Tool, ToolParam } from "@/types/agentConfig"; +import log from "@/lib/logger"; +import { mergeToolParamValues } from "./utils"; + +export function useMergedToolParams(currentAgentId?: number) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + return useCallback( + async (tool: Tool): Promise => { + const params = tool.initParams || []; + const selectedTools = useAgentStore.getState().editedAgent?.tools ?? []; + const selectedTool = selectedTools.find( + (item) => parseInt(item.id) === parseInt(tool.id) + ); + if (selectedTool) { + return mergeToolParamValues( + params, + Object.fromEntries( + selectedTool.initParams.map((param) => [param.name, param.value]) + ) + ); + } + if (!currentAgentId) return params; + + const instance = await searchToolConfig( + parseInt(tool.id), + currentAgentId + ); + if (!instance.success || !instance.data) { + log.error( + "Failed to load existing tool configuration:", + instance.message + ); + message.error( + t( + "nl2agent.resourceBinding.loadExistingConfigFailed", + "Failed to load the current resource configuration." + ) + ); + return null; + } + return mergeToolParamValues(params, instance.data.params); + }, + [currentAgentId, message, t] + ); +} diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/utils.ts b/frontend/app/[locale]/agents/components/agentConfig/tool/utils.ts index e733d9bba..76200db12 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/tool/utils.ts +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/utils.ts @@ -1,4 +1,4 @@ -import type { Tool } from "@/types/agentConfig"; +import type { Tool, ToolParam } from "@/types/agentConfig"; // Shared tool helpers used by Agent configuration and NL2Agent resource cards. @@ -20,6 +20,19 @@ export function mergeCanonicalTool(tool: Tool, tools: Tool[]): Tool { }; } +export function mergeToolParamValues( + params: ToolParam[], + values: Record | null | undefined +): ToolParam[] { + if (!values) return params.map((param) => ({ ...param })); + + return params.map((param) => + Object.prototype.hasOwnProperty.call(values, param.name) + ? { ...param, value: values[param.name] } + : { ...param } + ); +} + export const TOOLS_REQUIRING_KB_SELECTION = [ "dify_search", "datamate_search", diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index ea714b682..0229e8128 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -212,12 +212,14 @@ export type Nl2aRecommendedResource = | { candidate: Nl2aResourceCandidate & { resource_type: "tool" }; recommendation: "recommended" | "optional"; + is_bound: boolean; form_kind: "TOOL_CONFIG"; config: ToolParam[]; } | { candidate: Nl2aResourceCandidate & { resource_type: "skill" }; recommendation: "recommended" | "optional"; + is_bound: boolean; form_kind: "SKILL_CONFIG"; config: SkillParam[]; }; @@ -802,6 +804,7 @@ function parseNl2aMessage(chunk: SseChunk): Nl2aMessage | null { !resource?.candidate?.candidate_ref || !["tool", "skill"].includes(resource.candidate.resource_type) || !["recommended", "optional"].includes(resource.recommendation) || + typeof resource.is_bound !== "boolean" || !Array.isArray(resource.config) ) ) { diff --git a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx index 426dadce1..9d25c4541 100644 --- a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx +++ b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx @@ -17,13 +17,19 @@ import SkillConfigModal from "../../agents/components/agentConfig/skill/SkillCon import { findCanonicalTool, mergeCanonicalTool, + mergeToolParamValues, } from "../../agents/components/agentConfig/tool/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { useNl2AgentFlow } from "@/contexts/nl2AgentFlow"; +import { + useNl2AgentFlow, + type Nl2AgentConfigFocusTarget, +} from "@/contexts/nl2AgentFlow"; import { useToolList } from "@/hooks/agent/useToolList"; +import { isManagedKnowledgeTool } from "@/lib/managedKnowledgeTools"; import { searchAgentInfo, + searchToolConfig, updateToolConfig, saveSkillInstance, } from "@/services/agentConfigService"; @@ -66,6 +72,10 @@ interface BindingItemState { type BindingAction = | { type: "toggle"; ref: string } | { type: "save_config"; ref: string; params: Nl2AgentResourceParam[] } + | { + type: "sync_bound_tools"; + refs: Set; + } | { type: "validation_failed"; errors: Map; @@ -77,6 +87,54 @@ type BindingAction = const candidateRef = (item: BindingItemState): string => item.resource.candidate.candidate_ref; +const parseResourceId = (ref: string, expected: "tool" | "skill"): number => { + const match = new RegExp(`^${expected}:(\\d+)$`).exec(ref); + const resourceId = Number(match?.[1]); + if (!Number.isInteger(resourceId) || resourceId <= 0) { + throw new Error(`Invalid ${expected} candidate reference`); + } + return resourceId; +}; + +const findBindingTool = (item: BindingItemState, tools: Tool[]) => + findCanonicalTool(tools, parseResourceId(candidateRef(item), "tool")); + +const isManagedKnowledgeBinding = ( + item: BindingItemState, + tools: Tool[] +): boolean => { + if (item.resource.candidate.resource_type !== "tool") return false; + const tool = findBindingTool(item, tools); + return tool ? isManagedKnowledgeTool(tool) : false; +}; + +const isVisibleBinding = (item: BindingItemState, tools: Tool[]): boolean => { + if (item.resource.candidate.resource_type === "skill") return true; + return findBindingTool(item, tools)?.is_user_selectable !== false; +}; + +const resolveBindingFocusTarget = ( + boundItems: BindingItemState[], + tools: Tool[] +): Nl2AgentConfigFocusTarget | null => { + if (boundItems.every((item) => isManagedKnowledgeBinding(item, tools))) { + return { section: "knowledge_base" }; + } + + const visibleBoundItems = boundItems.filter((item) => + isVisibleBinding(item, tools) + ); + if (!visibleBoundItems.length) return null; + return { + section: "tools_skills", + capabilityTab: visibleBoundItems.some( + (item) => item.resource.candidate.resource_type === "tool" + ) + ? "tools" + : "skills", + }; +}; + const initializeItems = ( resources: Nl2aRecommendedResource[] ): BindingItemState[] => @@ -85,14 +143,14 @@ const initializeItems = ( const errors = validateNl2AgentResourceConfig(draftParams); return { resource, - selected: resource.recommendation === "recommended", + selected: resource.is_bound || resource.recommendation === "recommended", configStatus: draftParams.length === 0 ? "valid" : errors.length ? "unconfigured" : "valid", - bindingStatus: "idle", + bindingStatus: resource.is_bound ? "bound" : "idle", draftParams, fieldErrors: [], }; @@ -121,6 +179,19 @@ function reducer( error: undefined, }; } + if ( + action.type === "sync_bound_tools" && + item.resource.candidate.resource_type === "tool" && + item.bindingStatus !== "binding" + ) { + const isBound = action.refs.has(ref); + return { + ...item, + selected: isBound ? true : item.selected, + bindingStatus: isBound ? "bound" : "idle", + error: undefined, + }; + } if (action.type === "validation_failed") { const fieldErrors = action.errors.get(ref); return fieldErrors @@ -152,15 +223,6 @@ function reducer( }); } -const parseResourceId = (ref: string, expected: "tool" | "skill"): number => { - const match = new RegExp(`^${expected}:(\\d+)$`).exec(ref); - const resourceId = Number(match?.[1]); - if (!Number.isInteger(resourceId) || resourceId <= 0) { - throw new Error(`Invalid ${expected} candidate reference`); - } - return resourceId; -}; - const toPersistedBindings = ( boundItems: BindingItemState[], toolCatalog: Tool[], @@ -254,6 +316,7 @@ export const InstalledResourceBindingCard: FC<{ ); const [configuringRef, setConfiguringRef] = useState(null); const [summaryError, setSummaryError] = useState(null); + const [loadingConfigRef, setLoadingConfigRef] = useState(null); const [isSubmitted, setIsSubmitted] = useState(false); const [isSynchronizing, setIsSynchronizing] = useState(false); const hasToolResources = payload.resources.some( @@ -265,6 +328,8 @@ export const InstalledResourceBindingCard: FC<{ isError: isToolCatalogError, } = useToolList({ enabled: hasToolResources }); const waitForAutosave = useAgentStore((state) => state.waitForIdle); + const currentAgentId = useAgentStore((state) => state.currentAgentId); + const editedAgent = useAgentStore((state) => state.editedAgent); const applyPersistedResourceBindings = useAgentStore( (state) => state.applyPersistedResourceBindings ); @@ -272,11 +337,20 @@ export const InstalledResourceBindingCard: FC<{ (state) => state.replaceServerSnapshot ); + useEffect(() => { + if (currentAgentId !== payload.agent_id || !editedAgent) return; + dispatch({ + type: "sync_bound_tools", + refs: new Set(editedAgent.tools.map((tool) => `tool:${tool.id}`)), + }); + }, [currentAgentId, editedAgent, payload.agent_id]); + useEffect(() => { registerCard(cardKey, payload.subtype); }, [cardKey, payload.subtype, registerCard]); const isLocked = disabled || isSubmitted || !isCardInteractive(cardKey); + const isInteractionLocked = isLocked || loadingConfigRef !== null; const selectedItems = items.filter((item) => item.selected); const isBinding = items.some((item) => item.bindingStatus === "binding"); const canContinue = @@ -327,11 +401,78 @@ export const InstalledResourceBindingCard: FC<{ ); }; + const openResourceConfig = async ( + item: BindingItemState, + canonicalTool?: Tool + ) => { + const ref = candidateRef(item); + if (item.resource.candidate.resource_type === "skill") { + setConfiguringRef(ref); + return; + } + if (!canonicalTool) return; + setLoadingConfigRef(ref); + setSummaryError(null); + try { + const autosaveSucceeded = await waitForAutosave(); + if (!autosaveSucceeded) { + setSummaryError( + t( + "nl2agent.resourceBinding.autosaveFailed", + "Save the pending Agent changes before binding resources." + ) + ); + return; + } + const result = await searchToolConfig( + parseResourceId(ref, "tool"), + payload.agent_id + ); + if (!result.success || !result.data) { + setSummaryError( + t( + "nl2agent.resourceBinding.loadExistingConfigFailed", + "Failed to load the current resource configuration." + ) + ); + return; + } + dispatch({ + type: "save_config", + ref, + params: mergeToolParamValues( + item.resource.config as ToolParam[], + result.data.params + ), + }); + setConfiguringRef(ref); + } finally { + setLoadingConfigRef(null); + } + }; + const bindSelected = async () => { - if (isLocked || isBinding || isSynchronizing) return; + if (isInteractionLocked || isBinding || isSynchronizing) return; const pending = items.filter( (item) => item.selected && item.bindingStatus !== "bound" ); + const pendingToolWithoutCatalog = pending.some( + (item) => + item.resource.candidate.resource_type === "tool" && + !findCanonicalTool( + availableTools, + parseResourceId(candidateRef(item), "tool") + ) + ); + if (pendingToolWithoutCatalog) { + setSummaryError( + t( + "agentConfig.tools.fetchFailed", + "Failed to fetch tools list, please try again later" + ) + ); + return; + } const validationErrors = new Map(); pending.forEach((item) => { const errors = validateNl2AgentResourceConfig(item.draftParams); @@ -410,40 +551,45 @@ export const InstalledResourceBindingCard: FC<{ } }); - if (boundItems.length) { - const persistedBindings = toPersistedBindings( - boundItems, - queryClient.getQueryData(["tools"]) ?? [], - queryClient.getQueryData(["skills"]) ?? [] - ); - const applied = applyPersistedResourceBindings( - payload.agent_id, - persistedBindings - ); - const synchronized = - applied && - (await reloadAgentSnapshot([ - ...items.filter((item) => item.bindingStatus === "bound"), - ...boundItems, - ])); - if (!synchronized) { - showSynchronizationError(); - } else { - requestConfigFocus(payload.agent_id, { - section: "tools_skills", - capabilityTab: boundItems.some( - (item) => item.resource.candidate.resource_type === "tool" - ) - ? "tools" - : "skills", - }); - } + if (!boundItems.length) return; + + const persistedBindings = toPersistedBindings( + boundItems, + queryClient.getQueryData(["tools"]) ?? [], + queryClient.getQueryData(["skills"]) ?? [] + ); + const applied = applyPersistedResourceBindings( + payload.agent_id, + persistedBindings + ); + const synchronized = + applied && + (await reloadAgentSnapshot([ + ...items.filter((item) => item.bindingStatus === "bound"), + ...boundItems, + ])); + if (!synchronized) { + showSynchronizationError(); + return; } + + const focusTarget = resolveBindingFocusTarget(boundItems, availableTools); + if (focusTarget) requestConfigFocus(payload.agent_id, focusTarget); }; const continueFlow = async () => { - if (isLocked || !canContinue || isSynchronizing) return; + if (isInteractionLocked || !canContinue || isSynchronizing) return; setSummaryError(null); + const autosaveSucceeded = await waitForAutosave(); + if (!autosaveSucceeded) { + setSummaryError( + t( + "nl2agent.resourceBinding.autosaveFailed", + "Save the pending Agent changes before binding resources." + ) + ); + return; + } const synchronized = await reloadAgentSnapshot( items.filter((item) => item.bindingStatus === "bound") ); @@ -594,6 +740,7 @@ export const InstalledResourceBindingCard: FC<{ isToolResource && !canonicalTool && isToolCatalogFetching; const isToolUnavailable = isToolResource && !canonicalTool && !isToolCatalogFetching; + const isConfigLoading = loadingConfigRef === ref; const configureTitle = isToolCatalogPending ? t("toolPool.loadingTools", "Loading tools...") : isToolResource && isToolCatalogError @@ -609,7 +756,7 @@ export const InstalledResourceBindingCard: FC<{ dispatch({ type: "toggle", ref })} className="mt-1 size-4 accent-primary" aria-label={item.resource.candidate.name} @@ -663,18 +810,17 @@ export const InstalledResourceBindingCard: FC<{ size="icon" title={configureTitle} disabled={ - isLocked || - bound || + isInteractionLocked || isBinding || isToolCatalogPending || isToolUnavailable } - onClick={() => setConfiguringRef(ref)} + onClick={() => void openResourceConfig(item, canonicalTool)} className={ item.configStatus === "invalid" ? "border-destructive" : "" } > - {isToolCatalogPending ? ( + {isToolCatalogPending || isConfigLoading ? ( ) : ( @@ -703,7 +849,7 @@ export const InstalledResourceBindingCard: FC<{ ) : null} {!canContinue ? (