feat(skill): detail drawer + Agent Tool Advanced fold

This commit is contained in:
matevip 2026-05-01 09:48:46 +08:00
parent 688b37b652
commit 169a09506b
5 changed files with 316 additions and 19 deletions

View File

@ -772,6 +772,8 @@ export default {
binding: {
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
advancedToolsTitle: 'Advanced: Hand-picked atomic tools',
advancedToolsHint: 'Skill bindings already auto-expand allowed tools. Use this only for built-in micro-utilities not packaged as a skill (e.g. datetime, delegate_agent).',
providersHint: 'Preferred provider order for this agent (lower index tried first). Leave empty to use the global available-pool order. Cooling-down or pool-removed providers are still skipped automatically.',
providersAddHint: 'Click a provider below to add it to the preference list:',
noSkills: 'No skills available',
@ -1930,10 +1932,21 @@ export default {
},
actions: {
configure: 'Configure',
view: 'View',
delete: 'Delete',
saveChanges: 'Save Changes',
createSkill: 'Create Skill',
},
detail: {
title: 'Skill detail',
manifest: 'Manifest',
tools: 'Tools',
features: 'Features',
noManifest: 'This skill does not declare a v3 manifest. Legacy fields apply.',
noTools: 'No tools advertised by active features.',
noFeatures: 'No features[] matrix declared. The skill is treated as a single default feature.',
toolsHint: 'These tool names are merged into the LLM allowed-tool list when this skill is bound to an agent. Tools owned by SETUP_NEEDED features stay hidden.',
},
runtime: {
disabled: 'Disabled',
unknown: 'Unknown',

View File

@ -772,6 +772,8 @@ export default {
binding: {
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
advancedToolsTitle: '高级:手选原子工具',
advancedToolsHint: 'Skill 绑定已自动展开 allowed-tools。此处仅用于未打包成 Skill 的内置微工具(如 datetime、delegate_agent。',
providersHint: '此智能体优先使用的提供商顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。提供商进入冷却或被移出池时仍会被自动跳过。',
providersAddHint: '点击下方提供商加入偏好列表:',
noSkills: '暂无可用技能',
@ -1932,10 +1934,21 @@ export default {
},
actions: {
configure: '配置',
view: '查看',
delete: '删除',
saveChanges: '保存更改',
createSkill: '创建技能',
},
detail: {
title: '技能详情',
manifest: 'Manifest',
tools: '工具',
features: '特性',
noManifest: '该技能未声明 v3 manifest使用旧字段。',
noTools: '当前激活特性未暴露任何工具。',
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
toolsHint: 'Skill 绑定到 agent 时,这些工具名将合并进 LLM 的 allowed-tools。SETUP_NEEDED 特性下的工具保持隐藏。',
},
runtime: {
disabled: '已停用',
unknown: '未知',

View File

@ -233,6 +233,8 @@ export interface Skill {
/** 运行时解析状态(来自 /runtime/status */
export interface SkillRuntimeStatus {
/** RFC-090 Phase 2 — entity primary key */
id?: number
name: string
description?: string
source: string // "directory" | "database"
@ -256,6 +258,77 @@ export interface SkillRuntimeStatus {
dependencySummary?: string | null
// Computed label
runtimeStatusLabel?: string
// RFC-090 §14.1 — features matrix + manifest SoT
manifest?: SkillManifest | null
/** Map<featureId, "READY" | "SETUP_NEEDED" | "UNSUPPORTED"> */
featureStatuses?: Record<string, string>
/** featureIds whose status is READY */
activeFeatures?: string[]
/** Tools advertised to the LLM after feature filtering */
effectiveAllowedTools?: string[]
}
/** RFC-090 §14.6 — typed view onto manifest_json */
export interface SkillManifest {
id?: string
name?: string
description?: string
icon?: string
version?: string
author?: string
/** prompt | code | mcp | acp | knowledge */
type?: string
category?: string
allowedTools?: string[]
requires?: SkillManifestRequirement[]
platforms?: string[]
features?: SkillManifestFeature[]
settings?: SkillManifestSetting[]
requiresModel?: string[]
dashboardMetrics?: SkillManifestDashboardMetric[]
selfEvolution?: { lessonsEnabled?: boolean; lessonsMaxEntries?: number; memoryWritesAllowed?: boolean }
knowledge?: {
bindKb?: string
retrieval?: string
topK?: number
citation?: string
rerank?: boolean
boundKbId?: number | null
} | null
extras?: Record<string, any>
}
export interface SkillManifestRequirement {
key: string
type?: string
check?: string
optional?: boolean
description?: string
install?: Record<string, string>
}
export interface SkillManifestFeature {
id: string
label?: string
requires?: string[]
platforms?: string[]
tools?: string[]
fallbackMessage?: string
unsupportedMessage?: string
}
export interface SkillManifestSetting {
key: string
label?: string
type?: string
defaultValue?: any
options?: Record<string, any>[]
}
export interface SkillManifestDashboardMetric {
label?: string
memoryKey?: string
format?: string
}
/** 安全扫描发现 */

View File

@ -243,26 +243,39 @@
</div>
</div>
<!-- Tools Tab -->
<!-- Tools Tab RFC-090 §9.2 调整 B: Advanced bypass for atomic
tools not packaged as skills (e.g. datetime, delegate_agent).
Skill bindings already auto-expand allowed-tools (§14.2), so
the picker is collapsed by default to reduce noise. -->
<div v-if="modalTab === 'tools'" class="binding-tab">
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
<div v-if="availableTools.length === 0" class="binding-empty">{{ t('agents.binding.noTools') }}</div>
<div v-else class="binding-list">
<label
v-for="tool in availableTools"
:key="tool.name"
class="binding-item"
:class="{ selected: selectedToolNames.includes(tool.name) }"
>
<input type="checkbox" :value="tool.name" v-model="selectedToolNames" class="binding-checkbox" />
<span class="binding-icon">{{ tool.icon || '🔧' }}</span>
<div class="binding-info">
<span class="binding-name">{{ tool.displayName || tool.name }}</span>
<span v-if="tool.description" class="binding-desc">{{ tool.description?.slice(0, 80) }}</span>
</div>
<span class="binding-type-badge">{{ tool.toolType }}</span>
</label>
</div>
<details class="advanced-tools" :open="selectedToolNames.length > 0 || advancedToolsOpen">
<summary class="advanced-tools-summary" @click.prevent="advancedToolsOpen = !advancedToolsOpen">
<span class="advanced-tools-title">
{{ t('agents.binding.advancedToolsTitle') }}
<span v-if="selectedToolNames.length > 0" class="advanced-tools-count">{{ selectedToolNames.length }}</span>
</span>
<span class="advanced-tools-chevron">{{ (advancedToolsOpen || selectedToolNames.length > 0) ? '▾' : '▸' }}</span>
</summary>
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
<p class="binding-hint advanced-tools-note">{{ t('agents.binding.advancedToolsHint') }}</p>
<div v-if="availableTools.length === 0" class="binding-empty">{{ t('agents.binding.noTools') }}</div>
<div v-else class="binding-list">
<label
v-for="tool in availableTools"
:key="tool.name"
class="binding-item"
:class="{ selected: selectedToolNames.includes(tool.name) }"
>
<input type="checkbox" :value="tool.name" v-model="selectedToolNames" class="binding-checkbox" />
<span class="binding-icon">{{ tool.icon || '🔧' }}</span>
<div class="binding-info">
<span class="binding-name">{{ tool.displayName || tool.name }}</span>
<span v-if="tool.description" class="binding-desc">{{ tool.description?.slice(0, 80) }}</span>
</div>
<span class="binding-type-badge">{{ tool.toolType }}</span>
</label>
</div>
</details>
</div>
<!-- Providers Tab (RFC-009 PR-3) -->
@ -324,6 +337,10 @@ const activeFilter = ref('all')
const showModal = ref(false)
const editingAgent = ref<Agent | null>(null)
const modalTab = ref<'basic' | 'skills' | 'tools' | 'providers'>('basic')
/** RFC-090 §9.2 B Tool picker is an Advanced bypass; collapsed by
* default but stays open as soon as the agent has any direct tool
* bindings, so existing users don't lose visibility on their picks. */
const advancedToolsOpen = ref(false)
// Binding state
const availableSkills = ref<any[]>([])
@ -868,4 +885,15 @@ async function toggleAgent(agent: Agent) {
.template-tags { display: flex; flex-wrap: wrap; gap: 4px; align-self: flex-start; margin-top: 2px; }
.tag-chip { font-size: 11px; padding: 2px 8px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; white-space: nowrap; }
/* RFC-090 §9.2 调整 B — Advanced Tools picker (collapsed by default) */
.advanced-tools { border: 1px dashed var(--mc-border); border-radius: 12px; padding: 0; }
.advanced-tools[open] { padding: 12px 14px; }
.advanced-tools-summary { list-style: none; cursor: pointer; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 8px; user-select: none; color: var(--mc-text-secondary); font-size: 13px; font-weight: 600; }
.advanced-tools-summary::-webkit-details-marker { display: none; }
.advanced-tools[open] > .advanced-tools-summary { padding: 0 0 8px; border-bottom: 1px solid var(--mc-border-light); margin-bottom: 8px; }
.advanced-tools-title { display: inline-flex; align-items: center; gap: 8px; }
.advanced-tools-count { padding: 1px 8px; background: var(--mc-primary-bg); color: var(--mc-primary); border-radius: 999px; font-size: 11px; font-weight: 700; }
.advanced-tools-chevron { color: var(--mc-text-tertiary); font-size: 12px; }
.advanced-tools-note { font-style: italic; color: var(--mc-text-tertiary); margin-top: 4px; }
</style>

View File

@ -114,6 +114,10 @@
<span v-if="getDependencyBadge(skill)" class="runtime-badge" :class="getDependencyBadge(skill)?.cls">
{{ getDependencyBadge(skill)?.label }}
</span>
<!-- RFC-090 §14.1 features 矩阵Setup Needed (M/N) -->
<span v-if="getFeaturesBadge(skill)" class="runtime-badge" :class="getFeaturesBadge(skill)?.cls">
{{ getFeaturesBadge(skill)?.label }}
</span>
<span v-if="getSourceBadge(skill)" class="source-badge">{{ getSourceBadge(skill) }}</span>
<span v-if="getRuntimePath(skill)" class="skill-source-path">{{ getRuntimePath(skill) }}</span>
</div>
@ -179,6 +183,12 @@
<div class="skill-footer">
<span v-if="skill.author" class="skill-author">by {{ skill.author }}</span>
<div class="skill-actions">
<button class="skill-btn" @click="openDetailDrawer(skill)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/>
</svg>
{{ t('skills.actions.view') }}
</button>
<button class="skill-btn" @click="openEditModal(skill)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
@ -224,6 +234,72 @@
<!-- Import Hub Dialog -->
<ImportHubDialog v-model:visible="showImportDialog" @installed="loadAll" />
<!-- RFC-090 Phase 3 Skill detail drawer (Manifest / Tools / Features) -->
<el-drawer
v-model="detailDrawerVisible"
:title="detailSkill ? resolveSkillName(detailSkill) : t('skills.detail.title')"
direction="rtl"
size="640px"
:destroy-on-close="true"
>
<div v-if="detailSkill" class="detail-drawer">
<div class="detail-tabs">
<button class="detail-tab" :class="{ active: detailTab === 'manifest' }" @click="detailTab = 'manifest'">
{{ t('skills.detail.manifest') }}
</button>
<button class="detail-tab" :class="{ active: detailTab === 'tools' }" @click="detailTab = 'tools'">
{{ t('skills.detail.tools') }}
<span v-if="detailToolsCount > 0" class="tab-count">{{ detailToolsCount }}</span>
</button>
<button class="detail-tab" :class="{ active: detailTab === 'features' }" @click="detailTab = 'features'">
{{ t('skills.detail.features') }}
<span v-if="detailFeaturesCount > 0" class="tab-count">{{ detailFeaturesCount }}</span>
</button>
</div>
<!-- Manifest tab -->
<div v-if="detailTab === 'manifest'" class="detail-section">
<p v-if="!detailManifest" class="detail-empty">{{ t('skills.detail.noManifest') }}</p>
<pre v-else class="detail-pre">{{ detailManifestPretty }}</pre>
</div>
<!-- Tools tab -->
<div v-if="detailTab === 'tools'" class="detail-section">
<p v-if="detailToolsCount === 0" class="detail-empty">{{ t('skills.detail.noTools') }}</p>
<ul v-else class="detail-tool-list">
<li v-for="tool in detailEffectiveTools" :key="tool" class="detail-tool-item">
<code>{{ tool }}</code>
</li>
</ul>
<p class="detail-hint">{{ t('skills.detail.toolsHint') }}</p>
</div>
<!-- Features tab -->
<div v-if="detailTab === 'features'" class="detail-section">
<p v-if="detailFeaturesCount === 0" class="detail-empty">{{ t('skills.detail.noFeatures') }}</p>
<ul v-else class="detail-feature-list">
<li v-for="feat in detailFeatures" :key="feat.id" class="detail-feature-item">
<div class="detail-feature-head">
<span class="detail-feature-id">{{ feat.id }}</span>
<span class="detail-feature-status" :class="`feat-${(feat.status || 'unknown').toLowerCase()}`">
{{ feat.status }}
</span>
</div>
<div v-if="feat.label" class="detail-feature-label">{{ feat.label }}</div>
<div v-if="feat.requires?.length" class="detail-feature-meta">
<span class="detail-meta-key">requires:</span>
<span v-for="r in feat.requires" :key="r" class="detail-feature-tag">{{ r }}</span>
</div>
<div v-if="feat.platforms?.length" class="detail-feature-meta">
<span class="detail-meta-key">platforms:</span>
<span v-for="p in feat.platforms" :key="p" class="detail-feature-tag">{{ p }}</span>
</div>
<div v-if="feat.fallbackMessage" class="detail-feature-fallback">
{{ feat.fallbackMessage }}
</div>
</li>
</ul>
</div>
</div>
</el-drawer>
<!-- Modal -->
<div v-if="showModal" class="modal-overlay">
<div class="modal">
@ -350,6 +426,34 @@ const query = reactive({
const expandedFindings = ref<Record<string, boolean>>({})
const rescanning = ref<Record<string, boolean>>({})
/** RFC-090 Phase 3 — detail drawer state. */
const detailDrawerVisible = ref(false)
const detailSkill = ref<Skill | null>(null)
const detailTab = ref<'manifest' | 'tools' | 'features'>('manifest')
const detailRuntime = computed(() =>
detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null,
)
const detailManifest = computed(() => detailRuntime.value?.manifest ?? null)
const detailManifestPretty = computed(() =>
detailManifest.value ? JSON.stringify(detailManifest.value, null, 2) : '',
)
const detailEffectiveTools = computed(() => detailRuntime.value?.effectiveAllowedTools || [])
const detailToolsCount = computed(() => detailEffectiveTools.value.length)
const detailFeatures = computed(() => {
const m = detailManifest.value
if (!m || !m.features) return []
const statuses = detailRuntime.value?.featureStatuses || {}
return m.features.map(f => ({ ...f, status: statuses[f.id] || 'UNKNOWN' }))
})
const detailFeaturesCount = computed(() => detailFeatures.value.length)
function openDetailDrawer(skill: Skill) {
detailSkill.value = skill
detailTab.value = 'manifest'
detailDrawerVisible.value = true
}
const categoryTabs = computed(() => [
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
@ -691,6 +795,37 @@ function getDependencyBadge(skill: Skill): { label: string; cls: string } | null
return null
}
/**
* RFC-090 §14.1 when a manifest declares a features[] matrix, surface
* "Setup Needed (M/N)" so the user sees partial readiness instead of a
* binary "all-or-nothing" deps badge. Returns null when the manifest has
* no explicit features (legacy skills or single-feature shape) the
* existing dependency badge already covers that case.
*/
function getFeaturesBadge(skill: Skill): { label: string; cls: string } | null {
if (!skill.enabled) return null
const rt = getRuntimeStatus(skill)
if (!rt || !rt.manifest) return null
const declaredFeatures = rt.manifest.features || []
if (declaredFeatures.length === 0) return null
const statuses = rt.featureStatuses || {}
const total = declaredFeatures.length
const ready = (rt.activeFeatures || []).length
if (ready === total) {
return { label: `${ready}/${total} ready`, cls: 'rt-features-ready' }
}
if (ready === 0) {
return { label: `Setup Needed (0/${total})`, cls: 'rt-deps-missing' }
}
// Mixed: at least one feature is not READY.
// Distinguish UNSUPPORTED vs SETUP_NEEDED to color appropriately.
const anyUnsupported = Object.values(statuses).some(s => s === 'UNSUPPORTED')
return {
label: `Setup Needed (${ready}/${total})`,
cls: anyUnsupported ? 'rt-features-mixed' : 'rt-deps-missing',
}
}
function getMissingDeps(skill: Skill): string[] {
const rt = getRuntimeStatus(skill)
return rt?.missingDependencies || []
@ -997,6 +1132,11 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
:root.dark .rt-synthesized { background: rgba(99, 102, 241, 0.15); color: #818cf8; }
.rt-sec-warning { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.rt-deps-missing { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
/* 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; }
: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); }
.rt-unknown { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.skill-source-path { font-size: 11px; color: var(--mc-text-tertiary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
@ -1047,4 +1187,34 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
width: 100%;
}
}
/* RFC-090 Phase 3 — detail drawer */
.detail-drawer { padding: 0 16px 16px; display: flex; flex-direction: column; gap: 16px; }
.detail-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--mc-border-light); padding-bottom: 4px; }
.detail-tab { padding: 8px 14px; border: none; background: none; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); border-radius: 8px 8px 0 0; display: inline-flex; align-items: center; gap: 6px; }
.detail-tab:hover { color: var(--mc-text-primary); background: var(--mc-bg-muted); }
.detail-tab.active { color: var(--mc-primary); background: var(--mc-primary-bg); border-bottom: 2px solid var(--mc-primary); margin-bottom: -1px; font-weight: 600; }
.tab-count { font-size: 10px; padding: 1px 6px; border-radius: 10px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); font-weight: 600; }
.detail-tab.active .tab-count { background: var(--mc-primary); color: white; }
.detail-section { padding: 4px 0; }
.detail-empty { color: var(--mc-text-tertiary); font-size: 13px; font-style: italic; }
.detail-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; max-height: 480px; overflow: auto; font-size: 12px; line-height: 1.5; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; white-space: pre-wrap; word-break: break-word; }
.detail-tool-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.detail-tool-item code { display: block; padding: 6px 10px; background: var(--mc-bg-sunken); border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); }
.detail-hint { margin-top: 12px; font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; }
.detail-feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.detail-feature-item { padding: 12px; border: 1px solid var(--mc-border-light); border-radius: 12px; background: var(--mc-bg-muted); }
.detail-feature-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.detail-feature-id { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; font-weight: 600; color: var(--mc-text-primary); }
.detail-feature-status { font-size: 10px; padding: 2px 8px; border-radius: 999px; font-weight: 700; letter-spacing: 0.04em; }
.feat-ready { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
.feat-setup_needed { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.feat-unsupported { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.feat-unknown { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
:root.dark .feat-ready { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
.detail-feature-label { font-size: 13px; color: var(--mc-text-secondary); margin: 4px 0 6px; }
.detail-feature-meta { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin: 4px 0; font-size: 11px; }
.detail-meta-key { color: var(--mc-text-tertiary); margin-right: 4px; }
.detail-feature-tag { padding: 2px 6px; background: var(--mc-bg-elevated); border-radius: 4px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; color: var(--mc-text-primary); }
.detail-feature-fallback { font-size: 11px; color: var(--mc-primary-hover); margin-top: 6px; font-style: italic; }
</style>