feat(skills): distinguish agent templates from business skills in UI
Deploy to Production / deploy (push) Waiting to run Details

The skills tab mixed generic execution-engine templates (react/direct/
rewoo/...) with business-domain skills (monitor/geo_optimizer/...) with
no visual or data distinction. Adds a derived `category` field to the
SkillInfo/SkillDetail API models and groups the frontend display.

Backend:
- SkillInfo/SkillDetail: add category (Literal), agent_type, execution_mode,
  task_mode fields
- _skill_to_info: derive category from explicit _ENGINE_TEMPLATE_NAMES set
  (not name suffix — trend_agent/deai_agent are business skills despite
  the _agent suffix)
- Simplify repetitive hasattr pattern with getattr

Frontend:
- ISkillInfo/ISkillDetail: add category + mode fields
- skills store: agentTemplates/businessSkills computed getters
  (businessSkills is defensive: anything not explicitly engine template)
- SkillsView: group into 执行引擎 / 业务技能 sections with counts
- SkillCard: type badge (引擎/技能), category-based icon, mode display,
  dark-mode-aware accent color

Tests:
- test_category_derived_from_name_suffix: verifies field exposure
- test_category_no_orphans: invariant — every skill has a valid category
- test_trend_agent_classified_as_business_skill: regression guard for
  the _agent suffix misclassification bug

Code review (ce-code-review): 2 P1 + 5 P2 findings applied.
This commit is contained in:
chiguyong 2026-06-23 15:55:59 +08:00
parent e600722378
commit a672dddc9a
6 changed files with 255 additions and 38 deletions

View File

@ -4,6 +4,8 @@ import { BaseApiClient } from './base'
const API_BASE = '/api/v1/skill-management'
export type SkillCategory = 'agent_template' | 'business_skill'
export interface ISkillInfo {
name: string
version: string
@ -11,6 +13,11 @@ export interface ISkillInfo {
capabilities: string[]
dependencies: string[]
status: string
/** "agent_template" = 通用执行引擎 (react/direct/rewoo/...); "business_skill" = 业务领域技能 */
category: SkillCategory
agent_type: string
execution_mode: string
task_mode: string
}
export interface ISkillDetail {
@ -21,6 +28,10 @@ export interface ISkillDetail {
dependencies: string[]
config: Record<string, unknown>
health_status: string
category: SkillCategory
agent_type: string
execution_mode: string
task_mode: string
}
export interface ICapabilityInfo {

View File

@ -1,19 +1,32 @@
<template>
<div class="skill-card" @click="$emit('click')">
<div class="skill-card" :class="`skill-card--${skill.category}`" @click="$emit('click')">
<a-card hoverable size="small">
<template #title>
<div class="skill-card__title">
<AppstoreOutlined class="skill-card__icon" />
<component :is="titleIcon" class="skill-card__icon" />
<span>{{ skill.name }}</span>
</div>
</template>
<template #extra>
<a-badge
:status="skill.status === 'active' ? 'success' : 'default'"
:text="skill.status === 'active' ? '正常' : skill.status"
/>
<div class="skill-card__badges">
<a-tag
:color="skill.category === 'agent_template' ? 'purple' : 'blue'"
size="small"
class="skill-card__type-tag"
>
{{ skill.category === 'agent_template' ? '引擎' : '技能' }}
</a-tag>
<a-badge
:status="skill.status === 'active' ? 'success' : 'default'"
:text="skill.status === 'active' ? '正常' : skill.status"
/>
</div>
</template>
<p class="skill-card__desc">{{ skill.description || '暂无描述' }}</p>
<div v-if="modeText" class="skill-card__mode">
<span class="skill-card__mode-label">{{ modeLabel }}:</span>
<code class="skill-card__mode-value">{{ modeText }}</code>
</div>
<div class="skill-card__tags">
<a-tag v-for="cap in skill.capabilities" :key="cap" size="small" color="blue">
{{ cap }}
@ -36,16 +49,34 @@
</template>
<script setup lang="ts">
import { AppstoreOutlined } from '@ant-design/icons-vue'
import { computed } from 'vue'
import { ThunderboltOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
import type { ISkillInfo } from '@/api/skills'
defineProps<{
const props = defineProps<{
skill: ISkillInfo
}>()
defineEmits<{
click: []
}>()
// Differentiate icon by category: engine = thunderbolt, business = appstore
const titleIcon = computed(() =>
props.skill.category === 'agent_template' ? ThunderboltOutlined : AppstoreOutlined
)
// Show the most relevant mode field.
// Agent templates: execution_mode (react/direct/rewoo/...).
// Business skills: agent_type (the domain identifier).
const modeText = computed(() =>
props.skill.category === 'agent_template'
? props.skill.execution_mode
: props.skill.agent_type
)
const modeLabel = computed(() =>
props.skill.category === 'agent_template' ? '执行模式' : '领域类型'
)
</script>
<style scoped>
@ -68,6 +99,40 @@ defineEmits<{
color: var(--color-primary);
}
.skill-card--agent_template .skill-card__icon {
color: var(--accent-board); /* purple — matches the 引擎 tag, dark-mode aware */
}
.skill-card__badges {
display: flex;
align-items: center;
gap: 6px;
}
.skill-card__type-tag {
margin-right: 0;
}
.skill-card__mode {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-2);
font-size: var(--font-xs, 12px);
}
.skill-card__mode-label {
color: var(--text-placeholder);
}
.skill-card__mode-value {
font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
color: var(--text-secondary);
background: var(--bg-secondary, rgba(0, 0, 0, 0.04));
padding: 1px 4px;
border-radius: 3px;
}
.skill-card__desc {
font-size: var(--font-sm);
color: var(--text-secondary);

View File

@ -17,6 +17,14 @@ export const useSkillsStore = defineStore('skills', () => {
// --- Getters ---
const skillNames = computed(() => skills.value.map((s) => s.name))
/** 通用执行引擎模板 (react/direct/rewoo/reflexion/...) */
const agentTemplates = computed(() =>
skills.value.filter((s) => s.category === 'agent_template')
)
/** 业务领域技能 (monitor/geo_optimizer/code_reviewer/...) — defensive: anything not explicitly an engine template */
const businessSkills = computed(() =>
skills.value.filter((s) => s.category !== 'agent_template')
)
// --- Actions ---
async function fetchSkills(capability?: string, page?: number): Promise<void> {
@ -106,6 +114,8 @@ export const useSkillsStore = defineStore('skills', () => {
selectedCapability,
// Getters
skillNames,
agentTemplates,
businessSkills,
// Actions
fetchSkills,
fetchCapabilities,

View File

@ -25,15 +25,46 @@
</div>
<a-spin :spinning="skillsStore.isLoading">
<div class="skills-view__grid">
<SkillCard
v-for="skill in skillsStore.skills"
:key="skill.name"
:skill="skill"
@click="handleSkillClick(skill.name)"
/>
</div>
<a-empty v-if="!skillsStore.isLoading && skillsStore.skills.length === 0" description="暂无已注册技能" />
<!-- 通用执行引擎模板 -->
<section v-if="skillsStore.agentTemplates.length > 0" class="skills-view__group">
<div class="skills-view__group-header">
<ThunderboltOutlined class="skills-view__group-icon" />
<span class="skills-view__group-title">执行引擎</span>
<a-tag color="purple">{{ skillsStore.agentTemplates.length }}</a-tag>
<span class="skills-view__group-hint">通用推理模式决定 Agent 如何思考和调用工具</span>
</div>
<div class="skills-view__grid">
<SkillCard
v-for="skill in skillsStore.agentTemplates"
:key="skill.name"
:skill="skill"
@click="handleSkillClick(skill.name)"
/>
</div>
</section>
<!-- 业务领域技能 -->
<section v-if="skillsStore.businessSkills.length > 0" class="skills-view__group">
<div class="skills-view__group-header">
<AppstoreOutlined class="skills-view__group-icon" />
<span class="skills-view__group-title">业务技能</span>
<a-tag color="blue">{{ skillsStore.businessSkills.length }}</a-tag>
<span class="skills-view__group-hint">面向具体业务场景的领域能力</span>
</div>
<div class="skills-view__grid">
<SkillCard
v-for="skill in skillsStore.businessSkills"
:key="skill.name"
:skill="skill"
@click="handleSkillClick(skill.name)"
/>
</div>
</section>
<a-empty
v-if="!skillsStore.isLoading && skillsStore.skills.length === 0"
description="暂无已注册技能"
/>
</a-spin>
<SkillDetail
@ -159,4 +190,34 @@ async function handleInstall(): Promise<void> {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4);
}
.skills-view__group {
margin-bottom: var(--space-6);
}
.skills-view__group-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-color-split, rgba(0, 0, 0, 0.06));
}
.skills-view__group-icon {
font-size: 16px;
color: var(--color-primary);
}
.skills-view__group-title {
font-size: var(--font-md, 14px);
font-weight: 600;
color: var(--text-primary);
}
.skills-view__group-hint {
font-size: var(--font-xs, 12px);
color: var(--text-placeholder);
margin-left: var(--space-1);
}
</style>

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
@ -12,6 +12,20 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["skill-management"])
# Explicit set of generic execution-engine templates (react/direct/rewoo/...).
# Everything else is a business-domain skill. Explicit (not name-suffix based)
# because trend_agent/deai_agent are business skills despite the _agent suffix.
# ponytail: ceiling — new engine templates must be added here. Upgrade path:
# add an explicit `category` field to SkillConfig (方案 C).
_ENGINE_TEMPLATE_NAMES = frozenset({
"react_agent",
"direct_agent",
"rewoo_agent",
"reflexion_agent",
"plan_exec_agent",
"goal_driven_agent",
})
# ---------------------------------------------------------------------------
# Request / Response models
@ -25,6 +39,10 @@ class SkillInfo(BaseModel):
capabilities: list[str]
dependencies: list[str]
status: str
category: Literal["agent_template", "business_skill"]
agent_type: str = ""
execution_mode: str = ""
task_mode: str = ""
class SkillDetail(BaseModel):
@ -35,6 +53,10 @@ class SkillDetail(BaseModel):
dependencies: list[str]
config: dict[str, Any]
health_status: str
category: Literal["agent_template", "business_skill"]
agent_type: str = ""
execution_mode: str = ""
task_mode: str = ""
class CapabilityInfo(BaseModel):
@ -50,29 +72,30 @@ class CapabilityInfo(BaseModel):
def _skill_to_info(skill: Any) -> dict[str, Any]:
"""Convert a Skill object to a dict suitable for API responses."""
capabilities = []
if hasattr(skill, "config") and hasattr(skill.config, "capabilities"):
caps = skill.config.capabilities
if isinstance(caps, list):
capabilities = [c.tag if hasattr(c, "tag") else str(c) for c in caps]
elif isinstance(caps, dict):
capabilities = list(caps.keys())
config = getattr(skill, "config", None)
dependencies = []
if hasattr(skill, "config") and hasattr(skill.config, "dependencies"):
deps = skill.config.dependencies
if isinstance(deps, list):
dependencies = deps
elif isinstance(deps, dict):
dependencies = list(deps.keys())
capabilities: list[str] = []
caps = getattr(config, "capabilities", None)
if isinstance(caps, list):
capabilities = [c.tag if hasattr(c, "tag") else str(c) for c in caps]
elif isinstance(caps, dict):
capabilities = list(caps.keys())
version = ""
if hasattr(skill, "config") and hasattr(skill.config, "version"):
version = skill.config.version or ""
deps = getattr(config, "dependencies", None)
if isinstance(deps, list):
dependencies: list[str] = deps
elif isinstance(deps, dict):
dependencies = list(deps.keys())
else:
dependencies = []
description = ""
if hasattr(skill, "config") and hasattr(skill.config, "description"):
description = skill.config.description or ""
version = getattr(config, "version", "") or ""
description = getattr(config, "description", "") or ""
agent_type = getattr(config, "agent_type", "") or ""
execution_mode = getattr(config, "execution_mode", "") or ""
task_mode = getattr(config, "task_mode", "") or ""
category = "agent_template" if skill.name in _ENGINE_TEMPLATE_NAMES else "business_skill"
return {
"name": skill.name,
@ -81,6 +104,10 @@ def _skill_to_info(skill: Any) -> dict[str, Any]:
"capabilities": capabilities,
"dependencies": dependencies,
"status": "active",
"category": category,
"agent_type": agent_type,
"execution_mode": execution_mode,
"task_mode": task_mode,
}

View File

@ -113,6 +113,49 @@ class TestListSkills:
assert "dependencies" in skill
assert "status" in skill
def test_category_derived_from_name_suffix(self, client, skill_registry):
"""category field distinguishes agent templates from business skills.
*_agent -> "agent_template"
others -> "business_skill"
Also verifies agent_type/execution_mode/task_mode are exposed.
"""
_register_skill(skill_registry, "react_agent", execution_mode="react")
_register_skill(skill_registry, "geo_optimizer")
response = client.get("/api/v1/skill-management/skills")
data = response.json()
by_name = {s["name"]: s for s in data["skills"]}
agent = by_name["react_agent"]
assert agent["category"] == "agent_template"
assert agent["execution_mode"] == "react"
assert agent["agent_type"] == "test_type"
assert agent["task_mode"] == "llm_generate"
biz = by_name["geo_optimizer"]
assert biz["category"] == "business_skill"
def test_category_no_orphans(self, client, skill_registry):
"""Every skill must fall into exactly one category — no orphans."""
_register_skill(skill_registry, "react_agent")
_register_skill(skill_registry, "geo_optimizer")
_register_skill(skill_registry, "trend_agent")
response = client.get("/api/v1/skill-management/skills")
data = response.json()
valid = {"agent_template", "business_skill"}
assert all(s["category"] in valid for s in data["skills"])
def test_trend_agent_classified_as_business_skill(self, client, skill_registry):
"""trend_agent has _agent suffix but is a business-domain skill, not an engine."""
_register_skill(skill_registry, "trend_agent")
response = client.get("/api/v1/skill-management/skills")
data = response.json()
skill = next(s for s in data["skills"] if s["name"] == "trend_agent")
assert skill["category"] == "business_skill"
# ---------------------------------------------------------------------------
# GET /skill-management/skills/{skill_name}