feat(skills): usage stats, source filtering, archived skills, provenance, pin toggle (#386)

This commit is contained in:
Desmond Zhang
2026-05-02 10:56:58 +10:00
committed by GitHub
parent 018053db19
commit 9325aa5482
16 changed files with 753 additions and 166 deletions
+23 -2
View File
@@ -1,9 +1,17 @@
import { request } from '../client'
export type SkillSource = 'builtin' | 'hub' | 'local'
export interface SkillInfo {
name: string
description: string
enabled?: boolean
source?: SkillSource
modified?: boolean
patchCount?: number
useCount?: number
viewCount?: number
pinned?: boolean
}
export interface SkillCategory {
@@ -14,6 +22,7 @@ export interface SkillCategory {
export interface SkillListResponse {
categories: SkillCategory[]
archived: SkillInfo[]
}
export interface SkillFileEntry {
@@ -31,9 +40,14 @@ export interface MemoryData {
soul_mtime: number | null
}
export async function fetchSkills(): Promise<SkillCategory[]> {
export interface SkillsData {
categories: SkillCategory[]
archived: SkillInfo[]
}
export async function fetchSkills(): Promise<SkillsData> {
const res = await request<SkillListResponse>('/api/hermes/skills')
return res.categories
return { categories: res.categories, archived: res.archived ?? [] }
}
export async function fetchSkillContent(skillPath: string): Promise<string> {
@@ -63,3 +77,10 @@ export async function toggleSkill(name: string, enabled: boolean): Promise<void>
body: JSON.stringify({ name, enabled }),
})
}
export async function pinSkillApi(name: string, pinned: boolean): Promise<void> {
await request('/api/hermes/skills/pin', {
method: 'PUT',
body: JSON.stringify({ name, pinned }),
})
}
@@ -1,14 +1,25 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/hermes/skills'
import { fetchSkillContent, fetchSkillFiles, pinSkillApi, type SkillFileEntry } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
const { t } = useI18n()
const message = useMessage()
const props = defineProps<{
category: string
skill: string
skillName: string
patchCount?: number
useCount?: number
viewCount?: number
pinned?: boolean
}>()
const emit = defineEmits<{
pinToggled: [name: string, pinned: boolean]
}>()
const content = ref('')
@@ -67,6 +78,22 @@ function backToSkill() {
fileContent.value = ''
}
const pinLoading = ref(false)
async function handlePinToggle() {
if (pinLoading.value) return
pinLoading.value = true
try {
const newPinned = !props.pinned
await pinSkillApi(props.skillName, newPinned)
emit('pinToggled', props.skillName, newPinned)
} catch (err: any) {
message.error(t('skills.pinFailed') + `: ${err.message}`)
} finally {
pinLoading.value = false
}
}
watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
</script>
@@ -77,6 +104,23 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
<span class="detail-category">{{ category }}</span>
<span class="detail-separator">/</span>
<span class="detail-name">{{ skill }}</span>
<div class="usage-stats">
<button class="pin-toggle" :class="{ active: pinned }" :disabled="pinLoading" :title="pinned ? t('skills.unpin') : t('skills.pin')" @click="handlePinToggle">
<svg width="16" height="16" viewBox="0 0 24 24" :fill="pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>
</button>
<span v-if="viewCount != null" class="usage-stat" title="Views">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{{ viewCount }}
</span>
<span v-if="useCount != null" class="usage-stat" title="Uses">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{{ useCount }}
</span>
<span v-if="patchCount != null" class="usage-stat" title="Patches">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{{ patchCount }}
</span>
</div>
</div>
<div v-if="loading && !content" class="detail-loading">{{ t('common.loading') }}</div>
@@ -136,6 +180,8 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
border-bottom: 1px solid $border-color;
margin-bottom: 12px;
font-size: 15px;
display: flex;
align-items: center;
}
.detail-category {
@@ -153,6 +199,59 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
font-weight: 600;
}
.usage-stats {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
padding-left: 12px;
}
.usage-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
color: $text-secondary;
white-space: nowrap;
svg {
opacity: 0.7;
}
}
.pin-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: none;
color: $text-muted;
cursor: pointer;
padding: 4px;
border-radius: 6px;
opacity: 0.5;
transition: all $transition-fast;
&:hover {
opacity: 1;
color: $accent-primary;
background: rgba(var(--accent-primary-rgb), 0.08);
border-color: rgba(var(--accent-primary-rgb), 0.15);
}
&.active {
opacity: 1;
color: $accent-primary;
}
&:disabled {
cursor: wait;
opacity: 0.3;
}
}
.detail-loading {
flex: 1;
display: flex;
@@ -1,235 +1,335 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NSwitch, useMessage } from 'naive-ui'
import type { SkillCategory } from '@/api/hermes/skills'
import type { SkillCategory, SkillSource, SkillInfo } from '@/api/hermes/skills'
import { toggleSkill } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
type SourceFilter = SkillSource | 'modified'
const { t } = useI18n()
const message = useMessage()
const props = defineProps<{
categories: SkillCategory[]
selectedSkill: string | null
searchQuery: string
categories: SkillCategory[]
archived: SkillInfo[]
selectedSkill: string | null
searchQuery: string
sourceFilter: SourceFilter | null
}>()
const emit = defineEmits<{
select: [category: string, skill: string]
select: [category: string, skill: string]
}>()
const collapsedCategories = ref<Set<string>>(new Set())
const archiveCollapsed = ref(true)
const togglingSkills = ref<Set<string>>(new Set())
const filteredArchived = computed(() => {
let result = props.archived
if (props.sourceFilter && props.sourceFilter !== 'modified') {
result = result.filter(s => (s.source || 'local') === props.sourceFilter)
}
if (props.searchQuery) {
const q = props.searchQuery.toLowerCase()
result = result.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
}
return result
})
const filteredCategories = computed(() => {
if (!props.searchQuery) return props.categories
const q = props.searchQuery.toLowerCase()
return props.categories
.map(cat => ({
...cat,
skills: cat.skills.filter(
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
),
}))
.filter(cat => cat.skills.length > 0 || cat.name.toLowerCase().includes(q))
let result = props.categories
// Filter by source
if (props.sourceFilter) {
result = result
.map(cat => ({
...cat,
skills: cat.skills.filter(s => {
if (props.sourceFilter === 'modified') return s.modified
return (s.source || 'local') === props.sourceFilter
}),
}))
.filter(cat => cat.skills.length > 0)
}
// Filter by search query
if (props.searchQuery) {
const q = props.searchQuery.toLowerCase()
result = result
.map(cat => ({
...cat,
skills: cat.skills.filter(
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
),
}))
.filter(cat => cat.skills.length > 0 || cat.name.toLowerCase().includes(q))
}
return result
})
function toggleCategory(name: string) {
if (collapsedCategories.value.has(name)) {
collapsedCategories.value.delete(name)
} else {
collapsedCategories.value.add(name)
}
if (collapsedCategories.value.has(name)) {
collapsedCategories.value.delete(name)
} else {
collapsedCategories.value.add(name)
}
}
function handleSelect(category: string, skill: string) {
emit('select', category, skill)
function handleSelect(category: string, skillName: string) {
emit('select', category, skillName)
}
/** Unique key for selection tracking */
function skillKey(catName: string, skill: { name: string }): string {
return `${catName}/${skill.name}`
}
async function handleToggle(category: string, skillName: string, newEnabled: boolean) {
if (togglingSkills.value.has(skillName)) return
togglingSkills.value.add(skillName)
if (togglingSkills.value.has(skillName)) return
togglingSkills.value.add(skillName)
try {
await toggleSkill(skillName, newEnabled)
// Update local state
const cat = props.categories.find(c => c.name === category)
const skill = cat?.skills.find(s => s.name === skillName)
if (skill) skill.enabled = newEnabled
} catch (err: any) {
message.error(t('skills.toggleFailed') + `: ${err.message}`)
} finally {
togglingSkills.value.delete(skillName)
}
try {
await toggleSkill(skillName, newEnabled)
// Update local state
const cat = props.categories.find(c => c.name === category)
const skill = cat?.skills.find(s => s.name === skillName)
if (skill) skill.enabled = newEnabled
} catch (err: any) {
message.error(t('skills.toggleFailed') + `: ${err.message}`)
} finally {
togglingSkills.value.delete(skillName)
}
}
</script>
<template>
<div class="skill-list">
<div v-if="filteredCategories.length === 0" class="skill-empty">
{{ searchQuery ? t('skills.noMatch') : t('skills.noSkills') }}
<div class="skill-list">
<div v-if="filteredCategories.length === 0" class="skill-empty">
{{ searchQuery ? t('skills.noMatch') : t('skills.noSkills') }}
</div>
<div v-for="cat in filteredCategories" :key="cat.name" class="skill-category">
<button class="category-header" @click="toggleCategory(cat.name)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="category-arrow" :class="{ collapsed: collapsedCategories.has(cat.name) }">
<polyline points="6 9 12 15 18 9" />
</svg>
<span class="category-name">{{ cat.name }}</span>
<span class="category-count">{{ cat.skills.length }}</span>
</button>
<div v-if="!collapsedCategories.has(cat.name)" class="category-skills">
<button v-for="skill in cat.skills" :key="skillKey(cat.name, skill)" class="skill-item" :class="[
{ active: selectedSkill === skillKey(cat.name, skill) },
`source-${skill.source || 'local'}`,
]" @click="handleSelect(cat.name, skill.name)">
<div class="skill-info">
<span class="skill-name">
<span class="source-dot" :class="`dot-${skill.source || 'local'}`"
:title="t(`skills.source.${skill.source || 'local'}`)" />
{{ skill.name }}
<span v-if="skill.modified" class="modified-badge"
:title="t('skills.modified')"></span>
</span>
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
</div>
<NSwitch size="small" :value="skill.enabled !== false" :loading="togglingSkills.has(skill.name)"
@update:value="handleToggle(cat.name, skill.name, $event)" @click.stop />
</button>
</div>
</div>
<!-- Archived skills (separate section) -->
<div v-if="filteredArchived.length > 0 || archived.length > 0" class="skill-category archive-section">
<button class="category-header archive-header" @click="archiveCollapsed = !archiveCollapsed">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="category-arrow" :class="{ collapsed: archiveCollapsed }">
<polyline points="6 9 12 15 18 9" />
</svg>
<span class="category-name">{{ t('skills.archived') }}</span>
<span class="category-count">{{ archived.length }}</span>
</button>
<div v-if="!archiveCollapsed" class="category-skills">
<button v-for="skill in filteredArchived" :key="skillKey('.archive', skill)" class="skill-item skill-archived"
:class="{ active: selectedSkill === skillKey('.archive', skill) }"
@click="handleSelect('.archive', skill.name)">
<div class="skill-info">
<span class="skill-name">
<span class="source-dot" :class="`dot-${skill.source || 'local'}`"
:title="t(`skills.source.${skill.source || 'local'}`)" />
{{ skill.name }}
</span>
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
</div>
</button>
</div>
</div>
</div>
<div
v-for="cat in filteredCategories"
:key="cat.name"
class="skill-category"
>
<button class="category-header" @click="toggleCategory(cat.name)">
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
class="category-arrow"
:class="{ collapsed: collapsedCategories.has(cat.name) }"
>
<polyline points="6 9 12 15 18 9" />
</svg>
<span class="category-name">{{ cat.name }}</span>
<span class="category-count">{{ cat.skills.length }}</span>
</button>
<div v-if="!collapsedCategories.has(cat.name)" class="category-skills">
<button
v-for="skill in cat.skills"
:key="skill.name"
class="skill-item"
:class="{
active: selectedSkill === `${cat.name}/${skill.name}`,
}"
@click="handleSelect(cat.name, skill.name)"
>
<div class="skill-info">
<span class="skill-name">{{ skill.name }}</span>
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
</div>
<NSwitch
size="small"
:value="skill.enabled !== false"
:loading="togglingSkills.has(skill.name)"
@update:value="handleToggle(cat.name, skill.name, $event)"
@click.stop
/>
</button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.skill-list {
flex: 1;
overflow-y: auto;
padding: 8px;
flex: 1;
overflow-y: auto;
padding: 8px;
}
.skill-empty {
padding: 24px 16px;
font-size: 13px;
color: $text-muted;
text-align: center;
padding: 24px 16px;
font-size: 13px;
color: $text-muted;
text-align: center;
}
.skill-category {
margin-bottom: 4px;
margin-bottom: 4px;
}
.category-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
color: $text-secondary;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
border-radius: $radius-sm;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
color: $text-secondary;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
border-radius: $radius-sm;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.04);
}
&:hover {
background: rgba(var(--accent-primary-rgb), 0.04);
}
}
.category-arrow {
flex-shrink: 0;
transition: transform $transition-fast;
flex-shrink: 0;
transition: transform $transition-fast;
&.collapsed {
transform: rotate(-90deg);
}
&.collapsed {
transform: rotate(-90deg);
}
}
.category-name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-count {
font-size: 11px;
color: $text-muted;
background: rgba(var(--accent-primary-rgb), 0.06);
padding: 1px 6px;
border-radius: 8px;
font-size: 11px;
color: $text-muted;
background: rgba(var(--accent-primary-rgb), 0.06);
padding: 1px 6px;
border-radius: 8px;
}
.category-skills {
padding: 2px 0 4px;
padding: 2px 0 4px;
}
.skill-item {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 6px 10px 6px 28px;
border: none;
background: none;
color: $text-secondary;
font-size: 13px;
text-align: left;
cursor: pointer;
border-radius: $radius-sm;
transition: all $transition-fast;
gap: 8px;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 6px 10px 6px 28px;
border: none;
background: none;
color: $text-secondary;
font-size: 13px;
text-align: left;
cursor: pointer;
border-radius: $radius-sm;
transition: all $transition-fast;
gap: 8px;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
}
&:hover {
background: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
}
&.active {
background: rgba(var(--accent-primary-rgb), 0.1);
color: $text-primary;
font-weight: 500;
}
&.active {
background: rgba(var(--accent-primary-rgb), 0.1);
color: $text-primary;
font-weight: 500;
}
}
// Source indicator dot
.source-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
flex-shrink: 0;
vertical-align: middle;
}
.dot-builtin {
background: #888;
}
.dot-hub {
background: #4a90d9;
}
.dot-local {
background: #66bb6a;
}
.skill-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.skill-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modified-badge {
font-size: 11px;
color: $warning;
margin-left: 2px;
opacity: 0.7;
}
.skill-desc {
font-size: 11px;
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
font-size: 11px;
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.archive-section {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid $border-color;
}
.archive-header {
color: $text-muted;
}
.skill-archived {
opacity: 0.6;
padding-left: 28px;
}
</style>
+11
View File
@@ -220,7 +220,18 @@ jobTriggered: 'Job ausgelost',
attachedFiles: 'Angehange Dateien',
loadFailed: 'Laden der Fahigkeit fehlgeschlagen',
fileLoadFailed: 'Laden der Datei fehlgeschlagen',
modified: 'Benutzerbearbeitet',
archived: 'Archiviert',
pinned: 'Angeheftet',
pin: 'Fahigkeit anheften',
unpin: 'Anheften aufheben',
pinFailed: 'Anheft-Status konnte nicht geandert werden',
toggleFailed: 'Aktivieren/Deaktivieren der Fahigkeit fehlgeschlagen',
source: {
builtin: 'Integriert',
hub: 'Hub',
local: 'Lokal',
},
},
// Memory
+11
View File
@@ -249,7 +249,18 @@ export default {
attachedFiles: 'Attached Files',
loadFailed: 'Failed to load skill',
fileLoadFailed: 'Failed to load file',
modified: 'Modified',
archived: 'Archived',
pinned: 'Pinned',
pin: 'Pin skill',
unpin: 'Unpin skill',
pinFailed: 'Failed to change pin status',
toggleFailed: 'Failed to toggle skill',
source: {
builtin: 'Builtin',
hub: 'Hub',
local: 'Local',
},
},
// Memory
+11
View File
@@ -220,7 +220,18 @@ jobTriggered: 'Job ejecutado',
attachedFiles: 'Archivos adjuntos',
loadFailed: 'Error al cargar la habilidad',
fileLoadFailed: 'Error al cargar el archivo',
modified: 'Modificado por el usuario',
archived: 'Archivado',
pinned: 'Fijado',
pin: 'Fijar habilidad',
unpin: 'Desfijar habilidad',
pinFailed: 'Error al cambiar estado de fijacion',
toggleFailed: 'Error al activar/desactivar la habilidad',
source: {
builtin: 'Integrado',
hub: 'Hub',
local: 'Local',
},
},
// Memory
+11
View File
@@ -220,7 +220,18 @@ jobTriggered: 'Job declenche',
attachedFiles: 'Fichiers joints',
loadFailed: 'Echec du chargement de la competence',
fileLoadFailed: 'Echec du chargement du fichier',
modified: "Modifi\u00e9 par l'utilisateur",
archived: 'Archivé',
pinned: 'Épinglé',
pin: 'Épingler la compétence',
unpin: 'Désépingler la compétence',
pinFailed: "Impossible de changer le statut d'épinglage",
toggleFailed: 'Echec de l\'activation/desactivation de la competence',
source: {
builtin: 'Intégré',
hub: 'Hub',
local: 'Local',
},
},
// Memory
+11
View File
@@ -220,7 +220,18 @@ export default {
attachedFiles: '添付ファイル',
loadFailed: 'スキルの読み込みに失敗しました',
fileLoadFailed: 'ファイルの読み込みに失敗しました',
modified: 'ユーザー変更あり',
archived: 'アーカイブ済み',
pinned: 'ピン留め',
pin: 'スキルをピン留め',
unpin: 'ピン留めを解除',
pinFailed: 'ピン留め状態の変更に失敗しました',
toggleFailed: 'スキルの切り替えに失敗しました',
source: {
builtin: '組み込み',
hub: 'Hub',
local: 'ローカル',
},
},
// メモリ
+11
View File
@@ -220,7 +220,18 @@ export default {
attachedFiles: '첨부 파일',
loadFailed: '스킬을 불러오지 못했습니다',
fileLoadFailed: '파일을 불러오지 못했습니다',
modified: '사용자 수정됨',
archived: '보관됨',
pinned: '고정됨',
pin: '스킬 고정',
unpin: '고정 해제',
pinFailed: '고정 상태 변경 실패',
toggleFailed: '스킬 상태를 전환하지 못했습니다',
source: {
builtin: '내장',
hub: 'Hub',
local: '로컬',
},
},
// 메모리
+11
View File
@@ -220,7 +220,18 @@ jobTriggered: 'Job acionado',
attachedFiles: 'Arquivos anexados',
loadFailed: 'Falha ao carregar a habilidade',
fileLoadFailed: 'Falha ao carregar o arquivo',
modified: 'Modificado pelo usuário',
archived: 'Arquivado',
pinned: 'Fixado',
pin: 'Fixar habilidade',
unpin: 'Desfixar habilidade',
pinFailed: 'Falha ao alterar estado de fixacao',
toggleFailed: 'Falha ao ativar/desativar a habilidade',
source: {
builtin: 'Integrado',
hub: 'Hub',
local: 'Local',
},
},
// Memory
+11
View File
@@ -249,7 +249,18 @@ export default {
attachedFiles: '附件文件',
loadFailed: '加载技能失败',
fileLoadFailed: '加载文件失败',
modified: '用户已修改',
archived: '已归档',
pinned: '已置顶',
pin: '置顶技能',
unpin: '取消置顶',
pinFailed: '更改置顶状态失败',
toggleFailed: '切换技能状态失败',
source: {
builtin: '内置',
hub: 'Hub 安装',
local: '本地安装',
},
},
// 记忆
+115 -3
View File
@@ -1,20 +1,33 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { NInput } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import SkillList from '@/components/hermes/skills/SkillList.vue'
import SkillDetail from '@/components/hermes/skills/SkillDetail.vue'
import { fetchSkills, type SkillCategory } from '@/api/hermes/skills'
import { fetchSkills, type SkillCategory, type SkillSource, type SkillInfo } from '@/api/hermes/skills'
type SourceFilter = SkillSource | 'modified'
const { t } = useI18n()
const categories = ref<SkillCategory[]>([])
const archived = ref<SkillInfo[]>([])
const loading = ref(false)
const selectedCategory = ref('')
const selectedSkill = ref('')
const searchQuery = ref('')
const showSidebar = ref(true)
const sourceFilter = ref<SourceFilter | null>(null)
let mobileQuery: MediaQueryList | null = null
const selectedSkillData = computed(() => {
if (!selectedCategory.value || !selectedSkill.value) return null
if (selectedCategory.value === '.archive') {
return archived.value.find(s => s.name === selectedSkill.value) ?? null
}
const cat = categories.value.find(c => c.name === selectedCategory.value)
return cat?.skills.find(s => s.name === selectedSkill.value) ?? null
})
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
showSidebar.value = !e.matches
}
@@ -33,7 +46,9 @@ onUnmounted(() => {
async function loadSkills() {
loading.value = true
try {
categories.value = await fetchSkills()
const data = await fetchSkills()
categories.value = data.categories
archived.value = data.archived
} catch (err: any) {
console.error('Failed to load skills:', err)
} finally {
@@ -41,6 +56,10 @@ async function loadSkills() {
}
}
function toggleFilter(filter: SourceFilter) {
sourceFilter.value = sourceFilter.value === filter ? null : filter
}
function handleSelect(category: string, skill: string) {
selectedCategory.value = category
selectedSkill.value = skill
@@ -48,6 +67,18 @@ function handleSelect(category: string, skill: string) {
showSidebar.value = false
}
}
function handlePinToggled(name: string, pinned: boolean) {
// Update local state so the pin icon updates immediately
if (selectedCategory.value === '.archive') {
const skill = archived.value.find(s => s.name === name)
if (skill) skill.pinned = pinned
} else {
const cat = categories.value.find(c => c.name === selectedCategory.value)
const skill = cat?.skills.find(s => s.name === name)
if (skill) skill.pinned = pinned
}
}
</script>
<template>
@@ -59,6 +90,20 @@ function handleSelect(category: string, skill: string) {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
</div>
<div class="source-legend">
<button class="legend-item" :class="{ active: sourceFilter === 'builtin' }" @click="toggleFilter('builtin')">
<span class="legend-dot dot-builtin" />{{ t('skills.source.builtin') }}
</button>
<button class="legend-item" :class="{ active: sourceFilter === 'hub' }" @click="toggleFilter('hub')">
<span class="legend-dot dot-hub" />{{ t('skills.source.hub') }}
</button>
<button class="legend-item" :class="{ active: sourceFilter === 'local' }" @click="toggleFilter('local')">
<span class="legend-dot dot-local" />{{ t('skills.source.local') }}
</button>
<button class="legend-item" :class="{ active: sourceFilter === 'modified' }" @click="toggleFilter('modified')">
<span class="modified-icon"></span>{{ t('skills.modified') }}
</button>
</div>
<NInput
v-model:value="searchQuery"
:placeholder="t('skills.searchPlaceholder')"
@@ -75,8 +120,10 @@ function handleSelect(category: string, skill: string) {
<div v-if="showSidebar" class="skills-sidebar">
<SkillList
:categories="categories"
:archived="archived"
:selected-skill="selectedCategory && selectedSkill ? `${selectedCategory}/${selectedSkill}` : null"
:search-query="searchQuery"
:source-filter="sourceFilter"
@select="handleSelect"
/>
</div>
@@ -85,6 +132,12 @@ function handleSelect(category: string, skill: string) {
v-if="selectedCategory && selectedSkill"
:category="selectedCategory"
:skill="selectedSkill"
:skill-name="selectedSkillData?.name || selectedSkill"
:patch-count="selectedSkillData?.patchCount"
:use-count="selectedSkillData?.useCount"
:view-count="selectedSkillData?.viewCount"
:pinned="selectedSkillData?.pinned"
@pin-toggled="handlePinToggled"
/>
<div v-else class="empty-detail">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2">
@@ -109,6 +162,65 @@ function handleSelect(category: string, skill: string) {
flex-direction: column;
}
.source-legend {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
flex-wrap: wrap;
margin-left: 16px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: $text-muted;
white-space: nowrap;
padding: 2px 6px;
border: 1px solid transparent;
border-radius: 10px;
background: none;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: $text-secondary;
background: rgba(var(--accent-primary-rgb), 0.04);
}
&.active {
color: $text-primary;
border-color: $border-color;
background: rgba(var(--accent-primary-rgb), 0.08);
}
}
.legend-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-dot.dot-builtin { background: #888; }
.legend-dot.dot-hub { background: #4a90d9; }
.legend-dot.dot-local { background: #66bb6a; }
.modified-icon {
font-size: 11px;
color: $warning;
opacity: 0.7;
}
@media (max-width: $breakpoint-mobile) {
.source-legend {
display: none;
}
}
.search-input {
width: 100px;
@@ -1,15 +1,98 @@
import { readdir } from 'fs/promises'
import { readdir, readFile } from 'fs/promises'
import { join, resolve } from 'path'
import { createHash } from 'crypto'
import {
readConfigYaml, writeConfigYaml,
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
} from '../../services/config-helpers'
import { pinSkill } from '../../services/hermes/hermes-cli'
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
function readBundledManifest(manifestContent: string | null): Map<string, string> {
const map = new Map<string, string>()
if (!manifestContent) return map
for (const line of manifestContent.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const idx = trimmed.indexOf(':')
if (idx === -1) continue
const name = trimmed.slice(0, idx).trim()
const hash = trimmed.slice(idx + 1).trim()
if (name && hash) map.set(name, hash)
}
return map
}
/** Read hub-installed skill names from ~/.hermes/skills/.hub/lock.json */
function readHubInstalledNames(lockContent: string | null): Set<string> {
if (!lockContent) return new Set()
try {
const data = JSON.parse(lockContent)
if (data?.installed && typeof data.installed === 'object') {
return new Set(Object.keys(data.installed))
}
} catch { /* ignore */ }
return new Set()
}
/** Compute md5 hash of all files in a directory (mirrors Hermes _dir_hash), with in-memory cache */
const hashCache = new Map<string, { hash: string; mtime: number }>()
const HASH_CACHE_TTL = 60_000 // 1 minute
async function dirHash(directory: string): Promise<string> {
const cached = hashCache.get(directory)
if (cached && Date.now() - cached.mtime < HASH_CACHE_TTL) return cached.hash
const hasher = createHash('md5')
const files = await listFilesRecursive(directory, '')
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
for (const f of files) {
hasher.update(f.path)
const content = await readFile(join(directory, f.path))
hasher.update(content)
}
const hash = hasher.digest('hex')
hashCache.set(directory, { hash, mtime: Date.now() })
return hash
}
/** Determine the source type of a skill */
function getSkillSource(
dirName: string,
bundledManifest: Map<string, string>,
hubNames: Set<string>,
): 'builtin' | 'hub' | 'local' {
if (bundledManifest.has(dirName)) return 'builtin'
if (hubNames.has(dirName)) return 'hub'
return 'local'
}
/** Read .usage.json as a name→stats map */
interface UsageStats { patch_count: number; use_count: number; view_count: number; pinned: boolean }
function readUsageStats(usageContent: string | null): Map<string, UsageStats> {
const map = new Map<string, UsageStats>()
if (!usageContent) return map
try {
const data = JSON.parse(usageContent)
for (const [name, stats] of Object.entries(data)) {
const s = stats as any
map.set(name, { patch_count: s.patch_count ?? 0, use_count: s.use_count ?? 0, view_count: s.view_count ?? 0, pinned: !!s.pinned })
}
} catch { /* ignore */ }
return map
}
export async function list(ctx: any) {
const skillsDir = join(getHermesDir(), 'skills')
try {
const config = await readConfigYaml()
const disabledList: string[] = config.skills?.disabled || []
// Read provenance sources
const bundledManifest = readBundledManifest(await safeReadFile(join(skillsDir, '.bundled_manifest')))
const hubNames = readHubInstalledNames(await safeReadFile(join(skillsDir, '.hub', 'lock.json')))
const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json')))
const entries = await readdir(skillsDir, { withFileTypes: true })
const categories: any[] = []
for (const entry of entries) {
@@ -23,7 +106,31 @@ export async function list(ctx: any) {
if (!se.isDirectory()) continue
const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md'))
if (skillMd) {
skills.push({ name: se.name, description: extractDescription(skillMd), enabled: !disabledList.includes(se.name) })
const source = getSkillSource(se.name, bundledManifest, hubNames)
// Check if builtin skill has been user-modified
let modified = false
if (source === 'builtin') {
const manifestHash = bundledManifest.get(se.name)
if (manifestHash) {
const currentHash = await dirHash(join(catDir, se.name))
modified = currentHash !== manifestHash
}
}
const usage = usageStats.get(se.name)
skills.push({
name: se.name,
description: extractDescription(skillMd),
enabled: !disabledList.includes(se.name),
source,
modified: modified || undefined,
patchCount: usage?.patch_count,
useCount: usage?.use_count,
viewCount: usage?.view_count,
pinned: usage?.pinned || undefined,
})
}
}
if (skills.length > 0) {
@@ -32,7 +139,30 @@ export async function list(ctx: any) {
}
categories.sort((a, b) => a.name.localeCompare(b.name))
for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) }
ctx.body = { categories }
// Read archived skills from .archive/
const archived: any[] = []
const archiveDir = join(skillsDir, '.archive')
const archiveEntries = await readdir(archiveDir, { withFileTypes: true }).catch(() => [] as import('fs').Dirent[])
for (const entry of archiveEntries) {
if (!entry.isDirectory()) continue
const skillMd = await safeReadFile(join(archiveDir, entry.name, 'SKILL.md'))
if (skillMd) {
const usage = usageStats.get(entry.name)
archived.push({
name: entry.name,
description: extractDescription(skillMd),
source: getSkillSource(entry.name, bundledManifest, hubNames),
patchCount: usage?.patch_count,
useCount: usage?.use_count,
viewCount: usage?.view_count,
pinned: usage?.pinned || undefined,
})
}
}
archived.sort((a: any, b: any) => a.name.localeCompare(b.name))
ctx.body = { categories, archived }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
@@ -92,3 +222,19 @@ export async function readFile_(ctx: any) {
}
ctx.body = { content }
}
export async function pin_(ctx: any) {
const { name, pinned } = ctx.request.body as { name?: string; pinned?: boolean }
if (!name || typeof pinned !== 'boolean') {
ctx.status = 400
ctx.body = { error: 'Missing name or pinned flag' }
return
}
try {
await pinSkill(name, pinned)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -5,5 +5,6 @@ export const skillRoutes = new Router()
skillRoutes.get('/api/hermes/skills', ctrl.list)
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_)
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
skillRoutes.get('/api/hermes/skills/{*path}', ctrl.readFile_)
@@ -39,10 +39,13 @@ export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_en
// --- Types ---
export type SkillSource = 'builtin' | 'hub' | 'local'
export interface SkillInfo {
name: string
description: string
enabled: boolean
source?: SkillSource
}
export interface SkillCategory {
@@ -574,3 +574,20 @@ export async function importProfile(archivePath: string, name?: string): Promise
throw new Error(`Failed to import profile: ${err.message}`)
}
}
/**
* Pin or unpin a skill via hermes curator
*/
export async function pinSkill(name: string, pinned: boolean): Promise<string> {
const subcmd = pinned ? 'pin' : 'unpin'
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['curator', subcmd, name], {
timeout: 15000,
...execOpts,
})
return stdout || stderr
} catch (err: any) {
logger.error(err, `Hermes CLI: curator ${subcmd} failed`)
throw new Error(`Failed to ${subcmd} skill: ${err.message}`)
}
}