feat(kb):新增知识库文件管理功能

- 更新了 KnowledgeBaseManager 以支持新的文件管理方法。
- 在 workspace_router 中为知识库文件操作新增了端点,包括文件树列表、文件预览和文件下载。
- 在 workspace_api.js 中添加了相应的 API 调用,用于知识库交互。
- 增强了 AgentFilePreview 组件,支持预览变体之间的切换。
- 更新了导航逻辑,重定向至知识管理标签页,而非数据库视图。
- 修改了 WorkspaceView 以处理知识库条目和预览,包括加载和展示知识文件。
- 通过在文件操作期间提供加载与错误状态的反馈,改善了用户体验。
This commit is contained in:
Wenjie Zhang 2026-05-17 14:28:50 +08:00
parent fa2497e439
commit a0f9b0b6fd
17 changed files with 746 additions and 148 deletions

View File

@ -1,4 +1,5 @@
import asyncio
import mimetypes
import os
from abc import ABC, abstractmethod
from typing import Any
@ -377,20 +378,245 @@ class KnowledgeBase(ABC):
return upload_result.url
async def _read_markdown_from_minio(self, file_path: str) -> str:
"""Read markdown content from MinIO"""
async def _read_minio_bytes(self, file_path: str) -> bytes:
from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
from yuxi.storage.minio import get_minio_client
if not is_minio_url(file_path):
if not file_path or not is_minio_url(file_path):
raise ValueError(f"Invalid MinIO path format: {file_path}")
bucket_name, object_name = parse_minio_url(file_path)
minio_client = get_minio_client()
return await minio_client.adownload_file(bucket_name, object_name)
content_bytes = await minio_client.adownload_file(bucket_name, object_name)
async def _read_markdown_from_minio(self, file_path: str) -> str:
"""Read markdown content from MinIO"""
content_bytes = await self._read_minio_bytes(file_path)
return content_bytes.decode("utf-8")
def _get_file_meta(self, db_id: str, file_id: str) -> dict:
file_meta = self.files_meta.get(file_id)
if not file_meta or file_meta.get("database_id") != db_id:
raise ValueError(f"File {file_id} not found")
return file_meta
@staticmethod
def _original_file_path(file_meta: dict) -> str | None:
return file_meta.get("minio_url") or file_meta.get("path")
def _knowledge_preview_variants(self, file_meta: dict) -> list[dict]:
variants = []
original_path = self._original_file_path(file_meta)
if original_path:
variants.append({"key": "original", "label": "Source", "supported": True})
if file_meta.get("markdown_file"):
variants.append({"key": "parsed", "label": "MD", "supported": True})
return variants
def _knowledge_file_entry(self, db_id: str, file_id: str, file_meta: dict) -> dict:
is_dir = bool(file_meta.get("is_folder"))
variants = [] if is_dir else self._knowledge_preview_variants(file_meta)
preview_modes = [item["key"] for item in variants]
default_preview_mode = None
if "parsed" in preview_modes:
default_preview_mode = "parsed"
elif preview_modes:
default_preview_mode = preview_modes[0]
path = f"/{file_id}"
if is_dir:
path = f"{path}/"
return {
"source": "knowledge",
"db_id": db_id,
"file_id": file_id,
"parent_id": file_meta.get("parent_id"),
"path": path,
"virtual_path": f"/knowledge/{db_id}/{file_id}",
"name": file_meta.get("filename") or file_meta.get("original_filename") or file_id,
"is_dir": is_dir,
"size": 0 if is_dir else file_meta.get("size") or 0,
"modified_at": file_meta.get("updated_at") or file_meta.get("created_at") or "",
"readonly": True,
"status": file_meta.get("status", "done"),
"preview_modes": preview_modes,
"default_preview_mode": default_preview_mode,
}
def _sort_file_entries(self, entries: list[dict]) -> list[dict]:
return sorted(
entries,
key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower()),
)
def _list_knowledge_children(
self,
db_id: str,
parent_id: str | None,
*,
recursive: bool,
files_only: bool,
) -> list[dict]:
children = [
(file_id, meta)
for file_id, meta in self.files_meta.items()
if meta.get("database_id") == db_id and meta.get("parent_id") == parent_id
]
entries = []
for file_id, meta in children:
if not files_only or not meta.get("is_folder"):
entries.append(self._knowledge_file_entry(db_id, file_id, meta))
if recursive and meta.get("is_folder"):
entries.extend(
self._list_knowledge_children(
db_id,
file_id,
recursive=True,
files_only=files_only,
)
)
return self._sort_file_entries(entries)
async def list_file_tree(
self,
db_id: str,
parent_id: str | None = None,
recursive: bool = False,
files_only: bool = False,
) -> dict:
if db_id not in self.databases_meta:
raise ValueError(f"Database {db_id} not found")
if parent_id:
parent_meta = self._get_file_meta(db_id, parent_id)
if not parent_meta.get("is_folder"):
raise ValueError("Parent is not a folder")
return {
"entries": self._list_knowledge_children(
db_id,
parent_id,
recursive=recursive,
files_only=files_only,
),
"readonly": True,
}
async def read_file_preview(self, db_id: str, file_id: str, variant: str = "parsed") -> dict:
from yuxi.services.viewer_filesystem_service import _detect_preview_type
file_meta = self._get_file_meta(db_id, file_id)
if file_meta.get("is_folder"):
raise ValueError("Cannot preview a folder")
variants = self._knowledge_preview_variants(file_meta)
variant_keys = {item["key"] for item in variants}
if variant not in {"original", "parsed"}:
raise ValueError("Unsupported preview variant")
filename = file_meta.get("filename") or file_meta.get("original_filename") or file_id
response = {
"source": "knowledge",
"db_id": db_id,
"file_id": file_id,
"filename": filename,
"variant": variant,
"readonly": True,
"available_variants": variants,
}
if variant == "parsed":
markdown_file = file_meta.get("markdown_file")
if not markdown_file or "parsed" not in variant_keys:
return {
**response,
"content": None,
"preview_type": "unsupported",
"supported": False,
"message": "文件尚未生成解析结果",
}
content = await self._read_markdown_from_minio(markdown_file)
return {
**response,
"content": content,
"preview_type": "markdown",
"supported": True,
"message": None,
}
original_path = self._original_file_path(file_meta)
if not original_path or "original" not in variant_keys:
return {
**response,
"content": None,
"preview_type": "unsupported",
"supported": False,
"message": "文件没有可预览的原始内容",
}
preview_type, supported, message = _detect_preview_type(filename, b"")
if preview_type in {"image", "pdf"}:
return {
**response,
"content": None,
"preview_type": preview_type,
"supported": supported,
"message": message,
}
raw_content = await self._read_minio_bytes(original_path)
preview_type, supported, message = _detect_preview_type(filename, raw_content)
if preview_type in {"image", "pdf"} or not supported:
return {
**response,
"content": None,
"preview_type": preview_type,
"supported": supported,
"message": message,
}
try:
content = raw_content.decode("utf-8")
except UnicodeDecodeError:
return {
**response,
"content": None,
"preview_type": "unsupported",
"supported": False,
"message": "当前文件不是 UTF-8 文本,暂不支持预览",
}
return {
**response,
"content": content,
"preview_type": preview_type,
"supported": True,
"message": message,
}
async def get_file_download(self, db_id: str, file_id: str, variant: str = "original") -> dict:
file_meta = self._get_file_meta(db_id, file_id)
if file_meta.get("is_folder"):
raise ValueError("Cannot download a folder")
if variant not in {"original", "parsed"}:
raise ValueError("Unsupported download variant")
filename = file_meta.get("filename") or file_meta.get("original_filename") or file_id
if variant == "parsed":
markdown_file = file_meta.get("markdown_file")
if not markdown_file:
raise ValueError("文件尚未生成解析结果")
return {
"filename": f"{filename}.parsed.md",
"content": await self._read_minio_bytes(markdown_file),
"media_type": "text/markdown; charset=utf-8",
}
original_path = self._original_file_path(file_meta)
if not original_path:
raise ValueError("文件没有可下载的原始内容")
media_type = file_meta.get("content_type") or mimetypes.guess_type(filename)[0] or "application/octet-stream"
return {
"filename": filename,
"content": await self._read_minio_bytes(original_path),
"media_type": media_type,
}
def _build_open_file_window(self, content: str, *, offset: int = 0, limit: int = 800) -> dict[str, Any]:
lines = content.splitlines()
total_lines = len(lines)

View File

@ -70,6 +70,24 @@ class DifyKB(KnowledgeBase):
async def get_file_info(self, db_id: str, file_id: str) -> dict:
raise self._readonly_error()
async def list_file_tree(
self,
db_id: str,
parent_id: str | None = None,
recursive: bool = False,
files_only: bool = False,
) -> dict:
del db_id, parent_id, recursive, files_only
raise ValueError("Dify 知识库不支持文件树预览")
async def read_file_preview(self, db_id: str, file_id: str, variant: str = "parsed") -> dict:
del db_id, file_id, variant
raise ValueError("Dify 知识库不支持文件预览")
async def get_file_download(self, db_id: str, file_id: str, variant: str = "original") -> dict:
del db_id, file_id, variant
raise ValueError("Dify 知识库不支持文件下载")
async def aquery(self, query_text: str, db_id: str, agent_call: bool = False, **kwargs) -> list[dict]:
del agent_call
metadata = self.databases_meta.get(db_id, {}).get("metadata", {}) or {}

View File

@ -498,6 +498,24 @@ class KnowledgeBaseManager:
kb_instance = await self._get_kb_for_database(db_id)
return await kb_instance.get_file_info(db_id, file_id)
async def list_file_tree(
self,
db_id: str,
parent_id: str | None = None,
recursive: bool = False,
files_only: bool = False,
) -> dict:
kb_instance = await self._get_kb_for_database(db_id)
return await kb_instance.list_file_tree(db_id, parent_id, recursive, files_only)
async def read_file_preview(self, db_id: str, file_id: str, variant: str = "parsed") -> dict:
kb_instance = await self._get_kb_for_database(db_id)
return await kb_instance.read_file_preview(db_id, file_id, variant)
async def get_file_download(self, db_id: str, file_id: str, variant: str = "original") -> dict:
kb_instance = await self._get_kb_for_database(db_id)
return await kb_instance.get_file_download(db_id, file_id, variant)
async def file_name_existed_in_db(self, db_id: str | None, file_name: str | None) -> bool:
"""检查指定数据库中是否存在同名的文件"""
if not db_id or not file_name:

View File

@ -1,9 +1,14 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
import io
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from server.utils.auth_middleware import get_required_user
from yuxi import knowledge_base
from yuxi.services.workspace_service import (
create_workspace_directory,
delete_workspace_path,
@ -28,6 +33,25 @@ class UpdateWorkspaceFileContentRequest(BaseModel):
content: str
async def _ensure_knowledge_read_access(current_user: User, db_id: str) -> None:
allowed = await knowledge_base.check_accessible(
{
"role": current_user.role,
"department_id": current_user.department_id,
},
db_id,
)
if not allowed:
raise HTTPException(status_code=403, detail="Access denied")
def _raise_knowledge_read_error(error: ValueError) -> None:
message = str(error) or "知识库文件读取失败"
if message.startswith("Dify 知识库不支持"):
raise HTTPException(status_code=501, detail=message) from error
raise HTTPException(status_code=400, detail=message) from error
@workspace.get("/tree", response_model=dict)
async def get_workspace_tree(
path: str = Query("/", description="工作区目录路径"),
@ -51,6 +75,61 @@ async def get_workspace_file(
return await read_workspace_file_content(path=path, current_user=current_user)
@workspace.get("/knowledge/tree", response_model=dict)
async def get_workspace_knowledge_tree(
db_id: str = Query(..., description="知识库 ID"),
parent_id: str | None = Query(None, description="父文件夹 ID"),
recursive: bool = Query(False, description="是否递归返回子目录文件"),
files_only: bool = Query(False, description="是否仅返回文件"),
current_user: User = Depends(get_required_user),
):
await _ensure_knowledge_read_access(current_user, db_id)
try:
return await knowledge_base.list_file_tree(
db_id=db_id,
parent_id=parent_id,
recursive=recursive,
files_only=files_only,
)
except ValueError as error:
_raise_knowledge_read_error(error)
@workspace.get("/knowledge/file", response_model=dict)
async def get_workspace_knowledge_file(
db_id: str = Query(..., description="知识库 ID"),
file_id: str = Query(..., description="知识库文件 ID"),
variant: str = Query("parsed", description="预览模式parsed 或 original"),
current_user: User = Depends(get_required_user),
):
await _ensure_knowledge_read_access(current_user, db_id)
try:
return await knowledge_base.read_file_preview(db_id=db_id, file_id=file_id, variant=variant)
except ValueError as error:
_raise_knowledge_read_error(error)
@workspace.get("/knowledge/download")
async def download_workspace_knowledge_file(
db_id: str = Query(..., description="知识库 ID"),
file_id: str = Query(..., description="知识库文件 ID"),
variant: str = Query("original", description="下载模式original 或 parsed"),
current_user: User = Depends(get_required_user),
):
await _ensure_knowledge_read_access(current_user, db_id)
try:
data = await knowledge_base.get_file_download(db_id=db_id, file_id=file_id, variant=variant)
except ValueError as error:
_raise_knowledge_read_error(error)
filename = data["filename"]
return StreamingResponse(
io.BytesIO(data["content"]),
media_type=data["media_type"],
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@workspace.put("/file", response_model=dict)
async def update_workspace_file(
payload: UpdateWorkspaceFileContentRequest,

View File

@ -20,6 +20,26 @@ export const getWorkspaceFileContent = (path) => {
return apiGet(`/api/workspace/file?${query}`)
}
export const getWorkspaceKnowledgeTree = (
dbId,
parentId = null,
recursive = false,
filesOnly = false
) => {
const query = buildQuery({ db_id: dbId, parent_id: parentId, recursive, files_only: filesOnly })
return apiGet(`/api/workspace/knowledge/tree?${query}`)
}
export const getWorkspaceKnowledgeFileContent = (dbId, fileId, variant = 'parsed') => {
const query = buildQuery({ db_id: dbId, file_id: fileId, variant })
return apiGet(`/api/workspace/knowledge/file?${query}`)
}
export const downloadWorkspaceKnowledgeFile = (dbId, fileId, variant = 'original') => {
const query = buildQuery({ db_id: dbId, file_id: fileId, variant })
return apiGet(`/api/workspace/knowledge/download?${query}`, {}, true, 'blob')
}
export const saveWorkspaceFileContent = (path, content) => {
return apiPut('/api/workspace/file', { path, content })
}

View File

@ -686,7 +686,7 @@ const navigateToConfigPage = (kind) => {
setTimeout(() => {
switch (kind) {
case 'knowledges':
router.push('/database')
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
break
case 'tools':
router.push({ path: '/extensions', query: { tab: 'tools' } })

View File

@ -9,6 +9,18 @@
<span class="file-path-title">{{ filePath }}</span>
</div>
<div class="modal-actions">
<div v-if="availablePreviewVariants.length > 1" class="preview-mode-switch">
<button
v-for="variant in availablePreviewVariants"
:key="variant.key"
class="preview-mode-btn text-mode-btn"
:class="{ active: activePreviewVariant === variant.key }"
:title="variant.label"
@click="$emit('switchVariant', variant.key)"
>
{{ variant.label }}
</button>
</div>
<div v-if="canEdit" class="preview-mode-switch">
<button
class="preview-mode-btn"
@ -311,7 +323,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['close', 'download', 'save'])
const emit = defineEmits(['close', 'download', 'save', 'switchVariant'])
const themeStore = useThemeStore()
const closeTitle = computed(() =>
@ -327,6 +339,11 @@ const fullscreenPreviewVisible = ref(false)
const htmlPreviewRenderKey = ref(0)
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
const availablePreviewVariants = computed(() => {
const variants = props.file?.availableVariants || props.file?.available_variants || []
return variants.filter((variant) => variant?.supported !== false && variant?.key)
})
const activePreviewVariant = computed(() => props.file?.variant || props.file?.previewVariant || '')
const canEdit = computed(() => {
const previewType = props.file?.previewType
return (
@ -522,6 +539,13 @@ onUnmounted(() => {
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.text-mode-btn {
width: auto;
min-width: 48px;
padding: 0 8px;
font-size: 12px;
}
.file-content {
min-height: 300px;
max-height: 80vh;

View File

@ -90,7 +90,7 @@ const rules = {
}
const backToDatabase = () => {
router.push('/database')
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
}
const showEditModal = () => {

View File

@ -240,7 +240,7 @@ const copyDatabaseId = async () => {
//
const backToDatabase = () => {
router.push('/database')
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
}
// DatabaseHeader

View File

@ -3,7 +3,7 @@
<div class="file-list-header">
<div class="path-line">
<a-breadcrumb class="path-breadcrumb">
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
<a-breadcrumb-item v-for="item in resolvedBreadcrumbItems" :key="item.path">
<button
type="button"
class="breadcrumb-action"
@ -19,11 +19,11 @@
</div>
<div class="list-actions">
<span class="entry-count">{{ entries.length }} </span>
<a-tooltip title="多选">
<a-tooltip v-if="!readonly" title="多选">
<a-button
size="small"
class="lucide-icon-btn"
:type="selectionMode ? 'primary' : 'default'"
:type="effectiveSelectionMode ? 'primary' : 'default'"
aria-label="多选"
@click="toggleSelectionMode"
>
@ -31,7 +31,7 @@
</a-button>
</a-tooltip>
<a-button
v-if="selectionMode"
v-if="effectiveSelectionMode"
size="small"
danger
:disabled="!selectedPaths.length"
@ -44,8 +44,8 @@
</div>
<div class="file-table" role="table" aria-label="工作区文件列表">
<div class="file-row table-head" :class="{ 'selection-enabled': selectionMode }" role="row">
<span v-if="selectionMode" class="selection-cell">
<div class="file-row table-head" :class="{ 'selection-enabled': effectiveSelectionMode }" role="row">
<span v-if="effectiveSelectionMode" class="selection-cell">
<a-checkbox
:checked="allSelected"
:indeterminate="partiallySelected"
@ -66,14 +66,14 @@
:class="{
selected: selectedPath === entry.path,
deleting: isDeleting(entry.path),
'selection-enabled': selectionMode
'selection-enabled': effectiveSelectionMode
}"
role="row"
tabindex="0"
@click="$emit('select-entry', entry)"
@keydown.enter="$emit('select-entry', entry)"
>
<span v-if="selectionMode" class="selection-cell" @click.stop>
<span v-if="effectiveSelectionMode" class="selection-cell" @click.stop>
<a-checkbox
:checked="selectedPathSet.has(entry.path)"
:disabled="isDeleting(entry.path)"
@ -93,7 +93,7 @@
<span>{{ entry.is_dir ? '-' : formatFileSize(entry.size) }}</span>
<span>{{ formatRelativeTime(entry.modified_at) }}</span>
<span class="action-cell" @click.stop>
<a-dropdown :trigger="['click']">
<a-dropdown v-if="!entry.is_dir || !readonly" :trigger="['click']">
<button
type="button"
class="more-action"
@ -115,7 +115,7 @@
<span>下载</span>
</span>
</a-menu-item>
<a-menu-item key="delete" danger @click="$emit('delete-entry', entry)">
<a-menu-item v-if="!readonly" key="delete" danger @click="$emit('delete-entry', entry)">
<span class="menu-item-content">
<Trash2 :size="14" />
<span>删除</span>
@ -153,7 +153,10 @@ const props = defineProps({
selectedPaths: { type: Array, default: () => [] },
deletingPaths: { type: Array, default: () => [] },
selectionMode: { type: Boolean, default: false },
loading: { type: Boolean, default: false }
loading: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
breadcrumbItems: { type: Array, default: null },
rootLabel: { type: String, default: '工作区' }
})
const emit = defineEmits([
@ -169,11 +172,15 @@ const emit = defineEmits([
const selectedPathSet = computed(() => new Set(props.selectedPaths))
const deletingPathSet = computed(() => new Set(props.deletingPaths))
const entryPaths = computed(() => props.entries.map((entry) => entry.path))
const entryPathSet = computed(() => new Set(entryPaths.value))
const normalizedCurrentPath = computed(() => (props.currentPath || '/').replace(/\/+$/, '') || '/')
const breadcrumbItems = computed(() => {
const effectiveSelectionMode = computed(() => !props.readonly && props.selectionMode)
const resolvedBreadcrumbItems = computed(() => {
if (props.breadcrumbItems?.length) return props.breadcrumbItems
const normalizedPath = normalizedCurrentPath.value
if (normalizedPath === '/') {
return [{ name: '工作区', path: '/' }]
return [{ name: props.rootLabel, path: '/' }]
}
const segments = normalizedPath.split('/').filter(Boolean)
@ -184,7 +191,7 @@ const breadcrumbItems = computed(() => {
items.push({ name: segment, path })
return items
},
[{ name: '工作区', path: '/' }]
[{ name: props.rootLabel, path: '/' }]
)
})
@ -201,6 +208,7 @@ const partiallySelected = computed(() => {
const isDeleting = (path) => deletingPathSet.value.has(path)
const toggleSelectionMode = () => {
if (props.readonly) return
const nextMode = !props.selectionMode
emit('update:selectionMode', nextMode)
if (!nextMode) {
@ -221,7 +229,7 @@ const toggleEntrySelection = (path, checked) => {
}
emit(
'update:selectedPaths',
[...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath))
[...nextSelectedPaths].filter((selectedPath) => entryPathSet.value.has(selectedPath))
)
}
</script>

View File

@ -15,6 +15,7 @@
content-class="workspace-preview-content"
@close="$emit('close')"
@save="$emit('save', $event)"
@switch-variant="$emit('switchVariant', $event)"
/>
<div v-else-if="loading" class="preview-state">
<a-spin />
@ -40,7 +41,7 @@ defineProps({
saving: { type: Boolean, default: false }
})
defineEmits(['close', 'save'])
defineEmits(['close', 'save', 'switchVariant'])
</script>
<style scoped lang="less">

View File

@ -3,7 +3,6 @@ import { ref, reactive, onMounted, computed, provide, watch } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import { GithubOutlined } from '@ant-design/icons-vue'
import {
LibraryBig,
BarChart3,
ClipboardList,
Blocks,
@ -121,8 +120,6 @@ const organizationName = computed(() => {
})
//
const isLiteMode = import.meta.env.VITE_LITE_MODE === 'true'
const mainList = computed(() => {
const items = [
{
@ -142,19 +139,10 @@ const mainList = computed(() => {
})
if (userStore.isAdmin) {
if (!isLiteMode) {
items.push({
name: '知识库',
path: '/database',
activePaths: ['/database'],
icon: LibraryBig,
activeIcon: LibraryBig
})
}
items.push({
name: '扩展管理',
path: '/extensions',
activePaths: ['/extensions', '/database'],
icon: Blocks,
activeIcon: Blocks
})
@ -393,7 +381,7 @@ provide('settingsModal', {
<style lang="less" scoped>
// Less
@sidebar-width: 252px;
@sidebar-width: 230px;
@sidebar-collapsed-width: 56px;
@sidebar-padding: 6px 8px;
@sidebar-item-height: 36px;

View File

@ -67,21 +67,12 @@ const router = createRouter({
{
path: '/database',
name: 'database',
component: AppLayout,
children: [
{
path: '',
name: 'DatabaseComp',
component: () => import('../views/DataBaseView.vue'),
meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true }
},
{
path: ':database_id',
name: 'DatabaseInfoComp',
component: () => import('../views/DataBaseInfoView.vue'),
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
}
]
redirect: { path: '/extensions', query: { tab: 'knowledge' } }
},
{
path: '/database/:database_id',
name: 'DatabaseInfoCompLegacy',
redirect: (to) => ({ path: `/extensions/database/${to.params.database_id}` })
},
{
path: '/dashboard',
@ -122,37 +113,49 @@ const router = createRouter({
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'mcp/:name',
name: 'ExtensionMcpDetail',
component: () => import('../components/extensions/McpDetailView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'subagent/:name',
name: 'ExtensionSubagentDetail',
component: () => import('../components/extensions/SubagentDetailView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'skill/:slug',
name: 'ExtensionSkillDetail',
component: () => import('../components/extensions/SkillDetailView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
},
children: [
{
path: 'database/:database_id',
name: 'ExtensionDatabaseDetail',
component: () => import('../views/DataBaseInfoView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'mcp/:name',
name: 'ExtensionMcpDetail',
component: () => import('../components/extensions/McpDetailView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'subagent/:name',
name: 'ExtensionSubagentDetail',
component: () => import('../components/extensions/SubagentDetailView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'skill/:slug',
name: 'ExtensionSkillDetail',
component: () => import('../components/extensions/SkillDetailView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
}
}
]
}
]
},

View File

@ -150,7 +150,7 @@ export const useDatabaseStore = defineStore('database', () => {
try {
const data = await databaseApi.deleteDatabase(databaseId.value)
message.success(data.message || '删除成功')
router.push('/database')
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
} catch (error) {
console.error(error)
message.error(error.message || '删除失败')

View File

@ -1,6 +1,7 @@
<template>
<div class="database-container layout-container">
<PageHeader
v-if="!props.embedded"
title="知识库"
:active-key="knowledgeActiveView"
:tabs="knowledgeViewItems"
@ -212,11 +213,17 @@ const router = useRouter()
const configStore = useConfigStore()
const databaseStore = useDatabaseStore()
const props = defineProps({
embedded: { type: Boolean, default: false }
})
// 使 store
const { databases, state: dbState } = storeToRefs(databaseStore)
const knowledgeActiveView = 'documents'
const knowledgeViewItems = [{ key: 'documents', label: '文档知识库', path: '/database' }]
const knowledgeViewItems = [
{ key: 'documents', label: '文档知识库', path: '/extensions?tab=knowledge' }
]
const kbTypes = ['milvus', 'dify']
const searchQuery = ref('')
@ -434,13 +441,13 @@ const cardTags = (database) => {
}
const navigateToDatabase = (databaseId) => {
router.push({ path: `/database/${databaseId}` })
router.push({ path: `/extensions/database/${databaseId}` })
}
watch(
() => route.path,
(newPath) => {
if (newPath === '/database') {
if (newPath === '/database' || (newPath === '/extensions' && route.query.tab === 'knowledge')) {
databaseStore.loadDatabases()
}
}
@ -450,6 +457,10 @@ onMounted(() => {
loadSupportedKbTypes()
databaseStore.loadDatabases()
})
defineExpose({
loading: computed(() => dbState.value.listLoading)
})
</script>
<style lang="less" scoped>

View File

@ -11,6 +11,9 @@
/>
<div v-if="!isDetailPage" class="extensions-content">
<div v-show="activeTab === 'knowledge'" class="tab-panel">
<DataBaseView ref="knowledgeRef" embedded />
</div>
<div v-show="activeTab === 'tools'" class="tab-panel">
<ToolsCardList ref="toolsRef" />
</div>
@ -37,15 +40,18 @@ import McpCardList from '@/components/extensions/McpCardList.vue'
import SubagentCardList from '@/components/extensions/SubagentCardList.vue'
import SkillCardList from '@/components/extensions/SkillCardList.vue'
import PageHeader from '@/components/shared/PageHeader.vue'
import DataBaseView from '@/views/DataBaseView.vue'
const route = useRoute()
const activeTab = ref('tools')
const activeTab = ref('knowledge')
const knowledgeRef = ref(null)
const skillsRef = ref(null)
const mcpRef = ref(null)
const subagentsRef = ref(null)
const toolsRef = ref(null)
const extensionTabs = [
{ key: 'knowledge', label: '知识库管理' },
{ key: 'tools', label: '工具' },
{ key: 'mcp', label: 'MCP' },
{ key: 'subagents', label: 'Subagents' },
@ -54,6 +60,7 @@ const extensionTabs = [
const isDetailPage = computed(() => {
return (
route.path.startsWith('/extensions/database/') ||
route.path.startsWith('/extensions/mcp/') ||
route.path.startsWith('/extensions/subagent/') ||
route.path.startsWith('/extensions/skill/')
@ -61,7 +68,13 @@ const isDetailPage = computed(() => {
})
const activeChildLoading = computed(() => {
const refMap = { tools: toolsRef, skills: skillsRef, mcp: mcpRef, subagents: subagentsRef }
const refMap = {
knowledge: knowledgeRef,
tools: toolsRef,
skills: skillsRef,
mcp: mcpRef,
subagents: subagentsRef
}
const child = refMap[activeTab.value]
return child?.value?.loading || false
})
@ -69,7 +82,7 @@ const activeChildLoading = computed(() => {
watch(
() => route.query,
(query) => {
if (query.tab && ['tools', 'skills', 'mcp', 'subagents'].includes(query.tab)) {
if (query.tab && ['knowledge', 'tools', 'skills', 'mcp', 'subagents'].includes(query.tab)) {
activeTab.value = query.tab
}
},

View File

@ -58,7 +58,7 @@
:class="{ 'is-inline-preview': showInlinePreview }"
:style="workspaceMainStyle"
>
<template v-if="activeSourceKey === 'personal'">
<template v-if="activeSourceKey === 'personal' || selectedDatabase">
<WorkspaceFileList
:entries="filteredEntries"
:current-path="currentPath"
@ -67,8 +67,11 @@
:deleting-paths="deletingPaths"
:selection-mode="selectionMode"
:loading="loadingTree"
:readonly="isKnowledgeSource"
:root-label="selectedDatabase?.name || '工作区'"
:breadcrumb-items="isKnowledgeSource ? knowledgeBreadcrumbItems : null"
@select-entry="handleSelectEntry"
@select-path="selectWorkspacePath"
@select-path="handleSelectListPath"
@update:selected-paths="selectedPaths = $event"
@update:selection-mode="handleSelectionModeChange"
@delete-selected="confirmDeleteEntries(selectedEntries)"
@ -86,19 +89,20 @@
<WorkspacePreviewPane
v-if="showInlinePreview"
:file="previewFile"
:file-path="selectedEntry?.path || ''"
:file-path="selectedPreviewPath"
:loading="loadingPreview"
:editable="true"
:editable="!isKnowledgeSource"
:saving="savingPreviewFile"
@close="closePreview"
@save="handleSavePreviewFile"
@switch-variant="handleSwitchKnowledgeVariant"
/>
</template>
<div v-else class="workspace-placeholder">
<LibraryBig :size="32" />
<h2>{{ selectedDatabase?.name || '知识库' }}</h2>
<p>当前版本仅展示可访问知识库到列表级别知识库文件浏览后续支持</p>
<h2>知识库</h2>
<p>请选择一个可访问知识库以浏览文件</p>
</div>
</main>
</div>
@ -131,7 +135,7 @@
>
<AgentFilePreview
:file="previewFile"
:filePath="selectedEntry?.path || ''"
:filePath="selectedPreviewPath"
:showClose="true"
:showDownload="false"
:showFullscreen="true"
@ -139,6 +143,7 @@
:saving="savingPreviewFile"
@close="closePreview"
@save="handleSavePreviewFile"
@switch-variant="handleSwitchKnowledgeVariant"
/>
</a-modal>
</div>
@ -158,7 +163,10 @@ import {
createWorkspaceDirectory,
deleteWorkspacePath,
downloadWorkspaceFile,
downloadWorkspaceKnowledgeFile,
getWorkspaceFileContent,
getWorkspaceKnowledgeFileContent,
getWorkspaceKnowledgeTree,
getWorkspaceTree,
saveWorkspaceFileContent,
uploadWorkspaceFile
@ -166,6 +174,7 @@ import {
const activeSourceKey = ref('personal')
const currentPath = ref('/')
const knowledgeBreadcrumbItems = ref([])
const entries = ref([])
const selectedEntry = ref(null)
const selectedPaths = ref([])
@ -194,6 +203,12 @@ const previewRequestId = ref(0)
const INLINE_PREVIEW_MIN_WIDTH = 960
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
const isKnowledgeSource = computed(() => activeSourceKey.value.startsWith('database:'))
const selectedPreviewPath = computed(() =>
selectedEntry.value?.source === 'knowledge'
? selectedEntry.value.name || ''
: selectedEntry.value?.path || ''
)
const showInlinePreview = computed(() => useInlinePreview.value && Boolean(previewFile.value))
const workspaceMainStyle = computed(() => {
if (!showInlinePreview.value) return {}
@ -220,29 +235,151 @@ const selectedEntries = computed(() => {
const revokePreviewObjectUrl = () => {
if (!previewObjectUrl.value) return
URL.revokeObjectURL(previewObjectUrl.value)
window.URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = ''
}
const isBinaryPreview = (previewType) => previewType === 'image' || previewType === 'pdf'
const createBinaryPreviewUrl = async (entry, response) => {
const downloadResponse =
entry.source === 'knowledge'
? await downloadWorkspaceKnowledgeFile(entry.db_id, entry.file_id, response.variant || 'original')
: await downloadWorkspaceFile(entry.path)
const blob = await downloadResponse.blob()
return window.URL.createObjectURL(blob)
}
const normalizePreviewFile = async (entry, response) => {
const previewType = response.preview_type || response.previewType || 'text'
const file = {
...entry,
...response,
previewType,
supported: response.supported !== false
supported: response.supported !== false,
previewUrl: ''
}
if (previewType === 'image' || previewType === 'pdf') {
const downloadResponse = await downloadWorkspaceFile(entry.path)
const blob = await downloadResponse.blob()
revokePreviewObjectUrl()
previewObjectUrl.value = URL.createObjectURL(blob)
file.previewUrl = previewObjectUrl.value
if (entry.source === 'knowledge') {
file.availableVariants = response.available_variants || response.availableVariants || []
}
if (isBinaryPreview(previewType)) {
file.previewUrl = await createBinaryPreviewUrl(entry, response)
}
return file
}
const KNOWLEDGE_PREVIEW_LOAD_MESSAGES = {
log: '加载知识库文件预览失败:',
resolveUserMessage: () => '加载知识库文件预览失败'
}
const KNOWLEDGE_PREVIEW_SWITCH_MESSAGES = {
log: '切换知识库文件预览失败:',
resolveUserMessage: (error) => error?.message || '切换预览失败'
}
const buildPreviewLoadingFile = (entry, baseFile = entry) => ({
...baseFile,
...entry,
content: 'Loading...',
supported: true,
previewType: 'text',
message: '',
previewUrl: ''
})
const buildPreviewErrorFile = (entry, error) => ({
...entry,
content: `Error loading file: ${error?.message || 'unknown error'}`,
supported: false,
previewType: 'unsupported',
message: error?.message || '文件预览失败',
previewUrl: ''
})
const startPreviewRequest = (entry, baseFile = entry) => {
const requestId = previewRequestId.value + 1
previewRequestId.value = requestId
selectedEntry.value = entry
revokePreviewObjectUrl()
previewFile.value = buildPreviewLoadingFile(entry, baseFile)
previewModalVisible.value = !useInlinePreview.value
loadingPreview.value = true
return requestId
}
const isCurrentPreviewEntry = (requestId, entry) => {
if (previewRequestId.value !== requestId) return false
if (entry.source === 'knowledge') {
return selectedEntry.value?.file_id === entry.file_id
}
return selectedEntry.value?.path === entry.path
}
const applyPreviewFile = (requestId, entry, file) => {
if (!isCurrentPreviewEntry(requestId, entry)) {
if (file.previewUrl) {
window.URL.revokeObjectURL(file.previewUrl)
}
return
}
if (file.previewUrl) {
revokePreviewObjectUrl()
previewObjectUrl.value = file.previewUrl
}
previewFile.value = file
}
const showPreviewError = (requestId, entry, error, logMessage, userMessage) => {
if (!isCurrentPreviewEntry(requestId, entry)) return
console.warn(logMessage, error)
previewFile.value = buildPreviewErrorFile(entry, error)
message.error(userMessage)
}
const finishPreviewRequest = (requestId) => {
if (previewRequestId.value === requestId) {
loadingPreview.value = false
}
}
const loadWorkspacePreview = async (entry) => {
const requestId = startPreviewRequest(entry)
try {
const response = await getWorkspaceFileContent(entry.path)
if (!isCurrentPreviewEntry(requestId, entry)) return
const file = await normalizePreviewFile(entry, response)
applyPreviewFile(requestId, entry, file)
} catch (error) {
showPreviewError(requestId, entry, error, '加载文件预览失败:', '加载文件预览失败')
} finally {
finishPreviewRequest(requestId)
}
}
const loadKnowledgePreview = async (
entry,
variant = entry.default_preview_mode || 'parsed',
baseFile = entry,
messages = KNOWLEDGE_PREVIEW_LOAD_MESSAGES
) => {
const requestId = startPreviewRequest(entry, baseFile)
try {
const response = await getWorkspaceKnowledgeFileContent(entry.db_id, entry.file_id, variant)
if (!isCurrentPreviewEntry(requestId, entry)) return
const file = await normalizePreviewFile(entry, response)
applyPreviewFile(requestId, entry, file)
} catch (error) {
showPreviewError(requestId, entry, error, messages.log, messages.resolveUserMessage(error))
} finally {
finishPreviewRequest(requestId)
}
}
const syncSelectedPaths = () => {
const entryPathSet = new Set(entries.value.map((entry) => entry.path))
selectedPaths.value = selectedPaths.value.filter((path) => entryPathSet.has(path))
@ -265,6 +402,7 @@ const loadWorkspaceEntries = async (path = '/') => {
const response = await getWorkspaceTree(path)
entries.value = response.entries || []
currentPath.value = path
knowledgeBreadcrumbItems.value = []
syncSelectedPaths()
if (!selectedPaths.value.length) {
selectionMode.value = false
@ -277,6 +415,30 @@ const loadWorkspaceEntries = async (path = '/') => {
}
}
const loadKnowledgeEntries = async (database, parentId = null, breadcrumbs = null) => {
if (!database?.db_id) return
loadingTree.value = true
try {
const response = await getWorkspaceKnowledgeTree(database.db_id, parentId)
entries.value = response.entries || []
knowledgeBreadcrumbItems.value = breadcrumbs || [
{ name: database.name || '知识库', path: '/', parentId: null }
]
currentPath.value = knowledgeBreadcrumbItems.value.at(-1)?.path || '/'
syncSelectedPaths()
if (!selectedPaths.value.length) {
selectionMode.value = false
}
} catch (error) {
console.warn('加载知识库目录失败:', error)
entries.value = []
message.error(error?.message || '加载知识库目录失败')
} finally {
loadingTree.value = false
}
}
const loadDatabases = async () => {
loadingDatabases.value = true
try {
@ -291,11 +453,13 @@ const loadDatabases = async () => {
}
const selectPersonalWorkspace = async () => {
const wasKnowledgeSource = isKnowledgeSource.value
activeSourceKey.value = 'personal'
selectedDatabase.value = null
knowledgeBreadcrumbItems.value = []
closePreview()
clearWorkspaceSelection()
if (currentPath.value !== '/' || !entries.value.length) {
if (wasKnowledgeSource || currentPath.value !== '/' || !entries.value.length) {
await loadWorkspaceEntries('/')
}
}
@ -303,61 +467,79 @@ const selectPersonalWorkspace = async () => {
const selectWorkspacePath = async (path) => {
activeSourceKey.value = 'personal'
selectedDatabase.value = null
knowledgeBreadcrumbItems.value = []
closePreview()
clearWorkspaceSelection()
await loadWorkspaceEntries(path)
}
const selectDatabase = (database) => {
const selectKnowledgePath = async (path) => {
if (!selectedDatabase.value) return
closePreview()
clearWorkspaceSelection()
const targetIndex = knowledgeBreadcrumbItems.value.findIndex((item) => item.path === path)
if (targetIndex < 0) return
const breadcrumbs = knowledgeBreadcrumbItems.value.slice(0, targetIndex + 1)
await loadKnowledgeEntries(selectedDatabase.value, breadcrumbs.at(-1)?.parentId || null, breadcrumbs)
}
const handleSelectListPath = async (path) => {
if (isKnowledgeSource.value) {
await selectKnowledgePath(path)
return
}
await selectWorkspacePath(path)
}
const selectDatabase = async (database) => {
closePreview()
clearWorkspaceSelection()
selectedDatabase.value = database
activeSourceKey.value = `database:${database.db_id}`
await loadKnowledgeEntries(database)
}
const openKnowledgeDirectory = async (entry) => {
closePreview()
clearWorkspaceSelection()
const parentPath = knowledgeBreadcrumbItems.value.at(-1)?.path || '/'
const nextPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`
await loadKnowledgeEntries(selectedDatabase.value, entry.file_id, [
...knowledgeBreadcrumbItems.value,
{ name: entry.name, path: nextPath, parentId: entry.file_id }
])
}
const openWorkspaceDirectory = async (entry) => {
closePreview()
clearWorkspaceSelection()
await loadWorkspaceEntries(entry.path)
}
const handleSelectEntry = async (entry) => {
if (entry.is_dir) {
closePreview()
clearWorkspaceSelection()
await loadWorkspaceEntries(entry.path)
if (isKnowledgeSource.value) {
await openKnowledgeDirectory(entry)
return
}
await openWorkspaceDirectory(entry)
return
}
const requestId = previewRequestId.value + 1
previewRequestId.value = requestId
selectedEntry.value = entry
revokePreviewObjectUrl()
previewFile.value = {
...entry,
content: 'Loading...',
supported: true,
previewType: 'text',
message: '',
previewUrl: ''
}
previewModalVisible.value = !useInlinePreview.value
loadingPreview.value = true
try {
const response = await getWorkspaceFileContent(entry.path)
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
previewFile.value = await normalizePreviewFile(entry, response)
} catch (error) {
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
console.warn('加载文件预览失败:', error)
previewFile.value = {
...entry,
content: `Error loading file: ${error?.message || 'unknown error'}`,
supported: false,
previewType: 'unsupported',
message: error?.message || '文件预览失败',
previewUrl: ''
}
message.error('加载文件预览失败')
} finally {
if (previewRequestId.value === requestId) {
loadingPreview.value = false
}
if (isKnowledgeSource.value) {
await loadKnowledgePreview(entry)
return
}
await loadWorkspacePreview(entry)
}
const handleSwitchKnowledgeVariant = async (variant) => {
const entry = selectedEntry.value
if (!entry || entry.source !== 'knowledge' || !entry.db_id || !entry.file_id) return
if (previewFile.value?.variant === variant || previewFile.value?.previewVariant === variant) return
await loadKnowledgePreview(entry, variant, previewFile.value || entry, KNOWLEDGE_PREVIEW_SWITCH_MESSAGES)
}
const closePreview = () => {
@ -370,6 +552,10 @@ const closePreview = () => {
}
const handleSavePreviewFile = async (content) => {
if (selectedEntry.value?.source === 'knowledge') {
message.warning('知识库文件为只读,无法保存')
return
}
if (!selectedEntry.value?.path || savingPreviewFile.value) return
savingPreviewFile.value = true
@ -524,10 +710,13 @@ const parseDownloadFilename = (contentDisposition) => {
}
const downloadEntry = async (entry) => {
if (!entry?.path || entry.is_dir) return
if (!entry || entry.is_dir) return
try {
const response = await downloadWorkspaceFile(entry.path)
const response =
entry.source === 'knowledge'
? await downloadWorkspaceKnowledgeFile(entry.db_id, entry.file_id)
: await downloadWorkspaceFile(entry.path)
const blob = await response.blob()
const contentDisposition =
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')