feat: support anthropic skills (#4715)

* feat: support anthropic skills

closes: #4687

* chore: ruff

* feat: implement skills management and selection in persona configuration

* feat: enhance skills management with local environment tools and permissions
This commit is contained in:
Soulter
2026-01-28 01:48:57 +08:00
committed by GitHub
parent a4fc92e803
commit 22bd8d6824
44 changed files with 1552 additions and 170 deletions
@@ -10,8 +10,11 @@ from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.agent.message import TextPart
from astrbot.core.pipeline.process_stage.utils import (
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
)
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
class ProcessLLMRequest:
@@ -25,6 +28,15 @@ class ProcessLLMRequest:
else:
logger.info(f"Timezone set to: {self.timezone}")
self.skill_manager = SkillManager()
def _apply_local_env_tools(self, req: ProviderRequest) -> None:
"""Add local environment tools to the provider request."""
if req.func_tool is None:
req.func_tool = ToolSet()
req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
async def _ensure_persona(
self, req: ProviderRequest, cfg: dict, umo: str, platform_type: str
):
@@ -66,6 +78,30 @@ class ProcessLLMRequest:
if begin_dialogs := copy.deepcopy(persona["_begin_dialogs_processed"]):
req.contexts[:0] = begin_dialogs
# skills select and prompt
runtime = self.skills_cfg.get("runtime", "local")
skills = self.skill_manager.list_skills(active_only=True, runtime=runtime)
if runtime == "sandbox" and not self.sandbox_cfg.get("enable", False):
logger.warning(
"Skills runtime is set to sandbox, but sandbox mode is disabled, will skip skills prompt injection.",
)
req.system_prompt += "\n[Background: User added some skills, and skills runtime is set to sandbox, but sandbox mode is disabled. So skills will be unavailable.]\n"
elif skills:
# persona.skills == None means all skills are allowed
if persona and persona.get("skills") is not None:
if not persona["skills"]:
return
allowed = set(persona["skills"])
skills = [skill for skill in skills if skill.name in allowed]
if skills:
req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
# if user wants to use skills in non-sandbox mode, apply local env tools
runtime = self.skills_cfg.get("runtime", "local")
sandbox_enabled = self.sandbox_cfg.get("enable", False)
if runtime == "local" and not sandbox_enabled:
self._apply_local_env_tools(req)
# tools select
tmgr = self.ctx.get_llm_tool_manager()
if (persona and persona.get("tools") is None) or not persona:
@@ -81,7 +117,10 @@ class ProcessLLMRequest:
tool = tmgr.get_func(tool_name)
if tool and tool.active:
toolset.add_tool(tool)
req.func_tool = toolset
if not req.func_tool:
req.func_tool = toolset
else:
req.func_tool.merge(toolset)
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
async def _ensure_img_caption(
@@ -134,6 +173,8 @@ class ProcessLLMRequest:
cfg: dict = self.ctx.get_config(umo=event.unified_msg_origin)[
"provider_settings"
]
self.skills_cfg = cfg.get("skills", {})
self.sandbox_cfg = cfg.get("sandbox", {})
# prompt prefix
if prefix := cfg.get("prompt_prefix"):
+5
View File
@@ -274,6 +274,11 @@ class ToolSet:
"""获取所有工具的名称列表"""
return [tool.name for tool in self.tools]
def merge(self, other: "ToolSet"):
"""Merge another ToolSet into this one."""
for tool in other.tools:
self.add_tool(tool)
def __len__(self):
return len(self.tools)
@@ -1,7 +1,7 @@
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
class SandboxBooter:
class ComputerBooter:
@property
def fs(self) -> FileSystemComponent: ...
@@ -16,16 +16,16 @@ class SandboxBooter:
async def shutdown(self) -> None: ...
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox.
"""Upload file to the computer.
Should return a dict with `success` (bool) and `file_path` (str) keys.
"""
...
async def download_file(self, remote_path: str, local_path: str):
"""Download file from sandbox."""
"""Download file from the computer."""
...
async def available(self) -> bool:
"""Check if the sandbox is available."""
"""Check if the computer is available."""
...
@@ -11,7 +11,7 @@ from shipyard.shell import ShellComponent as ShipyardShellComponent
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import SandboxBooter
from .base import ComputerBooter
class MockShipyardSandboxClient:
@@ -124,7 +124,7 @@ class MockShipyardSandboxClient:
loop -= 1
class BoxliteBooter(SandboxBooter):
class BoxliteBooter(ComputerBooter):
async def boot(self, session_id: str) -> None:
logger.info(
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
import asyncio
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Any
from astrbot.api import logger
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_root,
get_astrbot_temp_path,
)
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
_BLOCKED_COMMAND_PATTERNS = [
" rm -rf ",
" rm -fr ",
" rm -r ",
" mkfs",
" dd if=",
" shutdown",
" reboot",
" poweroff",
" halt",
" sudo ",
":(){:|:&};:",
" kill -9 ",
" killall ",
]
def _is_safe_command(command: str) -> bool:
cmd = f" {command.strip().lower()} "
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
def _ensure_safe_path(path: str) -> str:
abs_path = os.path.abspath(path)
allowed_roots = [
os.path.abspath(get_astrbot_root()),
os.path.abspath(get_astrbot_data_path()),
os.path.abspath(get_astrbot_temp_path()),
]
if not any(abs_path.startswith(root) for root in allowed_roots):
raise PermissionError("Path is outside the allowed computer roots.")
return abs_path
@dataclass
class LocalShellComponent(ShellComponent):
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
if not _is_safe_command(command):
raise PermissionError("Blocked unsafe shell command.")
def _run() -> dict[str, Any]:
run_env = os.environ.copy()
if env:
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
if background:
proc = subprocess.Popen(
command,
shell=shell,
cwd=working_dir,
env=run_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
capture_output=True,
text=True,
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": result.returncode,
}
return await asyncio.to_thread(_run)
@dataclass
class LocalPythonComponent(PythonComponent):
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
try:
result = subprocess.run(
[os.environ.get("PYTHON", sys.executable), "-c", code],
timeout=timeout,
capture_output=True,
text=True,
)
stdout = "" if silent else result.stdout
stderr = result.stderr if result.returncode != 0 else ""
return {
"data": {
"output": {"text": stdout, "images": []},
"error": stderr,
}
}
except subprocess.TimeoutExpired:
return {
"data": {
"output": {"text": "", "images": []},
"error": "Execution timed out.",
}
}
return await asyncio.to_thread(_run)
@dataclass
class LocalFileSystemComponent(FileSystemComponent):
async def create_file(
self, path: str, content: str = "", mode: int = 0o644
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(abs_path, mode)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
with open(abs_path, encoding=encoding) as f:
content = f.read()
return {"success": True, "content": content}
return await asyncio.to_thread(_run)
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, mode, encoding=encoding) as f:
f.write(content)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def delete_file(self, path: str) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
if os.path.isdir(abs_path):
shutil.rmtree(abs_path)
else:
os.remove(abs_path)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def list_dir(
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
entries = os.listdir(abs_path)
if not show_hidden:
entries = [e for e in entries if not e.startswith(".")]
return {"success": True, "entries": entries}
return await asyncio.to_thread(_run)
class LocalBooter(ComputerBooter):
def __init__(self) -> None:
self._fs = LocalFileSystemComponent()
self._python = LocalPythonComponent()
self._shell = LocalShellComponent()
async def boot(self, session_id: str) -> None:
logger.info(f"Local computer booter initialized for session: {session_id}")
async def shutdown(self) -> None:
logger.info("Local computer booter shutdown complete.")
@property
def fs(self) -> FileSystemComponent:
return self._fs
@property
def python(self) -> PythonComponent:
return self._python
@property
def shell(self) -> ShellComponent:
return self._shell
async def upload_file(self, path: str, file_name: str) -> dict:
raise NotImplementedError(
"LocalBooter does not support upload_file operation. Use shell instead."
)
async def download_file(self, remote_path: str, local_path: str):
raise NotImplementedError(
"LocalBooter does not support download_file operation. Use shell instead."
)
async def available(self) -> bool:
return True
@@ -3,10 +3,10 @@ from shipyard import ShipyardClient, Spec
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import SandboxBooter
from .base import ComputerBooter
class ShipyardBooter(SandboxBooter):
class ShipyardBooter(ComputerBooter):
def __init__(
self,
endpoint_url: str,
+102
View File
@@ -0,0 +1,102 @@
import os
import shutil
import uuid
from pathlib import Path
from astrbot.api import logger
from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT
from astrbot.core.star.context import Context
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
get_astrbot_temp_path,
)
from .booters.base import ComputerBooter
from .booters.local import LocalBooter
session_booter: dict[str, ComputerBooter] = {}
local_booter: ComputerBooter | None = None
async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
skills_root = get_astrbot_skills_path()
if not os.path.isdir(skills_root):
return
if not any(Path(skills_root).iterdir()):
return
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
zip_base = os.path.join(temp_dir, "skills_bundle")
zip_path = f"{zip_base}.zip"
try:
if os.path.exists(zip_path):
os.remove(zip_path)
shutil.make_archive(zip_base, "zip", skills_root)
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
upload_result = await booter.upload_file(zip_path, str(remote_zip))
if not upload_result.get("success", False):
raise RuntimeError("Failed to upload skills bundle to sandbox.")
await booter.shell.exec(
f"unzip -o {remote_zip} -d {SANDBOX_SKILLS_ROOT} && rm -f {remote_zip}"
)
finally:
if os.path.exists(zip_path):
try:
os.remove(zip_path)
except Exception:
logger.warning(f"Failed to remove temp skills zip: {zip_path}")
async def get_booter(
context: Context,
session_id: str,
) -> ComputerBooter:
config = context.get_config(umo=session_id)
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard")
if session_id in session_booter:
booter = session_booter[session_id]
if not await booter.available():
# rebuild
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
if booter_type == "shipyard":
from .booters.shipyard import ShipyardBooter
ep = sandbox_cfg.get("shipyard_endpoint", "")
token = sandbox_cfg.get("shipyard_access_token", "")
ttl = sandbox_cfg.get("shipyard_ttl", 3600)
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
client = ShipyardBooter(
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
)
elif booter_type == "boxlite":
from .booters.boxlite import BoxliteBooter
client = BoxliteBooter()
else:
raise ValueError(f"Unknown booter type: {booter_type}")
try:
await client.boot(uuid_str)
await _sync_skills_to_sandbox(client)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
raise e
session_booter[session_id] = client
return session_booter[session_id]
def get_local_booter() -> ComputerBooter:
global local_booter
if local_booter is None:
local_booter = LocalBooter()
return local_booter
@@ -1,10 +1,11 @@
from .fs import FileDownloadTool, FileUploadTool
from .python import PythonTool
from .python import LocalPythonTool, PythonTool
from .shell import ExecuteShellTool
__all__ = [
"FileUploadTool",
"PythonTool",
"LocalPythonTool",
"ExecuteShellTool",
"FileDownloadTool",
]
@@ -9,7 +9,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import File
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..sandbox_client import get_booter
from ..computer_client import get_booter
# @dataclass
# class CreateFileTool(FunctionTool):
+94
View File
@@ -0,0 +1,94 @@
from dataclasses import dataclass, field
import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter, get_local_booter
param_schema = {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to execute.",
},
"silent": {
"type": "boolean",
"description": "Whether to suppress the output of the code execution.",
"default": False,
},
},
"required": ["code"],
}
def handle_result(result: dict) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
images: list[dict] = output.get("images", [])
text: str = output.get("text", "")
resp = mcp.types.CallToolResult(content=[])
if error:
resp.content.append(mcp.types.TextContent(type="text", text=f"error: {error}"))
if images:
for img in images:
resp.content.append(
mcp.types.ImageContent(
type="image", data=img["image/png"], mimeType="image/png"
)
)
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
if not resp.content:
resp.content.append(mcp.types.TextContent(type="text", text="No output."))
return resp
@dataclass
class PythonTool(FunctionTool):
name: str = "astrbot_execute_ipython"
description: str = "Run codes in an IPython shell."
parameters: dict = field(default_factory=lambda: param_schema)
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
except Exception as e:
return f"Error executing code: {str(e)}"
@dataclass
class LocalPythonTool(FunctionTool):
name: str = "astrbot_execute_python"
description: str = "Execute codes in a Python environment."
parameters: dict = field(default_factory=lambda: param_schema)
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Local Python execution is only allowed for admin users. Set admins in AstrBot WebUI."
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
except Exception as e:
return f"Error executing code: {str(e)}"
@@ -6,7 +6,7 @@ from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from ..sandbox_client import get_booter
from ..computer_client import get_booter, get_local_booter
@dataclass
@@ -37,6 +37,8 @@ class ExecuteShellTool(FunctionTool):
}
)
is_local: bool = False
async def call(
self,
context: ContextWrapper[AstrAgentContext],
@@ -44,10 +46,16 @@ class ExecuteShellTool(FunctionTool):
background: bool = False,
env: dict = {},
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
if context.context.event.role != "admin":
return "error: Permission denied. Shell execution is only allowed for admin users. Set admins in AstrBot WebUI."
if self.is_local:
sb = get_local_booter()
else:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.shell.exec(command, background=background, env=env)
return json.dumps(result)
+39
View File
@@ -121,6 +121,7 @@ DEFAULT_CONFIG = {
"shipyard_ttl": 3600,
"shipyard_max_sessions": 10,
},
"skills": {"runtime": "sandbox"},
},
"provider_stt_settings": {
"enable": False,
@@ -2196,6 +2197,17 @@ CONFIG_METADATA_2 = {
},
},
},
"skills": {
"type": "object",
"items": {
"enable": {
"type": "bool",
},
"runtime": {
"type": "string",
},
},
},
},
},
"provider_stt_settings": {
@@ -2573,6 +2585,7 @@ CONFIG_METADATA_3 = {
# },
"sandbox": {
"description": "Agent 沙箱环境",
"hint": "",
"type": "object",
"items": {
"provider_settings.sandbox.enable": {
@@ -2584,6 +2597,7 @@ CONFIG_METADATA_3 = {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard"],
"labels": ["Shipyard"],
"condition": {
"provider_settings.sandbox.enable": True,
},
@@ -2626,6 +2640,27 @@ CONFIG_METADATA_3 = {
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"skills": {
"description": "Skills",
"type": "object",
"items": {
"provider_settings.skills.runtime": {
"description": "Skill Runtime",
"type": "string",
"options": ["local", "sandbox"],
"labels": ["本地", "沙箱"],
"hint": "选择 Skills 运行环境。使用沙箱时需先启用沙箱环境。",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"truncate_and_compress": {
"description": "上下文管理策略",
@@ -2686,6 +2721,10 @@ CONFIG_METADATA_3 = {
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"others": {
"description": "其他配置",
+3
View File
@@ -254,6 +254,7 @@ class BaseDatabase(abc.ABC):
system_prompt: str,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
folder_id: str | None = None,
sort_order: int = 0,
) -> Persona:
@@ -264,6 +265,7 @@ class BaseDatabase(abc.ABC):
system_prompt: System prompt for the persona
begin_dialogs: Optional list of initial dialog strings
tools: Optional list of tool names (None means all tools, [] means no tools)
skills: Optional list of skill names (None means all skills, [] means no skills)
folder_id: Optional folder ID to place the persona in (None means root)
sort_order: Sort order within the folder (default 0)
"""
@@ -286,6 +288,7 @@ class BaseDatabase(abc.ABC):
system_prompt: str | None = None,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
) -> Persona | None:
"""Update a persona's system prompt or begin dialogs."""
...
+4
View File
@@ -125,6 +125,8 @@ class Persona(SQLModel, table=True):
"""a list of strings, each representing a dialog to start with"""
tools: list | None = Field(default=None, sa_type=JSON)
"""None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
skills: list | None = Field(default=None, sa_type=JSON)
"""None means use ALL skills for default, empty list means no skills, otherwise a list of skill names."""
folder_id: str | None = Field(default=None, max_length=36)
"""所属文件夹IDNULL 表示在根目录"""
sort_order: int = Field(default=0)
@@ -442,6 +444,8 @@ class Personality(TypedDict):
"""情感模拟对话预设。在 v4.0.0 版本及之后,已被废弃。"""
tools: list[str] | None
"""工具列表。None 表示使用所有工具,空列表表示不使用任何工具"""
skills: list[str] | None
"""Skills 列表。None 表示使用所有 Skills,空列表表示不使用任何 Skills"""
# cache
_begin_dialogs_processed: list[dict]
+19 -1
View File
@@ -52,8 +52,9 @@ class SQLiteDatabase(BaseDatabase):
await conn.execute(text("PRAGMA temp_store=MEMORY"))
await conn.execute(text("PRAGMA mmap_size=134217728"))
await conn.execute(text("PRAGMA optimize"))
# 确保 personas 表有 folder_idsort_order 列(前向兼容)
# 确保 personas 表有 folder_idsort_order、skills 列(前向兼容)
await self._ensure_persona_folder_columns(conn)
await self._ensure_persona_skills_column(conn)
await conn.commit()
async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -76,6 +77,18 @@ class SQLiteDatabase(BaseDatabase):
text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0")
)
async def _ensure_persona_skills_column(self, conn) -> None:
"""确保 personas 表有 skills 列。
这是为了支持旧版数据库的平滑升级新版数据库通过 SQLModel
metadata.create_all 自动创建这些列
"""
result = await conn.execute(text("PRAGMA table_info(personas)"))
columns = {row[1] for row in result.fetchall()}
if "skills" not in columns:
await conn.execute(text("ALTER TABLE personas ADD COLUMN skills JSON"))
# ====
# Platform Statistics
# ====
@@ -564,6 +577,7 @@ class SQLiteDatabase(BaseDatabase):
system_prompt,
begin_dialogs=None,
tools=None,
skills=None,
folder_id=None,
sort_order=0,
):
@@ -576,6 +590,7 @@ class SQLiteDatabase(BaseDatabase):
system_prompt=system_prompt,
begin_dialogs=begin_dialogs or [],
tools=tools,
skills=skills,
folder_id=folder_id,
sort_order=sort_order,
)
@@ -606,6 +621,7 @@ class SQLiteDatabase(BaseDatabase):
system_prompt=None,
begin_dialogs=None,
tools=NOT_GIVEN,
skills=NOT_GIVEN,
):
"""Update a persona's system prompt or begin dialogs."""
async with self.get_db() as session:
@@ -619,6 +635,8 @@ class SQLiteDatabase(BaseDatabase):
values["begin_dialogs"] = begin_dialogs
if tools is not NOT_GIVEN:
values["tools"] = tools
if skills is not NOT_GIVEN:
values["skills"] = skills
if not values:
return None
query = query.values(**values)
+8
View File
@@ -10,6 +10,7 @@ DEFAULT_PERSONALITY = Personality(
begin_dialogs=[],
mood_imitation_dialogs=[],
tools=None,
skills=None,
_begin_dialogs_processed=[],
_mood_imitation_dialogs_processed="",
)
@@ -71,6 +72,7 @@ class PersonaManager:
system_prompt: str | None = None,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
):
"""更新指定 persona 的信息。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
existing_persona = await self.db.get_persona_by_id(persona_id)
@@ -81,6 +83,7 @@ class PersonaManager:
system_prompt,
begin_dialogs,
tools=tools,
skills=skills,
)
if persona:
for i, p in enumerate(self.personas):
@@ -239,6 +242,7 @@ class PersonaManager:
system_prompt: str,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
folder_id: str | None = None,
sort_order: int = 0,
) -> Persona:
@@ -249,6 +253,7 @@ class PersonaManager:
system_prompt: 系统提示词
begin_dialogs: 预设对话列表
tools: 工具列表None 表示使用所有工具空列表表示不使用任何工具
skills: Skills 列表None 表示使用所有 Skills空列表表示不使用任何 Skills
folder_id: 所属文件夹 IDNone 表示根目录
sort_order: 排序顺序
"""
@@ -259,6 +264,7 @@ class PersonaManager:
system_prompt,
begin_dialogs,
tools=tools,
skills=skills,
folder_id=folder_id,
sort_order=sort_order,
)
@@ -284,6 +290,7 @@ class PersonaManager:
"begin_dialogs": persona.begin_dialogs or [],
"mood_imitation_dialogs": [], # deprecated
"tools": persona.tools,
"skills": persona.skills,
}
for persona in self.personas
]
@@ -339,6 +346,7 @@ class PersonaManager:
system_prompt=selected_default_persona["prompt"],
begin_dialogs=selected_default_persona["begin_dialogs"],
tools=selected_default_persona["tools"] or None,
skills=selected_default_persona["skills"] or None,
)
return v3_persona_config, personas_v3, selected_default_persona
+4 -1
View File
@@ -7,10 +7,11 @@ from astrbot.api import logger, sp
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.sandbox.tools import (
from astrbot.core.computer.tools import (
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
LocalPythonTool,
PythonTool,
)
from astrbot.core.star.context import Context
@@ -194,7 +195,9 @@ async def retrieve_knowledge_base(
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
EXECUTE_SHELL_TOOL = ExecuteShellTool()
LOCAL_EXECUTE_SHELL_TOOL = ExecuteShellTool(is_local=True)
PYTHON_TOOL = PythonTool()
LOCAL_PYTHON_TOOL = LocalPythonTool()
FILE_UPLOAD_TOOL = FileUploadTool()
FILE_DOWNLOAD_TOOL = FileDownloadTool()
-52
View File
@@ -1,52 +0,0 @@
import uuid
from astrbot.api import logger
from astrbot.core.star.context import Context
from .booters.base import SandboxBooter
session_booter: dict[str, SandboxBooter] = {}
async def get_booter(
context: Context,
session_id: str,
) -> SandboxBooter:
config = context.get_config(umo=session_id)
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard")
if session_id in session_booter:
booter = session_booter[session_id]
if not await booter.available():
# rebuild
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
if booter_type == "shipyard":
from .booters.shipyard import ShipyardBooter
ep = sandbox_cfg.get("shipyard_endpoint", "")
token = sandbox_cfg.get("shipyard_access_token", "")
ttl = sandbox_cfg.get("shipyard_ttl", 3600)
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
client = ShipyardBooter(
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
)
elif booter_type == "boxlite":
from .booters.boxlite import BoxliteBooter
client = BoxliteBooter()
else:
raise ValueError(f"Unknown booter type: {booter_type}")
try:
await client.boot(uuid_str)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
raise e
session_booter[session_id] = client
return session_booter[session_id]
-74
View File
@@ -1,74 +0,0 @@
from dataclasses import dataclass, field
import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.sandbox.sandbox_client import get_booter
@dataclass
class PythonTool(FunctionTool):
name: str = "astrbot_execute_ipython"
description: str = "Execute a command in an IPython shell."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to execute.",
},
"silent": {
"type": "boolean",
"description": "Whether to suppress the output of the code execution.",
"default": False,
},
},
"required": ["code"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.python.exec(code, silent=silent)
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
images: list[dict] = output.get("images", [])
text: str = output.get("text", "")
resp = mcp.types.CallToolResult(content=[])
if error:
resp.content.append(
mcp.types.TextContent(type="text", text=f"error: {error}")
)
if images:
for img in images:
resp.content.append(
mcp.types.ImageContent(
type="image", data=img["image/png"], mimeType="image/png"
)
)
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
if not resp.content:
resp.content.append(
mcp.types.TextContent(type="text", text="No output.")
)
return resp
except Exception as e:
return f"Error executing code: {str(e)}"
+3
View File
@@ -0,0 +1,3 @@
from .skill_manager import SkillInfo, SkillManager, build_skills_prompt
__all__ = ["SkillInfo", "SkillManager", "build_skills_prompt"]
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
import json
import os
import re
import shutil
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
SKILLS_CONFIG_FILENAME = "skills.json"
DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}}
SANDBOX_SKILLS_ROOT = "/home/shared/skills"
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
@dataclass
class SkillInfo:
name: str
description: str
path: str
active: bool
def _parse_frontmatter_description(text: str) -> str:
if not text.startswith("---"):
return ""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return ""
end_idx = None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end_idx = i
break
if end_idx is None:
return ""
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, value = line.split(":", 1)
if key.strip().lower() == "description":
return value.strip().strip('"').strip("'")
return ""
def build_skills_prompt(skills: list[SkillInfo]) -> str:
skills_lines = []
for skill in skills:
description = skill.description or "No description"
skills_lines.append(f"- {skill.name}: {description} (file: {skill.path})")
skills_block = "\n".join(skills_lines)
# Based on openai/codex
return (
"## Skills\n"
"A skill is a set of local instructions stored in a `SKILL.md` file.\n"
"### Available skills\n"
f"{skills_block}\n"
"### Skill Rules\n"
"\n"
"- Discovery: The list above shows all skills available in this session. Full instructions live in the referenced `SKILL.md`.\n"
"- Trigger rules: Use a skill if the user names it or the task matches its description. Do not carry skills across turns unless re-mentioned\n"
"- Unavailable: If a skill is missing or unreadable, say so and fallback.\n"
"### How to use a skill (progressive disclosure):\n"
" 1) After deciding to use a skill, open its `SKILL.md` and read only what is necessary to follow the workflow.\n"
" 2) Load only directly referenced files, DO NOT bulk-load everything.\n"
" 3) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n"
" 4) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n"
"- Coordination:\n"
" - If multiple skills apply, choose the minimal set that covers the request and state the order in which you will use them.\n"
" - Announce which skill(s) you are using and why (one short line). If you skip an obvious skill, explain why.\n"
" - Prefer to use `astrbot_*` tools to perform skills that need to run scripts.\n"
"- Context hygiene:\n"
" - Keep context small: summarize long sections instead of pasting them, and load extra files only when necessary.\n"
" - Avoid deep reference chasing: unless blocked, open only files that are directly linked from `SKILL.md`.\n"
" - When variants exist (frameworks, providers, domains), select only the relevant reference file(s) and note that choice.\n"
"- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative."
)
class SkillManager:
def __init__(self, skills_root: str | None = None) -> None:
self.skills_root = skills_root or get_astrbot_skills_path()
self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME)
os.makedirs(self.skills_root, exist_ok=True)
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
def _load_config(self) -> dict:
if not os.path.exists(self.config_path):
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
return DEFAULT_SKILLS_CONFIG.copy()
with open(self.config_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict) or "skills" not in data:
return DEFAULT_SKILLS_CONFIG.copy()
return data
def _save_config(self, config: dict) -> None:
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
def list_skills(
self,
*,
active_only: bool = False,
runtime: str = "local",
show_sandbox_path: bool = True,
) -> list[SkillInfo]:
"""List all skills.
show_sandbox_path: If True and runtime is "sandbox",
return the path as it would appear in the sandbox environment,
otherwise return the local filesystem path.
"""
config = self._load_config()
skill_configs = config.get("skills", {})
modified = False
skills: list[SkillInfo] = []
for entry in sorted(Path(self.skills_root).iterdir()):
if not entry.is_dir():
continue
skill_name = entry.name
skill_md = entry / "SKILL.md"
if not skill_md.exists():
continue
active = skill_configs.get(skill_name, {}).get("active", True)
if skill_name not in skill_configs:
skill_configs[skill_name] = {"active": active}
modified = True
if active_only and not active:
continue
description = ""
try:
content = skill_md.read_text(encoding="utf-8")
description = _parse_frontmatter_description(content)
except Exception:
description = ""
if runtime == "sandbox" and show_sandbox_path:
path_str = f"{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
else:
path_str = str(skill_md)
path_str = path_str.replace("\\", "/")
skills.append(
SkillInfo(
name=skill_name,
description=description,
path=path_str,
active=active,
)
)
if modified:
config["skills"] = skill_configs
self._save_config(config)
return skills
def set_skill_active(self, name: str, active: bool) -> None:
config = self._load_config()
config.setdefault("skills", {})
config["skills"][name] = {"active": bool(active)}
self._save_config(config)
def delete_skill(self, name: str) -> None:
skill_dir = Path(self.skills_root) / name
if skill_dir.exists():
shutil.rmtree(skill_dir)
config = self._load_config()
if name in config.get("skills", {}):
config["skills"].pop(name, None)
self._save_config(config)
def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str:
zip_path_obj = Path(zip_path)
if not zip_path_obj.exists():
raise FileNotFoundError(f"Zip file not found: {zip_path}")
if not zipfile.is_zipfile(zip_path):
raise ValueError("Uploaded file is not a valid zip archive.")
with zipfile.ZipFile(zip_path) as zf:
names = [name.replace("\\", "/") for name in zf.namelist()]
file_names = [name for name in names if name and not name.endswith("/")]
if not file_names:
raise ValueError("Zip archive is empty.")
top_dirs = {
PurePosixPath(name).parts[0] for name in file_names if name.strip()
}
print(top_dirs)
if len(top_dirs) != 1:
raise ValueError("Zip archive must contain a single top-level folder.")
skill_name = next(iter(top_dirs))
if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name):
raise ValueError("Invalid skill folder name.")
for name in names:
if not name:
continue
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
raise ValueError("Zip archive contains absolute paths.")
parts = PurePosixPath(name).parts
if ".." in parts:
raise ValueError("Zip archive contains invalid relative paths.")
if parts and parts[0] != skill_name:
raise ValueError(
"Zip archive contains unexpected top-level entries."
)
if (
f"{skill_name}/SKILL.md" not in file_names
and f"{skill_name}/skill.md" not in file_names
):
raise ValueError("SKILL.md not found in the skill folder.")
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
zf.extractall(tmp_dir)
src_dir = Path(tmp_dir) / skill_name
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")
dest_dir = Path(self.skills_root) / skill_name
if dest_dir.exists():
if not overwrite:
raise FileExistsError("Skill already exists.")
shutil.rmtree(dest_dir)
shutil.move(str(src_dir), str(dest_dir))
self.set_skill_active(skill_name, True)
return skill_name
+6
View File
@@ -9,6 +9,7 @@
T2I 模板目录路径固定为数据目录下的 t2i_templates 目录
WebChat 数据目录路径固定为数据目录下的 webchat 目录
临时文件目录路径固定为数据目录下的 temp 目录
Skills 目录路径固定为数据目录下的 skills 目录
"""
import os
@@ -63,6 +64,11 @@ def get_astrbot_temp_path() -> str:
return os.path.realpath(os.path.join(get_astrbot_data_path(), "temp"))
def get_astrbot_skills_path() -> str:
"""获取Astrbot Skills 目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills"))
def get_astrbot_knowledge_base_path() -> str:
"""获取Astrbot知识库根目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
+2
View File
@@ -12,6 +12,7 @@ from .persona import PersonaRoute
from .platform import PlatformRoute
from .plugin import PluginRoute
from .session_management import SessionManagementRoute
from .skills import SkillsRoute
from .stat import StatRoute
from .static_file import StaticFileRoute
from .tools import ToolsRoute
@@ -35,5 +36,6 @@ __all__ = [
"StatRoute",
"StaticFileRoute",
"ToolsRoute",
"SkillsRoute",
"UpdateRoute",
]
+7
View File
@@ -57,6 +57,7 @@ class PersonaRoute(Route):
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools,
"skills": persona.skills,
"folder_id": persona.folder_id,
"sort_order": persona.sort_order,
"created_at": persona.created_at.isoformat()
@@ -96,6 +97,7 @@ class PersonaRoute(Route):
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools,
"skills": persona.skills,
"folder_id": persona.folder_id,
"sort_order": persona.sort_order,
"created_at": persona.created_at.isoformat()
@@ -120,6 +122,7 @@ class PersonaRoute(Route):
system_prompt = data.get("system_prompt", "").strip()
begin_dialogs = data.get("begin_dialogs", [])
tools = data.get("tools")
skills = data.get("skills")
folder_id = data.get("folder_id") # None 表示根目录
sort_order = data.get("sort_order", 0)
@@ -142,6 +145,7 @@ class PersonaRoute(Route):
system_prompt=system_prompt,
begin_dialogs=begin_dialogs if begin_dialogs else None,
tools=tools if tools else None,
skills=skills if skills else None,
folder_id=folder_id,
sort_order=sort_order,
)
@@ -156,6 +160,7 @@ class PersonaRoute(Route):
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools or [],
"skills": persona.skills or [],
"folder_id": persona.folder_id,
"sort_order": persona.sort_order,
"created_at": persona.created_at.isoformat()
@@ -183,6 +188,7 @@ class PersonaRoute(Route):
system_prompt = data.get("system_prompt")
begin_dialogs = data.get("begin_dialogs")
tools = data.get("tools")
skills = data.get("skills")
if not persona_id:
return Response().error("缺少必要参数: persona_id").__dict__
@@ -200,6 +206,7 @@ class PersonaRoute(Route):
system_prompt=system_prompt,
begin_dialogs=begin_dialogs,
tools=tools,
skills=skills,
)
return Response().ok({"message": "人格更新成功"}).__dict__
+148
View File
@@ -0,0 +1,148 @@
import os
import traceback
from quart import request
from astrbot.core import DEMO_MODE, logger
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.skills.skill_manager import SkillManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from .route import Response, Route, RouteContext
class SkillsRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.routes = {
"/skills": ("GET", self.get_skills),
"/skills/upload": ("POST", self.upload_skill),
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
}
self.register_routes()
async def get_skills(self):
try:
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
"skills", {}
)
runtime = cfg.get("runtime", "local")
skills = SkillManager().list_skills(
active_only=False, runtime=runtime, show_sandbox_path=False
)
return Response().ok([skill.__dict__ for skill in skills]).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def upload_skill(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
temp_path = None
try:
files = await request.files
file = files.get("file")
if not file:
return Response().error("Missing file").__dict__
filename = os.path.basename(file.filename or "skill.zip")
if not filename.lower().endswith(".zip"):
return Response().error("Only .zip files are supported").__dict__
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, filename)
await file.save(temp_path)
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
"skills", {}
)
runtime = cfg.get("runtime", "local")
if runtime == "sandbox":
sandbox_enabled = (
self.core_lifecycle.astrbot_config.get("provider_settings", {})
.get("sandbox", {})
.get("enable", False)
)
if not sandbox_enabled:
return (
Response()
.error(
"Sandbox is not enabled. Please enable sandbox before using sandbox runtime."
)
.__dict__
)
skill_mgr = SkillManager()
skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True)
if runtime == "sandbox":
sb = await get_booter(self.core_lifecycle.star_context, "skills-upload")
remote_root = "/home/shared/skills"
remote_zip = f"{remote_root}/{skill_name}.zip"
await sb.shell.exec(f"mkdir -p {remote_root}")
upload_result = await sb.upload_file(temp_path, remote_zip)
if not upload_result.get("success", False):
return (
Response().error("Failed to upload skill to sandbox").__dict__
)
await sb.shell.exec(
f"unzip -o {remote_zip} -d {remote_root} && rm -f {remote_zip}"
)
return (
Response()
.ok({"name": skill_name}, "Skill uploaded successfully.")
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception:
logger.warning(f"Failed to remove temp skill file: {temp_path}")
async def update_skill(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
data = await request.get_json()
name = data.get("name")
active = data.get("active", True)
if not name:
return Response().error("Missing skill name").__dict__
SkillManager().set_skill_active(name, bool(active))
return Response().ok({"name": name, "active": bool(active)}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def delete_skill(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
data = await request.get_json()
name = data.get("name")
if not name:
return Response().error("Missing skill name").__dict__
SkillManager().delete_skill(name)
return Response().ok({"name": name}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
+1
View File
@@ -79,6 +79,7 @@ class AstrBotDashboard:
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.skills_route = SkillsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.file_route = FileRoute(self.context)
self.session_management_route = SessionManagementRoute(
@@ -945,7 +945,7 @@ export default {
// Check if tool is iPython executor
isIPythonTool(toolCall) {
return toolCall.name === 'astrbot_execute_ipython';
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
},
// Open refs sidebar
@@ -0,0 +1,213 @@
<template>
<div class="skills-page">
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<v-btn color="success" prepend-icon="mdi-upload" class="me-2" variant="tonal"
@click="uploadDialog = true">
{{ tm('skills.upload') }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="fetchSkills">
{{ tm('skills.refresh') }}
</v-btn>
</div>
</v-row>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<div v-else-if="skills.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-folder-open</v-icon>
<p class="text-grey mt-4">{{ tm('skills.empty') }}</p>
<small class="text-grey">{{ tm('skills.emptyHint') }}</small>
</div>
<v-row v-else>
<v-col v-for="skill in skills" :key="skill.name" cols="12" md="6" lg="4" xl="3">
<item-card :item="skill" title-field="name" enabled-field="active" :loading="itemLoading[skill.name] || false"
:show-edit-button="false" @toggle-enabled="toggleSkill" @delete="confirmDelete">
<template v-slot:item-details="{ item }">
<div class="text-caption text-medium-emphasis mb-2 skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm('skills.noDescription') }}
</div>
<div class="text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm('skills.path') }}: {{ item.path }}
</div>
</template>
</item-card>
</v-col>
</v-row>
</v-container>
<v-dialog v-model="uploadDialog" max-width="520px" persistent>
<v-card>
<v-card-title>{{ tm('skills.uploadDialogTitle') }}</v-card-title>
<v-card-text>
<small class="text-grey">{{ tm('skills.uploadHint') }}</small>
<v-file-input v-model="uploadFile" accept=".zip" :label="tm('skills.selectFile')" prepend-icon="mdi-file-zip"
variant="outlined" class="mt-4" :multiple="false" />
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="uploadDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn color="primary" :loading="uploading" :disabled="!uploadFile" @click="uploadSkill">
{{ tm('skills.confirmUpload') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog" max-width="400px">
<v-card>
<v-card-title>{{ tm('skills.deleteTitle') }}</v-card-title>
<v-card-text>{{ tm('skills.deleteMessage') }}</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="deleteDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn color="error" :loading="deleting" @click="deleteSkill">
{{ t('core.common.itemCard.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :timeout="3000" :color="snackbar.color" elevation="24">
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script>
import axios from "axios";
import { ref, reactive, onMounted } from "vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
export default {
name: "SkillsSection",
components: { ItemCard },
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const skills = ref([]);
const loading = ref(false);
const uploading = ref(false);
const uploadDialog = ref(false);
const uploadFile = ref(null);
const itemLoading = reactive({});
const deleteDialog = ref(false);
const deleting = ref(false);
const skillToDelete = ref(null);
const snackbar = reactive({ show: false, message: "", color: "success" });
const showMessage = (message, color = "success") => {
snackbar.message = message;
snackbar.color = color;
snackbar.show = true;
};
const fetchSkills = async () => {
loading.value = true;
try {
const res = await axios.get("/api/skills");
skills.value = res.data.data || [];
} catch (err) {
showMessage(tm("skills.loadFailed"), "error");
} finally {
loading.value = false;
}
};
const uploadSkill = async () => {
if (!uploadFile.value) return;
uploading.value = true;
try {
const formData = new FormData();
const file = Array.isArray(uploadFile.value)
? uploadFile.value[0]
: uploadFile.value;
if (!file) {
uploading.value = false;
return;
}
formData.append("file", file);
await axios.post("/api/skills/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
showMessage(tm("skills.uploadSuccess"), "success");
uploadDialog.value = false;
uploadFile.value = null;
await fetchSkills();
} catch (err) {
showMessage(tm("skills.uploadFailed"), "error");
} finally {
uploading.value = false;
}
};
const toggleSkill = async (skill) => {
const nextActive = !skill.active;
itemLoading[skill.name] = true;
try {
await axios.post("/api/skills/update", { name: skill.name, active: nextActive });
skill.active = nextActive;
showMessage(tm("skills.updateSuccess"), "success");
} catch (err) {
showMessage(tm("skills.updateFailed"), "error");
} finally {
itemLoading[skill.name] = false;
}
};
const confirmDelete = (skill) => {
skillToDelete.value = skill;
deleteDialog.value = true;
};
const deleteSkill = async () => {
if (!skillToDelete.value) return;
deleting.value = true;
try {
await axios.post("/api/skills/delete", { name: skillToDelete.value.name });
showMessage(tm("skills.deleteSuccess"), "success");
deleteDialog.value = false;
await fetchSkills();
} catch (err) {
showMessage(tm("skills.deleteFailed"), "error");
} finally {
deleting.value = false;
}
};
onMounted(fetchSkills);
return {
t,
tm,
skills,
loading,
uploadDialog,
uploadFile,
uploading,
itemLoading,
deleteDialog,
deleting,
snackbar,
fetchSkills,
uploadSkill,
toggleSkill,
confirmDelete,
deleteSkill,
};
},
};
</script>
<style scoped>
.skill-description {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+26 -21
View File
@@ -23,27 +23,28 @@
<slot name="item-details" :item="item"></slot>
</v-card-text>
<v-card-actions style="margin: 8px;">
<v-btn
variant="outlined"
color="error"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
</v-btn>
<v-btn
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('edit', item)"
>
{{ t('core.common.itemCard.edit') }}
</v-btn>
<v-card-actions style="margin: 8px;">
<v-btn
variant="outlined"
color="error"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
</v-btn>
<v-btn
v-if="showEditButton"
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('edit', item)"
>
{{ t('core.common.itemCard.edit') }}
</v-btn>
<v-btn
v-if="showCopyButton"
variant="tonal"
@@ -103,6 +104,10 @@ export default {
showCopyButton: {
type: Boolean,
default: false
},
showEditButton: {
type: Boolean,
default: true
}
},
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
+186 -2
View File
@@ -155,6 +155,100 @@
</v-expansion-panel-text>
</v-expansion-panel>
<!-- Skills 选择面板 -->
<v-expansion-panel value="skills">
<v-expansion-panel-title>
<v-icon class="mr-2">mdi-lightning-bolt</v-icon>
{{ tm('form.skills') }}
<v-chip v-if="Array.isArray(personaForm.skills) && personaForm.skills.length > 0"
size="small" color="primary" variant="tonal" class="ml-2">
{{ personaForm.skills.length }}
</v-chip>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="mb-3">
<p class="text-body-2 text-medium-emphasis">
{{ tm('form.skillsHelp') }}
</p>
</div>
<v-radio-group class="mt-2" v-model="skillSelectValue" hide-details="true">
<v-radio :label="tm('form.skillsAllAvailable')" value="0"></v-radio>
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
</v-radio-group>
<div v-if="skillSelectValue === '1'" class="mt-3 ml-8">
<v-text-field v-model="skillSearch" :label="tm('form.searchSkills')"
prepend-inner-icon="mdi-magnify" variant="outlined" density="compact"
hide-details clearable class="mb-3" />
<div v-if="filteredSkills.length > 0" class="skills-selection">
<v-virtual-scroll :items="filteredSkills" height="240" item-height="48">
<template v-slot:default="{ item }">
<v-list-item :key="item.name" density="comfortable"
@click="toggleSkill(item.name)">
<template v-slot:prepend>
<v-checkbox-btn :model-value="isSkillSelected(item.name)"
@click.stop="toggleSkill(item.name)" />
</template>
<v-list-item-title>
{{ item.name }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.description">
{{ truncateText(item.description, 100) }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-virtual-scroll>
</div>
<div v-else-if="!loadingSkills && availableSkills.length === 0"
class="text-center pa-4">
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-lightning-bolt</v-icon>
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noSkillsAvailable') }}
</p>
</div>
<div v-else-if="!loadingSkills && filteredSkills.length === 0"
class="text-center pa-4">
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-magnify</v-icon>
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noSkillsFound') }}
</p>
</div>
<div v-if="loadingSkills" class="text-center pa-4">
<v-progress-circular indeterminate color="primary" />
<p class="text-body-2 text-medium-emphasis mt-2">{{ tm('form.loadingSkills') }}
</p>
</div>
<div class="mt-4">
<h4 class="text-subtitle-2 mb-2">
{{ tm('form.selectedSkills') }}
<span v-if="personaForm.skills === null" class="text-success">
({{ tm('form.allSelected') }})
</span>
<span v-else-if="Array.isArray(personaForm.skills)">
({{ personaForm.skills.length }})
</span>
</h4>
<div v-if="Array.isArray(personaForm.skills) && personaForm.skills.length > 0"
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
<v-chip v-for="skillName in personaForm.skills" :key="skillName"
size="small" color="primary" variant="tonal" closable
@click:close="removeSkill(skillName)">
{{ skillName }}
</v-chip>
</div>
<div v-else class="text-body-2 text-medium-emphasis">
{{ tm('form.noSkillsSelected') }}
</div>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<!-- 预设对话面板 -->
<v-expansion-panel value="dialogs">
<v-expansion-panel-title>
@@ -245,12 +339,15 @@ export default {
mcpServers: [],
availableTools: [],
loadingTools: false,
availableSkills: [],
loadingSkills: false,
existingPersonaIds: [], // ID
personaForm: {
persona_id: '',
system_prompt: '',
begin_dialogs: [],
tools: [],
skills: [],
folder_id: null
},
personaIdRules: [
@@ -262,7 +359,9 @@ export default {
v => !!v || this.tm('validation.required'),
v => (v && v.length >= 10) || this.tm('validation.minLength', { min: 10 })
],
toolSearch: ''
toolSearch: '',
skillSearch: '',
skillSelectValue: '0'
}
},
@@ -286,6 +385,16 @@ export default {
(tool.mcp_server_name && tool.mcp_server_name.toLowerCase().includes(search))
);
},
filteredSkills() {
if (!this.skillSearch) {
return this.availableSkills;
}
const search = this.skillSearch.toLowerCase();
return this.availableSkills.filter(skill =>
skill.name.toLowerCase().includes(search) ||
(skill.description && skill.description.toLowerCase().includes(search))
);
},
folderDisplayName() {
// 使
if (this.currentFolderName) {
@@ -313,6 +422,7 @@ export default {
}
this.loadMcpServers();
this.loadTools();
this.loadSkills();
}
},
editingPersona: {
@@ -338,6 +448,15 @@ export default {
this.personaForm.tools = [];
}
}
},
skillSelectValue(newValue) {
if (newValue === '0') {
this.personaForm.skills = null;
} else if (newValue === '1') {
if (this.personaForm.skills === null) {
this.personaForm.skills = [];
}
}
}
},
@@ -348,9 +467,11 @@ export default {
system_prompt: '',
begin_dialogs: [],
tools: [],
skills: [],
folder_id: this.currentFolderId
};
this.toolSelectValue = '0';
this.skillSelectValue = '0';
this.expandedPanels = [];
},
@@ -360,10 +481,12 @@ export default {
system_prompt: persona.system_prompt,
begin_dialogs: [...(persona.begin_dialogs || [])],
tools: persona.tools === null ? null : [...(persona.tools || [])],
skills: persona.skills === null ? null : [...(persona.skills || [])],
folder_id: persona.folder_id
};
// tools toolSelectValue
this.toolSelectValue = persona.tools === null ? '0' : '1';
this.skillSelectValue = persona.skills === null ? '0' : '1';
this.expandedPanels = [];
},
@@ -402,6 +525,24 @@ export default {
}
},
async loadSkills() {
this.loadingSkills = true;
try {
const response = await axios.get('/api/skills');
if (response.data.status === 'ok') {
const skills = response.data.data || [];
this.availableSkills = skills.filter(skill => skill.active !== false);
} else {
this.$emit('error', response.data.message || 'Failed to load skills');
}
} catch (error) {
this.$emit('error', error.response?.data?.message || 'Failed to load skills');
this.availableSkills = [];
} finally {
this.loadingSkills = false;
}
},
async loadExistingPersonaIds() {
try {
const response = await axios.get('/api/persona/list');
@@ -538,6 +679,37 @@ export default {
}
},
toggleSkill(skillName) {
if (this.personaForm.skills === null) {
this.personaForm.skills = this.availableSkills.map(skill => skill.name)
.filter(name => name !== skillName);
this.skillSelectValue = '1';
} else if (Array.isArray(this.personaForm.skills)) {
const index = this.personaForm.skills.indexOf(skillName);
if (index !== -1) {
this.personaForm.skills.splice(index, 1);
} else {
this.personaForm.skills.push(skillName);
}
} else {
this.personaForm.skills = [skillName];
this.skillSelectValue = '1';
}
},
removeSkill(skillName) {
if (this.personaForm.skills === null) {
this.personaForm.skills = this.availableSkills.map(skill => skill.name)
.filter(name => name !== skillName);
this.skillSelectValue = '1';
} else if (Array.isArray(this.personaForm.skills)) {
const index = this.personaForm.skills.indexOf(skillName);
if (index !== -1) {
this.personaForm.skills.splice(index, 1);
}
}
},
truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
@@ -559,6 +731,13 @@ export default {
return Array.isArray(this.personaForm.tools) && this.personaForm.tools.includes(toolName);
},
isSkillSelected(skillName) {
if (this.personaForm.skills === null) {
return true;
}
return Array.isArray(this.personaForm.skills) && this.personaForm.skills.includes(skillName);
},
isServerSelected(server) {
if (!server.tools || server.tools.length === 0) return false;
@@ -581,7 +760,12 @@ export default {
overflow-y: auto;
}
.skills-selection {
max-height: 300px;
overflow-y: auto;
}
.v-virtual-scroll {
padding-bottom: 16px;
}
</style>
</style>
@@ -135,6 +135,7 @@
},
"sandbox": {
"description": "Agent Sandbox Env(Beta)",
"hint": "https://docs.astrbot.app/en/use/astrbot-agent-sandbox.html",
"provider_settings": {
"sandbox": {
"enable": {
@@ -163,6 +164,17 @@
}
}
},
"skills": {
"description": "Skills",
"provider_settings": {
"skills": {
"runtime": {
"description": "Skill Runtime",
"hint": "Select the runtime for Skills. Sandbox runtime requires sandbox to be enabled first. In local mode, the Agent CAN FULLY ACCESS the runtime environment through Shell and Python tools, but non-admin users will be automatically prohibited from using it to ensure security."
}
}
}
},
"truncate_and_compress": {
"description": "Context Management Strategy",
"provider_settings": {
@@ -4,6 +4,7 @@
"tabs": {
"installedPlugins": "Installed Plugins",
"installedMcpServers": "Installed MCP Servers",
"skills": "Skills",
"handlersOperation": "Manage Handlers",
"market": "Extension Market"
},
@@ -189,6 +190,28 @@
"selectFile": "Select File",
"enterUrl": "Enter extension repository URL"
},
"skills": {
"upload": "Upload Skills",
"refresh": "Refresh",
"empty": "No Skills found",
"emptyHint": "Upload a Skills zip to get started",
"uploadDialogTitle": "Upload Skills",
"uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
"selectFile": "Select file",
"confirmUpload": "Upload",
"cancel": "Cancel",
"noDescription": "No description",
"path": "Path",
"uploadSuccess": "Upload succeeded",
"uploadFailed": "Upload failed",
"loadFailed": "Failed to load Skills",
"updateSuccess": "Updated successfully",
"updateFailed": "Update failed",
"deleteTitle": "Delete confirmation",
"deleteMessage": "Are you sure you want to delete this Skill?",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Delete failed"
},
"card": {
"actions": {
"pluginConfig": "Extension Config",
@@ -222,4 +245,4 @@
"pluginChangelog": {
"menuTitle": "View Changelog"
}
}
}
@@ -38,6 +38,17 @@
"loadingTools": "Loading tools...",
"allToolsAvailable": "Use all available tools",
"noToolsSelected": "No tools selected",
"skills": "Skills Selection",
"skillsHelp": "Select available Skills for this persona. Skills provide reusable workflows and guidance.",
"skillsAllAvailable": "Use all available Skills",
"skillsSelectSpecific": "Select specific Skills",
"searchSkills": "Search Skills",
"selectedSkills": "Selected Skills",
"noSkillsAvailable": "No skills available",
"noSkillsFound": "No matching skills found",
"loadingSkills": "Loading skills...",
"allSkillsAvailable": "Use all available Skills",
"noSkillsSelected": "No skills selected",
"createInFolder": "Will be created in \"{folder}\"",
"rootFolder": "All Personas"
},
@@ -73,6 +84,7 @@
"persona": {
"personasTitle": "Personas",
"toolsCount": "tools",
"skillsCount": "skills",
"contextMenu": {
"moveTo": "Move to..."
},
@@ -135,6 +135,7 @@
},
"sandbox": {
"description": "Agent 沙箱环境(Beta)",
"hint": "https://docs.astrbot.app/use/astrbot-agent-sandbox.html",
"provider_settings": {
"sandbox": {
"enable": {
@@ -163,6 +164,17 @@
}
}
},
"skills": {
"description": "Skills",
"provider_settings": {
"skills": {
"runtime": {
"description": "Skill Runtime",
"hint": "选择 Skills 运行环境。使用 sandbox 前需启用沙箱;local 模式下 Agent 可通过 Shell 和 Python 功能完全访问运行环境,非管理员将被自动禁止使用以保证安全。"
}
}
}
},
"truncate_and_compress": {
"description": "上下文管理策略",
"provider_settings": {
@@ -4,6 +4,7 @@
"tabs": {
"installedPlugins": "已安装的插件",
"installedMcpServers": "已安装的 MCP 服务器",
"skills": "Skills",
"handlersOperation": "管理行为",
"market": "插件市场"
},
@@ -189,6 +190,28 @@
"selectFile": "选择文件",
"enterUrl": "输入插件仓库链接"
},
"skills": {
"upload": "上传 Skills",
"refresh": "刷新",
"empty": "暂无 Skills",
"emptyHint": "请上传 Skills 压缩包",
"uploadDialogTitle": "上传 Skills",
"uploadHint": "请上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
"selectFile": "选择文件",
"confirmUpload": "上传",
"cancel": "取消",
"noDescription": "无描述",
"path": "路径",
"uploadSuccess": "上传成功",
"uploadFailed": "上传失败",
"loadFailed": "加载 Skills 失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
"deleteTitle": "删除确认",
"deleteMessage": "确定要删除该 Skill 吗?",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败"
},
"card": {
"actions": {
"pluginConfig": "插件配置",
@@ -222,4 +245,4 @@
"pluginChangelog": {
"menuTitle": "查看更新日志"
}
}
}
@@ -38,6 +38,17 @@
"loadingTools": "正在加载工具...",
"allToolsAvailable": "使用所有可用工具",
"noToolsSelected": "未选择任何工具",
"skills": "Skills 选择",
"skillsHelp": "为这个人格选择可用的 Skills。Skills 会给 AI 提供可复用的流程与规范。",
"skillsAllAvailable": "默认使用全部 Skills",
"skillsSelectSpecific": "选择指定 Skills",
"searchSkills": "搜索 Skills",
"selectedSkills": "已选择的 Skills",
"noSkillsAvailable": "暂无可用 Skills",
"noSkillsFound": "未找到匹配的 Skills",
"loadingSkills": "正在加载 Skills...",
"allSkillsAvailable": "使用所有可用 Skills",
"noSkillsSelected": "未选择任何 Skills",
"createInFolder": "将在「{folder}」中创建",
"rootFolder": "全部人格"
},
@@ -73,6 +84,7 @@
"persona": {
"personasTitle": "人格",
"toolsCount": "个工具",
"skillsCount": "个 Skills",
"contextMenu": {
"moveTo": "移动到..."
},
+1
View File
@@ -20,6 +20,7 @@ export interface Persona {
system_prompt: string;
begin_dialogs: string[];
tools: string[] | null;
skills: string[] | null;
folder_id: string | null;
sort_order: number;
created_at: string;
+18
View File
@@ -6,6 +6,7 @@ import ReadmeDialog from "@/components/shared/ReadmeDialog.vue";
import ProxySelector from "@/components/shared/ProxySelector.vue";
import UninstallConfirmDialog from "@/components/shared/UninstallConfirmDialog.vue";
import McpServersSection from "@/components/extension/McpServersSection.vue";
import SkillsSection from "@/components/extension/SkillsSection.vue";
import ComponentPanel from "@/components/extension/componentPanel/index.vue";
import axios from "axios";
import { pinyin } from "pinyin-pro";
@@ -1061,6 +1062,10 @@ watch(isListView, (newVal) => {
<v-icon class="mr-2">mdi-server-network</v-icon>
{{ tm("tabs.installedMcpServers") }}
</v-tab>
<v-tab value="skills">
<v-icon class="mr-2">mdi-lightning-bolt</v-icon>
{{ tm("tabs.skills") }}
</v-tab>
<v-tab value="market">
<v-icon class="mr-2">mdi-store</v-icon>
{{ tm("tabs.market") }}
@@ -1541,6 +1546,19 @@ watch(isListView, (newVal) => {
</v-card>
</v-tab-item>
<!-- Skills 标签页内容 -->
<v-tab-item v-show="activeTab === 'skills'">
<v-card
class="rounded-lg"
variant="flat"
style="background-color: transparent"
>
<v-card-text class="pa-0">
<SkillsSection />
</v-card-text>
</v-card>
</v-tab-item>
<!-- 插件市场标签页内容 -->
<v-tab-item v-show="activeTab === 'market'">
<!-- 插件源管理区域 -->
@@ -49,6 +49,14 @@
prepend-icon="mdi-tools">
{{ persona.tools.length }} {{ tm('persona.toolsCount') }}
</v-chip>
<v-chip v-if="persona.skills === null" size="small" color="success" variant="tonal"
prepend-icon="mdi-lightning-bolt">
{{ tm('form.allSkillsAvailable') }}
</v-chip>
<v-chip v-else-if="persona.skills && persona.skills.length > 0" size="small" color="primary"
variant="tonal" prepend-icon="mdi-lightning-bolt">
{{ persona.skills.length }} {{ tm('persona.skillsCount') }}
</v-chip>
</div>
<div class="mt-3 text-caption text-medium-emphasis">
@@ -73,6 +81,7 @@ interface Persona {
system_prompt: string;
begin_dialogs?: string[] | null;
tools?: string[] | null;
skills?: string[] | null;
created_at?: string;
updated_at?: string;
folder_id?: string | null;
@@ -156,6 +156,25 @@
</div>
</div>
<div class="mb-4">
<h4 class="text-h6 mb-2">{{ tm('form.skills') }}</h4>
<div v-if="viewingPersona.skills === null" class="text-body-2 text-medium-emphasis">
<v-chip size="small" color="success" variant="tonal" prepend-icon="mdi-check-all">
{{ tm('form.allSkillsAvailable') }}
</v-chip>
</div>
<div v-else-if="viewingPersona.skills && viewingPersona.skills.length > 0"
class="d-flex flex-wrap ga-1">
<v-chip v-for="skillName in viewingPersona.skills" :key="skillName" size="small"
color="primary" variant="tonal">
{{ skillName }}
</v-chip>
</div>
<div v-else class="text-body-2 text-medium-emphasis">
{{ tm('form.noSkillsSelected') }}
</div>
</div>
<div class="text-caption text-medium-emphasis">
<div>{{ tm('labels.createdAt') }}: {{ formatDate(viewingPersona.created_at) }}</div>
<div v-if="viewingPersona.updated_at">{{ tm('labels.updatedAt') }}:
@@ -249,6 +268,7 @@ interface Persona {
system_prompt: string;
begin_dialogs?: string[] | null;
tools?: string[] | null;
skills?: string[] | null;
created_at?: string;
updated_at?: string;
folder_id?: string | null;