feat: 新增技能相关功能

This commit is contained in:
duanshuwen
2026-04-10 23:03:56 +08:00
parent 90a3ff6f77
commit 825fe36967
17 changed files with 2295 additions and 27 deletions

View File

@@ -0,0 +1,77 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<h3 class="text-[13px] font-bold text-[#171717]/80">
{{ t('skills.detail.envVars') }}
<el-tag v-if="modelValue.length > 0" type="info" class="ml-2 !h-5 !px-1.5 !text-[10px]">
{{ modelValue.length }}
</el-tag>
</h3>
</div>
<el-button
link
type="primary"
class="!h-7 !text-[12px] !font-semibold !px-2.5"
@click="handleAdd"
>
<RiAddLine class="h-3 w-3 mr-1" />
{{ t('skills.detail.addVariable', 'Add Variable') }}
</el-button>
</div>
<div class="space-y-2">
<div v-if="modelValue.length === 0" class="text-[13px] text-[#525866] font-medium italic flex items-center bg-[#eeece3] border border-black/5 rounded-xl px-4 py-3 shadow-sm">
{{ t('skills.detail.noEnvVars', 'No environment variables configured.') }}
</div>
<div v-for="(env, index) in modelValue" :key="index" class="flex items-center gap-3">
<el-input
:model-value="env.key"
@update:model-value="(v: string) => update(index, 'key', v)"
class="flex-1 !h-[40px]"
:placeholder="t('skills.detail.keyPlaceholder', 'Key')"
/>
<el-input
:model-value="env.value"
@update:model-value="(v: string) => update(index, 'value', v)"
class="flex-1 !h-[40px]"
:placeholder="t('skills.detail.valuePlaceholder', 'Value')"
/>
<el-button link type="danger" class="!h-10 !w-10 shrink-0" @click="remove(index)">
<RiDeleteBinLine class="h-4 w-4" />
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { RiAddLine, RiDeleteBinLine } from '@remixicon/vue'
const props = defineProps<{
modelValue: Array<{ key: string; value: string }>
}>()
const emit = defineEmits<{
'update:modelValue': [value: Array<{ key: string; value: string }>]
}>()
const { t } = useI18n()
function handleAdd() {
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
}
function update(index: number, field: 'key' | 'value', value: string) {
const next = [...props.modelValue]
next[index] = { ...next[index], [field]: value }
emit('update:modelValue', next)
}
function remove(index: number) {
const next = [...props.modelValue]
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<el-drawer
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
size="560px"
direction="rtl"
:with-header="false"
class="marketplace-drawer"
>
<div class="flex flex-col h-full bg-[#f3f1e9]">
<div class="px-7 py-6 border-b border-black/10">
<h2 class="text-[24px] font-serif text-[#171717] font-normal tracking-tight">{{ t('skills.marketplace.installDialogTitle') }}</h2>
<p class="mt-1 text-[13px] text-[#171717]/70">{{ t('skills.marketplace.installDialogSubtitle') }}</p>
<div class="mt-4 flex flex-col md:flex-row gap-2">
<div class="relative flex items-center bg-black/5 rounded-xl px-3 py-2 border border-black/10 flex-1">
<RiSearchLine class="h-4 w-4 shrink-0 text-[#525866]" />
<input
:value="query"
@input="$emit('update:query', ($event.target as HTMLInputElement).value)"
:placeholder="t('skills.searchMarketplace')"
class="ml-2 bg-transparent outline-none w-full font-normal placeholder:text-[#171717]/50 text-[13px] text-[#171717]"
/>
<button v-if="query" @click="$emit('update:query', '')" class="text-[#171717]/50 hover:text-[#171717] shrink-0 ml-1">
<RiCloseLine class="h-3.5 w-3.5" />
</button>
</div>
<el-button disabled class="!h-10 !rounded-xl !border-black/10 !bg-transparent !text-[#525866]">
{{ t('skills.marketplace.sourceLabel') }}: {{ t('skills.marketplace.sourceClawHub') }}
</el-button>
</div>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<div v-if="searchError" class="mb-4 p-4 rounded-xl border border-red-500/50 bg-red-500/10 text-red-600 text-sm font-medium flex items-center gap-2">
<RiErrorWarningLine class="h-5 w-5 shrink-0" />
<span>
{{ SEARCH_ERROR_CODES.has(searchError.replace('Error: ', '')) ? String(t(`skills.toast.${searchError.replace('Error: ', '')}`, { path: skillsDirPath })) : t('skills.marketplace.searchError') }}
</span>
</div>
<div v-if="searching" class="flex flex-col items-center justify-center py-20 text-[#525866]">
<RiRefreshLine class="h-8 w-8 animate-spin mb-4" />
<p class="text-sm">{{ t('skills.marketplace.searching') }}</p>
</div>
<div v-else-if="searchResults.length > 0" class="flex flex-col gap-1">
<div
v-for="skill in searchResults"
:key="skill.slug"
class="group flex flex-row items-center justify-between py-3.5 px-3 rounded-xl hover:bg-black/5 transition-colors cursor-pointer border-b border-black/5 last:border-0"
@click="handleOpenExternal(skill.slug)"
>
<div class="flex items-start gap-4 flex-1 overflow-hidden pr-4">
<div class="h-10 w-10 shrink-0 flex items-center justify-center text-xl bg-black/5 border border-black/5 rounded-xl overflow-hidden">
📦
</div>
<div class="flex flex-col overflow-hidden">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-[15px] font-semibold text-[#171717] truncate">{{ skill.name }}</h3>
<span v-if="skill.author" class="text-xs text-[#525866]"> {{ skill.author }}</span>
</div>
<p class="text-[13.5px] text-[#525866] line-clamp-1 pr-6 leading-relaxed">
{{ skill.description }}
</p>
</div>
</div>
<div class="flex items-center gap-4 shrink-0" @click.stop>
<span v-if="skill.version" class="text-[13px] font-mono text-[#525866] mr-2">
v{{ skill.version }}
</span>
<template v-if="isInstalled(skill)">
<el-button type="danger" size="small" class="!h-8" :loading="!!installing[skill.slug]" @click="$emit('uninstall', skill.slug)">
<RiDeleteBinLine v-if="!installing[skill.slug]" class="h-3.5 w-3.5" />
</el-button>
</template>
<template v-else>
<el-button type="primary" size="small" class="!h-8 !px-4 !rounded-full !font-medium !text-xs" :loading="!!installing[skill.slug]" @click="$emit('install', skill.slug)">
<span v-if="!installing[skill.slug]">{{ t('skills.marketplace.install', 'Install') }}</span>
</el-button>
</template>
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center py-20 text-[#525866]">
<RiArchiveLine class="h-10 w-10 mb-4 opacity-50" />
<p>{{ query.trim() ? t('skills.marketplace.noResults') : t('skills.marketplace.emptyPrompt') }}</p>
</div>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { MarketplaceSkill, Skill } from '@src/lib/skills-types'
import { SEARCH_ERROR_CODES } from '@src/store/skills'
import { apiGetSkillsDir } from '@src/lib/skills-api'
import { RiSearchLine, RiCloseLine, RiErrorWarningLine, RiRefreshLine, RiDeleteBinLine, RiArchiveLine } from '@remixicon/vue'
const props = defineProps<{
modelValue: boolean
query: string
searchResults: MarketplaceSkill[]
searching: boolean
searchError: string | null
installing: Record<string, boolean>
installedSkills: Skill[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:query': [value: string]
install: [slug: string]
uninstall: [slug: string]
}>()
const { t } = useI18n()
const skillsDirPath = ref('~/.zn-ai/skills')
watch(() => props.modelValue, async (open) => {
if (open) {
skillsDirPath.value = await apiGetSkillsDir()
}
})
function isInstalled(skill: MarketplaceSkill) {
return props.installedSkills.some(s => s.id === skill.slug || s.name === skill.name)
}
function handleOpenExternal(slug: string) {
window.api.external.open(`https://clawhub.ai/s/${slug}`)
}
</script>
<style scoped>
:deep(.marketplace-drawer .el-drawer__body) {
padding: 0;
}
</style>

View File

@@ -0,0 +1,250 @@
<template>
<el-drawer
:model-value="isOpen"
@update:model-value="(v: boolean) => !v && $emit('close')"
size="450px"
direction="rtl"
:with-header="false"
class="skill-detail-drawer"
>
<div v-if="skill" class="flex flex-col h-full bg-[#f3f1e9]">
<div class="flex-1 overflow-y-auto px-8 py-10">
<div class="flex flex-col items-center mb-8">
<div class="w-16 h-16 flex items-center justify-center rounded-full bg-white border border-black/5 shrink-0 mb-4 relative shadow-sm">
<span class="text-3xl">{{ skill.icon || '🔧' }}</span>
<div v-if="skill.isCore" class="absolute -bottom-1 -right-1 bg-[#f3f1e9] rounded-full p-1 shadow-sm border border-black/5">
<RiLockLine class="h-3 w-3 text-[#525866] shrink-0" />
</div>
</div>
<h2 class="text-[28px] font-serif text-[#171717] font-normal mb-3 text-center tracking-tight">
{{ skill.name }}
</h2>
<div class="flex items-center justify-center gap-2.5 mb-6 opacity-80">
<el-tag type="info" effect="plain" class="!rounded-full !bg-black/[0.04] !border-0 !text-[#171717]/70 !px-3 !py-0.5 !text-[11px] !font-mono">
v{{ skill.version || '1.0.0' }}
</el-tag>
<el-tag type="info" effect="plain" class="!rounded-full !bg-black/[0.04] !border-0 !text-[#171717]/70 !px-3 !py-0.5 !text-[11px]">
{{ skill.isCore ? t('skills.detail.coreSystem') : skill.isBundled ? t('skills.detail.bundled') : t('skills.detail.userInstalled') }}
</el-tag>
</div>
<p v-if="skill.description" class="text-[14px] text-[#171717]/70 font-medium leading-[1.6] text-center px-4">
{{ skill.description }}
</p>
</div>
<div class="space-y-7 px-1">
<div class="space-y-2">
<h3 class="text-[13px] font-bold text-[#171717]/80">{{ t('skills.detail.source') }}</h3>
<div class="flex items-center gap-2 flex-wrap">
<el-tag type="info" effect="plain" class="!rounded-full !bg-black/[0.04] !border-0 !text-[#171717]/70 !px-3 !py-0.5 !text-[11px]">
{{ resolvedSource }}
</el-tag>
</div>
<div class="flex items-center gap-2">
<el-input
:model-value="skill.baseDir || t('skills.detail.pathUnavailable')"
readonly
class="flex-1 !h-[38px] font-mono text-[12px] bg-[#eeece3] border-black/10 rounded-xl text-[#171717]/70"
/>
<el-button
class="!h-[38px] !w-[38px] !px-0"
@click="handleCopyPath"
:disabled="!skill.baseDir"
:title="t('skills.detail.copyPath')"
>
<RiFileCopyLine class="h-3.5 w-3.5" />
</el-button>
<el-button
class="!h-[38px] !w-[38px] !px-0"
@click="handleOpenFolder"
:disabled="!skill.baseDir"
:title="t('skills.detail.openActualFolder')"
>
<RiFolderOpenLine class="h-3.5 w-3.5" />
</el-button>
</div>
</div>
<div v-if="!skill.isCore" class="space-y-2">
<h3 class="text-[13px] font-bold flex items-center gap-2 text-[#171717]/80">
<RiKeyLine class="h-3.5 w-3.5 text-blue-500" />
{{ t('skills.detail.apiKey') }}
</h3>
<el-input
v-model="apiKey"
:placeholder="t('skills.detail.apiKeyPlaceholder', 'Enter API Key (optional)')"
type="password"
show-password
class="!h-[44px]"
/>
<p class="text-[12px] text-[#171717]/50 mt-2 font-medium">
{{ t('skills.detail.apiKeyDesc', 'The primary API key for this skill.') }}
</p>
</div>
<EnvVarManager v-if="!skill.isCore" v-model="envVars" />
<div v-if="skill.slug && !skill.isBundled && !skill.isCore" class="flex gap-2 justify-center pt-8">
<el-button size="small" class="!h-[28px] !text-[11px] !font-medium !px-3 !gap-1.5 !rounded-full !border-black/10 !bg-transparent hover:!bg-black/5" @click="handleOpenClawhub">
<RiGlobalLine class="h-3 w-3" />
ClawHub
</el-button>
<el-button size="small" class="!h-[28px] !text-[11px] !font-medium !px-3 !gap-1.5 !rounded-full !border-black/10 !bg-transparent hover:!bg-black/5" @click="handleOpenEditor">
<RiFileCodeLine class="h-3 w-3" />
{{ t('skills.detail.openManual') }}
</el-button>
</div>
</div>
<div class="pt-8 pb-4 flex items-center justify-center gap-4 w-full px-2 max-w-[340px] mx-auto">
<el-button
v-if="!skill.isCore"
type="primary"
class="flex-1 !h-[42px] !text-[13px] !rounded-full !font-semibold"
:loading="isSaving"
@click="handleSaveConfig"
>
{{ isSaving ? t('skills.detail.saving') : t('skills.detail.saveConfig') }}
</el-button>
<el-button
v-if="!skill.isCore"
class="flex-1 !h-[42px] !text-[13px] !rounded-full !font-semibold !border-black/20 hover:!bg-black/5"
@click="handleSecondaryAction"
>
{{ secondaryLabel }}
</el-button>
</div>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Skill } from '@src/lib/skills-types'
import { apiUpdateSkillConfig, apiOpenSkillPath, apiOpenSkillReadme } from '@src/lib/skills-api'
import { resolveSkillSourceLabel } from '@src/lib/skills-utils'
import { ElMessage } from 'element-plus'
import { RiLockLine, RiKeyLine, RiFileCopyLine, RiFolderOpenLine, RiGlobalLine, RiFileCodeLine } from '@remixicon/vue'
import EnvVarManager from './EnvVarManager.vue'
import { useSkillsStore } from '@src/store/skills'
const props = defineProps<{
skill: Skill | null
isOpen: boolean
}>()
const emit = defineEmits<{
close: []
toggle: [enabled: boolean]
uninstall: [slug: string]
}>()
const { t } = useI18n()
const store = useSkillsStore()
const apiKey = ref('')
const envVars = ref<Array<{ key: string; value: string }>>([])
const isSaving = ref(false)
watch(() => props.skill, (s) => {
if (!s) return
apiKey.value = String(s.config?.apiKey || '')
if (s.config?.env && typeof s.config.env === 'object') {
envVars.value = Object.entries(s.config.env).map(([k, v]) => ({ key: k, value: String(v) }))
} else {
envVars.value = []
}
}, { immediate: true })
const resolvedSource = computed(() => {
if (!props.skill) return ''
return resolveSkillSourceLabel(props.skill, t)
})
const secondaryLabel = computed(() => {
const s = props.skill
if (!s) return ''
if (!s.isBundled) {
return t('skills.detail.uninstall', 'Uninstall')
}
return s.enabled ? t('skills.detail.disable', 'Disable') : t('skills.detail.enable', 'Enable')
})
function handleOpenClawhub() {
if (!props.skill?.slug) return
window.api.external.open(`https://clawhub.ai/s/${props.skill.slug}`)
}
async function handleOpenEditor() {
if (!props.skill) return
try {
await apiOpenSkillReadme(props.skill.id, props.skill.slug, props.skill.baseDir)
ElMessage.success(t('skills.toast.openedEditor'))
} catch (err) {
ElMessage.error(t('skills.toast.failedEditor') + ': ' + String(err))
}
}
async function handleCopyPath() {
if (!props.skill?.baseDir) return
try {
await navigator.clipboard.writeText(props.skill.baseDir)
ElMessage.success(t('skills.toast.copiedPath'))
} catch (err) {
ElMessage.error(t('skills.toast.failedCopyPath') + ': ' + String(err))
}
}
async function handleOpenFolder() {
if (!props.skill) return
try {
await apiOpenSkillPath(props.skill.id, props.skill.slug, props.skill.baseDir)
} catch (err) {
ElMessage.error(t('skills.toast.failedOpenActualFolder') + ': ' + String(err))
}
}
async function handleSaveConfig() {
if (isSaving.value || !props.skill) return
isSaving.value = true
try {
const envObj = envVars.value.reduce((acc, curr) => {
const k = curr.key.trim()
if (k) acc[k] = curr.value.trim()
return acc
}, {} as Record<string, string>)
await apiUpdateSkillConfig(props.skill.id, {
apiKey: apiKey.value || '',
env: envObj,
})
await store.fetchSkills()
ElMessage.success(t('skills.detail.configSaved'))
} catch (err) {
ElMessage.error(t('skills.toast.failedSave') + ': ' + String(err))
} finally {
isSaving.value = false
}
}
function handleSecondaryAction() {
const s = props.skill
if (!s) return
if (!s.isBundled) {
emit('uninstall', s.slug || s.id)
emit('close')
} else {
emit('toggle', !s.enabled)
}
}
</script>
<style scoped>
:deep(.skill-detail-drawer .el-drawer__body) {
padding: 0;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div
class="group flex flex-row items-center justify-between py-3.5 px-3 rounded-xl hover:bg-black/5 transition-colors cursor-pointer border-b border-black/5 last:border-0"
@click="$emit('click')"
>
<div class="flex items-start gap-4 flex-1 overflow-hidden pr-4">
<div class="h-10 w-10 shrink-0 flex items-center justify-center text-2xl bg-black/5 border border-black/5 rounded-xl overflow-hidden">
{{ skill.icon || '🧩' }}
</div>
<div class="flex flex-col overflow-hidden min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-[15px] font-semibold text-[#171717] truncate">{{ skill.name }}</h3>
<RiLockLine v-if="skill.isCore" class="h-3 w-3 text-[#525866] shrink-0" />
<RiPuzzleLine v-else-if="skill.isBundled" class="h-3 w-3 text-blue-500/70 shrink-0" />
<span v-if="skill.slug && skill.slug !== skill.name" class="text-[11px] font-mono px-1.5 py-0.5 rounded border border-black/10 text-[#525866] shrink-0">
{{ skill.slug }}
</span>
</div>
<p class="text-[13.5px] text-[#525866] line-clamp-1 pr-6 leading-relaxed">
{{ skill.description }}
</p>
<div class="mt-1 flex items-center gap-2 text-[11px] text-[#525866]/80">
<el-tag size="small" effect="plain" class="!rounded-full !bg-black/5 !border-0 !text-[#525866] !h-5 !px-1.5 !text-[10px]">
{{ resolvedSource }}
</el-tag>
<span class="truncate font-mono">
{{ skill.baseDir || t('skills.detail.pathUnavailable') }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-6 shrink-0" @click.stop>
<span v-if="skill.version" class="text-[13px] font-mono text-[#525866]">
v{{ skill.version }}
</span>
<el-switch
:model-value="skill.enabled"
:disabled="skill.isCore"
@change="(val: boolean) => $emit('toggle', val)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Skill } from '@src/lib/skills-types'
import { resolveSkillSourceLabel } from '@src/lib/skills-utils'
import { RiLockLine, RiPuzzleLine } from '@remixicon/vue'
const props = defineProps<{
skill: Skill
}>()
defineEmits<{
click: []
toggle: [enabled: boolean]
}>()
const { t } = useI18n()
const resolvedSource = computed(() => resolveSkillSourceLabel(props.skill, t))
</script>

View File

@@ -1,11 +1,345 @@
<template>
<layout>
<div class="bg-white box-border w-full h-full flex rounded-[16px]">
<layout>
<div class="bg-[#f9f7f0] box-border w-full h-full flex rounded-[16px] overflow-hidden">
<div class="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-12">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-start justify-between mb-6 shrink-0 gap-4">
<div>
<h1 class="text-5xl md:text-6xl font-serif text-[#171717] mb-3 font-normal tracking-tight" style="font-family: Georgia, Cambria, 'Times New Roman', Times, serif">
{{ t('skills.title') }}
</h1>
<p class="text-[17px] text-[#171717]/70 font-medium">
{{ t('skills.subtitle') }}
</p>
</div>
<div class="flex items-center gap-3 md:mt-2">
<button
v-if="hasInstalledSkills"
@click="handleOpenSkillsFolder"
class="hover:bg-black/5 transition-colors shrink-0 text-[13px] font-medium px-4 h-8 rounded-full border border-black/10 flex items-center justify-center text-[#171717]/80 hover:text-[#171717]"
>
<RiFolderOpenLine class="h-4 w-4 mr-2" />
{{ t('skills.openFolder') }}
</button>
</div>
</div>
<!-- Sub Navigation and Actions -->
<div class="flex flex-col md:flex-row md:items-center justify-between border-b border-black/10 pb-4 mb-4 shrink-0 gap-4">
<div class="flex items-center flex-wrap gap-4 text-[14px]">
<div class="relative group flex items-center bg-black/5 rounded-full px-3 py-1.5 focus-within:bg-black/10 transition-colors border border-transparent focus-within:border-black/10 mr-2">
<RiSearchLine class="h-4 w-4 shrink-0 text-[#525866]" />
<input
:placeholder="t('skills.search')"
v-model="searchQuery"
class="ml-2 bg-transparent outline-none w-28 md:w-40 font-normal placeholder:text-[#171717]/50 text-[13px] text-[#171717]"
/>
<button
v-if="searchQuery"
type="button"
@click="searchQuery = ''"
class="text-[#171717]/50 hover:text-[#171717] shrink-0 ml-1"
>
<RiCloseLine class="h-3.5 w-3.5" />
</button>
</div>
<div class="flex items-center gap-6">
<button
@click="selectedSource = 'all'"
:class="['font-medium transition-colors flex items-center gap-1.5', selectedSource === 'all' ? 'text-[#171717]' : 'text-[#525866] hover:text-[#171717]']"
>
{{ t('skills.filter.all', { count: sourceStats.all }) }}
</button>
<button
@click="selectedSource = 'built-in'"
:class="['font-medium transition-colors flex items-center gap-1.5', selectedSource === 'built-in' ? 'text-[#171717]' : 'text-[#525866] hover:text-[#171717]']"
>
{{ t('skills.filter.builtIn', { count: sourceStats.builtIn }) }}
</button>
<button
@click="selectedSource = 'marketplace'"
:class="['font-medium transition-colors flex items-center gap-1.5', selectedSource === 'marketplace' ? 'text-[#171717]' : 'text-[#525866] hover:text-[#171717]']"
>
{{ t('skills.filter.marketplace', { count: sourceStats.marketplace }) }}
</button>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<el-button
size="small"
@click="bulkToggleVisible(true)"
class="!h-8 !text-[13px] !font-medium !rounded-md !px-3 !border-black/10 !bg-transparent hover:!bg-black/5"
>
{{ t('skills.actions.enableVisible') }}
</el-button>
<el-button
size="small"
@click="bulkToggleVisible(false)"
class="!h-8 !text-[13px] !font-medium !rounded-md !px-3 !border-black/10 !bg-transparent hover:!bg-black/5"
>
{{ t('skills.actions.disableVisible') }}
</el-button>
<el-button
size="small"
@click="openMarketplace"
class="!h-8 !text-[13px] !font-medium !rounded-md !px-3 !border-black/10 !bg-transparent hover:!bg-black/5"
>
{{ t('skills.actions.installSkill') }}
</el-button>
<el-button
size="small"
@click="store.fetchSkills()"
class="!h-8 !w-8 !ml-1 !rounded-md !border-black/10 !bg-transparent hover:!bg-black/5 !text-[#525866] hover:!text-[#171717]"
:title="t('skills.refresh')"
>
<RiRefreshLine :class="['h-4 w-4', store.loading && 'animate-spin']" />
</el-button>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2">
<div v-if="store.error" class="mb-4 p-4 rounded-xl border border-red-500/50 bg-red-500/10 text-red-600 text-sm font-medium flex items-center gap-2">
<RiErrorWarningLine class="h-5 w-5 shrink-0" />
<span>
{{ FETCH_ERROR_CODES.has(store.error) ? String(t(`skills.toast.${store.error}`, { path: skillsDirPath })) : store.error }}
</span>
</div>
<div v-if="store.loading && store.skills.length === 0" class="flex flex-col items-center justify-center h-full text-[#525866]">
<RiRefreshLine class="h-10 w-10 animate-spin mb-4" />
<p>{{ t('skills.marketplace.searching') }}</p>
</div>
<div v-else class="flex flex-col gap-1">
<template v-if="filteredSkills.length === 0">
<div class="flex flex-col items-center justify-center py-20 text-[#525866]">
<RiPuzzleLine class="h-10 w-10 mb-4 opacity-50" />
<p>{{ searchQuery ? t('skills.noSkillsSearch') : t('skills.noSkillsAvailable') }}</p>
</div>
</template>
<template v-else>
<SkillListItem
v-for="skill in filteredSkills"
:key="skill.id"
:skill="skill"
@click="selectedSkill = skill"
@toggle="(enabled) => handleToggle(skill.id, enabled)"
/>
</template>
</div>
</div>
</div>
</div>
<MarketplaceDrawer
v-model="installDrawerOpen"
:query="installQuery"
@update:query="installQuery = $event"
:search-results="store.searchResults"
:searching="store.searching"
:search-error="store.searchError"
:installing="store.installing"
:installed-skills="store.skills"
@install="handleInstall"
@uninstall="handleUninstall"
/>
<SkillDetailDrawer
:skill="selectedSkill"
:is-open="!!selectedSkill"
@close="selectedSkill = null"
@toggle="(enabled) => {
if (!selectedSkill) return
handleToggle(selectedSkill.id, enabled)
selectedSkill.enabled = enabled
}"
@uninstall="(slug) => handleUninstall(slug)"
/>
</layout>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSkillsStore, FETCH_ERROR_CODES, INSTALL_ERROR_CODES } from '@src/store/skills'
import { apiGetSkillsDir, apiOpenSkillsDir } from '@src/lib/skills-api'
import type { Skill } from '@src/lib/skills-types'
import { ElMessage } from 'element-plus'
import {
RiSearchLine,
RiPuzzleLine,
RiCloseLine,
RiErrorWarningLine,
RiRefreshLine,
RiFolderOpenLine,
} from '@remixicon/vue'
import SkillListItem from './components/SkillListItem.vue'
import SkillDetailDrawer from './components/SkillDetailDrawer.vue'
import MarketplaceDrawer from './components/MarketplaceDrawer.vue'
<style scoped lang="scss"></style>
const { t } = useI18n()
const store = useSkillsStore()
const searchQuery = ref('')
const installQuery = ref('')
const installDrawerOpen = ref(false)
const selectedSkill = ref<Skill | null>(null)
const selectedSource = ref<'all' | 'built-in' | 'marketplace'>('all')
const skillsDirPath = ref('~/.zn-ai/skills')
onMounted(async () => {
store.fetchSkills()
skillsDirPath.value = await apiGetSkillsDir()
})
const safeSkills = computed(() => (Array.isArray(store.skills) ? store.skills : []))
const filteredSkills = computed(() => {
const q = searchQuery.value.toLowerCase().trim()
const result = safeSkills.value.filter((skill: Skill) => {
const matchesSearch =
q.length === 0 ||
skill.name.toLowerCase().includes(q) ||
skill.description.toLowerCase().includes(q) ||
skill.id.toLowerCase().includes(q) ||
(skill.slug || '').toLowerCase().includes(q) ||
(skill.author || '').toLowerCase().includes(q)
let matchesSource = true
if (selectedSource.value === 'built-in') {
matchesSource = !!skill.isBundled
} else if (selectedSource.value === 'marketplace') {
matchesSource = !skill.isBundled
}
return matchesSearch && matchesSource
})
result.sort((a: Skill, b: Skill) => {
if (a.enabled && !b.enabled) return -1
if (!a.enabled && b.enabled) return 1
if (a.isCore && !b.isCore) return -1
if (!a.isCore && b.isCore) return 1
return a.name.localeCompare(b.name)
})
return result
})
const sourceStats = computed(() => store.sourceStats)
const hasInstalledSkills = computed(() => safeSkills.value.some((s) => !s.isBundled))
async function bulkToggleVisible(enable: boolean) {
const candidates = filteredSkills.value.filter((skill) => !skill.isCore && skill.enabled !== enable)
if (candidates.length === 0) {
ElMessage.info(enable ? t('skills.toast.noBatchEnableTargets') : t('skills.toast.noBatchDisableTargets'))
return
}
let succeeded = 0
for (const skill of candidates) {
try {
if (enable) {
await store.enableSkill(skill.id)
} else {
await store.disableSkill(skill.id)
}
succeeded += 1
} catch {
// Continue to next skill
}
}
if (succeeded === candidates.length) {
ElMessage.success(enable ? t('skills.toast.batchEnabled', { count: succeeded }) : t('skills.toast.batchDisabled', { count: succeeded }))
return
}
ElMessage.warning(t('skills.toast.batchPartial', { success: succeeded, total: candidates.length }))
}
async function handleToggle(skillId: string, enable: boolean) {
try {
if (enable) {
await store.enableSkill(skillId)
ElMessage.success(t('skills.toast.enabled'))
} else {
await store.disableSkill(skillId)
ElMessage.success(t('skills.toast.disabled'))
}
} catch (err) {
ElMessage.error(String(err))
}
}
async function handleOpenSkillsFolder() {
try {
await apiOpenSkillsDir()
ElMessage.success(t('skills.toast.copiedPath'))
} catch (err) {
const msg = String(err)
if (msg.toLowerCase().includes('copied')) {
ElMessage.info(msg)
} else {
ElMessage.error(t('skills.toast.failedOpenFolder') + ': ' + msg)
}
}
}
function openMarketplace() {
installQuery.value = ''
installDrawerOpen.value = true
}
watch(installDrawerOpen, (open) => {
if (open) {
store.searchSkills('')
}
})
watch(installQuery, (q) => {
if (!installDrawerOpen.value) return
const query = q.trim()
if (query.length === 0) {
store.searchSkills('')
return
}
// debounce 300ms handled by watch pattern isn't ideal without clearTimeout,
// but for simplicity we call directly. In a real app use useDebounceFn from vueuse.
setTimeout(() => {
store.searchSkills(query)
}, 300)
})
async function handleInstall(slug: string) {
try {
await store.installSkill(slug)
await store.enableSkill(slug)
ElMessage.success(t('skills.toast.installed'))
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
if (INSTALL_ERROR_CODES.has(errorMessage)) {
ElMessage({ type: 'error', message: String(t(`skills.toast.${errorMessage}`, { path: skillsDirPath.value })), duration: 10000 })
} else {
ElMessage.error(t('skills.toast.failedInstall') + ': ' + errorMessage)
}
}
}
async function handleUninstall(slug: string) {
try {
await store.uninstallSkill(slug)
ElMessage.success(t('skills.toast.uninstalled'))
} catch (err) {
ElMessage.error(t('skills.toast.failedUninstall') + ': ' + String(err))
}
}
</script>
<style scoped>
/* scrollbar styling is globally handled in tailwind.css */
</style>