feat: 新增技能功能

This commit is contained in:
duanshuwen
2026-04-11 10:55:11 +08:00
parent 825fe36967
commit b1ca06bb07
5 changed files with 84 additions and 92 deletions

View File

@@ -9,9 +9,9 @@
"marketplace": "Marketplace" "marketplace": "Marketplace"
}, },
"filter": { "filter": {
"all": "All ({{count}})", "all": "All ({count})",
"builtIn": "Built-in ({{count}})", "builtIn": "Built-in ({count})",
"marketplace": "Marketplace ({{count}})" "marketplace": "Marketplace ({count})"
}, },
"search": "Search skills...", "search": "Search skills...",
"searchMarketplace": "Search marketplace...", "searchMarketplace": "Search marketplace...",
@@ -83,17 +83,17 @@
"copiedPath": "Path copied", "copiedPath": "Path copied",
"failedCopyPath": "Failed to copy path", "failedCopyPath": "Failed to copy path",
"failedOpenActualFolder": "Failed to open actual skill folder", "failedOpenActualFolder": "Failed to open actual skill folder",
"searchTimeoutError": "Search timed out, check network. You can also search on ClawHub.ai, download the ZIP, and extract it to \"{{path}}\"", "searchTimeoutError": "Search timed out, check network. You can also search on ClawHub.ai, download the ZIP, and extract it to \"{path}\"",
"installTimeoutError": "Installation timed out, check network. You can also download the ZIP from ClawHub.ai and extract it to \"{{path}}\"", "installTimeoutError": "Installation timed out, check network. You can also download the ZIP from ClawHub.ai and extract it to \"{path}\"",
"searchRateLimitError": "Search rate limit exceeded. You can also search on ClawHub.ai, download the ZIP, and extract it to \"{{path}}\"", "searchRateLimitError": "Search rate limit exceeded. You can also search on ClawHub.ai, download the ZIP, and extract it to \"{path}\"",
"installRateLimitError": "Installation rate limit exceeded. You can also download the ZIP from ClawHub.ai and extract it to \"{{path}}\"", "installRateLimitError": "Installation rate limit exceeded. You can also download the ZIP from ClawHub.ai and extract it to \"{path}\"",
"fetchTimeoutError": "Fetching skills timed out, please check your network connection.", "fetchTimeoutError": "Fetching skills timed out, please check your network connection.",
"fetchRateLimitError": "Fetching skills rate limit exceeded, please try again later.", "fetchRateLimitError": "Fetching skills rate limit exceeded, please try again later.",
"noBatchEnableTargets": "All visible skills are already enabled.", "noBatchEnableTargets": "All visible skills are already enabled.",
"noBatchDisableTargets": "All visible skills are already disabled.", "noBatchDisableTargets": "All visible skills are already disabled.",
"batchEnabled": "{{count}} skills enabled.", "batchEnabled": "{count} skills enabled.",
"batchDisabled": "{{count}} skills disabled.", "batchDisabled": "{count} skills disabled.",
"batchPartial": "Updated {{success}} / {{total}} skills. Some items failed." "batchPartial": "Updated {success} / {total} skills. Some items failed."
}, },
"marketplace": { "marketplace": {
"title": "Marketplace", "title": "Marketplace",
@@ -102,7 +102,7 @@
"sourceLabel": "Source", "sourceLabel": "Source",
"sourceClawHub": "ClawHub", "sourceClawHub": "ClawHub",
"securityNote": "Click skill card to view its documentation and security information on ClawHub before installation.", "securityNote": "Click skill card to view its documentation and security information on ClawHub before installation.",
"manualInstallHint": "Network issues? You can always download skill ZIP archives from ClawHub.ai and extract them manually into \"{{path}}\".", "manualInstallHint": "Network issues? You can always download skill ZIP archives from ClawHub.ai and extract them manually into \"{path}\".",
"searching": "Searching ClawHub...", "searching": "Searching ClawHub...",
"noResults": "No skills found matching your search.", "noResults": "No skills found matching your search.",
"emptyPrompt": "Search for new skills to expand your capabilities.", "emptyPrompt": "Search for new skills to expand your capabilities.",

View File

@@ -9,9 +9,9 @@
"marketplace": "マーケットプレイス" "marketplace": "マーケットプレイス"
}, },
"filter": { "filter": {
"all": "すべて ({{count}})", "all": "すべて ({count})",
"builtIn": "内蔵 ({{count}})", "builtIn": "内蔵 ({count})",
"marketplace": "マーケットプレイス ({{count}})" "marketplace": "マーケットプレイス ({count})"
}, },
"search": "スキルを検索...", "search": "スキルを検索...",
"searchMarketplace": "マーケットプレイスを検索...", "searchMarketplace": "マーケットプレイスを検索...",
@@ -83,17 +83,17 @@
"copiedPath": "パスをコピーしました", "copiedPath": "パスをコピーしました",
"failedCopyPath": "パスのコピーに失敗しました", "failedCopyPath": "パスのコピーに失敗しました",
"failedOpenActualFolder": "スキルの実際のフォルダを開けませんでした", "failedOpenActualFolder": "スキルの実際のフォルダを開けませんでした",
"searchTimeoutError": "検索がタイムアウトしました。ClawHub.aiで検索してZIPをダウンロードし、\"{{path}}\" に展開することも可能です", "searchTimeoutError": "検索がタイムアウトしました。ClawHub.aiで検索してZIPをダウンロードし、\"{path}\" に展開することも可能です",
"installTimeoutError": "インストールがタイムアウトしました。ClawHub.aiでZIPをダウンロードし、\"{{path}}\" に展開することも可能です", "installTimeoutError": "インストールがタイムアウトしました。ClawHub.aiでZIPをダウンロードし、\"{path}\" に展開することも可能です",
"searchRateLimitError": "検索リクエストの制限を超過しました。ClawHub.aiで検索してZIPをダウンロードし、\"{{path}}\" に展開することも可能です", "searchRateLimitError": "検索リクエストの制限を超過しました。ClawHub.aiで検索してZIPをダウンロードし、\"{path}\" に展開することも可能です",
"installRateLimitError": "インストールリクエストの制限を超過しました。ClawHub.aiからZIPをダウンロードし、\"{{path}}\" に展開することも可能です", "installRateLimitError": "インストールリクエストの制限を超過しました。ClawHub.aiからZIPをダウンロードし、\"{path}\" に展開することも可能です",
"fetchTimeoutError": "スキルリストの取得がタイムアウトしました。ネットワークを確認してください。", "fetchTimeoutError": "スキルリストの取得がタイムアウトしました。ネットワークを確認してください。",
"fetchRateLimitError": "スキルリスト取得のリクエスト制限を超過しました。後でお試しください。", "fetchRateLimitError": "スキルリスト取得のリクエスト制限を超過しました。後でお試しください。",
"noBatchEnableTargets": "表示中のスキルはすべて有効です。", "noBatchEnableTargets": "表示中のスキルはすべて有効です。",
"noBatchDisableTargets": "表示中のスキルはすべて無効です。", "noBatchDisableTargets": "表示中のスキルはすべて無効です。",
"batchEnabled": "{{count}} 件のスキルを有効化しました。", "batchEnabled": "{count} 件のスキルを有効化しました。",
"batchDisabled": "{{count}} 件のスキルを無効化しました。", "batchDisabled": "{count} 件のスキルを無効化しました。",
"batchPartial": "{{success}} / {{total}} 件を更新しました。一部失敗しています。" "batchPartial": "{success} / {total} 件を更新しました。一部失敗しています。"
}, },
"marketplace": { "marketplace": {
"title": "マーケットプレイス", "title": "マーケットプレイス",
@@ -102,7 +102,7 @@
"sourceLabel": "ソース", "sourceLabel": "ソース",
"sourceClawHub": "ClawHub", "sourceClawHub": "ClawHub",
"securityNote": "インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。", "securityNote": "インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。",
"manualInstallHint": "ネットワークに問題がありますかいつでもClawHub.aiからスキルのZIPをダウンロードし、手動で \"{{path}}\" に展開してインストールできます。", "manualInstallHint": "ネットワークに問題がありますかいつでもClawHub.aiからスキルのZIPをダウンロードし、手動で \"{path}\" に展開してインストールできます。",
"searching": "ClawHubを検索中...", "searching": "ClawHubを検索中...",
"noResults": "検索に一致するスキルが見つかりません。", "noResults": "検索に一致するスキルが見つかりません。",
"emptyPrompt": "新しいスキルを検索して機能を拡張しましょう。", "emptyPrompt": "新しいスキルを検索して機能を拡張しましょう。",

View File

@@ -9,9 +9,9 @@
"marketplace": "市场" "marketplace": "市场"
}, },
"filter": { "filter": {
"all": "全部 ({{count}})", "all": "全部 ({count})",
"builtIn": "内置 ({{count}})", "builtIn": "内置 ({count})",
"marketplace": "市场 ({{count}})" "marketplace": "市场 ({count})"
}, },
"search": "搜索技能...", "search": "搜索技能...",
"searchMarketplace": "搜索市场...", "searchMarketplace": "搜索市场...",
@@ -83,17 +83,17 @@
"copiedPath": "路径已复制", "copiedPath": "路径已复制",
"failedCopyPath": "复制路径失败", "failedCopyPath": "复制路径失败",
"failedOpenActualFolder": "打开技能实际目录失败", "failedOpenActualFolder": "打开技能实际目录失败",
"searchTimeoutError": "搜索超时,请检查网络。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{{path}}\"", "searchTimeoutError": "搜索超时,请检查网络。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{path}\"",
"installTimeoutError": "安装超时,请检查网络。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{{path}}\"", "installTimeoutError": "安装超时,请检查网络。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{path}\"",
"searchRateLimitError": "搜索请求过于频繁。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{{path}}\"", "searchRateLimitError": "搜索请求过于频繁。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{path}\"",
"installRateLimitError": "安装请求过于频繁。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{{path}}\"", "installRateLimitError": "安装请求过于频繁。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{path}\"",
"fetchTimeoutError": "获取技能列表超时,请检查网络。", "fetchTimeoutError": "获取技能列表超时,请检查网络。",
"fetchRateLimitError": "获取技能列表请求过于频繁,请稍后再试。", "fetchRateLimitError": "获取技能列表请求过于频繁,请稍后再试。",
"noBatchEnableTargets": "当前可见技能都已启用。", "noBatchEnableTargets": "当前可见技能都已启用。",
"noBatchDisableTargets": "当前可见技能都已禁用。", "noBatchDisableTargets": "当前可见技能都已禁用。",
"batchEnabled": "已启用 {{count}} 个技能。", "batchEnabled": "已启用 {count} 个技能。",
"batchDisabled": "已禁用 {{count}} 个技能。", "batchDisabled": "已禁用 {count} 个技能。",
"batchPartial": "已更新 {{success}} / {{total}} 个技能,部分操作失败。" "batchPartial": "已更新 {success} / {total} 个技能,部分操作失败。"
}, },
"marketplace": { "marketplace": {
"title": "市场", "title": "市场",
@@ -102,7 +102,7 @@
"sourceLabel": "来源", "sourceLabel": "来源",
"sourceClawHub": "ClawHub", "sourceClawHub": "ClawHub",
"securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。", "securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。",
"manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。", "manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{path}\" 目录来完成手动安装。",
"searching": "正在搜索 ClawHub...", "searching": "正在搜索 ClawHub...",
"noResults": "未找到匹配的技能。", "noResults": "未找到匹配的技能。",
"emptyPrompt": "搜索新技能以扩展您的能力。", "emptyPrompt": "搜索新技能以扩展您的能力。",

View File

@@ -2,7 +2,7 @@ import { hostApiFetch } from './host-api';
import type { Skill, MarketplaceSkill } from './skills-types'; import type { Skill, MarketplaceSkill } from './skills-types';
// Mock data for UI development when backend is not ready // Mock data for UI development when backend is not ready
const MOCK_SKILLS: Skill[] = [ export const MOCK_SKILLS: Skill[] = [
{ id: '1password', slug: '1password', name: '1password', description: 'Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in...', enabled: true, icon: '🔐', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/1password' }, { id: '1password', slug: '1password', name: '1password', description: 'Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in...', enabled: true, icon: '🔐', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/1password' },
{ id: 'apple-notes', slug: 'apple-notes', name: 'apple-notes', description: 'Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes...', enabled: true, icon: '📝', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/apple-notes' }, { id: 'apple-notes', slug: 'apple-notes', name: 'apple-notes', description: 'Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes...', enabled: true, icon: '📝', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/apple-notes' },
{ id: 'apple-reminders', slug: 'apple-reminders', name: 'apple-reminders', description: 'Manage Apple Reminders via remindctl CLI (list, add, edit, complete, delete). Supports lists, date filters, and...', enabled: true, icon: '⏰', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/apple-reminders' }, { id: 'apple-reminders', slug: 'apple-reminders', name: 'apple-reminders', description: 'Manage Apple Reminders via remindctl CLI (list, add, edit, complete, delete). Supports lists, date filters, and...', enabled: true, icon: '⏰', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/apple-reminders' },
@@ -15,7 +15,7 @@ const MOCK_SKILLS: Skill[] = [
{ id: 'memory', slug: 'memory', name: 'memory', description: 'Store and retrieve long-term memories.', enabled: true, icon: '🧠', version: '1.0.0', isCore: true, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/memory' }, { id: 'memory', slug: 'memory', name: 'memory', description: 'Store and retrieve long-term memories.', enabled: true, icon: '🧠', version: '1.0.0', isCore: true, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/memory' },
]; ];
const MOCK_MARKETPLACE: MarketplaceSkill[] = [ export const MOCK_MARKETPLACE: MarketplaceSkill[] = [
{ slug: 'notion', name: 'notion', description: 'Read and write pages in Notion workspaces.', version: '1.0.0', author: 'clawhub' }, { slug: 'notion', name: 'notion', description: 'Read and write pages in Notion workspaces.', version: '1.0.0', author: 'clawhub' },
{ slug: 'linear', name: 'linear', description: 'Manage Linear issues and projects.', version: '1.0.0', author: 'clawhub' }, { slug: 'linear', name: 'linear', description: 'Manage Linear issues and projects.', version: '1.0.0', author: 'clawhub' },
{ slug: 'slack', name: 'slack', description: 'Send messages and search channels in Slack.', version: '1.0.0', author: 'clawhub' }, { slug: 'slack', name: 'slack', description: 'Send messages and search channels in Slack.', version: '1.0.0', author: 'clawhub' },

View File

@@ -1,7 +1,7 @@
<template> <template>
<layout> <layout>
<div class="bg-[#f9f7f0] box-border w-full h-full flex rounded-[16px] overflow-hidden"> <div class="bg-white 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"> <div class="w-full flex flex-col h-full p-10 pt-12">
<!-- Header --> <!-- Header -->
<div class="flex flex-col md:flex-row md:items-start justify-between mb-6 shrink-0 gap-4"> <div class="flex flex-col md:flex-row md:items-start justify-between mb-6 shrink-0 gap-4">
<div> <div>
@@ -165,9 +165,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSkillsStore, FETCH_ERROR_CODES, INSTALL_ERROR_CODES } from '@src/store/skills' import { useSkillsStore, FETCH_ERROR_CODES } from '@src/store/skills'
import { apiGetSkillsDir, apiOpenSkillsDir } from '@src/lib/skills-api' import { MOCK_SKILLS, MOCK_MARKETPLACE } from '@src/lib/skills-api'
import type { Skill } from '@src/lib/skills-types' import type { Skill, MarketplaceSkill } from '@src/lib/skills-types'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
RiSearchLine, RiSearchLine,
@@ -191,9 +191,12 @@ const selectedSkill = ref<Skill | null>(null)
const selectedSource = ref<'all' | 'built-in' | 'marketplace'>('all') const selectedSource = ref<'all' | 'built-in' | 'marketplace'>('all')
const skillsDirPath = ref('~/.zn-ai/skills') const skillsDirPath = ref('~/.zn-ai/skills')
onMounted(async () => { onMounted(() => {
store.fetchSkills() // 演示模式:直接填充本地 Mock 数据,避免调用后端接口
skillsDirPath.value = await apiGetSkillsDir() store.skills = MOCK_SKILLS as Skill[]
store.loading = false
// store.fetchSkills()
// skillsDirPath.value = await apiGetSkillsDir()
}) })
const safeSkills = computed(() => (Array.isArray(store.skills) ? store.skills : [])) const safeSkills = computed(() => (Array.isArray(store.skills) ? store.skills : []))
@@ -244,11 +247,8 @@ async function bulkToggleVisible(enable: boolean) {
let succeeded = 0 let succeeded = 0
for (const skill of candidates) { for (const skill of candidates) {
try { try {
if (enable) { // Demo mode: skip store.enableSkill / store.disableSkill API calls
await store.enableSkill(skill.id) skill.enabled = enable
} else {
await store.disableSkill(skill.id)
}
succeeded += 1 succeeded += 1
} catch { } catch {
// Continue to next skill // Continue to next skill
@@ -263,31 +263,15 @@ async function bulkToggleVisible(enable: boolean) {
} }
async function handleToggle(skillId: string, enable: boolean) { async function handleToggle(skillId: string, enable: boolean) {
try { const skill = safeSkills.value.find((s: Skill) => s.id === skillId)
if (enable) { if (!skill || skill.isCore) return
await store.enableSkill(skillId) skill.enabled = enable
ElMessage.success(t('skills.toast.enabled')) ElMessage.success(enable ? t('skills.toast.enabled') : t('skills.toast.disabled'))
} else {
await store.disableSkill(skillId)
ElMessage.success(t('skills.toast.disabled'))
}
} catch (err) {
ElMessage.error(String(err))
}
} }
async function handleOpenSkillsFolder() { async function handleOpenSkillsFolder() {
try { // Demo mode: skip API call
await apiOpenSkillsDir() ElMessage.info(t('skills.toast.copiedPath') + ' (Demo)')
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() { function openMarketplace() {
@@ -297,46 +281,54 @@ function openMarketplace() {
watch(installDrawerOpen, (open) => { watch(installDrawerOpen, (open) => {
if (open) { if (open) {
store.searchSkills('') store.searchResults = MOCK_MARKETPLACE as any
// store.searchSkills('')
} }
}) })
watch(installQuery, (q) => { watch(installQuery, (q) => {
if (!installDrawerOpen.value) return if (!installDrawerOpen.value) return
const query = q.trim() const query = q.trim()
const all = MOCK_MARKETPLACE
if (query.length === 0) { if (query.length === 0) {
store.searchSkills('') store.searchResults = all as any
return 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(() => { setTimeout(() => {
store.searchSkills(query) const lower = query.toLowerCase()
store.searchResults = all.filter(
(s: MarketplaceSkill) => s.name.toLowerCase().includes(lower) || s.description.toLowerCase().includes(lower)
) as any
// store.searchSkills(query)
}, 300) }, 300)
}) })
async function handleInstall(slug: string) { async function handleInstall(slug: string) {
try { // Demo mode: 模拟安装
await store.installSkill(slug) const ms = MOCK_MARKETPLACE.find((s: MarketplaceSkill) => s.slug === slug)
await store.enableSkill(slug) if (ms && !store.skills.some((s: Skill) => s.id === slug || s.name === ms.name)) {
ElMessage.success(t('skills.toast.installed')) store.skills.push({
} catch (err) { id: slug,
const errorMessage = err instanceof Error ? err.message : String(err) slug,
if (INSTALL_ERROR_CODES.has(errorMessage)) { name: ms.name,
ElMessage({ type: 'error', message: String(t(`skills.toast.${errorMessage}`, { path: skillsDirPath.value })), duration: 10000 }) description: ms.description,
} else { enabled: true,
ElMessage.error(t('skills.toast.failedInstall') + ': ' + errorMessage) icon: '📦',
} version: ms.version || '1.0.0',
isCore: false,
isBundled: false,
source: 'openclaw-managed',
author: ms.author,
} as Skill)
} }
ElMessage.success(t('skills.toast.installed') + ' (Demo)')
} }
async function handleUninstall(slug: string) { async function handleUninstall(slug: string) {
try { // Demo mode: 模拟卸载
await store.uninstallSkill(slug) const idx = store.skills.findIndex((s: Skill) => s.slug === slug)
ElMessage.success(t('skills.toast.uninstalled')) if (idx > -1) store.skills.splice(idx, 1)
} catch (err) { ElMessage.success(t('skills.toast.uninstalled') + ' (Demo)')
ElMessage.error(t('skills.toast.failedUninstall') + ': ' + String(err))
}
} }
</script> </script>