From 2048768baf66b14f61522dd2325c2304af608e4e Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 7 May 2026 08:17:46 +0800 Subject: [PATCH] feat(ui): grouped agent tool picker with stale, unavailable, and orphan handling --- mateclaw-ui/src/api/index.ts | 6 + mateclaw-ui/src/i18n/locales/en-US.ts | 7 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 7 + mateclaw-ui/src/views/Agents.vue | 179 +++++++++++++++++++++++--- 4 files changed, 183 insertions(+), 16 deletions(-) diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 84b85847..6d1c7a9c 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -318,6 +318,12 @@ export const datasourceApi = { export const toolApi = { list: () => http.get('/tools'), listEnabled: () => http.get('/tools/enabled'), + /** + * Unified picker source for the agent edit tool tab — returns built-in + * tools plus every MCP-discovered tool grouped by server. The `name` + * field is what gets saved into mate_agent_tool.tool_name. + */ + listAvailable: () => http.get('/tools/available'), get: (id: string | number) => http.get(`/tools/${id}`), create: (data: any) => http.post('/tools', data), update: (id: string | number, data: any) => http.put(`/tools/${id}`, data), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 8a97308c..e2067d9d 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1004,6 +1004,13 @@ export default { toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.', advancedToolsTitle: 'Advanced: Hand-picked atomic tools', advancedToolsHint: 'Skill bindings already auto-expand allowed tools. Use this only for built-in micro-utilities not packaged as a skill (e.g. datetime, delegate_agent).', + toolUnionHint: 'Tools selected here are unioned with tools from any bound skills. To restrict an employee to a subset of an MCP server\'s tools, leave the MCP skill unchecked and select only the tools you want here.', + toolStaleBadge: 'Cached (offline)', + toolStaleTooltip: 'The owning MCP server is not currently connected. This tool entry comes from the last successful snapshot; the LLM will not invoke it until the connection is restored.', + toolUnavailableBadge: 'Unavailable', + toolUnavailableTooltip: 'This tool\'s name conflicts with another tool on the same server and cannot be bound ({reason}). Rename the upstream tool to resolve.', + toolOrphanGroup: 'Bound but no longer available', + toolOrphanDescription: 'This tool was previously bound but is no longer in the available catalog (its MCP server may have been removed, or the tool was retired upstream). Uncheck and save to clean up the leftover binding.', providersHint: 'Preferred provider order for this agent (lower index tried first). Leave empty to use the global available-pool order. Cooling-down or pool-removed providers are still skipped automatically.', providersAddHint: 'Click a provider below to add it to the preference list:', noSkills: 'No skills available', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d9b2be0e..7d43e5fb 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -902,6 +902,13 @@ export default { toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。', advancedToolsTitle: '高级:手选原子工具', advancedToolsHint: 'Skill 绑定已自动展开 allowed-tools。此处仅用于未打包成 Skill 的内置微工具(如 datetime、delegate_agent)。', + toolUnionHint: '直选工具会与已绑定技能提供的工具合并生效。如果只想让员工使用某 MCP 服务的部分工具,请不要勾选对应的 MCP 技能,只在这里勾选具体工具。', + toolStaleBadge: '离线缓存', + toolStaleTooltip: '该 MCP 服务当前未连接,工具列表来自上次连接时的快照;连接恢复前 LLM 不会调用这个工具。', + toolUnavailableBadge: '不可用', + toolUnavailableTooltip: '此工具的命名与同服务下的另一个工具冲突,无法绑定({reason})。请在上游 MCP 服务中重命名后重试。', + toolOrphanGroup: '已绑定但当前不可用', + toolOrphanDescription: '此工具在以前绑定过,但已不在当前可用工具列表中(如所属 MCP 服务被删除或工具被上游下线)。取消勾选并保存可清理掉这条遗留绑定。', providersHint: '此智能体优先使用的提供商顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。提供商进入冷却或被移出池时仍会被自动跳过。', providersAddHint: '点击下方提供商加入偏好列表:', noSkills: '暂无可用技能', diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index fe7c3012..8edd5808 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -356,22 +356,56 @@

{{ t('agents.binding.toolsHint') }}

{{ t('agents.binding.advancedToolsHint') }}

-
{{ t('agents.binding.noTools') }}
+

{{ t('agents.binding.toolUnionHint') }}

+ +
{{ t('agents.binding.noTools') }}
- +
@@ -456,6 +490,116 @@ const advancedToolsOpen = ref(false) // Binding state const availableSkills = ref([]) const availableTools = ref([]) + +/** + * Group the flat /tools/available payload by source so the picker + * renders one section per origin (built-in, MCP per server). Groups + * are stable in insertion order — built-in first because the API + * returns them first, then MCP groups in server discovery order. + * + *

For each row we also pre-compute {@code _isSelected} / + * {@code _isDisabled} so the template doesn't have to derive them from + * {@code tool.name} alone. With hash-collision and duplicate-raw rows + * sharing the same {@code name} as the bindable twin, naively using + * {@code selectedToolNames.includes(tool.name)} would mark both checked + * and let the user uncheck the unavailable one — the unchecked twin + * would silently mutate the bound name. The pre-computed flags decouple + * each row's UI state from any sibling row that shares its prefixed + * name. + */ +const availableToolGroups = computed(() => { + const groups: Record = {} + const order: string[] = [] + + // Names that any available row claims. Unavailable rows whose name is + // also held by an available row are "shadowed" — they must never look + // selected and must never accept a click. Unavailable rows whose name + // ISN'T in this set are orphans (e.g. a saved binding whose tool got + // removed upstream); the user must still be able to uncheck them. + const bindableNames = new Set() + // Names that appear anywhere in availableTools (with either flag). Any + // entry in selectedToolNames whose name is not in this set is a + // "catalog-orphan" — saved before the upstream catalog dropped it — + // and needs a synthesized row so the user can uncheck it. + const knownNames = new Set() + for (const t of availableTools.value) { + knownNames.add(t.name) + if (t.available) bindableNames.add(t.name) + } + + for (const t of availableTools.value) { + const key = t.groupId || (t.source === 'mcp' ? `mcp:${t.providerId}` : 'builtin') + if (!groups[key]) { + const label = t.group || (t.source === 'mcp' ? `MCP · ${t.providerName ?? ''}` : t.source || 'tools') + groups[key] = { groupId: key, label, tools: [] } + order.push(key) + } + + const inSelection = selectedToolNames.value.includes(t.name) + const isOrphanUnavailable = !t.available && !bindableNames.has(t.name) + // selected: only the bindable row owns the name; orphan unavailable + // rows reflect their own selection state so the user can clean them up. + const _isSelected = (t.available || isOrphanUnavailable) && inSelection + // disabled: shadowed rows are hard-disabled (let the bindable twin + // own the click); orphan unavailable rows allow only "uncheck" + // (currently-selected → enabled; not selected → disabled). + const _isDisabled = !t.available && !(isOrphanUnavailable && inSelection) + groups[key].tools.push({ ...t, _isSelected, _isDisabled, _isOrphanUnavailable: isOrphanUnavailable }) + } + + // Catalog-orphan synthesis: any name in the existing binding that + // /tools/available no longer returns at all. The backend save path + // permits removing such names ("keeps" don't validate), but without a + // visible row the user has no way to trigger the removal. Render them + // in their own group; uncheck removes them from selectedToolNames and + // the synthesized row vanishes on the next computed pass (the name is + // no longer in selectedToolNames). + const orphanNames = selectedToolNames.value.filter((n) => !knownNames.has(n)) + if (orphanNames.length > 0) { + const orphanGroupId = 'orphan' + groups[orphanGroupId] = { + groupId: orphanGroupId, + label: t('agents.binding.toolOrphanGroup'), + tools: orphanNames.map((n) => ({ + rowId: `orphan#${n}`, + source: 'orphan', + providerId: null, + providerName: null, + name: n, + rawName: n, + description: t('agents.binding.toolOrphanDescription'), + group: t('agents.binding.toolOrphanGroup'), + groupId: orphanGroupId, + stale: false, + available: false, + unavailableReason: 'NOT_IN_CATALOG', + // Always selected (it is, by definition, in selectedToolNames) + // and always uncheckable so the user can remove it. + _isSelected: true, + _isDisabled: false, + _isOrphanUnavailable: true, + })), + } + order.push(orphanGroupId) + } + return order.map((k) => groups[k]) +}) + +/** + * Manual checkbox handler — replaces v-model on the picker row so that + * two rows sharing a tool name (collision/duplicate twins) don't drag + * each other's selection state via Vue's v-model auto-sync. + */ +function onToolToggle(toolName: string, event: Event) { + const target = event.target as HTMLInputElement + if (target.checked) { + if (!selectedToolNames.value.includes(toolName)) { + selectedToolNames.value.push(toolName) + } + } else { + selectedToolNames.value = selectedToolNames.value.filter((n) => n !== toolName) + } +} const selectedSkillIds = ref([]) const selectedToolNames = ref([]) // RFC-009 PR-3: per-agent provider preference order @@ -679,7 +823,10 @@ async function openEditModal(agent: Agent) { // RFC-042: /skills is now paginated; binding dropdown only needs enabled skills, // so listEnabled() is both semantically correct and shape-stable (returns array). skillApi.listEnabled(), - toolApi.list(), + // /tools/available aggregates built-in tools + every MCP-discovered + // tool grouped by server, with stale/available flags so the picker + // matches the runtime callback set exactly. + toolApi.listAvailable(), modelApi.listProviders(), agentBindingApi.listSkills(agent.id), agentBindingApi.listTools(agent.id),