feat: support skill management for hermes-agent (#12514)

This commit is contained in:
CityFun
2026-04-17 16:36:17 +08:00
committed by GitHub
parent 031a203a74
commit 12ae89923b
25 changed files with 1259 additions and 474 deletions
+20
View File
@@ -1171,6 +1171,26 @@ func (b *BaseApi) InstallAgentSkill(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Uninstall Agent skill
// @Accept json
// @Param request body dto.AgentSkillUninstallReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/skills/uninstall [post]
func (b *BaseApi) UninstallAgentSkill(c *gin.Context) {
var req dto.AgentSkillUninstallReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UninstallSkill(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Login Agent Weixin channel
// @Accept json
+19 -7
View File
@@ -590,25 +590,32 @@ type AgentConfigFile struct {
type AgentSkillSearchReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub official skills-sh"`
Keyword string `json:"keyword" validate:"required"`
}
type AgentSkillItem struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Bundled bool `json:"bundled"`
Disabled bool `json:"disabled"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
Tags []string `json:"tags"`
Source string `json:"source"`
Trust string `json:"trust"`
Identifier string `json:"identifier"`
Bundled bool `json:"bundled"`
Disabled bool `json:"disabled"`
Uninstallable bool `json:"uninstallable"`
}
type AgentSkillSearchItem struct {
Slug string `json:"slug"`
Identifier string `json:"identifier"`
Name string `json:"name"`
Description string `json:"description"`
Summary string `json:"summary"`
Version string `json:"version"`
Source string `json:"source"`
Trust string `json:"trust"`
Score string `json:"score"`
}
@@ -620,7 +627,12 @@ type AgentSkillUpdateReq struct {
type AgentSkillInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub official skills-sh"`
Slug string `json:"slug" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentSkillUninstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Name string `json:"name" validate:"required"`
}
+4 -3
View File
@@ -53,6 +53,7 @@ type IAgentService interface {
SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error)
UpdateSkill(req dto.AgentSkillUpdateReq) error
InstallSkill(req dto.AgentSkillInstallReq) error
UninstallSkill(req dto.AgentSkillUninstallReq) error
CreateRole(req dto.AgentRoleCreateReq) (*dto.AgentRoleCreateResp, error)
DeleteRole(req dto.AgentRoleDeleteReq) error
@@ -455,9 +456,9 @@ func (a AgentService) GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig,
if err != nil {
return nil, err
}
model := resolveHermesConfiguredModelID(account, accountModels, cfg.Model.Default)
if model == "" {
model = agent.Model
model, err := resolveHermesConfiguredModelIDStrict(account, accountModels, cfg.Model.Default)
if err != nil {
return nil, err
}
return &dto.AgentModelConfig{
AccountID: agent.AccountID,
+21
View File
@@ -10,6 +10,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/common"
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
@@ -19,6 +20,7 @@ import (
)
const hermesWorkspaceDir = "/opt/data/workspace"
const hermesExecutablePath = "/opt/hermes/.venv/bin/hermes"
type hermesConfig struct {
Model hermesModelConfig `yaml:"model"`
@@ -52,6 +54,17 @@ func buildHermesDockerExecArgs(containerName string, hermesArgs ...string) []str
return buildHermesDockerExecCommandArgs(containerName, "hermes", hermesArgs...)
}
func buildHermesSkillUninstallArgs(containerName, skillName string) []string {
return buildHermesDockerExecCommandArgs(
containerName,
"sh",
"-lc",
fmt.Sprintf(`printf 'y\n' | %s skills uninstall "$1"`, hermesExecutablePath),
"sh",
skillName,
)
}
func writeHermesConfig(confDir string, account *model.AgentAccount, modelName string, timezone string) error {
if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required")
@@ -386,6 +399,14 @@ func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels [
return ""
}
func resolveHermesConfiguredModelIDStrict(account *model.AgentAccount, accountModels []dto.AgentAccountModel, configuredModel string) (string, error) {
modelID := resolveHermesConfiguredModelID(account, accountModels, configuredModel)
if modelID == "" {
return "", buserr.New("ErrAgentModelNotInAccount")
}
return modelID, nil
}
func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
if account == nil {
return nil
+7 -4
View File
@@ -57,13 +57,15 @@ func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error)
}
groupAllowFrom := extractStringList(extra["group_allow_from"])
return &dto.AgentQQBotConfig{
result := &dto.AgentQQBotConfig{
Enabled: extractBoolValue(platform["enabled"], false) && appID != "" && clientSecret != "",
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
GroupPolicy: groupPolicy,
GroupAllowFrom: groupAllowFrom,
Bots: []dto.AgentQQBotBot{
}
if appID != "" || clientSecret != "" {
result.Bots = []dto.AgentQQBotBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
@@ -74,8 +76,9 @@ func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error)
AppID: appID,
ClientSecret: clientSecret,
},
},
}, nil
}
}
return result, nil
}
func writeHermesQQBotChannelConfig(confDir string, config dto.AgentQQBotConfig) error {
+215
View File
@@ -0,0 +1,215 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
type hermesSkillsListEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
}
type hermesSkillsListPayload struct {
Skills []hermesSkillsListEntry `json:"skills"`
}
func listHermesSkills(containerName string) ([]dto.AgentSkillItem, error) {
output, err := runHermesSkillsCommandWithStdout(2*time.Minute, containerName, "list", "--source", "all")
if err != nil {
return nil, err
}
items, err := parseHermesSkillsListOutput(output)
if err != nil {
return nil, err
}
metadata, err := readHermesSkillsListMetadata(containerName)
if err != nil {
return nil, err
}
return mergeHermesSkillsWithMetadata(items, metadata), nil
}
func searchHermesSkills(containerName, source, keyword string) ([]dto.AgentSkillSearchItem, error) {
if source != "official" && source != "skills-sh" {
return nil, fmt.Errorf("unsupported hermes skill source: %s", source)
}
output, err := runHermesSkillsCommandWithStdout(
2*time.Minute,
containerName,
"search",
keyword,
"--source",
source,
"--limit",
"20",
)
if err != nil {
return nil, err
}
return parseHermesSkillSearchOutput(output)
}
func runHermesSkillsCommandWithStdout(timeout time.Duration, containerName string, hermesArgs ...string) (string, error) {
args := []string{"exec", "-e", "COLUMNS=240", "-u", "hermes", containerName, "hermes", "skills"}
args = append(args, hermesArgs...)
return cmd.NewCommandMgr(cmd.WithTimeout(timeout)).RunWithStdout("docker", args...)
}
func readHermesSkillsListMetadata(containerName string) (map[string]hermesSkillsListEntry, error) {
output, err := cmd.NewCommandMgr(cmd.WithTimeout(2*time.Minute)).RunWithStdout(
"docker",
"exec",
"-u",
"hermes",
containerName,
"python",
"-c",
"import sys; sys.path.insert(0, '/opt/hermes'); from tools.skills_tool import skills_list; print(skills_list())",
)
if err != nil {
return nil, err
}
return parseHermesSkillsListMetadataOutput(output)
}
func parseHermesSkillsListOutput(output string) ([]dto.AgentSkillItem, error) {
headers, rows, err := parseHermesTableOutput(output)
if err != nil {
return nil, err
}
items := make([]dto.AgentSkillItem, 0, len(rows))
for _, row := range rows {
item := dto.AgentSkillItem{
Name: row[headers["Name"]],
Category: row[headers["Category"]],
Source: row[headers["Source"]],
Trust: row[headers["Trust"]],
Uninstallable: row[headers["Source"]] != "builtin" && row[headers["Source"]] != "local",
}
items = append(items, item)
}
return items, nil
}
func parseHermesSkillsListMetadataOutput(output string) (map[string]hermesSkillsListEntry, error) {
if strings.TrimSpace(output) == "" {
return map[string]hermesSkillsListEntry{}, nil
}
var payload hermesSkillsListPayload
if err := json.Unmarshal([]byte(output), &payload); err != nil {
return nil, err
}
metadata := make(map[string]hermesSkillsListEntry, len(payload.Skills))
for _, skill := range payload.Skills {
if skill.Name == "" {
continue
}
metadata[skill.Name] = skill
}
return metadata, nil
}
func mergeHermesSkillsWithMetadata(items []dto.AgentSkillItem, metadata map[string]hermesSkillsListEntry) []dto.AgentSkillItem {
for i := range items {
entry, ok := metadata[items[i].Name]
if !ok {
continue
}
items[i].Description = entry.Description
if items[i].Category == "" && entry.Category != "" {
items[i].Category = entry.Category
}
}
return items
}
func parseHermesSkillSearchOutput(output string) ([]dto.AgentSkillSearchItem, error) {
if strings.Contains(output, "No skills found matching your query.") {
return []dto.AgentSkillSearchItem{}, nil
}
headers, rows, err := parseHermesTableOutput(output)
if err != nil {
return nil, err
}
items := make([]dto.AgentSkillSearchItem, 0, len(rows))
for _, row := range rows {
identifier := row[headers["Identifier"]]
items = append(items, dto.AgentSkillSearchItem{
Slug: identifier,
Identifier: identifier,
Name: row[headers["Name"]],
Description: row[headers["Description"]],
Source: row[headers["Source"]],
Trust: row[headers["Trust"]],
})
}
return items, nil
}
func parseHermesTableOutput(output string) (map[string]int, [][]string, error) {
lines := strings.Split(strings.TrimSpace(ansiEscapePattern.ReplaceAllString(output, "")), "\n")
var headers []string
rows := make([][]string, 0)
var current []string
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if (!strings.HasPrefix(line, "│") || !strings.HasSuffix(line, "│")) &&
(!strings.HasPrefix(line, "┃") || !strings.HasSuffix(line, "┃")) {
continue
}
line = strings.ReplaceAll(line, "┃", "│")
parts := strings.Split(line, "│")
if len(parts) < 3 {
continue
}
cols := make([]string, 0, len(parts)-2)
for _, part := range parts[1 : len(parts)-1] {
cols = append(cols, strings.TrimSpace(part))
}
if len(headers) == 0 {
headers = cols
continue
}
if cols[0] != "" {
if current != nil {
rows = append(rows, current)
}
current = cols
continue
}
if current == nil {
continue
}
for i := range cols {
if cols[i] == "" {
continue
}
if current[i] == "" {
current[i] = cols[i]
continue
}
current[i] = current[i] + " " + cols[i]
}
}
if current != nil {
rows = append(rows, current)
}
if len(headers) == 0 {
return nil, nil, fmt.Errorf("hermes skills table not found")
}
headerIndex := make(map[string]int, len(headers))
for i, header := range headers {
headerIndex[header] = i
}
return headerIndex, rows, nil
}
+48 -3
View File
@@ -9,6 +9,7 @@ import (
"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"
)
@@ -41,13 +42,19 @@ var clawhubSearchLinePattern = regexp.MustCompile(`^(\S+)\s+(.+?)\s+\(([\d.]+)\)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return nil, err
}
if agent.AgentType == constant.AppHermesAgent {
return listHermesSkills(install.ContainerName)
}
if agent.AgentType != constant.AppOpenclaw {
return nil, fmt.Errorf("%s does not support", agent.AgentType)
}
output, err := runDockerExecWithStdout(5*time.Minute, install.ContainerName, "sh", "-c", "openclaw skills list --json 2>&1")
if err != nil {
return nil, err
@@ -59,13 +66,19 @@ func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, erro
}
func (a AgentService) SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return nil, err
}
if agent.AgentType == constant.AppHermesAgent {
return searchHermesSkills(install.ContainerName, req.Source, req.Keyword)
}
if agent.AgentType != constant.AppOpenclaw {
return nil, fmt.Errorf("%s does not support", agent.AgentType)
}
output, err := loadOpenclawSkillSearchOutput(install.ContainerName, req.Source, req.Keyword)
if err != nil {
return nil, err
@@ -102,7 +115,7 @@ func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
}
func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
@@ -113,6 +126,21 @@ func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
if err != nil {
return err
}
if agent.AgentType == constant.AppHermesAgent {
installTask.AddSubTask("Install Hermes skill", func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
return mgr.Run("docker", buildHermesDockerExecArgs(install.ContainerName, "skills", "install", req.Slug, "--yes")...)
}, nil)
go func() {
if err := installTask.Execute(); err != nil {
global.LOG.Errorf("install hermes skill failed: %v", err)
}
}()
return nil
}
if agent.AgentType != constant.AppOpenclaw {
return fmt.Errorf("%s does not support", agent.AgentType)
}
installTask.AddSubTask("Install OpenClaw skill", func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
return mgr.Run("docker", "exec", install.ContainerName, "sh", "-c", buildOpenclawSkillInstallCommand(req.Source, req.Slug))
@@ -125,6 +153,23 @@ func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
return nil
}
func (a AgentService) UninstallSkill(req dto.AgentSkillUninstallReq) error {
agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if agent.AgentType != constant.AppHermesAgent {
return fmt.Errorf("%s does not support", agent.AgentType)
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
return cmd.NewCommandMgr(cmd.WithTimeout(5*time.Minute)).Run(
"docker",
buildHermesSkillUninstallArgs(install.ContainerName, req.Name)...,
)
}
func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
payloadBytes, err := extractEmbeddedJSON(output)
if err != nil {
+1
View File
@@ -98,6 +98,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/skills/search", baseApi.SearchAgentSkills)
aiToolsRouter.POST("/agents/skills/update", baseApi.UpdateAgentSkill)
aiToolsRouter.POST("/agents/skills/install", baseApi.InstallAgentSkill)
aiToolsRouter.POST("/agents/skills/uninstall", baseApi.UninstallAgentSkill)
aiToolsRouter.POST("/agents/channel/pairing/approve", baseApi.ApproveAgentChannelPairing)
}
}
+15 -3
View File
@@ -621,7 +621,7 @@ export namespace AI {
export interface AgentChannelPairingApproveReq {
agentId: number;
type: 'feishu' | 'telegram' | 'discord' | 'wecom';
type: 'feishu' | 'telegram' | 'discord' | 'wecom' | 'qqbot';
pairingCode: string;
accountId?: string;
}
@@ -845,25 +845,32 @@ export namespace AI {
export interface AgentSkillSearchReq {
agentId: number;
source: 'clawhub-global' | 'clawhub-cn' | 'skillhub';
source: 'clawhub-global' | 'clawhub-cn' | 'skillhub' | 'official' | 'skills-sh';
keyword: string;
}
export interface AgentSkillItem {
name: string;
description: string;
category: string;
tags: string[];
source: string;
trust: string;
identifier: string;
bundled: boolean;
disabled: boolean;
uninstallable: boolean;
}
export interface AgentSkillSearchItem {
slug: string;
identifier: string;
name: string;
description: string;
summary: string;
version: string;
source: string;
trust: string;
score: string;
}
@@ -875,8 +882,13 @@ export namespace AI {
export interface AgentSkillInstallReq {
agentId: number;
source: 'clawhub-global' | 'clawhub-cn' | 'skillhub';
source: 'clawhub-global' | 'clawhub-cn' | 'skillhub' | 'official' | 'skills-sh';
slug: string;
taskID: string;
}
export interface AgentSkillUninstallReq {
agentId: number;
name: string;
}
}
+4
View File
@@ -326,6 +326,10 @@ export const installAgentSkill = (req: AI.AgentSkillInstallReq) => {
return http.post(`/ai/agents/skills/install`, req);
};
export const uninstallAgentSkill = (req: AI.AgentSkillUninstallReq) => {
return http.post(`/ai/agents/skills/uninstall`, req);
};
export const approveAgentChannelPairing = (req: AI.AgentChannelPairingApproveReq) => {
return http.post(`/ai/agents/channel/pairing/approve`, req, TimeoutEnum.T_5M);
};
+1
View File
@@ -719,6 +719,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub (China)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Score',
skillsMarketSourceOfficial: 'Official',
versionUnsupportedTitle: 'This feature is not supported in the current version',
versionUnsupportedHelper: 'Please upgrade OpenClaw to version {0} or later.',
skillsStatusDisabled: 'Disabled',
+1
View File
@@ -726,6 +726,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub (China)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Puntuación',
skillsMarketSourceOfficial: 'Oficial',
versionUnsupportedTitle: 'Esta función no es compatible con la versión actual',
versionUnsupportedHelper: 'Actualice OpenClaw a la versión {0} o posterior.',
skillsStatusDisabled: 'Deshabilitado',
+1
View File
@@ -720,6 +720,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub中国',
skillsMarketSourceSkillhub: 'SkillHubTencent',
skillsScore: 'スコア',
skillsMarketSourceOfficial: '公式',
versionUnsupportedTitle: '現在のバージョンではこの機能はサポートされていません',
versionUnsupportedHelper: 'OpenClaw をバージョン {0} 以降にアップグレードしてください',
skillsStatusDisabled: '無効',
+1
View File
@@ -712,6 +712,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub (중국)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: '점수',
skillsMarketSourceOfficial: '공식',
versionUnsupportedTitle: '현재 버전에서는 기능을 지원하지 않습니다',
versionUnsupportedHelper: 'OpenClaw 버전 {0} 이상으로 업그레이드하세요.',
skillsStatusDisabled: '비활성화됨',
+1
View File
@@ -727,6 +727,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub (China)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Skor',
skillsMarketSourceOfficial: 'Rasmi',
versionUnsupportedTitle: 'Ciri ini tidak disokong dalam versi semasa',
versionUnsupportedHelper: 'Sila naik taraf OpenClaw ke versi {0} atau lebih baharu.',
skillsStatusDisabled: 'Dilumpuhkan',
+1
View File
@@ -721,6 +721,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub (China)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Pontuação',
skillsMarketSourceOfficial: 'Oficial',
versionUnsupportedTitle: 'Este recurso não é compatível com a versão atual',
versionUnsupportedHelper: 'Atualize o OpenClaw para a versão {0} ou superior.',
skillsStatusDisabled: 'Desativado',
+1
View File
@@ -719,6 +719,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub (Китай)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Оценка',
skillsMarketSourceOfficial: 'Официальный',
versionUnsupportedTitle: 'Эта функция не поддерживается в текущей версии',
versionUnsupportedHelper: 'Пожалуйста, обновите OpenClaw до версии {0} или выше.',
skillsStatusDisabled: 'Отключено',
+1
View File
@@ -723,6 +723,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub (Çin)',
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
skillsScore: 'Puan',
skillsMarketSourceOfficial: 'Resmi',
versionUnsupportedTitle: 'Bu özellik mevcut sürümde desteklenmiyor',
versionUnsupportedHelper: 'Lütfen OpenClawı {0} veya üzeri bir sürüme yükseltin.',
skillsStatusDisabled: 'Devre dışı',
+1
View File
@@ -685,6 +685,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub(中國)',
skillsMarketSourceSkillhub: 'SkillHub(騰訊)',
skillsScore: '評分',
skillsMarketSourceOfficial: '官方',
versionUnsupportedTitle: '當前版本暫不支援該功能',
versionUnsupportedHelper: '請升級 OpenClaw 到 {0} 或以上版本後使用。',
skillsStatusDisabled: '已禁用',
+1
View File
@@ -684,6 +684,7 @@ const message = {
skillsMarketSourceClawhubChina: 'ClawHub(中国)',
skillsMarketSourceSkillhub: 'SkillHub(腾讯)',
skillsScore: '评分',
skillsMarketSourceOfficial: '官方',
versionUnsupportedTitle: '当前版本暂不支持该功能',
versionUnsupportedHelper: '请升级 OpenClaw 到 {0} 或以上版本后使用。',
skillsStatusDisabled: '已禁用',
@@ -15,8 +15,12 @@
<el-tab-pane v-if="agentType === 'openclaw'" :label="t('aiTools.agents.agentRoleTab')" name="agent">
<AgentTab ref="agentRef" />
</el-tab-pane>
<el-tab-pane v-if="agentType === 'openclaw'" :label="t('aiTools.agents.skillsTab')" name="skills">
<SkillsTab ref="skillsRef" :app-version="appVersion" />
<el-tab-pane
v-if="agentType === 'openclaw' || agentType === 'hermes-agent'"
:label="t('aiTools.agents.skillsTab')"
name="skills"
>
<SkillsTab ref="skillsRef" :app-version="appVersion" :agent-type="agentType" />
</el-tab-pane>
<el-tab-pane
v-if="agentType === 'openclaw' || agentType === 'hermes-agent'"
@@ -1,468 +1,31 @@
<template>
<VersionSupport v-if="!supported" :min-version="openclawMinSupportedVersion" />
<div v-else>
<el-radio-group v-model="mode" class="view-switch" @change="handleModeChange">
<el-radio-button label="market">{{ t('aiTools.agents.skillsMarket') }}</el-radio-button>
<el-radio-button label="installed">{{ t('app.installed') }}</el-radio-button>
</el-radio-group>
<div v-loading="loading" class="skills-content">
<div class="toolbar">
<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-global'"
:label="t('aiTools.agents.skillsMarketSourceClawhubGlobal')"
/>
<el-option :value="'clawhub-cn'" :label="t('aiTools.agents.skillsMarketSourceClawhubChina')" />
<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>
<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
placement="bottom-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-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="bottom-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>
</div>
<TaskLog ref="taskLogRef" @close="handleTaskClose" />
</div>
<OpenclawSkills v-if="agentType === 'openclaw'" ref="openclawRef" :app-version="appVersion" />
<HermesSkills v-else-if="agentType === 'hermes-agent'" ref="hermesRef" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { AI } from '@/api/interface/ai';
import { installAgentSkill, listAgentSkills, searchAgentSkills, updateAgentSkill } from '@/api/modules/ai';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { MsgSuccess } from '@/utils/message';
import { newUUID } from '@/utils/id';
import { isOpenclawCurrentHTTPVersion } from '@/utils/agent';
import TaskLog from '@/components/log/task/index.vue';
import VersionSupport from './components/version-support.vue';
import OpenclawSkills from './skills/openclaw.vue';
import HermesSkills from './skills/hermes.vue';
type SkillGroupKey = 'builtIn' | 'external' | 'workspace' | 'extra' | 'other';
type SkillViewMode = 'installed' | 'market';
type SkillMarketSource = 'clawhub-global' | 'clawhub-cn' | 'skillhub';
const openclawMinSupportedVersion = '2026.3.23';
const props = defineProps<{
appVersion: string;
agentType: AI.AgentType;
}>();
const { t } = useI18n();
const { isIntl } = useGlobalStore();
const loading = ref(false);
const searching = ref(false);
const mode = ref<SkillViewMode>('market');
const installedKeyword = ref('');
const marketKeyword = ref('');
const getDefaultMarketSource = (): SkillMarketSource => (isIntl.value ? 'clawhub-global' : 'clawhub-cn');
const marketSource = ref<SkillMarketSource>(getDefaultMarketSource());
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 installedLoaded = ref(false);
const supported = computed(() => isOpenclawCurrentHTTPVersion(props.appVersion));
const groupTagLabels = computed<Record<SkillGroupKey, string>>(() => ({
builtIn: t('aiTools.agents.skillsGroupBuiltIn'),
external: t('aiTools.agents.skillsGroupExternal'),
workspace: t('aiTools.agents.skillsGroupWorkspace'),
extra: t('runtime.extension'),
other: t('aiTools.agents.otherTab'),
}));
const formatSkillGroupLabel = (label: string) => {
const suffix = t('aiTools.agents.skillsTab');
return /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(suffix) ? `${label}${suffix}` : `${label} ${suffix}`;
};
const groupLabels = computed<Record<SkillGroupKey, string>>(() => ({
builtIn: formatSkillGroupLabel(groupTagLabels.value.builtIn),
external: formatSkillGroupLabel(groupTagLabels.value.external),
workspace: formatSkillGroupLabel(groupTagLabels.value.workspace),
extra: formatSkillGroupLabel(groupTagLabels.value.extra),
other: formatSkillGroupLabel(groupTagLabels.value.other),
}));
const filteredSkills = computed(() => {
const value = installedKeyword.value.trim().toLowerCase();
if (!value) {
return skills.value;
}
return skills.value.filter((item) =>
[item.name, item.description, item.source].join(' ').toLowerCase().includes(value),
);
});
const groupedSkills = computed(() => {
const groups: Record<SkillGroupKey, AI.AgentSkillItem[]> = {
builtIn: [],
external: [],
workspace: [],
extra: [],
other: [],
};
for (const skill of filteredSkills.value) {
groups[resolveGroupKey(skill)].push(skill);
}
const order: SkillGroupKey[] = ['external', 'workspace', 'extra', 'other', 'builtIn'];
return order
.filter((key) => groups[key].length > 0)
.map((key) => ({
key,
label: groupLabels.value[key],
tagLabel: groupTagLabels.value[key],
items: groups[key],
}));
});
const resolveGroupKey = (skill: AI.AgentSkillItem): SkillGroupKey => {
if (skill.bundled || skill.source === 'openclaw-bundled') {
return 'builtIn';
}
if (skill.source === 'openclaw-managed') {
return 'external';
}
if (skill.source === 'openclaw-workspace') {
return 'workspace';
}
if (skill.source === 'openclaw-extra') {
return 'extra';
}
return 'other';
};
const loadSkills = async () => {
if (!supported.value || !agentId.value) {
return;
}
loading.value = true;
try {
const res = await listAgentSkills({ agentId: agentId.value });
skills.value = res.data || [];
installedLoaded.value = true;
} finally {
loading.value = false;
}
};
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 handleModeChange = async (value: SkillViewMode) => {
if (value !== 'installed' || installedLoaded.value) {
return;
}
await loadSkills();
};
const openclawRef = ref<InstanceType<typeof OpenclawSkills>>();
const hermesRef = ref<InstanceType<typeof HermesSkills>>();
const load = async (id: number) => {
agentId.value = id;
mode.value = 'market';
marketSource.value = getDefaultMarketSource();
marketResults.value = [];
marketSearched.value = false;
skills.value = [];
installedLoaded.value = false;
};
const toggleSkill = async (skill: AI.AgentSkillItem, enabled: boolean) => {
if (!supported.value || !agentId.value) {
if (props.agentType === 'openclaw') {
await openclawRef.value?.load(id);
return;
}
updatingSkill.value = skill.name;
try {
await updateAgentSkill({
agentId: agentId.value,
name: skill.name,
enabled,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
await loadSkills();
} finally {
updatingSkill.value = '';
}
};
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 () => {
if (mode.value === 'installed') {
await loadSkills();
return;
}
installedLoaded.value = false;
await hermesRef.value?.load(id);
};
defineExpose({
load,
});
</script>
<style scoped lang="scss">
.view-switch {
margin-bottom: 16px;
}
.skills-content {
min-height: 200px;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.search-input {
flex: 1;
}
.group-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.group-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
min-height: 42px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: var(--el-fill-color-light);
}
.group-title,
.group-count {
color: var(--el-text-color-secondary);
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
padding: 2px;
}
.skill-card {
--el-card-border-color: var(--el-border-color-dark);
border-radius: 12px;
}
.skill-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.skill-name {
font-weight: 600;
font-size: 16px;
}
.skill-desc {
margin-top: 12px;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.skill-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
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;
}
:global(.skill-desc-tooltip .skill-desc-tooltip-content) {
white-space: normal;
word-break: break-word;
}
</style>
@@ -0,0 +1,406 @@
<template>
<div>
<el-radio-group v-model="mode" class="view-switch" @change="handleModeChange">
<el-radio-button label="market">{{ t('aiTools.agents.skillsMarket') }}</el-radio-button>
<el-radio-button label="installed">{{ t('app.installed') }}</el-radio-button>
</el-radio-group>
<div v-loading="loading" class="skills-content">
<div class="toolbar">
<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="loadInstalledSkills">
<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="'official'" :label="t('aiTools.agents.skillsMarketSourceOfficial')" />
<el-option :value="'skills-sh'" label="skills.sh" />
</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>
<template v-if="mode === 'installed'">
<div v-if="groupedInstalledSkills.length" class="group-list">
<section v-for="group in groupedInstalledSkills" :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-button
v-if="skill.uninstallable"
link
type="danger"
:loading="uninstallingSkill === skill.name"
@click="uninstallSkill(skill)"
>
{{ t('commons.button.uninstall') }}
</el-button>
</div>
<div v-if="skill.description || getInstalledSkillSummary(skill)" class="skill-desc">
{{ skill.description || getInstalledSkillSummary(skill) }}
</div>
<div class="skill-tags">
<el-tag v-if="skill.category" size="small" type="primary" effect="plain">
{{ skill.category }}
</el-tag>
<el-tag
v-for="tag in getInstalledSkillTags(skill)"
:key="`${skill.name}-${tag}`"
size="small"
effect="plain"
>
{{ tag }}
</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="skill.identifier || skill.slug" class="skill-card">
<div class="skill-head">
<div>
<div class="skill-name">{{ skill.name || skill.slug }}</div>
<div class="skill-slug">{{ skill.identifier || skill.slug }}</div>
</div>
<el-button
type="primary"
link
:loading="installingSkill === (skill.identifier || skill.slug)"
@click="installSkill(skill)"
>
{{ t('commons.button.install') }}
</el-button>
</div>
<div v-if="skill.description || skill.summary" class="skill-desc">
{{ skill.description || skill.summary }}
</div>
<div class="skill-tags">
<el-tag size="small" type="primary" effect="plain">
{{ skill.source || marketSource }}
</el-tag>
<el-tag v-if="skill.trust" size="small" effect="plain">
{{ skill.trust }}
</el-tag>
</div>
</el-card>
</div>
<el-empty
v-else
:description="
marketSearched ? t('aiTools.agents.skillsMarketEmpty') : t('aiTools.agents.skillsMarketHint')
"
/>
</template>
</div>
<TaskLog ref="taskLogRef" @close="handleTaskClose" />
</div>
</template>
<script setup lang="ts">
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 { installAgentSkill, listAgentSkills, searchAgentSkills, uninstallAgentSkill } from '@/api/modules/ai';
import { MsgSuccess } from '@/utils/message';
import { newUUID } from '@/utils/id';
import TaskLog from '@/components/log/task/index.vue';
type SkillViewMode = 'installed' | 'market';
type HermesSkillMarketSource = 'official' | 'skills-sh';
const { t } = useI18n();
const loading = ref(false);
const searching = ref(false);
const mode = ref<SkillViewMode>('market');
const installedKeyword = ref('');
const marketKeyword = ref('');
const marketSource = ref<HermesSkillMarketSource>('official');
const marketSearched = ref(false);
const agentId = ref(0);
const installedSkills = ref<AI.AgentSkillItem[]>([]);
const marketResults = ref<AI.AgentSkillSearchItem[]>([]);
const installingSkill = ref('');
const uninstallingSkill = ref('');
const taskLogRef = ref<InstanceType<typeof TaskLog>>();
const filteredInstalledSkills = computed(() => {
const keyword = installedKeyword.value.trim().toLowerCase();
if (!keyword) {
return installedSkills.value;
}
return installedSkills.value.filter((item) =>
[item.name, item.description, item.category, item.source, item.trust].join(' ').toLowerCase().includes(keyword),
);
});
const groupedInstalledSkills = computed(() => {
const installedExtensions: AI.AgentSkillItem[] = [];
const builtInSkills: AI.AgentSkillItem[] = [];
for (const skill of filteredInstalledSkills.value) {
if (skill.uninstallable) {
installedExtensions.push(skill);
continue;
}
builtInSkills.push(skill);
}
return [
{
key: 'installed-extensions',
label: t('app.installed'),
items: installedExtensions,
},
{
key: 'built-in',
label: t('aiTools.agents.skillsGroupBuiltIn'),
items: builtInSkills,
},
].filter((group) => group.items.length > 0);
});
const getInstalledSkillSummary = (skill: AI.AgentSkillItem) => {
return [skill.category, skill.source, skill.trust].filter(Boolean).join(' / ');
};
const getInstalledSkillTags = (skill: AI.AgentSkillItem) => {
const tags = skill.tags?.slice(0, 3) || [];
if (tags.length > 0) {
return tags;
}
if (skill.source && skill.source === skill.trust) {
return [skill.source];
}
return [skill.source, skill.trust].filter(Boolean);
};
const loadInstalledSkills = async () => {
if (!agentId.value) {
return;
}
loading.value = true;
try {
const res = await listAgentSkills({ agentId: agentId.value });
installedSkills.value = res.data || [];
} finally {
loading.value = false;
}
};
const searchMarketSkills = async () => {
if (!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 handleModeChange = async (value: SkillViewMode) => {
if (value !== 'installed') {
return;
}
await loadInstalledSkills();
};
const load = async (id: number) => {
agentId.value = id;
mode.value = 'market';
installedKeyword.value = '';
marketKeyword.value = '';
marketSource.value = 'official';
marketSearched.value = false;
installedSkills.value = [];
marketResults.value = [];
};
const installSkill = async (skill: AI.AgentSkillSearchItem) => {
if (!agentId.value) {
return;
}
const slug = skill.identifier || skill.slug;
const taskID = newUUID();
installingSkill.value = slug;
try {
await installAgentSkill({
agentId: agentId.value,
source: marketSource.value,
slug,
taskID,
});
taskLogRef.value?.openWithTaskID(taskID);
} finally {
installingSkill.value = '';
}
};
const uninstallSkill = async (skill: AI.AgentSkillItem) => {
if (!agentId.value) {
return;
}
uninstallingSkill.value = skill.name;
try {
await uninstallAgentSkill({
agentId: agentId.value,
name: skill.name,
});
MsgSuccess(t('commons.msg.uninstallSuccess'));
await loadInstalledSkills();
} finally {
uninstallingSkill.value = '';
}
};
const handleTaskClose = async () => {
await loadInstalledSkills();
};
defineExpose({
load,
});
</script>
<style scoped lang="scss">
.view-switch {
margin-bottom: 16px;
}
.skills-content {
min-height: 200px;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.search-input {
flex: 1;
}
.group-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.group-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
min-height: 42px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: var(--el-fill-color-light);
}
.group-title,
.group-count {
color: var(--el-text-color-secondary);
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
padding: 2px;
}
.skill-card {
--el-card-border-color: var(--el-border-color-dark);
border-radius: 12px;
}
.skill-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.skill-name {
font-weight: 600;
font-size: 16px;
}
.skill-desc {
margin-top: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.skill-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.skill-slug {
margin-top: 4px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
</style>
@@ -0,0 +1,468 @@
<template>
<VersionSupport v-if="!supported" :min-version="openclawMinSupportedVersion" />
<div v-else>
<el-radio-group v-model="mode" class="view-switch" @change="handleModeChange">
<el-radio-button label="market">{{ t('aiTools.agents.skillsMarket') }}</el-radio-button>
<el-radio-button label="installed">{{ t('app.installed') }}</el-radio-button>
</el-radio-group>
<div v-loading="loading" class="skills-content">
<div class="toolbar">
<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-global'"
:label="t('aiTools.agents.skillsMarketSourceClawhubGlobal')"
/>
<el-option :value="'clawhub-cn'" :label="t('aiTools.agents.skillsMarketSourceClawhubChina')" />
<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>
<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
placement="bottom-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-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="bottom-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>
</div>
<TaskLog ref="taskLogRef" @close="handleTaskClose" />
</div>
</template>
<script setup lang="ts">
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 { installAgentSkill, listAgentSkills, searchAgentSkills, updateAgentSkill } from '@/api/modules/ai';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { MsgSuccess } from '@/utils/message';
import { newUUID } from '@/utils/id';
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-global' | 'clawhub-cn' | 'skillhub';
const openclawMinSupportedVersion = '2026.3.23';
const props = defineProps<{
appVersion: string;
}>();
const { t } = useI18n();
const { isIntl } = useGlobalStore();
const loading = ref(false);
const searching = ref(false);
const mode = ref<SkillViewMode>('market');
const installedKeyword = ref('');
const marketKeyword = ref('');
const getDefaultMarketSource = (): SkillMarketSource => (isIntl.value ? 'clawhub-global' : 'clawhub-cn');
const marketSource = ref<SkillMarketSource>(getDefaultMarketSource());
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 installedLoaded = ref(false);
const supported = computed(() => isOpenclawCurrentHTTPVersion(props.appVersion));
const groupTagLabels = computed<Record<SkillGroupKey, string>>(() => ({
builtIn: t('aiTools.agents.skillsGroupBuiltIn'),
external: t('aiTools.agents.skillsGroupExternal'),
workspace: t('aiTools.agents.skillsGroupWorkspace'),
extra: t('runtime.extension'),
other: t('aiTools.agents.otherTab'),
}));
const formatSkillGroupLabel = (label: string) => {
const suffix = t('aiTools.agents.skillsTab');
return /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(suffix) ? `${label}${suffix}` : `${label} ${suffix}`;
};
const groupLabels = computed<Record<SkillGroupKey, string>>(() => ({
builtIn: formatSkillGroupLabel(groupTagLabels.value.builtIn),
external: formatSkillGroupLabel(groupTagLabels.value.external),
workspace: formatSkillGroupLabel(groupTagLabels.value.workspace),
extra: formatSkillGroupLabel(groupTagLabels.value.extra),
other: formatSkillGroupLabel(groupTagLabels.value.other),
}));
const filteredSkills = computed(() => {
const value = installedKeyword.value.trim().toLowerCase();
if (!value) {
return skills.value;
}
return skills.value.filter((item) =>
[item.name, item.description, item.source].join(' ').toLowerCase().includes(value),
);
});
const groupedSkills = computed(() => {
const groups: Record<SkillGroupKey, AI.AgentSkillItem[]> = {
builtIn: [],
external: [],
workspace: [],
extra: [],
other: [],
};
for (const skill of filteredSkills.value) {
groups[resolveGroupKey(skill)].push(skill);
}
const order: SkillGroupKey[] = ['external', 'workspace', 'extra', 'other', 'builtIn'];
return order
.filter((key) => groups[key].length > 0)
.map((key) => ({
key,
label: groupLabels.value[key],
tagLabel: groupTagLabels.value[key],
items: groups[key],
}));
});
const resolveGroupKey = (skill: AI.AgentSkillItem): SkillGroupKey => {
if (skill.bundled || skill.source === 'openclaw-bundled') {
return 'builtIn';
}
if (skill.source === 'openclaw-managed') {
return 'external';
}
if (skill.source === 'openclaw-workspace') {
return 'workspace';
}
if (skill.source === 'openclaw-extra') {
return 'extra';
}
return 'other';
};
const loadSkills = async () => {
if (!supported.value || !agentId.value) {
return;
}
loading.value = true;
try {
const res = await listAgentSkills({ agentId: agentId.value });
skills.value = res.data || [];
installedLoaded.value = true;
} finally {
loading.value = false;
}
};
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 handleModeChange = async (value: SkillViewMode) => {
if (value !== 'installed' || installedLoaded.value) {
return;
}
await loadSkills();
};
const load = async (id: number) => {
agentId.value = id;
mode.value = 'market';
marketSource.value = getDefaultMarketSource();
marketResults.value = [];
marketSearched.value = false;
skills.value = [];
installedLoaded.value = false;
};
const toggleSkill = async (skill: AI.AgentSkillItem, enabled: boolean) => {
if (!supported.value || !agentId.value) {
return;
}
updatingSkill.value = skill.name;
try {
await updateAgentSkill({
agentId: agentId.value,
name: skill.name,
enabled,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
await loadSkills();
} finally {
updatingSkill.value = '';
}
};
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 () => {
if (mode.value === 'installed') {
await loadSkills();
return;
}
installedLoaded.value = false;
};
defineExpose({
load,
});
</script>
<style scoped lang="scss">
.view-switch {
margin-bottom: 16px;
}
.skills-content {
min-height: 200px;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.search-input {
flex: 1;
}
.group-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.group-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
min-height: 42px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: var(--el-fill-color-light);
}
.group-title,
.group-count {
color: var(--el-text-color-secondary);
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
padding: 2px;
}
.skill-card {
--el-card-border-color: var(--el-border-color-dark);
border-radius: 12px;
}
.skill-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.skill-name {
font-weight: 600;
font-size: 16px;
}
.skill-desc {
margin-top: 12px;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.skill-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
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;
}
:global(.skill-desc-tooltip .skill-desc-tooltip-content) {
white-space: normal;
word-break: break-word;
}
</style>
+4 -4
View File
@@ -287,14 +287,14 @@ const buttons = [
click: (row: AI.AgentItem) => openHermesChat(row),
show: (row: AI.AgentItem) => row.agentType === 'hermes-agent' && row.status === 'Running',
},
{
label: i18n.global.t('menu.terminal'),
click: (row: AI.AgentItem) => openTerminal(row),
},
{
label: i18n.global.t('commons.button.log'),
click: (row: AI.AgentItem) => openLog(row),
},
{
label: i18n.global.t('menu.terminal'),
click: (row: AI.AgentItem) => openTerminal(row),
},
{
label: i18n.global.t('menu.home'),
click: (row: AI.AgentItem) => openOverview(row),