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

View File

@ -1,19 +1,32 @@
<template> <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"> <a-card hoverable size="small">
<template #title> <template #title>
<div class="skill-card__title"> <div class="skill-card__title">
<AppstoreOutlined class="skill-card__icon" /> <component :is="titleIcon" class="skill-card__icon" />
<span>{{ skill.name }}</span> <span>{{ skill.name }}</span>
</div> </div>
</template> </template>
<template #extra> <template #extra>
<a-badge <div class="skill-card__badges">
:status="skill.status === 'active' ? 'success' : 'default'" <a-tag
:text="skill.status === 'active' ? '正常' : skill.status" :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> </template>
<p class="skill-card__desc">{{ skill.description || '暂无描述' }}</p> <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"> <div class="skill-card__tags">
<a-tag v-for="cap in skill.capabilities" :key="cap" size="small" color="blue"> <a-tag v-for="cap in skill.capabilities" :key="cap" size="small" color="blue">
{{ cap }} {{ cap }}
@ -36,16 +49,34 @@
</template> </template>
<script setup lang="ts"> <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' import type { ISkillInfo } from '@/api/skills'
defineProps<{ const props = defineProps<{
skill: ISkillInfo skill: ISkillInfo
}>() }>()
defineEmits<{ defineEmits<{
click: [] 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> </script>
<style scoped> <style scoped>
@ -68,6 +99,40 @@ defineEmits<{
color: var(--color-primary); 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 { .skill-card__desc {
font-size: var(--font-sm); font-size: var(--font-sm);
color: var(--text-secondary); color: var(--text-secondary);

View File

@ -17,6 +17,14 @@ export const useSkillsStore = defineStore('skills', () => {
// --- Getters --- // --- Getters ---
const skillNames = computed(() => skills.value.map((s) => s.name)) 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 --- // --- Actions ---
async function fetchSkills(capability?: string, page?: number): Promise<void> { async function fetchSkills(capability?: string, page?: number): Promise<void> {
@ -106,6 +114,8 @@ export const useSkillsStore = defineStore('skills', () => {
selectedCapability, selectedCapability,
// Getters // Getters
skillNames, skillNames,
agentTemplates,
businessSkills,
// Actions // Actions
fetchSkills, fetchSkills,
fetchCapabilities, fetchCapabilities,

View File

@ -25,15 +25,46 @@
</div> </div>
<a-spin :spinning="skillsStore.isLoading"> <a-spin :spinning="skillsStore.isLoading">
<div class="skills-view__grid"> <!-- 通用执行引擎模板 -->
<SkillCard <section v-if="skillsStore.agentTemplates.length > 0" class="skills-view__group">
v-for="skill in skillsStore.skills" <div class="skills-view__group-header">
:key="skill.name" <ThunderboltOutlined class="skills-view__group-icon" />
:skill="skill" <span class="skills-view__group-title">执行引擎</span>
@click="handleSkillClick(skill.name)" <a-tag color="purple">{{ skillsStore.agentTemplates.length }}</a-tag>
/> <span class="skills-view__group-hint">通用推理模式决定 Agent 如何思考和调用工具</span>
</div> </div>
<a-empty v-if="!skillsStore.isLoading && skillsStore.skills.length === 0" description="暂无已注册技能" /> <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> </a-spin>
<SkillDetail <SkillDetail
@ -159,4 +190,34 @@ async function handleInstall(): Promise<void> {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4); 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> </style>

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
@ -12,6 +12,20 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["skill-management"]) 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 # Request / Response models
@ -25,6 +39,10 @@ class SkillInfo(BaseModel):
capabilities: list[str] capabilities: list[str]
dependencies: list[str] dependencies: list[str]
status: str status: str
category: Literal["agent_template", "business_skill"]
agent_type: str = ""
execution_mode: str = ""
task_mode: str = ""
class SkillDetail(BaseModel): class SkillDetail(BaseModel):
@ -35,6 +53,10 @@ class SkillDetail(BaseModel):
dependencies: list[str] dependencies: list[str]
config: dict[str, Any] config: dict[str, Any]
health_status: str health_status: str
category: Literal["agent_template", "business_skill"]
agent_type: str = ""
execution_mode: str = ""
task_mode: str = ""
class CapabilityInfo(BaseModel): class CapabilityInfo(BaseModel):
@ -50,29 +72,30 @@ class CapabilityInfo(BaseModel):
def _skill_to_info(skill: Any) -> dict[str, Any]: def _skill_to_info(skill: Any) -> dict[str, Any]:
"""Convert a Skill object to a dict suitable for API responses.""" """Convert a Skill object to a dict suitable for API responses."""
capabilities = [] config = getattr(skill, "config", None)
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())
dependencies = [] capabilities: list[str] = []
if hasattr(skill, "config") and hasattr(skill.config, "dependencies"): caps = getattr(config, "capabilities", None)
deps = skill.config.dependencies if isinstance(caps, list):
if isinstance(deps, list): capabilities = [c.tag if hasattr(c, "tag") else str(c) for c in caps]
dependencies = deps elif isinstance(caps, dict):
elif isinstance(deps, dict): capabilities = list(caps.keys())
dependencies = list(deps.keys())
version = "" deps = getattr(config, "dependencies", None)
if hasattr(skill, "config") and hasattr(skill.config, "version"): if isinstance(deps, list):
version = skill.config.version or "" dependencies: list[str] = deps
elif isinstance(deps, dict):
dependencies = list(deps.keys())
else:
dependencies = []
description = "" version = getattr(config, "version", "") or ""
if hasattr(skill, "config") and hasattr(skill.config, "description"): description = getattr(config, "description", "") or ""
description = skill.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 { return {
"name": skill.name, "name": skill.name,
@ -81,6 +104,10 @@ def _skill_to_info(skill: Any) -> dict[str, Any]:
"capabilities": capabilities, "capabilities": capabilities,
"dependencies": dependencies, "dependencies": dependencies,
"status": "active", "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 "dependencies" in skill
assert "status" 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} # GET /skill-management/skills/{skill_name}