feat(skills): mark sandbox preset skills readonly
expose skill source metadata and sandbox cache status in the skills API response so the dashboard can distinguish local, sandbox-only, and synced skills. prevent enabling, disabling, or deleting sandbox-only preset skills in both backend guards and UI actions to avoid invalid local operations. add source badges, discovery-pending hinting for sandbox runtime, and new i18n strings for source labels and readonly warnings.
This commit is contained in:
@@ -32,6 +32,10 @@ class SkillInfo:
|
||||
description: str
|
||||
path: str
|
||||
active: bool
|
||||
source_type: str = "local_only"
|
||||
source_label: str = "local"
|
||||
local_exists: bool = True
|
||||
sandbox_exists: bool = False
|
||||
|
||||
|
||||
def _parse_frontmatter_description(text: str) -> str:
|
||||
@@ -164,6 +168,7 @@ class SkillManager:
|
||||
return {
|
||||
"version": int(data.get("version", _SANDBOX_SKILLS_CACHE_VERSION)),
|
||||
"skills": skills,
|
||||
"updated_at": data.get("updated_at"),
|
||||
}
|
||||
except Exception:
|
||||
return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []}
|
||||
@@ -198,6 +203,17 @@ class SkillManager:
|
||||
}
|
||||
self._save_sandbox_skills_cache(cache)
|
||||
|
||||
def get_sandbox_skills_cache_status(self) -> dict[str, object]:
|
||||
cache = self._load_sandbox_skills_cache()
|
||||
skills = cache.get("skills", [])
|
||||
count = len(skills) if isinstance(skills, list) else 0
|
||||
return {
|
||||
"exists": os.path.exists(self.sandbox_skills_cache_path),
|
||||
"ready": count > 0,
|
||||
"count": count,
|
||||
"updated_at": cache.get("updated_at"),
|
||||
}
|
||||
|
||||
def list_skills(
|
||||
self,
|
||||
*,
|
||||
@@ -217,15 +233,18 @@ class SkillManager:
|
||||
skills_by_name: dict[str, SkillInfo] = {}
|
||||
|
||||
sandbox_cached_paths: dict[str, str] = {}
|
||||
if runtime == "sandbox":
|
||||
cache_for_paths = self._load_sandbox_skills_cache()
|
||||
for item in cache_for_paths.get("skills", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name", "") or "").strip()
|
||||
path = str(item.get("path", "") or "").strip().replace("\\", "/")
|
||||
if name and path and _SKILL_NAME_RE.match(name):
|
||||
sandbox_cached_paths[name] = path
|
||||
sandbox_cached_descriptions: dict[str, str] = {}
|
||||
cache_for_paths = self._load_sandbox_skills_cache()
|
||||
for item in cache_for_paths.get("skills", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name", "") or "").strip()
|
||||
path = str(item.get("path", "") or "").strip().replace("\\", "/")
|
||||
if not name or not _SKILL_NAME_RE.match(name):
|
||||
continue
|
||||
sandbox_cached_descriptions[name] = str(item.get("description", "") or "")
|
||||
if path:
|
||||
sandbox_cached_paths[name] = path
|
||||
|
||||
for entry in sorted(Path(self.skills_root).iterdir()):
|
||||
if not entry.is_dir():
|
||||
@@ -246,6 +265,11 @@ class SkillManager:
|
||||
description = _parse_frontmatter_description(content)
|
||||
except Exception:
|
||||
description = ""
|
||||
sandbox_exists = (
|
||||
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
|
||||
)
|
||||
source_type = "both" if sandbox_exists else "local_only"
|
||||
source_label = "synced" if sandbox_exists else "local"
|
||||
if runtime == "sandbox" and show_sandbox_path:
|
||||
path_str = sandbox_cached_paths.get(skill_name) or (
|
||||
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
|
||||
@@ -258,6 +282,10 @@ class SkillManager:
|
||||
description=description,
|
||||
path=path_str,
|
||||
active=active,
|
||||
source_type=source_type,
|
||||
source_label=source_label,
|
||||
local_exists=True,
|
||||
sandbox_exists=sandbox_exists,
|
||||
)
|
||||
|
||||
if runtime == "sandbox":
|
||||
@@ -278,22 +306,22 @@ class SkillManager:
|
||||
modified = True
|
||||
if active_only and not active:
|
||||
continue
|
||||
description = str(item.get("description", "") or "")
|
||||
description = sandbox_cached_descriptions.get(skill_name, "")
|
||||
if show_sandbox_path:
|
||||
path_str = (
|
||||
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
|
||||
)
|
||||
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
|
||||
else:
|
||||
path_str = str(item.get("path", "") or "")
|
||||
path_str = sandbox_cached_paths.get(skill_name, "")
|
||||
if not path_str:
|
||||
path_str = (
|
||||
f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
|
||||
)
|
||||
path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
|
||||
skills_by_name[skill_name] = SkillInfo(
|
||||
name=skill_name,
|
||||
description=description,
|
||||
path=path_str.replace("\\", "/"),
|
||||
active=active,
|
||||
source_type="sandbox_only",
|
||||
source_label="sandbox_preset",
|
||||
local_exists=False,
|
||||
sandbox_exists=True,
|
||||
)
|
||||
|
||||
if modified:
|
||||
@@ -302,7 +330,27 @@ class SkillManager:
|
||||
|
||||
return [skills_by_name[name] for name in sorted(skills_by_name)]
|
||||
|
||||
def is_sandbox_only_skill(self, name: str) -> bool:
|
||||
skill_dir = Path(self.skills_root) / name
|
||||
skill_md_exists = (skill_dir / "SKILL.md").exists()
|
||||
if skill_md_exists:
|
||||
return False
|
||||
cache = self._load_sandbox_skills_cache()
|
||||
skills = cache.get("skills", [])
|
||||
if not isinstance(skills, list):
|
||||
return False
|
||||
for item in skills:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("name", "")).strip() == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_skill_active(self, name: str, active: bool) -> None:
|
||||
if self.is_sandbox_only_skill(name):
|
||||
raise PermissionError(
|
||||
"Sandbox preset skill cannot be enabled/disabled from local skill management."
|
||||
)
|
||||
config = self._load_config()
|
||||
config.setdefault("skills", {})
|
||||
config["skills"][name] = {"active": bool(active)}
|
||||
@@ -318,8 +366,7 @@ class SkillManager:
|
||||
item
|
||||
for item in skills
|
||||
if not (
|
||||
isinstance(item, dict)
|
||||
and str(item.get("name", "")).strip() == name
|
||||
isinstance(item, dict) and str(item.get("name", "")).strip() == name
|
||||
)
|
||||
]
|
||||
|
||||
@@ -328,6 +375,11 @@ class SkillManager:
|
||||
self._save_sandbox_skills_cache(cache)
|
||||
|
||||
def delete_skill(self, name: str) -> None:
|
||||
if self.is_sandbox_only_skill(name):
|
||||
raise PermissionError(
|
||||
"Sandbox preset skill cannot be deleted from local skill management."
|
||||
)
|
||||
|
||||
skill_dir = Path(self.skills_root) / name
|
||||
if skill_dir.exists():
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from quart import request
|
||||
from quart import request, send_file
|
||||
|
||||
from astrbot.core import DEMO_MODE, logger
|
||||
from astrbot.core.computer.computer_client import (
|
||||
@@ -37,6 +40,9 @@ def _to_bool(value: Any, default: bool = False) -> bool:
|
||||
return bool(value)
|
||||
|
||||
|
||||
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
class SkillsRoute(Route):
|
||||
def __init__(self, context: RouteContext, core_lifecycle) -> None:
|
||||
super().__init__(context)
|
||||
@@ -44,6 +50,7 @@ class SkillsRoute(Route):
|
||||
self.routes = {
|
||||
"/skills": ("GET", self.get_skills),
|
||||
"/skills/upload": ("POST", self.upload_skill),
|
||||
"/skills/download": ("GET", self.download_skill),
|
||||
"/skills/update": ("POST", self.update_skill),
|
||||
"/skills/delete": ("POST", self.delete_skill),
|
||||
"/skills/neo/candidates": ("GET", self.get_neo_candidates),
|
||||
@@ -116,7 +123,8 @@ class SkillsRoute(Route):
|
||||
"provider_settings", {}
|
||||
)
|
||||
runtime = provider_settings.get("computer_use_runtime", "local")
|
||||
skills = SkillManager().list_skills(
|
||||
skill_mgr = SkillManager()
|
||||
skills = skill_mgr.list_skills(
|
||||
active_only=False, runtime=runtime, show_sandbox_path=False
|
||||
)
|
||||
return (
|
||||
@@ -124,6 +132,8 @@ class SkillsRoute(Route):
|
||||
.ok(
|
||||
{
|
||||
"skills": [skill.__dict__ for skill in skills],
|
||||
"runtime": runtime,
|
||||
"sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
@@ -178,6 +188,53 @@ class SkillsRoute(Route):
|
||||
except Exception:
|
||||
logger.warning(f"Failed to remove temp skill file: {temp_path}")
|
||||
|
||||
async def download_skill(self):
|
||||
try:
|
||||
name = str(request.args.get("name") or "").strip()
|
||||
if not name:
|
||||
return Response().error("Missing skill name").__dict__
|
||||
if not _SKILL_NAME_RE.match(name):
|
||||
return Response().error("Invalid skill name").__dict__
|
||||
|
||||
skill_mgr = SkillManager()
|
||||
if skill_mgr.is_sandbox_only_skill(name):
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
"Sandbox preset skill cannot be downloaded from local skill files."
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
skill_dir = Path(skill_mgr.skills_root) / name
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_dir.is_dir() or not skill_md.exists():
|
||||
return Response().error("Local skill not found").__dict__
|
||||
|
||||
export_dir = Path(get_astrbot_temp_path()) / "skill_exports"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_base = export_dir / name
|
||||
zip_path = zip_base.with_suffix(".zip")
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
shutil.make_archive(
|
||||
str(zip_base),
|
||||
"zip",
|
||||
root_dir=str(skill_mgr.skills_root),
|
||||
base_dir=name,
|
||||
)
|
||||
|
||||
return await send_file(
|
||||
str(zip_path),
|
||||
as_attachment=True,
|
||||
attachment_filename=f"{name}.zip",
|
||||
conditional=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def update_skill(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
|
||||
@@ -23,8 +23,17 @@
|
||||
</v-btn-toggle>
|
||||
</v-row>
|
||||
|
||||
<div v-if="mode === 'local'" class="px-2 pb-2">
|
||||
<div v-if="mode === 'local'" class="px-2 pb-2 d-flex flex-column ga-2">
|
||||
<small style="color: grey;">{{ tm("skills.runtimeHint") }}</small>
|
||||
<v-alert
|
||||
v-if="runtime === 'sandbox' && !sandboxCache.ready"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
border="start"
|
||||
>
|
||||
{{ tm("skills.sandboxDiscoveryPending") }}
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'neo' && !neoEnabled" class="px-3 pb-3">
|
||||
@@ -42,27 +51,58 @@
|
||||
<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">
|
||||
<v-row v-else align="stretch">
|
||||
<v-col
|
||||
v-for="skill in skills"
|
||||
:key="skill.name"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
xl="3"
|
||||
class="d-flex"
|
||||
>
|
||||
<item-card
|
||||
:item="skill"
|
||||
title-field="name"
|
||||
enabled-field="active"
|
||||
:loading="itemLoading[skill.name] || false"
|
||||
:show-edit-button="false"
|
||||
:disable-toggle="isSandboxPresetSkill(skill)"
|
||||
:disable-delete="isSandboxPresetSkill(skill)"
|
||||
@toggle-enabled="toggleSkill"
|
||||
@delete="confirmDelete"
|
||||
>
|
||||
<template #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 class="d-flex align-center mb-2 ga-2 flex-wrap">
|
||||
<v-chip
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
:color="sourceTypeColor(item.source_type)"
|
||||
>
|
||||
{{ sourceTypeLabel(item.source_type) }}
|
||||
</v-chip>
|
||||
<div class="text-caption text-medium-emphasis skill-description">
|
||||
<v-icon size="small" class="me-1">mdi-text</v-icon>
|
||||
{{ item.description || tm("skills.noDescription") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
<div class="text-caption text-medium-emphasis skill-path">
|
||||
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
|
||||
{{ tm("skills.path") }}: {{ item.path }}
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small"
|
||||
rounded="xl"
|
||||
:disabled="itemLoading[item.name] || false || isSandboxPresetSkill(item)"
|
||||
@click="downloadSkill(item)"
|
||||
>
|
||||
{{ tm("skills.download") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</item-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -288,6 +328,8 @@ export default {
|
||||
const mode = ref("local");
|
||||
const skills = ref([]);
|
||||
const loading = ref(false);
|
||||
const runtime = ref("local");
|
||||
const sandboxCache = reactive({ ready: false, count: 0, updated_at: null });
|
||||
const uploading = ref(false);
|
||||
const uploadDialog = ref(false);
|
||||
const uploadFile = ref(null);
|
||||
@@ -357,10 +399,35 @@ export default {
|
||||
|
||||
const normalizeSkillsPayload = (res) => {
|
||||
const payload = res?.data?.data || [];
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload)) {
|
||||
runtime.value = "local";
|
||||
sandboxCache.ready = false;
|
||||
sandboxCache.count = 0;
|
||||
sandboxCache.updated_at = null;
|
||||
return payload;
|
||||
}
|
||||
runtime.value = payload.runtime || "local";
|
||||
const cache = payload.sandbox_cache || {};
|
||||
sandboxCache.ready = !!cache.ready;
|
||||
sandboxCache.count = Number(cache.count || 0);
|
||||
sandboxCache.updated_at = cache.updated_at || null;
|
||||
return payload.skills || [];
|
||||
};
|
||||
|
||||
const sourceTypeLabel = (sourceType) => {
|
||||
if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly");
|
||||
if (sourceType === "both") return tm("skills.sourceBoth");
|
||||
return tm("skills.sourceLocalOnly");
|
||||
};
|
||||
|
||||
const sourceTypeColor = (sourceType) => {
|
||||
if (sourceType === "sandbox_only") return "indigo";
|
||||
if (sourceType === "both") return "success";
|
||||
return "primary";
|
||||
};
|
||||
|
||||
const isSandboxPresetSkill = (skill) => skill?.source_type === "sandbox_only";
|
||||
|
||||
const normalizeNeoItemsPayload = (res) => {
|
||||
const payload = res?.data?.data || [];
|
||||
if (Array.isArray(payload)) return payload;
|
||||
@@ -417,6 +484,10 @@ export default {
|
||||
};
|
||||
|
||||
const toggleSkill = async (skill) => {
|
||||
if (isSandboxPresetSkill(skill)) {
|
||||
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
|
||||
return;
|
||||
}
|
||||
const nextActive = !skill.active;
|
||||
itemLoading[skill.name] = true;
|
||||
try {
|
||||
@@ -435,6 +506,10 @@ export default {
|
||||
};
|
||||
|
||||
const confirmDelete = (skill) => {
|
||||
if (isSandboxPresetSkill(skill)) {
|
||||
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
|
||||
return;
|
||||
}
|
||||
skillToDelete.value = skill;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
@@ -457,6 +532,34 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSkill = async (skill) => {
|
||||
if (isSandboxPresetSkill(skill)) {
|
||||
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
|
||||
return;
|
||||
}
|
||||
itemLoading[skill.name] = true;
|
||||
try {
|
||||
const res = await axios.get("/api/skills/download", {
|
||||
params: { name: skill.name },
|
||||
responseType: "blob",
|
||||
});
|
||||
const blob = new Blob([res.data], { type: "application/zip" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${skill.name}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
showMessage(tm("skills.downloadSuccess"), "success");
|
||||
} catch (_err) {
|
||||
showMessage(tm("skills.downloadFailed"), "error");
|
||||
} finally {
|
||||
itemLoading[skill.name] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNeoCandidates = async () => {
|
||||
const params = {
|
||||
skill_key: neoFilters.skill_key || undefined,
|
||||
@@ -685,6 +788,8 @@ export default {
|
||||
mode,
|
||||
skills,
|
||||
loading,
|
||||
runtime,
|
||||
sandboxCache,
|
||||
uploadDialog,
|
||||
uploadFile,
|
||||
uploading,
|
||||
@@ -707,6 +812,7 @@ export default {
|
||||
refreshCurrentMode,
|
||||
fetchNeoData,
|
||||
uploadSkill,
|
||||
downloadSkill,
|
||||
toggleSkill,
|
||||
confirmDelete,
|
||||
deleteSkill,
|
||||
@@ -719,6 +825,9 @@ export default {
|
||||
viewPayload,
|
||||
deleteCandidate,
|
||||
deleteRelease,
|
||||
sourceTypeLabel,
|
||||
sourceTypeColor,
|
||||
isSandboxPresetSkill,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -730,6 +839,16 @@ export default {
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.skill-path {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.payload-preview {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
density="compact"
|
||||
:model-value="getItemEnabled()"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
:disabled="loading || disableToggle"
|
||||
v-bind="props"
|
||||
@update:model-value="toggleEnabled"
|
||||
></v-switch>
|
||||
@@ -29,7 +29,7 @@
|
||||
color="error"
|
||||
size="small"
|
||||
rounded="xl"
|
||||
:disabled="loading"
|
||||
:disabled="loading || disableDelete"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
{{ t('core.common.itemCard.delete') }}
|
||||
@@ -108,6 +108,14 @@ export default {
|
||||
showEditButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
disableToggle: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disableDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
|
||||
@@ -132,6 +140,7 @@ export default {
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
min-height: 220px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -220,6 +220,9 @@
|
||||
"path": "Path",
|
||||
"uploadSuccess": "Upload succeeded",
|
||||
"uploadFailed": "Upload failed",
|
||||
"download": "Download",
|
||||
"downloadSuccess": "Download succeeded",
|
||||
"downloadFailed": "Download failed",
|
||||
"loadFailed": "Failed to load Skills",
|
||||
"updateSuccess": "Updated successfully",
|
||||
"updateFailed": "Update failed",
|
||||
@@ -253,7 +256,12 @@
|
||||
"neoPayloadFailed": "Failed to load payload",
|
||||
"runtimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.",
|
||||
"runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills.",
|
||||
"neoRuntimeRequired": "Neo Skills are available only when runtime is sandbox and sandbox booter is shipyard_neo."
|
||||
"neoRuntimeRequired": "Neo Skills are available only when runtime is sandbox and sandbox booter is shipyard_neo.",
|
||||
"sourceLocalOnly": "Local Skill",
|
||||
"sourceSandboxOnly": "Sandbox Preset Skill",
|
||||
"sourceBoth": "Local + Sandbox",
|
||||
"sandboxDiscoveryPending": "Sandbox preset skills have not been discovered yet. Start at least one sandbox session to populate this list.",
|
||||
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills."
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -220,6 +220,9 @@
|
||||
"path": "路径",
|
||||
"uploadSuccess": "上传成功",
|
||||
"uploadFailed": "上传失败",
|
||||
"download": "下载",
|
||||
"downloadSuccess": "下载成功",
|
||||
"downloadFailed": "下载失败",
|
||||
"loadFailed": "加载 Skills 失败",
|
||||
"updateSuccess": "更新成功",
|
||||
"updateFailed": "更新失败",
|
||||
@@ -256,7 +259,12 @@
|
||||
"neoPayloadFailed": "读取 Payload 失败",
|
||||
"runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。",
|
||||
"runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。",
|
||||
"neoRuntimeRequired": "Neo Skills 仅在运行环境为 sandbox 且沙箱驱动为 shipyard_neo 时可用。"
|
||||
"neoRuntimeRequired": "Neo Skills 仅在运行环境为 sandbox 且沙箱驱动为 shipyard_neo 时可用。",
|
||||
"sourceLocalOnly": "本地 Skill",
|
||||
"sourceSandboxOnly": "Sandbox 预置 Skill",
|
||||
"sourceBoth": "本地 + Sandbox",
|
||||
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
|
||||
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
Reference in New Issue
Block a user