Compare commits

...

4 Commits

Author SHA1 Message Date
Soulter
c626b356f5 feat: update UI components and styles for improved layout and readability 2026-04-30 12:37:59 +08:00
Soulter
a0bb5db672 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
2026-04-30 11:55:14 +08:00
Soulter
8d985eba61 feat: update MCP servers management UI and add descriptions for better clarity 2026-04-30 11:45:27 +08:00
Soulter
798ee44620 feat: update ExtensionCard variant to outlined and adjust InstalledPluginsTab layout for better responsiveness 2026-04-30 11:00:39 +08:00
38 changed files with 1843 additions and 608 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

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 256 icons */
/* Auto-generated MDI subset 257 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -140,10 +140,6 @@
content: "\F08A7";
}
.mdi-calendar-multiple::before {
content: "\F00F1";
}
.mdi-calendar-plus::before {
content: "\F00F3";
}
@@ -364,6 +360,10 @@
content: "\F01DA";
}
.mdi-download-outline::before {
content: "\F0B8F";
}
.mdi-emoticon::before {
content: "\F0C68";
}
@@ -404,8 +404,8 @@
content: "\F0215";
}
.mdi-file-code::before {
content: "\F022E";
.mdi-file-code-outline::before {
content: "\F102B";
}
.mdi-file-delimited-outline::before {
@@ -940,6 +940,10 @@
content: "\F060D";
}
.mdi-sync::before {
content: "\F04E6";
}
.mdi-text::before {
content: "\F09A8";
}

View File

@@ -1,85 +1,166 @@
<template>
<div class="tools-page">
<v-container fluid class="pa-0" elevation="0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal"
@click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }}
</v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
>
{{ tm('mcpServers.buttons.sync') }}
</v-btn>
</div>
</v-row>
<!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
</div>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card style="background-color: rgb(var(--v-theme-mcpCardBg));" :item="server" title-field="name"
enabled-field="active" @toggle-enabled="updateServerStatus" @delete="deleteServer" @edit="editServer">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(item)">
{{ getServerConfigSummary(item) }}
</span>
</div>
<div v-else class="mcp-server-list">
<OutlinedActionListItem
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"
:title="getServerConfigSummary(server)"
>
<v-icon
:icon="getServerConfigIcon(server)"
size="small"
class="me-1"
/>
<span>{{ getServerConfigSummary(server) }}</span>
</div>
<div class="d-flex" style="gap: 8px;">
<div>
<div v-if="item.tools && item.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<v-dialog max-width="600px">
<template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
style="text-decoration: underline;">
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
</span>
</template>
<template v-slot:default="{ isActive }">
<v-card style="padding: 16px;">
<v-card-title class="d-flex align-center">
<span>{{ tm('mcpServers.status.availableTools') }}</span>
</v-card-title>
<v-card-text>
<ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" color="primary" @click="isActive.value = false">
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</div>
</div>
<div v-else class="text-caption text-medium-emphasis">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
{{ tm('mcpServers.status.noTools') }}
</div>
</div>
<div v-if="mcpServerUpdateLoaders[item.name]" class="text-caption text-medium-emphasis">
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div>
</div>
<div class="mcp-server-tools text-caption text-medium-emphasis">
<template v-if="server.tools && server.tools.length > 0">
<v-dialog max-width="600px">
<template v-slot:activator="{ props: listToolsProps }">
<button
v-bind="listToolsProps"
class="mcp-server-tools__button"
type="button"
@click.stop
>
<v-icon size="small" class="me-1">mdi-tools</v-icon>
{{
tm('mcpServers.status.availableTools', {
count: server.tools.length,
})
}}
({{ server.tools.length }})
</button>
</template>
<template v-slot:default="{ isActive }">
<v-card style="padding: 16px;">
<v-card-title class="d-flex align-center">
<span>{{ tm('mcpServers.status.availableTools') }}</span>
</v-card-title>
<v-card-text>
<ul>
<li
v-for="(tool, idx) in server.tools"
:key="idx"
style="margin: 8px 0px;"
>
{{ tool }}
</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn
variant="text"
color="primary"
@click="isActive.value = false"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
</item-card>
</v-col>
</v-row>
<template v-else>
<v-icon size="small" color="warning" class="me-1">
mdi-alert-circle
</v-icon>
{{ tm('mcpServers.status.noTools') }}
</template>
</div>
<template #actions>
<v-tooltip :text="t('core.common.itemCard.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="deleteServer(server)"
/>
</template>
</v-tooltip>
</template>
<template #control>
<v-progress-circular
v-if="mcpServerUpdateLoaders[server.name]"
indeterminate
color="primary"
size="18"
/>
<v-tooltip location="top">
<template #activator="{ props }">
<v-switch
v-bind="props"
color="primary"
density="compact"
hide-details
inset
:model-value="server.active"
:loading="mcpServerUpdateLoaders[server.name] || false"
:disabled="mcpServerUpdateLoaders[server.name] || false"
@click.stop
@update:model-value="updateServerStatus(server)"
/>
</template>
<span>{{
server.active
? t('core.common.itemCard.enabled')
: t('core.common.itemCard.disabled')
}}</span>
</v-tooltip>
</template>
</OutlinedActionListItem>
</div>
</v-container>
<div class="mcp-fab-stack">
<v-tooltip :text="tm('mcpServers.buttons.sync')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-sync"
size="x-large"
variant="elevated"
class="mcp-fab"
@click="showSyncMcpServerDialog = true"
/>
</template>
</v-tooltip>
<v-tooltip :text="tm('mcpServers.buttons.add')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-plus"
size="x-large"
variant="elevated"
class="mcp-fab"
@click="showMcpServerDialog = true"
/>
</template>
</v-tooltip>
</div>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showMcpServerDialog" max-width="750px">
<v-card>
@@ -220,8 +301,8 @@
<script>
import axios from 'axios';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import OutlinedActionListItem from '@/components/shared/OutlinedActionListItem.vue';
import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
@@ -231,7 +312,7 @@ export default {
name: 'McpServersSection',
components: {
VueMonacoEditor,
ItemCard
OutlinedActionListItem
},
setup() {
const { t } = useI18n();
@@ -272,6 +353,9 @@ export default {
},
getServerConfigSummary() {
return (server) => {
if (server.transport) {
return String(server.transport).trim();
}
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
@@ -283,6 +367,21 @@ export default {
}
return this.tm('mcpServers.status.noConfig');
};
},
getServerConfigIcon() {
return (server) => {
const transport = String(server.transport || '').toLowerCase();
if (transport === 'streamable_http') {
return 'mdi-web';
}
if (transport === 'sse') {
return 'mdi-broadcast';
}
if (server.command) {
return 'mdi-console-line';
}
return 'mdi-file-code-outline';
};
}
},
mounted() {
@@ -544,6 +643,73 @@ export default {
padding-top: 8px;
}
.mcp-server-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-server-config {
align-items: center;
display: flex;
overflow: hidden;
word-break: break-all;
}
.mcp-server-config span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcp-server-tools {
align-items: center;
display: flex;
margin-top: 6px;
}
.mcp-server-tools__button {
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.62);
display: inline-flex;
text-decoration: underline;
}
.mcp-server-tools__button:hover {
color: rgb(var(--v-theme-primary));
}
.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));
}
.mcp-fab-stack {
align-items: center;
bottom: 52px;
display: flex;
flex-direction: column;
gap: 16px;
position: fixed;
right: 52px;
z-index: 10000;
}
.mcp-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);
}
.mcp-fab:hover {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
transform: translateY(-4px) scale(1.05);
}
.monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
@@ -551,4 +717,5 @@ export default {
margin-top: 4px;
overflow: hidden;
}
</style>

View File

@@ -1,37 +1,22 @@
<template>
<div class="skills-page">
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
<div>
<v-btn
v-if="mode === 'local'"
color="primary"
prepend-icon="mdi-upload"
class="me-2"
variant="tonal"
@click="openUploadDialog"
>
{{ tm("skills.upload") }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
variant="tonal"
@click="refreshCurrentMode"
>
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-btn-toggle v-model="mode" mandatory divided density="comfortable">
<v-row
v-if="neoEnabled"
class="d-flex justify-end align-center px-4 py-3 pb-4"
>
<v-btn-toggle
v-model="mode"
mandatory
divided
density="comfortable"
>
<v-btn value="local">{{ tm("skills.modeLocal") }}</v-btn>
<v-btn value="neo" :disabled="!neoEnabled">{{
tm("skills.modeNeo")
}}</v-btn>
<v-btn value="neo">{{ tm("skills.modeNeo") }}</v-btn>
</v-btn-toggle>
</v-row>
<div v-if="mode === 'local'" class="px-2 pb-2 d-flex flex-column ga-2">
<small style="color: grey">{{ tm("skills.runtimeHint") }}</small>
<v-alert
v-if="runtime === 'sandbox' && !sandboxCache.ready"
type="info"
@@ -67,67 +52,92 @@
<small class="text-grey">{{ tm("skills.emptyHint") }}</small>
</div>
<v-row v-else align="stretch">
<v-col
<div v-else class="skills-list pb-3">
<OutlinedActionListItem
v-for="skill in skills"
:key="skill.name"
cols="12"
md="6"
lg="4"
xl="3"
class="d-flex"
:title="skill.name"
clickable
@click="openSkillEditor(skill)"
>
<item-card
:item="skill"
title-field="name"
enabled-field="active"
:loading="itemLoading[skill.name] || false"
:show-edit-button="false"
:disable-toggle="isSandboxPresetSkill(skill)"
:disable-delete="isSandboxPresetSkill(skill)"
@toggle-enabled="toggleSkill"
@delete="confirmDelete"
>
<template #item-details="{ item }">
<div class="d-flex align-center mb-2 ga-2 flex-wrap">
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(item.source_type)"
>
{{ sourceTypeLabel(item.source_type) }}
</v-chip>
<div
class="text-caption text-medium-emphasis skill-description"
>
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm("skills.noDescription") }}
</div>
</div>
<div class="text-caption text-medium-emphasis skill-path">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ item.path }}
</div>
</template>
<template #actions="{ item }">
<v-btn
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="
itemLoading[item.name] ||
false ||
isSandboxPresetSkill(item)
"
@click="downloadSkill(item)"
>
{{ tm("skills.download") }}
</v-btn>
</template>
</item-card>
</v-col>
</v-row>
<template #title-extra>
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(skill.source_type)"
>
{{ sourceTypeLabel(skill.source_type) }}
</v-chip>
</template>
<div class="skill-description text-body-2 text-medium-emphasis">
{{ skill.description || tm("skills.noDescription") }}
</div>
<div class="skill-path text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ skill.path }}
</div>
<template #actions>
<v-tooltip :text="tm('skills.download')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-download-outline"
variant="text"
size="small"
class="list-action-icon-btn"
:disabled="
itemLoading[skill.name] ||
false ||
isSandboxPresetSkill(skill)
"
@click.stop="downloadSkill(skill)"
/>
</template>
</v-tooltip>
<v-tooltip :text="t('core.common.itemCard.delete')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-delete-outline"
variant="text"
size="small"
class="list-action-icon-btn"
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
@click.stop="confirmDelete(skill)"
/>
</template>
</v-tooltip>
</template>
<template #control>
<v-tooltip location="top">
<template #activator="{ props }">
<v-switch
v-bind="props"
color="primary"
density="compact"
hide-details
inset
:model-value="skill.active"
:loading="itemLoading[skill.name] || false"
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
@click.stop
@update:model-value="toggleSkill(skill)"
/>
</template>
<span>{{
skill.active
? t("core.common.itemCard.enabled")
: t("core.common.itemCard.disabled")
}}</span>
</v-tooltip>
</template>
</OutlinedActionListItem>
</div>
</template>
<template v-else-if="mode === 'neo' && neoEnabled">
@@ -141,14 +151,6 @@
{{ tm("skills.neoFilterHint") }}
</div>
</div>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
variant="flat"
@click="fetchNeoData"
>
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-row class="ga-md-0 ga-2">
@@ -339,6 +341,39 @@
</template>
</v-container>
<div class="skills-fab-stack">
<v-tooltip :text="tm('skills.refresh')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-refresh"
size="x-large"
variant="elevated"
class="skills-fab"
@click="refreshCurrentMode"
/>
</template>
</v-tooltip>
<v-tooltip
v-if="mode === 'local'"
:text="tm('skills.upload')"
location="left"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-upload"
size="x-large"
variant="elevated"
class="skills-fab"
@click="openUploadDialog"
/>
</template>
</v-tooltip>
</div>
<v-dialog v-model="uploadDialog" max-width="880px" :persistent="uploading">
<v-card class="skills-upload-dialog">
<v-card-title class="skills-upload-dialog__header px-6 pt-6 pb-2">
@@ -561,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>
@@ -588,9 +759,11 @@
<script>
import axios from "axios";
import { computed, onMounted, reactive, ref, watch } from "vue";
import ItemCard from "@/components/shared/ItemCard.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";
@@ -600,10 +773,11 @@ const STATUS_SKIPPED = "skipped";
export default {
name: "SkillsSection",
components: { ItemCard },
components: { OutlinedActionListItem, VueMonacoEditor },
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const customizer = useCustomizerStore();
const mode = ref("local");
const skills = ref([]);
@@ -634,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("");
@@ -659,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) => {
@@ -1105,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,
@@ -1412,6 +1788,10 @@ export default {
candidateHeaders,
releaseHeaders,
payloadDialog,
editorDialog,
editorLanguage,
editorTheme,
editorOptions,
formatFileSize,
uploadStatusLabel,
statusChipClass,
@@ -1425,6 +1805,11 @@ export default {
fetchNeoData,
uploadSkillBatch,
downloadSkill,
openSkillEditor,
closeSkillEditor,
openSkillEntry,
openParentSkillDir,
saveSkillFile,
toggleSkill,
confirmDelete,
deleteSkill,
@@ -1448,23 +1833,178 @@ export default {
</script>
<style scoped>
.skills-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.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));
}
.skills-fab-stack {
align-items: center;
bottom: 52px;
display: flex;
flex-direction: column;
gap: 16px;
position: fixed;
right: 52px;
z-index: 10000;
}
.skills-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);
}
.skills-fab:hover {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
transform: translateY(-4px) scale(1.05);
}
.skill-description {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 20px;
}
.skill-path {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
margin-top: 6px;
overflow: hidden;
min-height: 40px;
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

@@ -23,7 +23,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
></v-btn>
</div>
<div id="term" style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto; height: 100%">
<div id="term" class="console-term">
</div>
</div>
</template>
@@ -279,6 +279,36 @@ export default {
this.isFullscreen = !!document.fullscreenElement;
},
appendLogContent(element, log) {
const levelMatch = log.match(/\[(DEBG|INFO|WARN|ERRO|CRIT|DEBUG|WARNING|ERROR|CRITICAL)\]/);
if (!levelMatch) {
element.innerText = `${log}`;
return;
}
const levelStart = levelMatch.index;
const levelEnd = levelStart + levelMatch[0].length;
const prefix = log.slice(0, levelStart).trimEnd();
const message = log.slice(levelEnd).trimStart();
const prefixSpan = document.createElement('span');
prefixSpan.className = 'console-log-prefix';
prefixSpan.innerText = prefix;
const levelSpan = document.createElement('span');
levelSpan.className = 'console-log-level';
levelSpan.innerText = levelMatch[0];
const messageSpan = document.createElement('span');
messageSpan.className = 'console-log-message';
messageSpan.innerText = message;
element.classList.add('console-log-line--structured');
element.appendChild(prefixSpan);
element.appendChild(levelSpan);
element.appendChild(messageSpan);
},
printLog(log) {
let ele = document.getElementById('term')
if (!ele) {
@@ -297,7 +327,7 @@ export default {
span.style = style
span.classList.add('console-log-line', 'fade-in')
span.innerText = `${log}`;
this.appendLogContent(span, log);
ele.appendChild(span)
if (this.autoScroll) {
ele.scrollTop = ele.scrollHeight
@@ -325,7 +355,14 @@ export default {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
margin-left: 20px;
}
.console-term {
background-color: #1e1e1e;
border-radius: 8px;
height: 100%;
overflow-y: auto;
padding: 16px;
}
.fullscreen-btn {
@@ -334,12 +371,35 @@ export default {
:deep(.console-log-line) {
display: block;
margin-bottom: 2px;
margin: 0 0 2px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, var(--astrbot-font-cjk-mono), monospace;
font-size: 12px;
white-space: pre-wrap;
}
:deep(.console-log-line--structured) {
display: grid;
grid-template-columns: max-content 10ch minmax(0, 1fr);
column-gap: 8px;
align-items: start;
white-space: normal;
}
:deep(.console-log-prefix),
:deep(.console-log-level),
:deep(.console-log-message) {
min-width: 0;
white-space: pre-wrap;
}
:deep(.console-log-level) {
font-variant-numeric: tabular-nums;
}
:deep(.console-log-message) {
overflow-wrap: anywhere;
}
:deep(.fade-in) {
animation: fadeIn 0.3s;
}

View File

@@ -124,6 +124,7 @@ const viewChangelog = () => {
elevation="0"
height="100%"
:ripple="false"
variant="outlined"
:style="{
position: 'relative',
backgroundColor:

View File

@@ -0,0 +1,149 @@
<template>
<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>
<slot name="title-extra"></slot>
</div>
<slot></slot>
</div>
<div
v-if="$slots.actions || $slots.control"
class="outlined-action-list-item__actions"
>
<div
v-if="$slots.actions"
class="outlined-action-list-item__hover-actions"
>
<slot name="actions"></slot>
</div>
<slot name="control"></slot>
</div>
</div>
</v-card>
</template>
<script setup>
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 {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
min-height: 104px;
padding: 16px;
}
.outlined-action-list-item__content {
min-width: 0;
}
.outlined-action-list-item__header {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.outlined-action-list-item__title {
font-size: 1rem;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outlined-action-list-item__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: 8px;
margin-left: auto;
}
.outlined-action-list-item__hover-actions {
align-items: center;
display: flex;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.outlined-action-list-item:hover .outlined-action-list-item__hover-actions,
.outlined-action-list-item:focus-within .outlined-action-list-item__hover-actions {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 860px) {
.outlined-action-list-item__main {
align-items: stretch;
flex-direction: column;
}
.outlined-action-list-item__actions {
flex-wrap: wrap;
justify-content: flex-end;
margin-left: 0;
}
.outlined-action-list-item__hover-actions {
opacity: 1;
pointer-events: auto;
}
}
</style>

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

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP Servers",
"description": "Manage MCP servers",
"buttons": {
"refresh": "Refresh",
"add": "Add Server",

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

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP Сервера",
"description": "Управление MCP-серверами",
"buttons": {
"refresh": "Обновить",
"add": "Добавить сервер",

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

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP 服务器",
"description": "管理 MCP 服务器",
"buttons": {
"refresh": "刷新",
"add": "新增服务器",

View File

@@ -38,27 +38,18 @@
min-width: 0;
}
.dashboard-eyebrow {
margin-bottom: 8px;
color: var(--dashboard-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dashboard-title {
margin: 0;
font-size: clamp(32px, 4vw, 44px);
line-height: 1.04;
font-size: 1.5rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.04em;
letter-spacing: 0;
}
.dashboard-subtitle {
margin: 10px 0 0;
margin: 4px 0 0;
color: var(--dashboard-muted);
font-size: 15px;
font-size: 0.875rem;
line-height: 1.6;
max-width: 860px;
}

View File

@@ -7,20 +7,13 @@ const { tm } = useModuleI18n('features/console');
</script>
<template>
<div style="height: 100%;">
<div
style="background-color: var(--v-theme-surface); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
<div class="console-page">
<div class="console-header">
<div>
<h4>{{ tm('title') }}</h4>
<v-alert
type="info"
variant="tonal"
density="compact"
class="mt-2"
style="max-width: 600px;"
>
<h1 class="text-h2 mb-1">{{ tm('title') }}</h1>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ tm('debugHint.text') }}
</v-alert>
</p>
</div>
<div class="d-flex align-center">
<v-switch
@@ -28,6 +21,7 @@ const { tm } = useModuleI18n('features/console');
:label="autoScrollEnabled ? tm('autoScroll.enabled') : tm('autoScroll.disabled')"
hide-details
density="compact"
inset
color="primary"
style="margin-right: 16px;"
></v-switch>
@@ -58,7 +52,7 @@ const { tm } = useModuleI18n('features/console');
</v-dialog>
</div>
</div>
<ConsoleDisplayer ref="consoleDisplayer" style="height: calc(100vh - 220px); " />
<ConsoleDisplayer ref="consoleDisplayer" class="console-display" />
</div>
</template>
<script>
@@ -108,7 +102,27 @@ export default {
</script>
<style>
<style scoped>
.console-page {
height: 100%;
margin: 0 auto;
max-width: 1400px;
padding: 24px;
width: 100%;
}
.console-header {
align-items: flex-start;
display: flex;
justify-content: space-between;
margin-bottom: 24px;
}
.console-display {
height: calc(100vh - 190px);
width: 100%;
}
@keyframes fadeIn {
from {
opacity: 0;
@@ -122,4 +136,15 @@ export default {
.fade-in {
animation: fadeIn 0.2s ease-in-out;
}
@media (max-width: 768px) {
.console-page {
padding: 16px;
}
.console-header {
flex-direction: column;
gap: 12px;
}
}
</style>

View File

@@ -3,7 +3,6 @@
<v-container fluid class="dashboard-shell pa-4 pa-md-6">
<div class="dashboard-header">
<div class="dashboard-header-main">
<div class="dashboard-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="dashboard-title">{{ tm('page.title') }}</h1>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>
@@ -25,21 +24,6 @@
</div>
</div>
<div class="dashboard-overview-grid">
<section
v-for="card in overviewCards"
:key="card.label"
class="dashboard-card dashboard-overview-card"
>
<div class="dashboard-card-icon">
<v-icon size="18">{{ card.icon }}</v-icon>
</div>
<div class="dashboard-card-label">{{ card.label }}</div>
<div class="dashboard-card-value">{{ card.value }}</div>
<div class="dashboard-card-note">{{ card.note }}</div>
</section>
</div>
<div class="dashboard-section-head">
<div>
<div class="dashboard-section-title">{{ tm('section.platforms.title') }}</div>
@@ -262,10 +246,6 @@ const proactivePlatformText = computed(() =>
proactivePlatforms.value.map((p) => `${p.display_name || p.name}(${p.id})`).join(' / ')
)
const enabledJobsCount = computed(() => jobs.value.filter((job) => job.enabled).length)
const runOnceCount = computed(() => jobs.value.filter((job) => job.run_once).length)
const recurringCount = computed(() => jobs.value.filter((job) => !job.run_once).length)
const sortedJobs = computed(() =>
[...jobs.value].sort((a, b) => {
if (a.enabled !== b.enabled) {
@@ -285,21 +265,6 @@ const sortedJobs = computed(() =>
})
)
const overviewCards = computed(() => [
{
label: tm('overview.totalTasks'),
value: String(jobs.value.length),
note: tm('overview.totalTasksNote'),
icon: 'mdi-calendar-multiple'
},
{
label: tm('overview.enabledTasks'),
value: String(enabledJobsCount.value),
note: tm('overview.enabledTasksNote'),
icon: 'mdi-check-circle-outline'
}
])
const isEditing = computed(() => !!editingJobId.value)
const dialogTitle = computed(() => tm(isEditing.value ? 'form.editTitle' : 'form.title'))
const dialogSubmitText = computed(() => tm(isEditing.value ? 'actions.save' : 'actions.submit'))
@@ -720,10 +685,6 @@ onMounted(() => {
font-size: 14px;
}
.cron-page :deep(.dashboard-overview-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 900px) {
.table-actions {
justify-items: start;

View File

@@ -237,8 +237,11 @@ const selectedMarketPlugin = computed(() => {
<!-- 已安装的 MCP 服务器标签页内容 -->
<v-tab-item v-if="activeTab === 'mcp'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<div class="d-flex flex-column" style="gap: 6px">
<h2 class="text-h2 mb-0">{{ tm("tabs.installedMcpServers") }}</h2>
<div class="text-body-2 text-medium-emphasis">
{{ t("features.tooluse.mcpServers.description") }}
</div>
</div>
</div>
<v-card
@@ -255,8 +258,11 @@ const selectedMarketPlugin = computed(() => {
<!-- Skills 标签页内容 -->
<v-tab-item v-if="activeTab === 'skills'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<div class="d-flex flex-column" style="gap: 6px">
<h2 class="text-h2 mb-0">{{ tm("tabs.skills") }}</h2>
<div class="text-body-2 text-medium-emphasis">
{{ tm("skills.runtimeHint") }}
</div>
</div>
</div>
<v-card

View File

@@ -2,12 +2,12 @@
<div class="persona-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-6">
<v-row class="d-flex justify-space-between align-center py-3 pb-6">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon class="me-2">mdi-heart</v-icon>{{ t('core.navigation.persona') }}
<h1 class="text-h2 mb-1">
{{ t('core.navigation.persona') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-0">
<p class="text-body-2 text-medium-emphasis mb-0">
{{ tm('page.description') }}
</p>
</div>
@@ -38,7 +38,15 @@ export default {
<style scoped>
.persona-page {
padding: 20px;
padding-top: 8px;
margin: 0 auto;
max-width: 1400px;
padding: 24px;
width: 100%;
}
@media (max-width: 768px) {
.persona-page {
padding: 16px;
}
}
</style>

View File

@@ -3,7 +3,6 @@
<v-container fluid class="dashboard-shell pa-4 pa-md-6">
<div class="dashboard-header">
<div class="dashboard-header-main">
<div class="dashboard-eyebrow">{{ tm('header.eyebrow') }}</div>
<div class="d-flex align-center flex-wrap" style="gap: 8px;">
<h1 class="dashboard-title">{{ tm('page.title') }}</h1>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>

View File

@@ -1,11 +1,14 @@
<script setup>
import TraceDisplayer from '@/components/shared/TraceDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useTheme } from 'vuetify';
import axios from 'axios';
const { tm } = useModuleI18n('features/trace');
const theme = useTheme();
const isDark = computed(() => theme.global.current.value.dark);
const traceEnabled = ref(true);
const loading = ref(false);
const traceDisplayerKey = ref(0);
@@ -42,31 +45,36 @@ onMounted(() => {
</script>
<template>
<div style="height: 100%; display: flex; flex-direction: column;">
<div class="trace-header">
<div class="trace-info">
<v-icon size="small" color="info" class="mr-2">mdi-information-outline</v-icon>
<span class="trace-hint">{{ tm('hint') }}</span>
<div class="dashboard-page trace-page" :class="{ 'is-dark': isDark }">
<v-container fluid class="dashboard-shell trace-shell pa-4 pa-md-6">
<div class="dashboard-header trace-header">
<div class="dashboard-header-main">
<h1 class="dashboard-title">{{ tm('title') }}</h1>
<p class="dashboard-subtitle">
{{ tm('hint') }}
</p>
</div>
<div class="dashboard-header-actions">
<v-switch
v-model="traceEnabled"
:loading="loading"
:disabled="loading"
color="primary"
hide-details
density="compact"
inset
@update:model-value="updateTraceSettings"
>
<template #label>
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
</template>
</v-switch>
</div>
</div>
<div class="trace-controls">
<v-switch
v-model="traceEnabled"
:loading="loading"
:disabled="loading"
color="primary"
hide-details
density="compact"
@update:model-value="updateTraceSettings"
>
<template #label>
<span class="switch-label">{{ traceEnabled ? tm('recording') : tm('paused') }}</span>
</template>
</v-switch>
<div class="trace-body">
<TraceDisplayer :key="traceDisplayerKey" />
</div>
</div>
<div style="flex: 1; min-height: 0;">
<TraceDisplayer :key="traceDisplayerKey" />
</div>
</v-container>
</div>
</template>
@@ -80,36 +88,36 @@ export default {
</script>
<style scoped>
@import '@/styles/dashboard-shell.css';
.trace-page,
.trace-shell {
height: 100%;
}
.trace-shell {
display: flex;
flex-direction: column;
}
.trace-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(59, 130, 246, 0.05);
border-bottom: 1px solid rgba(59, 130, 246, 0.1);
border-radius: 8px 8px 0 0;
margin-bottom: 8px;
flex: 0 0 auto;
}
.trace-info {
display: flex;
align-items: center;
}
.trace-hint {
font-size: 13px;
color: #6b7280;
}
.trace-controls {
display: flex;
align-items: center;
gap: 8px;
.trace-body {
flex: 1 1 auto;
min-height: 0;
}
.switch-label {
color: var(--dashboard-muted);
font-size: 13px;
color: #4b5563;
white-space: nowrap;
}
@media (max-width: 768px) {
.trace-header {
align-items: flex-start;
}
}
</style>

View File

@@ -267,6 +267,7 @@ const openPluginDetail = (extension) => {
<v-row>
<v-col
cols="12"
md="6"
v-for="extension in filteredPlugins"
:key="extension.name"
class="pb-2"

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

@@ -3,7 +3,6 @@
<v-container fluid class="stats-shell pa-4 pa-md-6">
<div class="stats-header">
<div>
<div class="eyebrow">{{ t('header.eyebrow') }}</div>
<h1 class="stats-title">{{ t('header.title') }}</h1>
<p class="stats-subtitle">{{ t('header.subtitle') }}</p>
</div>
@@ -723,30 +722,20 @@ onBeforeUnmount(() => {
margin-bottom: 24px;
}
.eyebrow {
margin-bottom: 8px;
color: var(--stats-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stats-title {
margin: 0;
font-size: clamp(34px, 4vw, 46px);
line-height: 1.04;
font-size: 1.5rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.04em;
letter-spacing: 0;
}
.stats-subtitle {
margin: 10px 0 0;
margin: 4px 0 0;
color: var(--stats-muted);
font-size: 15px;
font-size: 0.875rem;
}
.stats-page.is-dark .eyebrow,
.stats-page.is-dark .stats-subtitle,
.stats-page.is-dark .metric-label,
.stats-page.is-dark .section-subtitle,

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"