mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): card surface — Source / Used-by / Lessons count
This commit is contained in:
parent
442ffa9c9e
commit
e74f273ae2
@ -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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -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) ====================
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1998,6 +1998,13 @@ export default {
|
||||
mcp: 'MCP',
|
||||
dynamic: '动态',
|
||||
},
|
||||
source: {
|
||||
builtin: '内置',
|
||||
synthesized: 'AI 合成',
|
||||
local: '本地',
|
||||
},
|
||||
usedByTitle: '已绑定此技能的 Agent 数量',
|
||||
lessonsCountTitle: '已记录的 lessons — 点击查看',
|
||||
actions: {
|
||||
configure: '配置',
|
||||
view: '查看',
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user