diff --git a/backend/database/user_tenant_db.py b/backend/database/user_tenant_db.py index a5ec5b262d..1a2e3c7e12 100644 --- a/backend/database/user_tenant_db.py +++ b/backend/database/user_tenant_db.py @@ -2,7 +2,9 @@ Database operations for user tenant relationship management """ import logging -from typing import Any, List, Dict, Optional +from typing import Any, Dict, List, Optional + +from sqlalchemy import func, text from consts.const import ( DEFAULT_TENANT_ID, @@ -10,10 +12,10 @@ MAX_SUPER_ADMIN_COUNT, MAX_USERS_PER_TENANT, ) +from consts.exceptions import TenantResourceLimitError from database.client import as_dict, get_db_session from database.db_models import TenantGroupInfo, TenantGroupUser, UserTenant -from consts.exceptions import TenantResourceLimitError -from sqlalchemy import func, text + logger = logging.getLogger(__name__) @@ -131,17 +133,20 @@ def get_user_tenant_by_user_id(user_id: str) -> Optional[Dict[str, Any]]: return None -def get_user_email_map(user_ids: List[str]) -> Dict[str, str]: +def get_user_email_map(user_ids: List[str], tenant_id: Optional[str] = None) -> Dict[str, str]: """Return active user email addresses keyed by user ID.""" unique_user_ids = list({user_id for user_id in user_ids if user_id}) if not unique_user_ids: return {} with get_db_session() as session: - rows = session.query(UserTenant.user_id, UserTenant.user_email).filter( + query = session.query(UserTenant.user_id, UserTenant.user_email).filter( UserTenant.user_id.in_(unique_user_ids), UserTenant.delete_flag == "N", - ).all() + ) + if tenant_id is not None: + query = query.filter(UserTenant.tenant_id == tenant_id) + rows = query.all() return { user_id: user_email diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index a53626a4e7..df19048f94 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -17,7 +17,7 @@ import logging import time from http import HTTPStatus -from typing import Annotated, List, Optional +from typing import Annotated, List, Literal, Optional from uuid import UUID from fastapi import APIRouter, File, HTTPException, Path, Query, Request, UploadFile @@ -46,6 +46,7 @@ invalidate_aidp_kb_detail_cache, resolve_current_aidp_access, ) +from ext_components.aidp.services.aidp_creator_service import get_nexent_creator_names from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings from ext_components.aidp.services.aidp_permission_service import ( EDIT, @@ -58,12 +59,14 @@ count_aidp_docs_impl, create_aidp_kb_impl, delete_aidp_kb_impl, + get_aidp_graph_template_impl, get_aidp_kb_impl, list_aidp_channels_impl, list_aidp_doc_history_impl, list_aidp_docs_impl, list_aidp_models_impl, remove_aidp_docs_impl, + retry_aidp_docs_impl, select_aidp_channel, stream_aidp_doc_impl, update_aidp_kb_impl, @@ -173,6 +176,22 @@ class CreateKbRequest(BaseModel): similarity: Optional[float] = Field(None, description="Similarity score threshold") smartsplit: Optional[int] = Field(None, ge=0, le=1, description="Smart chunking mode, int 0 or 1") caption_enable: Optional[int] = Field(None, ge=0, le=1, description="Caption generation toggle, int 0 or 1") + chunk_mode: Optional[int] = Field( + None, ge=0, le=1, description="Chunking mode: 0 smart splitting, 1 legal clauses" + ) + is_exist_graph: Optional[bool] = Field( + None, description="Whether knowledge graph extraction is enabled for this KB" + ) + graph_config: Optional[dict] = Field( + None, + description=( + "Structured graph configuration. The service validates it and serializes it into the " + "AIDP graph_config string; it is omitted entirely when the graph is disabled." + ), + ) + llm_model_name: Optional[str] = Field( + None, description="Graph extraction model taken from the llm category" + ) # Nexent-side permission payload. Never forwarded to AIDP. ingroup_permission: Optional[str] = Field( "READ_ONLY", @@ -189,6 +208,8 @@ class UpdateKbRequest(BaseModel): name: Optional[str] = Field(None, description="Knowledge base name") description: Optional[str] = Field(None, description="Knowledge base description") + chunk_mode: Optional[int] = Field(None, ge=0, le=1, description="Chunk mode: 0 smart, 1 legal") + topk: Optional[int] = Field(None, ge=1, le=100, description="Vector retrieval Top K") class SetPermissionRequest(BaseModel): @@ -214,6 +235,12 @@ class DownloadAidpDocumentRequest(BaseModel): file_uuid: UUID = Field(..., description="AIDP file UUID") +class RetryAidpDocumentsRequest(BaseModel): + """AIDP accepts one or more failed file UUIDs in a single retry request.""" + + file_uuids: List[UUID] = Field(..., min_length=1) + + # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- @@ -325,8 +352,17 @@ def _has_kb_card_metadata(row: dict) -> bool: has_name = bool(row.get("kds_name") or row.get("name")) has_description = "description" in row has_created_at = "created_at" in row or "create_time" in row + has_updated_at = "updated_at" in row or "update_time" in row has_multimodal = "is_multimodal" in row or "caption_enable" in row - return has_name and has_description and has_created_at and has_multimodal + has_document_count = row.get("document_count") is not None + return ( + has_name + and has_description + and has_created_at + and has_updated_at + and has_multimodal + and has_document_count + ) def _load_cached_kb_detail(server_url: str, api_key: str, kb_id: str) -> dict: @@ -804,6 +840,20 @@ async def _load_ingested_documents( # --------------------------------------------------------------------------- +@aidp_mgmt_router.get("/knowledge-bases/graph-template") +async def get_graph_template( + request: Request, + language: Literal["chinese", "english"] = "chinese", +) -> JSONResponse: + await _auth(request) + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-graph-template", get_aidp_graph_template_impl, server_url, api_key, language, + lane="control-io", owner="config", + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + @aidp_mgmt_router.get("/knowledge-bases") async def list_knowledge_bases( request: Request, @@ -849,6 +899,22 @@ async def list_knowledge_bases( start = (page - 1) * page_size page_rows = rows[start:start + page_size] + creator_names = await run_blocking( + "aidp-creator-names", get_nexent_creator_names, + [row.get("owner_user_id") for row in page_rows], tenant_id, + lane="control-io", owner="config", + ) + # Older local permission fixtures used the literal ``user_id`` as an + # owner placeholder before Nexent user IDs were persisted. Resolve that + # legacy value to the authenticated Nexent account while keeping real + # owner IDs strictly tenant-scoped through the user table above. + if any(row.get("owner_user_id") == "user_id" for row in page_rows): + current_user_name = await run_blocking( + "aidp-current-creator-name", get_nexent_creator_names, + [user_id], tenant_id, lane="control-io", owner="config", + ) + if current_user_name.get(user_id): + creator_names["user_id"] = current_user_name[user_id] detail_semaphore = asyncio.Semaphore(5) @@ -879,6 +945,11 @@ async def resolve_detail(row: dict) -> tuple[dict, str]: items: list[dict] = [] for row, (detail, resource_status) in zip(page_rows, detail_results): kb_id = row["kb_id"] + document_count = ( + detail.get("document_count") + if detail.get("document_count") is not None + else row.get("document_count") + ) items.append({ "kds_id": kb_id, "kds_name": ( @@ -889,7 +960,7 @@ async def resolve_detail(row: dict) -> tuple[dict, str]: or "" ), "description": detail.get("description") or row.get("description") or "", - "document_count": detail.get("document_count", row.get("document_count", 0)), + "document_count": document_count if document_count is not None else 0, "chunk_count": detail.get("chunk_count", row.get("chunk_count", 0)), "embedding_model": detail.get("embedding_model") or row.get("embedding_model") or "", # ``is_multimodal`` is a Nexent-side concept (frontend sends it @@ -906,11 +977,30 @@ async def resolve_detail(row: dict) -> tuple[dict, str]: or row.get("created_at") or _timestamp_to_iso(row.get("create_time")) ), + "updated_at": ( + detail.get("updated_at") + or _timestamp_to_iso(detail.get("update_time")) + or row.get("updated_at") + or _timestamp_to_iso(row.get("update_time")) + ), "permission": row.get("permission"), "ingroup_permission": row.get("ingroup_permission"), "group_ids": row.get("group_ids"), "created_by": row.get("owner_user_id"), + "creator_name": creator_names.get(row.get("owner_user_id")), "resource_status": resource_status, + # --- Optional display metadata (AIDP knowledge base pages) --- + # These stay absent when the upstream response does not carry + # them, so the UI renders an unknown value instead of a fabricated + # one. ``user_name`` is only ever a display name AIDP reported — + # the Nexent user id is never presented as a name. + "is_private": detail.get("is_private", row.get("is_private")), + "current_cap": detail.get("current_cap", row.get("current_cap")), + "user_name": detail.get("user_name", row.get("user_name")), + # A count from either the AIDP detail response or its catalog row + # is confirmed data. The compatibility default above remains + # marked unreliable when neither response includes a count. + "document_count_reliable": document_count is not None, }) total_ms = (time.perf_counter() - started_at) * 1000 @@ -1098,6 +1188,15 @@ async def get_knowledge_base( detail["kds_id"] = kds_id detail["permission"] = decision.permission detail["resource_status"] = resource_status + permission_record = aidp_permission_db.get_permission_by_kb_id(kds_id, tenant_id) or {} + detail["ingroup_permission"] = permission_record.get("ingroup_permission") + detail["group_ids"] = permission_record.get("group_ids") or [] + detail["created_by"] = permission_record.get("owner_user_id") + creator_names = await run_blocking( + "aidp-creator-names", get_nexent_creator_names, + [detail["created_by"]], tenant_id, lane="control-io", owner="config", + ) + detail["creator_name"] = creator_names.get(detail["created_by"]) return JSONResponse(status_code=HTTPStatus.OK, content=detail) @@ -1114,7 +1213,7 @@ async def update_knowledge_base( if not payload: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="At least one field (name or description) must be provided for update", + detail="At least one supported knowledge base field must be provided for update", ) server_url, api_key = _credentials() result = update_aidp_kb_impl(server_url, api_key, kds_id, payload) @@ -1323,6 +1422,183 @@ async def list_documents( return JSONResponse(status_code=HTTPStatus.OK, content=result) +@aidp_mgmt_router.get("/knowledge-bases/{kds_id}/files") +async def list_ingested_files( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 10, + keyword: Annotated[str | None, Query(max_length=200)] = None, +) -> JSONResponse: + """List only files AIDP confirms as ingested; upload history stays separate.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + server_url, api_key = _credentials() + normalized_keyword = (keyword or "").strip() + + # AIDP's list response exposes next_link as the authoritative paging signal; + # total_count can be page-local. The Count endpoint is unfiltered, so use it + # only for the unfiltered list and keep keyword results in simple paging mode. + if normalized_keyword: + list_result = await run_blocking( + "aidp-list-ingested-files", + list_aidp_docs_impl, + server_url, + api_key, + kds_id, + page, + page_size, + normalized_keyword, + lane="control-io", + owner="config", + ) + count_result: int | BaseException | None = None + else: + list_result, count_result = await asyncio.gather( + run_blocking( + "aidp-list-ingested-files", + list_aidp_docs_impl, + server_url, + api_key, + kds_id, + page, + page_size, + None, + lane="control-io", + owner="config", + ), + run_blocking( + "aidp-ingested-file-count", + _load_cached_doc_count, + server_url, + api_key, + kds_id, + lane="control-io", + owner="config", + ), + return_exceptions=True, + ) + if isinstance(list_result, BaseException): + raise list_result + result = dict(list_result or {}) + rows = result.get("value") if isinstance(result.get("value"), list) else [] + total_reliable = isinstance(count_result, int) and not isinstance(count_result, bool) + total = int(count_result) if total_reliable else (page - 1) * page_size + len(rows) + reported_more = _history_reports_more(result) + has_more = ( + total > page * page_size + if total_reliable + else bool(reported_more) if reported_more is not None + else len(rows) >= page_size + ) + result.update({ + "total_count": total, + "has_more": has_more, + "total_reliable": total_reliable, + "processing_count": 0, + }) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + +@aidp_mgmt_router.get("/knowledge-bases/{kds_id}/upload-tasks") +async def list_upload_tasks( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 10, + keyword: Annotated[str | None, Query(max_length=200)] = None, + status: Annotated[int, Query(ge=0, le=5)] = 0, +) -> JSONResponse: + """Read the recent AIDP upload history, separate from the ingested file list.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + server_url, api_key = _credentials() + channel = await run_blocking( + "aidp-upload-task-channel", + _resolve_doc_history_channel, + server_url, + api_key, + kds_id, + lane="control-io", + owner="config", + ) + history = {} + if channel: + history = await run_blocking( + "aidp-upload-tasks", + list_aidp_doc_history_impl, + server_url, + api_key, + channel["fs_id"], + f"/{kds_id}", + kds_id, + None, + page, + page_size, + status, + (keyword or "").strip() or None, + lane="control-io", + owner="config", + ) + raw_items = history.get("value") if isinstance(history, dict) else None + rows = [item for item in raw_items if isinstance(item, dict)] if isinstance(raw_items, list) else [] + raw_total = history.get("total_count") if isinstance(history, dict) else None + total_reliable = isinstance(raw_total, int) and not isinstance(raw_total, bool) + total = int(raw_total) if total_reliable else (page - 1) * page_size + len(rows) + reported_more = _history_reports_more(history) if isinstance(history, dict) else None + has_more = ( + bool(reported_more) + if reported_more is not None + else total > page * page_size + ) + + def _history_count(field: str, fallback: int = 0) -> int: + value = history.get(field) if isinstance(history, dict) else None + return value if isinstance(value, int) and not isinstance(value, bool) else fallback + + stats = { + "total": _history_count("total_record_count", total), + "extracting": _history_count("processing_record_count"), + "failed": _history_count("failed_record_count"), + "success": _history_count("success_record_count"), + "queued": _history_count("queued_record_count"), + } + return JSONResponse(status_code=HTTPStatus.OK, content={ + "value": rows, + "total_count": total, + "has_more": has_more, + "total_reliable": total_reliable, + "stats": stats, + "retention_days": 30, + }) + + +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/upload-tasks/retry") +async def retry_upload_tasks( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: RetryAidpDocumentsRequest, +) -> JSONResponse: + """Retry selected failed tasks; the upstream request always carries a UUID array.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") + file_uuids = list(dict.fromkeys(str(file_uuid) for file_uuid in body.file_uuids)) + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-retry-upload-tasks", + retry_aidp_docs_impl, + server_url, + api_key, + kds_id, + file_uuids, + lane="control-io", + owner="config", + ) + 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/remove") async def remove_documents( request: Request, @@ -1420,7 +1696,10 @@ async def set_permission( detail=str(exc), ) - metadata = body.model_dump(include={"name", "description"}, exclude_none=True) + metadata = body.model_dump( + include={"name", "description"}, + exclude_none=True, + ) if "name" in metadata: metadata["name"] = metadata["name"].strip() if not metadata["name"]: @@ -1442,11 +1721,14 @@ async def set_permission( @aidp_mgmt_router.get("/models") async def list_models( request: Request, - service: Annotated[str, Query(description="Model service category (default: llm)")] = "llm", + service: Annotated[str, Query(description="Optional model category; omitted returns all categories")] = "", app: Annotated[str, Query(description="Application filter (default: KnowledgeBase)")] = "KnowledgeBase", ) -> JSONResponse: """List available models from AIDP ModelService. Auth required; no per-KB permission.""" await _auth(request) server_url, api_key = _credentials() - result = list_aidp_models_impl(server_url, api_key, service=service, app=app) + result = await run_blocking( + "aidp-models", list_aidp_models_impl, server_url, api_key, service, app, + lane="control-io", owner="config", + ) return JSONResponse(status_code=HTTPStatus.OK, content=result) diff --git a/backend/ext_components/aidp/services/aidp_creator_service.py b/backend/ext_components/aidp/services/aidp_creator_service.py new file mode 100644 index 0000000000..3a8163b67b --- /dev/null +++ b/backend/ext_components/aidp/services/aidp_creator_service.py @@ -0,0 +1,22 @@ +"""Resolve AIDP permission owners to Nexent account names without remote identity lookup.""" + +import logging +from collections.abc import Iterable + +from database.user_tenant_db import get_user_email_map + + +logger = logging.getLogger(__name__) + + +def get_nexent_creator_names(user_ids: Iterable[str], tenant_id: str) -> dict[str, str]: + """Use the same account name as user management, scoped to the current tenant.""" + ids = sorted({str(user_id) for user_id in user_ids if user_id}) + if not ids: + return {} + try: + return get_user_email_map(ids, tenant_id=tenant_id) + except Exception: + # Missing identity metadata must not make the knowledge base inaccessible. + logger.exception("Failed to resolve Nexent AIDP creator names") + return {} diff --git a/backend/ext_components/aidp/services/aidp_service.py b/backend/ext_components/aidp/services/aidp_service.py index aee98f6970..64c4d27542 100644 --- a/backend/ext_components/aidp/services/aidp_service.py +++ b/backend/ext_components/aidp/services/aidp_service.py @@ -2,6 +2,7 @@ AIDP Service Layer Handles API calls to AIDP for paginated knowledge base listing. """ +import json import logging import time from datetime import datetime, timezone @@ -340,7 +341,13 @@ def _extract_doc_status(raw: Dict[str, Any]) -> str | None: deployment that renames the field keeps working without a code change. """ for key in _HISTORY_STATUS_KEYS: - status = _normalize_doc_status(raw.get(key)) + value = raw.get(key) + # F12 confirmed that the canonical `status` field uses numeric codes + # (1-5). Other legacy aliases may be unrelated numeric state fields and + # remain ignored unless they contain a textual status. + if key == "status" and isinstance(value, (int, float)) and not isinstance(value, bool): + return str(int(value)) if int(value) == value else str(value) + status = _normalize_doc_status(value) if status is not None: return status return None @@ -856,18 +863,144 @@ def count_aidp_kbs_impl(server_url: str, api_key: str) -> int: _AIDP_CREATE_DEFAULTS: Dict[str, Any] = { "chunk_token_num": 1024, "chunk_overlap_num": 128, + # chunk_mode: 0 smart splitting, 1 legal clauses. Distinct from + # ``smartsplit``, which stays in place for existing callers. + "chunk_mode": 0, "embedding_model": "default", # AIDP expects the VLM model identifier exactly as registered in its system. "vlm_model": "Qwen3-VL-8B-Instruct", "is_personal": 0, "topk": 10, "similarity": 0.0, - "smartsplit": 1, # caption_enable: int 0/1, not string or bool. "caption_enable": 0, + # Graph extraction stays off unless the caller enables it; its + # configuration is only serialized in that case. + "is_exist_graph": False, } +# Bounds from the AIDP create contract. They guard the new creation page and +# any other caller: values outside them would be rejected upstream anyway, and +# an invalid overlap would otherwise be forwarded as-is. +_CHUNK_TOKEN_MIN = 256 +_CHUNK_TOKEN_MAX = 4096 +_GRAPH_DOMAINS = {"医疗", "金融", "常规", "法律法规"} +_GRAPH_PROMPT_LANGUAGES = {"中文", "英文"} +_GRAPH_PROMPT_MAX_CHARS = 4096 + + +def _validate_chunking(result: Dict[str, Any]) -> None: + """Reject a chunk overlap that exceeds half of an explicit chunk size. + + Only the ratio is validated here, and only for a positive chunk size: the + absolute token bounds belong to the creation page, and an existing caller + that already sends a valid pair (1024 / 128) is unaffected. + """ + chunk_tokens = result.get("chunk_token_num") + chunk_overlap = result.get("chunk_overlap_num") + if not isinstance(chunk_tokens, int) or isinstance(chunk_tokens, bool): + return + if chunk_tokens <= 0: + # A non-positive chunk size is the upstream contract's business; the + # overlap ratio cannot be evaluated against it. + return + if isinstance(chunk_overlap, int) and not isinstance(chunk_overlap, bool): + if chunk_overlap < 0 or chunk_overlap > chunk_tokens * 0.5: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + "chunk_overlap_num must not exceed half of chunk_token_num", + ) + + +def _serialize_graph_config(config: Dict[str, Any]) -> str: + """Validate a structured graph configuration and serialize it for AIDP. + + Only documented keys are forwarded — unknown input is dropped rather than + passed through — and out-of-range values are rejected instead of being + silently clamped. The result is the JSON string AIDP expects in + ``graph_config``; the frontend never builds that string itself. + """ + domain = config.get("domain") or "常规" + if not isinstance(domain, str): + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "Graph domain must be a string") + domain = {"medical": "医疗", "finance": "金融", "general": "常规"}.get(domain, domain) + if domain not in _GRAPH_DOMAINS: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + f"Unsupported graph domain: {domain}", + ) + + prompt_language = config.get("prompt_language") or "中文" + if not isinstance(prompt_language, str): + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "Graph prompt language must be a string") + prompt_language = {"chinese": "中文", "english": "英文"}.get(prompt_language, prompt_language) + if prompt_language not in _GRAPH_PROMPT_LANGUAGES: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + f"Unsupported graph prompt language: {prompt_language}", + ) + + prompt_text = config.get("prompt_text") or "" + if not isinstance(prompt_text, str): + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + "Graph prompt text must be a string", + ) + if not 1 <= len(prompt_text) <= _GRAPH_PROMPT_MAX_CHARS: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + f"Graph prompt must contain 1 to {_GRAPH_PROMPT_MAX_CHARS} characters", + ) + + def _bounded_int(key: str, default: int, minimum: int, maximum: int) -> str: + value = config.get(key, default) + if isinstance(value, str) and value in {str(n) for n in range(minimum, maximum + 1)}: + return value + if ( + not isinstance(value, int) + or isinstance(value, bool) + or not minimum <= value <= maximum + ): + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + f"Graph parameter {key} must be an integer between {minimum} and {maximum}", + ) + return str(value) + + def _yes_no(key: str, default: bool) -> str: + value = config.get(key, default) + if isinstance(value, bool): + return "是" if value else "否" + if value in ("是", "否"): + return value + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, f"Graph parameter {key} must be 是 or 否") + + payload = { + "domain": domain, + "retrieve_subgraph_hop": _bounded_int("retrieve_subgraph_hop", 2, 1, 3), + "no_think_mode": _yes_no("no_think_mode", True), + "prompt_language": prompt_language, + "prompt_text": prompt_text, + "synonym_merge_enable": _yes_no("synonym_merge_enable", False), + "disambiguation_enable": _yes_no("disambiguation_enable", False), + } + llm_model_name = config.get("llm_model_name") + if llm_model_name is not None: + if not isinstance(llm_model_name, str) or not llm_model_name.strip(): + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + "Graph LLM model name must be a non-empty string", + ) + if len(llm_model_name.encode("utf-8")) > 256: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + "Graph LLM model name must not exceed 256 UTF-8 bytes", + ) + payload["llm_model_name"] = llm_model_name + return json.dumps(payload, ensure_ascii=False) + + def _apply_create_defaults(payload: Dict[str, Any]) -> Dict[str, Any]: """Fill missing AIDP create-KB fields with reference defaults. @@ -888,6 +1021,8 @@ def _apply_create_defaults(payload: Dict[str, Any]) -> Dict[str, Any]: empty description in the UI payload. """ result = dict(payload) + # Legacy internal chunking aliases are not part of the verified create request. + result.pop("smartsplit", None) for key, default in _AIDP_CREATE_DEFAULTS.items(): if key not in result: result[key] = default @@ -910,6 +1045,36 @@ def _apply_create_defaults(payload: Dict[str, Any]) -> Dict[str, Any]: caption = result.get("caption_enable") if caption in (0, "0", False): result["vlm_model"] = "" + + # A disabled graph submits neither its configuration nor a hidden model + # selection; an enabled one is validated and serialized into the string + # payload AIDP expects. + if not result.get("is_exist_graph"): + result.pop("graph_config", None) + result.pop("llm_model_name", None) + else: + # Keep the internal legacy alias out of the upstream request body. + llm_model_name = result.pop("llm_model_name", None) + graph_config = result.get("graph_config") + if isinstance(graph_config, dict): + graph_config = dict(graph_config) + # Accept the legacy Nexent top-level property, but put it in the + # nested field required by AIDP's documented graph_config shape. + if llm_model_name is not None and "llm_model_name" not in graph_config: + graph_config["llm_model_name"] = llm_model_name + result["graph_config"] = _serialize_graph_config(graph_config) + elif isinstance(graph_config, str): + try: + parsed = json.loads(graph_config) + except ValueError as exc: + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "Invalid graph configuration JSON") from exc + if not isinstance(parsed, dict): + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "Graph configuration must be an object") + result["graph_config"] = _serialize_graph_config(parsed) + else: + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "Graph configuration is required") + + _validate_chunking(result) return result @@ -1347,6 +1512,60 @@ def remove_aidp_docs_impl( ) +def retry_aidp_docs_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuids: List[str], +) -> Dict[str, Any]: + """Retry one or more failed AIDP knowledge files using the documented array body.""" + normalized_url = _validate_params(server_url, api_key) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + retry_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Retry" + retry_url = urljoin(f"{normalized_url}/", retry_path) + logger.info("Retrying %d AIDP documents for KB %s", len(file_uuids), kds_id) + + 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( + retry_url, + headers=headers, + json={"file_uuids": file_uuids}, + ), + context=f"retry-docs:{kds_id}", + ) + response.raise_for_status() + result = response.json() + if not isinstance(result, dict): + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "Unexpected AIDP document retry response format", + ) + return result + except httpx.RequestError as e: + logger.exception("AIDP document retry 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 retry") + except ValueError as e: + logger.exception("Failed to parse AIDP document retry 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, @@ -1485,6 +1704,7 @@ def list_aidp_docs_impl( kds_id: str, page: int = 1, page_size: int = 10, + keyword: str | None = None, ) -> Dict[str, Any]: """List documents in a knowledge base via AIDP API.""" normalized_url = _validate_params(server_url, api_key) @@ -1495,6 +1715,8 @@ def list_aidp_docs_impl( } list_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles?page={page}&page_size={page_size}" + if isinstance(keyword, str) and keyword.strip(): + list_path += f"&keyword={quote(keyword.strip(), safe='')}" list_url = urljoin(f"{normalized_url}/", list_path) logger.info("Listing AIDP documents from %s", list_url) @@ -1781,6 +2003,9 @@ def list_aidp_doc_history_impl( kds_id: str, tenant_id: str | None = None, page: int = 1, + page_size: int | None = None, + status: int | None = None, + keyword: str | None = None, ) -> Dict[str, Any]: """List a page of a channel directory regardless of processing status. @@ -1792,10 +2017,9 @@ def list_aidp_doc_history_impl( chunked/embedded (``PROCESSING``) or that failed (``FAILED``), which is what lets the UI show an upload immediately instead of only after ingestion. - The endpoint is paginated (``page`` is one-based) and sorts files that are - still being processed to the front, so a burst of simultaneous uploads can - spill past the first page: callers must walk the pages instead of reading - only the first one. + The endpoint is paginated (``page`` is one-based) and supports filtering by + status and file-name keyword, allowing callers to request only the page the + user is viewing. """ normalized_url = _validate_params(server_url, api_key) normalized_page = page if isinstance(page, int) and page > 0 else 1 @@ -1841,6 +2065,9 @@ def list_aidp_doc_history_impl( "fs_id": normalized_fs_id, "dir_path": normalized_dir_path, "page": normalized_page, + **({"page_size": page_size} if isinstance(page_size, int) and page_size > 0 else {}), + **({"status": status} if isinstance(status, int) and not isinstance(status, bool) else {}), + **({"keyword": keyword.strip()} if isinstance(keyword, str) and keyword.strip() else {}), }, ), context=f"list-doc-history:{normalized_fs_id}", @@ -1949,7 +2176,9 @@ def list_aidp_models_impl( "Content-Type": "application/json", } - models_path = f"{_get_models_path()}?service={service}&app={app}" + models_path = f"{_get_models_path()}?app={quote(app, safe='')}" + if service: + models_path += f"&service={quote(service, safe='')}" models_url = urljoin(f"{normalized_url}/", models_path.lstrip("/")) logger.info("Fetching AIDP models from %s", models_url) @@ -1965,12 +2194,12 @@ def list_aidp_models_impl( ) response.raise_for_status() result = response.json() - if not isinstance(result, dict): + if not isinstance(result, (dict, list)): raise AppException( ErrorCode.AIDP_RESPONSE_ERROR, "Unexpected AIDP models response format", ) - raw_models = result.get("models") or [] + raw_models = result if isinstance(result, list) else result.get("models") or [] if not isinstance(raw_models, list): raise AppException( ErrorCode.AIDP_RESPONSE_ERROR, @@ -2020,6 +2249,35 @@ def list_aidp_models_impl( ) +def get_aidp_graph_template_impl( + server_url: str, api_key: str, language: str = "chinese", +) -> Dict[str, Any]: + """Read the live graph parameter defaults, constraints and domain templates.""" + if language not in {"chinese", "english"}: + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "Unsupported graph template language") + normalized_url = _validate_params(server_url, api_key) + url = urljoin(f"{normalized_url}/", f"{_get_list_path()}/GraphConfigTemplate?language={language}") + 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.get(url, headers={"Authorization": f"Bearer {api_key}"}), + context="graph-template", + ) + response.raise_for_status() + result = response.json() + if not isinstance(result, dict) or not isinstance(result.get("value"), list): + raise AppException(ErrorCode.AIDP_RESPONSE_ERROR, "Unexpected AIDP graph template response") + return result + except httpx.RequestError as exc: + raise AppException(ErrorCode.AIDP_CONNECTION_ERROR, "AIDP graph template request failed") from exc + except httpx.HTTPStatusError as exc: + _raise_aidp_http_error(exc, "graph template") + except ValueError as exc: + raise AppException(ErrorCode.AIDP_RESPONSE_ERROR, "Invalid AIDP graph template JSON") from exc + + def _get_retrieval_path(tenant_id: str | None = None) -> str: """Build the tenant-scoped retrieval (FusionSearch) API path.""" return f"/KnowledgeBase/Tenants/{_resolve_tenant_id(tenant_id)}/Retrieval/FusionSearch" diff --git a/docs/design/aidp-create-knowledge-tooltips.md b/docs/design/aidp-create-knowledge-tooltips.md new file mode 100644 index 0000000000..f62213e4a4 --- /dev/null +++ b/docs/design/aidp-create-knowledge-tooltips.md @@ -0,0 +1,76 @@ +# AIDP 创建知识库:问号提示文案补充 + +记录日期:2026-10-09。 + +来源:用户分两次提供的十八张 AIDP 页面截图。用户要求先记录这些问号提示,供后续开发使用;本次没有修改页面。 + +适用范围:AIDP 创建知识库页面。已有界面字段名称尽量保持;下面的位置名称用于定位提示,不代表要求重命名字段。ES 场景不在本次范围内。 + +## 提示原文与位置 + +以下按截图原文记录,包括原文的术语和标点,不自行改写。 + +| 编号 | 问号所在位置 | 提示原文 | +| --- | --- | --- | +| 1 | 文档切片模块标题 | 智能分析文档内容结构进行切片,保留段落完整性的切片策略 | +| 2 | 文本切片最长 Token | 文档切片允许包含的最长字符数量,仅针对智能分片模式生效。 | +| 3 | 相邻切片重叠字符百分比 | 在文本分块过程中,前一个切片的末尾部分与后一个切片的开头部分重复的字符数量占切片总字符数(或重叠部分长度)的比例,仅针对智能分片模式生效。 | +| 4 | 向量生成模块标题 | 设置向量生成,可选择向量化模型以及配置VLM模型。 | +| 5 | 向量化模型 | 一种将现实世界中的非结构化数据(比如文字、图片、声音)转换成计算机能理解的数字形式。 | +| 6 | VLM 模型 | VLM模型是融合图像理解与文本生产的多模态大模型,可统一处理图片、文字输入并输出自然语言回答。 | +| 7 | 检索匹配模块标题 | 设置检索匹配值,可设置相似度阈值以及向量检索TopK值。 | +| 8 | 相似度阈值 | 在检索任务中,从向量数据库或索引中检索出的与查询向量相似的百分比。 | +| 9 | 检索匹配模块中的向量检索 TOP K 值 | 在检索任务中,从向量数据库或索引中检索出的与查询向量最相似的前K个结果。 | +| 10 | 知识图谱模块标题 | 开启知识图谱后生成实体逻辑链路,可追溯实体上下游关联知识。 | +| 11 | 知识图谱中的 LLM 模型 | 用于理解和处理文本,帮助系统从知识库内容中提取实体及其关联关系。 | +| 12 | 子图扩展跳数 | PPR检索时子图扩展的跳数,范围1-3 | +| 13 | 原截图的“禁用LLM思考过程”字段 | 禁用LLM思考过程,LLM提取三元组时只返回提取结果,不输出完整思考过程,提升三元组提取效率,可选是或否 | +| 14 | 提示词语言 | 三元组抽取提示词语言,可选中文或英文 | +| 15 | 领域类型 | 业务领域,可选医疗\|金融\|常规\|法律法规 | +| 16 | 知识抽取提示词 | 自定义三元组抽取提示词,可参考模板来编写,长度限制为1-4096 | +| 17 | 同义词检测开关 | 建图时是否启用同义词检测,启用后相似实体间建立关系,可选是或否 | +| 18 | 实体消歧开关 | 建图时是否启用同名实体消歧,启用后相似实体会合并,可选是或否 | + +## 知识图谱模块内的说明条 + +第二批第 2 张截图还包含知识图谱模块下方的蓝色说明条,原文为: + +> 系统将在文件入库时提取实体及其关联关系,支持追溯实体的上下游知识。知识库建库后支持关闭知识图谱。 + +一并记录供后续使用;本次没有实施或验证创建后的知识图谱关闭功能。 + +## 使用约定 + +- 将上述文案放到对应问号的悬浮提示中;本记录不要求更改字段名称或页面布局。 +- 第 2、3 项原文明确说明仅针对智能分片模式生效。这里记录的是提示文案,不另行推导法律条文模式下的接口行为。 +- 第 2 项原文写“字符数量”,字段名称写 Token;第 6 项原文写“文本生产”。先保留截图文案,不在本次记录中自行更正。 +- 第 7 项提到的是“检索匹配”模块中的向量检索 TOP K,不是已经决定移除的知识图谱 TOP K。 +- 第 9 项同样属于检索匹配模块,不恢复知识图谱 TOP K。 +- 第 13 项按截图记录“禁用思考”的说明。Nexent 已有界面字段“构图过程开启大模型思考”与接口 `no_think_mode` 的含义相反;后续接入提示时必须保持语义一致,不能因沿用原文而把开启、禁用的对应关系弄反。本记录不要求改名。 +- 第 15 项的竖线为原文分隔符,Markdown 中仅作转义,展示内容不变。 +- 图谱参数、模型查询、创建请求及提示词模板联动属于另一组接口适配事项,不因本次文案记录而视为已经实施。 + +## 截图来源索引 + +用户附件原始目录:`C:/Users/cj/AppData/Local/Temp/`。提示原文已保存在本文件中,不依赖临时附件持续存在。 + +| 编号 | 原始附件文件名 | +| --- | --- | +| 1 | `codex-clipboard-195b2267-0b4f-4fea-bcaa-06dda7242ae2.png` | +| 2 | `codex-clipboard-072fcf90-ac77-475a-a635-d9fea707962d.png` | +| 3 | `codex-clipboard-2124f2d4-a595-4e2c-9646-de1c86048bf0.png` | +| 4 | `codex-clipboard-9831f027-4972-458c-a263-6cf69db3bdbc.png` | +| 5 | `codex-clipboard-9c601e5a-8236-4b05-806c-d85df4b16c12.png` | +| 6 | `codex-clipboard-3de7fa5c-8d48-46ae-9217-eeba66dc9d52.png` | +| 7 | `codex-clipboard-373e46a3-20fd-4f23-9e26-8729168f41fd.png` | +| 8 | `codex-clipboard-4ef3a95b-3190-4875-8053-66e163b2d49f.png` | +| 9 | `codex-clipboard-17583768-a947-42b1-b9bf-e2ef3d14d46f.png` | +| 10 | `codex-clipboard-ecd40e71-09db-4727-82b9-692ed9bd527f.png` | +| 11 | `codex-clipboard-dfea9c8c-8b4a-4a19-9316-6fa2fdffbc11.png` | +| 12 | `codex-clipboard-bcc542bc-631d-4a7a-aa91-c23aac694564.png` | +| 13 | `codex-clipboard-776e0df6-9583-4560-8ce9-da8fe51e2b3f.png` | +| 14 | `codex-clipboard-cad34f85-3cd4-4066-9659-0bced92e67d5.png` | +| 15 | `codex-clipboard-bd409a1e-4fec-4ceb-8ce9-4ac710afebc8.png` | +| 16 | `codex-clipboard-425e0747-0a83-465a-ab56-98d140b3fbcd.png` | +| 17 | `codex-clipboard-c0035bf9-2c9c-4afd-937f-7ece171bebf1.png` | +| 18 | `codex-clipboard-3af9ea73-b6fe-4c46-a7f5-24f5a4903aba.png` | diff --git a/docs/design/aidp-create-live-contract.md b/docs/design/aidp-create-live-contract.md new file mode 100644 index 0000000000..968a2981b9 --- /dev/null +++ b/docs/design/aidp-create-live-contract.md @@ -0,0 +1,60 @@ +# AIDP 创建知识库:联调接口适配 + +记录日期:2026-10-09。以用户提供的 AIDP 浏览器请求、返回和参数模板为依据,旧接口文档与这些信息冲突时采用实际接口信息。 + +## 本次行为 + +- 创建页通过一次 `ModelService/Tenants/aidp/Service?app=KnowledgeBase` 查询获取模型,按顶层 `model_type` 分为 embedding、vlm、llm。下拉显示 `display_name`,提交 `model_name`。不使用虚构的默认模型标识。 +- 开启知识图谱后查询 `KnowledgeBases/GraphConfigTemplate?language=chinese`;切换提示词语言后按 `english` 查询。接口的 `param_value` 用于初始值,`regexp` 用于校验和枚举选项,`template` 用于不同领域的默认提示词。 +- 默认提示词随语言和领域切换;用户手工修改的内容保持,点击“使用默认模板”才主动恢复当前模板。接口查询失败时提供重试,并阻止相应创建请求。 +- `is_modifiable` 保留在模板返回中,表示创建后参数是否支持修改,不据此禁止创建时选择语言、领域和提示词。本次不增加图谱编辑功能。 +- 知识图谱里的向量检索 TOP K 删除,不改成另一个未经确认的图谱字段。“检索匹配”模块里的 `topk` 保留,默认 10;相似度默认 0.6。其他已有字段显示名称尽量保持。 +- 十八个问号提示采用 [截图文案记录](aidp-create-knowledge-tooltips.md)。原界面“构图过程开启大模型思考”与接口禁用思考含义相反,提示补充说明正反对应关系。 +- 文件列表导入方式固定显示“本地导入 / Local import”,不读取 `import_source_dir` 或其他目录字段。 +- 列表和详情的创建人通过 `aidp_kb_permission_t.owner_user_id` 查询当前租户有效的 `user_tenant_t.user_email`,与 Nexent 用户管理所使用的账号名称一致。列表按当前页批量查询,不逐条查询用户。历史记录没有创建用户或用户不可用时显示“—”,不显示远端空 `user_name` 或用户 ID,不把当前浏览者当成创建人。 +- 更新时间、文件数、容量本次保持现状。ES 知识库不在本次范围。 + +## 创建请求 + +Nexent 前端提交结构化配置,后端将 `graph_config` 序列化成 JSON **字符串**,再调用 AIDP 的 `PUT /KnowledgeBase/Tenants/aidp/KnowledgeBases`。 + +开启图谱时,字符串内容只包含模板的七个参数及所选 LLM: + +```json +{ + "retrieve_subgraph_hop": "2", + "no_think_mode": "是", + "prompt_language": "中文", + "domain": "常规", + "prompt_text": "从模板接口获取的提示词或用户编辑后的内容", + "synonym_merge_enable": "否", + "disambiguation_enable": "否", + "llm_model_name": "从模型接口选择的 model_name" +} +``` + +数值跳数使用字符串;开关使用“是 / 否”;领域使用“医疗 / 金融 / 常规 / 法律法规”,语言使用“中文 / 英文”。提示词长度为 1–4096 个 Unicode 字符,不再按 2048 个 UTF-8 字节计算。界面开启思考时,`no_think_mode` 提交“否”。 + +外层保留 `is_personal`、`chunk_mode`、切片长度与重叠数、向量模型、`topk`、`similarity`、`caption_enable`、`vlm_model`、`is_exist_graph` 等实际请求字段。不再发送 `smartsplit`。Nexent 权限与用户组仍只写入本地权限表。关闭图谱后不发送其配置或隐藏的 LLM;关闭 VLM 后发送 `caption_enable=0` 和空 `vlm_model`。 + +## Mock 与验证边界 + +Mock 新增七项图谱模板返回、三类模型及其显示名和类型,创建时保存参数供详情回显,并严格拒绝旧图谱 TOP K、布尔值、英文枚举、非字符串跳数和无效提示词。新增回归检查覆盖这些联调报错,避免 mock 再次静默丢弃图谱字段。 + +验证包括前端表单查询与提交、模板联动及手工内容保留、真实表单控件的十八项提示与字符边界,以及后端转发到 mock 的创建和详情回读。用户查询按当前租户进行,不需要数据库迁移。 + +英文 mock 提示词是合成测试数据,并非正式 AIDP 的模板。另一台电脑联调时仍需验证英文模板真实返回、正式模型标识以及实际创建响应。本地 mock 通过不等同于正式 AIDP 联调通过。 + +2026-10-09 补充:中文 mock 原先使用一句话测试数据,造成页面默认提示词过短。现改为用户提供的完整中文接口响应,保存在 `test/ext_components/aidp/mock_servers/graph_config_template_chinese.json`,包含四个领域的完整模板。页面仍从接口读取,没有在产品前端或 Nexent 后端内置这四份提示词。 + +全局正式测试资产检查另有历史缺失绑定、已有脚本哈希不匹配等问题;本次新增的 `AKPR-D1-011` 实现单独登记,不将全局资产检查报告为通过。 + +## 本次检查结果 + +- AIDP 服务、管理路由、mock 和创建人查询回归:404 项通过。 +- 用户数据库回归:52 项通过,包含新增的租户过滤检查。 +- 前端 AIDP 组件与上传相关回归:19 项通过,包含真实创建表单的十八项提示及 Unicode 字符边界检查。 +- `npm run type-check` 通过;本次修改的 AIDP 组件、服务与图谱工具 ESLint 检查通过;backend 修改文件的 Ruff 和 frontend 修改文件的 Prettier 检查通过。 +- 更广的 ESLint 检查在 `frontend/services/api.ts`、`frontend/types/agentConfig.ts` 中报告 10 处既有 `no-explicit-any`,这些行不属于本次新增代码。 +- 正式测试资产设计阶段检查通过,并重新生成派生 Excel。实现阶段全局检查仍报告 24 项既有绑定缺失或哈希不一致;本次新增的创建表单绑定没有出现在错误报告中。 +- 本次未重启本地 deploy 服务,也未在正式 AIDP 环境执行浏览器联调。 diff --git a/docs/design/aidp-knowledge-list-column-widths.md b/docs/design/aidp-knowledge-list-column-widths.md new file mode 100644 index 0000000000..fcaa0f8116 --- /dev/null +++ b/docs/design/aidp-knowledge-list-column-widths.md @@ -0,0 +1,35 @@ +# AIDP 知识库列表列宽规则 + +更新日期:2026-10-09。 + +本次只调整 AIDP 知识库列表的表格模式。知识库详情内的文件、上传任务表格已交接给其他开发人员,不在本次修改范围内。卡片模式和 ES 知识库不变。 + +## 拖动列宽 + +- 在相邻表头之间放置拖动区域,鼠标悬浮显示左右调整光标和分隔线。 +- 向右拖动:左列增加,右列减少;向左拖动:左列减少,右列增加。 +- 每次只调整分界线两侧的两列,其他列保持宽度,表格总宽不变。 +- 所有列最小宽度为 64px。任意一侧到达下限后停止移动,不继续挤压第三列。 +- 最后一列右边没有相邻列,不提供拖动区域。操作列可以通过它左侧的分界线调整。 +- 隐藏列不参与分配,拖动影响的是当前可见的相邻列。 +- 拖动期间不选中文字,不触发行点击;松开、取消或离开页面时恢复光标。 +- 拖动区域支持键盘聚焦,左右方向键每次移动 8px,同样遵守最小宽度。 + +示例:名称 240px、类型 120px,分界线向右移动 40px 后分别为 280px、80px。继续向右时,类型最多缩至 64px,名称最多增至 296px。 + +## 容器宽度变化 + +- 按表格容器实际宽度计算,包含窗口变化和侧栏展开、收起。 +- 使用用户调整后的比例重新分配可用宽度。 +- 按比例计算不足 64px 的列固定为 64px,剩余空间按其他列的比例分配。 +- 容器小于可见列数乘以 64px 时,保留最小宽度并提供横向滚动。 +- 名称固定在左侧、操作固定在右侧。 +- 隐藏、恢复列时保留对应的宽度偏好。当前偏好保存在组件内存中,刷新页面恢复默认比例。 + +## 内容与操作 + +- 表头和文字单元格保持单行,超出显示省略号;鼠标悬浮显示完整内容。 +- 权限列空间足够时保留现有标签,空间不足时使用可省略的文字摘要;悬浮展示权限和有权查看的用户组名称。 +- 操作列小于 144px 时使用三个点菜单,保留导入文件、删除及现有权限限制;宽度足够时保留文字按钮。 + +本规则取代知识库列表旧方案中“只改变当前列、其他列不变、拖动使表格总宽变化”的说明。详情页列宽规则需由接手同事单独确认,不随本次改动改变。 diff --git a/docs/design/aidp-retrieval-metadata-investigation.md b/docs/design/aidp-retrieval-metadata-investigation.md new file mode 100644 index 0000000000..629f037c2c --- /dev/null +++ b/docs/design/aidp-retrieval-metadata-investigation.md @@ -0,0 +1,64 @@ +# AIDP 知识检索元数据:接口核对与后续需求记录 + +记录日期:2026-10-09。 + +状态:已核对现有接口文档和代码;用户要求先记录,后续再看,暂不开发。 + +核对代码:`feat-knowledge-base-pages-refactor`,业务代码提交 `0cf1f44b4`。 + +## 需求背景 + +用户了解到 AIDP 知识检索接口将增加一些元数据字段,希望后续智能体对话使用 AIDP 知识库搜索时,也能在右侧检索来源面板展示相关信息,参考当前 ES 知识库的展示方式。 + +本次只确认已有接口是否包含元数据、现有代码是否保留这些数据,没有确定新增字段的名称和页面展示方案。 + +## 原始接口文档结论 + +来源:`C:\Users\cj\Downloads\AIDP-26.0.0-1790238797166.xls`,工作表 `AIDP`。融合检索接口的方法和地址在第 6075 行,示例在第 6077 行,元数据过滤参数在第 6085 行,返回字段说明在第 6100–6109 行。 + +Nexent 当前调用的是融合检索接口: + +```text +POST /KnowledgeBase/Tenants/{tenant_id}/Retrieval/FusionSearch +``` + +现有文档已经包含以下字段: + +| 位置 | 字段 | 文档含义及限制 | +| --- | --- | --- | +| 请求 | `metadata_condition` | 可选字典,元数据过滤条件;融合检索这一节未给出内部条件的完整字段定义 | +| 返回 | `result` | 检索结果列表 | +| 返回 | `total_return_count` | 返回结果数量 | +| 每条结果 | `title` | 切片所属文件名称 | +| 每条结果 | `id` | 切片主键,不能直接当成原始文件 ID | +| 每条结果 | `score` | 切片得分,范围 0–1 | +| 每条结果 | `text` | 切片内容 | +| 每条结果 | `metadata` | 元数据字典,示例为 `{}`,未说明内部键名、类型和含义 | +| 每条结果 | `chunk_type` | `text`、`image`、`table` | +| 每条结果 | `file_url` | 文档说明为图片文件地址,不能假定是原始文档下载地址 | +| 每条结果 | `pages` | 切片所属文件页码列表,未说明从 0 还是从 1 开始 | + +因此,现有接口已经有承载元数据的字段,但文档不足以确认即将新增的具体内容。“增加元数据字段”可能是补充现有 `metadata` 对象里的内容,这只是推测,需以 AIDP 更新后的文档或真实响应为准。 + +文档另有 Dify 检索接口 `POST /KnowledgeBase/Tenants/{tenant_id}/Retrieval/FusionSearch/retrieval`,也包含 `metadata_condition` 和结果 `metadata`。这不是 Nexent 当前调用的接口。其中出现的 `source`、`department`、`tags` 属于过滤条件示例,不能当成融合检索必定返回的标准字段。 + +## 当前代码如何处理这些字段 + +1. `sdk/nexent/core/ext_components/aidp/aidp_search_tool.py` 调用融合检索接口,并在 `_build_chunk_message` 中读取 `metadata`、`pages`、`chunk_type`、`file_url`、`id`、`score`。其中元数据保存在 `score_details.metadata`,页码保存在 `score_details.pages`;完整 UI 结果通过 `ProcessType.SEARCH_CONTENT` 发出。SDK 已经保留元数据。 +2. `frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts` 在转换检索结果时,仅取出部分字段,例如图片类型、检索高亮词、文件名和文本。传给来源面板的数据没有保留完整的 `score_details`、任意元数据、页码和得分。正常流式解析和末尾缓冲解析都有同类转换,后续修改需要同时检查。 +3. `frontend/app/[locale]/newchat/ui/sources-panel.tsx` 的 `PanelSourceItem` 没有元数据、页码和得分字段,当前来源卡片也没有任意元数据的展示逻辑。 + +当前问题主要在前端数据转换和来源面板展示,不能说 SDK 已经丢掉了这些字段。后续还需检查历史消息恢复路径,确保重新打开对话后能够展示;本次没有做这条路径的端到端验证。 + +## 后续待确认内容 + +- 获取 AIDP 更新后的融合检索响应样例或字段说明,确认新增元数据的名称、类型、含义,以及哪些字段可能为空。 +- 根据真实字段决定右侧面板展示哪些信息、顺序如何、缺失值如何处理。不要先编造知识库 ID、文件 ID 或原始文件下载地址的对应关系。 +- 如果展示页码,确认页码起点;如果提供下载或跳转,确认接口给出的地址用途。 +- 当前机器没有正式 AIDP 环境;mock 样例只能用于验证页面,不能证明 AIDP 新接口的实际返回。 + +## 后续开发建议 + +确认真实字段后,再补齐前端流式解析、来源数据和历史消息恢复的数据传递,并扩展现有右侧来源面板。对应更新 mock 和必要验证,覆盖文本、图片、表格切片,以及元数据缺失的情况。AIDP 新展示需保持 ES 知识库现有行为。 + +本记录不代表已确定最终页面设计,也不代表已经实施该需求。 diff --git a/docs/handoffs/2026-10-09-aidp-knowledge-detail-handoff.md b/docs/handoffs/2026-10-09-aidp-knowledge-detail-handoff.md new file mode 100644 index 0000000000..5dfb69c832 --- /dev/null +++ b/docs/handoffs/2026-10-09-aidp-knowledge-detail-handoff.md @@ -0,0 +1,344 @@ +# AIDP 知识库详情页开发与联调交接 + +更新日期:2026-10-09。交接对象:接手知识库详情页前端、Nexent 后端代理及真实 AIDP 联调的开发同事。 + +## 1. 从哪里开始 + +| 项目 | 交接基线 | +| -------------- | --------------------------------------------------------------------------- | +| 仓库 | `ModelEngine-Group/nexent` | +| 远端分支 | `feat-knowledge-base-pages-refactor` | +| 业务代码提交 | `92cf2d8d2c51b61e4fa4b05d81b89c911b71fe57` | +| 功能范围 | 仅 AIDP 知识库详情页,包括文件、上传任务、详细信息、编辑、权限、导入抽屉 | +| 不属于本次接手 | ES 知识库;知识库列表和创建页的独立需求;图谱可视化、文件预览、切片内容编辑 | +| 当前验证环境 | 本地 AIDP mock;尚不能据此认定真实 AIDP 已联调通过 | + +当前分支包含知识库列表、创建和详情的完整改造。建议接手者从此基线新建自己的详情页分支,保留完整运行依赖;不要只复制详情组件或者随意挑选几条提交。 + +下面的分支名是建议名称,可以换为团队约定的名称: + +```bash +git fetch origin +git switch -c feat-aidp-knowledge-detail-followup 92cf2d8d2c51b61e4fa4b05d81b89c911b71fe57 +``` + +这不是一条“只包含详情改动、可独立合入 develop”的分支。若需要那种交付方式,要另外整理公共依赖和提交,不能把上面的新分支当成已经完成代码拆分。 + +交接后的职责:接手者负责详情页前后端和 AIDP 联调;原开发者继续负责列表、创建页。修改共用组件、接口返回类型或权限服务时,双方需要同步评估其他页面。 + +## 2. 文档优先级与交接资料 + +本说明记录截至基线提交的当前行为、用户最新决定、已发现差异与联调任务。历史设计稿和原型用于补充视觉与接口证据,不能覆盖后续已经确认的修改。 + +原始资料目前在交接人的工作区 `uploads/`,不在 Git 分支中;只拉代码不会拿到这些资料。因此本次另提供附件包,包含: + +| 附件包内位置 | 内容 | +| --------------------------------------------------------------------- | -------------------------------------------------------------- | +| 根目录的本交接说明 | 当前范围、代码入口、接口和验收清单 | +| `references/knowledge-base-detail-design/` | 历史详情设计 `design.md`、`proposal.md`、`tasks.md` | +| `references/knowledge-base-detail-design/references/detail-files.png` | 详情概览与文件页原型 | +| 同目录 `detail-tasks.png` | 上传任务原型 | +| 同目录 `detail-failure-drawer.png` | 失败详情原型 | +| 同目录 `detail-information.png` | 详细信息原型 | +| 同目录 `AIDP-26.0.0-1790238797166.xls` | AIDP 接口文档 | +| `references/knowledge-base-import-design/` | 导入抽屉设计和 UCD 测量记录 | +| `local-dev-guide/DEV-GUIDE.md` | 交接人电脑的本地启动说明;另一台电脑需替换路径、端口配置和凭据 | + +历史详情稿日期为 2026-09-29,仍写“尚未开发”,并包含后来取消的安全护栏。导入稿也仍有“尚未部署”等旧状态。阅读这些资料时,以本说明中的当前状态为准。 + +重要的后续决定:安全护栏已取消;相似度阈值只读;导入选择文件即上传、底部按钮为“确定”;上传区不再有独立“选择本地文件”按钮;用户组只读展示最多一行;搜索、分页使用 AIDP 能力,不在 Nexent 全量读取后处理。 + +## 3. 页面行为与数据来源 + +### 3.1 入口与顶部概览 + +- 从知识库列表点击知识库名称或卡片进入详情,列表不提供编辑入口。 +- 详情占满内容区,面包屑为“知识库 / 知识库详情”;当前由列表容器切换到详情组件,不能假定它是一个独立的 `/detail` 路由。 +- 顶部展示名称、描述、文件数量、私有/共享、可访问用户组、创建时间、创建人;可编辑用户有名称旁的编辑入口及删除入口。 +- 顶部编辑弹窗直接展示名称、描述、权限与用户组,不再放入折叠的高级选项。 +- 顶部“权限与用户组”当前只读值主要展示私有/共享;组内可编辑或只读的详细设置在“详细信息”的权限区展示。 + +### 3.2 文件页 + +- 数据来自 AIDP 的已入库文件接口,不能用上传任务 History 替代,也不将文件列表写入 Nexent PG。 +- 列为名称、类型、大小、导入方式、首次上传时间、更新时间、操作;操作为下载、删除。 +- 名称搜索、页码与每页数量传给后端,再由后端传给 AIDP。不要新增本地全量搜索。 +- 文件类型、大小、导入方式和时间的筛选/排序,只能在上游支持并确认参数后增加。当前不显示这些未经确认的能力。 +- 分页在右侧,可选每页数量;名称和操作列固定,表格中间横向滚动。列宽拖动尚未实现,见第 9 节。 + +### 3.3 上传任务页 + +- 数据来自 AIDP History;AIDP 负责只返回近 30 天任务。页面提醒任务只保留近 30 天,Nexent 不再做额外日期过滤。 +- 上传后先有任务记录;成功入库后,文件才属于文件页。成功任务在保留期内仍可查询。 +- 列为名称、类型、大小、更新时间、状态、操作;文件名称可搜索。 +- 状态筛选位于“状态”表头,单选,必须包含“全部”。 +- 请求 `status` 映射固定为:全部 0、成功 1、提取中 2、向量入库失败 3、排队中 4、图谱入库失败 5。没有独立“失败”筛选选项。 +- 统计使用 AIDP 返回的统计字段,不从当前页计算总数、提取中、失败或成功数量。 +- 顶部没有批量重试按钮。失败行有单文件“重新提取”和“失败详情”;只读用户不应提交重试。 +- 失败详情从右侧抽屉展示文件名称、状态、任务更新时间、错误码、原因;缺失值显示“—”。当前实现还在抽屉底部提供单文件重试,历史稿对此有矛盾说明,见第 9 节。 + +请求状态数字与任务记录返回的状态不是同一份约定。返回的 `FAILED` 暂显示为向量入库失败,`UPLOADING` 暂显示为排队中;这是用户授权的临时映射,需真实环境核对。不要将未识别的状态直接判断为成功。 + +### 3.4 详细信息页 + +| 模块 | 展示或修改范围 | +| ------------ | ------------------------------------------------------------------------------------------------ | +| 基础信息 | 知识库 ID、创建时间、更新时间、创建人 | +| 配置参数 | 切片方式、向量化模型、VLM 开启状态与模型、Rerank 模型、相似度、向量检索 Top K | +| 可修改设置 | 当前只有切片方式 `chunk_mode` 和向量 Top K `topk`;Top K 使用创建页同款滑块与数值控件 | +| 只读设置 | 相似度阈值、模型及其他没有明确修改参数的配置,不额外提交这些字段 | +| 知识图谱 | LLM 模型、子图扩展跳数、领域、图谱检索 Top K、思考、同义词合并、语义消歧、提示词语言、提示词模板 | +| 权限与用户组 | 私有/共享、组内权限、可访问用户组;此处编辑弹窗只有权限与用户组,没有名称、描述 | + +`chunk_mode=0` 为智能分片,`chunk_mode=1` 为法律条文。向量生成的 embedding、VLM 与构图 LLM 是不同用途的字段,不能混成一个模型选择。 + +用户组只读展示已统一到一个组件:最多一行、最多两个标签,其余用 `+N`;长名称截断,可悬停看完整名称;点击 `+N` 打开有滚动条的完整名单。私有库显示仅创建人可访问;共享库名称无法解析时当前会回退到组 ID。编辑仍使用多选框。 + +### 3.5 数据归属 + +- 文件内容、已入库文件、上传任务、提取状态和知识库处理配置由 AIDP 管理。 +- Nexent PG 保存本地知识库授权关系及用户组关系;它们用于限制用户能查看、编辑哪些 AIDP 知识库。 +- 单库详情经过现有 AIDP 详情缓存读取,并合并本地权限。文件和任务列表按页面请求查询上游;不要把“有缓存的单库配置”和“全量落库文件列表”混为一谈。 +- 不能为补齐页面空值,在 PG 新增文件或任务历史副本。字段缺失应显示“—”,并记录真实返回供适配。 + +## 4. 前端代码入口 + +路径相对仓库根目录;下面链接可在仓库中直接打开。 + +| 文件 | 职责与接手注意点 | +| ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| [AidpKnowledgeConfiguration.tsx](../../frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx) | 列表容器选择知识库后渲染详情;返回、删除、更新回调由容器提供。不是整文件归详情页独占 | +| [AidpKnowledgeDetail.tsx](../../frontend/ext_components/aidp/components/AidpKnowledgeDetail.tsx) | 主详情组件;顶部概览、请求与状态、文件/任务表、失败抽屉、配置编辑、导入入口。仍包含较多逻辑 | +| [AidpKnowledgeDetailInformation.tsx](../../frontend/ext_components/aidp/components/AidpKnowledgeDetailInformation.tsx) | 基础信息、配置、图谱、权限区;导出 `AidpDetailField` 供顶部复用 | +| [AidpGroupNamesDisplay.tsx](../../frontend/ext_components/aidp/components/AidpGroupNamesDisplay.tsx) | 一行用户组标签、`+N` 完整名单;顶部和权限区共用 | +| [AidpUpdateKbModal.tsx](../../frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx) | `metadata` 与 `permissions` 两种编辑模式;只发送发生变化的名称/描述,处理权限已保存但 AIDP 元数据失败的结果 | +| [AidpKnowledgeBaseModalParts.tsx](../../frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx) | 编辑/创建共用的基础字段、权限字段与弹窗布局 | +| [AidpImportDrawer.tsx](../../frontend/ext_components/aidp/components/AidpImportDrawer.tsx) | 列表与详情共用的文件导入抽屉;选择即上传、结果行、进度、确认/取消 | +| [AidpImportDrawer.module.css](../../frontend/ext_components/aidp/components/AidpImportDrawer.module.css) | 导入抽屉、文件行、边框与进度布局 | +| [AidpSliderNumberField.tsx](../../frontend/ext_components/aidp/components/AidpSliderNumberField.tsx) | 创建和详情 Top K 编辑共用控件 | +| [AidpPagination.tsx](../../frontend/ext_components/aidp/components/AidpPagination.tsx) | 列表、文件、任务共用分页;支持可靠总数与只知道还有下一页的情况 | +| [useAidpGroupOptions.ts](../../frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts) | 根据账号角色获取可配置用户组,映射 ID/名称 | +| [aidpKnowledgeService.ts](../../frontend/ext_components/aidp/services/aidpKnowledgeService.ts) | 前端接口方法、类型和操作结果整理;多个页面共用 | +| [services/api.ts](../../frontend/services/api.ts) | `API_ENDPOINTS.aidpMgmt` 定义请求路径与代理前缀 | +| [aidpKnowledgeDisplay.ts](../../frontend/lib/aidpKnowledgeDisplay.ts) | 缺失值、可靠文件计数等公共展示规则 | +| `frontend/public/locales/zh/common.json`、`en/common.json` | `aidpKnowledge.*` 文案;新增文案需同时维护中英文 | + +接手后若继续拆组件,建议从主组件中逐步拆顶部、文件表、任务表和失败抽屉;先保持现有请求参数、回调与权限校验不变。代码拆组件与交接是两件事,不必在联调前整体重写。 + +## 5. 后端代码入口与接口对应 + +| 文件 | 职责 | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| [aidp_mgmt_app.py](../../backend/ext_components/aidp/apps/aidp_mgmt_app.py) | HTTP 路由、请求校验、当前用户/租户、权限校验、接口返回组合;列表/创建/详情共用 | +| [aidp_service.py](../../backend/ext_components/aidp/services/aidp_service.py) | AIDP HTTP 请求、字段整理、文件与任务分页、上传/下载/删除/重试 | +| [aidp_kb_update_service.py](../../backend/ext_components/aidp/services/aidp_kb_update_service.py) | 先保存本地权限,再同步明确改变的名称/描述;处理部分成功 | +| [aidp_permission_service.py](../../backend/ext_components/aidp/services/aidp_permission_service.py) | 可见范围与 READ/EDIT 判断、用户组校验 | +| [aidp_permission_db.py](../../backend/ext_components/aidp/database/aidp_permission_db.py) | 本地授权记录读写,不是 AIDP 文件数据库 | +| [aidp_access_service.py](../../backend/ext_components/aidp/services/aidp_access_service.py) | AIDP 目录/详情/计数缓存及失效;修改成功后需维持刷新逻辑 | + +下表的 Nexent 路径省略前端反向代理前缀;前端实际地址以 `API_ENDPOINTS` 为准。AIDP 路径省略 `/KnowledgeBase/Tenants/{tenant_id}`。Nexent 当前登录租户与配置中的 `AIDP_TENANT_ID` 不能当成同一个参数。 + +| 页面操作 | Nexent 接口 | 请求重点 | AIDP 对应接口 | +| ---------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------- | +| 单库详情 | GET `/aidp-mgmt/knowledge-bases/{id}` | 本地 READ 权限;合并 `permission/group_ids/ingroup_permission` | GET `/KnowledgeBases/{id}` | +| 修改切片/Top K | PUT `/aidp-mgmt/knowledge-bases/{id}` | `chunk_mode` 0/1、`topk` 1–100;仅提交所改字段 | PATCH `/KnowledgeBases/{id}` | +| 顶部名称/描述/权限编辑 | PATCH `/aidp-mgmt/aidp-permissions/{id}` | 必须有组内权限,名称/描述只在改变时发送 | 本地权限先保存;有元数据变化才 PATCH AIDP 单库 | +| 权限区编辑 | 同上 | 只发 `ingroup_permission/group_ids` | 不调用 AIDP 元数据修改 | +| 删除库 | DELETE `/aidp-mgmt/knowledge-bases/{id}` | EDIT;删除成功清理本地授权、失效相关缓存 | DELETE `/KnowledgeBases/{id}` | +| 已入库文件 | GET `/aidp-mgmt/knowledge-bases/{id}/files` | `page/page_size/keyword` | GET `/KnowledgeBases/{id}/KnowledgeFiles` | +| 未筛选文件总数 | 由文件列表接口处理 | 无 keyword 时使用 Count;不能拿未筛选 Count 充当搜索总数 | POST `/KnowledgeBases/{id}/KnowledgeFiles/Count` | +| 上传任务 | GET `/aidp-mgmt/knowledge-bases/{id}/upload-tasks` | `page/page_size/keyword/status` | POST `/KnowledgeBases/{id}/KnowledgeFiles/History` | +| 任务来源信息 | 后端内部调用 | 获取本地上传 `fs_id`;History 的 `dir_path=/{id}` | GET `/KnowledgeBases/{id}/Channels` | +| 单文件重新提取 | POST `/aidp-mgmt/knowledge-bases/{id}/upload-tasks/retry` | `file_uuids` 非空 UUID 数组;页面发一个元素 | POST `/KnowledgeBases/{id}/KnowledgeFiles/Retry` | +| 导入文件 | POST `/aidp-mgmt/knowledge-bases/{id}/documents` | multipart 同名 `files` 字段,可多个;EDIT | POST `/KnowledgeBases/{id}/KnowledgeFiles/Upload` | +| 删除文件 | POST `/aidp-mgmt/knowledge-bases/{id}/documents/remove` | `file_uuids` 数组;页面单文件 | POST `/KnowledgeBases/{id}/KnowledgeFiles/Remove` | +| 下载文件 | POST `/aidp-mgmt/knowledge-bases/{id}/documents/download` | `file_uuid`;READ;响应文件流 | POST `/KnowledgeBases/{id}/KnowledgeFiles/Download` | + +现有 `/documents` GET 是旧混合文件视图兼容入口。新详情文件表使用 `/files`,上传任务表使用 `/upload-tasks`。不要为复用旧逻辑把两个新表重新合并。 + +### 5.1 分页返回与字段 + +文件列表返回 `value`、`total_count`、`total_reliable`、`has_more`。没有名称筛选时 Count 能提供可靠总数;名称搜索时未确认上游有可靠的筛选总数,后端根据 `next_link` 等判断还有没有下一页,总数可能只是下界。前端应尊重 `total_reliable`,不能把估计值展示成准确总数。 + +任务列表返回 `value`、`total_count`、`total_reliable`、`has_more`、`stats`、`retention_days=30`。统计映射为: + +| Nexent 字段 | AIDP 字段 | +| ------------------ | ---------------------------------------------- | +| `stats.total` | `total_record_count`,缺失时当前回退到列表总数 | +| `stats.extracting` | `processing_record_count` | +| `stats.failed` | `failed_record_count` | +| `stats.success` | `success_record_count` | +| `stats.queued` | `queued_record_count` | + +任务明细重点字段:`file_uuid`、`file_name`、`file_type`、`file_size`、`status`、`updated_at`、`error_code`、`extraction_failure_reason/reason`。时间、来源和异常字段需按真实响应核对,不能根据 mock 中“字段完整”推断上游一定返回。 + +文件名称搜索和 History 请求每次只读取所需页;重试不再预先扫描整份 History。缺少上游能力时记录问题,不要重新加入全量抓取、前端过滤或重复的 30 天过滤。 + +### 5.2 请求示例与部分成功 + +以下 ID 仅说明结构,联调时要使用环境中真实知识库、文件 UUID 和用户组 ID。 + +```json +{ "file_uuids": ["00000000-0000-4000-8000-000000000001"] } +``` + +```json +{ "ingroup_permission": "READ_ONLY", "group_ids": [101, 102] } +``` + +顶部编辑若名字发生变化,向权限请求额外加入 `name`;未变化就省略。描述的清空和未修改不同:需要清空时传空字符串,未修改时不传。 + +本地权限先保存,随后 AIDP 元数据同步可能失败。返回中的 `permissions_saved` 和 `metadata_status` 表示各自结果;`metadata_status=failed` 时不能提示“全部保存成功”或把名称改成新值,也不能假定权限被回滚。 + +上传/删除/重试结果使用 `summary`、`success_list`、`failed_list`。HTTP 200 不等于全部文件成功,必须读取结果列表;真实 AIDP 操作响应和部分成功结构仍需验证。 + +## 6. 导入文件的现行方案 + +1. 点击或拖入上传区域选择文件;没有单独的选择文件按钮。 +2. 同次有效选择作为一次多文件 multipart 请求,浏览器先提交到 Nexent 后端。 +3. FastAPI 以 `UploadFile` 接收文件,后端将 `UploadFile.file` 作为文件对象交给 httpx,再提交给 AIDP。框架可能有请求期临时文件,但没有独立暂存业务接口、MinIO 暂存或“确认后才开始上传”的流程。 +4. 文件行的圆环使用这一请求的整体 XHR 上传百分比,每个上传中行显示相同百分比;第二行只显示该文件总大小,不显示已上传字节数,也不显示“本批进度”字样。 +5. XHR 上传达到 100% 只表示浏览器向 Nexent 的请求体传输结束;此时仍可能在等待 Nexent/AIDP 结果。不能据此提前显示上传成功,更不等于解析入库完成。 +6. 后端返回后,按 `success_list/failed_list` 更新各行;上传成功表示 AIDP 接受上传,后续处理状态去任务页查询。 +7. 底部“确定”只关闭,不发第二次上传。存在待上传、上传中或等待接口结果时保持禁用;上传期间关闭、取消、遮罩关闭和 Esc 也被限制。 +8. 只有失败行有删除按钮,删除只移除当前抽屉里的前端记录,不调用文件删除接口;没有重新上传按钮。 + +最多 50 条,失败条目也占名额;TXT、Excel、CSV 不超过 20 MB,其他受支持文件不超过 1024 MB。格式范围及具体校验以 `frontend/services/uploadService.ts` 和后端 `_validate_upload_files` 为准,调整限制时两侧同步。 + +文件列表在抽屉内独立滚动,顶部上传区域与底部按钮保持可见;待上传/上传中/失败文件行灰色虚线边框,成功行明显黑色实线。沿用 Nexent 字体与图标。 + +刷新浏览器会中断当前客户端请求;是否已有文件被 AIDP 接受不能仅据取消请求判断。因此不开放上传中删除,也不把浏览器中断解释为可靠撤回。真实环境需测试大文件、超时和部分失败。 + +## 7. 环境与启动交接 + +### 7.1 配置与页面入口 + +后端代码工作区根目录 `.env` 要开启 AIDP,并使用真实 AIDP 地址或 mock 地址。下面只有 mock 示例,不包含真实凭据: + +```dotenv +DEPLOYMENT_VERSION=full +ENABLE_AIDP_KNOWLEDGE=true +AIDP_SERVER_URL=http://127.0.0.1:30081 +AIDP_API_KEY=mock-aidp-key +AIDP_TENANT_ID=aidp +``` + +实际 PG、Supabase、JWT 和 AIDP 凭据由环境负责人提供,不随文档发送。运行后可检查 config 服务 `/tenant_config/deployment_version` 返回的 `enable_aidp_knowledge` 是否为 true。 + +当前基线的 `/zh/knowledges` 会根据配置选择 AIDP 或 ES;另有显式 AIDP 入口 `/zh/aidp-knowledges`。旧 DEV 文档将 `/zh/knowledges` 固定写成 ES,这个入口描述已不能原样套用。验收时结合配置与实际网络请求确认使用 `/aidp-mgmt/...`,不要仅凭 URL 判断。 + +AIDP 知识库最小环境需要主 PG 及 Full 登录所需 Supabase 服务、config、runtime、真实 AIDP 或 mock。该知识库路径不要求为了文件导入额外启动本地 ES、MinIO、Redis 和 data-process;项目其他功能如有依赖另行处理。 + +### 7.2 交接人电脑的运行方式 + +- Windows 前端:`C:/Users/cj/IdeaProjects/nexent/worktrees/deploy/frontend`,端口 3000。 +- WSL 后端:`/mnt/c/Users/cj/IdeaProjects/nexent/worktrees/deploy/backend`,config 5010、runtime 5014。 +- WSL mock:同一部署 worktree 的 `test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py`,端口 30081。 +- 前后端都从 `worktrees/deploy` 的同一提交启动;切换提交前先停止两侧服务。开发代码在功能 worktree 修改。 +- 固定历史数据根为 `C:/Users/cj/nexent-data`,不得通过改成空目录来修复登录。Docker 配置与历史数据不属于代码交接内容。 + +另一台电脑按自己的路径、解释器和数据库配置启动。下面是已有环境配置完成后的命令,不替代首次安装说明。 + +WSL/Python 环境,在仓库根目录启动 mock: + +```bash +python -u test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py --port 30081 +``` + +两个独立 WSL 终端,在 `backend/` 启动: + +```bash +python -u config_service.py +``` + +```bash +python -u runtime_service.py +``` + +Windows PowerShell,在 `frontend/` 启动;对齐后端端口: + +```powershell +$env:HTTP_BACKEND = 'http://127.0.0.1:5010' +$env:WS_BACKEND = 'ws://127.0.0.1:5014' +$env:RUNTIME_HTTP_BACKEND = 'http://127.0.0.1:5014' +npm run dev +``` + +mock 状态保存在 `test/ext_components/aidp/mock_servers/_state/knowledge_bases.json`,重启不会清空。`POST /_reset` 会恢复种子数据,不要在保留演示数据的环境随意调用。 + +## 8. 测试与已有验证记录 + +当前交接前最近一次实际运行:AIDP 前端组件/服务测试 3 个文件、13 项通过;`npm run type-check` 和本次改动的 Prettier 检查通过。本地浏览器已确认详情页用户组单标签样式;当前演示知识库只有 Default Group,多个组的浮层行为由组件测试覆盖,尚没有该数据下的浏览器验收记录。 + +这不代表生产构建、所有后端测试或真实 AIDP 联调已在同次验证通过。接手者按修改内容补做检查。 + +前端,在 `frontend/`: + +```bash +npm run type-check +npm exec vitest -- run --config vitest.aidp.config.ts +``` + +后端,在已配置项目 Python 测试环境的仓库根目录: + +```bash +python -m pytest test/ext_components/aidp/test_aidp_mgmt_app.py test/ext_components/aidp/test_aidp_service.py test/ext_components/aidp/test_aidp_mgmt_mock_server.py +``` + +相关资产: + +- [后端路由测试](../../test/ext_components/aidp/test_aidp_mgmt_app.py):已包含文件搜索单页转发、任务参数转发/统计、重试不预读 History 的检查;旧兼容 `/documents` 的全量历史测试不等于新接口行为。 +- [AIDP 服务测试](../../test/ext_components/aidp/test_aidp_service.py)和 [mock 测试](../../test/ext_components/aidp/test_aidp_mgmt_mock_server.py)。 +- [导入抽屉测试](../../test/automation/d1/AidpImportDrawer.test.tsx)、[上传服务测试](../../test/automation/d1/AidpUploadService.test.tsx)、[用户组展示测试](../../test/automation/d1/AidpGroupNamesDisplay.test.tsx)。 +- [详情功能规则](../../test/features/aidp-knowledge-detail.yaml)、[需求记录](../../test/changes/requirements/aidp-knowledge-detail.yaml)、[缺陷记录](../../test/changes/bugs/aidp-knowledge-detail.yaml)。 +- `test/cases/d1` 至 `d5` 下的 `aidp-knowledge-detail.yaml` 是分层验收定义,不可因为有文件就认为相关自动化已经全部执行。 + +## 9. 需要接手者继续处理或核对的事项 + +### 9.1 真实 AIDP 联调 + +| 编号 | 验证内容 | 当前做法及需要记录的证据 | +| ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| A-01 | 六个任务筛选值及返回状态 | 请求数字 0–5 已由用户实测确认;记录每类任务的真实返回 `status`,重点核对暂定的 FAILED、UPLOADING | +| A-02 | 单文件重试 | 先按 POST + `file_uuids` 数组示例;分别验证向量失败、图谱失败被接受后的任务变化 | +| A-03 | 多文件重试与部分失败 | 后端保留数组,不增加 UI 批量按钮;确认上游数量限制、重复 ID、无效 ID、部分成功返回 | +| A-04 | 文件来源字段 | 确认导入方式真实返回字段;不把目录路径或 mock 的本地上传文字当成正式接口事实 | +| A-05 | 中文搜索和分页 | 文件/任务中文、空结果、翻页和每页数量;保存实际请求,确认一次只取所需页,`next_link/total_count` 含义正确 | +| A-06 | 任务统计 | 确认统计是本库、近 30 天、筛选前还是筛选后,以及失败统计是否包含两种入库失败;按返回展示,不从当前页合计 | +| A-07 | History 来源参数 | 确认 Channels 得到正确 `fs_id`;请求 `dir_path=/{id}`;无管道时当前可能返回空任务,需区分真的无任务与来源解析失败 | +| A-08 | 失败详情 | 返回错误码、原因、更新时间的字段和格式;没有错误码就显示“—”,不能拆错误文字臆造代码 | +| A-09 | 参数更新 | chunk_mode 与 topk 保存后重新查询;相似度不提交;名称、描述更改和清空正确 | +| A-10 | 权限及部分成功 | 多用户组、只读/可编辑/私有、无权账号;模拟本地权限成功而 AIDP 元数据失败,确认警告与回读结果 | +| A-11 | 上传、下载与删除 | 多个文件、部分失败、同名不同文件、大文件、断网/超时;检查成功仅代表上传接受;下载文件名和内容、删除后刷新正确 | +| A-12 | 详情字段 | 更新时间、文件数量、容量、创建人、模型和 graph_config 缺失/字符串/对象等返回,保留真实响应以确认整理逻辑 | + +每条记录包含:环境与 AIDP 版本、Nexent 提交、页面操作、请求方法/路径/参数、响应字段、实际页面结果、通过或问题。请求头 token、API key、用户真实文件内容不加入交接记录。 + +### 9.2 已识别的实现和设计差异 + +| 编号 | 当前事实 | 后续动作 | +| ---- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| U-01 | 两张详情表已固定名称与操作列并横向滚动,但没有列宽拖动手柄 | 按用户已提出的要求补齐:拖动一个中间列只改该列宽,其他列不变,总表宽随之变化 | +| U-02 | `AidpKnowledgeDetailInformation` 的图谱区域渲染了两次 LLM 模型 | 历史详情确认要求为只展示一次;整理成一处,保留构图模型字段,不能因此删接口字段 | +| U-03 | 多用户组只读展示已实现,但浏览器演示数据仅一个组 | 用多个已有测试组验证窄宽度、长名称、两个/多个组、`+N` 点击、名单滚动,确认不换行也不裁掉按钮 | +| U-04 | 历史 proposal 写“失败抽屉只展示信息”,初始用户描述和当前实现保留抽屉底部重试 | 当前行为作为交接基线;若要移除按钮,需要明确产品决定,不能因旧稿一句话顺手删除 | +| U-05 | 后端将本地授权 `owner_user_id` 写入 `created_by`,前端优先展示它 | 当前“创建人”可能为 ID;若要显示人名,需要使用可核对的用户名称来源,不编造映射 | +| U-06 | 旧稿有安全护栏、尚未开发/尚未部署说明,旧 DEV 文档入口描述也有过期内容 | 安全护栏已取消;按本说明与代码确认现行范围。不要重新接入安全字段或把 AIDP 页面判成 ES | + +上面是交接时识别到的具体事项,不是对全部 UI 的完整审查。继续按原型逐项检查容器、间距、字号、颜色、固定高度和分页;字体与图标沿用 Nexent。 + +## 10. 接手后的验收流程 + +- [ ] 拉取基线,创建自己的分支,能独立启动 mock 页面;确认 `enable_aidp_knowledge=true`。 +- [ ] 名称或卡片进入详情,面包屑返回列表;名称/描述修改与删除权限正确。 +- [ ] 文件页只显示已入库数据,中文搜索、分页、下载、删除、导入与缺失值展示正确。 +- [ ] 任务状态筛选在表头,包含全部 0;统计来自上游;没有顶部批量重试。 +- [ ] 两种失败任务可查看抽屉;可编辑用户可重试,只读用户不能提交。 +- [ ] 配置解析、chunk_mode/topk 修改、相似度只读、私有/共享及多个用户组显示和修改正确。 +- [ ] 上传区点击/拖拽、50 条限制、滚动、圆环、虚实边框、部分失败、确认禁用与失败本地删除正确。 +- [ ] 完成 U-01/U-02 的处理或给出明确延期记录;完成 U-03 浏览器检查。 +- [ ] 连接真实 AIDP,逐条记录 A-01 至 A-12;mock 与真实返回不同的字段集中修复并补充回归检查。 +- [ ] 检查共用组件涉及的知识库列表、创建页,确认 ES 场景未受影响。 +- [ ] 提交代码、联调记录、仍未解决的问题及对应负责人;更新本说明或新增带日期的联调补充文档。 + +完成交接的标准:接手者能独立拉代码、启动环境、定位每项页面操作的前后端与上游接口,并按此清单完成一次真实 AIDP 验收。无需依赖前一段聊天记录才能继续开发。 diff --git a/frontend/app/[locale]/knowledges/create/page.tsx b/frontend/app/[locale]/knowledges/create/page.tsx new file mode 100644 index 0000000000..fbc265e550 --- /dev/null +++ b/frontend/app/[locale]/knowledges/create/page.tsx @@ -0,0 +1,13 @@ +import AidpCreateKbPage from "@/ext_components/aidp/components/AidpCreateKbPage"; + +/** + * Dedicated AIDP knowledge base creation entry. + * + * The page renders the AIDP creation flow only; the component sends a visitor + * back to the knowledge base overview when the deployment does not have the + * AIDP backend enabled. ES deployments keep their own creation flow and the + * existing `knowledges` entry behavior is untouched. + */ +export default function AidpCreateKnowledgeBasePage() { + return ; +} diff --git a/frontend/app/[locale]/layout.client.tsx b/frontend/app/[locale]/layout.client.tsx index dda339dd74..50ce2f1d89 100644 --- a/frontend/app/[locale]/layout.client.tsx +++ b/frontend/app/[locale]/layout.client.tsx @@ -26,7 +26,7 @@ export function ClientLayout({ children }: { children: ReactNode }) { const pathname = usePathname(); const { isAuthenticated } = useAuthenticationContext(); const { isAuthorized } = useAuthorizationContext(); - const { isSpeedMode } = useDeployment(); + const { isSpeedMode, enableAidpKnowledge } = useDeployment(); // Check if current route is setup page const isSetupPage = pathname?.includes("/setup"); @@ -38,9 +38,12 @@ export function ClientLayout({ children }: { children: ReactNode }) { const isHomePage = effectivePath === "/"; const isOAuthCompletePage = effectivePath === "/oauth/complete"; const isSharePage = effectivePath.startsWith("/share/"); + const isAidpCreateKnowledgePage = + effectivePath === "/knowledges/create" && enableAidpKnowledge; // Sidebar collapse state const [collapsed, setCollapsed] = useState(effectivePath === "/workbench"); + const effectiveCollapsed = collapsed || isAidpCreateKnowledgePage; useEffect(() => { if (effectivePath !== "/workbench") return; @@ -67,7 +70,7 @@ export function ClientLayout({ children }: { children: ReactNode }) { justifyContent: "flex-start", position: "fixed", top: headerReservedHeight, - bottom: isSetupPage ? 0 : footerReservedHeight, + bottom: isSetupPage || isAidpCreateKnowledgePage ? 0 : footerReservedHeight, left: 0, backgroundColor: "#fff", overflow: "visible", @@ -106,7 +109,7 @@ export function ClientLayout({ children }: { children: ReactNode }) { overflowY: "auto", overflowX: "hidden", position: "relative", - marginLeft: collapsed + marginLeft: effectiveCollapsed ? `${SIDER_CONFIG.COLLAPSED_WIDTH}px` : `${SIDER_CONFIG.EXPANDED_WIDTH}px`, backgroundColor: "#fff", @@ -141,7 +144,7 @@ export function ClientLayout({ children }: { children: ReactNode }) {
- +
- + + )} + + ); +}; + +export default AidpGroupNamesDisplay; diff --git a/frontend/ext_components/aidp/components/AidpImportDrawer.module.css b/frontend/ext_components/aidp/components/AidpImportDrawer.module.css new file mode 100644 index 0000000000..a0cd1edf0e --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpImportDrawer.module.css @@ -0,0 +1,272 @@ +.drawer :global(.ant-drawer-content-wrapper) { + max-width: 100vw; +} + +.drawer :global(.ant-drawer-header) { + min-height: 64px; + padding: 18px 24px; +} + +.drawer :global(.ant-drawer-title) { + color: #1f1f1f; + font-size: 20px; + font-weight: 500; + line-height: 28px; +} + +.drawer :global(.ant-drawer-body) { + display: flex; + min-height: 0; + flex-direction: column; +} + +.drawerBody { + display: flex; + min-height: 0; + height: 100%; + flex: 1; + flex-direction: column; +} + +.dropzone { + min-height: 160px; + flex: none; +} + +.dropzone :global(.ant-upload.ant-upload-drag) { + min-height: 160px; + height: auto; + border: 1px dashed #c9c9c9; + border-radius: 4px; + background: #fff; + transition: border-color 0.2s ease; +} + +.dropzone :global(.ant-upload.ant-upload-drag:hover) { + border-color: #1677ff; +} + +.dropzone :global(.ant-upload-btn) { + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; + padding: 12px 20px !important; +} + +.dropzone :global(.ant-upload-drag-container) { + display: flex; + align-items: center; + justify-content: center; +} + +.dropzoneContent { + width: 100%; + color: #595959; + text-align: center; +} + +.dropzoneIcon { + margin-bottom: 8px; + color: #1677ff; + font-size: 24px; +} + +.dropzoneTitle { + color: #1f1f1f; + font-size: 14px; + line-height: 22px; +} + +.dropzoneNotes { + margin-top: 4px; + color: #8c8c8c; + font-size: 12px; + line-height: 18px; +} + +.dropzoneDescription { + margin-top: 4px; + color: #777; + font-size: 14px; + line-height: 22px; +} + +.formats { + max-width: 100%; + overflow-wrap: anywhere; + white-space: normal; +} + +.fileList { + min-height: 0; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + margin-top: 12px; + padding: 1px; +} + +.fileRow { + display: flex; + width: 100%; + height: 58px; + align-items: center; + gap: 8px; + box-sizing: border-box; + margin-bottom: 4px; + padding: 0 12px; + border-width: 1px; + border-radius: 4px; + background: #fff; +} + +.dashedRow { + border-style: dashed; + border-color: #c9c9c9; +} + +.successRow { + border-style: solid; + border-color: #191919; +} + +.fileIcon { + position: relative; + display: flex; + width: 32px; + height: 32px; + flex: none; + align-items: center; + justify-content: center; + border-radius: 5px; + background: #6b7280; + color: #fff; + font-size: 19px; +} + +.progressRing { + position: absolute; + top: 50%; + left: 50%; + overflow: visible; + filter: drop-shadow(0 0 1px rgb(0 0 0 / 35%)); + transform: translate(-50%, -50%); +} + +.fileText { + min-width: 0; + flex: 1; +} + +.fileName, +.fileDetail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileName { + color: #1f1f1f; + font-size: 14px; + font-weight: 400; + line-height: 22px; +} + +.fileDetail { + color: #8c8c8c; + font-size: 12px; + line-height: 20px; +} + +.fileError { + color: #e02128; +} + +.deleteButton { + width: 28px; + height: 28px; + flex: none; + color: #1677ff; +} + +.emptyState { + display: flex; + min-height: 100%; + align-items: center; + justify-content: center; + color: #8c8c8c; + font-size: 14px; +} + +.fileCount { + flex: none; + padding-top: 8px; + color: #8c8c8c; + font-size: 12px; + line-height: 18px; + text-align: right; +} + +.footer, +.footerActions { + display: flex; + align-items: center; +} + +.footer { + justify-content: flex-end; + gap: 12px; +} + +.footerActions { + justify-content: flex-end; + gap: 8px; +} + +.footerActions :global(.ant-btn-primary) { + width: 88px; +} + +.footerActions :global(.ant-btn) { + width: 88px; + height: 32px; + padding: 4px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 400; + line-height: 22px; + letter-spacing: 0; +} + +@media (max-width: 600px) { + .drawer :global(.ant-drawer-header) { + padding-right: 16px; + padding-left: 16px; + } + + .drawerBody { + min-height: 0; + } + + .dropzone { + min-height: 160px; + } + + .dropzoneNotes { + font-size: 11px; + } + + .dropzoneDescription { + font-size: 12px; + line-height: 18px; + } + + .drawer :global(.ant-drawer-body) { + padding: 16px !important; + } + + .drawer :global(.ant-drawer-footer) { + padding: 12px 16px; + } +} diff --git a/frontend/ext_components/aidp/components/AidpImportDrawer.tsx b/frontend/ext_components/aidp/components/AidpImportDrawer.tsx new file mode 100644 index 0000000000..e9fc895497 --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpImportDrawer.tsx @@ -0,0 +1,523 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { App, Button, Drawer, Tooltip, Upload } from "antd"; +import type { UploadProps } from "antd"; +import { + DeleteOutlined, + FileTextOutlined, + UploadOutlined, +} from "@ant-design/icons"; + +import { + AIDP_ACCEPT_STRING, + AIDP_MAX_UPLOAD_FILE_COUNT, +} from "@/const/knowledgeBase"; +import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; +import type { AidpUploadFailedItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; +import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; +import { validateAidpFiles } from "@/services/uploadService"; +import { ApiError } from "@/services/api"; +import { getAidpUploadErrorMessage } from "../services/aidpUploadUtils"; + +import styles from "./AidpImportDrawer.module.css"; + +const { Dragger } = Upload; + +type ImportFileStatus = "pending" | "uploading" | "failed" | "success"; + +interface ImportFileRow { + id: number; + file: File; + status: ImportFileStatus; + progress: number; + error?: string; +} + +interface AidpImportDrawerProps { + open: boolean; + title: string; + knowledgeBase: AidpKnowledgeBaseItem; + onClose: () => void; + onDocsUploaded: (uploadedFileIds: string[]) => void; + onRefresh: () => void; +} + +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)}M`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`; +}; + +const getFailureReason = ( + item: AidpUploadFailedItem | undefined, + language: string, + translate: (key: string, options: { defaultValue: string }) => string, + fallback: string +): string => { + if (!item) return fallback; + const reason = language.startsWith("zh") + ? item.reason_zh || item.reason_en || fallback + : item.reason_en || item.reason_zh || fallback; + return item.code !== undefined && item.code !== "" + ? getAidpUploadErrorMessage( + new ApiError(item.code, reason), + translate, + fallback + ) + : reason; +}; + +const ProgressRing: React.FC<{ percent: number }> = ({ percent }) => { + const circumference = 2 * Math.PI * 6.5; + const offset = circumference * (1 - percent / 100); + return ( + + + + + ); +}; + +const AidpImportDrawer: React.FC = ({ + open, + title, + knowledgeBase, + onClose, + onDocsUploaded, + onRefresh, +}) => { + const { t, i18n } = useTranslation(); + const { message } = App.useApp(); + const [rows, setRows] = useState([]); + const [uploading, setUploading] = useState(false); + const rowsRef = useRef([]); + const uploadingRef = useRef(false); + const mountedRef = useRef(true); + const nextIdRef = useRef(0); + const pendingSelectionRef = useRef([]); + const selectionFrameRef = useRef(null); + const abortControllersRef = useRef(new Map()); + + const replaceRows = useCallback( + ( + nextRows: + ImportFileRow[] | ((current: ImportFileRow[]) => ImportFileRow[]) + ) => { + const resolved = + typeof nextRows === "function" ? nextRows(rowsRef.current) : nextRows; + rowsRef.current = resolved; + setRows(resolved); + }, + [] + ); + + useEffect(() => { + mountedRef.current = true; + const controllers = abortControllersRef.current; + return () => { + mountedRef.current = false; + if (selectionFrameRef.current !== null) { + window.cancelAnimationFrame(selectionFrameRef.current); + } + controllers.forEach((controller) => controller.abort()); + }; + }, []); + + const removeFile = useCallback( + (row: ImportFileRow) => { + if (row.status !== "failed") return; + replaceRows((current) => current.filter((item) => item.id !== row.id)); + }, + [replaceRows] + ); + + const uploadPendingFiles = useCallback(async () => { + const pendingRows = rowsRef.current.filter( + (row) => row.status === "pending" + ); + if (uploadingRef.current) return; + if (pendingRows.length === 0) { + setUploading(false); + return; + } + + uploadingRef.current = true; + setUploading(true); + const batchIds = new Set(pendingRows.map((row) => row.id)); + const controller = new AbortController(); + const batchId = pendingRows[0].id; + abortControllersRef.current.set(batchId, controller); + const updateBatch = (update: (row: ImportFileRow) => ImportFileRow) => + replaceRows((current) => + current.map((row) => (batchIds.has(row.id) ? update(row) : row)) + ); + updateBatch((row) => ({ ...row, status: "uploading", progress: 0 })); + + const uploadedFileIds: string[] = []; + try { + const result = await aidpKnowledgeService.uploadDocsWithProgress( + knowledgeBase.kds_id, + pendingRows.map((row) => row.file), + (loaded, total) => { + if (!mountedRef.current) return; + // The batch percentage is intentionally shared by all uploading rows. + const ratio = + total > 0 ? Math.min(1, Math.max(0, loaded / total)) : 0; + updateBatch((row) => ({ + ...row, + progress: Math.round(ratio * 1000) / 10, + })); + }, + controller.signal + ); + if (!mountedRef.current) return; + const successes = [...result.success_list]; + const failures = [...result.failed_list]; + updateBatch((row) => { + const successIndex = successes.findIndex( + (item) => item.file_name === row.file.name + ); + if (successIndex >= 0) { + const [success] = successes.splice(successIndex, 1); + if (success.file_ino_no) + uploadedFileIds.push(String(success.file_ino_no)); + return { ...row, status: "success", progress: 100 }; + } + const failureIndex = failures.findIndex( + (item) => item.file_name === row.file.name + ); + const failure = + failureIndex >= 0 ? failures.splice(failureIndex, 1)[0] : undefined; + return { + ...row, + status: "failed", + error: getFailureReason( + failure, + i18n.language, + t, + t("aidpKnowledge.uploadFailed") + ), + }; + }); + } catch (error) { + if (mountedRef.current && !controller.signal.aborted) { + updateBatch((row) => ({ + ...row, + status: "failed", + error: getAidpUploadErrorMessage( + error, + t, + t("aidpKnowledge.uploadFailed") + ), + })); + } + } finally { + abortControllersRef.current.delete(batchId); + } + if (!mountedRef.current) return; + uploadingRef.current = false; + setUploading(false); + onRefresh(); + + const finishedRows = rowsRef.current.filter((row) => batchIds.has(row.id)); + const failedCount = finishedRows.filter( + (row) => row.status === "failed" + ).length; + const succeededCount = finishedRows.filter( + (row) => row.status === "success" + ).length; + if (uploadedFileIds.length > 0) onDocsUploaded(uploadedFileIds); + + if (failedCount > 0) { + if (succeededCount > 0) { + message.warning( + t("aidpKnowledge.uploadPartial", { + success: succeededCount, + failed: failedCount, + }) + ); + } + return; + } + + if (succeededCount > 0) { + message.success( + t("aidpKnowledge.uploadSuccess", { count: succeededCount }) + ); + } + }, [ + i18n.language, + knowledgeBase.kds_id, + message, + onDocsUploaded, + onRefresh, + replaceRows, + t, + ]); + + const flushSelection = useCallback(() => { + const selectedFiles = pendingSelectionRef.current; + pendingSelectionRef.current = []; + selectionFrameRef.current = null; + if (selectedFiles.length === 0) { + setUploading(false); + return; + } + + const nextRows = [...rowsRef.current]; + const knownFiles = new Set( + nextRows.map( + ({ file }) => `${file.name}\u0000${file.size}\u0000${file.lastModified}` + ) + ); + let duplicateCount = 0; + let overLimitCount = 0; + + for (const file of selectedFiles) { + const key = `${file.name}\u0000${file.size}\u0000${file.lastModified}`; + if (knownFiles.has(key)) { + duplicateCount += 1; + continue; + } + if (nextRows.length >= AIDP_MAX_UPLOAD_FILE_COUNT) { + overLimitCount += 1; + continue; + } + + knownFiles.add(key); + const validation = validateAidpFiles([file]); + let error: string | undefined; + if (validation.invalidType.length > 0) { + error = t("aidpKnowledge.invalidFileType", { count: 1 }); + } else if (validation.oversized.length > 0) { + error = t("aidpKnowledge.fileTooLarge", { + fileName: file.name, + maxSize: validation.oversized[0].maxSizeMb, + }); + } + + nextRows.push({ + id: nextIdRef.current++, + file, + status: error ? "failed" : "pending", + progress: 0, + error, + }); + } + + replaceRows(nextRows); + void uploadPendingFiles(); + if (duplicateCount > 0) { + message.info(t("aidpKnowledge.importDuplicateSelection")); + } + if (overLimitCount > 0) { + message.error( + t("aidpKnowledge.tooManyFiles", { + count: nextRows.length + overLimitCount, + maxCount: AIDP_MAX_UPLOAD_FILE_COUNT, + }) + ); + } + }, [message, replaceRows, t, uploadPendingFiles]); + + const collectFiles: NonNullable = useCallback( + (file) => { + if (uploadingRef.current) return false; + setUploading(true); + pendingSelectionRef.current.push(file as File); + if (selectionFrameRef.current === null) { + selectionFrameRef.current = + window.requestAnimationFrame(flushSelection); + } + return false; + }, + [flushSelection] + ); + + const handleClose = useCallback(() => { + if (uploadingRef.current || pendingSelectionRef.current.length > 0) return; + if (selectionFrameRef.current !== null) { + window.cancelAnimationFrame(selectionFrameRef.current); + selectionFrameRef.current = null; + } + pendingSelectionRef.current = []; + replaceRows([]); + onClose(); + }, [onClose, replaceRows]); + + const renderFileRow = (row: ImportFileRow) => { + const borderClass = + row.status === "success" ? styles.successRow : styles.dashedRow; + const detail = + row.status === "uploading" + ? formatBytes(row.file.size) + : row.status === "failed" + ? row.error || t("aidpKnowledge.uploadFailed") + : row.status === "success" + ? t("aidpKnowledge.importUploadComplete") + : `${formatBytes(row.file.size)} · ${t("aidpKnowledge.importPending")}`; + + return ( +
+ = 100 + ? t("aidpKnowledge.importAwaitingResult") + : undefined + } + > + + +
+ +
{row.file.name}
+
+ +
+ {detail} +
+
+
+ {row.status === "failed" && ( +
+ ); + }; + + return ( + +
+ + +
+ + } + > +
+ = AIDP_MAX_UPLOAD_FILE_COUNT} + > +
+ +
+ {t("aidpKnowledge.uploadHint")} +
+
+ {t("aidpKnowledge.importDescription")} +
+
+
{t("aidpKnowledge.uploadHintCount")}
+
{t("aidpKnowledge.uploadHintSize")}
+
+ {t("aidpKnowledge.uploadHintFormats")} +
+
+
+
+
+ {rows.length === 0 ? ( +
+ {t("aidpKnowledge.importEmpty")} +
+ ) : ( + rows.map(renderFileRow) + )} +
+
+ {t("aidpKnowledge.importFileCount", { + count: rows.length, + maxCount: AIDP_MAX_UPLOAD_FILE_COUNT, + })} +
+
+
+ ); +}; + +export default AidpImportDrawer; diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx index 85b2d77a87..373084dbcd 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx @@ -17,7 +17,7 @@ export const AIDP_MODAL_STYLES = { interface AidpModalHeaderProps { title: string; - subtitle: string; + subtitle?: string; } export const AidpKnowledgeBaseModalHeader: React.FC = ({ @@ -31,7 +31,7 @@ export const AidpKnowledgeBaseModalHeader: React.FC = ({

{title}

-

{subtitle}

+ {subtitle &&

{subtitle}

} ); diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx index bef0cf94e7..e3fbc2e925 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx @@ -1,535 +1,302 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { App, Row, Col, Modal } from "antd"; -import { InfoCircleFilled } from "@ant-design/icons"; +import { App, Modal } from "antd"; -import { - SETUP_PAGE_CONTAINER, - TWO_COLUMN_LAYOUT, - STANDARD_CARD, -} from "@/const/layoutConstants"; import { KB_SEARCH_DEBOUNCE_MS } from "@/const/knowledgeBase"; import { AIDP_DOC_STATUS_POLL_MS, AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS, - findPendingUploadIds, } from "@/lib/aidpDocumentStatus"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; -import aidpKnowledgeService, { - type AidpKbDetail, - type AidpDocumentItem, -} from "@/ext_components/aidp/services/aidpKnowledgeService"; +import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; import log from "@/lib/logger"; - -import AidpKnowledgeList from "./AidpKnowledgeList"; -import AidpDocumentList from "./AidpDocumentList"; -import AidpCreateKbModal from "./AidpCreateKbModal"; -import AidpUpdateKbModal from "./AidpUpdateKbModal"; +import { ApiError } from "@/services/api"; +import { useErrorHandler } from "@/hooks/useErrorHandler"; +import { + useAidpKnowledgeCache, + useAidpKnowledgeList, + useDeleteAidpKnowledgeBase, +} from "../hooks/useAidpKnowledgeQueries"; + +import AidpKnowledgeDetail from "./AidpKnowledgeDetail"; +import AidpKnowledgeList, { + AIDP_KB_DEFAULT_COLUMNS, + type AidpKbColumnKey, + type AidpKbViewMode, +} from "./AidpKnowledgeList"; +import AidpImportDrawer from "./AidpImportDrawer"; const AidpKnowledgeConfiguration: React.FC = () => { const { t } = useTranslation(); - const { message: appMessage } = App.useApp(); - - // ---- KB list state ---- - const [kbs, setKbs] = useState([]); - const [loadingKbs, setLoadingKbs] = useState(false); - const [kbTotal, setKbTotal] = useState(0); - const [kbHasMore, setKbHasMore] = useState(false); - const [kbTotalReliable, setKbTotalReliable] = useState(true); - - // ---- Active KB / document state ---- - // activeKbId is stored separately from the paginated `kbs` list, because - // refetching the KB list (e.g. after upload) returns only the current page, - // which may not contain the currently active KB. `selectedKb` is the item - // itself — set on selection, kept stable across list refetches. - const [activeKbId, setActiveKbId] = useState(null); - const [selectedKb, setSelectedKb] = useState(null); - const [activeKbDetail, setActiveKbDetail] = useState(null); - const [documents, setDocuments] = useState([]); - const [totalDocs, setTotalDocs] = useState(0); - const [docHasMore, setDocHasMore] = useState(false); - const [docTotalReliable, setDocTotalReliable] = useState(true); - // Files still being processed across the whole knowledge base (not just the - // visible page). Drives the status polling below. - const [docProcessingCount, setDocProcessingCount] = useState(0); - const [loadingDocs, setLoadingDocs] = useState(false); - - // ---- Pagination state ---- - const KB_PAGE_SIZE = 10; - const DOC_PAGE_SIZE = 10; - const [kbPage, setKbPage] = useState(1); - const [docPage, setDocPage] = useState(1); - - // ---- Modal state ---- - const [createModalOpen, setCreateModalOpen] = useState(false); - const [updateModalOpen, setUpdateModalOpen] = useState(false); - const [editingKb, setEditingKb] = useState(null); - - // ---- Keyword search state ---- - // `kbKeyword` is the raw input value and keeps the text field responsive; - // `debouncedKbKeyword` is what actually drives requests. Splitting them means - // typing never waits on the network, and a pause settles on one request. + const { message } = App.useApp(); + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const locale = (params?.locale as string) || "zh"; + const { getI18nErrorMessage, handleError } = useErrorHandler(); + const { refreshLists, updateKnowledgeBase, fetchKnowledgeBase } = + useAidpKnowledgeCache(); + const deleteMutation = useDeleteAidpKnowledgeBase(); + const [viewMode, setViewMode] = useState("cards"); + const [visibleColumns, setVisibleColumns] = useState([ + ...AIDP_KB_DEFAULT_COLUMNS, + ]); + const [selectedKb, setSelectedKb] = useState( + null + ); + const [listParams, setListParams] = useState({ + page: 1, + pageSize: 10, + keyword: "", + }); + const { page: kbPage, pageSize: kbPageSize } = listParams; const [kbKeyword, setKbKeyword] = useState(""); - const [debouncedKbKeyword, setDebouncedKbKeyword] = useState(""); + const [quickImportKb, setQuickImportKb] = + useState(null); + const [quickImportWatchKbId, setQuickImportWatchKbId] = useState< + string | null + >(null); + const quickImportPendingIdsRef = useRef([]); + const quickImportWatchStartedAtRef = useRef(0); + const openedRequestedKbRef = useRef(null); + const listQuery = useAidpKnowledgeList( + kbPage, + kbPageSize, + listParams.keyword, + !selectedKb + ); + const kbs = listQuery.isError ? [] : (listQuery.data?.value ?? []); + const listErrorMessage = + listQuery.error instanceof ApiError + ? getI18nErrorMessage(listQuery.error.code) + : listQuery.error?.message; useEffect(() => { - const timer = setTimeout(() => { - setDebouncedKbKeyword(kbKeyword.trim()); + const timer = window.setTimeout(() => { + const keyword = kbKeyword.trim(); + setListParams((current) => + current.keyword === keyword ? current : { ...current, keyword, page: 1 } + ); }, KB_SEARCH_DEBOUNCE_MS); - return () => clearTimeout(timer); + return () => window.clearTimeout(timer); }, [kbKeyword]); - // ---- Fetch KB list (server-side pagination: each page fetches page_size items + Count total) ---- - const fetchKbs = useCallback( - async (page: number = 1, keyword: string = "") => { - setLoadingKbs(true); - try { - const result = await aidpKnowledgeService.listKbs( - page, - KB_PAGE_SIZE, - keyword, - ); - setKbs(result.value); - setKbTotal(result.total_count ?? result.value.length); - setKbHasMore(result.has_more ?? false); - setKbTotalReliable(result.total_reliable !== false); - setKbPage(page); - } catch (error) { - log.error("Failed to fetch AIDP knowledge bases:", error); - appMessage.error(t("aidpKnowledge.fetchKbsFailed")); - setKbs([]); - setKbTotal(0); - setKbHasMore(false); - setKbTotalReliable(false); - } finally { - setLoadingKbs(false); - } - }, - [appMessage, t] - ); - - // Fetch on mount, and again whenever the debounced keyword settles. - // Every keyword change restarts at page 1 on purpose: the previous page - // number is meaningless against a different result set and would otherwise - // render an empty list whenever the filtered set is shorter than that page. - useEffect(() => { - fetchKbs(1, debouncedKbKeyword); - }, [fetchKbs, debouncedKbKeyword]); - - // ---- Cleanup legacy localStorage credentials on mount ---- - // v7.1: AIDP credentials moved backend-side; frontends that pre-date the - // migration may still carry serverUrl / apiKey in localStorage. Remove - // them on mount so a stale value cannot accidentally leak. useEffect(() => { if (typeof window === "undefined") return; try { window.localStorage.removeItem("aidp_kb_server_url"); window.localStorage.removeItem("aidp_kb_api_key"); } catch { - // localStorage may be unavailable in some test/SSR environments; - // ignore — the cleanup is best-effort. + // The credentials were moved server-side; cleanup is best-effort. } }, []); - // ---- Fetch documents for active KB (server-side pagination) ---- - // `silent` refreshes are used by the status poller: the table keeps rendering - // the previous page instead of flashing the loading placeholder, and a failed - // poll stays out of the way (logged only) so a short upstream hiccup cannot - // spam the user with a toast every interval. The manual refresh button always - // runs a non-silent fetch, so errors stay visible when the user asks for them. - const fetchDocs = useCallback( - async (kbId: string, page: number = 1, options?: { silent?: boolean }) => { - const silent = options?.silent === true; - if (!silent) setLoadingDocs(true); - try { - const result = await aidpKnowledgeService.listDocs( - kbId, - page, - DOC_PAGE_SIZE, - ); - const count = result.total_count ?? result.value.length; - setDocuments(result.value); - setTotalDocs(count); - setDocHasMore(result.has_more ?? false); - setDocTotalReliable(result.total_reliable !== false); - setDocProcessingCount(result.processing_count ?? 0); - setDocPage(page); + const handleSelectKb = useCallback((kb: AidpKnowledgeBaseItem) => { + setSelectedKb(kb); + }, []); - // Settle the upload watch: a just-uploaded file counts as done only - // once it is listed with a terminal status. Files AIDP has not listed - // yet stay pending on purpose, so the refresh keeps running instead of - // stopping while the list is still missing the upload. - if (pendingUploadIdsRef.current.length > 0) { - const stillPending = findPendingUploadIds( - pendingUploadIdsRef.current, - result.value - ); - pendingUploadIdsRef.current = stillPending; - if (stillPending.length === 0) setUploadWatchActive(false); - } + const openKbById = useCallback( + async (kbId: string) => { + try { + const detail = await fetchKnowledgeBase(kbId); + const item = { + ...detail, + kds_id: kbId, + kds_name: detail.kds_name || kbId, + } as AidpKnowledgeBaseItem; + setSelectedKb(item); } catch (error) { - log.error("Failed to fetch AIDP documents:", error); - if (!silent) { - appMessage.error(t("aidpKnowledge.fetchDocsFailed")); - setDocuments([]); - setTotalDocs(0); - setDocHasMore(false); - setDocTotalReliable(false); - setDocProcessingCount(0); - } - } finally { - if (!silent) setLoadingDocs(false); + log.error("Failed to open AIDP knowledge base from query:", error); + const result = handleError(error, { + showMessage: false, + handleSession: false, + }); + message.error(result.message); } }, - [appMessage, t] + [fetchKnowledgeBase, handleError, message] ); - // ---- Upload watch - state - --- - // Files the user just uploaded and that are not settled yet. Kept in a ref - // so `fetchDocs` can settle them without becoming a new function on every - // watch update (which would restart the polling interval). - const pendingUploadIdsRef = useRef([]); - const uploadWatchStartedAtRef = useRef(0); - const [uploadWatchActive, setUploadWatchActive] = useState(false); - - /** Start refreshing until every just-uploaded file reaches a terminal state. */ - const startUploadWatch = useCallback((uploadedFileIds: string[]) => { - if (uploadedFileIds.length === 0) return; - pendingUploadIdsRef.current = uploadedFileIds; - uploadWatchStartedAtRef.current = Date.now(); - setUploadWatchActive(true); - }, []); - - /** Stop watching uploads (no upload in flight to wait for). */ - const stopUploadWatch = useCallback(() => { - pendingUploadIdsRef.current = []; - setUploadWatchActive(false); - }, []); - - // ---- Poll the document list while work is outstanding ---- - // Two independent reasons to poll: - // * a file just uploaded by the user has not settled yet (`uploadWatchActive`), - // which covers the window where AIDP has accepted the upload but does not - // list it yet - polling on processing_count alone would never start there; - // * the knowledge base still reports files being ingested - // (`docProcessingCount`, counted across the whole KB, so a processing file - // on another page keeps the status column live). - // Polling stops once neither holds: every file is COMPLETED or FAILED. The - // upload watch additionally gives up after AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS so - // a silently dropped upload cannot keep the list refreshing forever. - const shouldPollDocs = uploadWatchActive || docProcessingCount > 0; + const requestedKbId = searchParams?.get("kb") || null; useEffect(() => { - if (!activeKbId || !shouldPollDocs) return; - const tick = () => { - if ( - uploadWatchActive && - Date.now() - uploadWatchStartedAtRef.current > - AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS - ) { - stopUploadWatch(); - return; - } - void fetchDocs(activeKbId, docPage, { silent: true }); - }; - const timer = window.setInterval(tick, AIDP_DOC_STATUS_POLL_MS); - return () => window.clearInterval(timer); - }, [ - activeKbId, - docPage, - shouldPollDocs, - uploadWatchActive, - fetchDocs, - stopUploadWatch, - ]); - - // ---- Handle KB selection ---- - const handleSelectKb = useCallback( - (kb: AidpKnowledgeBaseItem) => { - stopUploadWatch(); - setActiveKbId(kb.kds_id); - setSelectedKb(kb); - setDocPage(1); - setDocHasMore(false); - setDocTotalReliable(true); - setDocProcessingCount(0); - fetchDocs(kb.kds_id, 1); - }, - [fetchDocs, stopUploadWatch] - ); + if (!requestedKbId || openedRequestedKbRef.current === requestedKbId) + return; + openedRequestedKbRef.current = requestedKbId; + void openKbById(requestedKbId); + }, [openKbById, requestedKbId]); + + const handleBackToList = useCallback(() => { + setSelectedKb(null); + if (requestedKbId && typeof window !== "undefined") { + window.history.replaceState(null, "", `/${locale}/knowledges`); + openedRequestedKbRef.current = null; + } + }, [locale, requestedKbId]); - // ---- Handle KB deletion ---- const handleDeleteKb = useCallback( (kb: AidpKnowledgeBaseItem) => { Modal.confirm({ title: t("aidpKnowledge.confirmDeleteTitle"), - content: t("aidpKnowledge.confirmDeleteContent", { - name: kb.kds_name, - }), + content: t("aidpKnowledge.confirmDeleteContent", { name: kb.kds_name }), okText: t("common.confirm"), cancelText: t("common.cancel"), okButtonProps: { danger: true }, centered: true, onOk: async () => { try { - await aidpKnowledgeService.deleteKb(kb.kds_id); - appMessage.success(t("aidpKnowledge.deleteKbSuccess")); - - // If the deleted KB was active, clear selection - if (activeKbId === kb.kds_id) { - stopUploadWatch(); - setActiveKbId(null); - setSelectedKb(null); - setActiveKbDetail(null); - setDocuments([]); - setTotalDocs(0); - setDocHasMore(false); - setDocTotalReliable(true); - setDocProcessingCount(0); - setDocPage(1); - } - - // Refresh list, keeping the active search filter applied - fetchKbs(kbPage, debouncedKbKeyword); + await deleteMutation.mutateAsync(kb.kds_id); + message.success(t("aidpKnowledge.deleteKbSuccess")); + setSelectedKb(null); } catch (error) { - appMessage.error(t("aidpKnowledge.deleteKbFailed")); + const result = handleError(error, { + showMessage: false, + handleSession: false, + }); + message.error(result.message); + throw error; } }, }); }, - [ - activeKbId, - appMessage, - t, - fetchKbs, - kbPage, - debouncedKbKeyword, - stopUploadWatch, - ] + [deleteMutation, handleError, message, t] ); - // ---- Edit KB ---- - const handleEditKb = useCallback((kb: AidpKnowledgeBaseItem) => { - setEditingKb(kb); - setUpdateModalOpen(true); - }, []); - - // ---- After update success ---- - // Apply the returned resource locally instead of reloading the full KB page. - const handleUpdateKbSuccess = useCallback( + const handleUpdatedKb = useCallback( (updatedKb: AidpKnowledgeBaseItem) => { - setUpdateModalOpen(false); - setEditingKb(null); - setKbs((current) => - current.map((kb) => (kb.kds_id === updatedKb.kds_id ? updatedKb : kb)) - ); - if (activeKbId === updatedKb.kds_id) { - setSelectedKb(updatedKb); - setActiveKbDetail(updatedKb); - } + setSelectedKb(updatedKb); + updateKnowledgeBase(updatedKb); }, - [activeKbId] + [updateKnowledgeBase] ); - // ---- After create success ---- - // The create response already contains the resource. Insert it locally - // instead of scanning up to 50 expensive server-side pages. Files uploaded - // together with the KB are watched like any other upload so their processing - // status appears without a manual refresh. - const handleCreateKbSuccess = useCallback( - (newKb: AidpKnowledgeBaseItem, uploadedFileIds: string[] = []) => { - setCreateModalOpen(false); - setKbs((current) => - [newKb, ...current.filter((kb) => kb.kds_id !== newKb.kds_id)].slice( - 0, - KB_PAGE_SIZE - ) - ); - setKbTotal((current) => { - const nextTotal = current + 1; - setKbHasMore(kbPage * KB_PAGE_SIZE < nextTotal); - return nextTotal; - }); - setKbTotalReliable(true); - stopUploadWatch(); - startUploadWatch(uploadedFileIds); - setActiveKbId(newKb.kds_id); - setSelectedKb(newKb); - setActiveKbDetail(newKb); - setDocPage(1); - setDocHasMore(false); - setDocTotalReliable(true); - setDocProcessingCount(0); - void fetchDocs(newKb.kds_id, 1); - }, - [fetchDocs, kbPage, startUploadWatch, stopUploadWatch] - ); - - // ---- Refresh the active KB metadata (counts / name) ---- - const refreshActiveKbDetail = useCallback(() => { - if (!activeKbId) return; - void aidpKnowledgeService - .getKb(activeKbId) - .then((detail) => { - const refreshed = { - ...selectedKb, - ...detail, - kds_id: activeKbId, - kds_name: detail.kds_name || selectedKb?.kds_name || activeKbId, - } as AidpKnowledgeBaseItem; - setSelectedKb(refreshed); - setActiveKbDetail(detail); - setKbs((current) => - current.map((kb) => (kb.kds_id === activeKbId ? refreshed : kb)) - ); - }) - .catch((error) => - log.error("Failed to refresh active AIDP KB detail:", error) - ); - }, [activeKbId, selectedKb]); - - // ---- After documents uploaded ---- - // Refresh immediately so the uploaded files show up without the user having - // to press refresh, then watch them until AIDP reports a terminal status. - const handleDocsUploaded = useCallback( + const handleQuickDocsUploaded = useCallback( (uploadedFileIds: string[]) => { - startUploadWatch(uploadedFileIds); - if (!activeKbId) return; - // Reset doc pagination to page 1 so data and pagination UI stay in sync - setDocPage(1); - void fetchDocs(activeKbId, 1); - refreshActiveKbDetail(); + if (!quickImportKb || uploadedFileIds.length === 0) return; + quickImportPendingIdsRef.current = uploadedFileIds; + quickImportWatchStartedAtRef.current = Date.now(); + setQuickImportWatchKbId(quickImportKb.kds_id); + void refreshLists(); }, - [activeKbId, fetchDocs, refreshActiveKbDetail, startUploadWatch] + [refreshLists, quickImportKb] ); - // ---- Manual refresh (refresh button) ---- - // Refreshes the visible page and the KB metadata but never starts an upload - // watch: the user is not waiting for a file they just added. - const handleRefreshDocs = useCallback(() => { - if (!activeKbId) return; - void fetchDocs(activeKbId, docPage); - refreshActiveKbDetail(); - }, [activeKbId, docPage, fetchDocs, refreshActiveKbDetail]); - - // Active KB item is stored in `selectedKb` state (not derived from `kbs`), - // because the KB list is server-paginated and refetching it after upload - // returns only the current page — which may not contain the active KB. - const activeKbItem = selectedKb; + useEffect(() => { + if (!quickImportWatchKbId) return; + const timer = window.setInterval(() => { + if ( + Date.now() - quickImportWatchStartedAtRef.current > + AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS + ) { + quickImportPendingIdsRef.current = []; + setQuickImportWatchKbId(null); + void refreshLists(); + return; + } + void aidpKnowledgeService + .listUploadTasks(quickImportWatchKbId, 1, 100, "", 0) + .then((result) => { + const stillPending = quickImportPendingIdsRef.current.filter((id) => { + const task = result.value.find( + (item) => String(item.file_ino_no) === id || item.file_uuid === id + ); + if (!task) return true; + const status = String(task.status ?? "").toUpperCase(); + return ![ + "1", + "3", + "5", + "COMPLETED", + "SUCCESS", + "FAILED", + "VECTOR_INGESTION_FAILED", + "GRAPH_INGESTION_FAILED", + ].includes(status); + }); + quickImportPendingIdsRef.current = stillPending; + if (stillPending.length === 0) { + setQuickImportWatchKbId(null); + void refreshLists(); + } + }) + .catch((error) => + log.error("Failed to poll AIDP upload tasks:", error) + ); + }, AIDP_DOC_STATUS_POLL_MS); + return () => window.clearInterval(timer); + }, [quickImportWatchKbId, refreshLists]); + + const handleCreateNew = useCallback(() => { + router.push(`/${locale}/knowledges/create`); + }, [locale, router]); + + if (selectedKb) { + return ( +
+
+ +
+
+ ); + } return ( -
- {/* Two-column layout — content-sized cards with a single - scroll container; no card stretches to viewport height. */} -
- - {/* Left column: KB list */} - - fetchKbs(page, debouncedKbKeyword)} - onSelect={handleSelectKb} - onRefresh={() => fetchKbs(kbPage, debouncedKbKeyword)} - onCreateNew={() => setCreateModalOpen(true)} - onEdit={handleEditKb} - onDelete={handleDeleteKb} - /> - - - {/* Right column: Document list or empty state */} - - {activeKbItem ? ( - { - // The upload watch only makes sense on the page the upload - // landed on; the status poller still covers other pages. - stopUploadWatch(); - void fetchDocs(activeKbId!, page); - }} - onDocsUploaded={handleDocsUploaded} - onRefresh={handleRefreshDocs} - /> - ) : ( -
-
-
-
- -
-

- {t("aidpKnowledge.selectKbTitle")} -

-

- {t("aidpKnowledge.selectKbHint")} -

-
-
-
- )} - -
+
+
+ + setListParams((current) => ({ ...current, page })) + } + onPageSizeChange={(pageSize) => + setListParams((current) => ({ ...current, pageSize, page: 1 })) + } + onViewModeChange={setViewMode} + onVisibleColumnsChange={setVisibleColumns} + onSelect={handleSelectKb} + onRefresh={() => void refreshLists()} + onCreateNew={handleCreateNew} + onImport={setQuickImportKb} + onDelete={handleDeleteKb} + onRetry={() => void listQuery.refetch()} + />
- {/* Create KB Modal */} - setCreateModalOpen(false)} - onSuccess={handleCreateKbSuccess} - /> - - {/* Update KB Modal */} - { - setUpdateModalOpen(false); - setEditingKb(null); - }} - onSuccess={handleUpdateKbSuccess} - /> + {quickImportKb && ( + setQuickImportKb(null)} + onDocsUploaded={handleQuickDocsUploaded} + onRefresh={() => void refreshLists()} + /> + )}
); }; diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeDetail.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeDetail.tsx new file mode 100644 index 0000000000..d5c0fa0a3b --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpKnowledgeDetail.tsx @@ -0,0 +1,1149 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + App, + Breadcrumb, + Button, + Drawer, + Empty, + Form, + Input, + Modal, + Select, + Skeleton, + Table, + Tabs, + Tooltip, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + ClockCircleOutlined, + CloseOutlined, + DatabaseOutlined, + DeleteOutlined, + EditOutlined, + FileTextOutlined, + InfoCircleOutlined, + ReloadOutlined, + SearchOutlined, + TeamOutlined, + UserOutlined, + UploadOutlined, +} from "@ant-design/icons"; + +import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; +import aidpKnowledgeService, { + type AidpDocumentItem, + type AidpKbDetail, +} from "@/ext_components/aidp/services/aidpKnowledgeService"; +import { + AIDP_UNKNOWN_VALUE, + formatAidpDocumentCount, +} from "@/lib/aidpKnowledgeDisplay"; +import { useAidpGroupOptions } from "../hooks/useAidpGroupOptions"; +import AidpPagination from "./AidpPagination"; +import AidpImportDrawer from "./AidpImportDrawer"; +import AidpUpdateKbModal from "./AidpUpdateKbModal"; +import AidpSliderNumberField from "./AidpSliderNumberField"; +import AidpGroupNamesDisplay from "./AidpGroupNamesDisplay"; +import AidpKnowledgeDetailInformation, { + AidpDetailField, +} from "./AidpKnowledgeDetailInformation"; + +type DetailTab = "files" | "tasks" | "information"; +type TaskFilter = 0 | 1 | 2 | 3 | 4 | 5; + +interface AidpKnowledgeDetailProps { + knowledgeBase: AidpKnowledgeBaseItem; + onBack: () => void; + onDelete: (knowledgeBase: AidpKnowledgeBaseItem) => void; + onUpdated: (knowledgeBase: AidpKnowledgeBaseItem) => void; +} + +const PAGE_SIZE = 10; +const UNKNOWN = AIDP_UNKNOWN_VALUE; + +const formatDetailDateTime = (value: unknown): string => { + let date: Date; + if (typeof value === "number" && Number.isFinite(value)) { + date = new Date(value < 1e12 ? value * 1000 : value); + } else if (typeof value === "string" && value.trim()) { + const normalized = value.trim(); + const numeric = /^\d+(?:\.\d+)?$/.test(normalized) + ? Number(normalized) + : null; + date = + numeric === null + ? new Date(normalized) + : new Date(numeric < 1e12 ? numeric * 1000 : numeric); + } else { + return UNKNOWN; + } + if (Number.isNaN(date.getTime())) return UNKNOWN; + return date.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }); +}; + +const formatFailureTaskDateTime = (value: unknown): string => { + let date: Date; + if (typeof value === "number" && Number.isFinite(value)) { + date = new Date(value < 1e12 ? value * 1000 : value); + } else if (typeof value === "string" && value.trim()) { + const normalized = value.trim(); + const numeric = /^\d+(?:\.\d+)?$/.test(normalized) + ? Number(normalized) + : null; + date = + numeric === null + ? new Date(normalized) + : new Date(numeric < 1e12 ? numeric * 1000 : numeric); + } else { + return UNKNOWN; + } + if (Number.isNaN(date.getTime())) return UNKNOWN; + const pad = (part: number) => String(part).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +}; + +const formatFileSize = (bytes?: number): string => { + if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) { + return UNKNOWN; + } + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +}; + +const statusCode = (status: AidpDocumentItem["status"]): number | null => { + if (typeof status === "number") return status; + if (typeof status === "string" && /^\d+$/.test(status.trim())) { + return Number(status); + } + const aliases: Record = { + COMPLETED: 1, + SUCCESS: 1, + PROCESSING: 2, + EXTRACTING: 2, + FAILED: 3, + VECTOR_INGESTION_FAILED: 3, + UPLOADING: 4, + QUEUED: 4, + GRAPH_INGESTION_FAILED: 5, + }; + return typeof status === "string" + ? (aliases[status.toUpperCase()] ?? null) + : null; +}; + +const graphConfigOf = (detail: AidpKbDetail): Record => { + const raw = detail.graph_config; + if (raw && typeof raw === "object") return raw; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + return {}; +}; + +const AidpKnowledgeDetail: React.FC = ({ + knowledgeBase, + onBack, + onDelete, + onUpdated, +}) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const { canConfigureGroupPermissions, groupOptions } = useAidpGroupOptions(); + const [activeTab, setActiveTab] = useState("files"); + const [detail, setDetail] = useState(knowledgeBase); + const [loadingDetail, setLoadingDetail] = useState(false); + const [editingKbMode, setEditingKbMode] = useState< + "metadata" | "permissions" | null + >(null); + const [importOpen, setImportOpen] = useState(false); + const [savingSetting, setSavingSetting] = useState(false); + const [settingModal, setSettingModal] = useState< + "chunk_mode" | "topk" | null + >(null); + const [settingForm] = Form.useForm<{ value: number }>(); + + const [fileKeyword, setFileKeyword] = useState(""); + const [files, setFiles] = useState([]); + const [fileTotal, setFileTotal] = useState(0); + const [fileHasMore, setFileHasMore] = useState(false); + const [fileTotalReliable, setFileTotalReliable] = useState(true); + const [filePage, setFilePage] = useState(1); + const [filePageSize, setFilePageSize] = useState(PAGE_SIZE); + const [loadingFiles, setLoadingFiles] = useState(false); + const [taskKeyword, setTaskKeyword] = useState(""); + const [taskStatus, setTaskStatus] = useState(0); + const [tasks, setTasks] = useState([]); + const [taskTotal, setTaskTotal] = useState(0); + const [taskStats, setTaskStats] = useState({ + total: 0, + extracting: 0, + failed: 0, + success: 0, + queued: 0, + }); + const [taskPage, setTaskPage] = useState(1); + const [taskPageSize, setTaskPageSize] = useState(PAGE_SIZE); + const [loadingTasks, setLoadingTasks] = useState(false); + const [failureTask, setFailureTask] = useState(null); + const [retryingFileUuid, setRetryingFileUuid] = useState(null); + const graphConfig = useMemo(() => graphConfigOf(detail), [detail]); + const groupNames = useMemo(() => { + const names = new Map( + groupOptions.map((option) => [option.value, option.label]) + ); + return (detail.group_ids || []) + .map((id) => names.get(id)) + .filter((name): name is string => Boolean(name)); + }, [detail.group_ids, groupOptions]); + const canEdit = detail.permission === "EDIT"; + const isAvailable = + detail.resource_status !== "UNAVAILABLE" && + detail.resource_status !== "ORPHANED"; + + useEffect(() => { + if (!settingModal) return; + settingForm.setFieldsValue({ + value: + settingModal === "chunk_mode" + ? (detail.chunk_mode ?? 0) + : (detail.topk ?? 10), + }); + }, [detail.chunk_mode, detail.topk, settingForm, settingModal]); + + const refreshDetail = useCallback(async () => { + setLoadingDetail(true); + try { + const result = await aidpKnowledgeService.getKb(knowledgeBase.kds_id); + const merged = { ...knowledgeBase, ...result } as AidpKbDetail; + setDetail(merged); + onUpdated(merged as AidpKnowledgeBaseItem); + } catch { + message.error(t("aidpKnowledge.detailLoadFailed")); + } finally { + setLoadingDetail(false); + } + }, [knowledgeBase, message, onUpdated, t]); + + const fetchFiles = useCallback( + async (page = 1, keyword = fileKeyword, pageSize = filePageSize) => { + setLoadingFiles(true); + try { + const result = await aidpKnowledgeService.listIngestedFiles( + knowledgeBase.kds_id, + page, + pageSize, + keyword + ); + setFiles(result.value); + setFileTotal(result.total_count ?? result.value.length); + setFileHasMore(result.has_more ?? false); + setFileTotalReliable(result.total_reliable !== false); + setFilePage(page); + } catch { + message.error(t("aidpKnowledge.detailFilesLoadFailed")); + setFiles([]); + setFileTotal(0); + setFileHasMore(false); + setFileTotalReliable(false); + } finally { + setLoadingFiles(false); + } + }, + [fileKeyword, filePageSize, knowledgeBase.kds_id, message, t] + ); + + const fetchTasks = useCallback( + async ( + page = 1, + keyword = taskKeyword, + status = taskStatus, + pageSize = taskPageSize, + silent = false + ) => { + if (!silent) setLoadingTasks(true); + try { + const result = await aidpKnowledgeService.listUploadTasks( + knowledgeBase.kds_id, + page, + pageSize, + keyword, + status + ); + setTasks(result.value); + setTaskTotal(result.total_count); + setTaskStats(result.stats); + setTaskPage(page); + } catch { + if (!silent) message.error(t("aidpKnowledge.detailTasksLoadFailed")); + setTasks([]); + setTaskTotal(0); + } finally { + if (!silent) setLoadingTasks(false); + } + }, + [knowledgeBase.kds_id, message, taskKeyword, taskPageSize, taskStatus, t] + ); + + useEffect(() => { + const timer = window.setTimeout(() => { + void refreshDetail(); + void fetchFiles(1, "", PAGE_SIZE); + void fetchTasks(1, "", 0, PAGE_SIZE); + }, 0); + return () => window.clearTimeout(timer); + // The parent remounts this component when the selected KB id changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [knowledgeBase.kds_id]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void fetchFiles(1, fileKeyword.trim(), filePageSize); + }, 250); + return () => window.clearTimeout(timer); + }, [fileKeyword, filePageSize, fetchFiles]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void fetchTasks(1, taskKeyword.trim(), taskStatus, taskPageSize); + }, 250); + return () => window.clearTimeout(timer); + }, [taskKeyword, taskStatus, taskPageSize, fetchTasks]); + + useEffect(() => { + if ( + activeTab !== "tasks" || + taskStats.extracting + taskStats.queued === 0 + ) { + return; + } + const timer = window.setInterval(() => { + void fetchTasks(taskPage, taskKeyword, taskStatus, taskPageSize, true); + }, 10000); + return () => window.clearInterval(timer); + }, [ + activeTab, + fetchTasks, + taskKeyword, + taskPage, + taskPageSize, + taskStats.extracting, + taskStats.queued, + taskStatus, + ]); + + const uploadComplete = useCallback(() => { + void fetchFiles(1, fileKeyword, filePageSize); + void fetchTasks(1, taskKeyword, taskStatus, taskPageSize); + void refreshDetail(); + }, [ + fetchFiles, + fetchTasks, + fileKeyword, + filePageSize, + refreshDetail, + taskKeyword, + taskPageSize, + taskStatus, + ]); + + const handleDownload = useCallback( + async (file: AidpDocumentItem) => { + try { + const response = await aidpKnowledgeService.downloadDoc( + knowledgeBase.kds_id, + file.file_uuid + ); + const blobUrl = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = file.file_name || "download"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(blobUrl); + } catch { + message.error(t("aidpKnowledge.downloadFailed")); + } + }, + [knowledgeBase.kds_id, message, t] + ); + + const handleDeleteFile = useCallback( + (file: AidpDocumentItem) => { + Modal.confirm({ + title: t("aidpKnowledge.deleteDocTitle"), + content: file.file_name, + okButtonProps: { danger: true }, + onOk: async () => { + try { + await aidpKnowledgeService.removeDoc( + knowledgeBase.kds_id, + file.file_uuid + ); + message.success(t("aidpKnowledge.deleteDocSuccess")); + void fetchFiles(filePage, fileKeyword, filePageSize); + void refreshDetail(); + } catch { + message.error(t("aidpKnowledge.deleteDocFailed")); + } + }, + }); + }, + [ + fetchFiles, + fileKeyword, + filePage, + filePageSize, + knowledgeBase.kds_id, + message, + refreshDetail, + t, + ] + ); + + const handleRetry = useCallback( + async (task: AidpDocumentItem) => { + if (!task.file_uuid) return; + setRetryingFileUuid(task.file_uuid); + try { + const result = await aidpKnowledgeService.retryUploadTasks( + knowledgeBase.kds_id, + [task.file_uuid] + ); + if (result.summary.failed > 0) { + message.error(t("aidpKnowledge.retryTaskFailed")); + } else { + message.success(t("aidpKnowledge.retryTaskSuccess")); + setFailureTask(null); + void fetchTasks(taskPage, taskKeyword, taskStatus, taskPageSize); + } + } catch { + message.error(t("aidpKnowledge.retryTaskFailed")); + } finally { + setRetryingFileUuid(null); + } + }, + [ + fetchTasks, + knowledgeBase.kds_id, + message, + taskKeyword, + taskPage, + taskPageSize, + taskStatus, + t, + ] + ); + + const saveSetting = async () => { + if (!settingModal) return; + const values = await settingForm.validateFields(); + setSavingSetting(true); + try { + await aidpKnowledgeService.updateKb(knowledgeBase.kds_id, { + [settingModal]: values.value, + }); + setSettingModal(null); + await refreshDetail(); + message.success(t("aidpKnowledge.detailSettingSaved")); + } catch { + message.error(t("aidpKnowledge.detailSettingSaveFailed")); + } finally { + setSavingSetting(false); + } + }; + + const fileColumns: ColumnsType = [ + { + title: t("aidpKnowledge.detailFileName"), + dataIndex: "file_name", + key: "file_name", + fixed: "left", + width: 220, + ellipsis: true, + render: (value: string) => ( + {value || UNKNOWN} + ), + }, + { + title: t("aidpKnowledge.detailFileType"), + dataIndex: "file_type", + key: "file_type", + width: 75, + render: (value?: string) => value || UNKNOWN, + }, + { + title: t("aidpKnowledge.detailFileSize"), + dataIndex: "file_size", + key: "file_size", + width: 85, + render: (value?: number) => formatFileSize(value), + }, + { + title: t("aidpKnowledge.detailImportMode"), + key: "import_mode", + width: 120, + ellipsis: true, + render: () => t("aidpKnowledge.detailLocalImport"), + }, + { + title: t("aidpKnowledge.detailFirstUploadTime"), + dataIndex: "created_at", + key: "created_at", + width: 150, + ellipsis: true, + render: (value?: string) => formatDetailDateTime(value), + }, + { + title: t("aidpKnowledge.detailUpdateTime"), + dataIndex: "updated_at", + key: "updated_at", + width: 150, + ellipsis: true, + render: (value?: string) => formatDetailDateTime(value), + }, + { + title: t("aidpKnowledge.detailOperation"), + key: "actions", + fixed: "right", + width: 120, + render: (_value, record) => ( +
+ + {canEdit && ( + + )} +
+ ), + }, + ]; + + const taskStatusOptions = [ + { value: 0, label: t("aidpKnowledge.taskAll") }, + { value: 1, label: t("aidpKnowledge.taskSuccess") }, + { value: 2, label: t("aidpKnowledge.taskExtracting") }, + { value: 3, label: t("aidpKnowledge.taskVectorFailed") }, + { value: 4, label: t("aidpKnowledge.taskQueued") }, + { value: 5, label: t("aidpKnowledge.taskGraphFailed") }, + ]; + + const taskColumns: ColumnsType = [ + { + title: t("aidpKnowledge.detailFileName"), + dataIndex: "file_name", + key: "file_name", + fixed: "left", + width: 300, + ellipsis: true, + }, + { + title: t("aidpKnowledge.detailFileType"), + dataIndex: "file_type", + key: "file_type", + width: 75, + render: (value?: string) => value || UNKNOWN, + }, + { + title: t("aidpKnowledge.detailFileSize"), + dataIndex: "file_size", + key: "file_size", + width: 80, + render: (value?: number) => formatFileSize(value), + }, + { + title: t("aidpKnowledge.detailUpdateTime"), + dataIndex: "updated_at", + key: "updated_at", + width: 135, + ellipsis: true, + render: (value?: string) => formatDetailDateTime(value), + }, + { + title: t("aidpKnowledge.detailTaskStatus"), + dataIndex: "status", + key: "status", + width: 120, + filters: taskStatusOptions.map(({ value, label }) => ({ + text: label, + value, + })), + filterMultiple: false, + filteredValue: [taskStatus], + render: (status: AidpDocumentItem["status"]) => { + const code = statusCode(status); + const labelKeys: Record = { + 1: "aidpKnowledge.taskSuccess", + 2: "aidpKnowledge.taskExtracting", + 3: "aidpKnowledge.taskVectorFailed", + 4: "aidpKnowledge.taskQueued", + 5: "aidpKnowledge.taskGraphFailed", + }; + const dotColor = + code === 1 + ? "bg-green-500" + : code === 3 || code === 5 + ? "bg-red-500" + : code === 4 + ? "bg-gray-400" + : "bg-blue-500"; + return ( + + + {code && labelKeys[code] ? t(labelKeys[code]) : (status ?? UNKNOWN)} + + ); + }, + }, + { + title: t("aidpKnowledge.detailOperation"), + key: "actions", + fixed: "right", + width: 175, + render: (_value, record) => { + const code = statusCode(record.status); + if (code !== 3 && code !== 5) return null; + return ( +
+ + +
+ ); + }, + }, + ]; + + const privateScope = detail.ingroup_permission === "PRIVATE"; + const descriptionValue = (value: unknown): string => { + if (value === undefined || value === null || value === "") return UNKNOWN; + if (typeof value === "boolean") + return value ? t("common.yes") : t("common.no"); + return String(value); + }; + + return ( +
+ + {t("aidpKnowledge.breadcrumbKnowledgeBase")} + + ), + }, + { + title: ( + + {t("aidpKnowledge.detailBreadcrumb")} + + ), + }, + ]} + /> + +
+
+
+ + + +
+
+

+ {detail.kds_name} +

+ {canEdit && ( + +
+

+ {detail.description || t("aidpKnowledge.noDescription")} +

+
+
+
+ {canEdit && ( + + )} +
+
+
+
+ + + {formatAidpDocumentCount( + detail.document_count, + detail.document_count_reliable + )} + +
+
+ + + {privateScope + ? t("aidpKnowledge.scopePrivate") + : t("aidpKnowledge.scopeShared")} + +
+
+ + + {privateScope ? ( + t("aidpKnowledge.detailOwnerOnly") + ) : groupNames.length > 0 || + (detail.group_ids || []).length > 0 ? ( + 0 + ? groupNames + : (detail.group_ids || []).map(String) + } + /> + ) : ( + UNKNOWN + )} + +
+
+ + + {formatDetailDateTime(detail.created_at)} + +
+
+ + + {detail.creator_name || UNKNOWN} + +
+
+
+ + setActiveTab(key as DetailTab)} + className="shrink-0 [&>.ant-tabs-nav]:mb-0 [&>.ant-tabs-content-holder]:hidden" + tabBarStyle={{ marginBottom: 0 }} + items={[ + { key: "files", label: t("aidpKnowledge.detailTabFiles") }, + { key: "tasks", label: t("aidpKnowledge.detailTabTasks") }, + { + key: "information", + label: t("aidpKnowledge.detailTabInformation"), + }, + ]} + /> + + {activeTab === "files" && ( +
+
+ } + value={fileKeyword} + onChange={(event) => setFileKeyword(event.target.value)} + placeholder={t("aidpKnowledge.detailFileSearch")} + style={{ width: 280, maxWidth: "100%" }} + /> +
+ + + )} +
+
+
+ + rowKey={(record) => record.file_uuid || record.file_ino_no} + columns={fileColumns} + dataSource={files} + loading={loadingFiles} + pagination={false} + scroll={{ x: 930 }} + locale={{ + emptyText: ( + + ), + }} + size="middle" + /> +
+
+ + void fetchFiles(page, fileKeyword, filePageSize) + } + onPageSizeChange={(size) => setFilePageSize(size)} + /> +
+
+ )} + + {activeTab === "tasks" && ( +
+
+
+ } + value={taskKeyword} + onChange={(event) => setTaskKeyword(event.target.value)} + placeholder={t("aidpKnowledge.detailFileSearch")} + style={{ width: 220, maxWidth: "100%" }} + /> +
+ + + {t("aidpKnowledge.taskStatusSummary", { days: 30 })} + + + + + {t("aidpKnowledge.taskStatTotal", { + count: taskStats.total, + })} + + + + {t("aidpKnowledge.taskStatExtracting", { + count: taskStats.extracting, + })} + + + + {t("aidpKnowledge.taskStatFailed", { + count: taskStats.failed, + })} + + + + {t("aidpKnowledge.taskStatSuccess", { + count: taskStats.success, + })} + +
+
+ +
+
+ + rowKey={(record) => record.file_uuid || record.file_ino_no} + columns={taskColumns} + dataSource={tasks} + loading={loadingTasks} + onChange={(_pagination, filters) => { + const selectedStatus = filters.status?.[0]; + setTaskStatus( + typeof selectedStatus === "number" && + selectedStatus >= 1 && + selectedStatus <= 5 + ? (selectedStatus as TaskFilter) + : 0 + ); + }} + pagination={false} + scroll={{ x: 885 }} + locale={{ + emptyText: ( + + ), + }} + size="middle" + /> +
+
+ + void fetchTasks(page, taskKeyword, taskStatus, taskPageSize) + } + onPageSizeChange={(size) => setTaskPageSize(size)} + /> +
+
+ )} + + {activeTab === "information" && + (loadingDetail ? ( + + ) : ( + setEditingKbMode("permissions")} + onEditSetting={setSettingModal} + /> + ))} + + setEditingKbMode(null)} + onSuccess={(updated) => { + setEditingKbMode(null); + setDetail((current) => ({ ...current, ...updated })); + onUpdated(updated); + void refreshDetail(); + }} + /> + + setSettingModal(null)} + onOk={() => void saveSetting()} + confirmLoading={savingSetting} + destroyOnHidden + > + {settingModal && ( +
+ + {settingModal === "chunk_mode" ? ( + } - value={keyword} - onChange={(e) => onKeywordChange(e.target.value)} + )} + + {kb.permission === "READ_ONLY" && !isRowUnavailable(kb) && ( + + {t("aidpKnowledge.kbReadOnly")} + + )} + {isRowUnavailable(kb) && ( + + {t("aidpKnowledge.kbUnavailable")} + + )} +
+ ); + }; + + const menuFor = (kb: AidpKnowledgeBaseItem): MenuProps => ({ + items: [ + { + key: "import", + icon: , + label: t("aidpKnowledge.importFile"), + }, + { + type: "divider" as const, + }, + { + key: "delete", + danger: true, + icon: , + label: t("common.delete"), + }, + ], + onClick: ({ key, domEvent }) => { + domEvent.stopPropagation(); + if (key === "import") onImport(kb); + if (key === "delete") onDelete(kb); + }, + }); + + const renderMoreMenu = (kb: AidpKnowledgeBaseItem) => + canModify(kb) ? ( + +
+ + ) : null; - {/* List */} -
- {displayedKbs.length > 0 ? ( -
- {displayedKbs.map((kb) => { - const isActive = activeKbId === kb.kds_id; - const isUnavailable = - kb.resource_status === "UNAVAILABLE" || - kb.resource_status === "ORPHANED"; - // Only EDIT-level callers may modify the KB or its files. - const canModify = kb.permission === "EDIT" && !isUnavailable; + const renderCards = () => ( +
+ {displayedKbs.map((kb) => ( +
onSelect(kb)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(kb); + } + }} + className={styles.card} + > +
+
+ +

+ {kb.kds_name} +

+ {renderMoreMenu(kb)} +
- return ( -
onSelect(kb)} - > -
-
-
-

- {kb.kds_name} -

- {isUnavailable && ( - - {t("aidpKnowledge.kbUnavailable")} - - )} - {kb.permission === "READ_ONLY" && !isUnavailable && ( - - {t("aidpKnowledge.kbReadOnly")} - - )} -
- {kb.description && ( -

- {kb.description} -

- )} -
- {kb.ingroup_permission === "PRIVATE" && ( - - {t("knowledgeBase.ingroup.permission.PRIVATE")} - - )} - {/* Authorized user-group tags. Aligned with the local - knowledge base list: only render group names when - ``ingroup_permission !== "PRIVATE"``, each group - gets its own blue tag, and when there are no - groups to show we render nothing (no "not - authorized" fallback). Gated by the ``group:read`` - permission so users without group visibility see - the KB card cleanly without the tag area. */} - - {kb.ingroup_permission !== "PRIVATE" && - getGroupNames(kb.group_ids).map((groupName, idx) => ( - - {groupName} - - ))} - - {kb.created_at ? ( - - {t("aidpKnowledge.createdAt", { - date: new Date(kb.created_at).toLocaleDateString(), - })} - - ) : ( - {t("aidpKnowledge.createdAtUnknown")} - )} -
-
-
- {canModify && ( - -
-
-
- ); - })} +

+ {kb.description?.trim() || t("aidpKnowledge.noDescription")} +

- ) : ( -
- {keyword.trim() - ? t("aidpKnowledge.searchEmpty") - : t("aidpKnowledge.listEmpty")} + +
+ + + +
- )} +
+ ))} +
+ ); + + const handleResize = ( + key: AidpKbColumnKey, + nextKey: AidpKbColumnKey, + delta: number, + renderedWidths: Record + ) => { + const resized = resizeAidpColumnPair(renderedWidths, key, nextKey, delta); + if ( + Math.abs(resized[key] - columnWidths[key]) < 0.02 && + Math.abs(resized[nextKey] - columnWidths[nextKey]) < 0.02 + ) + return; + // Preserve hidden-column preferences in the same weight scale. + setColumnWeights((current) => { + const totalWeight = activeKeys.reduce( + (sum, item) => sum + current[item], + 0 + ); + const totalWidth = activeKeys.reduce( + (sum, item) => sum + resized[item], + 0 + ); + const next = { ...current }; + activeKeys.forEach((item) => { + next[item] = (resized[item] / totalWidth) * totalWeight; + }); + return next; + }); + }; + + const renderTable = () => { + const renderText = (value: string | number) => ( + + {value} + + ); + const scopeText = (kb: AidpKnowledgeBaseItem) => { + const shared = + kb.ingroup_permission === "EDIT" || + kb.ingroup_permission === "READ_ONLY"; + const type = normalizeAidpKbType(kb.is_private); + return ( + <> + {kb.ingroup_permission === "PRIVATE" + ? t("aidpKnowledge.scopePrivate") + : shared + ? `${t("aidpKnowledge.scopeShared")} · ${t(kb.ingroup_permission === "EDIT" ? "aidpKnowledge.scopeGroupEdit" : "aidpKnowledge.scopeGroupRead")}` + : type + ? t( + type === "personal" + ? "aidpKnowledge.kbTypePersonal" + : "aidpKnowledge.kbTypeEnterprise" + ) + : AIDP_UNKNOWN_VALUE} + + {shared && getGroupNames(kb.group_ids).length > 0 + ? ` · ${getGroupNames(kb.group_ids).join("、")}` + : null} + + {kb.permission === "READ_ONLY" && !isRowUnavailable(kb) + ? ` · ${t("aidpKnowledge.kbReadOnly")}` + : null} + {isRowUnavailable(kb) + ? ` · ${t("aidpKnowledge.kbUnavailable")}` + : null} + + ); + }; + const makeColumn = ( + key: AidpKbColumnKey, + column: ColumnsType[number] + ) => { + const nextKey = activeKeys[activeKeys.indexOf(key) + 1]; + return { + ...column, + title: renderText(t(COLUMN_LABEL_KEYS[key])), + ellipsis: { showTitle: false }, + width: columnWidths[key], + onHeaderCell: () => + ({ + width: columnWidths[key], + maxWidth: nextKey + ? columnWidths[key] + + columnWidths[nextKey] - + AIDP_MIN_COLUMN_WIDTH + : undefined, + "data-column-key": key, + onResize: nextKey + ? (delta: number, renderedWidths: Record) => + handleResize(key, nextKey, delta, renderedWidths) + : undefined, + }) as AidpResizableTitleProps, + }; + }; + const allColumns: Record< + AidpKbColumnKey, + ColumnsType[number] + > = { + name: makeColumn("name", { + title: t("aidpKnowledge.columnName"), + dataIndex: "kds_name", + key: "name", + fixed: "left", + render: (_value, kb) => ( + + + + ), + }), + type: makeColumn("type", { + title: t("aidpKnowledge.columnType"), + key: "type", + render: (_value, kb) => ( + + {columnWidths.type >= 260 ? ( +
{renderScope(kb)}
+ ) : ( + {scopeText(kb)} + )} +
+ ), + }), + description: makeColumn("description", { + title: t("aidpKnowledge.columnDescription"), + dataIndex: "description", + key: "description", + ellipsis: true, + render: (value: string | undefined) => + renderText(value?.trim() || AIDP_UNKNOWN_VALUE), + }), + creator: makeColumn("creator", { + title: t("aidpKnowledge.columnCreator"), + key: "creator", + render: (_value, kb) => renderText(formatAidpCreator(kb.creator_name)), + }), + created_at: makeColumn("created_at", { + title: t("aidpKnowledge.columnCreatedAt"), + key: "created_at", + render: (_value, kb) => renderText(formatAidpDateTime(kb.created_at)), + }), + actions: makeColumn("actions", { + title: t("aidpKnowledge.columnActions"), + key: "actions", + fixed: "right", + align: "left", + render: (_value, kb) => + canModify(kb) ? ( + columnWidths.actions < 144 ? ( + renderMoreMenu(kb) + ) : ( +
+ + +
+ ) + ) : null, + }), + }; + const totalWidth = activeKeys.reduce( + (sum, key) => sum + columnWidths[key], + 0 + ); + + return ( +
+ + rowKey="kds_id" + size="middle" + className={styles.knowledgeTable} + columns={activeKeys.map((key) => allColumns[key])} + dataSource={displayedKbs} + pagination={false} + tableLayout="fixed" + components={{ + header: { cell: AidpResizableTitle }, + }} + scroll={{ x: totalWidth }} + onRow={(kb) => ({ + onClick: () => onSelect(kb), + style: { cursor: "pointer" }, + })} + />
+ ); + }; - {/* Server-side pagination. - AIDP exposes a dedicated Count API for KBs which the backend calls - alongside the list request. When Count succeeds, `totalReliable` - is true and we display the full pagination (page numbers + - "共 N 条"). When Count fails (e.g. endpoint unavailable), we fall - back to simple prev/next mode using `has_more`. */} - {kbs.length > 0 && (() => { - const effectiveTotal = totalReliable - ? total - : (hasMore - ? currentPage * pageSize + 1 - : currentPage * pageSize); - return ( -
- t("aidpKnowledge.showTotal", { count: total }) - : undefined - } - size="small" - /> -
- ); - })()} + const renderEmptyState = () => ( +
+ {keyword.trim() + ? t("aidpKnowledge.searchEmpty") + : t("aidpKnowledge.listEmpty")} +
+ ); + const renderFailedState = () => ( +
+

+ {loadError || t("aidpKnowledge.listLoadFailed")} +

+ +
+ ); + const renderBody = () => { + if (isLoading && displayedKbs.length === 0) { + return ( +
+ {t("aidpKnowledge.listLoading")} +
+ ); + } + if (loadFailed && displayedKbs.length === 0) return renderFailedState(); + if (displayedKbs.length === 0) return renderEmptyState(); + return viewMode === "cards" ? renderCards() : renderTable(); + }; + + const columnSettingContent = ( +
+ + onVisibleColumnsChange(values as AidpKbColumnKey[]) + } + className="flex flex-col gap-1" + > + {AIDP_KB_DEFAULT_COLUMNS.map((key) => ( + + {t(COLUMN_LABEL_KEYS[key])} + + ))} + +
); + + return ( + +
+ +
+
+
+ } + value={keyword} + onChange={(event) => onKeywordChange(event.target.value)} + size="middle" + /> + +
+
+ + onViewModeChange(value as AidpKbViewMode)} + options={[ + { + value: "table", + title: t("aidpKnowledge.viewTable"), + label: , + }, + { + value: "cards", + title: t("aidpKnowledge.viewCards"), + label: , + }, + ]} + /> + {viewMode === "table" && ( + + +
+
+
{renderBody()}
+ {kbs.length > 0 && ( +
+ +
+ )} +
+
+
+ ); }; export default AidpKnowledgeList; diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeVisuals.module.css b/frontend/ext_components/aidp/components/AidpKnowledgeVisuals.module.css new file mode 100644 index 0000000000..7e576cf41d --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpKnowledgeVisuals.module.css @@ -0,0 +1,524 @@ +.overview, +.pagination { + --aidp-primary: #0067d1; + --aidp-text: #191919; + --aidp-secondary: rgba(25, 25, 25, 0.7); + --aidp-muted: #777777; + --aidp-outline: rgba(201, 201, 201, 0.4); + color: var(--aidp-text); +} + +.overview { + container-type: inline-size; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 0 12px; + flex-shrink: 0; +} + +.searchGroup { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.searchInput { + width: 360px; + max-width: 100%; + flex-shrink: 1; + height: 32px; + padding: 5px 12px; +} + +.searchInput :global(.ant-input-prefix) { + margin-inline-end: 4px; + color: var(--aidp-muted); +} + +.createButton { + min-width: 102px; + padding-inline: 16px; + box-shadow: none; +} + +.viewSwitch { + border-radius: 6px; +} + +.viewSwitch :global(.ant-segmented-group) { + gap: 4px; +} + +.viewSwitch :global(.ant-segmented-item-label) { + width: 28px; + height: 28px; + min-height: 28px; + padding: 5px; + display: flex; + align-items: center; + justify-content: center; + line-height: 18px; + font-size: 18px; +} + +.viewSwitch :global(.ant-segmented-item-selected) { + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08); +} + +.cardGrid { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: start; + gap: 12px 16px; + padding: 1px; +} + +@container (min-width: 640px) { + .cardGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container (min-width: 1200px) { + .cardGrid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.card { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 10px; + height: 148px; + min-width: 0; + padding: 16px 24px; + border-radius: 12px; + background: white; + outline: 1px solid var(--aidp-outline); + cursor: pointer; + transition: outline-color 0.15s; +} + +.card:hover, +.card:focus-visible { + outline-color: var(--aidp-primary); +} + +.cardContent { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 12px; + height: 40px; + min-width: 0; +} + +.cardIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 8px; + color: #7c50e8; + background: #f3edff; + font-size: 24px; +} + +.cardTitle { + margin: 0; + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: var(--aidp-text); +} + +.cardMore { + align-self: flex-start; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + color: #939393; +} + +.cardDescription { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + margin: 0; + height: 42px; + font-size: 12px; + font-weight: 400; + line-height: 20px; + color: var(--aidp-secondary); +} + +.cardMetadata { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: 18px; + font-size: 12px; + line-height: 18px; + color: var(--aidp-secondary); +} + +.cardMetadata > span { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.cardMetadata :global(.anticon) { + flex-shrink: 0; + font-size: 14px; + color: #939393; +} + +.metadataValue { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardDate { + flex: 0 1 auto; +} + +.cardStatistic { + flex-shrink: 0; +} + +.guideHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 32px; +} + +.guideHeader h1 { + margin: 0; + color: var(--aidp-text); + font-size: 18px; + font-weight: 500; + line-height: 26px; +} + +.guideToggle { + padding-inline: 0; + color: var(--aidp-secondary); + font-size: 14px; +} + +.guideBody { + margin-top: 16px; + padding: 24px; + border-radius: 12px; + background: linear-gradient(180deg, #ecf3ff, rgba(255, 255, 255, 0)); +} + +.guideHeading { + display: flex; + align-items: center; + gap: 8px; +} + +.guideFlow { + margin-top: 24px; +} + +.guideBody h2 { + margin: 0; + color: var(--aidp-text); + font-size: 16px; + font-weight: 500; + line-height: 24px; +} + +.guideIntro { + margin: 12px 0 0; + color: var(--aidp-text); + font-size: 14px; + line-height: 22px; +} + +.guideSteps { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + margin-top: 12px; +} + +@container (min-width: 640px) { + .guideSteps { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.guideStep { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + height: 156px; + padding: 24px; + overflow: hidden; + border-radius: 8px; + outline: 1px solid #f5f5f5; + background: linear-gradient(120deg, #ecf3ff, #fcfdfd); +} + +.stepDecoration { + position: absolute; + right: 16px; + bottom: -8px; + width: 64px; + height: 64px; + color: #9ac1e8; + opacity: 0.18; + pointer-events: none; +} + +.guideStep h3 { + margin: 0; + font-size: 18px; + font-weight: 500; + line-height: 26px; + color: var(--aidp-text); +} + +.guideStep p { + margin: 0; + font-size: 14px; + line-height: 22px; + color: var(--aidp-muted); +} + +@container (min-width: 640px) and (max-width: 1000px) { + .guideStep { + gap: 12px; + padding: 16px; + } + + .guideStep h3 { + font-size: 16px; + line-height: 24px; + } + + .guideStep p { + font-size: 12px; + line-height: 20px; + } +} + +.tableName { + color: var(--aidp-primary); + font-weight: 400; +} + +.tableName:hover { + color: #0052a8; +} + +.tableArea { + width: 100%; + min-width: 0; +} + +.knowledgeTable :global(table) { + width: var(--aidp-table-width, 100%) !important; + min-width: var(--aidp-table-width, 100%) !important; +} + +.knowledgeTable :global(.ant-table-thead > tr > th) { + height: 40px; + padding: 8px !important; + font-size: 14px; + font-weight: 500; + line-height: 22px; + border-bottom: 1px solid #f3f3f3; +} + +.knowledgeTable :global(.ant-table-tbody > tr > td) { + height: 40px; + padding: 4px 8px !important; + font-size: 14px; + line-height: 22px; + border-bottom: 1px solid #f3f3f3; +} + +.knowledgeTable :global(.ant-table-thead > tr > th[data-column-key="name"]), +.knowledgeTable :global(.ant-table-tbody > tr > td:first-child) { + padding-left: 16px !important; +} + +.knowledgeTable :global(.ant-table-thead > tr > th::before) { + display: none; +} + +.columnResizeHandle { + position: absolute; + right: 0; + top: 0; + z-index: 10; + width: 8px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.columnResizeHandle::after { + position: absolute; + right: 3px; + top: 25%; + height: 50%; + width: 1px; + background: #c9c9c9; + content: ""; + opacity: 0; +} + +.columnResizeHandle:hover::after, +.columnResizeHandle:focus-visible::after { + background: var(--aidp-primary); + opacity: 1; +} + +.columnResizeHandle:focus-visible { + outline: 1px solid var(--aidp-primary); + outline-offset: -1px; +} + +.tableCellText { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tableScope { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.tableScope :global(.ant-tag) { + flex-shrink: 0; + height: 22px; + line-height: 20px; + border-radius: 4px; +} + +.tableScope .tableGroup { + min-width: 0; + max-width: 96px; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.tableActions { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + white-space: nowrap; +} + +.tableActions :global(.ant-btn) { + height: 24px; + padding: 0; + line-height: 22px; + font-size: 14px; +} + +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 24px; + width: 100%; + min-width: 0; + font-size: 14px; +} + +.paginationTotal { + flex-shrink: 0; + color: var(--aidp-secondary); + line-height: 22px; +} + +.paginationControls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-inline-start: auto; + min-width: 0; +} + +.pageSize { + width: 136px; +} + +.pageNumbers :global(.ant-pagination-item), +.pageNumbers :global(.ant-pagination-prev), +.pageNumbers :global(.ant-pagination-next) { + border-color: transparent; +} + +.pageNumbers :global(.ant-pagination-item-active) { + border-color: transparent; + font-weight: 400; +} + +.pageNumbers :global(.ant-pagination-item-active:hover) { + border-color: transparent; +} + +.pageJump { + display: flex; + align-items: center; + gap: 8px; +} + +.pageJumpInput { + width: 44px; + padding-inline: 4px; + text-align: center; +} diff --git a/frontend/ext_components/aidp/components/AidpPagination.tsx b/frontend/ext_components/aidp/components/AidpPagination.tsx new file mode 100644 index 0000000000..7e4fdad88d --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpPagination.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button, ConfigProvider, Input, Pagination, Select } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import enUS from "antd/locale/en_US"; +import { aidpKnowledgeVisualTheme } from "./aidpKnowledgeVisualTheme"; +import styles from "./AidpKnowledgeVisuals.module.css"; + +interface AidpPaginationProps { + currentPage: number; + pageSize: number; + total: number; + /** True when `total` came from the AIDP Count API. */ + totalReliable: boolean; + hasMore: boolean; + onPageChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; +} + +/** + * Server-side pagination shared by the AIDP knowledge base and document lists. + * + * AIDP exposes a dedicated Count API; when it succeeds `totalReliable` is true + * and the full control including the total is shown. When Count fails the total + * is only a fallback estimate, so the control degrades to simple prev/next mode + * driven by `hasMore` and omits the total. + */ +const AidpPagination: React.FC = ({ + currentPage, + pageSize, + total, + totalReliable, + hasMore, + onPageChange, + onPageSizeChange, +}) => { + const { t, i18n } = useTranslation(); + const [jumpPage, setJumpPage] = useState(""); + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + // When the total is unreliable the control still needs a value that enables + // "next": set it just past the current page when another page exists, + // otherwise clamp it to the current page end. + const effectiveTotal = totalReliable + ? total + : hasMore + ? currentPage * pageSize + 1 + : currentPage * pageSize; + + return ( + +
+ + {totalReliable + ? t("aidpKnowledge.showTotal", { count: total }) + : t("aidpKnowledge.showLoadedCount", { + count: Math.max(0, total), + })} + +
+ {onPageSizeChange && ( + + setJumpPage(event.target.value.replace(/\D/g, "")) + } + /> + + + )} +
+
+
+ ); +}; + +export default AidpPagination; diff --git a/frontend/ext_components/aidp/components/AidpResizableTitle.tsx b/frontend/ext_components/aidp/components/AidpResizableTitle.tsx new file mode 100644 index 0000000000..d5c8c2fed8 --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpResizableTitle.tsx @@ -0,0 +1,110 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { AIDP_MIN_COLUMN_WIDTH } from "@/lib/aidpKnowledgeColumnWidths"; +import styles from "./AidpKnowledgeVisuals.module.css"; + +export interface AidpResizableTitleProps extends Omit< + React.ThHTMLAttributes, + "onResize" +> { + width?: number; + maxWidth?: number; + onResize?: (delta: number, widths: Record) => void; +} + +function measureWidths(handle: HTMLElement): Record { + const widths: Record = {}; + handle + .closest("table") + ?.querySelectorAll("th[data-column-key]") + .forEach((cell) => { + if (cell.dataset.columnKey) + widths[cell.dataset.columnKey] = cell.getBoundingClientRect().width; + }); + return widths; +} + +const AidpResizableTitle: React.FC = ({ + width, + maxWidth, + onResize, + children, + ...rest +}) => { + const { t } = useTranslation(); + const drag = useRef<{ + x: number; + widths: Record; + cursor: string; + userSelect: string; + } | null>(null); + const finishDrag = () => { + if (!drag.current) return; + document.body.style.cursor = drag.current.cursor; + document.body.style.userSelect = drag.current.userSelect; + drag.current = null; + }; + useEffect(() => finishDrag, []); + const isFixedColumn = Boolean(rest.className?.includes("ant-table-cell-fix")); + + return ( + + {children} + {onResize && ( +
{ + if (event.button !== 0 || !event.isPrimary) return; + event.preventDefault(); + event.stopPropagation(); + drag.current = { + x: event.clientX, + widths: measureWidths(event.currentTarget), + cursor: document.body.style.cursor, + userSelect: document.body.style.userSelect, + }; + event.currentTarget.setPointerCapture(event.pointerId); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }} + onPointerMove={(event) => { + if (!drag.current) return; + event.preventDefault(); + onResize(event.clientX - drag.current.x, drag.current.widths); + }} + onPointerUp={finishDrag} + onPointerCancel={finishDrag} + onLostPointerCapture={finishDrag} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + onResize( + event.key === "ArrowRight" ? 8 : -8, + measureWidths(event.currentTarget) + ); + }} + /> + )} + + ); +}; + +export default AidpResizableTitle; diff --git a/frontend/ext_components/aidp/components/AidpSliderNumberField.tsx b/frontend/ext_components/aidp/components/AidpSliderNumberField.tsx new file mode 100644 index 0000000000..b5df696e90 --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpSliderNumberField.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { InputNumber, Slider } from "antd"; + +import styles from "./AidpCreateKbSections.module.css"; + +interface AidpSliderNumberFieldProps { + value?: number | null; + onChange?: (value: number | null) => void; + min: number; + max: number; + step: number; + unit?: string; + precision?: number; + marks: Record; + ariaLabel: string; +} + +const AidpSliderNumberField: React.FC = ({ + value, + onChange, + min, + max, + step, + unit, + precision, + marks, + ariaLabel, +}) => { + const safeValue = typeof value === "number" ? value : min; + + return ( +
+
+
+ `${sliderValue}${unit || ""}`, + }} + onChange={(nextValue) => onChange?.(nextValue)} + aria-label={ariaLabel} + /> +
+ {marks[min]} + {marks[max]} +
+
+
+ + {unit || ""} +
+
+
+ ); +}; + +export default AidpSliderNumberField; diff --git a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx index b9597a39f8..141c8388a5 100644 --- a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx +++ b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx @@ -3,8 +3,7 @@ import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Collapse, Modal, Form, Input, message } from "antd"; -import { SettingOutlined } from "@ant-design/icons"; +import { Modal, Form, Input, message } from "antd"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; @@ -19,6 +18,7 @@ import { interface AidpUpdateKbModalProps { open: boolean; + mode: "metadata" | "permissions"; knowledgeBase: AidpKnowledgeBaseItem | null; onCancel: () => void; onSuccess: (knowledgeBase: AidpKnowledgeBaseItem) => void; @@ -26,6 +26,7 @@ interface AidpUpdateKbModalProps { const AidpUpdateKbModal: React.FC = ({ open, + mode, knowledgeBase, onCancel, onSuccess, @@ -36,8 +37,6 @@ const AidpUpdateKbModal: React.FC = ({ const { isUser, canConfigureGroupPermissions, groupOptions } = useAidpGroupOptions(); - const [advancedOpen, setAdvancedOpen] = React.useState(false); - const ingroupPermission = Form.useWatch("ingroup_permission", form); // Pre-fill form when opening. ``group_ids`` may be null/undefined on rows @@ -45,7 +44,6 @@ const AidpUpdateKbModal: React.FC = ({ // (mode="multiple") receives a value shape it accepts. useEffect(() => { if (!open) return; - setAdvancedOpen(false); if (!knowledgeBase) return; form.setFieldsValue({ name: knowledgeBase.kds_name, @@ -68,15 +66,22 @@ const AidpUpdateKbModal: React.FC = ({ const values = await form.validateFields(); setLoading(true); - const name = values.name.trim(); - const description = values.description?.trim() || ""; - const nameChanged = name !== knowledgeBase.kds_name.trim(); + const name = + mode === "metadata" + ? values.name.trim() + : knowledgeBase.kds_name.trim(); + const description = + mode === "metadata" + ? values.description?.trim() || "" + : (knowledgeBase.description || "").trim(); + const nameChanged = + mode === "metadata" && name !== knowledgeBase.kds_name.trim(); const descriptionChanged = + mode === "metadata" && description !== (knowledgeBase.description || "").trim(); - // The advanced permission fields are intentionally hidden for USER - // accounts. Keep a form-level fallback so metadata-only edits still - // submit a valid permission when those fields are not mounted. + // Permission fields are hidden for USER accounts. Keep a form-level + // fallback so metadata-only edits still submit a valid permission. const newPermission = isUser ? "PRIVATE" : values.ingroup_permission || @@ -172,7 +177,7 @@ const AidpUpdateKbModal: React.FC = ({ confirmLoading={loading} centered width={640} - maskClosable={false} + mask={{ closable: false }} destroyOnHidden styles={AIDP_MODAL_STYLES} footer={ @@ -187,52 +192,30 @@ const AidpUpdateKbModal: React.FC = ({ >
- + {mode === "metadata" && } {canConfigureGroupPermissions ? ( - - setAdvancedOpen( - Array.isArray(keys) - ? keys.includes("advanced") - : keys === "advanced" - ) - } - items={[ - { - key: "advanced", - // Keep permission fields registered with the Form while - // the Collapse is closed so metadata-only edits still - // submit the existing group_ids value. - forceRender: true, - label: ( - - - {t("aidpKnowledge.createAdvancedOptions")} - - ), - children: ( -
- -
- ), - }, - ]} - /> +
+ +
) : (