From be971dbf1aedcb1e56019d8bc9f48e11ca7ef859 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Thu, 17 Sep 2026 15:41:15 +0800 Subject: [PATCH 01/59] =?UTF-8?q?=E2=9C=A8=20Feature:=20support=20AIDP=20k?= =?UTF-8?q?nowledge=20file=20deletion=20and=20download=20(#3930)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support AIDP knowledge file management Co-authored-by: Codex \nGenerated-by: Codex * fix: unblock AIDP PR quality checks Co-authored-by: Codex \nGenerated-by: Codex * fix: reduce Sonar duplication and stabilize web install Co-authored-by: Codex \nGenerated-by: Codex * test: improve AIDP file operation coverage * fix: support unicode filenames in AIDP mock download * fix: align AIDP document deletion with local KB * fix: stream AIDP document downloads * fix: preserve AIDP permission test compatibility * fix: simplify AIDP document download streaming * fix(aidp): align document removal request with API * chore: remove unrelated assistant ui dependency change * test(aidp): align mock document id type --- .../ext_components/aidp/apps/aidp_mgmt_app.py | 105 +++++++++-- .../aidp/services/aidp_service.py | 140 ++++++++++++++- .../aidp/components/AidpDocumentList.tsx | 137 ++++++++++++++- .../aidp/services/aidpKnowledgeService.ts | 137 +++++++++++---- frontend/public/locales/en/common.json | 9 + frontend/public/locales/zh/common.json | 9 + frontend/services/api.ts | 4 + .../mock_servers/aidp_mgmt_mock_server.py | 164 +++++++++++++++++- .../ext_components/aidp/test_aidp_mgmt_app.py | 155 ++++++++++++++++- test/ext_components/aidp/test_aidp_service.py | 156 ++++++++++++++++- 10 files changed, 948 insertions(+), 68 deletions(-) diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index 1b4bccefcb..3d16ab3e18 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -18,27 +18,25 @@ import time from http import HTTPStatus from typing import Annotated, List, Optional +from uuid import UUID -from fastapi import APIRouter, File, Path, Query, Request, UploadFile -from fastapi.responses import JSONResponse +from fastapi import APIRouter, File, HTTPException, Path, Query, Request, UploadFile +from fastapi.responses import JSONResponse, StreamingResponse +from nexent.core.concurrency import run_blocking from pydantic import BaseModel, Field from sqlalchemy.exc import IntegrityError -from nexent.core.concurrency import run_blocking +from starlette.background import BackgroundTask from consts.const import AIDP_API_KEY, AIDP_SERVER_URL from consts.error_code import ErrorCode from consts.exceptions import AppException, UnauthorizedError from database.user_tenant_db import get_user_role_by_tenant from ext_components.aidp.consts.aidp_exceptions import ( - AidpKbConflictError, - AidpKbNotFoundError, - AidpKbPermissionDeniedError, - AidpKbSyncError, AidpGroupValidationError, + AidpKbConflictError, ) from ext_components.aidp.database import aidp_permission_db from ext_components.aidp.services import aidp_permission_service as perms -from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings from ext_components.aidp.services.aidp_access_service import ( get_cached_aidp_doc_count, get_cached_aidp_kb_detail, @@ -47,6 +45,13 @@ invalidate_aidp_kb_detail_cache, resolve_current_aidp_access, ) +from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings +from ext_components.aidp.services.aidp_permission_service import ( + EDIT, + PRIVATE, + READ_ONLY, + _validate_group_ids_strict, # noqa: F401 - retained as a module-level compatibility symbol +) from ext_components.aidp.services.aidp_service import ( _timestamp_to_iso, count_aidp_docs_impl, @@ -55,17 +60,14 @@ get_aidp_kb_impl, list_aidp_docs_impl, list_aidp_models_impl, + remove_aidp_docs_impl, + stream_aidp_doc_impl, update_aidp_kb_impl, upload_aidp_docs_impl, ) -from ext_components.aidp.services.aidp_permission_service import ( - EDIT, - PRIVATE, - READ_ONLY, - _validate_group_ids_strict, -) from utils import auth_utils as auth_utils_module + aidp_mgmt_router = APIRouter(prefix="/aidp-mgmt") logger = logging.getLogger("aidp_mgmt_app") @@ -187,6 +189,17 @@ class SetPermissionRequest(BaseModel): ) +class RemoveAidpDocumentsRequest(BaseModel): + + file_uuids: List[UUID] = Field(..., min_length=1, description="AIDP file UUIDs") + + +class DownloadAidpDocumentRequest(BaseModel): + """AIDP file selected for download.""" + + file_uuid: UUID = Field(..., description="AIDP file UUID") + + # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- @@ -243,11 +256,6 @@ def _raise_aidp_conflict(exc: IntegrityError) -> None: ) -# HTTPException is imported lazily to keep FastAPI's exception handler in -# control of the response body. -from fastapi import HTTPException # noqa: E402 (placed here to avoid editing mid-file) - - def _credentials() -> tuple[str, str]: return AIDP_SERVER_URL, AIDP_API_KEY @@ -785,6 +793,65 @@ async def list_documents( return JSONResponse(status_code=HTTPStatus.OK, content=result) +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/remove") +async def remove_documents( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: RemoveAidpDocumentsRequest, +) -> JSONResponse: + """Remove AIDP documents.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") + + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-remove-documents", + remove_aidp_docs_impl, + server_url, + api_key, + kds_id, + [str(file_uuid) for file_uuid in body.file_uuids], + lane="control-io", + owner="config", + ) + + success_list = result["success_list"] + if success_list: + invalidate_aidp_kb_detail_cache(server_url, api_key, kds_id) + invalidate_aidp_doc_count_cache(server_url, api_key, kds_id) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/download") +async def download_document( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: DownloadAidpDocumentRequest, +) -> StreamingResponse: + """Proxy an AIDP document as a binary attachment.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + + server_url, api_key = _credentials() + aidp_response = await stream_aidp_doc_impl( + server_url, + api_key, + kds_id, + str(body.file_uuid), + ) + response_headers = { + "Content-Disposition": aidp_response.headers["Content-Disposition"], + "X-File-Size": aidp_response.headers["X-File-Size"], + } + + return StreamingResponse( + aidp_response.aiter_bytes(), + media_type=aidp_response.headers["Content-Type"], + headers=response_headers, + background=BackgroundTask(aidp_response.aclose), + ) + + @aidp_mgmt_router.patch("/aidp-permissions/{kds_id}") async def set_permission( request: Request, diff --git a/backend/ext_components/aidp/services/aidp_service.py b/backend/ext_components/aidp/services/aidp_service.py index ac5cb4d37c..9a1c145ce0 100644 --- a/backend/ext_components/aidp/services/aidp_service.py +++ b/backend/ext_components/aidp/services/aidp_service.py @@ -5,15 +5,16 @@ import logging import time from datetime import datetime, timezone -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, NoReturn from urllib.parse import urljoin import httpx +from nexent.utils.http_client_manager import http_client_manager from consts.const import AIDP_TENANT_ID from consts.error_code import ErrorCode from consts.exceptions import AppException -from nexent.utils.http_client_manager import http_client_manager + logger = logging.getLogger("aidp_service") @@ -66,6 +67,39 @@ def _extract_upstream_error(response: httpx.Response) -> str | None: return reason[:_MAX_UPSTREAM_ERROR_REASON_LENGTH] +def _raise_aidp_http_error(error: httpx.HTTPStatusError, operation: str) -> NoReturn: + """Map an AIDP HTTP error to the common application exception format.""" + response = error.response + upstream_reason = _extract_upstream_error(response) + logger.exception( + "AIDP %s HTTP error: status_code=%s upstream_reason=%s", + operation, + response.status_code, + upstream_reason or "unavailable", + ) + details = { + "upstream_status": response.status_code, + "upstream_reason": upstream_reason, + } + error_code = { + 401: ErrorCode.AIDP_AUTH_ERROR, + 403: ErrorCode.AIDP_AUTH_ERROR, + 429: ErrorCode.AIDP_RATE_LIMIT, + }.get(response.status_code, ErrorCode.AIDP_SERVICE_ERROR) + fallback_message = { + ErrorCode.AIDP_AUTH_ERROR: f"AIDP authentication failed: {str(error)}", + ErrorCode.AIDP_RATE_LIMIT: f"AIDP rate limit exceeded: {str(error)}", + }.get( + error_code, + f"AIDP API HTTP error {response.status_code}: {str(error)}", + ) + raise AppException( + error_code, + upstream_reason or fallback_message, + details=details, + ) + + def _extract_upload_failures(response: httpx.Response) -> List[Dict[str, str]]: """Extract per-file upload failures from AIDP's structured error body.""" try: @@ -183,6 +217,7 @@ def _validate_params(server_url: str, api_key: str) -> str: _AIDP_RETRY_BACKOFF_FACTOR = 0.5 _AIDP_RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504} _AIDP_READ_TIMEOUT_SECONDS = 30.0 +_AIDP_DOWNLOAD_TIMEOUT_SECONDS = 120.0 def _request_with_retry( @@ -1012,6 +1047,107 @@ def upload_aidp_docs_impl( ) +def remove_aidp_docs_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuids: List[str], +) -> Dict[str, Any]: + """Remove one or more documents from an AIDP knowledge base.""" + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + remove_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Remove" + remove_url = urljoin(f"{normalized_url}/", remove_path) + logger.info("Removing %d AIDP documents from %s", len(file_uuids), remove_url) + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=_AIDP_READ_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = _request_with_retry( + lambda: client.post( + remove_url, + headers=headers, + json={"file_uuids": file_uuids}, + ), + context=f"remove-docs:{kds_id}", + ) + response.raise_for_status() + result = response.json() + return result + except httpx.RequestError as e: + logger.exception("AIDP document removal request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + _raise_aidp_http_error(e, "document removal") + except ValueError as e: + logger.exception("Failed to parse AIDP document removal response: %s", e) + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + f"Failed to parse AIDP API response: {str(e)}", + ) + + +async def stream_aidp_doc_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuid: str, +) -> httpx.Response: + """Open a streaming response for one AIDP document.""" + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + download_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Download" + download_url = urljoin(f"{normalized_url}/", download_path) + logger.info("Downloading AIDP document %s from %s", file_uuid, download_url) + + response: httpx.Response | None = None + try: + client = http_client_manager.get_async_client( + base_url=normalized_url, + timeout=_AIDP_DOWNLOAD_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = await client.send( + client.build_request( + "POST", + download_url, + headers=headers, + json={"file_uuid": file_uuid}, + ), + stream=True, + ) + if response.status_code >= 400: + await response.aread() + response.raise_for_status() + return response + except httpx.RequestError as e: + if response is not None: + await response.aclose() + logger.exception("AIDP document download request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + if response is not None: + await response.aclose() + _raise_aidp_http_error(e, "document download") + + def count_aidp_docs_impl(server_url: str, api_key: str, kds_id: str) -> int: """Get total document count in a KB via AIDP POST .../Count endpoint. diff --git a/frontend/ext_components/aidp/components/AidpDocumentList.tsx b/frontend/ext_components/aidp/components/AidpDocumentList.tsx index 3394e4450f..ccf118f372 100644 --- a/frontend/ext_components/aidp/components/AidpDocumentList.tsx +++ b/frontend/ext_components/aidp/components/AidpDocumentList.tsx @@ -1,21 +1,34 @@ import React, { useState, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Pagination, Upload, message, Tooltip } from "antd"; -import { - UploadOutlined, - InboxOutlined, - ReloadOutlined, -} from "@ant-design/icons"; +import { Button, Modal, Pagination, Upload, message, Tooltip } from "antd"; +import { InboxOutlined, ReloadOutlined } from "@ant-design/icons"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import type { AidpDocumentItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; import { AIDP_ACCEPT_STRING } from "@/const/knowledgeBase"; +import log from "@/lib/logger"; import { partitionAidpFiles } from "@/services/uploadService"; const { Dragger } = Upload; +const resolveDownloadFilename = (response: Response, fallback: string) => { + const contentDisposition = response.headers.get("content-disposition") || ""; + const encodedName = /filename\*=UTF-8''([^;]+)/i.exec( + contentDisposition + )?.[1]; + if (encodedName) { + try { + return decodeURIComponent(encodedName); + } catch { + // Use the regular filename or document name when decoding fails. + } + } + const plainName = /filename="?([^";]+)"?/i.exec(contentDisposition)?.[1]; + return plainName || response.headers.get("x-file-name") || fallback; +}; + interface AidpDocumentListProps { activeKb: AidpKnowledgeBaseItem | null; documents: AidpDocumentItem[]; @@ -47,6 +60,10 @@ const AidpDocumentList: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [downloadingFileUuid, setDownloadingFileUuid] = useState( + null + ); // Antd fires beforeUpload once per file in a multi-select batch. // The `fileList` array may-or-may-not be the same reference across the N // calls (behavior differs between and and antd versions), @@ -56,6 +73,80 @@ const AidpDocumentList: React.FC = ({ const pendingFilesRef = useRef([]); const rafIdRef = useRef(null); + const isUnavailable = + activeKb?.resource_status === "UNAVAILABLE" || + activeKb?.resource_status === "ORPHANED"; + const canDeleteDocuments = + !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; + const canDownloadDocuments = + !!activeKb && + !isUnavailable && + (activeKb.permission === "EDIT" || activeKb.permission === "READ_ONLY"); + + const handleDownload = useCallback( + async (document: AidpDocumentItem) => { + if (!activeKb || !document.file_uuid) return; + setDownloadingFileUuid(document.file_uuid); + try { + const response = await aidpKnowledgeService.downloadDoc( + activeKb.kds_id, + document.file_uuid + ); + const blob = await response.blob(); + const downloadUrl = URL.createObjectURL(blob); + const link = window.document.createElement("a"); + link.href = downloadUrl; + link.download = resolveDownloadFilename(response, document.file_name); + link.click(); + URL.revokeObjectURL(downloadUrl); + message.success(t("aidpKnowledge.downloadSuccess")); + } catch (error) { + log.error("Failed to download AIDP document:", error); + message.error(t("aidpKnowledge.downloadFailed")); + } finally { + setDownloadingFileUuid(null); + } + }, + [activeKb, t] + ); + + const handleDelete = useCallback( + (document: AidpDocumentItem) => { + if (!activeKb || !document.file_uuid) return; + Modal.confirm({ + title: t("aidpKnowledge.confirmDeleteDocTitle"), + content: t("aidpKnowledge.confirmDeleteDocContent"), + okText: t("common.confirm"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + setDeleting(true); + try { + const result = await aidpKnowledgeService.removeDoc( + activeKb.kds_id, + document.file_uuid + ); + if (result.summary.success > 0) { + message.success(t("aidpKnowledge.deleteDocSuccess")); + } else { + message.error(t("aidpKnowledge.deleteDocFailed")); + } + if (result.summary.success > 0) { + onDocsUploaded(); + } + } catch (error) { + log.error("Failed to delete AIDP document:", error); + message.error(t("aidpKnowledge.deleteDocFailed")); + } finally { + setDeleting(false); + } + }, + }); + }, + [activeKb, onDocsUploaded, t] + ); + const handleUpload = useCallback( async (fileList: File[]) => { if (!activeKb) return; @@ -180,11 +271,17 @@ const AidpDocumentList: React.FC = ({ {t("aidpKnowledge.docCreatedAt")} + + {t("aidpKnowledge.docActions")} + {documents.map((doc) => ( - +
= ({ ? new Date(doc.created_at).toLocaleString() : "-"} + +
+ {canDownloadDocuments && ( + + )} + {canDeleteDocuments && ( + + )} +
+ ))} diff --git a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts index 4059242337..d2017d5fe9 100644 --- a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts +++ b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts @@ -1,7 +1,7 @@ /** * AIDP Knowledge Base Management Service * - * Wraps the 8 AIDP management backend endpoints. + * Wraps the AIDP management backend endpoints. * Credentials (server_url, api_key) are read by the backend from environment variables. */ @@ -29,15 +29,12 @@ export interface AidpKbDetail { ingroup_permission?: "EDIT" | "READ_ONLY" | "PRIVATE"; group_ids?: number[]; resource_status?: - | "ACTIVE" - | "CREATING" - | "DELETE_PENDING" - | "ORPHANED" - | "UNAVAILABLE"; + "ACTIVE" | "CREATING" | "DELETE_PENDING" | "ORPHANED" | "UNAVAILABLE"; } export interface AidpDocumentItem { - file_ino_no: string; + file_uuid: string; + file_ino_no: number; file_name: string; file_size?: number; file_type?: string; @@ -55,6 +52,7 @@ export interface AidpDocumentListResponse { } export interface AidpUploadSuccessItem { + file_uuid: string; file_name: string; file_type: string; file_size: number; @@ -78,6 +76,62 @@ export interface AidpUploadResponse { failed_list: AidpUploadFailedItem[]; } +export interface AidpDocumentOperationItem { + file_uuid: string; +} + +export interface AidpDocumentRemoveResponse { + summary: { + total: number; + success: number; + failed: number; + }; + success_list: AidpDocumentOperationItem[]; + failed_list: AidpDocumentOperationItem[]; +} + +type AidpOperationSummary = { + total: number; + success: number; + failed: number; +}; + +type AidpOperationResponse = { + summary: AidpOperationSummary; + success_list: TSuccess[]; + failed_list: TFailure[]; +}; + +const normalizeAidpOperationResponse = ( + result: Partial> +): AidpOperationResponse => { + const successList: TSuccess[] = Array.isArray(result.success_list) + ? result.success_list + : []; + const failedList: TFailure[] = Array.isArray(result.failed_list) + ? result.failed_list + : []; + + return { + summary: { + total: + typeof result.summary?.total === "number" + ? result.summary.total + : successList.length + failedList.length, + success: + typeof result.summary?.success === "number" + ? result.summary.success + : successList.length, + failed: + typeof result.summary?.failed === "number" + ? result.summary.failed + : failedList.length, + }, + success_list: successList, + failed_list: failedList, + }; +}; + export interface AidpModelItem { /** Display / identifier used for the model (sent to AIDP as ``vlm_model``). */ model_name: string; @@ -346,31 +400,10 @@ class AidpKnowledgeService { } const result = (await response.json()) as Partial; - const successList = Array.isArray(result.success_list) - ? result.success_list - : []; - const failedList = Array.isArray(result.failed_list) - ? result.failed_list - : []; - - return { - summary: { - total: - typeof result.summary?.total === "number" - ? result.summary.total - : successList.length + failedList.length, - success: - typeof result.summary?.success === "number" - ? result.summary.success - : successList.length, - failed: - typeof result.summary?.failed === "number" - ? result.summary.failed - : failedList.length, - }, - success_list: successList, - failed_list: failedList, - }; + return normalizeAidpOperationResponse< + AidpUploadSuccessItem, + AidpUploadFailedItem + >(result); } /** @@ -453,6 +486,46 @@ class AidpKnowledgeService { : typeof result.total_count === "number", }; } + + /** + * Remove one document from an AIDP knowledge base. + * The AIDP API accepts an array, so the single-document UI sends one item. + */ + async removeDoc( + id: string, + fileUuid: string + ): Promise { + const url = buildUrl(API_ENDPOINTS.aidpMgmt.removeKbDocuments(id), {}); + const response = await fetchWithErrorHandling(url, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ file_uuids: [fileUuid] }), + }); + const result = + (await response.json()) as Partial; + return normalizeAidpOperationResponse< + AidpDocumentOperationItem, + AidpDocumentOperationItem + >(result); + } + + /** + * Download one document through the AIDP management backend. + */ + async downloadDoc(id: string, fileUuid: string): Promise { + const url = buildUrl(API_ENDPOINTS.aidpMgmt.downloadKbDocument(id), {}); + return fetchWithErrorHandling(url, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ file_uuid: fileUuid }), + }); + } } const aidpKnowledgeService = new AidpKnowledgeService(); diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index fc1c438c82..3235fcee1c 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -4003,6 +4003,15 @@ "aidpKnowledge.docType": "Type", "aidpKnowledge.docSize": "Size", "aidpKnowledge.docCreatedAt": "Created At", + "aidpKnowledge.docActions": "Actions", + "aidpKnowledge.download": "Download", + "aidpKnowledge.delete": "Delete", + "aidpKnowledge.downloadSuccess": "File download started", + "aidpKnowledge.downloadFailed": "Failed to download file", + "aidpKnowledge.confirmDeleteDocTitle": "Delete document", + "aidpKnowledge.confirmDeleteDocContent": "Are you sure you want to delete this document? This cannot be undone.", + "aidpKnowledge.deleteDocSuccess": "Document deleted successfully", + "aidpKnowledge.deleteDocFailed": "Failed to delete document", "aidpKnowledge.noDocuments": "No documents yet", "aidpKnowledge.loadingDocs": "Loading documents...", "aidpKnowledge.uploadSuccess": "{{count}} document(s) uploaded successfully", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index b5f66b9d19..e076ce6bca 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -3718,6 +3718,15 @@ "aidpKnowledge.docType": "类型", "aidpKnowledge.docSize": "大小", "aidpKnowledge.docCreatedAt": "创建时间", + "aidpKnowledge.docActions": "操作", + "aidpKnowledge.download": "下载", + "aidpKnowledge.delete": "删除", + "aidpKnowledge.downloadSuccess": "文件下载已开始", + "aidpKnowledge.downloadFailed": "文件下载失败", + "aidpKnowledge.confirmDeleteDocTitle": "删除文档", + "aidpKnowledge.confirmDeleteDocContent": "确定删除该文档吗?此操作无法撤销。", + "aidpKnowledge.deleteDocSuccess": "文档删除成功", + "aidpKnowledge.deleteDocFailed": "文档删除失败", "aidpKnowledge.noDocuments": "暂无文档", "aidpKnowledge.loadingDocs": "正在加载文档...", "aidpKnowledge.uploadSuccess": "成功上传 {{count}} 个文档", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index a6fa45e0cf..b1c1336288 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -377,6 +377,10 @@ export const API_ENDPOINTS = { kbDetail: (id: string) => `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}`, kbDocuments: (id: string) => `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents`, + removeKbDocuments: (id: string) => + `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents/remove`, + downloadKbDocument: (id: string) => + `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents/download`, models: `${API_BASE_URL}/aidp-mgmt/models`, /** PATCH endpoint for the per-KB in-group permission. */ kbPermission: (id: string) => diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index 742133bdf9..e6cf7031d3 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -9,6 +9,8 @@ - DELETE /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id} (delete) - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Upload (upload docs) - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles (list docs) + - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Remove (remove docs) + - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Download (download doc) - POST /KnowledgeBase/Tenants/{tenant}/Retrieval/FusionSearch (search - preserved from reference) Knowledge base + document state is persisted to ``_state/knowledge_bases.json`` @@ -22,16 +24,18 @@ import argparse import json import logging +import mimetypes import os import time import uuid from pathlib import Path from typing import Any, Dict, List, Literal, Optional +from urllib.parse import quote from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, StreamingResponse logger = logging.getLogger("aidp_mgmt_mock") logging.basicConfig( @@ -118,14 +122,16 @@ def _seed_initial_data() -> None: # Seed some documents for the FAQ KB so list_docs is non-empty by default. _DOCUMENTS_BY_KB["aidp-kb-faq"] = [ { - "file_ino_no": "file-faq-001", + "file_uuid": "00000000-0000-4000-8000-000000000001", + "file_ino_no": 1001, "file_name": "常见问题汇总.txt", "file_size": 2048, "file_type": "txt", "create_time": 1718000400, }, { - "file_ino_no": "file-faq-002", + "file_uuid": "00000000-0000-4000-8000-000000000002", + "file_ino_no": 1002, "file_name": "troubleshooting.md", "file_size": 4096, "file_type": "md", @@ -192,7 +198,55 @@ def _load_state() -> None: ) +def _ensure_document_uuids() -> None: + """Backfill stable UUIDs for state created before UUID support existed.""" + for kds_id, documents in _DOCUMENTS_BY_KB.items(): + if not isinstance(documents, list): + continue + for document in documents: + if not isinstance(document, dict) or document.get("file_uuid"): + continue + file_ino_no = str(document.get("file_ino_no") or uuid.uuid4()) + document["file_uuid"] = str( + uuid.uuid5(uuid.NAMESPACE_URL, f"mock-aidp:{kds_id}:{file_ino_no}") + ) + + _load_state() +_ensure_document_uuids() +_save_state() + + +def _public_document(document: Dict[str, Any]) -> Dict[str, Any]: + """Return document metadata without any mock-only private fields.""" + return {key: value for key, value in document.items() if not key.startswith("_")} + + +def _find_document(kds_id: str, file_uuid: str) -> Optional[Dict[str, Any]]: + return next( + ( + document + for document in _DOCUMENTS_BY_KB.get(kds_id, []) + if document.get("file_uuid") == file_uuid + ), + None, + ) + + +def _document_content(document: Dict[str, Any]) -> bytes: + """Build deterministic mock content for a document download.""" + return ( + f"Mock AIDP content for {document.get('file_name', 'download')}\n" + ).encode("utf-8") + + +def _content_disposition(filename: str) -> str: + """Build a standard ASCII fallback plus RFC 5987 UTF-8 filename header.""" + ascii_name = "".join( + char if 32 <= ord(char) < 127 and char not in {'"', "\\"} else "_" + for char in filename + ) + return f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quote(filename)}' # ============================================================================= @@ -230,6 +284,14 @@ class FusionSearchRequest(BaseModel): metadata_condition: Optional[MetadataCondition] = None +class RemoveFilesBody(BaseModel): + file_uuids: List[uuid.UUID] = Field(..., min_length=1) + + +class DownloadFileBody(BaseModel): + file_uuid: uuid.UUID = Field(...) + + # ============================================================================= # Auth helper # ============================================================================= @@ -503,8 +565,16 @@ async def upload_documents( for f in files: try: content = await f.read() - file_ino_no = f"file-{uuid.uuid4().hex[:12]}" + file_ino_no = max( + ( + document["file_ino_no"] + for document in _DOCUMENTS_BY_KB.get(kds_id, []) + if isinstance(document.get("file_ino_no"), int) + ), + default=0, + ) + 1 doc = { + "file_uuid": str(uuid.uuid4()), "file_ino_no": file_ino_no, "file_name": f.filename or "unknown", "file_size": len(content), @@ -535,6 +605,7 @@ async def upload_documents( "file_type": doc["file_type"], "file_size": doc["file_size"], "file_ino_no": doc["file_ino_no"], + "file_uuid": doc["file_uuid"], "first_upload_time": doc["create_time"], } for doc in success_docs @@ -559,7 +630,7 @@ def list_documents( all_docs = _DOCUMENTS_BY_KB.get(kds_id, []) start = (page - 1) * page_size end = start + page_size - items = all_docs[start:end] + items = [_public_document(doc) for doc in all_docs[start:end]] # Real AIDP returns `next_link` as the authoritative "more pages exist" # signal. When there are no more docs, next_link is simply absent. @@ -576,6 +647,89 @@ def list_documents( }) +@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/Remove") +def remove_documents( + kds_id: str, + body: RemoveFilesBody, + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """Remove documents by file UUID and return per-file success/failure lists.""" + _check_auth(authorization) + + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + + documents = _DOCUMENTS_BY_KB.setdefault(kds_id, []) + remaining = list(documents) + success_list: List[Dict[str, str]] = [] + failed_list: List[Dict[str, str]] = [] + for raw_file_uuid in body.file_uuids: + file_uuid = str(raw_file_uuid) + matched = next( + (document for document in remaining if document.get("file_uuid") == file_uuid), + None, + ) + if matched is None: + failed_list.append({"file_uuid": file_uuid}) + continue + remaining.remove(matched) + success_list.append({"file_uuid": file_uuid}) + + _DOCUMENTS_BY_KB[kds_id] = remaining + _save_state() + logger.info( + "REMOVE DOCS kds_id=%s total=%d success=%d failed=%d", + kds_id, + len(body.file_uuids), + len(success_list), + len(failed_list), + ) + return JSONResponse(content={ + "summary": { + "total": len(body.file_uuids), + "success": len(success_list), + "failed": len(failed_list), + }, + "success_list": success_list, + "failed_list": failed_list, + }) + + +@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/Download") +def download_document( + kds_id: str, + body: DownloadFileBody, + authorization: Optional[str] = Header(default=None), +) -> StreamingResponse: + """Return deterministic binary content for a document download.""" + _check_auth(authorization) + + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + + file_uuid = str(body.file_uuid) + document = _find_document(kds_id, file_uuid) + if document is None: + raise HTTPException(status_code=404, detail=f"File {file_uuid} not found") + + filename = str(document.get("file_name") or "download") + content = _document_content(document) + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + response_headers = { + "Content-Disposition": _content_disposition(filename), + "X-File-Size": str(len(content)), + } + async def content_stream(): + for offset in range(0, len(content), 8 * 1024): + yield content[offset : offset + 8 * 1024] + + return StreamingResponse( + content_stream(), + media_type=content_type, + headers=response_headers, + ) + + # ============================================================================= # FusionSearch (preserved from reference mock) # ============================================================================= diff --git a/test/ext_components/aidp/test_aidp_mgmt_app.py b/test/ext_components/aidp/test_aidp_mgmt_app.py index 00ca05099a..3328eea3f7 100644 --- a/test/ext_components/aidp/test_aidp_mgmt_app.py +++ b/test/ext_components/aidp/test_aidp_mgmt_app.py @@ -21,6 +21,7 @@ from typing import Any from unittest.mock import MagicMock, patch +import httpx import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -46,6 +47,11 @@ def _mod(name): nexent_storage_factory = _mod("nexent.storage.storage_client_factory") nexent_storage_factory.create_storage_client_from_config = MagicMock() +services_pkg = _mod("services") +services_pkg.__path__ = [os.path.join(BACKEND_DIR, "services")] +tag_management_service = _mod("services.tag_management_service") +tag_management_service.TagManagementService = MagicMock() + class _MinIOStorageConfig: def __init__(self, **kwargs): @@ -55,7 +61,7 @@ def __init__(self, **kwargs): nexent_storage_factory.MinIOStorageConfig = _MinIOStorageConfig for mod in (nexent_pkg, nexent_utils, nexent_http_mgr, nexent_storage, - nexent_storage_factory): + nexent_storage_factory, services_pkg, tag_management_service): sys.modules.setdefault(mod.__name__, mod) # Register non-prefixed ``database`` / ``database.client`` stubs so that @@ -801,6 +807,153 @@ def count_docs(*_args, **_kwargs): assert response.json()["total_count"] == 1 +# --- Remove/download documents ------------------------------------------- + + +class TestAidpDocumentFileOperations: + def test_remove_forwards_only_uuids(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + aidp_result = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "success_list": [{"file_uuid": "00000000-0000-4000-8000-000000000001"}], + "failed_list": [{"file_uuid": "00000000-0000-4000-8000-000000000002"}], + } + with patch.object( + aidp_permission_service, + "require_permission", + return_value=MagicMock(permission="EDIT"), + ), patch.object( + aidp_mgmt_app, + "remove_aidp_docs_impl", + return_value=aidp_result, + ) as mock_remove: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={ + "file_uuids": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ] + }, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == aidp_result + assert mock_remove.call_args.args[3] == [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ] + + def test_remove_requires_file_uuids(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + + with patch.object(aidp_mgmt_app, "remove_aidp_docs_impl") as mock_remove: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={}, + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + mock_remove.assert_not_called() + + def test_remove_requires_standard_file_uuid(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + + with patch.object(aidp_mgmt_app, "remove_aidp_docs_impl") as mock_remove: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={"file_uuids": ["uuid-1"]}, + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + mock_remove.assert_not_called() + + def test_download_returns_binary_response(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + mock_response = httpx.Response( + 200, + headers={ + "Content-Type": "text/plain", + "Content-Disposition": 'attachment; filename="a.txt"', + "X-File-Size": "5", + }, + content=b"hello", + request=httpx.Request("POST", SERVER_URL), + ) + + async def stream_document(*args): + return mock_response + + with patch.object( + aidp_permission_service, + "require_permission", + return_value=MagicMock(permission="READ_ONLY"), + ), patch.object( + aidp_mgmt_app, + "stream_aidp_doc_impl", + side_effect=stream_document, + ) as mock_download: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/download", + headers=_bearer(), + json={"file_uuid": "00000000-0000-4000-8000-000000000001"}, + ) + + assert response.status_code == HTTPStatus.OK + assert response.content == b"hello" + assert response.headers["content-type"].startswith("text/plain") + assert response.headers["content-disposition"] == 'attachment; filename="a.txt"' + assert response.headers["x-file-size"] == "5" + assert mock_download.call_args.args == ( + SERVER_URL, + API_KEY, + "kb-1", + "00000000-0000-4000-8000-000000000001", + ) + assert "x-file-name" not in response.headers + + def test_remove_does_not_invalidate_cache_when_no_file_succeeds(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + aidp_result = { + "summary": {"total": 1, "success": 0, "failed": 1}, + "success_list": [], + "failed_list": [{"file_uuid": "00000000-0000-4000-8000-000000000001"}], + } + with patch.object( + aidp_permission_service, + "require_permission", + return_value=MagicMock(permission="EDIT"), + ), patch.object( + aidp_mgmt_app, + "remove_aidp_docs_impl", + return_value=aidp_result, + ), patch.object(aidp_mgmt_app, "invalidate_aidp_kb_detail_cache") as mock_kb_cache, patch.object( + aidp_mgmt_app, "invalidate_aidp_doc_count_cache" + ) as mock_count_cache: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={"file_uuids": ["00000000-0000-4000-8000-000000000001"]}, + ) + + assert response.status_code == HTTPStatus.OK + mock_kb_cache.assert_not_called() + mock_count_cache.assert_not_called() + # --- Models list (auth only, no per-KB permission) ------------------------ diff --git a/test/ext_components/aidp/test_aidp_service.py b/test/ext_components/aidp/test_aidp_service.py index e8a6c67906..46274d94af 100644 --- a/test/ext_components/aidp/test_aidp_service.py +++ b/test/ext_components/aidp/test_aidp_service.py @@ -2,7 +2,7 @@ import os import sys from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -1091,6 +1091,16 @@ def _setup_mock_client(aidp_service_module, method="get", response=None, side_ef return mock_client +def _setup_mock_async_client(aidp_service_module, response=None, side_effect=None): + """Create and wire an async mock client into the service module manager.""" + mock_client = MagicMock() + mock_client.send = AsyncMock(side_effect=side_effect, return_value=response) + mock_manager = MagicMock() + mock_manager.get_async_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + return mock_client + + def _make_http_error(status_code, method="GET"): """Create an httpx.HTTPStatusError with given status code.""" request = httpx.Request(method, "http://127.0.0.1:30081") @@ -1995,7 +2005,7 @@ def test_invalid_config(self, aidp_service_module, server_url, api_key): def test_success_normalizes_docs(self, aidp_service_module): mock_resp = _make_success_response({ "value": [ - {"name": "doc1", "first_upload_time": 1700000000}, + {"name": "doc1", "file_uuid": "uuid-1", "first_upload_time": 1700000000}, {"name": "doc2", "create_time": 1700100000, "update_time": 1700200000}, ], "total_count": 2, @@ -2012,6 +2022,7 @@ def test_success_normalizes_docs(self, aidp_service_module): assert len(result["value"]) == 2 # Normalization adds created_at / updated_at assert result["value"][0]["created_at"] is not None + assert result["value"][0]["file_uuid"] == "uuid-1" assert result["value"][1]["updated_at"] is not None def test_success_non_list_value_not_normalized(self, aidp_service_module): @@ -2081,6 +2092,147 @@ def test_json_parse_value_error(self, aidp_service_module): assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR +# --------------------------------------------------------------------------- +# remove_aidp_docs_impl / download_aidp_doc_impl tests +# --------------------------------------------------------------------------- +class TestAidpDocumentFileOperations: + def test_remove_sends_uuid_array_and_preserves_partial_result( + self, aidp_service_module + ): + expected = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "success_list": [{"file_uuid": "uuid-1"}], + "failed_list": [{"file_uuid": "uuid-2"}], + } + mock_client = _setup_mock_client( + aidp_service_module, + method="post", + response=_make_success_response(expected), + ) + + result = aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", + "jwt-token", + "kb-1", + ["uuid-1", "uuid-2"], + ) + + assert result == expected + call = mock_client.post.call_args + assert call.args[0].endswith( + "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Remove" + ) + assert call.kwargs["json"] == {"file_uuids": ["uuid-1", "uuid-2"]} + + def test_remove_maps_request_error(self, aidp_service_module): + request = httpx.Request("POST", "http://127.0.0.1:30081") + _setup_mock_client( + aidp_service_module, + method="post", + side_effect=httpx.RequestError("network down", request=request), + ) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR + + def test_remove_maps_invalid_json(self, aidp_service_module): + mock_response = _make_success_response({}) + mock_response.json.side_effect = ValueError("bad json") + _setup_mock_client(aidp_service_module, method="post", response=mock_response) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] + ) + assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR + + @pytest.mark.parametrize("status_code", [401, 403, 500]) + def test_remove_maps_upstream_http_errors(self, aidp_service_module, status_code): + _setup_mock_client( + aidp_service_module, + method="post", + side_effect=_make_http_error(status_code, "POST"), + ) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] + ) + expected_code = ( + ErrorCode.AIDP_AUTH_ERROR + if status_code in (401, 403) + else ErrorCode.AIDP_SERVICE_ERROR + ) + assert exc_info.value.error_code == expected_code + + @pytest.mark.asyncio + async def test_download_streams_binary_content_and_headers(self, aidp_service_module): + mock_response = httpx.Response( + 200, + headers={ + "Content-Type": "text/plain", + "Content-Disposition": 'attachment; filename="a.txt"', + "X-File-Size": "16", + }, + content=b"downloaded bytes", + request=httpx.Request("POST", "http://127.0.0.1:30081"), + ) + mock_client = _setup_mock_async_client(aidp_service_module, response=mock_response) + + response = await aidp_service_module.stream_aidp_doc_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" + ) + chunks = [chunk async for chunk in response.aiter_bytes()] + + assert b"".join(chunks) == b"downloaded bytes" + assert response.headers["Content-Type"] == "text/plain" + assert response.headers["Content-Disposition"] == 'attachment; filename="a.txt"' + assert response.headers["X-File-Size"] == "16" + request = mock_client.build_request.call_args + assert request.args[0] == "POST" + assert request.args[1].endswith( + "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Download" + ) + assert request.kwargs["json"] == {"file_uuid": "uuid-1"} + await response.aclose() + assert mock_response.is_closed + + @pytest.mark.asyncio + async def test_download_maps_request_error(self, aidp_service_module): + request = httpx.Request("POST", "http://127.0.0.1:30081") + _setup_mock_async_client( + aidp_service_module, + side_effect=httpx.RequestError("network down", request=request), + ) + + with pytest.raises(AppException) as exc_info: + await aidp_service_module.stream_aidp_doc_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR + + @pytest.mark.asyncio + async def test_download_maps_upstream_http_error(self, aidp_service_module): + response = httpx.Response( + 404, + json={"error": "file not found"}, + request=httpx.Request("POST", "http://127.0.0.1:30081"), + ) + _setup_mock_async_client( + aidp_service_module, + response=response, + ) + + with pytest.raises(AppException) as exc_info: + await aidp_service_module.stream_aidp_doc_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" + ) + assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR + + # --------------------------------------------------------------------------- # list_aidp_models_impl tests # --------------------------------------------------------------------------- From 5f4a517d30514213c53b20be93128976e4e564db Mon Sep 17 00:00:00 2001 From: hzwllm <79397354+hzwllm@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:06:46 +0800 Subject: [PATCH 02/59] Feature: Knowledge base interface optimization (#3909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: 知识库界面优化 * Fix: 修复知识库门禁问题 --------- Co-authored-by: hzw --- .../references/design-template.md | 1 - .../knowledges/KnowledgeBaseConfiguration.tsx | 486 +++++---- .../components/document/DocumentChunk.tsx | 62 +- .../components/document/DocumentList.tsx | 858 +++++++++------ .../knowledge/KnowledgeBaseList.tsx | 860 ++++++++------- .../PersonalKnowledgeBaseCapacityBar.tsx | 26 + .../components/upload/UploadArea.tsx | 51 +- .../components/upload/UploadAreaUI.tsx | 595 +++++++---- .../contexts/KnowledgeBaseContext.tsx | 127 +-- frontend/components/tag/ResourceTagChips.tsx | 9 +- frontend/components/tag/TagChips.tsx | 95 +- .../aidp/components/AidpCreateKbModal.tsx | 997 +++++++----------- .../aidp/components/AidpDocumentList.tsx | 300 +++--- .../AidpKnowledgeBaseModalParts.tsx | 162 +++ .../components/AidpKnowledgeConfiguration.tsx | 98 +- .../aidp/components/AidpKnowledgeList.tsx | 499 +++++---- .../aidp/components/AidpUpdateKbModal.tsx | 201 ++-- .../aidp/hooks/useAidpGroupOptions.ts | 30 + .../aidp/services/aidpUploadUtils.ts | 15 + frontend/lib/knowledgeBaseQuota.ts | 54 + frontend/public/locales/en/common.json | 36 + frontend/public/locales/zh/common.json | 36 + 22 files changed, 3128 insertions(+), 2470 deletions(-) create mode 100644 frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx create mode 100644 frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts create mode 100644 frontend/ext_components/aidp/services/aidpUploadUtils.ts create mode 100644 frontend/lib/knowledgeBaseQuota.ts diff --git a/.agents/skills/nexent-spec-coding/references/design-template.md b/.agents/skills/nexent-spec-coding/references/design-template.md index e5ef6befe3..9f826fde9c 100644 --- a/.agents/skills/nexent-spec-coding/references/design-template.md +++ b/.agents/skills/nexent-spec-coding/references/design-template.md @@ -67,4 +67,3 @@ Reference `proposal.md` for motivation, scope and AC definitions. Follow [D1 tes ## Open Questions - diff --git a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx index 2a78cb10da..cbc3a4b5ae 100644 --- a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx +++ b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx @@ -1,6 +1,7 @@ "use client"; import type React from "react"; +import type { TFunction } from "i18next"; import { useState, useEffect, @@ -11,11 +12,11 @@ import { } from "react"; import { useTranslation } from "react-i18next"; -import { App, Modal, Row, Col, theme, Button, Input } from "antd"; +import { App, Modal, theme, Button, Input } from "antd"; import { + ArrowLeftOutlined, ExclamationCircleFilled, WarningFilled, - InfoCircleFilled, } from "@ant-design/icons"; import { DOCUMENT_ACTION_TYPES, @@ -32,11 +33,11 @@ import { isKnowledgeBaseFileSizeValid } from "@/services/uploadService"; import { KnowledgeBase } from "@/types/knowledgeBase"; import { useConfig } from "@/hooks/useConfig"; import { useModelList } from "@/hooks/model/useModelList"; -import { - SETUP_PAGE_CONTAINER, - TWO_COLUMN_LAYOUT, - STANDARD_CARD, -} from "@/const/layoutConstants"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { SETUP_PAGE_CONTAINER, STANDARD_CARD } from "@/const/layoutConstants"; +import { QUOTA_USAGE_CHANGED_EVENT } from "@/lib/quotaEvents"; +import quotaService from "@/services/quotaService"; +import type { KBQuotaStatus, QuotaUsageResponse } from "@/types/quota"; import KnowledgeBaseList from "./components/knowledge/KnowledgeBaseList"; import DocumentList from "./components/document/DocumentList"; @@ -60,6 +61,39 @@ const isApiErrorCode = (error: unknown, code: string | number): boolean => "code" in error && String((error as { code?: unknown }).code) === String(code); +const getKnowledgeBaseCreateErrorMessage = ( + error: unknown, + name: string, + t: TFunction +) => { + if (isApiErrorCode(error, 409)) { + return t("knowledgeBase.message.nameExists", { name }); + } + if (isApiErrorCode(error, ErrorCode.TENANT_PERSONAL_KB_QUOTA_EXCEEDED)) { + return t("quota.personalKbUploadBlocked"); + } + if (isApiErrorCode(error, ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE)) { + return t(`errorCode.${ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE}`); + } + if (isApiErrorCode(error, 413)) { + return t("quota.uploadBlocked"); + } + return t("knowledgeBase.message.createUploadError"); +}; + +const getKnowledgeBaseUploadErrorMessage = (error: unknown, t: TFunction) => { + if (isApiErrorCode(error, ErrorCode.TENANT_PERSONAL_KB_QUOTA_EXCEEDED)) { + return t("quota.personalKbUploadBlocked"); + } + if (isApiErrorCode(error, ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE)) { + return t(`errorCode.${ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE}`); + } + if (isApiErrorCode(error, 413)) { + return t("quota.uploadBlocked"); + } + return t("document.message.uploadError"); +}; + const toEmbeddingModelOptionValue = (displayName: string, type: string) => `${displayName}${EMBEDDING_MODEL_OPTION_DELIMITER}${type}`; @@ -87,43 +121,6 @@ const parseEmbeddingModelOptionValue = (value: string) => { }; }; -// EmptyState component defined directly in this file -interface EmptyStateProps { - icon?: React.ReactNode | string; - title: string; - description?: string; - action?: React.ReactNode; - containerHeight?: string; -} - -const EmptyState: React.FC = ({ - icon = "馃搵", - title, - description, - action, - containerHeight = "100%", -}) => { - return ( -
-
- {typeof icon === "string" ? ( -
{icon}
- ) : ( -
{icon}
- )} -

{title}

- {description && ( -

{description}

- )} - {action &&
{action}
} -
-
- ); -}; - // Combined AppProvider implementation interface AppProviderProps { children: React.ReactNode; @@ -176,6 +173,43 @@ function DataConfig({ isActive }: DataConfigProps) { saveConfig, } = useConfig(); const { token } = theme.useToken(); + const { user } = useAuthorizationContext(); + + const [quotaUsage, setQuotaUsage] = useState(null); + + const loadQuotaUsage = useCallback( + async (forceRefresh = false) => { + if (!user?.tenantId) { + setQuotaUsage(null); + return; + } + + try { + const usage = await quotaService.getQuotaUsage( + user.tenantId, + forceRefresh, + true + ); + setQuotaUsage(usage); + } catch (error) { + log.warn("Failed to load knowledge base quota usage:", error); + } + }, + [user?.tenantId] + ); + + useEffect(() => { + void loadQuotaUsage(); + const handleQuotaUsageChanged = () => { + void loadQuotaUsage(true); + }; + window.addEventListener(QUOTA_USAGE_CHANGED_EVENT, handleQuotaUsageChanged); + return () => + window.removeEventListener( + QUOTA_USAGE_CHANGED_EVENT, + handleQuotaUsageChanged + ); + }, [loadQuotaUsage]); // Get available embedding models for knowledge base creation const { models } = useModelList({ enabled: true }); @@ -216,7 +250,6 @@ function DataConfig({ isActive }: DataConfigProps) { deleteKnowledgeBase, setActiveKnowledgeBase, updateKnowledgeBase, - hasKnowledgeBaseModelMismatch, refreshKnowledgeBaseData, refreshKnowledgeBaseDataWithDataMate, dispatch: kbDispatch, @@ -238,6 +271,7 @@ function DataConfig({ isActive }: DataConfigProps) { // Create mode state const [isCreatingMode, setIsCreatingMode] = useState(false); const [newKbName, setNewKbName] = useState(""); + const [newKbDescription, setNewKbDescription] = useState(""); const [newKbIngroupPermission, setNewKbIngroupPermission] = useState("READ_ONLY"); const [newKbGroupIds, setNewKbGroupIds] = useState([]); @@ -357,26 +391,6 @@ function DataConfig({ isActive }: DataConfigProps) { }); }, [models]); - const hasKnowledgeBaseDetailModelMismatch = useCallback( - (knowledgeBase: KnowledgeBase) => { - if (hasKnowledgeBaseModelMismatch(knowledgeBase)) { - return true; - } - if ( - knowledgeBase.embeddingModel === "unknown" || - knowledgeBase.source === "datamate" - ) { - return false; - } - - const knowledgeBaseModel = knowledgeBase.embeddingModel.trim(); - return !availableEmbeddingModels.some( - (model) => model.displayName.trim() === knowledgeBaseModel - ); - }, - [availableEmbeddingModels, hasKnowledgeBaseModelMismatch] - ); - const resolveEmbeddingModelId = useCallback( ({ displayName, @@ -444,66 +458,13 @@ function DataConfig({ isActive }: DataConfigProps) { ]); // User configuration loading and saving logic based on isActive state - const prevIsActiveRef = useRef(null); // Initialize as null to distinguish first render - const hasLoadedRef = useRef(false); // Track whether configuration has been loaded - const hasCleanedRef = useRef(false); // Ensure auto-deselect runs only once per entry - // Listen for isActive state changes useLayoutEffect(() => { // Clear cache that might affect state localStorage.removeItem("preloaded_kb_data"); localStorage.removeItem("kb_cache"); - - const prevIsActive = prevIsActiveRef.current; - - // Mark ready to load when entering second page - if ((prevIsActive === null || !prevIsActive) && isActive) { - hasLoadedRef.current = false; // Reset loading state - hasCleanedRef.current = false; // Reset auto-clean flag on entering - } - - // Update ref - prevIsActiveRef.current = isActive; }, [isActive]); - // Separately listen for knowledge base loading state, load user configuration when knowledge base loading is complete and in active state - useEffect(() => { - // Only execute when second page is active, knowledge base is loaded, and user configuration hasn't been loaded yet - if ( - isActive && - kbState.knowledgeBases.length > 0 && - !kbState.isLoading && - !hasLoadedRef.current - ) { - hasLoadedRef.current = true; - } - }, [isActive, kbState.knowledgeBases.length, kbState.isLoading]); - - // Auto-deselect incompatible knowledge bases once after selections are loaded and page is active - useEffect(() => { - if (!isActive) return; - if (!hasLoadedRef.current) return; // ensure user selections loaded - if (kbState.isLoading) return; // avoid running during list loading - if (hasCleanedRef.current) return; // run once per entry - - const embeddingName = modelConfig?.embedding?.displayName?.trim() || ""; - const multiEmbeddingName = - modelConfig?.multiEmbedding?.displayName?.trim() || ""; - - const allowedModels = new Set(); - if (embeddingName) allowedModels.add(embeddingName); - if (multiEmbeddingName) allowedModels.add(multiEmbeddingName); - - hasCleanedRef.current = true; - }, [ - isActive, - kbState.isLoading, - kbState.knowledgeBases, - modelConfig?.embedding?.displayName, - modelConfig?.multiEmbedding?.displayName, - kbDispatch, - ]); - // Generate the default name from knowledge bases visible to the current user. const generateVisibleKbName = (existingKbs: KnowledgeBase[]): string => { const baseNamePrefix = t("knowledgeBase.name.new"); @@ -623,7 +584,9 @@ function DataConfig({ isActive }: DataConfigProps) { } if (validFiles.length > 0) { setUploadFiles(validFiles); - handleFileUpload(); + if (!isCreatingMode) { + void handleFileUpload(validFiles); + } } } else { message.warning(t("knowledgeBase.message.selectFirst")); @@ -834,6 +797,7 @@ function DataConfig({ isActive }: DataConfigProps) { // Generate the default name without probing hidden tenant knowledge bases. const defaultName = generateVisibleKbName(kbState.knowledgeBases); setNewKbName(defaultName); + setNewKbDescription(""); setNewKbIngroupPermission("READ_ONLY"); setNewKbGroupIds([]); setNewKbPreserveSourceFile(true); @@ -900,7 +864,7 @@ function DataConfig({ isActive }: DataConfigProps) { }); }; - // Handle file upload - in creation mode create knowledge base first then upload, in normal mode upload directly + // Create the knowledge base from the modal, then upload the selected files if any. const handleFileUpload = async (selectedFiles: File[] = uploadFiles) => { if ( !isCreatingMode && @@ -909,7 +873,7 @@ function DataConfig({ isActive }: DataConfigProps) { message.error(t("errorCode.000202", "Access forbidden.")); return; } - if (!selectedFiles.length) { + if (!isCreatingMode && !selectedFiles.length) { message.warning(t("document.message.noFiles")); return; } @@ -920,6 +884,14 @@ function DataConfig({ isActive }: DataConfigProps) { message.warning(t("knowledgeBase.message.nameRequired")); return; } + if (!newKbEmbeddingModel) { + message.warning(t("knowledgeBase.message.embeddingModelRequired")); + return; + } + if (!selectedFiles.length) { + message.warning(t("knowledgeBase.message.noFiles")); + return; + } setHasClickedUpload(true); @@ -949,7 +921,7 @@ function DataConfig({ isActive }: DataConfigProps) { const newKB = await createKnowledgeBase( newKbName.trim(), - t("knowledgeBase.description.default"), + newKbDescription.trim() || t("knowledgeBase.description.default"), "elasticsearch", newKbIngroupPermission, newKbGroupIds, @@ -962,7 +934,13 @@ function DataConfig({ isActive }: DataConfigProps) { setActiveKnowledgeBase(newKB); knowledgeBasePollingService.setActiveKnowledgeBase(newKB.id); setHasClickedUpload(false); - setNewlyCreatedKbId(newKB.id); // Mark this KB as newly created + setNewlyCreatedKbId(filesToUpload.length > 0 ? newKB.id : null); // Mark this KB as newly created when files need processing + + if (!filesToUpload.length) { + setUploadFiles([]); + knowledgeBasePollingService.triggerKnowledgeBaseListUpdate(true); + return; + } await uploadDocuments(newKB.id, filesToUpload); setUploadFiles([]); @@ -987,23 +965,7 @@ function DataConfig({ isActive }: DataConfigProps) { }); } catch (error) { message.error( - isApiErrorCode(error, 409) - ? t("knowledgeBase.message.nameExists", { - name: newKbName.trim(), - }) - : isApiErrorCode( - error, - ErrorCode.TENANT_PERSONAL_KB_QUOTA_EXCEEDED - ) - ? t("quota.personalKbUploadBlocked") - : isApiErrorCode( - error, - ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE - ) - ? t(`errorCode.${ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE}`) - : isApiErrorCode(error, 413) - ? t("quota.uploadBlocked") - : t("knowledgeBase.message.createUploadError") + getKnowledgeBaseCreateErrorMessage(error, newKbName.trim(), t) ); setHasClickedUpload(false); // Clear the waiting flag so a failed upload cannot leave the page @@ -1045,18 +1007,7 @@ function DataConfig({ isActive }: DataConfigProps) { } ); } catch (error) { - message.error( - isApiErrorCode(error, ErrorCode.TENANT_PERSONAL_KB_QUOTA_EXCEEDED) - ? t("quota.personalKbUploadBlocked") - : isApiErrorCode( - error, - ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE - ) - ? t(`errorCode.${ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE}`) - : isApiErrorCode(error, 413) - ? t("quota.uploadBlocked") - : t("document.message.uploadError") - ); + message.error(getKnowledgeBaseUploadErrorMessage(error, t)); throw error; } }; @@ -1085,6 +1036,16 @@ function DataConfig({ isActive }: DataConfigProps) { const viewingKbName = kbState.activeKnowledgeBase?.name || (isCreatingMode ? newKbName : ""); + const activeKnowledgeBaseQuota: KBQuotaStatus | undefined = useMemo(() => { + const activeKnowledgeBase = kbState.activeKnowledgeBase; + if (!activeKnowledgeBase) return undefined; + + const indexName = activeKnowledgeBase.index_name || activeKnowledgeBase.id; + return quotaUsage?.breakdown?.find( + (quota) => quota.index_name === indexName + ); + }, [kbState.activeKnowledgeBase, quotaUsage]); + // Check if current knowledge base is newly created and waiting for documents const isNewlyCreatedAndWaiting = newlyCreatedKbId !== null && @@ -1129,6 +1090,12 @@ function DataConfig({ isActive }: DataConfigProps) { setNewKbName(name); }; + const handleCloseCreateModal = () => { + setIsCreatingMode(false); + setHasClickedUpload(false); + setUploadFiles([]); + }; + // If Embedding model is not configured, show warning container instead of content if (showEmbeddingWarning) { return ( @@ -1177,15 +1144,8 @@ function DataConfig({ isActive }: DataConfigProps) { onDrop={handleDrop} >
- - + {!kbState.activeKnowledgeBase ? ( + <> - - - - {isCreatingMode ? ( - {}} - knowledgeBaseSource={""} - isCreatingMode={true} - knowledgeBaseId={""} - knowledgeBaseName={newKbName} - onNameChange={handleNameChange} - containerHeight={SETUP_PAGE_CONTAINER.MAIN_CONTENT_HEIGHT} - hasDocuments={hasClickedUpload || docState.isUploading} - // Group permission and user groups for create mode - ingroupPermission={newKbIngroupPermission} - onIngroupPermissionChange={setNewKbIngroupPermission} - selectedGroupIds={newKbGroupIds} - onSelectedGroupIdsChange={setNewKbGroupIds} - preserveSourceFile={newKbPreserveSourceFile} - onPreserveSourceFileChange={setNewKbPreserveSourceFile} - quotaLimitBytes={newKbQuotaBytes} - onQuotaLimitBytesChange={setNewKbQuotaBytes} - // Embedding model for create mode - availableEmbeddingModels={availableEmbeddingModels} - selectedEmbeddingModel={newKbEmbeddingModel} - onEmbeddingModelChange={setNewKbEmbeddingModel} - // v2.6.0: embedding model params override - embeddingModelParamsOverride={newKbEmbeddingParamsOverride} - onEmbeddingModelParamsOverrideChange={setNewKbEmbeddingParamsOverride} - // Upload related props - isDragging={uiState.isDragging} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - onFileSelect={handleFileSelect} - onUpload={handleFileUpload} - isUploading={docState.isUploading} - /> - ) : kbState.activeKnowledgeBase ? ( + + + +
+ } + width={640} + centered + maskClosable={false} + destroyOnHidden + onCancel={handleCloseCreateModal} + styles={{ + container: { padding: 0 }, + body: { padding: 0 }, + footer: { + margin: 0, + padding: "12px 20px 16px", + borderTop: "1px solid #f0f0f0", + }, + }} + getContainer={() => contentRef.current || document.body} + > +
+ {}} + knowledgeBaseSource="" + isCreatingMode={true} + knowledgeBaseId="" + knowledgeBaseName={newKbName} + onNameChange={handleNameChange} + knowledgeBaseDescription={newKbDescription} + onDescriptionChange={setNewKbDescription} + selectedFiles={uploadFiles} + containerHeight={SETUP_PAGE_CONTAINER.MAIN_CONTENT_HEIGHT} + hasDocuments={hasClickedUpload || docState.isUploading} + // Group permission and user groups for create mode + ingroupPermission={newKbIngroupPermission} + onIngroupPermissionChange={setNewKbIngroupPermission} + selectedGroupIds={newKbGroupIds} + onSelectedGroupIdsChange={setNewKbGroupIds} + preserveSourceFile={newKbPreserveSourceFile} + onPreserveSourceFileChange={setNewKbPreserveSourceFile} + quotaLimitBytes={newKbQuotaBytes} + onQuotaLimitBytesChange={setNewKbQuotaBytes} + // Embedding model for create mode + availableEmbeddingModels={availableEmbeddingModels} + selectedEmbeddingModel={newKbEmbeddingModel} + onEmbeddingModelChange={setNewKbEmbeddingModel} + // v2.6.0: embedding model params override + embeddingModelParamsOverride={newKbEmbeddingParamsOverride} + onEmbeddingModelParamsOverrideChange={ + setNewKbEmbeddingParamsOverride + } + // Upload related props + isDragging={uiState.isDragging} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + onFileSelect={handleFileSelect} + onUpload={handleFileUpload} + isUploading={docState.isUploading} + /> +
+ + + ) : ( +
+
+ +
+
0} isNewlyCreatedAndWaiting={isNewlyCreatedAndWaiting} @@ -1319,6 +1324,15 @@ function DataConfig({ isActive }: DataConfigProps) { true ); }} + onRefresh={() => { + if (kbState.activeKnowledgeBase) { + void fetchDocuments( + kbState.activeKnowledgeBase.id, + true, + kbState.activeKnowledgeBase.source + ); + } + }} permission={kbState.activeKnowledgeBase?.permission} summaryFrequency={ kbState.activeKnowledgeBase?.summaryFrequency @@ -1355,27 +1369,9 @@ function DataConfig({ isActive }: DataConfigProps) { onUpload={handleFileUpload} isUploading={docState.isUploading} /> - ) : ( -
- - } - containerHeight="100%" - /> -
- )} - - +
+
+ )}
diff --git a/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx b/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx index e31f775222..0e8bd6db62 100644 --- a/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx +++ b/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx @@ -62,8 +62,6 @@ interface DocumentChunkProps { knowledgeBaseId: string; // Internal knowledge base ID / Elasticsearch index name documents: Document[]; getFileIcon: (type: string) => string; - currentEmbeddingModel?: string | null; - knowledgeBaseEmbeddingModel?: string; onChunkCountChange?: () => void; // Callback when chunk count changes (for updating KnowledgeBaseList) permission?: string; // User's permission for this knowledge base (READ_ONLY, EDIT, etc.) } @@ -84,8 +82,6 @@ const DocumentChunk: React.FC = ({ knowledgeBaseId, documents, getFileIcon, - currentEmbeddingModel, - knowledgeBaseEmbeddingModel, onChunkCountChange, permission, }) => { @@ -140,33 +136,10 @@ const DocumentChunk: React.FC = ({ const effectiveIndexName = knowledgeBaseId || knowledgeBaseName; - const hasKnowledgeBaseModel = - Boolean(knowledgeBaseEmbeddingModel) && - knowledgeBaseEmbeddingModel !== "unknown"; - const hasCurrentModel = Boolean(currentEmbeddingModel); - - // Determine if embedding models mismatch (specific condition for tooltip) - const isEmbeddingModelMismatch = React.useMemo(() => { - if (!hasKnowledgeBaseModel) { - return false; - } - return ( - !hasCurrentModel || currentEmbeddingModel !== knowledgeBaseEmbeddingModel - ); - }, [ - currentEmbeddingModel, - hasCurrentModel, - hasKnowledgeBaseModel, - knowledgeBaseEmbeddingModel, - ]); - - // Determine if in read-only mode (embedding model mismatch OR user has READ_ONLY permission) - // Note: isReadOnlyMode is broader, includes model mismatch and other conditions - const isReadOnlyMode = React.useMemo(() => { - return permission === "READ_ONLY" || isEmbeddingModelMismatch; - }, [permission, isEmbeddingModelMismatch]); - - const isSearchDisabled = isEmbeddingModelMismatch; + // The knowledge base owns its embedding model, so the global model must not + // disable chunk search or editing in this knowledge base. + const isReadOnlyMode = permission === "READ_ONLY"; + const isSearchDisabled = false; useEffect(() => { if (isSearchDisabled) { @@ -644,11 +617,12 @@ const DocumentChunk: React.FC = ({ }, {}); }, [chunkSearchResult]); - const visibleDocuments = isChunkSearchActive && !chunkSearchLoading - ? documents.filter( - (doc) => (chunkSearchResultMap?.[doc.id]?.length ?? 0) > 0 - ) - : documents; + const visibleDocuments = + isChunkSearchActive && !chunkSearchLoading + ? documents.filter( + (doc) => (chunkSearchResultMap?.[doc.id]?.length ?? 0) > 0 + ) + : documents; const tabItems = visibleDocuments.map((doc) => { const chunkCount = isChunkSearchActive @@ -762,7 +736,9 @@ const DocumentChunk: React.FC = ({ className="self-center" /> - {t("document.chunk.tooltip.edit")} + + {t("document.chunk.tooltip.edit")} + )} @@ -775,7 +751,9 @@ const DocumentChunk: React.FC = ({ className="self-center" /> - {t("document.chunk.tooltip.download")} + + {t("document.chunk.tooltip.download")} + {!isReadOnlyMode && ( @@ -789,7 +767,9 @@ const DocumentChunk: React.FC = ({ className="self-center" /> - {t("document.chunk.tooltip.delete")} + + {t("document.chunk.tooltip.delete")} + )} @@ -913,7 +893,9 @@ const DocumentChunk: React.FC = ({ onClick={openCreateChunkModal} /> - {t("document.chunk.tooltip.create")} + + {t("document.chunk.tooltip.create")} + )} diff --git a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx index 14c6112235..0f396f10a3 100644 --- a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx +++ b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx @@ -15,14 +15,18 @@ import { App, Select, Popover, + Progress, Segmented, - Space, Modal, Tooltip, } from "antd"; import { useStorageQuotaBlocked } from "@/hooks/useStorageQuotaBlocked"; const { TextArea } = Input; -import { FilterOutlined, InfoCircleFilled } from "@ant-design/icons"; +import { + FileTextOutlined, + FilterOutlined, + ReloadOutlined, +} from "@ant-design/icons"; import { BookText, Pilcrow, @@ -33,6 +37,9 @@ import { AlertCircle, Settings2, Tag, + SlidersHorizontal, + ChevronDown, + ChevronUp, } from "lucide-react"; import { NAME_CHECK_STATUS } from "@/const/agentConfig"; import { MarkdownRenderer } from "@/components/common/markdownRenderer"; @@ -51,12 +58,14 @@ import { modelService } from "@/services/modelService"; import { getTenantDefaultGroupId } from "@/services/groupService"; import { extractObjectNameFromUrl } from "@/services/storageService"; import { Document } from "@/types/knowledgeBase"; +import type { KBQuotaStatus } from "@/types/quota"; import type { TagDocumentPredicate, TagDocumentBatchStatusEntry, } from "@/types/tagManagement"; import { ModelOption } from "@/types/modelConfig"; import { formatFileSize } from "@/lib/utils"; +import { getKnowledgeBaseQuotaDisplay } from "@/lib/knowledgeBaseQuota"; import log from "@/lib/logger"; import { useInferenceFieldSpecs } from "@/hooks/model/useInferenceFieldSpecs"; import { @@ -72,6 +81,7 @@ import DocumentStatus from "./DocumentStatus"; import DocumentChunk from "./DocumentChunk"; import UploadArea from "../upload/UploadArea"; import ResourceTagAssignmentModal from "@/components/tag/ResourceTagAssignmentModal"; +import ResourceTagChips from "@/components/tag/ResourceTagChips"; import TagDefinitionManagementModal from "@/components/tag/TagDefinitionManagementModal"; import TagFilterControls from "@/components/tag/TagFilterControls"; import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; @@ -98,6 +108,8 @@ interface DocumentListProps { knowledgeBaseSource?: string; // User-facing knowledge base name (display name) knowledgeBaseName?: string; + knowledgeBaseDescription?: string; + onDescriptionChange?: (description: string) => void; // Internal knowledge base ID / Elasticsearch index name knowledgeBaseId?: string; modelMismatch?: boolean; @@ -110,6 +122,7 @@ interface DocumentListProps { hasDocuments?: boolean; isNewlyCreatedAndWaiting?: boolean; // New prop to track newly created KB waiting for documents onChunkCountChange?: () => void; // Callback when chunk count changes + onRefresh?: () => void; // Group permission and user groups for create mode ingroupPermission?: string; @@ -143,6 +156,7 @@ interface DocumentListProps { isMultimodal?: boolean; quotaLimitBytes?: number | null; onQuotaLimitBytesChange?: (value: number | null) => void; + quotaStatus?: KBQuotaStatus; onMultimodalChange?: (value: boolean) => void; permission?: string; // User's permission for this knowledge base (READ_ONLY, EDIT, etc.) preserveSourceFile?: boolean; @@ -158,6 +172,7 @@ interface DocumentListProps { onDragLeave?: (e: React.DragEvent) => void; onDrop?: (e: React.DragEvent) => void; onFileSelect: (files: File[]) => void; + selectedFiles?: File[]; onUpload?: (files: File[]) => Promise; isUploading?: boolean; } @@ -174,6 +189,8 @@ const DocumentListContainer = forwardRef( knowledgeBaseSource = "", knowledgeBaseId = "", knowledgeBaseName = "", + knowledgeBaseDescription = "", + onDescriptionChange, modelMismatch = false, currentModel = "", knowledgeBaseModel = "", @@ -184,6 +201,7 @@ const DocumentListContainer = forwardRef( hasDocuments = false, isNewlyCreatedAndWaiting = false, // New prop onChunkCountChange, + onRefresh, // Group permission and user groups for create mode ingroupPermission, onIngroupPermissionChange, @@ -203,6 +221,7 @@ const DocumentListContainer = forwardRef( onPreserveSourceFileChange, quotaLimitBytes = null, onQuotaLimitBytesChange, + quotaStatus, // Auto-summary frequency summaryFrequency, onSummaryFrequencyChange, @@ -213,6 +232,7 @@ const DocumentListContainer = forwardRef( onDragLeave, onDrop, onFileSelect, + selectedFiles = [], onUpload, isUploading = false, }, @@ -255,18 +275,18 @@ const DocumentListContainer = forwardRef( // Use fixed height instead of percentage const titleBarHeight = UI_CONFIG.TITLE_BAR_HEIGHT; const uploadHeight = UI_CONFIG.UPLOAD_COMPONENT_HEIGHT; + const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false); const [assignTarget, setAssignTarget] = useState<{ docId: string; canEdit: boolean; } | null>(null); + const [documentTagRefreshKey, setDocumentTagRefreshKey] = useState(0); const { data: tagLibraries } = useTagLibraries(); const documentLibrary = tagLibraries?.find((lib) => lib.bucket_key === "knowledge_content") ?? null; - const { - data: assignDefinitions, - refresh: refreshAssignDefinitions, - } = useTagDefinitions(documentLibrary?.bucket_id ?? null); + const { data: assignDefinitions, refresh: refreshAssignDefinitions } = + useTagDefinitions(documentLibrary?.bucket_id ?? null); const [tagManagementOpen, setTagManagementOpen] = useState(false); const [documentPredicates, setDocumentPredicates] = useState< @@ -417,6 +437,18 @@ const DocumentListContainer = forwardRef( enabled: embeddingAdvancedOpen, }); const isDataMate = (knowledgeBaseSource || "").toLowerCase() === "datamate"; + const { + hasQuota, + availableCapacity: quotaAvailable, + totalCapacity: quotaTotal, + usagePercent: quotaUsagePercent, + } = getKnowledgeBaseQuotaDisplay(quotaStatus, t); + let documentListClassName = "flex-grow min-h-0 overflow-auto px-6 py-5"; + if (isCreatingMode) { + documentListClassName = "w-full bg-white p-2"; + } else if (showChunk) { + documentListClassName = "flex-grow min-h-0 overflow-hidden px-6 pt-5"; + } // Determine if user has read-only permission const isReadOnlyMode = permission === "READ_ONLY"; @@ -776,234 +808,101 @@ const DocumentListContainer = forwardRef( return (
{/* Title bar */}
-
+
{isCreatingMode ? ( -
-
- - onNameChange && onNameChange(e.target.value) - } - placeholder={t("document.input.knowledgeBaseName")} - className={`${LAYOUT.KB_TITLE_MARGIN} max-w-[240px] font-medium`} - size="large" - prefix={📚} - status={ - isCreatingMode && - (nameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT || - nameStatus === - NAME_CHECK_STATUS.EXISTS_IN_OTHER_TENANT) - ? "error" - : undefined - } - autoFocus - disabled={ - hasDocuments || - isUploading || - docState.isLoadingDocuments - } - /> - {isCreatingMode && - (nameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT || - nameStatus === - NAME_CHECK_STATUS.EXISTS_IN_OTHER_TENANT) && ( -
- - - {t("tenantResources.knowledgeBase.nameExists")} - -
- )} -
- {/* Right dropdowns for create mode */} -
- {/* Embedding model selection - first position in create mode */} - {isCreatingMode && onEmbeddingModelChange && ( -
- + {modelMismatch && ( + + {getMismatchInfo()} + )} - options={groupOptions} - maxTagCount={2} - allowClear - disabled={isGroupSelectDisabled} - /> - - {/* Group permission dropdown */} - - - )} - {onQuotaLimitBytesChange && ( - - { - if (v == null) { - onQuotaLimitBytesChange(null); - } else if (quotaUnit === "GB") { - onQuotaLimitBytesChange(v * 1024 * 1024 * 1024); - } else { - onQuotaLimitBytesChange(v * 1024 * 1024); - } - }} - addonAfter={quotaUnit} - placeholder={t("quota.unlimited", "无限制")} - min={0} - precision={0} - style={{ width: 130 }} - /> - + {hasQuota && ( + setQuotaUnit(val as "GB" | "MB")} + status={ + quotaStatus.kb_warning_level === "exceeded" + ? "exception" + : "normal" + } /> - - )} -
-
- ) : ( -

- {knowledgeBaseName} -

- )} - {modelMismatch && !isCreatingMode && ( -
- {getMismatchInfo()} -
+ )} +
+ )} + )}
{/* Right: tag filter, Tag Management, overview and detail buttons */} {!isCreatingMode && !isDataMate && ( -
+
{documentLibrary && assignDefinitions && ( ( )} + {onRefresh && ( + +
)}
@@ -1083,9 +995,7 @@ const DocumentListContainer = forwardRef( {/* Document list */}
{ if (!isCreatingMode && knowledgeBaseName) { return; @@ -1107,15 +1017,13 @@ const DocumentListContainer = forwardRef( }} > {showChunk ? ( -
+
@@ -1132,7 +1040,7 @@ const DocumentListContainer = forwardRef(
) : showDetail ? ( -
+
{t("document.summary.title")} @@ -1272,72 +1180,372 @@ const DocumentListContainer = forwardRef(
) : isCreatingMode ? ( - hasDocuments || isUploading || docState.isLoadingDocuments ? ( -
-
-
-

- {t("document.status.waitingForTask")} -

+
+ {hasDocuments || isUploading || docState.isLoadingDocuments ? ( +
+
+
+

+ {t("document.status.waitingForTask")} +

+
-
- ) : ( -
-
-
- + ) : ( +
+
+ + + onNameChange && onNameChange(e.target.value) + } + placeholder={t( + "knowledgeBase.create.namePlaceholder", + "例如:产品知识中心" + )} + className="!rounded-xl" + size="large" + status={ + nameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT || + nameStatus === NAME_CHECK_STATUS.EXISTS_IN_OTHER_TENANT + ? "error" + : undefined + } + autoFocus + disabled={ + hasDocuments || + isUploading || + docState.isLoadingDocuments + } + /> + {(nameStatus === NAME_CHECK_STATUS.EXISTS_IN_TENANT || + nameStatus === + NAME_CHECK_STATUS.EXISTS_IN_OTHER_TENANT) && ( +
+ + + {t("tenantResources.knowledgeBase.nameExists")} + +
+ )}
-

- {t("document.title.createNew")} -

-

- {t("document.hint.uploadToCreate")} -

+ +
+ +