feat(skill): SkillMarket lifecycle UI + curator control panel

This commit is contained in:
matevip 2026-05-19 09:56:08 +08:00
parent 4cd21056a0
commit 2c1e673fba
10 changed files with 759 additions and 12 deletions

View File

@ -185,6 +185,8 @@ export const skillApi = {
enabled?: boolean
/** 'PASSED' / 'FAILED' — filters by security_scan_status. */
scanStatus?: string
/** 'active' / 'stale' / 'archived' — filters by lifecycle_state. */
lifecycleState?: string
} = {}) => http.get('/skills', { params }),
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
counts: () => http.get('/skills/counts'),
@ -217,6 +219,35 @@ export const skillApi = {
http.post(`/skills/${id}/secrets`, { key, value }),
deleteSecret: (id: string | number, key: string) =>
http.delete(`/skills/${id}/secrets/${encodeURIComponent(key)}`),
// ---- Lifecycle curator ----
/** Pin / unpin a skill — pinned skills are never auto-archived. */
pin: (id: string | number, pinned: boolean) =>
http.post(`/skills/${id}/pin`, { pinned }),
/**
* Manually archive a skill. When the skill is bound to an enabled agent
* and {@code force} is false, the backend replies HTTP 409 with
* {@code code: 'BOUND_SKILL_CONFIRM_REQUIRED'} and a {@code boundAgents}
* list; retry with {@code force: true} to confirm.
*/
archive: (id: string | number, opts: { force?: boolean; reason?: string } = {}) =>
http.post(`/skills/${id}/archive`, { reason: opts.reason ?? null },
{ params: { force: opts.force ?? false } }),
/** Restore an archived skill back to active. */
restore: (id: string | number) => http.post(`/skills/${id}/restore`),
/** Curator control-panel status (config / control / counts / lastReport). */
curatorStatus: () => http.get('/skills/curator/status'),
/** Run a curator dry-run preview immediately. */
curatorDryRun: () => http.post('/skills/curator/dry-run'),
/** Activate (apply transitions) or deactivate (preview-only) the curator. */
curatorActivate: (activate: boolean) =>
http.post('/skills/curator/activate', null, { params: { activate } }),
curatorPause: () => http.post('/skills/curator/pause'),
curatorResume: () => http.post('/skills/curator/resume'),
/** List recent curator run report ids. */
curatorReports: () => http.get('/skills/curator/reports'),
/** Read one curator run report (parsed run.json). */
curatorReport: (runId: string) => http.get(`/skills/curator/reports/${runId}`),
}
/** Shape returned by GET /skills/{id}/secrets. */

View File

@ -0,0 +1,28 @@
<template>
<svg
:width="size" :height="size" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" v-html="path"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { skillLineIcons, type SkillLineIconName } from './lineIcons'
const props = withDefaults(defineProps<{
/** Icon key from the skill icon registry. */
name: string
/** Rendered width/height in pixels. */
size?: number | string
}>(), {
size: 16,
})
const path = computed(() => skillLineIcons[props.name as SkillLineIconName] ?? '')
</script>
<style scoped>
svg { display: block; flex-shrink: 0; }
</style>

View File

@ -0,0 +1,20 @@
/**
* Feather-style line-art icons for the skill catalog and lifecycle curator.
* Each glyph is drawn on a 24x24 canvas and stroked with `currentColor`.
* Consumed by SkillLineIcon.vue so the skill surfaces share one icon source
* instead of inlining raw SVG or emoji mirrors the memory module's registry.
*/
export const skillLineIcons = {
// Catalog source tabs
all: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
builtin: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
mcp: '<path d="M9 2v6"/><path d="M15 2v6"/><path d="M6 8h12v3a6 6 0 0 1-12 0z"/><path d="M12 17v5"/>',
acp: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
dynamic: '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
// Lifecycle
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
archive: '<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>',
pin: '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
} as const
export type SkillLineIconName = keyof typeof skillLineIcons

View File

@ -573,6 +573,7 @@ export default {
featureFlags: 'Feature Flags',
about: 'About',
advanced: 'Advanced',
skillCurator: 'Skill Curator',
},
models: {
sidecar: {
@ -2980,6 +2981,34 @@ export default {
mcp: 'MCP',
acp: 'ACP',
dynamic: 'Dynamic',
stale: 'Stale',
archived: 'Archived',
},
lifecycle: {
section: 'Lifecycle',
state: 'State',
lastUsed: 'Last used',
neverUsed: 'Never used',
today: 'Today',
daysAgo: '{n}d ago',
states: {
active: 'Active',
stale: 'Stale',
archived: 'Archived',
},
pin: 'Pin this skill',
pinHint: 'Pinned skills are never auto-archived',
archive: 'Archive',
restore: 'Restore',
pinFailed: 'Failed to update pin',
archiveTitle: 'Archive skill',
archiveConfirm: 'Archive "{name}"? The skill will be disabled and can be restored anytime.',
archiveSuccess: 'Skill archived',
archiveFailed: 'Failed to archive',
restoreSuccess: 'Skill restored',
restoreFailed: 'Failed to restore',
boundConfirmTitle: 'Skill is bound to agents',
boundConfirmBody: '"{name}" is explicitly bound to {count} enabled agent(s) ({agents}). Archiving removes that capability from them. Continue?',
},
search: {
placeholder: 'Search by name, description, or tags...',
@ -3209,6 +3238,52 @@ export default {
tooLarge: 'File too large (max 50MB)',
},
},
skillCurator: {
title: 'Skill Curator',
desc: 'Automatically archives long-idle skills on a time window to keep the skill catalog tidy.',
stateEnabled: 'Deployed',
stateActivated: 'Activated',
statePreview: 'Preview only',
statePaused: 'Paused',
config: 'Configuration',
scope: 'Scope',
staleAfter: 'Stale after (days)',
archiveAfter: 'Archive after (days)',
cron: 'Schedule',
counts: 'Current counts',
active: 'Active',
stale: 'Stale',
archived: 'Archived',
pinned: 'Pinned',
blockedByBindings: 'Binding-protected',
control: 'Runtime control',
lastObservedAt: 'First observed',
lastDryRunAt: 'Last preview',
lastRunAt: 'Last run',
nextScheduledRun: 'Next run',
activate: 'Activate curator',
deactivate: 'Back to preview',
pause: 'Pause',
resume: 'Resume',
runDryRun: 'Preview now',
activatedHint: 'The curator is activated and archives idle skills on schedule.',
previewHint: 'The curator is in preview-only mode and never archives. Review a preview report, then activate.',
pausedHint: 'The curator is paused; the scheduled sweep will not run.',
reports: 'Run reports',
noReports: 'No run reports yet',
reportPlanned: 'Planned',
reportApplied: 'Applied',
reportScanned: 'Scanned',
reportDryRun: 'Preview',
reportTransitions: 'Transitions',
activateSuccess: 'Skill curator activated',
deactivateSuccess: 'Back to preview-only mode',
pauseSuccess: 'Skill curator paused',
resumeSuccess: 'Skill curator resumed',
dryRunSuccess: 'Preview report generated',
actionFailed: 'Action failed',
loadFailed: 'Failed to load skill curator status',
},
onboarding: {
title: 'Welcome to MateClaw',
subtitle: 'Set up your first AI model',

View File

@ -453,6 +453,7 @@ export default {
featureFlags: '功能开关',
about: '关于',
advanced: '高级',
skillCurator: '技能管家',
},
models: {
sidecar: {
@ -3072,6 +3073,34 @@ export default {
mcp: 'MCP',
acp: 'ACP',
dynamic: '动态',
stale: '待归档',
archived: '已归档',
},
lifecycle: {
section: '生命周期',
state: '状态',
lastUsed: '上次使用',
neverUsed: '从未使用',
today: '今天',
daysAgo: '{n} 天前',
states: {
active: '活跃',
stale: '待归档',
archived: '已归档',
},
pin: '钉住此技能',
pinHint: '钉住后永不会被自动归档',
archive: '归档',
restore: '恢复',
pinFailed: '钉住操作失败',
archiveTitle: '归档技能',
archiveConfirm: '确定归档「{name}」吗?归档后该技能将停用,可随时恢复。',
archiveSuccess: '技能已归档',
archiveFailed: '归档失败',
restoreSuccess: '技能已恢复',
restoreFailed: '恢复失败',
boundConfirmTitle: '该技能已被 Agent 绑定',
boundConfirmBody: '「{name}」被 {count} 个启用中的 Agent 显式绑定({agents})。归档后这些 Agent 将失去该能力,确定继续?',
},
search: {
placeholder: '搜索技能名称、描述或标签...',
@ -3301,6 +3330,52 @@ export default {
tooLarge: '文件过大(最大 50MB',
},
},
skillCurator: {
title: '技能管家',
desc: '按时间窗口自动归档长期闲置的技能,保持技能目录整洁。',
stateEnabled: '已部署',
stateActivated: '已激活',
statePreview: '仅预览',
statePaused: '已暂停',
config: '配置',
scope: '清理范围',
staleAfter: 'stale 阈值(天)',
archiveAfter: '归档阈值(天)',
cron: '调度表达式',
counts: '当前统计',
active: '活跃',
stale: '待归档',
archived: '已归档',
pinned: '已钉住',
blockedByBindings: '被绑定保护',
control: '运行控制',
lastObservedAt: '首次观测',
lastDryRunAt: '上次预览',
lastRunAt: '上次运行',
nextScheduledRun: '下次调度',
activate: '激活管家',
deactivate: '退回仅预览',
pause: '暂停',
resume: '恢复',
runDryRun: '立即预览',
activatedHint: '管家已激活,每日按调度真正归档闲置技能。',
previewHint: '管家处于仅预览模式,不会真正归档。查看预览报告满意后再激活。',
pausedHint: '管家已暂停,定时扫描不会运行。',
reports: '运行报告',
noReports: '暂无运行报告',
reportPlanned: '计划',
reportApplied: '执行',
reportScanned: '扫描',
reportDryRun: '预览',
reportTransitions: '状态变更',
activateSuccess: '技能管家已激活',
deactivateSuccess: '已退回仅预览模式',
pauseSuccess: '技能管家已暂停',
resumeSuccess: '技能管家已恢复',
dryRunSuccess: '预览报告已生成',
actionFailed: '操作失败',
loadFailed: '加载技能管家状态失败',
},
onboarding: {
title: '欢迎使用 MateClaw',
subtitle: '配置你的第一个 AI 模型',

View File

@ -182,6 +182,12 @@ const router = createRouter({
component: () => import('@/views/CronJobs.vue'),
meta: { title: 'Settings - Cron Jobs', requiredCapability: 'manage:agents' },
},
{
path: 'skill-curator',
name: 'SettingsSkillCurator',
component: () => import('@/views/Settings/SkillCurator/index.vue'),
meta: { title: 'Settings - Skill Curator', requiredCapability: 'manage:settings' },
},
{
path: 'workflows',
name: 'SettingsWorkflows',

View File

@ -285,6 +285,14 @@ export interface Skill {
securityScanResult?: string
/** RFC-042 §2.3 — wall-clock time of the last scan */
securityScanTime?: string
/** Lifecycle curator state: 'active' / 'stale' / 'archived' */
lifecycleState?: string
/** User-pinned skill — exempt from automatic archival */
pinned?: boolean
/** Last activity timestamp — drives the curator's idle window */
lastActivityAt?: string
/** When the curator moved this skill to archived state */
archivedAt?: string
}
/** 运行时解析状态(来自 /runtime/status */

View File

@ -167,6 +167,12 @@ const sections = computed(() => [
label: t('nav.triggers', 'Triggers'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
},
{
id: 'skill-curator',
path: '/settings/skill-curator',
label: t('settings.sections.skillCurator', 'Skill Curator'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5" rx="1"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
},
{
id: 'datasources',
path: '/settings/datasources',

View File

@ -0,0 +1,308 @@
<template>
<div class="settings-section">
<div class="section-header">
<h2 class="section-title">{{ t('skillCurator.title') }}</h2>
<p class="section-desc">{{ t('skillCurator.desc') }}</p>
</div>
<div v-if="loading" class="settings-card state-card">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="error" class="settings-card state-card state-card--error">
<el-icon><WarningFilled /></el-icon>
<span>{{ error }}</span>
<button class="btn-secondary" @click="load">{{ t('common.retry', 'Retry') }}</button>
</div>
<template v-else-if="status">
<!-- Runtime state + control -->
<div class="settings-card">
<div class="state-pills">
<span class="curator-pill" :class="status.config.enabled ? 'pill-on' : 'pill-off'">
{{ t('skillCurator.stateEnabled') }}
</span>
<span class="curator-pill" :class="status.control.activated ? 'pill-on' : 'pill-muted'">
{{ status.control.activated ? t('skillCurator.stateActivated') : t('skillCurator.statePreview') }}
</span>
<span v-if="status.control.paused" class="curator-pill pill-warn">
{{ t('skillCurator.statePaused') }}
</span>
</div>
<p class="curator-hint">
{{ status.control.paused ? t('skillCurator.pausedHint')
: status.control.activated ? t('skillCurator.activatedHint')
: t('skillCurator.previewHint') }}
</p>
<div class="curator-actions">
<button class="btn-secondary" :disabled="busy" @click="runDryRun">
{{ t('skillCurator.runDryRun') }}
</button>
<button
v-if="!status.control.activated"
class="btn-primary" :disabled="busy"
@click="setActivated(true)"
>{{ t('skillCurator.activate') }}</button>
<button
v-else
class="btn-secondary" :disabled="busy"
@click="setActivated(false)"
>{{ t('skillCurator.deactivate') }}</button>
<button
v-if="!status.control.paused"
class="btn-secondary" :disabled="busy"
@click="setPaused(true)"
>{{ t('skillCurator.pause') }}</button>
<button
v-else
class="btn-secondary" :disabled="busy"
@click="setPaused(false)"
>{{ t('skillCurator.resume') }}</button>
</div>
</div>
<!-- Counts -->
<div class="settings-card">
<h3 class="card-title">{{ t('skillCurator.counts') }}</h3>
<div class="count-grid">
<div class="count-cell"><span class="count-num">{{ status.counts.active }}</span><span class="count-label">{{ t('skillCurator.active') }}</span></div>
<div class="count-cell"><span class="count-num count-stale">{{ status.counts.stale }}</span><span class="count-label">{{ t('skillCurator.stale') }}</span></div>
<div class="count-cell"><span class="count-num">{{ status.counts.archived }}</span><span class="count-label">{{ t('skillCurator.archived') }}</span></div>
<div class="count-cell"><span class="count-num">{{ status.counts.pinned }}</span><span class="count-label">{{ t('skillCurator.pinned') }}</span></div>
<div class="count-cell"><span class="count-num">{{ status.counts.blockedByBindings }}</span><span class="count-label">{{ t('skillCurator.blockedByBindings') }}</span></div>
</div>
</div>
<!-- Config + control timestamps -->
<div class="settings-card">
<h3 class="card-title">{{ t('skillCurator.config') }}</h3>
<dl class="kv-list">
<div class="kv-row"><dt>{{ t('skillCurator.scope') }}</dt><dd><code>{{ status.config.scope }}</code></dd></div>
<div class="kv-row"><dt>{{ t('skillCurator.staleAfter') }}</dt><dd>{{ status.config.staleAfterDays }}</dd></div>
<div class="kv-row"><dt>{{ t('skillCurator.archiveAfter') }}</dt><dd>{{ status.config.archiveAfterDays }}</dd></div>
<div class="kv-row"><dt>{{ t('skillCurator.cron') }}</dt><dd><code>{{ status.config.cron }}</code></dd></div>
<div class="kv-row"><dt>{{ t('skillCurator.lastObservedAt') }}</dt><dd>{{ fmt(status.control.lastObservedAt) }}</dd></div>
<div class="kv-row"><dt>{{ t('skillCurator.lastDryRunAt') }}</dt><dd>{{ fmt(status.control.lastDryRunAt) }}</dd></div>
<div class="kv-row"><dt>{{ t('skillCurator.lastRunAt') }}</dt><dd>{{ fmt(status.control.lastRunAt) }}</dd></div>
<div class="kv-row"><dt>{{ t('skillCurator.nextScheduledRun') }}</dt><dd>{{ fmt(status.control.nextScheduledRun) }}</dd></div>
</dl>
</div>
<!-- Run reports -->
<div class="settings-card">
<h3 class="card-title">{{ t('skillCurator.reports') }}</h3>
<p v-if="reports.length === 0" class="empty-note">{{ t('skillCurator.noReports') }}</p>
<div v-else class="report-list">
<button
v-for="rid in reports"
:key="rid"
class="report-item"
:class="{ active: selectedReportId === rid }"
@click="openReport(rid)"
>{{ rid }}</button>
</div>
<div v-if="selectedReport" class="report-detail">
<div class="report-detail-row">
<span class="curator-pill" :class="selectedReport.dryRun ? 'pill-muted' : 'pill-on'">
{{ selectedReport.dryRun ? t('skillCurator.reportDryRun') : t('skillCurator.reportApplied') }}
</span>
<span class="report-meta">{{ t('skillCurator.reportScanned') }}: {{ selectedReport.scanned }}</span>
</div>
<div class="report-counts">
<span>{{ t('skillCurator.reportPlanned') }}: stale {{ selectedReport.planned?.stale ?? 0 }} · archived {{ selectedReport.planned?.archived ?? 0 }} · reactivated {{ selectedReport.planned?.reactivated ?? 0 }}</span>
<span>{{ t('skillCurator.reportApplied') }}: stale {{ selectedReport.applied?.stale ?? 0 }} · archived {{ selectedReport.applied?.archived ?? 0 }} · reactivated {{ selectedReport.applied?.reactivated ?? 0 }}</span>
</div>
<div v-if="(selectedReport.transitions || []).length > 0" class="report-transitions">
<div class="report-transitions-head">{{ t('skillCurator.reportTransitions') }}</div>
<div v-for="(tr, i) in selectedReport.transitions" :key="i" class="report-transition">
<code>{{ tr.name }}</code>
<span>{{ tr.from }} {{ tr.to }}</span>
<span class="report-meta">{{ tr.daysIdle }}d</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElIcon } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { mcToast } from '@/composables/useMcToast'
import { skillApi } from '@/api/index'
const { t } = useI18n()
interface CuratorStatus {
config: { enabled: boolean; scope: string; staleAfterDays: number; archiveAfterDays: number; cron: string }
control: {
activated: boolean; paused: boolean
lastObservedAt: string | null; lastDryRunAt: string | null
lastRunAt: string | null; nextScheduledRun: string | null
}
counts: Record<string, number | string>
lastReport: { id: string; url: string } | null
}
interface CuratorReport {
dryRun: boolean
scanned: number
planned?: { stale: number; archived: number; reactivated: number }
applied?: { stale: number; archived: number; reactivated: number }
transitions?: Array<{ name: string; from: string; to: string; daysIdle: number }>
}
const loading = ref(true)
const error = ref('')
const busy = ref(false)
const status = ref<CuratorStatus | null>(null)
const reports = ref<string[]>([])
const selectedReportId = ref<string>('')
const selectedReport = ref<CuratorReport | null>(null)
function fmt(ts: string | null | undefined): string {
if (!ts) return '—'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ts
return d.toLocaleString()
}
async function load() {
loading.value = true
error.value = ''
try {
const res: any = await skillApi.curatorStatus()
status.value = res.data as CuratorStatus
await loadReports()
} catch (e: any) {
error.value = e?.message || t('skillCurator.loadFailed')
} finally {
loading.value = false
}
}
async function loadReports() {
try {
const res: any = await skillApi.curatorReports()
reports.value = Array.isArray(res.data) ? res.data : []
} catch {
reports.value = []
}
}
async function openReport(runId: string) {
selectedReportId.value = runId
try {
const res: any = await skillApi.curatorReport(runId)
selectedReport.value = res.data as CuratorReport
} catch (e: any) {
selectedReport.value = null
mcToast.error(e?.message || t('skillCurator.actionFailed'))
}
}
async function runDryRun() {
busy.value = true
try {
await skillApi.curatorDryRun()
mcToast.success(t('skillCurator.dryRunSuccess'))
await load()
} catch (e: any) {
mcToast.error(e?.message || t('skillCurator.actionFailed'))
} finally {
busy.value = false
}
}
async function setActivated(activate: boolean) {
busy.value = true
try {
const res: any = await skillApi.curatorActivate(activate)
status.value = res.data as CuratorStatus
mcToast.success(t(activate ? 'skillCurator.activateSuccess' : 'skillCurator.deactivateSuccess'))
} catch (e: any) {
mcToast.error(e?.message || t('skillCurator.actionFailed'))
} finally {
busy.value = false
}
}
async function setPaused(paused: boolean) {
busy.value = true
try {
const res: any = paused ? await skillApi.curatorPause() : await skillApi.curatorResume()
status.value = res.data as CuratorStatus
mcToast.success(t(paused ? 'skillCurator.pauseSuccess' : 'skillCurator.resumeSuccess'))
} catch (e: any) {
mcToast.error(e?.message || t('skillCurator.actionFailed'))
} finally {
busy.value = false
}
}
onMounted(load)
</script>
<style scoped>
.settings-section { width: 100%; }
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04); width: 100%; margin-bottom: 16px; }
.card-title { margin: 0 0 14px; font-size: 15px; font-weight: 700; color: var(--mc-text-primary); }
.state-card { display: flex; align-items: center; gap: 10px; color: var(--mc-text-secondary); }
.state-card--error { color: var(--el-color-danger); }
.state-pills { display: flex; gap: 8px; flex-wrap: wrap; }
.curator-pill { padding: 3px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.pill-on { color: #1e8e3e; background: rgba(46, 160, 67, 0.14); }
.pill-off { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
.pill-muted { color: var(--mc-text-secondary); background: var(--mc-bg-sunken); }
.pill-warn { color: #b9770e; background: rgba(243, 156, 18, 0.16); }
.curator-hint { margin: 12px 0 14px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.5; }
.curator-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn-secondary { border: 1px solid var(--mc-border); border-radius: 10px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--mc-bg-elevated); color: var(--mc-text-primary); transition: all 0.15s; }
.btn-secondary:hover:not(:disabled) { background: var(--mc-bg-sunken); }
.btn-primary { border: 1px solid var(--mc-primary); border-radius: 10px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--mc-primary); color: #fff; transition: all 0.15s; }
.btn-primary:hover:not(:disabled) { background: var(--mc-primary-hover); }
.btn-secondary:disabled, .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.count-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 12px; }
.count-cell { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 8px; background: var(--mc-bg-sunken); border-radius: 12px; }
.count-num { font-size: 24px; font-weight: 700; color: var(--mc-text-primary); }
.count-stale { color: #b9770e; }
.count-label { font-size: 12px; color: var(--mc-text-secondary); }
.kv-list { margin: 0; display: flex; flex-direction: column; }
.kv-row { display: flex; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px solid var(--mc-border-light); }
.kv-row:last-child { border-bottom: none; }
.kv-row dt { font-size: 13px; color: var(--mc-text-secondary); }
.kv-row dd { margin: 0; font-size: 13px; color: var(--mc-text-primary); font-weight: 600; }
.kv-row code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; }
.empty-note { margin: 0; font-size: 13px; color: var(--mc-text-tertiary); }
.report-list { display: flex; gap: 8px; flex-wrap: wrap; }
.report-item { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
.report-item:hover { border-color: var(--mc-text-tertiary); }
.report-item.active { border-color: var(--mc-primary); color: var(--mc-primary); }
.report-detail { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--mc-border-light); display: flex; flex-direction: column; gap: 10px; }
.report-detail-row { display: flex; align-items: center; gap: 12px; }
.report-meta { font-size: 12px; color: var(--mc-text-tertiary); }
.report-counts { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mc-text-secondary); }
.report-transitions-head { font-size: 12px; font-weight: 700; color: var(--mc-text-secondary); margin-bottom: 6px; }
.report-transition { display: flex; gap: 12px; align-items: center; font-size: 13px; color: var(--mc-text-primary); padding: 4px 0; }
.report-transition code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; }
@media (max-width: 900px) {
.kv-row { flex-direction: column; gap: 2px; }
}
</style>

View File

@ -41,10 +41,11 @@
<!-- 分类 Tab -->
<div class="category-tabs mc-surface-card">
<button v-for="tab in categoryTabs" :key="tab.value" class="cat-tab"
:class="{ active: query.skillType === tab.value }" @click="onTabChange(tab.value)">
<span class="cat-icon">{{ tab.icon }}</span>
:class="{ active: isTabActive(tab), 'cat-tab--lifecycle': tab.kind === 'lifecycle' }"
@click="onTabChange(tab)">
<SkillLineIcon :name="tab.icon" :size="15" class="cat-icon" />
{{ tab.label }}
<span class="cat-count">{{ getCategoryCount(tab.value) }}</span>
<span class="cat-count">{{ getCategoryCount(tab) }}</span>
</button>
</div>
@ -132,6 +133,12 @@
</span>
<span class="source-label" :class="getSourceClass(skill)">{{ getSourceLabel(skill) }}</span>
<span v-if="skill.version" class="skill-version">v{{ skill.version }}</span>
<span
v-if="!isSkillRowVirtual(skill) && skill.lastActivityAt"
class="lifecycle-badge"
:class="{ 'lifecycle-badge--stale': skill.lifecycleState === 'stale',
'lifecycle-badge--archived': skill.lifecycleState === 'archived' }"
><SkillLineIcon name="clock" :size="11" />{{ lastUsedLabel(skill) }}</span>
</div>
<div class="skill-footer" @click.stop>
@ -287,6 +294,42 @@
<!-- Overview tab manifest-projected chips (read-only) +
DB-only display overrides (editable) + collapsed manifest. -->
<div v-if="detailTab === 'overview'" class="detail-section">
<!-- Lifecycle: state + last-used + pin / archive / restore. -->
<div v-if="!isVirtualSkill" class="detail-block">
<div class="detail-block-head">
<h4 class="detail-block-title">{{ t('skills.lifecycle.section') }}</h4>
</div>
<div class="meta-chips">
<span class="meta-chip">
<span class="meta-chip-label">{{ t('skills.lifecycle.state') }}</span>
<span class="lc-pill" :class="'lc-' + (detailSkill.lifecycleState || 'active')">
{{ t('skills.lifecycle.states.' + (detailSkill.lifecycleState || 'active')) }}
</span>
</span>
<span class="meta-chip">
<span class="meta-chip-label">{{ t('skills.lifecycle.lastUsed') }}</span>
{{ lastUsedLabel(detailSkill) }}
</span>
</div>
<div class="lifecycle-actions">
<label class="lifecycle-pin" :title="t('skills.lifecycle.pinHint')">
<input type="checkbox" :checked="!!detailSkill.pinned" @change="togglePin(detailSkill)" />
<SkillLineIcon name="pin" :size="14" />
<span>{{ t('skills.lifecycle.pin') }}</span>
</label>
<button
v-if="detailSkill.lifecycleState === 'archived'"
class="lc-btn lc-btn--restore"
@click="restoreSkill(detailSkill)"
>{{ t('skills.lifecycle.restore') }}</button>
<button
v-else-if="detailSkill.skillType !== 'builtin'"
class="lc-btn lc-btn--archive"
@click="archiveSkill(detailSkill)"
>{{ t('skills.lifecycle.archive') }}</button>
</div>
</div>
<!-- Manifest-projected fields (chips, read-only).
These columns are overwritten by SkillPackageResolver from
manifest_json on every resolve, so editing them on the row
@ -668,6 +711,7 @@ import McPagination from '@/components/common/McPagination.vue'
import MateDrawer from '@/components/common/MateDrawer.vue'
import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
import SkillLineIcon from '@/components/skill/SkillLineIcon.vue'
import { mcConfirm } from '@/components/common/useConfirm'
import { useSkillName } from '@/composables/useSkillName'
@ -689,6 +733,8 @@ const query = reactive({
skillType: 'all' as string,
statusFilter: '' as string,
sort: 'recommended' as string,
/** '' = active+stale catalog; 'stale' / 'archived' = lifecycle tabs. */
lifecycleState: '' as string,
})
/** Per-skill UI state for the RFC-042 §2.3 findings panel. */
@ -892,17 +938,30 @@ watch(detailTab, (tab) => {
})
const categoryTabs = computed(() => [
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
{ label: t('skills.tabs.mcp'), value: 'mcp', icon: '🔌' },
{ label: t('skills.tabs.all'), value: 'all', icon: 'all', kind: 'source' },
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: 'builtin', kind: 'source' },
{ label: t('skills.tabs.mcp'), value: 'mcp', icon: 'mcp', kind: 'source' },
// ACP (Agent Communication Protocol) auto-bridged from
// Settings ACP Endpoints; one card per enabled endpoint.
{ label: t('skills.tabs.acp'), value: 'acp', icon: '🤝' },
{ label: t('skills.tabs.dynamic'), value: 'dynamic', icon: '📦' },
{ label: t('skills.tabs.acp'), value: 'acp', icon: 'acp', kind: 'source' },
{ label: t('skills.tabs.dynamic'), value: 'dynamic', icon: 'dynamic', kind: 'source' },
// Lifecycle tabs orthogonal to skillType; they filter by lifecycle_state.
{ label: t('skills.tabs.stale'), value: 'stale', icon: 'clock', kind: 'lifecycle' },
{ label: t('skills.tabs.archived'), value: 'archived', icon: 'archive', kind: 'lifecycle' },
])
function getCategoryCount(category: string) {
return counts.value[category] ?? 0
/** Stale / archived counts, sourced from the curator status endpoint. */
const lifecycleCounts = ref<Record<string, number>>({})
function getCategoryCount(tab: { value: string; kind: string }) {
if (tab.kind === 'lifecycle') return lifecycleCounts.value[tab.value] ?? 0
return counts.value[tab.value] ?? 0
}
function isTabActive(tab: { value: string; kind: string }) {
return tab.kind === 'lifecycle'
? query.lifecycleState === tab.value
: !query.lifecycleState && query.skillType === tab.value
}
function parseTags(tags: string): string[] {
@ -918,9 +977,24 @@ async function loadAll() {
// transient "checking" state until runtimeStatusMap populates.
loadCounts()
loadRuntimeStatus()
loadLifecycleCounts()
await loadSkills()
}
/** Stale / archived tab counts — best-effort, from the curator status endpoint. */
async function loadLifecycleCounts() {
try {
const res: any = await skillApi.curatorStatus()
const c = res.data?.counts || {}
lifecycleCounts.value = {
stale: Number(c.stale) || 0,
archived: Number(c.archived) || 0,
}
} catch {
lifecycleCounts.value = {}
}
}
/** Coalesce keyword edits into one server call per 300ms so typing doesn't thrash. */
let searchDebounce: ReturnType<typeof setTimeout> | null = null
watch(() => query.keyword, () => {
@ -931,8 +1005,14 @@ watch(() => query.keyword, () => {
}, 300)
})
function onTabChange(tab: string) {
query.skillType = tab
function onTabChange(tab: { value: string; kind: string }) {
if (tab.kind === 'lifecycle') {
query.lifecycleState = tab.value
query.skillType = 'all'
} else {
query.lifecycleState = ''
query.skillType = tab.value
}
query.page = 1
loadSkills()
}
@ -959,6 +1039,7 @@ async function loadSkills(allowPageClamp = true) {
if (query.statusFilter === 'enabled') params.enabled = true
else if (query.statusFilter === 'disabled') params.enabled = false
else if (query.statusFilter === 'scan_failed') params.scanStatus = 'FAILED'
if (query.lifecycleState) params.lifecycleState = query.lifecycleState
const res: any = await skillApi.page(params)
const data = res.data || {}
@ -1237,6 +1318,80 @@ async function toggleSkill(skill: Skill) {
}
}
// ==================== Lifecycle: pin / archive / restore ====================
/** Human-readable "last used" label from {@code lastActivityAt}. */
function lastUsedLabel(skill: Skill): string {
const ts = skill.lastActivityAt
if (!ts) return t('skills.lifecycle.neverUsed')
const then = new Date(ts).getTime()
if (Number.isNaN(then)) return t('skills.lifecycle.neverUsed')
const days = Math.floor((Date.now() - then) / 86400000)
if (days <= 0) return t('skills.lifecycle.today')
return t('skills.lifecycle.daysAgo', { n: days })
}
async function togglePin(skill: Skill) {
try {
const res: any = await skillApi.pin(skill.id, !skill.pinned)
if (detailSkill.value && detailSkill.value.id === skill.id) {
detailSkill.value = { ...detailSkill.value, ...(res.data || {}) }
}
await loadSkills()
loadLifecycleCounts()
} catch (e: any) {
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.lifecycle.pinFailed'))
}
}
async function archiveSkill(skill: Skill) {
const ok = await mcConfirm({
title: t('skills.lifecycle.archiveTitle'),
message: t('skills.lifecycle.archiveConfirm', { name: resolveSkillName(skill) }),
tone: 'danger',
})
if (!ok) return
await doArchive(skill, false)
}
/** Archive call with the bound-skill 409 confirm handshake. */
async function doArchive(skill: Skill, force: boolean) {
try {
await skillApi.archive(skill.id, { force })
mcToast.success(t('skills.lifecycle.archiveSuccess'))
closeDetailDrawer()
await loadAll()
} catch (e: any) {
const resp = e?.response
if (!force && resp?.status === 409 && resp?.data?.code === 'BOUND_SKILL_CONFIRM_REQUIRED') {
const bound = (resp.data.boundAgents || []) as Array<{ name?: string }>
const confirmForce = await mcConfirm({
title: t('skills.lifecycle.boundConfirmTitle'),
message: t('skills.lifecycle.boundConfirmBody', {
name: resolveSkillName(skill),
count: bound.length,
agents: bound.map(a => a.name).filter(Boolean).join('、'),
}),
tone: 'danger',
})
if (confirmForce) await doArchive(skill, true)
return
}
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.lifecycle.archiveFailed'))
}
}
async function restoreSkill(skill: Skill) {
try {
await skillApi.restore(skill.id)
mcToast.success(t('skills.lifecycle.restoreSuccess'))
closeDetailDrawer()
await loadAll()
} catch (e: any) {
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.lifecycle.restoreFailed'))
}
}
async function handleRefreshRuntime() {
refreshing.value = true
try {
@ -2357,4 +2512,39 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
color: #fff;
}
/* ==================== Lifecycle / curator ==================== */
.cat-tab--lifecycle { margin-left: 4px; }
.lifecycle-badge {
display: inline-flex; align-items: center; gap: 3px;
font-size: 11px; color: var(--mc-text-tertiary);
padding: 1px 7px; border-radius: 999px; background: var(--mc-bg-sunken);
}
.lifecycle-badge--stale { color: #b9770e; background: rgba(243, 156, 18, 0.14); }
.lifecycle-badge--archived { color: var(--mc-text-tertiary); background: var(--mc-bg-muted); }
.lc-pill {
display: inline-block; padding: 1px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600;
}
.lc-active { color: #1e8e3e; background: rgba(46, 160, 67, 0.14); }
.lc-stale { color: #b9770e; background: rgba(243, 156, 18, 0.16); }
.lc-archived { color: var(--mc-text-secondary); background: var(--mc-bg-sunken); }
.lifecycle-actions {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px;
}
.lifecycle-pin {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; user-select: none;
}
.lifecycle-pin input { cursor: pointer; }
.lc-btn {
padding: 5px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; border: 1px solid var(--mc-border); background: var(--mc-bg-muted);
color: var(--mc-text-secondary); transition: all 0.15s;
}
.lc-btn:hover { border-color: var(--mc-text-tertiary); }
.lc-btn--archive:hover { color: #d14343; border-color: #d14343; }
.lc-btn--restore { color: var(--mc-primary); border-color: var(--mc-primary); }
.lc-btn--restore:hover { background: var(--mc-primary); color: #fff; }
</style>