mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 18:20:16 +08:00
Compare commits
4 Commits
codex/add-
...
feat/new-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c626b356f5 | ||
|
|
a0bb5db672 | ||
|
|
8d985eba61 | ||
|
|
798ee44620 |
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ const viewChangelog = () => {
|
||||
elevation="0"
|
||||
height="100%"
|
||||
:ripple="false"
|
||||
variant="outlined"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
backgroundColor:
|
||||
|
||||
149
dashboard/src/components/shared/OutlinedActionListItem.vue
Normal file
149
dashboard/src/components/shared/OutlinedActionListItem.vue
Normal 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>
|
||||
@@ -348,7 +348,17 @@
|
||||
"sourceSandboxOnly": "Sandbox Preset Skill",
|
||||
"sourceBoth": "Local + Sandbox",
|
||||
"sandboxDiscoveryPending": "Sandbox preset skills have not been discovered yet. Start at least one sandbox session to populate this list.",
|
||||
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills."
|
||||
"sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills.",
|
||||
"openEditor": "View/Edit",
|
||||
"editorTitle": "Edit Skill",
|
||||
"editorLoadFailed": "Failed to load Skill file",
|
||||
"editorSaveFailed": "Failed to save Skill file",
|
||||
"editorSaveSuccess": "Saved successfully",
|
||||
"saveFile": "Save file",
|
||||
"readonly": "Read-only",
|
||||
"unsaved": "Unsaved",
|
||||
"noFileSelected": "No file selected",
|
||||
"discardChanges": "This file has unsaved changes. Discard them?"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "Knowledge Base Details",
|
||||
"backToList": "Back to List",
|
||||
"breadcrumb": {
|
||||
"list": "Knowledge Bases"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"documents": "Documents",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "Knowledge Base Management",
|
||||
"subtitle": "Manage and query knowledge base contents",
|
||||
"list": {
|
||||
"title": "My Knowledge Bases",
|
||||
"title": "Knowledge Bases",
|
||||
"subtitle": "Manage all your knowledge base collections",
|
||||
"create": "Create Knowledge Base",
|
||||
"refresh": "Refresh List",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP Servers",
|
||||
"description": "Manage MCP servers",
|
||||
"buttons": {
|
||||
"refresh": "Refresh",
|
||||
"add": "Add Server",
|
||||
|
||||
@@ -347,7 +347,17 @@
|
||||
"sourceSandboxOnly": "Предустановленный Sandbox навык",
|
||||
"sourceBoth": "Локальный + Sandbox",
|
||||
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
|
||||
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
|
||||
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
|
||||
"openEditor": "Просмотр/правка",
|
||||
"editorTitle": "Редактировать навык",
|
||||
"editorLoadFailed": "Не удалось открыть файл навыка",
|
||||
"editorSaveFailed": "Не удалось сохранить файл навыка",
|
||||
"editorSaveSuccess": "Сохранено",
|
||||
"saveFile": "Сохранить файл",
|
||||
"readonly": "Только чтение",
|
||||
"unsaved": "Не сохранено",
|
||||
"noFileSelected": "Файл не выбран",
|
||||
"discardChanges": "В файле есть несохраненные изменения. Отменить их?"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "Детали базы знаний",
|
||||
"backToList": "К списку",
|
||||
"breadcrumb": {
|
||||
"list": "Базы знаний"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Обзор",
|
||||
"documents": "Документы",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "Управление базами знаний",
|
||||
"subtitle": "Централизованное управление всеми знаниями AstrBot",
|
||||
"list": {
|
||||
"title": "Мои базы знаний",
|
||||
"title": "Базы знаний",
|
||||
"subtitle": "Все доступные коллекции знаний",
|
||||
"create": "Создать базу",
|
||||
"refresh": "Обновить",
|
||||
@@ -65,4 +65,4 @@
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"loadError": "Не удалось загрузить список"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP Сервера",
|
||||
"description": "Управление MCP-серверами",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"add": "Добавить сервер",
|
||||
|
||||
@@ -348,7 +348,17 @@
|
||||
"sourceSandboxOnly": "Sandbox 预置 Skill",
|
||||
"sourceBoth": "本地 + Sandbox",
|
||||
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
|
||||
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。"
|
||||
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。",
|
||||
"openEditor": "查看/编辑",
|
||||
"editorTitle": "编辑 Skill",
|
||||
"editorLoadFailed": "读取 Skill 文件失败",
|
||||
"editorSaveFailed": "保存 Skill 文件失败",
|
||||
"editorSaveSuccess": "保存成功",
|
||||
"saveFile": "保存文件",
|
||||
"readonly": "只读",
|
||||
"unsaved": "未保存",
|
||||
"noFileSelected": "未选择文件",
|
||||
"discardChanges": "当前文件有未保存修改,确定要丢弃吗?"
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"title": "知识库详情",
|
||||
"backToList": "返回列表",
|
||||
"breadcrumb": {
|
||||
"list": "知识库"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"documents": "文档管理",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "知识库管理",
|
||||
"subtitle": "统一管理和查询知识库内容",
|
||||
"list": {
|
||||
"title": "我的知识库",
|
||||
"title": "知识库",
|
||||
"subtitle": "管理您的所有知识库集合",
|
||||
"create": "创建知识库",
|
||||
"refresh": "刷新列表",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP 服务器",
|
||||
"description": "管理 MCP 服务器",
|
||||
"buttons": {
|
||||
"refresh": "刷新",
|
||||
"add": "新增服务器",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -21,9 +21,8 @@
|
||||
<!-- 主内容 -->
|
||||
<div v-else class="document-content">
|
||||
<!-- 文档信息卡片 -->
|
||||
<v-card elevation="2" class="mb-6">
|
||||
<v-card variant="outlined" class="mb-6">
|
||||
<v-card-title>{{ t('info.title') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -78,7 +77,7 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 分块列表 -->
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
<span>{{ t('chunks.title') }}</span>
|
||||
<v-chip class="ml-2" size="small" variant="tonal">
|
||||
@@ -97,8 +96,6 @@
|
||||
/> -->
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
@@ -187,7 +184,6 @@
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-6">
|
||||
<v-list density="comfortable">
|
||||
<v-list-item>
|
||||
@@ -215,14 +211,11 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<div class="text-caption text-medium-emphasis mb-2">{{ t('view.content') }}</div>
|
||||
<div class="chunk-content-view">
|
||||
{{ selectedChunk?.content }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showViewDialog = false">
|
||||
@@ -434,6 +427,10 @@ onMounted(() => {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.document-detail-page :deep(.v-card--variant-outlined) {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
<template>
|
||||
<div class="kb-detail-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="$router.push({ name: 'NativeKBList' })"
|
||||
/>
|
||||
<div class="header-content">
|
||||
<div class="kb-title">
|
||||
<span class="kb-emoji">{{ kb.emoji || '📚' }}</span>
|
||||
<h1 class="text-h4">{{ kb.kb_name }}</h1>
|
||||
</div>
|
||||
<p v-if="kb.description" class="text-subtitle-1 text-medium-emphasis mt-2">
|
||||
{{ kb.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<v-progress-circular indeterminate color="primary" size="64" />
|
||||
@@ -52,9 +34,8 @@
|
||||
<v-window-item value="overview">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title>{{ t('overview.title') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-list density="comfortable">
|
||||
<v-list-item>
|
||||
@@ -102,9 +83,8 @@
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card elevation="2" class="mb-4">
|
||||
<v-card variant="outlined" class="mb-4">
|
||||
<v-card-title>{{ t('overview.stats') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
@@ -125,9 +105,8 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title>{{ t('overview.embeddingModel') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-list density="comfortable">
|
||||
<v-list-item>
|
||||
@@ -177,7 +156,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
@@ -188,6 +167,10 @@ import SettingsTab from './components/SettingsTab.vue'
|
||||
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
|
||||
const route = useRoute()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'title-change', title: string): void
|
||||
}>()
|
||||
|
||||
const kbId = ref(route.params.kbId as string)
|
||||
const loading = ref(true)
|
||||
const activeTab = ref('overview')
|
||||
@@ -214,6 +197,7 @@ const loadKB = async () => {
|
||||
})
|
||||
if (response.data.status === 'ok') {
|
||||
kb.value = response.data.data
|
||||
emit('title-change', kb.value.kb_name || '')
|
||||
} else {
|
||||
showSnackbar(response.data.message || '加载失败', 'error')
|
||||
}
|
||||
@@ -241,51 +225,22 @@ const formatDate = (dateStr: string) => {
|
||||
onMounted(() => {
|
||||
loadKB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => kb.value?.kb_name,
|
||||
(name) => {
|
||||
emit('title-change', name || '')
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-detail-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn 0.3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.kb-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 48px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
.kb-detail-page :deep(.v-card--variant-outlined) {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -296,21 +251,6 @@ onMounted(() => {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.kb-content {
|
||||
animation: slideUp 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -340,12 +280,7 @@ onMounted(() => {
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.kb-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 36px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,84 @@
|
||||
<template>
|
||||
<div class="kb-list-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2">{{ t('list.title') }}</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis">{{ t('list.subtitle') }}</p>
|
||||
</div>
|
||||
<v-btn icon="mdi-information-outline" variant="text" size="small" color="grey"
|
||||
href="https://docs.astrbot.app/use/knowledge-base.html" target="_blank" />
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮栏 -->
|
||||
<div class="action-bar mb-6">
|
||||
<v-btn prepend-icon="mdi-plus" color="primary" variant="elevated" @click="showCreateDialog = true">
|
||||
{{ t('list.create') }}
|
||||
</v-btn>
|
||||
<v-btn prepend-icon="mdi-refresh" variant="tonal" @click="loadKnowledgeBases" :loading="loading">
|
||||
{{ t('list.refresh') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 知识库网格 -->
|
||||
<div v-if="loading && kbList.length === 0" class="loading-container">
|
||||
<v-progress-circular indeterminate color="primary" size="64" />
|
||||
<p class="mt-4 text-medium-emphasis">{{ t('list.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="kbList.length > 0" class="kb-grid">
|
||||
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" :hover="!kb.init_error"
|
||||
:class="{ 'kb-card-error': kb.init_error }"
|
||||
@click="!kb.init_error && navigateToDetail(kb.kb_id)">
|
||||
<!-- Error badge -->
|
||||
<v-badge v-if="kb.init_error" color="error" icon="mdi-alert-circle"
|
||||
class="kb-error-badge position-absolute" style="top: 0; right: 0; transform: translate(34%, -34%);" />
|
||||
<div class="kb-card-content" :class="{ 'kb-card-content-error': kb.init_error }">
|
||||
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
|
||||
<h3 class="kb-name">{{ kb.kb_name }}</h3>
|
||||
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
|
||||
<div v-else-if="kbList.length > 0" class="kb-list">
|
||||
<OutlinedActionListItem
|
||||
v-for="kb in kbList"
|
||||
:key="kb.kb_id"
|
||||
:title="kb.kb_name"
|
||||
:clickable="!kb.init_error"
|
||||
@click="navigateToDetail(kb.kb_id)"
|
||||
>
|
||||
<template #title-prepend>
|
||||
<span class="kb-list-emoji">{{ kb.emoji || '📚' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Error message display -->
|
||||
<div v-if="kb.init_error" class="kb-error-panel mt-3 mb-2">
|
||||
<template #title-extra>
|
||||
<v-chip
|
||||
v-if="kb.init_error"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ t('list.initError') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<div v-if="!kb.init_error" class="kb-description text-body-2 text-medium-emphasis">
|
||||
{{ kb.description || '暂无描述' }}
|
||||
</div>
|
||||
|
||||
<div v-if="kb.init_error" class="kb-error-panel">
|
||||
<div class="kb-error-title">
|
||||
<v-icon size="16" color="error">mdi-close-circle</v-icon>
|
||||
<span>{{ t('list.initError') }}</span>
|
||||
</div>
|
||||
<div class="kb-error-detail" :title="kb.init_error">{{ kb.init_error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kb-stats mt-4" v-if="!kb.init_error">
|
||||
<div class="kb-stats" v-if="!kb.init_error">
|
||||
<div class="stat-item">
|
||||
<v-icon size="small" color="primary">mdi-file-document</v-icon>
|
||||
<v-icon size="small">mdi-file-document</v-icon>
|
||||
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<v-icon size="small" color="secondary">mdi-text-box</v-icon>
|
||||
<v-icon size="small">mdi-text-box</v-icon>
|
||||
<span>{{ kb.chunk_count || 0 }} {{ t('list.chunks') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kb-actions" :class="{ 'error-actions': kb.init_error }">
|
||||
<v-btn v-if="!kb.init_error" icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<template #actions>
|
||||
<v-tooltip v-if="!kb.init_error" :text="t('card.edit')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-pencil-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="list-action-icon-btn"
|
||||
@click.stop="editKB(kb)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip :text="t('card.delete')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="list-action-icon-btn"
|
||||
@click.stop="confirmDelete(kb)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</OutlinedActionListItem>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -76,6 +91,36 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="kb-fab-stack">
|
||||
<v-tooltip :text="t('list.refresh')" location="left">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
color="darkprimary"
|
||||
icon="mdi-refresh"
|
||||
size="x-large"
|
||||
variant="elevated"
|
||||
class="kb-fab"
|
||||
:loading="loading"
|
||||
@click="loadKnowledgeBases()"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip :text="t('list.create')" location="left">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
color="darkprimary"
|
||||
icon="mdi-plus"
|
||||
size="x-large"
|
||||
variant="elevated"
|
||||
class="kb-fab"
|
||||
@click="showCreateDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<v-dialog v-model="showCreateDialog" max-width="600px" persistent>
|
||||
<v-card>
|
||||
@@ -214,6 +259,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import OutlinedActionListItem from '@/components/shared/OutlinedActionListItem.vue'
|
||||
|
||||
const { tm: t } = useModuleI18n('features/knowledge-base/index')
|
||||
const router = useRouter()
|
||||
@@ -454,114 +500,31 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.kb-list-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 知识库网格 */
|
||||
.kb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.kb-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kb-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Error state card styles */
|
||||
.kb-card-error {
|
||||
cursor: not-allowed;
|
||||
border: 1px solid rgba(var(--v-theme-error), 0.3);
|
||||
background-color: rgba(var(--v-theme-error), 0.02) !important;
|
||||
overflow: visible; /* Allow badge to overflow */
|
||||
}
|
||||
|
||||
.kb-card-error:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-error), 0.1) !important;
|
||||
border-color: rgba(var(--v-theme-error), 0.5);
|
||||
}
|
||||
|
||||
.kb-card-error .kb-emoji {
|
||||
opacity: 0.7;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.kb-card-error .kb-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
}
|
||||
|
||||
.kb-error-badge {
|
||||
z-index: 10;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.kb-card-content {
|
||||
padding: 24px;
|
||||
.kb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-height: 260px;
|
||||
position: relative;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kb-card-content-error {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 56px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kb-name {
|
||||
.kb-list-emoji {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kb-description {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
max-height: 3em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kb-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.kb-error-panel {
|
||||
@@ -599,22 +562,38 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
}
|
||||
|
||||
.list-action-icon-btn {
|
||||
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||
}
|
||||
|
||||
.list-action-icon-btn:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kb-actions {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
.kb-fab-stack {
|
||||
align-items: center;
|
||||
bottom: 52px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.kb-card:hover .kb-actions {
|
||||
opacity: 1;
|
||||
.kb-fab {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.kb-fab:hover {
|
||||
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
@@ -676,14 +655,6 @@ onMounted(() => {
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.kb-list-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.kb-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="documents-tab">
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar mb-4">
|
||||
<v-btn prepend-icon="mdi-upload" color="primary" variant="elevated" @click="showUploadDialog = true">
|
||||
<v-btn prepend-icon="mdi-upload" color="primary" variant="outlined" @click="showUploadDialog = true">
|
||||
{{ t('documents.upload') }}
|
||||
</v-btn>
|
||||
<v-text-field v-model="searchQuery" prepend-inner-icon="mdi-magnify" :placeholder="'搜索文档...'" variant="outlined"
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 文档列表 -->
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-data-table :headers="headers" :items="documents" :loading="loading" :search="searchQuery" :items-per-page="10">
|
||||
<template #item.doc_name="{ item }">
|
||||
<div class="d-flex align-center gap-2">
|
||||
@@ -65,8 +65,6 @@
|
||||
<v-btn icon="mdi-close" variant="text" @click="closeUploadDialog" />
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-tabs v-model="uploadMode" grow class="mb-4">
|
||||
<v-tab value="file">{{ t('upload.fileUpload') }}</v-tab>
|
||||
<v-tab value="url">
|
||||
@@ -193,8 +191,6 @@
|
||||
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeUploadDialog" :disabled="uploading">
|
||||
@@ -212,14 +208,12 @@
|
||||
<v-dialog v-model="showDeleteDialog" max-width="450px">
|
||||
<v-card>
|
||||
<v-card-title class="pa-4 text-h6">{{ t('documents.delete') }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-6">
|
||||
<p>{{ t('documents.deleteConfirm', { name: deleteTarget?.doc_name || '' }) }}</p>
|
||||
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
|
||||
{{ t('documents.deleteWarning') }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div class="retrieval-tab">
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title>
|
||||
<v-card-subtitle class="pb-4 pt-2">
|
||||
{{ t('retrieval.subtitle') }}
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-divider />
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" height="2" />
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
@@ -58,8 +57,6 @@
|
||||
|
||||
<!-- 检索结果 -->
|
||||
<div v-if="hasSearched" class="results-section">
|
||||
<v-divider class="mb-4" />
|
||||
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
|
||||
<v-chip class="ml-3" color="primary" variant="tonal" size="small">
|
||||
@@ -93,8 +90,6 @@
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<div class="content-box">
|
||||
{{ result.content }}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="settings-tab">
|
||||
<v-card elevation="2">
|
||||
<v-card variant="outlined">
|
||||
<v-card-title class="pa-4">{{ t('settings.title') }}</v-card-title>
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-form ref="formRef">
|
||||
@@ -104,8 +103,6 @@
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
|
||||
@@ -1,37 +1,111 @@
|
||||
<template>
|
||||
<div class="kb-container">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="kb-fade" mode="out-in">
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="kb-page-title">
|
||||
<button
|
||||
v-if="isDetailRoute"
|
||||
class="kb-page-title__parent"
|
||||
type="button"
|
||||
@click="goToList"
|
||||
>
|
||||
{{ t('list.title') }}
|
||||
</button>
|
||||
<template v-else>
|
||||
{{ t('list.title') }}
|
||||
</template>
|
||||
<template v-if="isDetailRoute">
|
||||
<v-icon icon="mdi-chevron-right" size="24" class="mx-1" />
|
||||
<span class="kb-page-title__current">{{ displayDetailTitle }}</span>
|
||||
</template>
|
||||
</h1>
|
||||
<p class="text-body-2 text-medium-emphasis">{{ t('list.subtitle') }}</p>
|
||||
</div>
|
||||
<v-btn
|
||||
icon="mdi-information-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="grey"
|
||||
href="https://docs.astrbot.app/use/knowledge-base.html"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<router-view @title-change="detailTitle = $event" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 主容器组件,提供路由出口和页面切换动画
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const { tm: t } = useModuleI18n('features/knowledge-base/index')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const detailTitle = ref('')
|
||||
|
||||
const isDetailRoute = computed(() => route.name === 'NativeKBDetail')
|
||||
const displayDetailTitle = computed(() => detailTitle.value || String(route.params.kbId || ''))
|
||||
|
||||
const goToList = () => {
|
||||
router.push({ name: 'NativeKBList' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-container {
|
||||
margin: 0 auto;
|
||||
max-width: 1400px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 页面切换动画 */
|
||||
.kb-fade-enter-active,
|
||||
.kb-fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.kb-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
.kb-page-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
gap: 2px;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kb-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
.kb-page-title__parent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.kb-page-title__parent:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.kb-page-title__current {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.kb-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1212,3 +1212,102 @@ async def test_batch_upload_skills_partial_success(
|
||||
assert data["data"]["failed"] == [
|
||||
{"filename": "bad_skill.zip", "error": "install failed"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_file_browser_and_editor_security(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
async def _fake_sync_skills_to_active_sandboxes():
|
||||
return
|
||||
|
||||
skills_root = tmp_path / "skills"
|
||||
skill_dir = skills_root / "demo_skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
"---\ndescription: Demo skill\n---\n# Demo\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
|
||||
(skill_dir / "large.md").write_text("x" * (512 * 1024 + 1), encoding="utf-8")
|
||||
(skill_dir / "binary.md").write_bytes(b"\xff\xfe\x00")
|
||||
outside_file = tmp_path / "outside.txt"
|
||||
outside_file.write_text("outside", encoding="utf-8")
|
||||
if hasattr(os, "symlink"):
|
||||
os.symlink(outside_file, skill_dir / "outside-link.txt")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.skill_manager.get_astrbot_skills_path",
|
||||
lambda: str(skills_root),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
|
||||
_fake_sync_skills_to_active_sandboxes,
|
||||
)
|
||||
|
||||
test_client = app.test_client()
|
||||
|
||||
list_response = await test_client.get(
|
||||
"/api/skills/files?name=demo_skill",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
list_data = await list_response.get_json()
|
||||
assert list_data["status"] == "ok"
|
||||
listed_paths = {item["path"] for item in list_data["data"]["entries"]}
|
||||
assert "SKILL.md" in listed_paths
|
||||
assert "outside-link.txt" not in listed_paths
|
||||
|
||||
read_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=SKILL.md",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
read_data = await read_response.get_json()
|
||||
assert read_data["status"] == "ok"
|
||||
assert "# Demo" in read_data["data"]["content"]
|
||||
|
||||
update_response = await test_client.post(
|
||||
"/api/skills/file",
|
||||
json={
|
||||
"name": "demo_skill",
|
||||
"path": "SKILL.md",
|
||||
"content": "# Updated\n",
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
update_data = await update_response.get_json()
|
||||
assert update_data["status"] == "ok"
|
||||
assert skill_md.read_text(encoding="utf-8") == "# Updated\n"
|
||||
|
||||
traversal_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=../outside.txt",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
traversal_data = await traversal_response.get_json()
|
||||
assert traversal_data["status"] == "error"
|
||||
|
||||
symlink_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=outside-link.txt",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
symlink_data = await symlink_response.get_json()
|
||||
assert symlink_data["status"] == "error"
|
||||
|
||||
large_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=large.md",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
large_data = await large_response.get_json()
|
||||
assert large_data["status"] == "error"
|
||||
assert large_data["message"] == "File is too large"
|
||||
|
||||
binary_response = await test_client.get(
|
||||
"/api/skills/file?name=demo_skill&path=binary.md",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
binary_data = await binary_response.get_json()
|
||||
assert binary_data["status"] == "error"
|
||||
assert binary_data["message"] == "File is not valid UTF-8 text"
|
||||
|
||||
Reference in New Issue
Block a user