feat(skill): card surface — Source / Used-by / Lessons count

This commit is contained in:
matevip 2026-05-01 09:49:51 +08:00
parent 442ffa9c9e
commit e74f273ae2
5 changed files with 159 additions and 2 deletions

View File

@ -6,6 +6,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.model.SkillEntity;
@ -41,6 +46,8 @@ public class SkillController {
private final SkillSynthesisService synthesisService;
private final SkillDependencyChecker dependencyChecker;
private final SkillLessonsService lessonsService;
private final AgentSkillBindingMapper agentSkillBindingMapper;
private final AgentService agentService;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
@ -220,6 +227,38 @@ public class SkillController {
));
}
// ==================== Reverse lookup (RFC-090 §7) ====================
/**
* RFC-090 §7 list agents (employees) currently bound to this
* skill so the skill card can render "Used by N employees" with a
* click-through. Returns lightweight {id, name, icon} rows; the
* full agent object would balloon the card payload.
*/
@Operation(summary = "List agents bound to this skill (RFC-090)")
@GetMapping("/{id}/employees")
public R<List<Map<String, Object>>> employees(@PathVariable Long id) {
List<AgentSkillBinding> bindings = agentSkillBindingMapper.selectList(
new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getSkillId, id)
.eq(AgentSkillBinding::getEnabled, true));
List<Map<String, Object>> rows = new ArrayList<>(bindings.size());
for (AgentSkillBinding b : bindings) {
try {
AgentEntity agent = agentService.getAgent(b.getAgentId());
if (agent == null) continue;
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", agent.getId());
row.put("name", agent.getName());
row.put("icon", agent.getIcon());
rows.add(row);
} catch (Exception ignored) {
// agent may have been deleted; skip rather than fail the whole list
}
}
return R.ok(rows);
}
// ==================== Lessons API (RFC-090 §7 + §11.4) ====================
/**
@ -235,10 +274,24 @@ public class SkillController {
.orElse(null);
if (resolved == null) return R.fail("Skill not found: " + id);
String body = lessonsService.readLessons(resolved);
// entryCount = number of "## " headed sections (RFC-090 §11.4 "💡 N entries" badge).
int entryCount = 0;
if (body != null && !body.isBlank()) {
int from = 0;
while (true) {
int idx = body.indexOf("\n## ", from);
if (idx < 0) break;
entryCount++;
from = idx + 1;
}
// Also count a leading "## " (no preceding newline) at start of file.
if (body.startsWith("## ")) entryCount++;
}
return R.ok(Map.of(
"skillId", id,
"skillName", resolved.getName(),
"raw", body == null ? "" : body
"raw", body == null ? "" : body,
"entryCount", entryCount
));
}

View File

@ -170,10 +170,11 @@ export const skillApi = {
refreshRuntime: () => http.post('/skills/runtime/refresh'),
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`),
// RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md
// RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + reverse lookup
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`),
employees: (id: string | number) => http.get(`/skills/${id}/employees`),
}
// ==================== ACP Endpoints (RFC-090 Phase 7) ====================

View File

@ -1996,6 +1996,13 @@ export default {
mcp: 'MCP',
dynamic: 'Dynamic',
},
source: {
builtin: 'Built-in',
synthesized: 'AI Synthesized',
local: 'Local',
},
usedByTitle: 'Number of agents that have this skill bound',
lessonsCountTitle: 'Lessons recorded — click to view',
actions: {
configure: 'Configure',
view: 'View',

View File

@ -1998,6 +1998,13 @@ export default {
mcp: 'MCP',
dynamic: '动态',
},
source: {
builtin: '内置',
synthesized: 'AI 合成',
local: '本地',
},
usedByTitle: '已绑定此技能的 Agent 数量',
lessonsCountTitle: '已记录的 lessons — 点击查看',
actions: {
configure: '配置',
view: '查看',

View File

@ -126,6 +126,14 @@
{{ getFeaturesBadge(skill)?.label }}
</span>
<span v-if="getSourceBadge(skill)" class="source-badge">{{ getSourceBadge(skill) }}</span>
<!-- RFC-090 §4.2 Source label, used-by count, lessons count -->
<span class="source-label" :class="getSourceClass(skill)">{{ getSourceLabel(skill) }}</span>
<span v-if="getUsedByCount(skill) > 0" class="usedby-badge" :title="t('skills.usedByTitle')">
👥 {{ getUsedByCount(skill) }}
</span>
<span v-if="getLessonsCount(skill) > 0" class="lessons-badge" :title="t('skills.lessonsCountTitle')" @click.stop="openDetailDrawer(skill); detailTab = 'lessons'">
💡 {{ getLessonsCount(skill) }}
</span>
<span v-if="getRuntimePath(skill)" class="skill-source-path">{{ getRuntimePath(skill) }}</span>
</div>
<!-- Missing Dependencies Detail -->
@ -455,6 +463,10 @@ const detailTab = ref<'manifest' | 'tools' | 'features' | 'lessons'>('manifest')
const detailLessonsRaw = ref<string>('')
const detailLessonsLoading = ref(false)
/** RFC-090 §4.2 card surface — per-skill side data (lessons count, used-by). */
const lessonsCountBySkill = ref<Record<string, number>>({})
const usedByBySkill = ref<Record<string, number>>({})
const detailRuntime = computed(() =>
detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null,
)
@ -615,6 +627,9 @@ async function loadSkills() {
} else {
total.value = 0
}
// RFC-090 §4.2 fire-and-forget load card side data (lessons /
// used-by counts) for the currently visible page.
loadCardSideData()
} catch (e) {
skills.value = []
total.value = 0
@ -928,6 +943,70 @@ function getSourceBadge(skill: Skill): string {
return ''
}
/**
* RFC-090 §4.2 Source label for the skill card.
*
* Derivation precedence:
* 1. builtin=true "Built-in"
* 2. skillType=mcp "MCP"
* 3. skillType=acp "ACP" (Phase 7)
* 4. sourceConversationId set "AI Synthesized" (RFC-023)
* 5. configJson.source.type / upstream "ClawHub" / "GitHub"
* 6. fallback "Local"
*/
function getSourceLabel(skill: Skill): string {
if (skill.builtin) return t('skills.source.builtin')
if (skill.skillType === 'mcp') return 'MCP'
if (skill.skillType === 'acp') return 'ACP'
if (skill.sourceConversationId) return t('skills.source.synthesized')
try {
const config = skill.configJson ? JSON.parse(skill.configJson) : null
const sourceType = config?.source?.type || config?.upstream
if (sourceType === 'clawhub') return 'ClawHub'
if (sourceType === 'github') return 'GitHub'
} catch { /* ignore */ }
return t('skills.source.local')
}
function getSourceClass(skill: Skill): string {
if (skill.builtin) return 'src-builtin'
if (skill.skillType === 'mcp' || skill.skillType === 'acp') return 'src-protocol'
if (skill.sourceConversationId) return 'src-synth'
return 'src-local'
}
function getLessonsCount(skill: Skill): number {
return lessonsCountBySkill.value[String(skill.id)] || 0
}
function getUsedByCount(skill: Skill): number {
return usedByBySkill.value[String(skill.id)] || 0
}
/**
* Batch-load lessons count + used-by count for the currently rendered
* skill list. Called after each loadSkills(); failures are silenced
* per-skill so a single 404 doesn't blank the entire counts map.
*/
async function loadCardSideData() {
const items = skills.value
if (items.length === 0) return
const tasks = items.map(async (skill) => {
const id = String(skill.id)
const calls = [
skillApi.getLessons(skill.id).then((res: any) => {
lessonsCountBySkill.value[id] = res?.data?.entryCount || 0
}).catch(() => { lessonsCountBySkill.value[id] = 0 }),
skillApi.employees(skill.id).then((res: any) => {
usedByBySkill.value[id] = Array.isArray(res?.data) ? res.data.length : 0
}).catch(() => { usedByBySkill.value[id] = 0 }),
]
await Promise.all(calls)
})
// Don't block on the whole batch; render whatever lands first.
await Promise.allSettled(tasks)
}
function getSkillIcon(type: string) {
return { builtin: '🔧', mcp: '🔌', dynamic: '📦' }[type] ?? '🛠️'
}
@ -1203,6 +1282,16 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
/* RFC-090 §14.1 — features 矩阵徽标 */
.rt-features-ready { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
.rt-features-mixed { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
/* RFC-090 §4.2 — Source label + Used-by + Lessons count */
.source-label { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; }
.source-label.src-builtin { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
.source-label.src-protocol { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.source-label.src-synth { background: rgba(168, 85, 247, 0.12); color: #a855f7; }
.source-label.src-local { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.usedby-badge, .lessons-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.lessons-badge { cursor: pointer; }
.lessons-badge:hover { background: var(--mc-primary-bg); color: var(--mc-primary); }
:root.dark .rt-features-ready { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
:root.dark .rt-features-mixed { background: rgba(129, 140, 248, 0.18); color: #a5b4fc; }
.rt-disabled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }