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;