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:
Soulter
2026-04-30 11:55:14 +08:00
parent 8d985eba61
commit a0bb5db672
21 changed files with 1130 additions and 311 deletions

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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": {

View File

@@ -1,6 +1,9 @@
{
"title": "Knowledge Base Details",
"backToList": "Back to List",
"breadcrumb": {
"list": "Knowledge Bases"
},
"tabs": {
"overview": "Overview",
"documents": "Documents",

View File

@@ -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",

View File

@@ -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": {

View File

@@ -1,6 +1,9 @@
{
"title": "Детали базы знаний",
"backToList": "К списку",
"breadcrumb": {
"list": "Базы знаний"
},
"tabs": {
"overview": "Обзор",
"documents": "Документы",

View File

@@ -2,7 +2,7 @@
"title": "Управление базами знаний",
"subtitle": "Централизованное управление всеми знаниями AstrBot",
"list": {
"title": "Мои базы знаний",
"title": "Базы знаний",
"subtitle": "Все доступные коллекции знаний",
"create": "Создать базу",
"refresh": "Обновить",
@@ -65,4 +65,4 @@
"deleteFailed": "Ошибка удаления",
"loadError": "Не удалось загрузить список"
}
}
}

View File

@@ -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": {

View File

@@ -1,6 +1,9 @@
{
"title": "知识库详情",
"backToList": "返回列表",
"breadcrumb": {
"list": "知识库"
},
"tabs": {
"overview": "概览",
"documents": "文档管理",

View File

@@ -2,7 +2,7 @@
"title": "知识库管理",
"subtitle": "统一管理和查询知识库内容",
"list": {
"title": "我的知识库",
"title": "知识库",
"subtitle": "管理您的所有知识库集合",
"create": "创建知识库",
"refresh": "刷新列表",

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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>

View File

@@ -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"