mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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',
|
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',
|
||||||
|
|||||||
@ -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: '前往编辑上下文',
|
||||||
|
|||||||
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,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;
|
||||||
|
|||||||
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