mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
feat(ui): add agent binding search (#106)
This commit is contained in:
parent
0ffc224623
commit
bcf194c35e
@ -1127,6 +1127,8 @@ export default {
|
||||
toolsKicker: 'Atomic tools the agent can call',
|
||||
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.',
|
||||
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
|
||||
searchSkills: 'Search skill name, description, or version',
|
||||
searchTools: 'Search tool name, description, source, or group',
|
||||
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.',
|
||||
@ -1140,6 +1142,8 @@ export default {
|
||||
providersAddHint: 'Click a provider below to add it to the preference list:',
|
||||
noSkills: 'No skills available',
|
||||
noTools: 'No tools available',
|
||||
noMatchingSkills: 'No matching skills',
|
||||
noMatchingTools: 'No matching tools',
|
||||
noProviderPreferences: 'No preferences set — the agent uses the global fallback chain order.',
|
||||
contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.',
|
||||
goToContext: 'Edit Context Files',
|
||||
|
||||
@ -1025,6 +1025,8 @@ export default {
|
||||
toolsKicker: '会用的原子工具',
|
||||
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
|
||||
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
|
||||
searchSkills: '搜索技能名称、描述或版本',
|
||||
searchTools: '搜索工具名称、描述、来源或分组',
|
||||
advancedToolsTitle: '高级:手选原子工具',
|
||||
advancedToolsHint: 'Skill 绑定已自动展开 allowed-tools。此处仅用于未打包成 Skill 的内置微工具(如 datetime、delegate_agent)。',
|
||||
toolUnionHint: '直选工具会与已绑定技能提供的工具合并生效。如果只想让员工使用某 MCP 服务的部分工具,请不要勾选对应的 MCP 技能,只在这里勾选具体工具。',
|
||||
@ -1038,6 +1040,8 @@ export default {
|
||||
providersAddHint: '点击下方提供商加入偏好列表:',
|
||||
noSkills: '暂无可用技能',
|
||||
noTools: '暂无可用工具',
|
||||
noMatchingSkills: '没有匹配的技能',
|
||||
noMatchingTools: '没有匹配的工具',
|
||||
noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
|
||||
contextHint: '管理此智能体的上下文文件(如 AGENT.md),定义智能体的行为、知识和指令。',
|
||||
goToContext: '前往编辑上下文',
|
||||
|
||||
47
mateclaw-ui/src/utils/agentBindingSearch.ts
Normal file
47
mateclaw-ui/src/utils/agentBindingSearch.ts
Normal file
@ -0,0 +1,47 @@
|
||||
type SearchableRecord = Record<string, unknown>
|
||||
|
||||
export interface AgentToolGroup<T extends SearchableRecord = SearchableRecord> {
|
||||
groupId: string
|
||||
label: string
|
||||
tools: T[]
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
return query.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function includesQuery(value: unknown, query: string): boolean {
|
||||
if (value === null || value === undefined) return false
|
||||
return String(value).toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
export function filterAgentBindingItems<T extends SearchableRecord>(items: T[], query: string): T[] {
|
||||
const q = normalizeQuery(query)
|
||||
if (!q) return items
|
||||
|
||||
return items.filter((item) => (
|
||||
includesQuery(item.name, q) ||
|
||||
includesQuery(item.rawName, q) ||
|
||||
includesQuery(item.description, q) ||
|
||||
includesQuery(item.version, q) ||
|
||||
includesQuery(item.source, q) ||
|
||||
includesQuery(item.group, q) ||
|
||||
includesQuery(item.providerName, q)
|
||||
))
|
||||
}
|
||||
|
||||
export function filterAgentToolGroups<T extends SearchableRecord>(
|
||||
groups: Array<AgentToolGroup<T>>,
|
||||
query: string,
|
||||
): Array<AgentToolGroup<T>> {
|
||||
const q = normalizeQuery(query)
|
||||
if (!q) return groups
|
||||
|
||||
return groups
|
||||
.map((group) => {
|
||||
const groupMatches = includesQuery(group.label, q) || includesQuery(group.groupId, q)
|
||||
const tools = groupMatches ? group.tools : filterAgentBindingItems(group.tools, q)
|
||||
return { ...group, tools }
|
||||
})
|
||||
.filter((group) => group.tools.length > 0)
|
||||
}
|
||||
@ -319,9 +319,17 @@
|
||||
</div>
|
||||
<p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
|
||||
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
|
||||
<template v-else>
|
||||
<div class="binding-search">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" />
|
||||
</div>
|
||||
<div v-if="filteredAvailableSkills.length === 0" class="binding-empty binding-empty--compact">{{ t('agents.binding.noMatchingSkills') }}</div>
|
||||
<div v-else class="binding-list">
|
||||
<label
|
||||
v-for="skill in availableSkills"
|
||||
v-for="skill in filteredAvailableSkills"
|
||||
:key="skill.id"
|
||||
class="binding-item"
|
||||
:class="{ selected: selectedSkillIds.includes(skill.id) }"
|
||||
@ -335,6 +343,7 @@
|
||||
<span v-if="skill.version" class="binding-version">v{{ skill.version }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Tools Tab — RFC-090 §9.2 调整 B: Advanced bypass for atomic
|
||||
@ -357,6 +366,12 @@
|
||||
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
|
||||
<p class="binding-hint advanced-tools-note">{{ t('agents.binding.advancedToolsHint') }}</p>
|
||||
<p class="binding-hint advanced-tools-note">{{ t('agents.binding.toolUnionHint') }}</p>
|
||||
<div v-if="availableToolGroups.length > 0" class="binding-search">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input v-model="toolBindingSearch" :placeholder="t('agents.binding.searchTools')" />
|
||||
</div>
|
||||
<!-- Render the empty state only when there is genuinely
|
||||
nothing to show. availableTools can be empty while
|
||||
availableToolGroups still contains a synthesized
|
||||
@ -364,8 +379,9 @@
|
||||
of the catalog) — that case must reach the list so
|
||||
the user can clean those orphans up. -->
|
||||
<div v-if="availableToolGroups.length === 0" class="binding-empty">{{ t('agents.binding.noTools') }}</div>
|
||||
<div v-else-if="filteredAvailableToolGroups.length === 0" class="binding-empty binding-empty--compact">{{ t('agents.binding.noMatchingTools') }}</div>
|
||||
<div v-else class="binding-list">
|
||||
<template v-for="group in availableToolGroups" :key="group.groupId">
|
||||
<template v-for="group in filteredAvailableToolGroups" :key="group.groupId">
|
||||
<div class="binding-group-header">{{ group.label }}</div>
|
||||
<label
|
||||
v-for="tool in group.tools"
|
||||
@ -473,6 +489,7 @@ import {
|
||||
type AgentPromptProfile,
|
||||
} from '@/utils/agentPromptProfile'
|
||||
import { agentIconColor } from '@/utils/agentIconColor'
|
||||
import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBindingSearch'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@ -490,6 +507,10 @@ const advancedToolsOpen = ref(false)
|
||||
// Binding state
|
||||
const availableSkills = ref<any[]>([])
|
||||
const availableTools = ref<any[]>([])
|
||||
const skillBindingSearch = ref('')
|
||||
const toolBindingSearch = ref('')
|
||||
|
||||
const filteredAvailableSkills = computed(() => filterAgentBindingItems(availableSkills.value, skillBindingSearch.value))
|
||||
|
||||
/**
|
||||
* Group the flat /tools/available payload by source so the picker
|
||||
@ -585,6 +606,8 @@ const availableToolGroups = computed(() => {
|
||||
return order.map((k) => groups[k])
|
||||
})
|
||||
|
||||
const filteredAvailableToolGroups = computed(() => filterAgentToolGroups(availableToolGroups.value, toolBindingSearch.value))
|
||||
|
||||
/**
|
||||
* Manual checkbox handler — replaces v-model on the picker row so that
|
||||
* two rows sharing a tool name (collision/duplicate twins) don't drag
|
||||
@ -743,6 +766,8 @@ function openBlankCreateModal() {
|
||||
form.value = defaultForm()
|
||||
profileForm.value = emptyProfile()
|
||||
modalTab.value = 'basic'
|
||||
skillBindingSearch.value = ''
|
||||
toolBindingSearch.value = ''
|
||||
selectedSkillIds.value = []
|
||||
selectedToolNames.value = []
|
||||
selectedProviderIds.value = []
|
||||
@ -815,6 +840,8 @@ async function openEditModal(agent: Agent) {
|
||||
}
|
||||
profileForm.value = parsePrompt(agent.systemPrompt)
|
||||
modalTab.value = 'basic'
|
||||
skillBindingSearch.value = ''
|
||||
toolBindingSearch.value = ''
|
||||
showModal.value = true
|
||||
|
||||
// Load available skills/tools/providers and current bindings in parallel
|
||||
@ -856,6 +883,8 @@ async function openEditModal(agent: Agent) {
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingAgent.value = null
|
||||
skillBindingSearch.value = ''
|
||||
toolBindingSearch.value = ''
|
||||
}
|
||||
|
||||
async function saveAgent() {
|
||||
@ -1242,6 +1271,29 @@ html.dark .live-pill {
|
||||
margin: 0;
|
||||
}
|
||||
.binding-empty { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
|
||||
.binding-empty--compact { padding: 24px 12px; }
|
||||
.binding-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.binding-search svg { flex-shrink: 0; }
|
||||
.binding-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.binding-search input::placeholder { color: var(--mc-text-tertiary); }
|
||||
.binding-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.binding-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||
|
||||
59
mateclaw-ui/test/agentBindingSearch.test.ts
Normal file
59
mateclaw-ui/test/agentBindingSearch.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
filterAgentBindingItems,
|
||||
filterAgentToolGroups,
|
||||
} from '../src/utils/agentBindingSearch.ts'
|
||||
|
||||
test('filterAgentBindingItems matches skills by name, description, and version', () => {
|
||||
const skills = [
|
||||
{ id: 1, name: 'Contract Review', description: 'Legal workflow', version: '1.2.0' },
|
||||
{ id: 2, name: 'Web Search', description: 'Find current facts', version: '2.0.0' },
|
||||
{ id: 3, name: 'Data Analyst', description: 'Spreadsheet work', version: null },
|
||||
]
|
||||
|
||||
assert.deepEqual(filterAgentBindingItems(skills, 'legal').map((skill) => skill.id), [1])
|
||||
assert.deepEqual(filterAgentBindingItems(skills, '2.0.0').map((skill) => skill.id), [2])
|
||||
})
|
||||
|
||||
test('filterAgentBindingItems keeps all skills in original order for an empty query', () => {
|
||||
const skills = [
|
||||
{ id: 1, name: 'Contract Review' },
|
||||
{ id: 2, name: 'Web Search' },
|
||||
]
|
||||
|
||||
assert.deepEqual(filterAgentBindingItems(skills, ' ').map((skill) => skill.id), [1, 2])
|
||||
})
|
||||
|
||||
test('filterAgentToolGroups matches tools by group, source, raw name, and description', () => {
|
||||
const groups = [
|
||||
{
|
||||
groupId: 'builtin',
|
||||
label: 'Built-in',
|
||||
tools: [
|
||||
{ name: 'datetime', rawName: 'datetime', source: 'builtin', description: 'Current time' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupId: 'mcp:browser',
|
||||
label: 'MCP · browser',
|
||||
tools: [
|
||||
{ name: 'mcp__browser__click', rawName: 'click', source: 'mcp', providerName: 'browser', description: 'Click page elements' },
|
||||
{ name: 'mcp__browser__screenshot', rawName: 'screenshot', source: 'mcp', providerName: 'browser', description: 'Capture the viewport' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
assert.deepEqual(filterAgentToolGroups(groups, 'browser').map((group) => group.groupId), ['mcp:browser'])
|
||||
assert.deepEqual(filterAgentToolGroups(groups, 'viewport')[0].tools.map((tool) => tool.name), ['mcp__browser__screenshot'])
|
||||
assert.deepEqual(filterAgentToolGroups(groups, 'built')[0].tools.map((tool) => tool.name), ['datetime'])
|
||||
})
|
||||
|
||||
test('filterAgentToolGroups keeps all groups and tools for an empty query', () => {
|
||||
const groups = [
|
||||
{ groupId: 'builtin', label: 'Built-in', tools: [{ name: 'datetime' }] },
|
||||
{ groupId: 'orphan', label: 'Bound but no longer available', tools: [{ name: 'old_tool' }] },
|
||||
]
|
||||
|
||||
assert.deepEqual(filterAgentToolGroups(groups, '').map((group) => group.groupId), ['builtin', 'orphan'])
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user