feat(kb):新增知识库文件管理功能
- 更新了 KnowledgeBaseManager 以支持新的文件管理方法。 - 在 workspace_router 中为知识库文件操作新增了端点,包括文件树列表、文件预览和文件下载。 - 在 workspace_api.js 中添加了相应的 API 调用,用于知识库交互。 - 增强了 AgentFilePreview 组件,支持预览变体之间的切换。 - 更新了导航逻辑,重定向至知识管理标签页,而非数据库视图。 - 修改了 WorkspaceView 以处理知识库条目和预览,包括加载和展示知识文件。 - 通过在文件操作期间提供加载与错误状态的反馈,改善了用户体验。
This commit is contained in:
parent
fa2497e439
commit
a0f9b0b6fd
@ -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)
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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' } })
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -90,7 +90,7 @@ const rules = {
|
||||
}
|
||||
|
||||
const backToDatabase = () => {
|
||||
router.push('/database')
|
||||
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
|
||||
}
|
||||
|
||||
const showEditModal = () => {
|
||||
|
||||
@ -240,7 +240,7 @@ const copyDatabaseId = async () => {
|
||||
|
||||
// 返回数据库列表
|
||||
const backToDatabase = () => {
|
||||
router.push('/database')
|
||||
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
|
||||
}
|
||||
|
||||
// 编辑相关逻辑(复用自 DatabaseHeader)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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 || '删除失败')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user