From a0f9b0b6fd51d107703a89b75d33c73dd9516464 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 17 May 2026 14:28:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(kb)=EF=BC=9A=E6=96=B0=E5=A2=9E=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新了 KnowledgeBaseManager 以支持新的文件管理方法。 - 在 workspace_router 中为知识库文件操作新增了端点,包括文件树列表、文件预览和文件下载。 - 在 workspace_api.js 中添加了相应的 API 调用,用于知识库交互。 - 增强了 AgentFilePreview 组件,支持预览变体之间的切换。 - 更新了导航逻辑,重定向至知识管理标签页,而非数据库视图。 - 修改了 WorkspaceView 以处理知识库条目和预览,包括加载和展示知识文件。 - 通过在文件操作期间提供加载与错误状态的反馈,改善了用户体验。 --- backend/package/yuxi/knowledge/base.py | 234 +++++++++++++- .../yuxi/knowledge/implementations/dify.py | 18 ++ backend/package/yuxi/knowledge/manager.py | 18 ++ backend/server/routers/workspace_router.py | 81 ++++- web/src/apis/workspace_api.js | 20 ++ web/src/components/AgentConfigSidebar.vue | 2 +- web/src/components/AgentFilePreview.vue | 26 +- web/src/components/DatabaseHeader.vue | 2 +- web/src/components/KnowledgeBaseCard.vue | 2 +- .../workspace/WorkspaceFileList.vue | 38 ++- .../workspace/WorkspacePreviewPane.vue | 3 +- web/src/layouts/AppLayout.vue | 16 +- web/src/router/index.js | 95 +++--- web/src/stores/database.js | 2 +- web/src/views/DataBaseView.vue | 17 +- web/src/views/ExtensionsView.vue | 19 +- web/src/views/WorkspaceView.vue | 301 ++++++++++++++---- 17 files changed, 746 insertions(+), 148 deletions(-) diff --git a/backend/package/yuxi/knowledge/base.py b/backend/package/yuxi/knowledge/base.py index 75fd2877..991ca9ec 100644 --- a/backend/package/yuxi/knowledge/base.py +++ b/backend/package/yuxi/knowledge/base.py @@ -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) diff --git a/backend/package/yuxi/knowledge/implementations/dify.py b/backend/package/yuxi/knowledge/implementations/dify.py index 09fb1b22..075f7c76 100644 --- a/backend/package/yuxi/knowledge/implementations/dify.py +++ b/backend/package/yuxi/knowledge/implementations/dify.py @@ -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 {} diff --git a/backend/package/yuxi/knowledge/manager.py b/backend/package/yuxi/knowledge/manager.py index 5cf276f7..71618e96 100644 --- a/backend/package/yuxi/knowledge/manager.py +++ b/backend/package/yuxi/knowledge/manager.py @@ -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: diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py index 4fd1d70d..59abef8e 100644 --- a/backend/server/routers/workspace_router.py +++ b/backend/server/routers/workspace_router.py @@ -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, diff --git a/web/src/apis/workspace_api.js b/web/src/apis/workspace_api.js index a5a9a264..1cf437e1 100644 --- a/web/src/apis/workspace_api.js +++ b/web/src/apis/workspace_api.js @@ -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 }) } diff --git a/web/src/components/AgentConfigSidebar.vue b/web/src/components/AgentConfigSidebar.vue index 1a8747d5..b8c2fc83 100644 --- a/web/src/components/AgentConfigSidebar.vue +++ b/web/src/components/AgentConfigSidebar.vue @@ -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' } }) diff --git a/web/src/components/AgentFilePreview.vue b/web/src/components/AgentFilePreview.vue index 48df3117..dd08564c 100644 --- a/web/src/components/AgentFilePreview.vue +++ b/web/src/components/AgentFilePreview.vue @@ -9,6 +9,18 @@ {{ filePath }}