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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user