mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-26 22:10:15 +00:00
feat(skills): usage stats, source filtering, archived skills, provenance, pin toggle (#386)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -220,7 +220,18 @@ export default {
|
||||
attachedFiles: '添付ファイル',
|
||||
loadFailed: 'スキルの読み込みに失敗しました',
|
||||
fileLoadFailed: 'ファイルの読み込みに失敗しました',
|
||||
modified: 'ユーザー変更あり',
|
||||
archived: 'アーカイブ済み',
|
||||
pinned: 'ピン留め',
|
||||
pin: 'スキルをピン留め',
|
||||
unpin: 'ピン留めを解除',
|
||||
pinFailed: 'ピン留め状態の変更に失敗しました',
|
||||
toggleFailed: 'スキルの切り替えに失敗しました',
|
||||
source: {
|
||||
builtin: '組み込み',
|
||||
hub: 'Hub',
|
||||
local: 'ローカル',
|
||||
},
|
||||
},
|
||||
|
||||
// メモリ
|
||||
|
||||
@@ -220,7 +220,18 @@ export default {
|
||||
attachedFiles: '첨부 파일',
|
||||
loadFailed: '스킬을 불러오지 못했습니다',
|
||||
fileLoadFailed: '파일을 불러오지 못했습니다',
|
||||
modified: '사용자 수정됨',
|
||||
archived: '보관됨',
|
||||
pinned: '고정됨',
|
||||
pin: '스킬 고정',
|
||||
unpin: '고정 해제',
|
||||
pinFailed: '고정 상태 변경 실패',
|
||||
toggleFailed: '스킬 상태를 전환하지 못했습니다',
|
||||
source: {
|
||||
builtin: '내장',
|
||||
hub: 'Hub',
|
||||
local: '로컬',
|
||||
},
|
||||
},
|
||||
|
||||
// 메모리
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -249,7 +249,18 @@ export default {
|
||||
attachedFiles: '附件文件',
|
||||
loadFailed: '加载技能失败',
|
||||
fileLoadFailed: '加载文件失败',
|
||||
modified: '用户已修改',
|
||||
archived: '已归档',
|
||||
pinned: '已置顶',
|
||||
pin: '置顶技能',
|
||||
unpin: '取消置顶',
|
||||
pinFailed: '更改置顶状态失败',
|
||||
toggleFailed: '切换技能状态失败',
|
||||
source: {
|
||||
builtin: '内置',
|
||||
hub: 'Hub 安装',
|
||||
local: '本地安装',
|
||||
},
|
||||
},
|
||||
|
||||
// 记忆
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user