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

@@ -20,5 +20,6 @@ export const NAMESPACES = [
'knowledge',
'component',
'models',
'skills',
] as const;
export type Namespace = (typeof NAMESPACES)[number];

View File

@@ -0,0 +1,112 @@
{
"title": "Skills",
"subtitle": "Browse and manage AI capabilities",
"refresh": "Refresh",
"openFolder": "Open Skills Folder",
"gatewayWarning": "Gateway is not running. Skills cannot be loaded without an active Gateway.",
"tabs": {
"installed": "Installed",
"marketplace": "Marketplace"
},
"filter": {
"all": "All ({{count}})",
"builtIn": "Built-in ({{count}})",
"marketplace": "Marketplace ({{count}})"
},
"search": "Search skills...",
"searchMarketplace": "Search marketplace...",
"searchButton": "Search",
"actions": {
"enableVisible": "Enable Visible",
"disableVisible": "Disable Visible",
"installSkill": "Install Skills"
},
"noSkills": "No skills found",
"noSkillsSearch": "Try a different search term",
"noSkillsAvailable": "No skills available",
"detail": {
"info": "Information",
"config": "Configuration",
"description": "Description",
"version": "Version",
"author": "Author",
"source": "Source",
"coreSystem": "Core System",
"bundled": "Bundled",
"userInstalled": "User Installed",
"enabled": "Enabled",
"disabled": "Disabled",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter API Key (optional)",
"apiKeyDesc": "The primary API key for this skill. Leave blank if not required or configured elsewhere.",
"envVars": "Environment Variables",
"addVariable": "Add Variable",
"noEnvVars": "No environment variables configured.",
"keyPlaceholder": "KEY (e.g. BASE_URL)",
"valuePlaceholder": "VALUE",
"envNote": "Note: Rows with empty keys will be automatically removed during save.",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"configSaved": "Configuration saved",
"openManual": "Open Manual",
"openActualFolder": "Open Actual Folder",
"copyPath": "Copy path",
"pathUnavailable": "Path not available",
"configurable": "Configurable",
"uninstall": "Uninstall",
"enable": "Enable",
"disable": "Disable"
},
"source": {
"badge": {
"bundled": "Bundled",
"managed": "Managed",
"workspace": "Workspace",
"extra": "Extra dirs",
"agentsPersonal": "Personal .agents",
"agentsProject": "Project .agents",
"unknown": "Unknown source"
}
},
"toast": {
"enabled": "Skill enabled",
"disabled": "Skill disabled",
"installed": "Skill installed and enabled",
"uninstalled": "Skill uninstalled successfully",
"openedEditor": "Opened in editor",
"failedEditor": "Failed to open editor",
"failedSave": "Failed to save configuration",
"failedOpenFolder": "Failed to open skills folder",
"failedInstall": "Failed to install",
"failedUninstall": "Failed to uninstall",
"failedFolderNotFound": "Skills folder does not exist yet. Install a skill first.",
"copiedPath": "Path copied",
"failedCopyPath": "Failed to copy path",
"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}}\"",
"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}}\"",
"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.",
"fetchRateLimitError": "Fetching skills rate limit exceeded, please try again later.",
"noBatchEnableTargets": "All visible skills are already enabled.",
"noBatchDisableTargets": "All visible skills are already disabled.",
"batchEnabled": "{{count}} skills enabled.",
"batchDisabled": "{{count}} skills disabled.",
"batchPartial": "Updated {{success}} / {{total}} skills. Some items failed."
},
"marketplace": {
"title": "Marketplace",
"installDialogTitle": "Install Skills",
"installDialogSubtitle": "Browse Explore by default, or enter keywords to search.",
"sourceLabel": "Source",
"sourceClawHub": "ClawHub",
"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}}\".",
"searching": "Searching ClawHub...",
"noResults": "No skills found matching your search.",
"emptyPrompt": "Search for new skills to expand your capabilities.",
"searchError": "ClawHub search failed. Check your connection or installation.",
"install": "Install"
}
}

View File

@@ -0,0 +1,112 @@
{
"title": "スキル",
"subtitle": "AI機能の閲覧と管理",
"refresh": "更新",
"openFolder": "スキルフォルダを開く",
"gatewayWarning": "ゲートウェイが稼働していません。アクティブなゲートウェイがないとスキルを読み込めません。",
"tabs": {
"installed": "インストール済み",
"marketplace": "マーケットプレイス"
},
"filter": {
"all": "すべて ({{count}})",
"builtIn": "内蔵 ({{count}})",
"marketplace": "マーケットプレイス ({{count}})"
},
"search": "スキルを検索...",
"searchMarketplace": "マーケットプレイスを検索...",
"searchButton": "検索",
"actions": {
"enableVisible": "表示中を一括有効化",
"disableVisible": "表示中を一括無効化",
"installSkill": "スキルをインストール"
},
"noSkills": "スキルが見つかりません",
"noSkillsSearch": "別の検索語をお試しください",
"noSkillsAvailable": "利用可能なスキルがありません",
"detail": {
"info": "情報",
"config": "設定",
"description": "説明",
"version": "バージョン",
"author": "作者",
"source": "ソース",
"coreSystem": "コアシステム",
"bundled": "内蔵",
"userInstalled": "ユーザーインストール",
"enabled": "有効",
"disabled": "無効",
"apiKey": "APIキー",
"apiKeyPlaceholder": "APIキーを入力任意",
"apiKeyDesc": "このスキルの主要なAPIキーです。不要な場合または別の場所で設定している場合は空白のままにしてください。",
"envVars": "環境変数",
"addVariable": "変数を追加",
"noEnvVars": "環境変数が設定されていません。",
"keyPlaceholder": "キーBASE_URL",
"valuePlaceholder": "値",
"envNote": "注意:キーが空の行は保存時に自動的に削除されます。",
"saving": "保存中...",
"saveConfig": "設定を保存",
"configSaved": "設定を保存しました",
"openManual": "マニュアルを開く",
"openActualFolder": "実際のフォルダを開く",
"copyPath": "パスをコピー",
"pathUnavailable": "パスを取得できません",
"configurable": "設定可能",
"uninstall": "アンインストール",
"enable": "有効化",
"disable": "無効化"
},
"source": {
"badge": {
"bundled": "内蔵",
"managed": "管理ディレクトリ",
"workspace": "ワークスペース",
"extra": "追加ディレクトリ",
"agentsPersonal": "個人 .agents",
"agentsProject": "プロジェクト .agents",
"unknown": "不明なソース"
}
},
"toast": {
"enabled": "スキルを有効にしました",
"disabled": "スキルを無効にしました",
"installed": "スキルをインストールして有効にしました",
"uninstalled": "スキルのアンインストールに成功しました",
"openedEditor": "エディターで開きました",
"failedEditor": "エディターを開けませんでした",
"failedSave": "設定の保存に失敗しました",
"failedOpenFolder": "スキルフォルダを開けませんでした",
"failedInstall": "インストールに失敗しました",
"failedUninstall": "アンインストールに失敗しました",
"failedFolderNotFound": "スキルフォルダがまだ存在しません。先にスキルをインストールしてください。",
"copiedPath": "パスをコピーしました",
"failedCopyPath": "パスのコピーに失敗しました",
"failedOpenActualFolder": "スキルの実際のフォルダを開けませんでした",
"searchTimeoutError": "検索がタイムアウトしました。ClawHub.aiで検索してZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
"installTimeoutError": "インストールがタイムアウトしました。ClawHub.aiでZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
"searchRateLimitError": "検索リクエストの制限を超過しました。ClawHub.aiで検索してZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
"installRateLimitError": "インストールリクエストの制限を超過しました。ClawHub.aiからZIPをダウンロードし、\"{{path}}\" に展開することも可能です",
"fetchTimeoutError": "スキルリストの取得がタイムアウトしました。ネットワークを確認してください。",
"fetchRateLimitError": "スキルリスト取得のリクエスト制限を超過しました。後でお試しください。",
"noBatchEnableTargets": "表示中のスキルはすべて有効です。",
"noBatchDisableTargets": "表示中のスキルはすべて無効です。",
"batchEnabled": "{{count}} 件のスキルを有効化しました。",
"batchDisabled": "{{count}} 件のスキルを無効化しました。",
"batchPartial": "{{success}} / {{total}} 件を更新しました。一部失敗しています。"
},
"marketplace": {
"title": "マーケットプレイス",
"installDialogTitle": "スキルをインストール",
"installDialogSubtitle": "初期表示は Explore、キーワード入力時は検索します。",
"sourceLabel": "ソース",
"sourceClawHub": "ClawHub",
"securityNote": "インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。",
"manualInstallHint": "ネットワークに問題がありますかいつでもClawHub.aiからスキルのZIPをダウンロードし、手動で \"{{path}}\" に展開してインストールできます。",
"searching": "ClawHubを検索中...",
"noResults": "検索に一致するスキルが見つかりません。",
"emptyPrompt": "新しいスキルを検索して機能を拡張しましょう。",
"searchError": "ClawHub検索に失敗しました。接続またはインストールを確認してください。",
"install": "インストール"
}
}

View File

@@ -0,0 +1,112 @@
{
"title": "技能",
"subtitle": "浏览和管理 AI 能力",
"refresh": "刷新",
"openFolder": "打开技能文件夹",
"gatewayWarning": "网关未运行。没有活跃的网关,无法加载技能。",
"tabs": {
"installed": "已安装",
"marketplace": "市场"
},
"filter": {
"all": "全部 ({{count}})",
"builtIn": "内置 ({{count}})",
"marketplace": "市场 ({{count}})"
},
"search": "搜索技能...",
"searchMarketplace": "搜索市场...",
"searchButton": "搜索",
"actions": {
"enableVisible": "批量启用可见项",
"disableVisible": "批量禁用可见项",
"installSkill": "安装技能"
},
"noSkills": "未找到技能",
"noSkillsSearch": "尝试不同的搜索词",
"noSkillsAvailable": "暂无可用技能",
"detail": {
"info": "信息",
"config": "配置",
"description": "描述",
"version": "版本",
"author": "作者",
"source": "来源",
"coreSystem": "核心系统",
"bundled": "内置",
"userInstalled": "用户安装",
"enabled": "已启用",
"disabled": "已禁用",
"apiKey": "API 密钥",
"apiKeyPlaceholder": "输入 API 密钥(可选)",
"apiKeyDesc": "此技能的主要 API 密钥。如果不需要或在别处配置,请留空。",
"envVars": "环境变量",
"addVariable": "添加变量",
"noEnvVars": "未配置环境变量。",
"keyPlaceholder": "键名 (例如 BASE_URL)",
"valuePlaceholder": "值",
"envNote": "注意:键名为空的行将在保存时自动移除。",
"saving": "保存中...",
"saveConfig": "保存配置",
"configSaved": "配置已保存",
"openManual": "打开手册",
"openActualFolder": "打开实际目录",
"copyPath": "复制路径",
"pathUnavailable": "路径不可用",
"configurable": "可配置",
"uninstall": "卸载",
"enable": "启用",
"disable": "禁用"
},
"source": {
"badge": {
"bundled": "内置",
"managed": "托管目录",
"workspace": "工作区",
"extra": "额外目录",
"agentsPersonal": "个人 .agents",
"agentsProject": "项目 .agents",
"unknown": "未知来源"
}
},
"toast": {
"enabled": "技能已启用",
"disabled": "技能已禁用",
"installed": "技能已安装并启用",
"uninstalled": "技能已成功卸载",
"openedEditor": "已在编辑器中打开",
"failedEditor": "无法打开编辑器",
"failedSave": "保存配置失败",
"failedOpenFolder": "无法打开技能文件夹",
"failedInstall": "安装失败",
"failedUninstall": "卸载失败",
"failedFolderNotFound": "技能文件夹尚不存在,请先安装一个技能。",
"copiedPath": "路径已复制",
"failedCopyPath": "复制路径失败",
"failedOpenActualFolder": "打开技能实际目录失败",
"searchTimeoutError": "搜索超时,请检查网络。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{{path}}\"",
"installTimeoutError": "安装超时,请检查网络。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{{path}}\"",
"searchRateLimitError": "搜索请求过于频繁。您也可访问 ClawHub.ai 搜索并下载压缩包,解压到 \"{{path}}\"",
"installRateLimitError": "安装请求过于频繁。您也可在 ClawHub.ai 下载该技能压缩包,解压到 \"{{path}}\"",
"fetchTimeoutError": "获取技能列表超时,请检查网络。",
"fetchRateLimitError": "获取技能列表请求过于频繁,请稍后再试。",
"noBatchEnableTargets": "当前可见技能都已启用。",
"noBatchDisableTargets": "当前可见技能都已禁用。",
"batchEnabled": "已启用 {{count}} 个技能。",
"batchDisabled": "已禁用 {{count}} 个技能。",
"batchPartial": "已更新 {{success}} / {{total}} 个技能,部分操作失败。"
},
"marketplace": {
"title": "市场",
"installDialogTitle": "安装技能",
"installDialogSubtitle": "默认展示 Explore输入关键词后执行搜索。",
"sourceLabel": "来源",
"sourceClawHub": "ClawHub",
"securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。",
"manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。",
"searching": "正在搜索 ClawHub...",
"noResults": "未找到匹配的技能。",
"emptyPrompt": "搜索新技能以扩展您的能力。",
"searchError": "ClawHub 搜索失败。请检查您的连接或安装。",
"install": "安装"
}
}

174
src/lib/skills-api.ts Normal file
View File

@@ -0,0 +1,174 @@
import { hostApiFetch } from './host-api';
import type { Skill, MarketplaceSkill } from './skills-types';
// Mock data for UI development when backend is not ready
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: '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: 'bear-notes', slug: 'bear-notes', name: 'bear-notes', description: 'Create, search, and manage Bear notes via grizzly CLI.', enabled: true, icon: '🐻', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/bear-notes' },
{ id: 'fetch-hacker-news', slug: 'fetch-hacker-news', name: 'fetch-hacker-news', description: 'Fetch top stories from Hacker News.', enabled: false, icon: '📰', version: '1.0.0', isCore: false, isBundled: false, source: 'openclaw-managed', baseDir: '~/.openclaw/skills/fetch-hacker-news' },
{ id: 'github', slug: 'github', name: 'github', description: 'Interact with GitHub issues, pull requests, repositories, and more.', enabled: true, icon: '🐙', version: '1.2.0', isCore: false, isBundled: false, source: 'openclaw-managed', baseDir: '~/.openclaw/skills/github' },
{ id: 'todoist', slug: 'todoist', name: 'todoist', description: 'Manage Todoist tasks and projects.', enabled: true, icon: '✅', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/todoist' },
{ id: 'zotero', slug: 'zotero', name: 'zotero', description: 'Search and manage Zotero references.', enabled: false, icon: '📚', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/zotero' },
{ id: 'browser-use', slug: 'browser-use', name: 'browser-use', description: 'Use a browser to search and navigate the web.', enabled: true, icon: '🌐', version: '2.0.0', isCore: true, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/browser-use' },
{ 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[] = [
{ 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: 'slack', name: 'slack', description: 'Send messages and search channels in Slack.', version: '1.0.0', author: 'clawhub' },
{ slug: 'discord', name: 'discord', description: 'Send messages and manage Discord servers.', version: '1.0.0', author: 'clawhub' },
{ slug: 'figma', name: 'figma', description: 'Search and manage Figma files and comments.', version: '1.0.0', author: 'clawhub' },
{ slug: 'spotify', name: 'spotify', description: 'Control Spotify playback and manage playlists.', version: '1.0.0', author: 'clawhub' },
];
export async function apiFetchSkills(): Promise<Skill[]> {
try {
const configs = await hostApiFetch<Record<string, any>>('/api/skills/configs');
const listResult = await hostApiFetch<{ success: boolean; results?: any[]; error?: string }>('/api/clawhub/list');
let skills: Skill[] = [];
if (configs && typeof configs === 'object' && Object.keys(configs).length > 0) {
// Support extended config format that includes metadata (for zn-ai without gateway)
skills = Object.entries(configs).map(([key, cfg]: [string, any]) => ({
id: key,
slug: cfg.slug || key,
name: cfg.name || key,
description: cfg.description || '',
enabled: cfg.enabled !== false,
icon: cfg.icon || '📦',
version: cfg.version || '1.0.0',
author: cfg.author,
config: cfg.config || { apiKey: cfg.apiKey, env: cfg.env },
isCore: cfg.isCore || false,
isBundled: cfg.isBundled ?? true,
source: cfg.source,
baseDir: cfg.baseDir,
filePath: cfg.filePath,
}));
} else if (listResult?.success && listResult.results && listResult.results.length > 0) {
skills = listResult.results.map((item: any) => ({
id: item.slug,
slug: item.slug,
name: item.slug,
description: 'Recently installed, initializing...',
enabled: false,
icon: '⌛',
version: item.version || 'unknown',
source: item.source || 'openclaw-managed',
baseDir: item.baseDir,
isCore: false,
isBundled: false,
}));
}
if (skills.length === 0) {
return MOCK_SKILLS;
}
return skills;
} catch (error) {
console.error('Failed to fetch skills:', error);
return MOCK_SKILLS;
}
}
export async function apiSearchSkills(query: string): Promise<MarketplaceSkill[]> {
try {
const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', {
method: 'POST',
body: JSON.stringify({ query }),
});
if (result?.success && result.results && result.results.length > 0) {
return result.results;
}
if (!query) return MOCK_MARKETPLACE;
const q = query.toLowerCase();
return MOCK_MARKETPLACE.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
} catch (error) {
console.error('Search error:', error);
if (!query) return MOCK_MARKETPLACE;
const q = query.toLowerCase();
return MOCK_MARKETPLACE.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
}
}
export async function apiInstallSkill(slug: string, version?: string): Promise<void> {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', {
method: 'POST',
body: JSON.stringify({ slug, version }),
});
if (!result?.success) {
throw new Error(result?.error || 'Install failed');
}
}
export async function apiUninstallSkill(slug: string): Promise<void> {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/uninstall', {
method: 'POST',
body: JSON.stringify({ slug }),
});
if (!result?.success) {
throw new Error(result?.error || 'Uninstall failed');
}
}
export async function apiUpdateSkillConfig(
skillKey: string,
config: { apiKey?: string; env?: Record<string, string>; enabled?: boolean }
): Promise<void> {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/config', {
method: 'PUT',
body: JSON.stringify({ skillKey, ...config }),
});
if (!result?.success) {
throw new Error(result?.error || 'Save failed');
}
}
export async function apiOpenSkillPath(skillKey: string, slug?: string, baseDir?: string): Promise<void> {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-path', {
method: 'POST',
body: JSON.stringify({ skillKey, slug, baseDir }),
});
if (!result?.success) {
throw new Error(result?.error || 'Open path failed');
}
}
export async function apiOpenSkillReadme(skillKey: string, slug?: string, baseDir?: string): Promise<void> {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', {
method: 'POST',
body: JSON.stringify({ skillKey, slug, baseDir }),
});
if (!result?.success) {
throw new Error(result?.error || 'Open readme failed');
}
}
export async function apiGetSkillsDir(): Promise<string> {
try {
const result = await hostApiFetch<{ success: boolean; dir?: string; error?: string }>('/api/clawhub/skills-dir');
if (result?.success && result.dir) return result.dir;
} catch {
// fallback
}
return '~/.zn-ai/skills';
}
export async function apiOpenSkillsDir(): Promise<void> {
const dir = await apiGetSkillsDir();
try {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-skills-dir', {
method: 'POST',
body: JSON.stringify({ dir }),
});
if (result?.success) return;
throw new Error(result?.error || 'Open failed');
} catch (error) {
await navigator.clipboard.writeText(dir);
throw new Error('Path copied to clipboard: ' + dir);
}
}

68
src/lib/skills-types.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Skill Type Definitions
* Types for skills/plugins
*/
/**
* Skill data structure
*/
export interface Skill {
id: string;
slug?: string;
name: string;
description: string;
enabled: boolean;
icon?: string;
version?: string;
author?: string;
configurable?: boolean;
config?: Record<string, unknown>;
isCore?: boolean;
isBundled?: boolean;
dependencies?: string[];
source?: string;
baseDir?: string;
filePath?: string;
}
/**
* Skill bundle (preset skill collection)
*/
export interface SkillBundle {
id: string;
name: string;
nameZh: string;
description: string;
descriptionZh: string;
icon: string;
skills: string[];
recommended?: boolean;
}
/**
* Marketplace skill data
*/
export interface MarketplaceSkill {
slug: string;
name: string;
description: string;
version: string;
author?: string;
downloads?: number;
stars?: number;
}
/**
* Skill configuration schema
*/
export interface SkillConfigSchema {
type: 'object';
properties: Record<string, {
type: 'string' | 'number' | 'boolean' | 'array';
title?: string;
description?: string;
default?: unknown;
enum?: unknown[];
}>;
required?: string[];
}

19
src/lib/skills-utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Skill } from './skills-types';
export function resolveSkillSourceLabel(
skill: Skill,
t: (key: string, defaultValue?: string) => string
): string {
const source = (skill.source || '').trim().toLowerCase();
if (!source) {
if (skill.isBundled) return t('skills.source.badge.bundled', 'Built-in');
return t('skills.source.badge.unknown', 'Unknown source');
}
if (source === 'openclaw-bundled') return t('skills.source.badge.bundled', 'Built-in');
if (source === 'openclaw-managed') return t('skills.source.badge.managed', 'Managed');
if (source === 'openclaw-workspace') return t('skills.source.badge.workspace', 'Workspace');
if (source === 'openclaw-extra') return t('skills.source.badge.extra', 'Extra dirs');
if (source === 'agents-skills-personal') return t('skills.source.badge.agentsPersonal', 'Personal .agents');
if (source === 'agents-skills-project') return t('skills.source.badge.agentsProject', 'Project .agents');
return source;
}

View File

@@ -1,18 +1,18 @@
<template>
<el-dialog v-model="visible" :title="t('knowledge.event.addEvent')" width="500" :before-close="handleClose" :close-on-click-modal="false"
<el-dialog v-model="visible" title="添加事件" width="500" :before-close="handleClose" :close-on-click-modal="false"
:close-on-press-escape="false" append-to-body>
<div class="p-[20px]">
<el-form ref="ruleFormRef" style="max-width: 600px" :model="ruleForm" :rules="rules" label-position="top"
label-width="auto">
<el-form-item :label="t('knowledge.event.eventName')" prop="name">
<el-input v-model="ruleForm.name" :placeholder="t('knowledge.event.pleaseEnter')" />
<el-form-item label="事件名称" prop="name">
<el-input v-model="ruleForm.name" placeholder="请输入" />
</el-form-item>
<el-form-item :label="t('knowledge.event.eventDesc')" prop="des">
<el-input v-model="ruleForm.des" :placeholder="t('knowledge.event.pleaseEnter')" />
<el-form-item label="事件描述" prop="des">
<el-input v-model="ruleForm.des" placeholder="请输入" />
</el-form-item>
<el-form-item :label="t('knowledge.event.effectiveTimeRange')" prop="time">
<el-date-picker type="daterange" v-model="ruleForm.time" :range-separator="t('knowledge.event.to')" :start-placeholder="t('knowledge.event.startDate')"
:end-placeholder="t('knowledge.event.endDate')">
<el-form-item label="生效时间段" prop="time">
<el-date-picker type="daterange" v-model="ruleForm.time" range-separator="" start-placeholder="开始日期"
end-placeholder="结束日期">
<template #suffix>
<el-icon class="el-input__icon">
<calendar />
@@ -24,9 +24,9 @@
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">{{ t('knowledge.common.cancel') }}</el-button>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="submitForm(ruleFormRef)">
{{ t('knowledge.common.confirm') }}
确认
</el-button>
</div>
</template>
@@ -34,11 +34,8 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { FormInstance } from 'element-plus';
import { ref, reactive, computed } from 'vue';
const { t } = useI18n();
import { ref, reactive } from 'vue';
const visible = ref(false)
@@ -48,19 +45,19 @@ interface RuleForm {
time: string[]
}
const ruleFormRef = ref<any>();
const rules = computed(() => ({
const rules = ref<any>({
name: [
{ required: true, message: t('knowledge.event.pleaseEnterEventName'), trigger: 'blur' },
{ min: 3, max: 50, message: t('knowledge.event.lengthValidation'), trigger: 'blur' },
{ required: true, message: '请输入活动名称', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' },
],
des: [
{ required: true, message: t('knowledge.event.pleaseEnterEventDesc'), trigger: 'blur' },
{ min: 3, max: 50, message: t('knowledge.event.lengthValidation'), trigger: 'blur' },
{ required: true, message: '请输入活动描述', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' },
],
time: [
{ required: true, message: t('knowledge.event.pleaseSelectTimeRange'), trigger: 'change' },
{ required: true, message: '请选择生效时间段', trigger: 'change' },
],
}));
});
const ruleForm = reactive<RuleForm>({
name: '',
des: '',

View File

@@ -92,7 +92,7 @@ import { useUserStore } from "../../store/userinfo"
import { RiUser3Fill, RiKey2Fill } from '@remixicon/vue'
import { generateUUID } from "../../utils/generateUUID"
import { rule } from '../../utils/validate'
import { useLocale } from '@src/composables/useLocale'
import { useLocale } from '../../composables/useLocale'
const { t } = useLocale()

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>

123
src/store/skills.ts Normal file
View File

@@ -0,0 +1,123 @@
import { defineStore } from 'pinia';
import type { Skill, MarketplaceSkill } from '@src/lib/skills-types';
import {
apiFetchSkills,
apiSearchSkills,
apiInstallSkill,
apiUninstallSkill,
apiUpdateSkillConfig,
} from '@src/lib/skills-api';
const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitError']);
const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']);
const SEARCH_ERROR_CODES = new Set(['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError']);
export const useSkillsStore = defineStore('skills', {
state: () => ({
skills: [] as Skill[],
searchResults: [] as MarketplaceSkill[],
loading: false,
searching: false,
searchError: null as string | null,
installing: {} as Record<string, boolean>,
error: null as string | null,
}),
getters: {
sourceStats: (state) => {
const safeSkills = Array.isArray(state.skills) ? state.skills : [];
return {
all: safeSkills.length,
builtIn: safeSkills.filter(s => s.isBundled).length,
marketplace: safeSkills.filter(s => !s.isBundled).length,
};
},
},
actions: {
async fetchSkills() {
if (this.skills.length === 0) {
this.loading = true;
}
this.error = null;
try {
this.skills = await apiFetchSkills();
} catch (error) {
console.error('Failed to fetch skills:', error);
const msg = error instanceof Error ? error.message : String(error);
this.error = FETCH_ERROR_CODES.has(msg) ? msg : msg;
} finally {
this.loading = false;
}
},
async searchSkills(query: string) {
this.searching = true;
this.searchError = null;
try {
this.searchResults = await apiSearchSkills(query);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
this.searchError = SEARCH_ERROR_CODES.has(msg) ? msg : msg;
} finally {
this.searching = false;
}
},
async installSkill(slug: string, version?: string) {
this.installing[slug] = true;
try {
await apiInstallSkill(slug, version);
await this.fetchSkills();
} finally {
delete this.installing[slug];
}
},
async uninstallSkill(slug: string) {
this.installing[slug] = true;
try {
await apiUninstallSkill(slug);
await this.fetchSkills();
} finally {
delete this.installing[slug];
}
},
async enableSkill(skillId: string) {
const skill = this.skills.find(s => s.id === skillId);
if (!skill) return;
try {
await apiUpdateSkillConfig(skillId, { enabled: true });
skill.enabled = true;
} catch (error) {
console.error('Failed to enable skill:', error);
throw error;
}
},
async disableSkill(skillId: string) {
const skill = this.skills.find(s => s.id === skillId);
if (!skill) return;
if (skill.isCore) {
throw new Error('Cannot disable core skill');
}
try {
await apiUpdateSkillConfig(skillId, { enabled: false });
skill.enabled = false;
} catch (error) {
console.error('Failed to disable skill:', error);
throw error;
}
},
updateSkill(skillId: string, updates: Partial<Skill>) {
const skill = this.skills.find(s => s.id === skillId);
if (skill) {
Object.assign(skill, updates);
}
},
},
});
export { INSTALL_ERROR_CODES, FETCH_ERROR_CODES, SEARCH_ERROR_CODES };