From 948863437215c1dffba726e03a9ebf890042209a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Mon, 8 Jun 2026 00:31:43 +0800 Subject: [PATCH] feat(agent): add tag filtering to the roster (#146) Adds a tag filter bar to the agent roster, orthogonal to the existing type/status tabs. Multi-select narrows by intersection (an agent must carry every selected tag). Tags also render as clickable chips on each agent card. The bar is hidden when no agent has tags. Tags are de-duped per agent and ordered by usage frequency. When a workspace has more than 12 distinct tags, only the top-N show inline and a search box filters the rest; selected tags stay visible regardless. --- mateclaw-ui/src/i18n/locales/en-US.ts | 6 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 6 ++ mateclaw-ui/src/views/Agents.vue | 90 +++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index e5bf7160..11394990 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1276,6 +1276,12 @@ export default { toggleFailed: 'Failed to toggle status', toggleSuccess: 'Status updated', }, + tagFilter: { + label: 'Tags', + clear: 'Clear', + search: 'Search tags', + noMatch: 'No matching tags', + }, binding: { skillsKicker: 'Trained workflows', skillsTagline: 'A skill is a workflow — a step-by-step playbook the LLM follows for a coherent multi-step task.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index bb212e70..346f48c6 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1168,6 +1168,12 @@ export default { toggleFailed: '切换状态失败', toggleSuccess: '状态已更新', }, + tagFilter: { + label: '标签', + clear: '清除', + search: '搜索标签', + noMatch: '无匹配标签', + }, binding: { skillsKicker: '受过培训的工作流程', skillsTagline: '技能 = 一段流程,一份工作手册。LLM 按手册执行一系列连贯动作。', diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index 760fc1b3..b12b5008 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -57,6 +57,23 @@ + +
+ {{ t('agents.tagFilter.label') }} + + + + {{ t('agents.tagFilter.noMatch') }} + + +
@@ -86,6 +103,12 @@

{{ agentTagline(agent) || t('agents.messages.noTagline') }}

+
+ + {{ tag }} + +
@@ -644,6 +667,9 @@ const { resolveSkillName } = useSkillName() const agents = ref([]) const searchText = ref('') const activeFilter = ref('all') +// Tag filter (#146) — orthogonal to activeFilter; multi-select with AND +// (intersection) semantics. Empty array = no tag constraint. +const activeTags = ref([]) const showModal = ref(false) const editingAgent = ref(null) const modalTab = ref<'basic' | 'skills' | 'tools' | 'providers' | 'wiki'>('basic') @@ -904,6 +930,48 @@ function agentTagline(agent: Agent): string { return deriveTagline(profile, agent.description) } +/** Parse an agent's comma-separated tags into a trimmed, non-empty, de-duped array. */ +function agentTags(agent: Agent): string[] { + const parsed = (agent.tags || '').split(',').map(t => t.trim()).filter(Boolean) + return [...new Set(parsed)] +} + +// Distinct tags across all agents, ordered by frequency (most-used first) so +// the filter bar surfaces the common categories. Hidden entirely when empty. +const availableTags = computed(() => { + const freq = new Map() + for (const a of agents.value) { + for (const tag of agentTags(a)) freq.set(tag, (freq.get(tag) ?? 0) + 1) + } + return [...freq.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(e => e[0]) +}) + +// Cap the inline chips so a workspace with dozens of tags doesn't bury the +// roster under a wall of chips. Beyond the cap, a search box appears. +const TAG_FILTER_LIMIT = 12 +const tagSearch = ref('') +const hasMoreTags = computed(() => availableTags.value.length > TAG_FILTER_LIMIT) + +// Chips actually rendered. When searching, show every name match (no cap). +// Otherwise show the top-N frequent tags, but always append any selected tags +// that fell outside the cap so they stay deselectable. +const visibleTags = computed(() => { + const q = tagSearch.value.trim().toLowerCase() + if (q) return availableTags.value.filter(t => t.toLowerCase().includes(q)) + const top = availableTags.value.slice(0, TAG_FILTER_LIMIT) + const extraActive = activeTags.value.filter(t => !top.includes(t) && availableTags.value.includes(t)) + return [...top, ...extraActive] +}) + +function toggleTag(tag: string) { + const i = activeTags.value.indexOf(tag) + if (i >= 0) activeTags.value.splice(i, 1) + else activeTags.value.push(tag) +} +function clearTagFilter() { + activeTags.value = [] +} + const filteredAgents = computed(() => { let list = agents.value if (searchText.value) { @@ -918,6 +986,13 @@ const filteredAgents = computed(() => { else if (activeFilter.value === 'plan_execute') list = list.filter(a => a.agentType === 'plan_execute') else if (activeFilter.value === 'enabled') list = list.filter(a => a.enabled) else if (activeFilter.value === 'disabled') list = list.filter(a => !a.enabled) + // Tag filter: intersection — an agent must carry every selected tag. + if (activeTags.value.length) { + list = list.filter(a => { + const tags = agentTags(a) + return activeTags.value.every(t => tags.includes(t)) + }) + } return list }) @@ -1356,6 +1431,16 @@ html.dark .seg-count.warn { .filter-tab { padding: 8px 14px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; font-weight: 600; } .filter-tab:hover { background: var(--mc-bg-sunken); } .filter-tab.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 500; } +.tag-filter-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--mc-border); } +.tag-filter-bar__label { font-size: 12px; color: var(--mc-text-tertiary); font-weight: 600; margin-right: 2px; } +.tag-filter-chip { padding: 4px 12px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; } +.tag-filter-chip:hover { background: var(--mc-bg-sunken); } +.tag-filter-chip.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 600; } +.tag-filter-search { padding: 4px 10px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 12px; color: var(--mc-text-primary); width: 120px; outline: none; } +.tag-filter-search:focus { border-color: var(--mc-primary); } +.tag-filter-bar__empty { font-size: 12px; color: var(--mc-text-tertiary); } +.tag-filter-clear { padding: 4px 10px; border: none; background: transparent; font-size: 12px; color: var(--mc-text-tertiary); cursor: pointer; text-decoration: underline; } +.tag-filter-clear:hover { color: var(--mc-text-secondary); } /* Agent Card Grid */ .agent-grid { @@ -1440,6 +1525,11 @@ html.dark .seg-count.warn { letter-spacing: -0.005em; } +.agent-card__tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } +.agent-card__tag { font-size: 11px; padding: 2px 8px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; white-space: nowrap; cursor: pointer; transition: all 0.15s; } +.agent-card__tag:hover { color: var(--mc-text-secondary); } +.agent-card__tag.active { background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 600; } + .agent-card__action-row { display: flex; align-items: center;