mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat: enhance OutlinedActionListItem component with clickable functionality and new slots
feat(i18n): update English, Russian, and Chinese translations for extension and knowledge base features fix: improve DocumentDetail and KBDetail views with outlined card styles and remove unnecessary dividers refactor: streamline KBList component to use OutlinedActionListItem for better UI consistency style: adjust styles for knowledge base components and improve responsive design test: add security tests for skill file browser and editor to prevent path traversal and file size issues
This commit is contained in:
@@ -41,6 +41,23 @@ def _to_bool(value: Any, default: bool = False) -> bool:
|
||||
|
||||
|
||||
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
_SKILL_FILE_MAX_BYTES = 512 * 1024
|
||||
_EDITABLE_SKILL_FILE_SUFFIXES = {
|
||||
".css",
|
||||
".html",
|
||||
".ini",
|
||||
".js",
|
||||
".json",
|
||||
".md",
|
||||
".py",
|
||||
".sh",
|
||||
".toml",
|
||||
".ts",
|
||||
".txt",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
_EDITABLE_SKILL_FILENAMES = {"Dockerfile", "Makefile"}
|
||||
|
||||
|
||||
def _next_available_temp_path(temp_dir: str, filename: str) -> str:
|
||||
@@ -63,6 +80,11 @@ class SkillsRoute(Route):
|
||||
"/skills/upload": ("POST", self.upload_skill),
|
||||
"/skills/batch-upload": ("POST", self.batch_upload_skills),
|
||||
"/skills/download": ("GET", self.download_skill),
|
||||
"/skills/files": ("GET", self.list_skill_files),
|
||||
"/skills/file": [
|
||||
("GET", self.get_skill_file),
|
||||
("POST", self.update_skill_file),
|
||||
],
|
||||
"/skills/update": ("POST", self.update_skill),
|
||||
"/skills/delete": ("POST", self.delete_skill),
|
||||
"/skills/neo/candidates": ("GET", self.get_neo_candidates),
|
||||
@@ -77,6 +99,75 @@ class SkillsRoute(Route):
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
def _resolve_local_skill_dir(self, name: str) -> Path:
|
||||
skill_name = str(name or "").strip()
|
||||
if not skill_name:
|
||||
raise ValueError("Missing skill name")
|
||||
if not _SKILL_NAME_RE.match(skill_name):
|
||||
raise ValueError("Invalid skill name")
|
||||
|
||||
skill_mgr = SkillManager()
|
||||
if skill_mgr.is_sandbox_only_skill(skill_name):
|
||||
raise PermissionError(
|
||||
"Sandbox preset skill cannot be opened from local skill files."
|
||||
)
|
||||
|
||||
skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
|
||||
skill_dir = (skills_root / skill_name).resolve(strict=True)
|
||||
if not skill_dir.is_relative_to(skills_root):
|
||||
raise PermissionError("Invalid skill path")
|
||||
if not skill_dir.is_dir() or not (skill_dir / "SKILL.md").exists():
|
||||
raise FileNotFoundError("Local skill not found")
|
||||
return skill_dir
|
||||
|
||||
def _resolve_skill_relative_path(
|
||||
self,
|
||||
skill_dir: Path,
|
||||
relative_path: str | None,
|
||||
*,
|
||||
expect_file: bool,
|
||||
) -> Path:
|
||||
raw_path = str(relative_path or ".").strip() or "."
|
||||
normalized = Path(raw_path.replace("\\", "/"))
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("Invalid relative path")
|
||||
|
||||
target = (skill_dir / normalized).resolve(strict=True)
|
||||
if not target.is_relative_to(skill_dir):
|
||||
raise PermissionError("Path escapes skill directory")
|
||||
if expect_file and not target.is_file():
|
||||
raise FileNotFoundError("Skill file not found")
|
||||
if not expect_file and not target.is_dir():
|
||||
raise FileNotFoundError("Skill directory not found")
|
||||
return target
|
||||
|
||||
@staticmethod
|
||||
def _skill_relative_path(skill_dir: Path, target: Path) -> str:
|
||||
rel = target.relative_to(skill_dir).as_posix()
|
||||
return "" if rel == "." else rel
|
||||
|
||||
@staticmethod
|
||||
def _is_editable_skill_file(path: Path) -> bool:
|
||||
return (
|
||||
path.name in _EDITABLE_SKILL_FILENAMES
|
||||
or path.suffix.lower() in _EDITABLE_SKILL_FILE_SUFFIXES
|
||||
)
|
||||
|
||||
def _serialize_skill_file_entry(self, skill_dir: Path, path: Path) -> dict:
|
||||
stat = path.stat()
|
||||
is_dir = path.is_dir()
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": self._skill_relative_path(skill_dir, path),
|
||||
"type": "directory" if is_dir else "file",
|
||||
"size": 0 if is_dir else stat.st_size,
|
||||
"editable": (
|
||||
(not is_dir)
|
||||
and self._is_editable_skill_file(path)
|
||||
and stat.st_size <= _SKILL_FILE_MAX_BYTES
|
||||
),
|
||||
}
|
||||
|
||||
def _get_neo_client_config(self) -> tuple[str, str]:
|
||||
provider_settings = self.core_lifecycle.astrbot_config.get(
|
||||
"provider_settings",
|
||||
@@ -417,6 +508,137 @@ class SkillsRoute(Route):
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def list_skill_files(self):
|
||||
try:
|
||||
name = str(request.args.get("name") or "").strip()
|
||||
relative_path = request.args.get("path", "")
|
||||
skill_dir = self._resolve_local_skill_dir(name)
|
||||
target_dir = self._resolve_skill_relative_path(
|
||||
skill_dir,
|
||||
relative_path,
|
||||
expect_file=False,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for entry in sorted(
|
||||
target_dir.iterdir(),
|
||||
key=lambda item: (not item.is_dir(), item.name.lower()),
|
||||
):
|
||||
try:
|
||||
resolved = entry.resolve(strict=True)
|
||||
except OSError:
|
||||
continue
|
||||
if not resolved.is_relative_to(skill_dir):
|
||||
continue
|
||||
if not resolved.is_dir() and not resolved.is_file():
|
||||
continue
|
||||
entries.append(self._serialize_skill_file_entry(skill_dir, resolved))
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"name": name,
|
||||
"path": self._skill_relative_path(skill_dir, target_dir),
|
||||
"entries": entries,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def get_skill_file(self):
|
||||
try:
|
||||
name = str(request.args.get("name") or "").strip()
|
||||
relative_path = request.args.get("path", "SKILL.md")
|
||||
skill_dir = self._resolve_local_skill_dir(name)
|
||||
target_file = self._resolve_skill_relative_path(
|
||||
skill_dir,
|
||||
relative_path,
|
||||
expect_file=True,
|
||||
)
|
||||
if not self._is_editable_skill_file(target_file):
|
||||
return Response().error("Unsupported file type").__dict__
|
||||
|
||||
size = target_file.stat().st_size
|
||||
if size > _SKILL_FILE_MAX_BYTES:
|
||||
return Response().error("File is too large").__dict__
|
||||
|
||||
try:
|
||||
content = target_file.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return Response().error("File is not valid UTF-8 text").__dict__
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"name": name,
|
||||
"path": self._skill_relative_path(skill_dir, target_file),
|
||||
"content": content,
|
||||
"size": size,
|
||||
"editable": True,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def update_skill_file(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 = str(data.get("name") or "").strip()
|
||||
relative_path = data.get("path", "SKILL.md")
|
||||
content = data.get("content")
|
||||
if not isinstance(content, str):
|
||||
return Response().error("Missing file content").__dict__
|
||||
|
||||
encoded = content.encode("utf-8")
|
||||
if len(encoded) > _SKILL_FILE_MAX_BYTES:
|
||||
return Response().error("File content is too large").__dict__
|
||||
|
||||
skill_dir = self._resolve_local_skill_dir(name)
|
||||
target_file = self._resolve_skill_relative_path(
|
||||
skill_dir,
|
||||
relative_path,
|
||||
expect_file=True,
|
||||
)
|
||||
if not self._is_editable_skill_file(target_file):
|
||||
return Response().error("Unsupported file type").__dict__
|
||||
|
||||
target_file.write_text(content, encoding="utf-8")
|
||||
|
||||
try:
|
||||
await sync_skills_to_active_sandboxes()
|
||||
except Exception:
|
||||
logger.warning("Failed to sync edited skills to active sandboxes.")
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"name": name,
|
||||
"path": self._skill_relative_path(skill_dir, target_file),
|
||||
"size": len(encoded),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def update_skill(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
v-for="server in mcpServers || []"
|
||||
:key="server.name"
|
||||
:title="server.name"
|
||||
clickable
|
||||
@click="editServer(server)"
|
||||
>
|
||||
<div
|
||||
class="mcp-server-config text-body-2 text-medium-emphasis"
|
||||
@@ -33,6 +35,7 @@
|
||||
v-bind="listToolsProps"
|
||||
class="mcp-server-tools__button"
|
||||
type="button"
|
||||
@click.stop
|
||||
>
|
||||
<v-icon size="small" class="me-1">mdi-tools</v-icon>
|
||||
{{
|
||||
@@ -81,19 +84,6 @@
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<v-tooltip :text="t('core.common.itemCard.edit')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-pencil-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="list-action-icon-btn"
|
||||
@click="editServer(server)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip :text="t('core.common.itemCard.delete')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
@@ -102,7 +92,7 @@
|
||||
variant="text"
|
||||
size="small"
|
||||
class="list-action-icon-btn"
|
||||
@click="deleteServer(server)"
|
||||
@click.stop="deleteServer(server)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
@@ -127,6 +117,7 @@
|
||||
:model-value="server.active"
|
||||
:loading="mcpServerUpdateLoaders[server.name] || false"
|
||||
:disabled="mcpServerUpdateLoaders[server.name] || false"
|
||||
@click.stop
|
||||
@update:model-value="updateServerStatus(server)"
|
||||
/>
|
||||
</template>
|
||||
@@ -655,7 +646,7 @@ export default {
|
||||
.mcp-server-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mcp-server-config {
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
v-for="skill in skills"
|
||||
:key="skill.name"
|
||||
:title="skill.name"
|
||||
clickable
|
||||
@click="openSkillEditor(skill)"
|
||||
>
|
||||
<template #title-extra>
|
||||
<v-chip
|
||||
@@ -91,7 +93,7 @@
|
||||
false ||
|
||||
isSandboxPresetSkill(skill)
|
||||
"
|
||||
@click="downloadSkill(skill)"
|
||||
@click.stop="downloadSkill(skill)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
@@ -105,7 +107,7 @@
|
||||
size="small"
|
||||
class="list-action-icon-btn"
|
||||
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
|
||||
@click="confirmDelete(skill)"
|
||||
@click.stop="confirmDelete(skill)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
@@ -123,6 +125,7 @@
|
||||
:model-value="skill.active"
|
||||
:loading="itemLoading[skill.name] || false"
|
||||
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
|
||||
@click.stop
|
||||
@update:model-value="toggleSkill(skill)"
|
||||
/>
|
||||
</template>
|
||||
@@ -593,6 +596,142 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog
|
||||
v-model="editorDialog.show"
|
||||
max-width="1180px"
|
||||
:persistent="editorDialog.saving"
|
||||
>
|
||||
<v-card class="skill-editor-dialog">
|
||||
<v-card-title class="skill-editor-dialog__header">
|
||||
<div>
|
||||
<div class="text-h3 font-weight-bold">
|
||||
{{ editorDialog.skillName }}
|
||||
</div>
|
||||
</div>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
:disabled="editorDialog.saving"
|
||||
@click="closeSkillEditor"
|
||||
/>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="skill-editor-dialog__body">
|
||||
<div class="skill-editor">
|
||||
<div class="skill-editor__files">
|
||||
<div class="skill-editor__files-header">
|
||||
<v-btn
|
||||
icon="mdi-arrow-up"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!editorDialog.currentDir || editorDialog.loadingFiles"
|
||||
@click="openParentSkillDir"
|
||||
/>
|
||||
<span>{{ editorDialog.currentDir || "/" }}</span>
|
||||
</div>
|
||||
|
||||
<v-progress-linear
|
||||
v-if="editorDialog.loadingFiles"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
<div v-else class="skill-editor__file-list">
|
||||
<button
|
||||
v-for="entry in editorDialog.entries"
|
||||
:key="`${entry.type}:${entry.path}`"
|
||||
class="skill-editor__file-row"
|
||||
:class="{
|
||||
'skill-editor__file-row--active':
|
||||
editorDialog.filePath === entry.path,
|
||||
}"
|
||||
type="button"
|
||||
@click="openSkillEntry(entry)"
|
||||
>
|
||||
<v-icon size="18">
|
||||
{{
|
||||
entry.type === "directory"
|
||||
? "mdi-folder-outline"
|
||||
: "mdi-file-document-outline"
|
||||
}}
|
||||
</v-icon>
|
||||
<span>{{ entry.name }}</span>
|
||||
<v-chip
|
||||
v-if="entry.type === 'file' && !entry.editable"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ tm("skills.readonly") }}
|
||||
</v-chip>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-editor__content">
|
||||
<div class="skill-editor__content-header">
|
||||
<div class="skill-editor__path">
|
||||
{{ editorDialog.filePath || tm("skills.noFileSelected") }}
|
||||
</div>
|
||||
<v-chip
|
||||
v-if="editorDialog.fileDirty"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ tm("skills.unsaved") }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="editorDialog.error"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
{{ editorDialog.error }}
|
||||
</v-alert>
|
||||
|
||||
<div class="skill-editor__monaco">
|
||||
<VueMonacoEditor
|
||||
v-model:value="editorDialog.content"
|
||||
:theme="editorTheme"
|
||||
:language="editorLanguage"
|
||||
:options="editorOptions"
|
||||
style="height: 100%; width: 100%;"
|
||||
@change="editorDialog.fileDirty = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="skill-editor-dialog__actions">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
:disabled="editorDialog.saving"
|
||||
@click="closeSkillEditor"
|
||||
>
|
||||
{{ tm("skills.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="editorDialog.saving"
|
||||
:disabled="
|
||||
!editorDialog.filePath ||
|
||||
!editorDialog.fileEditable ||
|
||||
!editorDialog.fileDirty
|
||||
"
|
||||
@click="saveSkillFile"
|
||||
>
|
||||
{{ tm("skills.saveFile") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="payloadDialog.show" max-width="820px">
|
||||
<v-card>
|
||||
<v-card-title>{{ tm("skills.neoPayloadTitle") }}</v-card-title>
|
||||
@@ -620,9 +759,11 @@
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { VueMonacoEditor } from "@guolao/vue-monaco-editor";
|
||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||
import OutlinedActionListItem from "@/components/shared/OutlinedActionListItem.vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
|
||||
const STATUS_WAITING = "waiting";
|
||||
const STATUS_UPLOADING = "uploading";
|
||||
@@ -632,10 +773,11 @@ const STATUS_SKIPPED = "skipped";
|
||||
|
||||
export default {
|
||||
name: "SkillsSection",
|
||||
components: { OutlinedActionListItem },
|
||||
components: { OutlinedActionListItem, VueMonacoEditor },
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n("features/extension");
|
||||
const customizer = useCustomizerStore();
|
||||
|
||||
const mode = ref("local");
|
||||
const skills = ref([]);
|
||||
@@ -666,6 +808,20 @@ export default {
|
||||
show: false,
|
||||
content: "",
|
||||
});
|
||||
const editorDialog = reactive({
|
||||
show: false,
|
||||
skillName: "",
|
||||
currentDir: "",
|
||||
entries: [],
|
||||
filePath: "",
|
||||
content: "",
|
||||
fileEditable: false,
|
||||
fileDirty: false,
|
||||
loadingFiles: false,
|
||||
loadingFile: false,
|
||||
saving: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
const neoEnabled = ref(false);
|
||||
const neoUnavailableMessage = ref("");
|
||||
@@ -691,6 +847,33 @@ export default {
|
||||
const activeReleaseCount = computed(
|
||||
() => neoReleases.value.filter((item) => item?.is_active).length,
|
||||
);
|
||||
const editorLanguage = computed(() => {
|
||||
const path = String(editorDialog.filePath || "").toLowerCase();
|
||||
if (path.endsWith(".json")) return "json";
|
||||
if (path.endsWith(".yaml") || path.endsWith(".yml")) return "yaml";
|
||||
if (path.endsWith(".toml") || path.endsWith(".ini")) return "ini";
|
||||
if (path.endsWith(".py")) return "python";
|
||||
if (path.endsWith(".js")) return "javascript";
|
||||
if (path.endsWith(".ts")) return "typescript";
|
||||
if (path.endsWith(".html")) return "html";
|
||||
if (path.endsWith(".css")) return "css";
|
||||
if (path.endsWith(".sh")) return "shell";
|
||||
if (path.endsWith(".md") || path.endsWith(".txt")) return "markdown";
|
||||
return "plaintext";
|
||||
});
|
||||
const editorTheme = computed(() =>
|
||||
customizer.uiTheme === "PurpleThemeDark" ? "vs-dark" : "vs-light",
|
||||
);
|
||||
const editorOptions = computed(() => ({
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
lineNumbers: "on",
|
||||
minimap: { enabled: false },
|
||||
readOnly: !editorDialog.fileEditable || editorDialog.loadingFile,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 2,
|
||||
wordWrap: "on",
|
||||
}));
|
||||
const uploadStateCounts = computed(() =>
|
||||
uploadItems.value.reduce(
|
||||
(counts, item) => {
|
||||
@@ -1137,6 +1320,167 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
const resetEditorDialog = () => {
|
||||
editorDialog.skillName = "";
|
||||
editorDialog.currentDir = "";
|
||||
editorDialog.entries = [];
|
||||
editorDialog.filePath = "";
|
||||
editorDialog.content = "";
|
||||
editorDialog.fileEditable = false;
|
||||
editorDialog.fileDirty = false;
|
||||
editorDialog.loadingFiles = false;
|
||||
editorDialog.loadingFile = false;
|
||||
editorDialog.saving = false;
|
||||
editorDialog.error = "";
|
||||
};
|
||||
|
||||
const loadSkillDir = async (path = "") => {
|
||||
if (!editorDialog.skillName) return [];
|
||||
editorDialog.loadingFiles = true;
|
||||
editorDialog.error = "";
|
||||
try {
|
||||
const res = await axios.get("/api/skills/files", {
|
||||
params: { name: editorDialog.skillName, path },
|
||||
});
|
||||
if (res?.data?.status !== "ok") {
|
||||
editorDialog.error =
|
||||
res?.data?.message || tm("skills.editorLoadFailed");
|
||||
return [];
|
||||
}
|
||||
const payload = res.data.data || {};
|
||||
editorDialog.currentDir = payload.path || "";
|
||||
editorDialog.entries = Array.isArray(payload.entries)
|
||||
? payload.entries
|
||||
: [];
|
||||
return editorDialog.entries;
|
||||
} catch (_err) {
|
||||
editorDialog.error = tm("skills.editorLoadFailed");
|
||||
return [];
|
||||
} finally {
|
||||
editorDialog.loadingFiles = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSkillFile = async (path) => {
|
||||
if (!editorDialog.skillName || !path) return;
|
||||
if (
|
||||
editorDialog.fileDirty &&
|
||||
!window.confirm(tm("skills.discardChanges"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
editorDialog.loadingFile = true;
|
||||
editorDialog.error = "";
|
||||
try {
|
||||
const res = await axios.get("/api/skills/file", {
|
||||
params: { name: editorDialog.skillName, path },
|
||||
});
|
||||
if (res?.data?.status !== "ok") {
|
||||
editorDialog.error =
|
||||
res?.data?.message || tm("skills.editorLoadFailed");
|
||||
return;
|
||||
}
|
||||
const payload = res.data.data || {};
|
||||
editorDialog.filePath = payload.path || path;
|
||||
editorDialog.content = payload.content || "";
|
||||
editorDialog.fileEditable = payload.editable !== false;
|
||||
await nextTick();
|
||||
editorDialog.fileDirty = false;
|
||||
} catch (_err) {
|
||||
editorDialog.error = tm("skills.editorLoadFailed");
|
||||
} finally {
|
||||
editorDialog.loadingFile = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openSkillEditor = async (skill) => {
|
||||
if (isSandboxPresetSkill(skill)) {
|
||||
showMessage(tm("skills.sandboxPresetReadonly"), "warning");
|
||||
return;
|
||||
}
|
||||
resetEditorDialog();
|
||||
editorDialog.skillName = skill.name;
|
||||
editorDialog.show = true;
|
||||
const entries = await loadSkillDir("");
|
||||
const skillMd = entries.find((entry) => entry.path === "SKILL.md");
|
||||
if (skillMd?.editable) {
|
||||
await loadSkillFile(skillMd.path);
|
||||
}
|
||||
};
|
||||
|
||||
const closeSkillEditor = () => {
|
||||
if (editorDialog.saving) return;
|
||||
if (
|
||||
editorDialog.fileDirty &&
|
||||
!window.confirm(tm("skills.discardChanges"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
editorDialog.show = false;
|
||||
resetEditorDialog();
|
||||
};
|
||||
|
||||
const openSkillEntry = async (entry) => {
|
||||
if (!entry) return;
|
||||
if (entry.type === "directory") {
|
||||
if (
|
||||
editorDialog.fileDirty &&
|
||||
!window.confirm(tm("skills.discardChanges"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await loadSkillDir(entry.path);
|
||||
return;
|
||||
}
|
||||
await loadSkillFile(entry.path);
|
||||
};
|
||||
|
||||
const openParentSkillDir = async () => {
|
||||
if (!editorDialog.currentDir) return;
|
||||
if (
|
||||
editorDialog.fileDirty &&
|
||||
!window.confirm(tm("skills.discardChanges"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parts = editorDialog.currentDir.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
await loadSkillDir(parts.join("/"));
|
||||
};
|
||||
|
||||
const saveSkillFile = async () => {
|
||||
if (
|
||||
!editorDialog.skillName ||
|
||||
!editorDialog.filePath ||
|
||||
!editorDialog.fileEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
editorDialog.saving = true;
|
||||
editorDialog.error = "";
|
||||
try {
|
||||
const res = await axios.post("/api/skills/file", {
|
||||
name: editorDialog.skillName,
|
||||
path: editorDialog.filePath,
|
||||
content: editorDialog.content,
|
||||
});
|
||||
if (res?.data?.status !== "ok") {
|
||||
editorDialog.error =
|
||||
res?.data?.message || tm("skills.editorSaveFailed");
|
||||
showMessage(editorDialog.error, "error");
|
||||
return;
|
||||
}
|
||||
editorDialog.fileDirty = false;
|
||||
showMessage(tm("skills.editorSaveSuccess"), "success");
|
||||
await fetchSkills();
|
||||
} catch (_err) {
|
||||
editorDialog.error = tm("skills.editorSaveFailed");
|
||||
showMessage(tm("skills.editorSaveFailed"), "error");
|
||||
} finally {
|
||||
editorDialog.saving = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNeoCandidates = async () => {
|
||||
const params = {
|
||||
skill_key: neoFilters.skill_key || undefined,
|
||||
@@ -1444,6 +1788,10 @@ export default {
|
||||
candidateHeaders,
|
||||
releaseHeaders,
|
||||
payloadDialog,
|
||||
editorDialog,
|
||||
editorLanguage,
|
||||
editorTheme,
|
||||
editorOptions,
|
||||
formatFileSize,
|
||||
uploadStatusLabel,
|
||||
statusChipClass,
|
||||
@@ -1457,6 +1805,11 @@ export default {
|
||||
fetchNeoData,
|
||||
uploadSkillBatch,
|
||||
downloadSkill,
|
||||
openSkillEditor,
|
||||
closeSkillEditor,
|
||||
openSkillEntry,
|
||||
openParentSkillDir,
|
||||
saveSkillFile,
|
||||
toggleSkill,
|
||||
confirmDelete,
|
||||
deleteSkill,
|
||||
@@ -1483,7 +1836,7 @@ export default {
|
||||
.skills-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-action-icon-btn {
|
||||
@@ -1533,6 +1886,125 @@ export default {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.skill-editor-dialog {
|
||||
max-height: min(88vh, 980px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-editor-dialog__header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 22px 14px;
|
||||
}
|
||||
|
||||
.skill-editor-dialog__body {
|
||||
min-height: 0;
|
||||
padding: 16px 22px;
|
||||
}
|
||||
|
||||
.skill-editor-dialog__actions {
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
padding: 12px 22px;
|
||||
}
|
||||
|
||||
.skill-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.skill-editor__files {
|
||||
border: 1px solid rgba(128, 128, 128, 0.28);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-editor__files-header {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.28);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.skill-editor__files-header span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-editor__file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.skill-editor__file-row {
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.skill-editor__file-row:hover,
|
||||
.skill-editor__file-row--active {
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.skill-editor__file-row--active {
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.skill-editor__file-row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-editor__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-editor__content-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.skill-editor__path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-editor__monaco {
|
||||
border: 1px solid rgba(128, 128, 128, 0.28);
|
||||
border-radius: 10px;
|
||||
flex: 1 1 auto;
|
||||
margin-top: 12px;
|
||||
min-height: 520px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skills-upload-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<template>
|
||||
<v-card class="outlined-action-list-item rounded-lg" variant="outlined">
|
||||
<v-card
|
||||
class="outlined-action-list-item rounded-lg"
|
||||
:class="{ 'outlined-action-list-item--clickable': clickable }"
|
||||
variant="outlined"
|
||||
:ripple="false"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="outlined-action-list-item__main">
|
||||
<div class="outlined-action-list-item__content">
|
||||
<div class="outlined-action-list-item__header">
|
||||
<slot name="title-prepend"></slot>
|
||||
<div class="outlined-action-list-item__title">
|
||||
{{ title }}
|
||||
</div>
|
||||
@@ -30,17 +37,43 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (!props.clickable) return;
|
||||
emit("click", event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.outlined-action-list-item {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
transition: background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.outlined-action-list-item:hover,
|
||||
.outlined-action-list-item:focus-within {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.outlined-action-list-item--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.outlined-action-list-item :deep(.v-card__overlay),
|
||||
.outlined-action-list-item :deep(.v-ripple__container) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.outlined-action-list-item__main {
|
||||
|
||||
@@ -348,7 +348,17 @@
|
||||
"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."
|
||||
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills.",
|
||||
"openEditor": "View/Edit",
|
||||
"editorTitle": "Edit Skill",
|
||||
"editorLoadFailed": "Failed to load Skill file",
|
||||
"editorSaveFailed": "Failed to save Skill file",
|
||||
"editorSaveSuccess": "Saved successfully",
|
||||
"saveFile": "Save file",
|
||||
"readonly": "Read-only",
|
||||
"unsaved": "Unsaved",
|
||||
"noFileSelected": "No file selected",
|
||||
"discardChanges": "This file has unsaved changes. Discard them?"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "Knowledge Base Details",
|
||||
"backToList": "Back to List",
|
||||
"breadcrumb": {
|
||||
"list": "Knowledge Bases"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"documents": "Documents",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "Knowledge Base Management",
|
||||
"subtitle": "Manage and query knowledge base contents",
|
||||
"list": {
|
||||
"title": "My Knowledge Bases",
|
||||
"title": "Knowledge Bases",
|
||||
"subtitle": "Manage all your knowledge base collections",
|
||||
"create": "Create Knowledge Base",
|
||||
"refresh": "Refresh List",
|
||||
|
||||
@@ -347,7 +347,17 @@
|
||||
"sourceSandboxOnly": "Предустановленный Sandbox навык",
|
||||
"sourceBoth": "Локальный + Sandbox",
|
||||
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
|
||||
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
|
||||
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
|
||||
"openEditor": "Просмотр/правка",
|
||||
"editorTitle": "Редактировать навык",
|
||||
"editorLoadFailed": "Не удалось открыть файл навыка",
|
||||
"editorSaveFailed": "Не удалось сохранить файл навыка",
|
||||
"editorSaveSuccess": "Сохранено",
|
||||
"saveFile": "Сохранить файл",
|
||||
"readonly": "Только чтение",
|
||||
"unsaved": "Не сохранено",
|
||||
"noFileSelected": "Файл не выбран",
|
||||
"discardChanges": "В файле есть несохраненные изменения. Отменить их?"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "Детали базы знаний",
|
||||
"backToList": "К списку",
|
||||
"breadcrumb": {
|
||||
"list": "Базы знаний"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Обзор",
|
||||
"documents": "Документы",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "Управление базами знаний",
|
||||
"subtitle": "Централизованное управление всеми знаниями AstrBot",
|
||||
"list": {
|
||||
"title": "Мои базы знаний",
|
||||
"title": "Базы знаний",
|
||||
"subtitle": "Все доступные коллекции знаний",
|
||||
"create": "Создать базу",
|
||||
"refresh": "Обновить",
|
||||
@@ -65,4 +65,4 @@
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"loadError": "Не удалось загрузить список"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +348,17 @@
|
||||
"sourceSandboxOnly": "Sandbox 预置 Skill",
|
||||
"sourceBoth": "本地 + Sandbox",
|
||||
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
|
||||
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。"
|
||||
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。",
|
||||
"openEditor": "查看/编辑",
|
||||
"editorTitle": "编辑 Skill",
|
||||
"editorLoadFailed": "读取 Skill 文件失败",
|
||||
"editorSaveFailed": "保存 Skill 文件失败",
|
||||
"editorSaveSuccess": "保存成功",
|
||||
"saveFile": "保存文件",
|
||||
"readonly": "只读",
|
||||
"unsaved": "未保存",
|
||||
"noFileSelected": "未选择文件",
|
||||
"discardChanges": "当前文件有未保存修改,确定要丢弃吗?"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "知识库详情",
|
||||
"backToList": "返回列表",
|
||||
"breadcrumb": {
|
||||
"list": "知识库"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"documents": "文档管理",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "知识库管理",
|
||||
"subtitle": "统一管理和查询知识库内容",
|
||||
"list": {
|
||||
"title": "我的知识库",
|
||||
"title": "知识库",
|
||||
"subtitle": "管理您的所有知识库集合",
|
||||
"create": "创建知识库",
|
||||
"refresh": "刷新列表",
|
||||
|
||||
@@ -21,9 +21,8 @@
|
||||
<!-- 主内容 -->
|
||||
<div v-else class="document-content">
|
||||
<!-- 文档信息卡片 -->
|
||||
<v-card elevation="2" class="mb-6">
|
||||
<v-card variant="outlined" class="mb-6">
|
||||
<v-card-title>{{ t('info.title') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -78,7 +77,7 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 分块列表 -->
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
<span>{{ t('chunks.title') }}</span>
|
||||
<v-chip class="ml-2" size="small" variant="tonal">
|
||||
@@ -97,8 +96,6 @@
|
||||
/> -->
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
@@ -187,7 +184,6 @@
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-6">
|
||||
<v-list density="comfortable">
|
||||
<v-list-item>
|
||||
@@ -215,14 +211,11 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<div class="text-caption text-medium-emphasis mb-2">{{ t('view.content') }}</div>
|
||||
<div class="chunk-content-view">
|
||||
{{ selectedChunk?.content }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showViewDialog = false">
|
||||
@@ -434,6 +427,10 @@ onMounted(() => {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.document-detail-page :deep(.v-card--variant-outlined) {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
<template>
|
||||
<div class="kb-detail-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="$router.push({ name: 'NativeKBList' })"
|
||||
/>
|
||||
<div class="header-content">
|
||||
<div class="kb-title">
|
||||
<span class="kb-emoji">{{ kb.emoji || '📚' }}</span>
|
||||
<h1 class="text-h4">{{ kb.kb_name }}</h1>
|
||||
</div>
|
||||
<p v-if="kb.description" class="text-subtitle-1 text-medium-emphasis mt-2">
|
||||
{{ kb.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<v-progress-circular indeterminate color="primary" size="64" />
|
||||
@@ -52,9 +34,8 @@
|
||||
<v-window-item value="overview">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title>{{ t('overview.title') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-list density="comfortable">
|
||||
<v-list-item>
|
||||
@@ -102,9 +83,8 @@
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card elevation="2" class="mb-4">
|
||||
<v-card variant="outlined" class="mb-4">
|
||||
<v-card-title>{{ t('overview.stats') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
@@ -125,9 +105,8 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title>{{ t('overview.embeddingModel') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-list density="comfortable">
|
||||
<v-list-item>
|
||||
@@ -177,7 +156,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
@@ -188,6 +167,10 @@ import SettingsTab from './components/SettingsTab.vue'
|
||||
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
|
||||
const route = useRoute()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'title-change', title: string): void
|
||||
}>()
|
||||
|
||||
const kbId = ref(route.params.kbId as string)
|
||||
const loading = ref(true)
|
||||
const activeTab = ref('overview')
|
||||
@@ -214,6 +197,7 @@ const loadKB = async () => {
|
||||
})
|
||||
if (response.data.status === 'ok') {
|
||||
kb.value = response.data.data
|
||||
emit('title-change', kb.value.kb_name || '')
|
||||
} else {
|
||||
showSnackbar(response.data.message || '加载失败', 'error')
|
||||
}
|
||||
@@ -241,51 +225,22 @@ const formatDate = (dateStr: string) => {
|
||||
onMounted(() => {
|
||||
loadKB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => kb.value?.kb_name,
|
||||
(name) => {
|
||||
emit('title-change', name || '')
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-detail-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn 0.3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.kb-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 48px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
.kb-detail-page :deep(.v-card--variant-outlined) {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -296,21 +251,6 @@ onMounted(() => {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.kb-content {
|
||||
animation: slideUp 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -340,12 +280,7 @@ onMounted(() => {
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.kb-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 36px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,84 @@
|
||||
<template>
|
||||
<div class="kb-list-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2">{{ t('list.title') }}</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis">{{ t('list.subtitle') }}</p>
|
||||
</div>
|
||||
<v-btn icon="mdi-information-outline" variant="text" size="small" color="grey"
|
||||
href="https://docs.astrbot.app/use/knowledge-base.html" target="_blank" />
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮栏 -->
|
||||
<div class="action-bar mb-6">
|
||||
<v-btn prepend-icon="mdi-plus" color="primary" variant="elevated" @click="showCreateDialog = true">
|
||||
{{ t('list.create') }}
|
||||
</v-btn>
|
||||
<v-btn prepend-icon="mdi-refresh" variant="tonal" @click="loadKnowledgeBases" :loading="loading">
|
||||
{{ t('list.refresh') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 知识库网格 -->
|
||||
<div v-if="loading && kbList.length === 0" class="loading-container">
|
||||
<v-progress-circular indeterminate color="primary" size="64" />
|
||||
<p class="mt-4 text-medium-emphasis">{{ t('list.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kbList.length > 0" class="kb-grid">
|
||||
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" :hover="!kb.init_error"
|
||||
:class="{ 'kb-card-error': kb.init_error }"
|
||||
@click="!kb.init_error && navigateToDetail(kb.kb_id)">
|
||||
<!-- Error badge -->
|
||||
<v-badge v-if="kb.init_error" color="error" icon="mdi-alert-circle"
|
||||
class="kb-error-badge position-absolute" style="top: 0; right: 0; transform: translate(34%, -34%);" />
|
||||
<div class="kb-card-content" :class="{ 'kb-card-content-error': kb.init_error }">
|
||||
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
|
||||
<h3 class="kb-name">{{ kb.kb_name }}</h3>
|
||||
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
|
||||
<div v-else-if="kbList.length > 0" class="kb-list">
|
||||
<OutlinedActionListItem
|
||||
v-for="kb in kbList"
|
||||
:key="kb.kb_id"
|
||||
:title="kb.kb_name"
|
||||
:clickable="!kb.init_error"
|
||||
@click="navigateToDetail(kb.kb_id)"
|
||||
>
|
||||
<template #title-prepend>
|
||||
<span class="kb-list-emoji">{{ kb.emoji || '📚' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Error message display -->
|
||||
<div v-if="kb.init_error" class="kb-error-panel mt-3 mb-2">
|
||||
<template #title-extra>
|
||||
<v-chip
|
||||
v-if="kb.init_error"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ t('list.initError') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<div v-if="!kb.init_error" class="kb-description text-body-2 text-medium-emphasis">
|
||||
{{ kb.description || '暂无描述' }}
|
||||
</div>
|
||||
|
||||
<div v-if="kb.init_error" class="kb-error-panel">
|
||||
<div class="kb-error-title">
|
||||
<v-icon size="16" color="error">mdi-close-circle</v-icon>
|
||||
<span>{{ t('list.initError') }}</span>
|
||||
</div>
|
||||
<div class="kb-error-detail" :title="kb.init_error">{{ kb.init_error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kb-stats mt-4" v-if="!kb.init_error">
|
||||
<div class="kb-stats" v-if="!kb.init_error">
|
||||
<div class="stat-item">
|
||||
<v-icon size="small" color="primary">mdi-file-document</v-icon>
|
||||
<v-icon size="small">mdi-file-document</v-icon>
|
||||
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<v-icon size="small" color="secondary">mdi-text-box</v-icon>
|
||||
<v-icon size="small">mdi-text-box</v-icon>
|
||||
<span>{{ kb.chunk_count || 0 }} {{ t('list.chunks') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kb-actions" :class="{ 'error-actions': kb.init_error }">
|
||||
<v-btn v-if="!kb.init_error" icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<template #actions>
|
||||
<v-tooltip v-if="!kb.init_error" :text="t('card.edit')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-pencil-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="list-action-icon-btn"
|
||||
@click.stop="editKB(kb)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip :text="t('card.delete')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="list-action-icon-btn"
|
||||
@click.stop="confirmDelete(kb)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</OutlinedActionListItem>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -76,6 +91,36 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="kb-fab-stack">
|
||||
<v-tooltip :text="t('list.refresh')" location="left">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
color="darkprimary"
|
||||
icon="mdi-refresh"
|
||||
size="x-large"
|
||||
variant="elevated"
|
||||
class="kb-fab"
|
||||
:loading="loading"
|
||||
@click="loadKnowledgeBases()"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip :text="t('list.create')" location="left">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
color="darkprimary"
|
||||
icon="mdi-plus"
|
||||
size="x-large"
|
||||
variant="elevated"
|
||||
class="kb-fab"
|
||||
@click="showCreateDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<v-dialog v-model="showCreateDialog" max-width="600px" persistent>
|
||||
<v-card>
|
||||
@@ -214,6 +259,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import OutlinedActionListItem from '@/components/shared/OutlinedActionListItem.vue'
|
||||
|
||||
const { tm: t } = useModuleI18n('features/knowledge-base/index')
|
||||
const router = useRouter()
|
||||
@@ -454,114 +500,31 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.kb-list-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 知识库网格 */
|
||||
.kb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.kb-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kb-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Error state card styles */
|
||||
.kb-card-error {
|
||||
cursor: not-allowed;
|
||||
border: 1px solid rgba(var(--v-theme-error), 0.3);
|
||||
background-color: rgba(var(--v-theme-error), 0.02) !important;
|
||||
overflow: visible; /* Allow badge to overflow */
|
||||
}
|
||||
|
||||
.kb-card-error:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-error), 0.1) !important;
|
||||
border-color: rgba(var(--v-theme-error), 0.5);
|
||||
}
|
||||
|
||||
.kb-card-error .kb-emoji {
|
||||
opacity: 0.7;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.kb-card-error .kb-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
}
|
||||
|
||||
.kb-error-badge {
|
||||
z-index: 10;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.kb-card-content {
|
||||
padding: 24px;
|
||||
.kb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-height: 260px;
|
||||
position: relative;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kb-card-content-error {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 56px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kb-name {
|
||||
.kb-list-emoji {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kb-description {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
max-height: 3em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kb-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.kb-error-panel {
|
||||
@@ -599,22 +562,38 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
}
|
||||
|
||||
.list-action-icon-btn {
|
||||
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||
}
|
||||
|
||||
.list-action-icon-btn:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kb-actions {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
.kb-fab-stack {
|
||||
align-items: center;
|
||||
bottom: 52px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.kb-card:hover .kb-actions {
|
||||
opacity: 1;
|
||||
.kb-fab {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.kb-fab:hover {
|
||||
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
@@ -676,14 +655,6 @@ onMounted(() => {
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.kb-list-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kb-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="documents-tab">
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar mb-4">
|
||||
<v-btn prepend-icon="mdi-upload" color="primary" variant="elevated" @click="showUploadDialog = true">
|
||||
<v-btn prepend-icon="mdi-upload" color="primary" variant="outlined" @click="showUploadDialog = true">
|
||||
{{ t('documents.upload') }}
|
||||
</v-btn>
|
||||
<v-text-field v-model="searchQuery" prepend-inner-icon="mdi-magnify" :placeholder="'搜索文档...'" variant="outlined"
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 文档列表 -->
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-data-table :headers="headers" :items="documents" :loading="loading" :search="searchQuery" :items-per-page="10">
|
||||
<template #item.doc_name="{ item }">
|
||||
<div class="d-flex align-center gap-2">
|
||||
@@ -65,8 +65,6 @@
|
||||
<v-btn icon="mdi-close" variant="text" @click="closeUploadDialog" />
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-tabs v-model="uploadMode" grow class="mb-4">
|
||||
<v-tab value="file">{{ t('upload.fileUpload') }}</v-tab>
|
||||
<v-tab value="url">
|
||||
@@ -193,8 +191,6 @@
|
||||
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeUploadDialog" :disabled="uploading">
|
||||
@@ -212,14 +208,12 @@
|
||||
<v-dialog v-model="showDeleteDialog" max-width="450px">
|
||||
<v-card>
|
||||
<v-card-title class="pa-4 text-h6">{{ t('documents.delete') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-6">
|
||||
<p>{{ t('documents.deleteConfirm', { name: deleteTarget?.doc_name || '' }) }}</p>
|
||||
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
|
||||
{{ t('documents.deleteWarning') }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div class="retrieval-tab">
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title>
|
||||
<v-card-subtitle class="pb-4 pt-2">
|
||||
{{ t('retrieval.subtitle') }}
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-divider />
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" height="2" />
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
@@ -58,8 +57,6 @@
|
||||
|
||||
<!-- 检索结果 -->
|
||||
<div v-if="hasSearched" class="results-section">
|
||||
<v-divider class="mb-4" />
|
||||
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
|
||||
<v-chip class="ml-3" color="primary" variant="tonal" size="small">
|
||||
@@ -93,8 +90,6 @@
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<div class="content-box">
|
||||
{{ result.content }}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="settings-tab">
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title class="pa-4">{{ t('settings.title') }}</v-card-title>
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-form ref="formRef">
|
||||
@@ -104,8 +103,6 @@
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
|
||||
@@ -1,37 +1,111 @@
|
||||
<template>
|
||||
<div class="kb-container">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="kb-fade" mode="out-in">
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="kb-page-title">
|
||||
<button
|
||||
v-if="isDetailRoute"
|
||||
class="kb-page-title__parent"
|
||||
type="button"
|
||||
@click="goToList"
|
||||
>
|
||||
{{ t('list.title') }}
|
||||
</button>
|
||||
<template v-else>
|
||||
{{ t('list.title') }}
|
||||
</template>
|
||||
<template v-if="isDetailRoute">
|
||||
<v-icon icon="mdi-chevron-right" size="24" class="mx-1" />
|
||||
<span class="kb-page-title__current">{{ displayDetailTitle }}</span>
|
||||
</template>
|
||||
</h1>
|
||||
<p class="text-body-2 text-medium-emphasis">{{ t('list.subtitle') }}</p>
|
||||
</div>
|
||||
<v-btn
|
||||
icon="mdi-information-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="grey"
|
||||
href="https://docs.astrbot.app/use/knowledge-base.html"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<router-view @title-change="detailTitle = $event" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 主容器组件,提供路由出口和页面切换动画
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const { tm: t } = useModuleI18n('features/knowledge-base/index')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const detailTitle = ref('')
|
||||
|
||||
const isDetailRoute = computed(() => route.name === 'NativeKBDetail')
|
||||
const displayDetailTitle = computed(() => detailTitle.value || String(route.params.kbId || ''))
|
||||
|
||||
const goToList = () => {
|
||||
router.push({ name: 'NativeKBList' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-container {
|
||||
margin: 0 auto;
|
||||
max-width: 1400px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 页面切换动画 */
|
||||
.kb-fade-enter-active,
|
||||
.kb-fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.kb-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
.kb-page-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
gap: 2px;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kb-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
.kb-page-title__parent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.kb-page-title__parent:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.kb-page-title__current {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.kb-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1212,3 +1212,102 @@ async def test_batch_upload_skills_partial_success(
|
||||
assert data["data"]["failed"] == [
|
||||
{"filename": "bad_skill.zip", "error": "install failed"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_file_browser_and_editor_security(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
async def _fake_sync_skills_to_active_sandboxes():
|
||||
return
|
||||
|
||||
skills_root = tmp_path / "skills"
|
||||
skill_dir = skills_root / "demo_skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
"---\ndescription: Demo skill\n---\n# Demo\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
|
||||
(skill_dir / "large.md").write_text("x" * (512 * 1024 + 1), encoding="utf-8")
|
||||
(skill_dir / "binary.md").write_bytes(b"\xff\xfe\x00")
|
||||
outside_file = tmp_path / "outside.txt"
|
||||
outside_file.write_text("outside", encoding="utf-8")
|
||||
if hasattr(os, "symlink"):
|
||||
os.symlink(outside_file, skill_dir / "outside-link.txt")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_skills_path",
|
||||
lambda: str(skills_root),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
|
||||
_fake_sync_skills_to_active_sandboxes,
|
||||
)
|
||||
|
||||
test_client = app.test_client()
|
||||
|
||||
list_response = await test_client.get(
|
||||
"/api/skills/files?name=demo_skill",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
list_data = await list_response.get_json()
|
||||
assert list_data["status"] == "ok"
|
||||
listed_paths = {item["path"] for item in list_data["data"]["entries"]}
|
||||
assert "SKILL.md" in listed_paths
|
||||
assert "outside-link.txt" not in listed_paths
|
||||
|
||||
read_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=SKILL.md",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
read_data = await read_response.get_json()
|
||||
assert read_data["status"] == "ok"
|
||||
assert "# Demo" in read_data["data"]["content"]
|
||||
|
||||
update_response = await test_client.post(
|
||||
"/api/skills/file",
|
||||
json={
|
||||
"name": "demo_skill",
|
||||
"path": "SKILL.md",
|
||||
"content": "# Updated\n",
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
update_data = await update_response.get_json()
|
||||
assert update_data["status"] == "ok"
|
||||
assert skill_md.read_text(encoding="utf-8") == "# Updated\n"
|
||||
|
||||
traversal_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=../outside.txt",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
traversal_data = await traversal_response.get_json()
|
||||
assert traversal_data["status"] == "error"
|
||||
|
||||
symlink_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=outside-link.txt",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
symlink_data = await symlink_response.get_json()
|
||||
assert symlink_data["status"] == "error"
|
||||
|
||||
large_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=large.md",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
large_data = await large_response.get_json()
|
||||
assert large_data["status"] == "error"
|
||||
assert large_data["message"] == "File is too large"
|
||||
|
||||
binary_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=binary.md",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
binary_data = await binary_response.get_json()
|
||||
assert binary_data["status"] == "error"
|
||||
assert binary_data["message"] == "File is not valid UTF-8 text"
|
||||
|
||||
Reference in New Issue
Block a user