feat(ui): add agent binding search (#106)

This commit is contained in:
matevip 2026-05-12 18:16:55 +08:00
parent 0ffc224623
commit bcf194c35e
5 changed files with 183 additions and 17 deletions

View File

@ -1127,6 +1127,8 @@ export default {
toolsKicker: 'Atomic tools the agent can call', toolsKicker: 'Atomic tools the agent can call',
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.', 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.', 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', 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).', 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.', 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:', providersAddHint: 'Click a provider below to add it to the preference list:',
noSkills: 'No skills available', noSkills: 'No skills available',
noTools: 'No tools 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.', 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.', contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.',
goToContext: 'Edit Context Files', goToContext: 'Edit Context Files',

View File

@ -1025,6 +1025,8 @@ export default {
toolsKicker: '会用的原子工具', toolsKicker: '会用的原子工具',
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。', toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。', toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
searchSkills: '搜索技能名称、描述或版本',
searchTools: '搜索工具名称、描述、来源或分组',
advancedToolsTitle: '高级:手选原子工具', advancedToolsTitle: '高级:手选原子工具',
advancedToolsHint: 'Skill 绑定已自动展开 allowed-tools。此处仅用于未打包成 Skill 的内置微工具(如 datetime、delegate_agent。', advancedToolsHint: 'Skill 绑定已自动展开 allowed-tools。此处仅用于未打包成 Skill 的内置微工具(如 datetime、delegate_agent。',
toolUnionHint: '直选工具会与已绑定技能提供的工具合并生效。如果只想让员工使用某 MCP 服务的部分工具,请不要勾选对应的 MCP 技能,只在这里勾选具体工具。', toolUnionHint: '直选工具会与已绑定技能提供的工具合并生效。如果只想让员工使用某 MCP 服务的部分工具,请不要勾选对应的 MCP 技能,只在这里勾选具体工具。',
@ -1038,6 +1040,8 @@ export default {
providersAddHint: '点击下方提供商加入偏好列表:', providersAddHint: '点击下方提供商加入偏好列表:',
noSkills: '暂无可用技能', noSkills: '暂无可用技能',
noTools: '暂无可用工具', noTools: '暂无可用工具',
noMatchingSkills: '没有匹配的技能',
noMatchingTools: '没有匹配的工具',
noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。', noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
contextHint: '管理此智能体的上下文文件(如 AGENT.md定义智能体的行为、知识和指令。', contextHint: '管理此智能体的上下文文件(如 AGENT.md定义智能体的行为、知识和指令。',
goToContext: '前往编辑上下文', goToContext: '前往编辑上下文',

View 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)
}

View File

@ -319,22 +319,31 @@
</div> </div>
<p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p> <p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div> <div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
<div v-else class="binding-list"> <template v-else>
<label <div class="binding-search">
v-for="skill in availableSkills" <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
:key="skill.id" <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
class="binding-item" </svg>
:class="{ selected: selectedSkillIds.includes(skill.id) }" <input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" />
> </div>
<input type="checkbox" :value="skill.id" v-model="selectedSkillIds" class="binding-checkbox" /> <div v-if="filteredAvailableSkills.length === 0" class="binding-empty binding-empty--compact">{{ t('agents.binding.noMatchingSkills') }}</div>
<span class="binding-icon"><SkillIcon :value="skill.icon" :size="20" :fallback="'🧩'" /></span> <div v-else class="binding-list">
<div class="binding-info"> <label
<span class="binding-name">{{ skill.name }}</span> v-for="skill in filteredAvailableSkills"
<span v-if="skill.description" class="binding-desc">{{ skill.description?.slice(0, 80) }}</span> :key="skill.id"
</div> class="binding-item"
<span v-if="skill.version" class="binding-version">v{{ skill.version }}</span> :class="{ selected: selectedSkillIds.includes(skill.id) }"
</label> >
</div> <input type="checkbox" :value="skill.id" v-model="selectedSkillIds" class="binding-checkbox" />
<span class="binding-icon"><SkillIcon :value="skill.icon" :size="20" :fallback="'🧩'" /></span>
<div class="binding-info">
<span class="binding-name">{{ skill.name }}</span>
<span v-if="skill.description" class="binding-desc">{{ skill.description?.slice(0, 80) }}</span>
</div>
<span v-if="skill.version" class="binding-version">v{{ skill.version }}</span>
</label>
</div>
</template>
</div> </div>
<!-- Tools Tab RFC-090 §9.2 调整 B: Advanced bypass for atomic <!-- 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">{{ 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.advancedToolsHint') }}</p>
<p class="binding-hint advanced-tools-note">{{ t('agents.binding.toolUnionHint') }}</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 <!-- Render the empty state only when there is genuinely
nothing to show. availableTools can be empty while nothing to show. availableTools can be empty while
availableToolGroups still contains a synthesized availableToolGroups still contains a synthesized
@ -364,8 +379,9 @@
of the catalog) that case must reach the list so of the catalog) that case must reach the list so
the user can clean those orphans up. --> the user can clean those orphans up. -->
<div v-if="availableToolGroups.length === 0" class="binding-empty">{{ t('agents.binding.noTools') }}</div> <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"> <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> <div class="binding-group-header">{{ group.label }}</div>
<label <label
v-for="tool in group.tools" v-for="tool in group.tools"
@ -473,6 +489,7 @@ import {
type AgentPromptProfile, type AgentPromptProfile,
} from '@/utils/agentPromptProfile' } from '@/utils/agentPromptProfile'
import { agentIconColor } from '@/utils/agentIconColor' import { agentIconColor } from '@/utils/agentIconColor'
import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBindingSearch'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
@ -490,6 +507,10 @@ const advancedToolsOpen = ref(false)
// Binding state // Binding state
const availableSkills = ref<any[]>([]) const availableSkills = ref<any[]>([])
const availableTools = 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 * Group the flat /tools/available payload by source so the picker
@ -585,6 +606,8 @@ const availableToolGroups = computed(() => {
return order.map((k) => groups[k]) 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 * Manual checkbox handler replaces v-model on the picker row so that
* two rows sharing a tool name (collision/duplicate twins) don't drag * two rows sharing a tool name (collision/duplicate twins) don't drag
@ -743,6 +766,8 @@ function openBlankCreateModal() {
form.value = defaultForm() form.value = defaultForm()
profileForm.value = emptyProfile() profileForm.value = emptyProfile()
modalTab.value = 'basic' modalTab.value = 'basic'
skillBindingSearch.value = ''
toolBindingSearch.value = ''
selectedSkillIds.value = [] selectedSkillIds.value = []
selectedToolNames.value = [] selectedToolNames.value = []
selectedProviderIds.value = [] selectedProviderIds.value = []
@ -815,6 +840,8 @@ async function openEditModal(agent: Agent) {
} }
profileForm.value = parsePrompt(agent.systemPrompt) profileForm.value = parsePrompt(agent.systemPrompt)
modalTab.value = 'basic' modalTab.value = 'basic'
skillBindingSearch.value = ''
toolBindingSearch.value = ''
showModal.value = true showModal.value = true
// Load available skills/tools/providers and current bindings in parallel // Load available skills/tools/providers and current bindings in parallel
@ -856,6 +883,8 @@ async function openEditModal(agent: Agent) {
function closeModal() { function closeModal() {
showModal.value = false showModal.value = false
editingAgent.value = null editingAgent.value = null
skillBindingSearch.value = ''
toolBindingSearch.value = ''
} }
async function saveAgent() { async function saveAgent() {
@ -1242,6 +1271,29 @@ html.dark .live-pill {
margin: 0; margin: 0;
} }
.binding-empty { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; } .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-list { display: flex; flex-direction: column; gap: 6px; }
.binding-item { .binding-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; padding: 10px 12px;

View 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'])
})