mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 16:00:51 +00:00
feat: support skill management for hermes-agent (#12514)
This commit is contained in:
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -720,6 +720,7 @@ const message = {
|
||||
skillsMarketSourceClawhubChina: 'ClawHub(中国)',
|
||||
skillsMarketSourceSkillhub: 'SkillHub(Tencent)',
|
||||
skillsScore: 'スコア',
|
||||
skillsMarketSourceOfficial: '公式',
|
||||
versionUnsupportedTitle: '現在のバージョンではこの機能はサポートされていません',
|
||||
versionUnsupportedHelper: 'OpenClaw をバージョン {0} 以降にアップグレードしてください。',
|
||||
skillsStatusDisabled: '無効',
|
||||
|
||||
@@ -712,6 +712,7 @@ const message = {
|
||||
skillsMarketSourceClawhubChina: 'ClawHub (중국)',
|
||||
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
|
||||
skillsScore: '점수',
|
||||
skillsMarketSourceOfficial: '공식',
|
||||
versionUnsupportedTitle: '현재 버전에서는 이 기능을 지원하지 않습니다',
|
||||
versionUnsupportedHelper: 'OpenClaw 를 버전 {0} 이상으로 업그레이드하세요.',
|
||||
skillsStatusDisabled: '비활성화됨',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -719,6 +719,7 @@ const message = {
|
||||
skillsMarketSourceClawhubChina: 'ClawHub (Китай)',
|
||||
skillsMarketSourceSkillhub: 'SkillHub (Tencent)',
|
||||
skillsScore: 'Оценка',
|
||||
skillsMarketSourceOfficial: 'Официальный',
|
||||
versionUnsupportedTitle: 'Эта функция не поддерживается в текущей версии',
|
||||
versionUnsupportedHelper: 'Пожалуйста, обновите OpenClaw до версии {0} или выше.',
|
||||
skillsStatusDisabled: 'Отключено',
|
||||
|
||||
@@ -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ışı',
|
||||
|
||||
@@ -685,6 +685,7 @@ const message = {
|
||||
skillsMarketSourceClawhubChina: 'ClawHub(中國)',
|
||||
skillsMarketSourceSkillhub: 'SkillHub(騰訊)',
|
||||
skillsScore: '評分',
|
||||
skillsMarketSourceOfficial: '官方',
|
||||
versionUnsupportedTitle: '當前版本暫不支援該功能',
|
||||
versionUnsupportedHelper: '請升級 OpenClaw 到 {0} 或以上版本後使用。',
|
||||
skillsStatusDisabled: '已禁用',
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user