feat: Add skill hub for openclaw (#12333)

This commit is contained in:
CityFun
2026-03-25 09:32:18 +00:00
committed by GitHub
parent bb642cd8a8
commit 7b554c0d61
18 changed files with 516 additions and 49 deletions
+41
View File
@@ -763,6 +763,27 @@ func (b *BaseApi) ListAgentSkills(c *gin.Context) {
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Search Agent skills
// @Accept json
// @Param request body dto.AgentSkillSearchReq true "request"
// @Success 200 {array} dto.AgentSkillSearchItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/skills/search [post]
func (b *BaseApi) SearchAgentSkills(c *gin.Context) {
var req dto.AgentSkillSearchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.SearchSkills(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Update Agent skill status
// @Accept json
@@ -783,6 +804,26 @@ func (b *BaseApi) UpdateAgentSkill(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Install Agent skill
// @Accept json
// @Param request body dto.AgentSkillInstallReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/skills/install [post]
func (b *BaseApi) InstallAgentSkill(c *gin.Context) {
var req dto.AgentSkillInstallReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.InstallSkill(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Login Agent Weixin channel
// @Accept json
+23
View File
@@ -389,6 +389,12 @@ type AgentSkillsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentSkillSearchReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub skillhub"`
Keyword string `json:"keyword" validate:"required"`
}
type AgentSkillItem struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -397,8 +403,25 @@ type AgentSkillItem struct {
Disabled bool `json:"disabled"`
}
type AgentSkillSearchItem struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Summary string `json:"summary"`
Version string `json:"version"`
Source string `json:"source"`
Score string `json:"score"`
}
type AgentSkillUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Name string `json:"name" validate:"required"`
Enabled bool `json:"enabled"`
}
type AgentSkillInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub skillhub"`
Slug string `json:"slug" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
}
+3
View File
@@ -39,7 +39,9 @@ type IAgentService interface {
GetConfigFile(req dto.AgentConfigFileReq) (*dto.AgentConfigFile, error)
UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error
ListSkills(req dto.AgentSkillsReq) ([]dto.AgentSkillItem, error)
SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error)
UpdateSkill(req dto.AgentSkillUpdateReq) error
InstallSkill(req dto.AgentSkillInstallReq) error
CreateAccount(req dto.AgentAccountCreateReq) error
UpdateAccount(req dto.AgentAccountUpdateReq) error
@@ -79,6 +81,7 @@ const (
maxCommunityAIAgents = int64(5)
openclawPluginBaseDir = "/home/node/.openclaw/extensions"
openclawPluginPackageTmpDir = "/tmp/openclaw-plugin"
openclawManagedSkillsDir = "/home/node/.openclaw/skills"
openclawGatewayPort = 18789
openclawAllowedOriginHost = "127.0.0.1"
openclawHTTPSVersion = "2026.3.13"
+152
View File
@@ -3,11 +3,14 @@ package service
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
@@ -27,6 +30,13 @@ type openclawSkillInfo struct {
SkillKey string `json:"skillKey"`
}
type skillhubSearchPayload struct {
Skills []dto.AgentSkillSearchItem `json:"skills"`
Results []dto.AgentSkillSearchItem `json:"results"`
}
var clawhubSearchLinePattern = regexp.MustCompile(`^(\S+)\s+(.+?)\s+\(([\d.]+)\)$`)
func (a AgentService) ListSkills(req dto.AgentSkillsReq) ([]dto.AgentSkillItem, error) {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
@@ -56,6 +66,36 @@ func (a AgentService) ListSkills(req dto.AgentSkillsReq) ([]dto.AgentSkillItem,
return parseOpenclawSkillsList(output)
}
func (a AgentService) SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error) {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if agent.AgentType != constant.AppOpenclaw {
return nil, fmt.Errorf("copaw does not support skills")
}
status, err := checkContainerStatus(install.ContainerName)
if err != nil {
return nil, err
}
if status != "running" {
return nil, fmt.Errorf("container %s is not running, please check and retry", install.ContainerName)
}
output, err := loadOpenclawSkillSearchOutput(install.ContainerName, req.Source, req.Keyword)
if err != nil {
return nil, err
}
if len(output) == 0 {
return nil, nil
}
switch req.Source {
case "skillhub":
return parseSkillhubSearchResult(output)
default:
return parseClawhubSearchResult(output), nil
}
}
func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
@@ -83,6 +123,37 @@ func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
return writeOpenclawConfigRaw(agent.ConfigPath, conf)
}
func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if agent.AgentType != constant.AppOpenclaw {
return fmt.Errorf("copaw does not support skills")
}
status, err := checkContainerStatus(install.ContainerName)
if err != nil {
return err
}
if status != "running" {
return fmt.Errorf("container %s is not running, please check and retry", install.ContainerName)
}
installTask, err := task.NewTaskWithOps(req.Slug, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
installTask.AddSubTask("Install OpenClaw skill", func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
return mgr.Run("docker", "exec", install.ContainerName, "sh", "-c", buildOpenclawSkillInstallCommand(req.Source, req.Slug))
}, nil)
go func() {
if err := installTask.Execute(); err != nil {
global.LOG.Errorf("install openclaw skill failed: %v", err)
}
}()
return nil
}
func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
var payload openclawSkillsList
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil {
@@ -101,6 +172,87 @@ func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
return items, nil
}
func loadOpenclawSkillSearchOutput(containerName, source, keyword string) (string, error) {
switch source {
case "skillhub":
return cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s skillhub search %q --json 2>&1",
30*time.Second,
containerName,
keyword,
)
default:
return cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s clawhub search %q 2>&1",
30*time.Second,
containerName,
keyword,
)
}
}
func parseSkillhubSearchResult(output string) ([]dto.AgentSkillSearchItem, error) {
trimmed := strings.TrimSpace(output)
if trimmed == "" {
return nil, nil
}
var list []dto.AgentSkillSearchItem
if err := json.Unmarshal([]byte(trimmed), &list); err == nil {
for i := range list {
list[i].Source = "skillhub"
}
return list, nil
}
var payload skillhubSearchPayload
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, err
}
items := payload.Skills
if len(items) == 0 {
items = payload.Results
}
for i := range items {
items[i].Source = "skillhub"
}
return items, nil
}
func parseClawhubSearchResult(output string) []dto.AgentSkillSearchItem {
lines := strings.Split(strings.TrimSpace(output), "\n")
items := make([]dto.AgentSkillSearchItem, 0, len(lines))
for _, line := range lines {
matches := clawhubSearchLinePattern.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) != 4 {
continue
}
items = append(items, dto.AgentSkillSearchItem{
Slug: matches[1],
Name: matches[2],
Score: matches[3],
Source: "clawhub",
})
}
return items
}
func buildOpenclawSkillInstallCommand(source, slug string) string {
switch source {
case "clawhub":
return fmt.Sprintf(
"mkdir -p %s && clawhub --workdir /home/node/.openclaw --dir skills install %q",
openclawManagedSkillsDir,
slug,
)
default:
return fmt.Sprintf(
"mkdir -p %s && skillhub --dir %s install %q",
openclawManagedSkillsDir,
openclawManagedSkillsDir,
slug,
)
}
}
func getOpenclawSkillKey(containerName, name string) (string, error) {
output, err := cmd.RunDefaultWithStdoutBashCfAndTimeOut(
"docker exec %s openclaw skills info %q --json 2>&1",
+2
View File
@@ -78,7 +78,9 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/config-file/get", baseApi.GetAgentConfigFile)
aiToolsRouter.POST("/agents/config-file/update", baseApi.UpdateAgentConfigFile)
aiToolsRouter.POST("/agents/skills/list", baseApi.ListAgentSkills)
aiToolsRouter.POST("/agents/skills/search", baseApi.SearchAgentSkills)
aiToolsRouter.POST("/agents/skills/update", baseApi.UpdateAgentSkill)
aiToolsRouter.POST("/agents/skills/install", baseApi.InstallAgentSkill)
aiToolsRouter.POST("/agents/channel/pairing/approve", baseApi.ApproveAgentChannelPairing)
}
}
+23
View File
@@ -623,6 +623,12 @@ export namespace AI {
agentId: number;
}
export interface AgentSkillSearchReq {
agentId: number;
source: 'clawhub' | 'skillhub';
keyword: string;
}
export interface AgentSkillItem {
name: string;
description: string;
@@ -631,9 +637,26 @@ export namespace AI {
disabled: boolean;
}
export interface AgentSkillSearchItem {
slug: string;
name: string;
description: string;
summary: string;
version: string;
source: string;
score: string;
}
export interface AgentSkillUpdateReq {
agentId: number;
name: string;
enabled: boolean;
}
export interface AgentSkillInstallReq {
agentId: number;
source: 'clawhub' | 'skillhub';
slug: string;
taskID: string;
}
}
+8
View File
@@ -245,10 +245,18 @@ export const listAgentSkills = (req: AI.AgentSkillsReq) => {
return http.post<AI.AgentSkillItem[]>(`/ai/agents/skills/list`, req);
};
export const searchAgentSkills = (req: AI.AgentSkillSearchReq) => {
return http.post<AI.AgentSkillSearchItem[]>(`/ai/agents/skills/search`, req);
};
export const updateAgentSkill = (req: AI.AgentSkillUpdateReq) => {
return http.post(`/ai/agents/skills/update`, req);
};
export const installAgentSkill = (req: AI.AgentSkillInstallReq) => {
return http.post(`/ai/agents/skills/install`, req);
};
export const approveAgentChannelPairing = (req: AI.AgentChannelPairingApproveReq) => {
return http.post(`/ai/agents/channel/pairing/approve`, req);
};
+6
View File
@@ -706,6 +706,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub (Official)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -714,6 +714,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub (Official)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -707,6 +707,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub公式',
skillsMarketSourceSkillhub: 'SkillHubTencent',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -699,6 +699,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub (Official)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -714,6 +714,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub (Official)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -709,6 +709,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub (Official)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -706,6 +706,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub (Official)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -710,6 +710,12 @@ const message = {
'Go to Settings -> Other to configure the NPM registry and speed up plugin installation',
skillsSearchPlaceholder: 'Search skills...',
skillsEmpty: 'No skills',
skillsMarket: 'Skill Market',
skillsMarketHint: 'Select a source and search for skills',
skillsMarketEmpty: 'No matching skills found',
skillsMarketSourceClawhub: 'ClawHub (Official)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+6
View File
@@ -672,6 +672,12 @@ const message = {
pluginInstallNPMRegistryHelper: '可前往 設定 -> 其他 設定 NPM 以加速外掛安裝',
skillsSearchPlaceholder: '搜尋技能...',
skillsEmpty: '暫無技能',
skillsMarket: '技能市場',
skillsMarketHint: '請選擇來源並搜尋技能',
skillsMarketEmpty: '未找到相關技能',
skillsMarketSourceClawhub: 'ClawHub官方',
skillsMarketSourceSkillhub: 'SkillHub騰訊',
skillsScore: '評分',
versionUnsupportedTitle: '當前版本暫不支援該功能',
versionUnsupportedHelper: '請升級 OpenClaw {0} 或以上版本後使用',
skillsStatusDisabled: '已禁用',
+6
View File
@@ -671,6 +671,12 @@ const message = {
pluginInstallNPMRegistryHelper: '可前往 设置 -> 其他 配置 NPM 以加速插件安装',
skillsSearchPlaceholder: '搜索技能...',
skillsEmpty: '暂无技能',
skillsMarket: '技能市场',
skillsMarketHint: '请选择来源并搜索技能',
skillsMarketEmpty: '未找到相关技能',
skillsMarketSourceClawhub: 'ClawHub官方',
skillsMarketSourceSkillhub: 'SkillHub腾讯',
skillsScore: '评分',
versionUnsupportedTitle: '当前版本暂不支持该功能',
versionUnsupportedHelper: '请升级 OpenClaw {0} 或以上版本后使用',
skillsStatusDisabled: '已禁用',
@@ -1,56 +1,129 @@
<template>
<VersionSupport v-if="!supported" :min-version="openclawMinSupportedVersion" />
<div v-else v-loading="loading">
<el-radio-group v-model="mode" class="view-switch">
<el-radio-button label="installed">{{ t('app.installed') }}</el-radio-button>
<el-radio-button label="market">{{ t('aiTools.agents.skillsMarket') }}</el-radio-button>
</el-radio-group>
<div class="toolbar">
<el-input
v-model="keyword"
:placeholder="t('aiTools.agents.skillsSearchPlaceholder')"
clearable
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button :loading="loading" @click="loadSkills">
<el-icon><Refresh /></el-icon>
</el-button>
<template v-if="mode === 'installed'">
<el-input
v-model="installedKeyword"
:placeholder="t('aiTools.agents.skillsSearchPlaceholder')"
clearable
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button :loading="loading" @click="loadSkills">
<el-icon><Refresh /></el-icon>
</el-button>
</template>
<template v-else>
<el-select v-model="marketSource" class="p-w-200" @change="handleMarketSourceChange">
<el-option :value="'clawhub'" :label="t('aiTools.agents.skillsMarketSourceClawhub')" />
<el-option :value="'skillhub'" :label="t('aiTools.agents.skillsMarketSourceSkillhub')" />
</el-select>
<el-input
v-model="marketKeyword"
:placeholder="t('aiTools.agents.skillsSearchPlaceholder')"
clearable
class="search-input"
@keyup.enter="searchMarketSkills"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button :loading="searching" @click="searchMarketSkills">{{ t('commons.button.search') }}</el-button>
</template>
</div>
<div v-if="groupedSkills.length" class="group-list">
<section v-for="group in groupedSkills" :key="group.key" class="group-section">
<div class="group-header">
<span class="group-title">{{ group.label }}</span>
<span class="group-count">{{ group.items.length }}</span>
</div>
<div class="skills-grid">
<el-card v-for="skill in group.items" :key="skill.name" class="skill-card">
<div class="skill-head">
<div class="skill-name">{{ skill.name }}</div>
<el-switch
:model-value="!skill.disabled"
:loading="updatingSkill === skill.name"
@change="(value) => toggleSkill(skill, Boolean(value))"
/>
</div>
<el-tooltip placement="top-start" :show-after="200" popper-class="skill-desc-tooltip">
<template #content>
<div class="skill-desc-tooltip-content">{{ skill.description }}</div>
</template>
<div class="skill-desc">
{{ skill.description }}
<template v-if="mode === 'installed'">
<div v-if="groupedSkills.length" class="group-list">
<section v-for="group in groupedSkills" :key="group.key" class="group-section">
<div class="group-header">
<span class="group-title">{{ group.label }}</span>
<span class="group-count">{{ group.items.length }}</span>
</div>
<div class="skills-grid">
<el-card v-for="skill in group.items" :key="skill.name" class="skill-card">
<div class="skill-head">
<div class="skill-name">{{ skill.name }}</div>
<el-switch
:model-value="!skill.disabled"
:loading="updatingSkill === skill.name"
@change="(value) => toggleSkill(skill, Boolean(value))"
/>
</div>
</el-tooltip>
<div class="skill-tags">
<el-tag size="small" effect="plain">
{{ group.tagLabel }}
</el-tag>
<el-tooltip placement="top-start" :show-after="200" popper-class="skill-desc-tooltip">
<template #content>
<div class="skill-desc-tooltip-content">{{ skill.description }}</div>
</template>
<div class="skill-desc">
{{ skill.description }}
</div>
</el-tooltip>
<div class="skill-tags">
<el-tag size="small" type="primary" effect="plain">
{{ group.tagLabel }}
</el-tag>
</div>
</el-card>
</div>
</section>
</div>
<el-empty v-else :description="t('aiTools.agents.skillsEmpty')" />
</template>
<template v-else>
<div v-if="marketResults.length" class="skills-grid">
<el-card v-for="skill in marketResults" :key="`${marketSource}-${skill.slug}`" class="skill-card">
<div class="skill-head">
<div>
<div class="skill-name">{{ skill.name || skill.slug }}</div>
<div class="skill-slug">{{ skill.slug }}</div>
</div>
</el-card>
</div>
</section>
</div>
<el-empty v-else :description="t('aiTools.agents.skillsEmpty')" />
<el-button
type="primary"
link
:loading="installingSkill === skill.slug"
@click="installSkill(skill)"
>
{{ t('commons.button.install') }}
</el-button>
</div>
<el-tooltip
v-if="skill.description || skill.summary"
placement="top-start"
:show-after="200"
popper-class="skill-desc-tooltip"
>
<template #content>
<div class="skill-desc-tooltip-content">{{ skill.description || skill.summary }}</div>
</template>
<div class="skill-desc">
{{ skill.description || skill.summary }}
</div>
</el-tooltip>
<div class="skill-meta">
<span v-if="skill.version">{{ `${t('app.version')}: ${skill.version}` }}</span>
<span v-if="skill.score">{{ `${t('aiTools.agents.skillsScore')}: ${skill.score}` }}</span>
</div>
</el-card>
</div>
<el-empty
v-else
:description="
marketSearched ? t('aiTools.agents.skillsMarketEmpty') : t('aiTools.agents.skillsMarketHint')
"
/>
</template>
<TaskLog ref="taskLogRef" @close="handleTaskClose" />
</div>
</template>
@@ -59,12 +132,17 @@ import { computed, ref } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
import { AI } from '@/api/interface/ai';
import { listAgentSkills, updateAgentSkill } from '@/api/modules/ai';
import { installAgentSkill, listAgentSkills, searchAgentSkills, updateAgentSkill } from '@/api/modules/ai';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { MsgSuccess } from '@/utils/message';
import { newUUID } from '@/utils/util';
import { isOpenclawCurrentHTTPVersion } from '@/utils/agent';
import TaskLog from '@/components/log/task/index.vue';
import VersionSupport from './components/version-support.vue';
type SkillGroupKey = 'builtIn' | 'external' | 'workspace' | 'extra' | 'other';
type SkillViewMode = 'installed' | 'market';
type SkillMarketSource = 'clawhub' | 'skillhub';
const openclawMinSupportedVersion = '2026.3.23';
const props = defineProps<{
@@ -72,11 +150,20 @@ const props = defineProps<{
}>();
const { t } = useI18n();
const { isIntl } = useGlobalStore();
const loading = ref(false);
const keyword = ref('');
const searching = ref(false);
const mode = ref<SkillViewMode>('installed');
const installedKeyword = ref('');
const marketKeyword = ref('');
const marketSource = ref<SkillMarketSource>(isIntl.value ? 'clawhub' : 'skillhub');
const marketSearched = ref(false);
const agentId = ref(0);
const skills = ref<AI.AgentSkillItem[]>([]);
const marketResults = ref<AI.AgentSkillSearchItem[]>([]);
const updatingSkill = ref('');
const installingSkill = ref('');
const taskLogRef = ref<InstanceType<typeof TaskLog>>();
const supported = computed(() => isOpenclawCurrentHTTPVersion(props.appVersion));
const groupTagLabels = computed<Record<SkillGroupKey, string>>(() => ({
@@ -101,7 +188,7 @@ const groupLabels = computed<Record<SkillGroupKey, string>>(() => ({
}));
const filteredSkills = computed(() => {
const value = keyword.value.trim().toLowerCase();
const value = installedKeyword.value.trim().toLowerCase();
if (!value) {
return skills.value;
}
@@ -121,7 +208,7 @@ const groupedSkills = computed(() => {
for (const skill of filteredSkills.value) {
groups[resolveGroupKey(skill)].push(skill);
}
const order: SkillGroupKey[] = ['external', 'extra', 'builtIn', 'workspace', 'other'];
const order: SkillGroupKey[] = ['external', 'workspace', 'extra', 'other', 'builtIn'];
return order
.filter((key) => groups[key].length > 0)
.map((key) => ({
@@ -161,8 +248,35 @@ const loadSkills = async () => {
}
};
const searchMarketSkills = async () => {
if (!supported.value || !agentId.value || !marketKeyword.value.trim()) {
return;
}
searching.value = true;
try {
const res = await searchAgentSkills({
agentId: agentId.value,
source: marketSource.value,
keyword: marketKeyword.value.trim(),
});
marketResults.value = res.data || [];
marketSearched.value = true;
} finally {
searching.value = false;
}
};
const handleMarketSourceChange = () => {
marketResults.value = [];
marketSearched.value = false;
};
const load = async (id: number) => {
agentId.value = id;
mode.value = 'installed';
marketSource.value = isIntl.value ? 'clawhub' : 'skillhub';
marketResults.value = [];
marketSearched.value = false;
await loadSkills();
};
@@ -184,12 +298,39 @@ const toggleSkill = async (skill: AI.AgentSkillItem, enabled: boolean) => {
}
};
const installSkill = async (skill: AI.AgentSkillSearchItem) => {
if (!supported.value || !agentId.value) {
return;
}
const taskID = newUUID();
installingSkill.value = skill.slug;
try {
await installAgentSkill({
agentId: agentId.value,
source: skill.source as SkillMarketSource,
slug: skill.slug,
taskID,
});
taskLogRef.value?.openWithTaskID(taskID);
} finally {
installingSkill.value = '';
}
};
const handleTaskClose = async () => {
await loadSkills();
};
defineExpose({
load,
});
</script>
<style scoped lang="scss">
.view-switch {
margin-bottom: 16px;
}
.toolbar {
display: flex;
gap: 12px;
@@ -271,6 +412,20 @@ defineExpose({
margin-top: 12px;
}
.skill-slug {
margin-top: 4px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
.skill-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
color: var(--el-text-color-secondary);
}
:global(.skill-desc-tooltip) {
max-width: 360px;
}