From 961e178f0f20ee6ccab2f9a732bb30f3949c7ca2 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Mon, 28 Sep 2026 19:43:32 +0800 Subject: [PATCH 01/36] feat(aidp): refactor knowledge base list and creation pages Split the AIDP knowledge base overview and its file management into two views: a card or table overview with a collapsible three-step guide, an independent name search, a column visibility setting and a full-page file view that restores the previous search, page, view mode and column state. Move creation to a dedicated two-step page under knowledges/create. The first step owns the basic fields, permission and user groups, the safety guard, the knowledge graph, the chunking mode, the models and the retrieval settings; the next step validates locally only and the knowledge base is created once at the final submission, before any file upload. Restore the document download and delete entries that were dropped from the AIDP file list during the v2.6.1 merge, keeping the documented read and edit permission gates. Backend: accept the new create and update fields, validate and serialize the structured graph configuration, forward an explicit disabled safety guard, and expose the optional display metadata (personal/enterprise flag, personal capacity, creator name, document count reliability) on the list response. Formal assets: requirement, feature and D1-D5 case documents for the change, validated in the design phase. Co-authored-by: ZCode Generated-by: deepseek-flash --- .../ext_components/aidp/apps/aidp_mgmt_app.py | 44 +- .../aidp/services/aidp_service.py | 113 +++ .../app/[locale]/knowledges/create/page.tsx | 13 + .../aidp/components/AidpCreateKbPage.tsx | 951 ++++++++++++++++++ .../aidp/components/AidpDocumentList.tsx | 163 ++- .../components/AidpKnowledgeConfiguration.tsx | 361 ++++--- .../aidp/components/AidpKnowledgeGuide.tsx | 61 ++ .../aidp/components/AidpKnowledgeList.tsx | 660 ++++++++---- .../aidp/components/AidpUpdateKbModal.tsx | 59 +- .../aidp/services/aidpKnowledgeService.ts | 60 +- frontend/lib/aidpKnowledgeDisplay.ts | 110 ++ frontend/public/locales/en/common.json | 84 +- frontend/public/locales/zh/common.json | 84 +- frontend/types/agentConfig.ts | 26 +- .../d1/aidp-knowledge-pages-refactor.yaml | 502 +++++++++ .../d2/aidp-knowledge-pages-refactor.yaml | 176 ++++ .../d3/aidp-knowledge-pages-refactor.yaml | 99 ++ .../d4/aidp-knowledge-pages-refactor.yaml | 202 ++++ .../d5/aidp-knowledge-pages-refactor.yaml | 206 ++++ .../aidp-knowledge-pages-refactor.yaml | 41 + .../aidp-knowledge-pages-refactor.yaml | 43 + ...\350\257\225\345\237\272\347\272\277.xlsx" | Bin 20202 -> 37587 bytes 22 files changed, 3678 insertions(+), 380 deletions(-) create mode 100644 frontend/app/[locale]/knowledges/create/page.tsx create mode 100644 frontend/ext_components/aidp/components/AidpCreateKbPage.tsx create mode 100644 frontend/ext_components/aidp/components/AidpKnowledgeGuide.tsx create mode 100644 frontend/lib/aidpKnowledgeDisplay.ts create mode 100644 test/cases/d1/aidp-knowledge-pages-refactor.yaml create mode 100644 test/cases/d2/aidp-knowledge-pages-refactor.yaml create mode 100644 test/cases/d3/aidp-knowledge-pages-refactor.yaml create mode 100644 test/cases/d4/aidp-knowledge-pages-refactor.yaml create mode 100644 test/cases/d5/aidp-knowledge-pages-refactor.yaml create mode 100644 test/changes/requirements/aidp-knowledge-pages-refactor.yaml create mode 100644 test/features/aidp-knowledge-pages-refactor.yaml diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index 9dfc970fc8..e3f35406bf 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -171,6 +171,25 @@ 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" + ) + sensitive_intercept_enalbe: Optional[int] = Field( + None, ge=0, le=1, description="Safety guard: 1 enabled, 0 disabled" + ) # Nexent-side permission payload. Never forwarded to AIDP. ingroup_permission: Optional[str] = Field( "READ_ONLY", @@ -194,6 +213,12 @@ class SetPermissionRequest(BaseModel): name: Optional[str] = Field(None, min_length=1, description="Changed KB name; omit when unchanged") description: Optional[str] = Field(None, description="Changed description; omit when unchanged") + sensitive_intercept_enalbe: Optional[int] = Field( + None, + ge=0, + le=1, + description="Changed safety guard value; an explicit 0 disables it and must not be dropped", + ) ingroup_permission: str = Field(..., description="EDIT / READ_ONLY / PRIVATE") group_ids: Optional[List[int]] = Field( None, @@ -630,6 +655,18 @@ async def resolve_detail(row: dict) -> tuple[dict, str]: "group_ids": row.get("group_ids"), "created_by": 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 document count is only a real statistic when the detail + # payload supplied it; the catalog fallback is a compatibility + # default, not a confirmed count. + "document_count_reliable": bool(detail), }) total_ms = (time.perf_counter() - started_at) * 1000 @@ -1123,7 +1160,12 @@ async def set_permission( detail=str(exc), ) - metadata = body.model_dump(include={"name", "description"}, exclude_none=True) + # An explicit safety guard value of 0 must survive this whitelist: dropping + # it would silently leave the remote setting enabled. + metadata = body.model_dump( + include={"name", "description", "sensitive_intercept_enalbe"}, + exclude_none=True, + ) if "name" in metadata: metadata["name"] = metadata["name"].strip() if not metadata["name"]: diff --git a/backend/ext_components/aidp/services/aidp_service.py b/backend/ext_components/aidp/services/aidp_service.py index b9830296ef..e22cd1325a 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 @@ -808,6 +809,9 @@ 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", @@ -817,9 +821,105 @@ def count_aidp_kbs_impl(server_url: str, api_key: str) -> int: "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 = {"medical", "finance", "general"} +_GRAPH_PROMPT_LANGUAGES = {"chinese", "english"} +_GRAPH_PROMPT_MAX_BYTES = 2048 + + +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 "general" + if domain not in _GRAPH_DOMAINS: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + f"Unsupported graph domain: {domain}", + ) + + prompt_language = config.get("prompt_language") or "chinese" + 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 len(prompt_text.encode("utf-8")) > _GRAPH_PROMPT_MAX_BYTES: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + f"Graph prompt exceeds {_GRAPH_PROMPT_MAX_BYTES} UTF-8 bytes", + ) + + def _bounded_int(key: str, default: int, minimum: int, maximum: int) -> int: + value = config.get(key, default) + 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 value + + payload = { + "domain": domain, + "retrieve_default_topk": _bounded_int("retrieve_default_topk", 5, 1, 100), + "retrieve_subgraph_hop": _bounded_int("retrieve_subgraph_hop", 2, 1, 3), + "no_think_mode": bool(config.get("no_think_mode", True)), + "prompt_language": prompt_language, + "prompt_text": prompt_text, + "synonym_merge_enable": bool(config.get("synonym_merge_enable", False)), + "disambiguation_enable": bool(config.get("disambiguation_enable", False)), + } + 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. @@ -862,6 +962,19 @@ 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: + graph_config = result.get("graph_config") + if isinstance(graph_config, dict): + result["graph_config"] = _serialize_graph_config(graph_config) + + _validate_chunking(result) return result 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/ext_components/aidp/components/AidpCreateKbPage.tsx b/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx new file mode 100644 index 0000000000..31474dfb6e --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx @@ -0,0 +1,951 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { useParams, useRouter } from "next/navigation"; + +import { + Button, + Collapse, + Divider, + Form, + Input, + InputNumber, + Select, + Space, + Steps, + Switch, + Tooltip, + Upload, + message, +} from "antd"; +import { + ArrowLeftOutlined, + InboxOutlined, + QuestionCircleOutlined, +} from "@ant-design/icons"; + +import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; +import type { + AidpGraphConfig, + AidpModelItem, +} from "@/ext_components/aidp/services/aidpKnowledgeService"; +import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; +import { USER_ROLES } from "@/const/auth"; +import { + AIDP_ACCEPT_STRING, + AIDP_KNOWLEDGE_BASE_NAME_PATTERN, +} from "@/const/knowledgeBase"; +import { collectUploadedFileIds } from "@/lib/aidpDocumentStatus"; +import { + partitionAidpFiles, + validateAidpFiles, +} from "@/services/uploadService"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { useDeployment } from "@/components/providers/deploymentProvider"; +import log from "@/lib/logger"; + +import { + AidpKnowledgeBaseBasicFields, + AidpKnowledgeBasePermissionFields, +} from "./AidpKnowledgeBaseModalParts"; + +const { Dragger } = Upload; + +/** + * Default AIDP knowledge base configuration. + * + * The chunking, retrieval, embedding and caption values keep the semantics the + * existing Nexent create call already had (1024 tokens, 128 token overlap, + * similarity 0.0, Top K 10, embedding "default", captioning off). The graph + * parameters use the documented AIDP defaults, which are not the prototype + * sample values. + */ +const AIDP_CREATE_DEFAULTS = { + chunk_token_num: 1024, + /** Overlap as a percentage of the chunk size; 128/1024 = 12.5%. */ + chunk_overlap_percent: 12.5, + chunk_mode: 0, + embedding_model: "default", + is_personal: 0, + topk: 10, + similarity: 0.0, + smartsplit: 1, + caption_enable: 0, + is_exist_graph: false, + graph_domain: "general" as const, + graph_topk: 5, + graph_hop: 2, + /** UI switch; the submitted `no_think_mode` is its inverse. */ + graph_thinking: false, + graph_prompt_language: "chinese" as const, + graph_synonym_merge: false, + graph_disambiguation: false, + sensitive_intercept_enalbe: 0, +}; + +/** Editable default extraction prompt (UTF-8 length is validated on submit). */ +const DEFAULT_GRAPH_PROMPT = `你作为专业知识图谱抽取引擎,仅执行输入文本中实体、关系、属性三元组的标准化提取。 +严格遵循构图任务约定的输出字段与结构,输出合法、固定格式的 JSON 数组,不输出 Markdown 标记、解释、推理过程或其他冗余文本。 +仅依据输入文本和其中明确的上下文提取信息,不编造实体、关系或属性,不补充原文没有提供的事实。 +实体名称应保持一致;同一实体使用统一表述,不合并无法确认是同一对象的实体。 +关系和属性使用明确、简洁的表述,保留原文中的否定、条件和范围限定。 +没有可提取信息时输出空数组 []。`; + +const GRAPH_PROMPT_MAX_BYTES = 2048; +const CHUNK_TOKEN_MIN = 256; +const CHUNK_TOKEN_MAX = 4096; +const OVERLAP_PERCENT_MAX = 50; + +const byteLength = (value: string): number => + typeof TextEncoder === "undefined" + ? value.length + : new TextEncoder().encode(value).length; + +/** + * Dedicated AIDP knowledge base creation page. + * + * Two steps: the first collects every creation parameter (permissions included) + * and only validates locally — no resource exists until the final submit. The + * second step optionally uploads files after the knowledge base was created. + * Upload failures never recreate the knowledge base; only the failed files can + * be retried. + */ +const AidpCreateKbPage: React.FC = () => { + const { t, i18n } = useTranslation(); + const router = useRouter(); + const params = useParams(); + const locale = (params?.locale as string) || "zh"; + const { enableAidpKnowledge, isDeploymentReady } = useDeployment(); + + const [form] = Form.useForm(); + const [current, setCurrent] = useState(0); + const [loading, setLoading] = useState(false); + const [fileList, setFileList] = useState([]); + const fileListRef = useRef([]); + const pendingFilesRef = useRef([]); + const rafIdRef = useRef(null); + /** Guards against a second create while one submission is in flight. */ + const submittingRef = useRef(false); + /** Knowledge base created by the current submission, if any. */ + const createdKbIdRef = useRef(null); + + useEffect(() => { + fileListRef.current = fileList; + }, [fileList]); + + // The entry only serves AIDP; an ES deployment has no creation entry here, + // so a direct visit goes back to the knowledge base page. + useEffect(() => { + if (!isDeploymentReady) return; + if (!enableAidpKnowledge) { + router.replace(`/${locale}/knowledges`); + } + }, [enableAidpKnowledge, isDeploymentReady, locale, router]); + + // ---- Permissions and groups ---- + const { user } = useAuthorizationContext(); + const isUser = user?.role === USER_ROLES.USER; + const canConfigureGroupPermissions = !!user && !isUser; + const tenantId = user?.tenantId ?? null; + const { data: groupListData } = useGroupList( + canConfigureGroupPermissions ? tenantId : null + ); + const groupOptions = useMemo( + () => + (groupListData?.groups ?? []).map((g) => ({ + value: g.group_id, + label: g.group_name, + })), + [groupListData] + ); + const ingroupPermission = Form.useWatch("ingroup_permission", form); + + // ---- Model queries: one category per control, independent caches ---- + const { data: llmModelsData, isLoading: llmModelsLoading } = useQuery({ + queryKey: ["aidp-models", "llm", "KnowledgeBase"], + queryFn: () => aidpKnowledgeService.listModels("llm", "KnowledgeBase"), + staleTime: 5 * 60 * 1000, + }); + const { data: vlmModelsData, isLoading: vlmModelsLoading } = useQuery({ + queryKey: ["aidp-models", "vlm", "KnowledgeBase"], + queryFn: () => aidpKnowledgeService.listModels("vlm", "KnowledgeBase"), + staleTime: 5 * 60 * 1000, + }); + const { data: embeddingModelsData, isLoading: embeddingModelsLoading } = + useQuery({ + queryKey: ["aidp-models", "embedding", "KnowledgeBase"], + queryFn: () => + aidpKnowledgeService.listModels("embedding", "KnowledgeBase"), + staleTime: 5 * 60 * 1000, + }); + + const modelNames = useCallback( + (data: { models?: AidpModelItem[] } | undefined) => { + const models: AidpModelItem[] = data?.models ?? []; + return models + .map((m) => m.model_name) + .filter( + (name): name is string => typeof name === "string" && name.length > 0 + ); + }, + [] + ); + + const llmModelOptions = useMemo( + () => modelNames(llmModelsData), + [llmModelsData, modelNames] + ); + const vlmModelOptions = useMemo( + () => modelNames(vlmModelsData), + [vlmModelsData, modelNames] + ); + // `default` keeps the established semantics of the existing create call, so + // it stays selectable even when AIDP advertises other embedding models. + const embeddingModelOptions = useMemo(() => { + const advertised = modelNames(embeddingModelsData); + return advertised.includes(AIDP_CREATE_DEFAULTS.embedding_model) + ? advertised + : [AIDP_CREATE_DEFAULTS.embedding_model, ...advertised]; + }, [embeddingModelsData, modelNames]); + + // ---- Conditional sections ---- + const graphEnabled = Form.useWatch("is_exist_graph", form); + const captionEnabled = Form.useWatch("caption_enable", form); + + // ---- Defaults ---- + useEffect(() => { + form.setFieldsValue({ + chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, + chunk_overlap_percent: AIDP_CREATE_DEFAULTS.chunk_overlap_percent, + chunk_mode: AIDP_CREATE_DEFAULTS.chunk_mode, + topk: AIDP_CREATE_DEFAULTS.topk, + similarity: AIDP_CREATE_DEFAULTS.similarity, + embedding_model: AIDP_CREATE_DEFAULTS.embedding_model, + caption_enable: AIDP_CREATE_DEFAULTS.caption_enable === 1, + is_exist_graph: AIDP_CREATE_DEFAULTS.is_exist_graph, + graph_domain: AIDP_CREATE_DEFAULTS.graph_domain, + graph_topk: AIDP_CREATE_DEFAULTS.graph_topk, + graph_hop: AIDP_CREATE_DEFAULTS.graph_hop, + graph_thinking: AIDP_CREATE_DEFAULTS.graph_thinking, + graph_prompt_language: AIDP_CREATE_DEFAULTS.graph_prompt_language, + graph_synonym_merge: AIDP_CREATE_DEFAULTS.graph_synonym_merge, + graph_disambiguation: AIDP_CREATE_DEFAULTS.graph_disambiguation, + graph_prompt_text: DEFAULT_GRAPH_PROMPT, + sensitive_intercept_enalbe: + AIDP_CREATE_DEFAULTS.sensitive_intercept_enalbe === 1, + ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", + group_ids: [], + }); + }, [form, isUser]); + + const steps = [ + { title: t("aidpKnowledge.createStepInfo") }, + { title: t("aidpKnowledge.createStepUpload") }, + ]; + + const goBackToList = () => router.push(`/${locale}/knowledges`); + + /** Validate the first step; nothing is created here. */ + const handleNext = async () => { + try { + await form.validateFields(); + setCurrent(1); + } catch { + // Validation errors are rendered next to the fields. + } + }; + + const handleBack = () => setCurrent(0); + + /** Jump to the created knowledge base file view. */ + const openCreatedKb = (kbId: string) => { + router.push(`/${locale}/knowledges?kb=${encodeURIComponent(kbId)}`); + }; + + const handleSubmit = async (skipUpload: boolean) => { + if (submittingRef.current) return; + submittingRef.current = true; + let createdId: string | null = createdKbIdRef.current; + setLoading(true); + try { + const values = await form.validateFields(); + const permission = isUser + ? "PRIVATE" + : values.ingroup_permission || "READ_ONLY"; + const groupIds = + isUser || permission === "PRIVATE" + ? [] + : Array.isArray(values.group_ids) + ? values.group_ids + : []; + + // Defense in depth: re-validate every selected file in case the drop + // handler was bypassed. + if (createdId === null && !skipUpload && fileList.length > 0) { + const validation = validateAidpFiles(fileList); + if (validation.valid.length !== fileList.length) { + partitionAidpFiles(fileList, t, message); + return; + } + } + + if (createdId === null) { + const chunkTokens = + values.chunk_token_num ?? AIDP_CREATE_DEFAULTS.chunk_token_num; + const overlapPercent = + values.chunk_overlap_percent ?? + AIDP_CREATE_DEFAULTS.chunk_overlap_percent; + const overlapTokens = Math.floor( + (chunkTokens * Number(overlapPercent)) / 100 + ); + + const graphConfig: AidpGraphConfig | undefined = values.is_exist_graph + ? { + domain: values.graph_domain || AIDP_CREATE_DEFAULTS.graph_domain, + retrieve_default_topk: + values.graph_topk ?? AIDP_CREATE_DEFAULTS.graph_topk, + retrieve_subgraph_hop: + values.graph_hop ?? AIDP_CREATE_DEFAULTS.graph_hop, + // The UI switch is the inverse of the submitted thinking mode. + no_think_mode: !values.graph_thinking, + prompt_language: + values.graph_prompt_language || + AIDP_CREATE_DEFAULTS.graph_prompt_language, + prompt_text: values.graph_prompt_text || DEFAULT_GRAPH_PROMPT, + synonym_merge_enable: !!values.graph_synonym_merge, + disambiguation_enable: !!values.graph_disambiguation, + } + : undefined; + + const created = await aidpKnowledgeService.createKb({ + name: values.name.trim(), + description: values.description?.trim() || "", + chunk_token_num: chunkTokens, + chunk_overlap_num: overlapTokens, + chunk_mode: values.chunk_mode ?? AIDP_CREATE_DEFAULTS.chunk_mode, + embedding_model: + values.embedding_model || AIDP_CREATE_DEFAULTS.embedding_model, + topk: values.topk ?? AIDP_CREATE_DEFAULTS.topk, + similarity: values.similarity ?? AIDP_CREATE_DEFAULTS.similarity, + smartsplit: AIDP_CREATE_DEFAULTS.smartsplit, + is_personal: AIDP_CREATE_DEFAULTS.is_personal, + caption_enable: values.caption_enable ? 1 : 0, + vlm_model: values.caption_enable ? values.vlm_model || "" : "", + is_exist_graph: !!values.is_exist_graph, + graph_config: graphConfig, + llm_model_name: values.is_exist_graph + ? values.llm_model_name || "" + : "", + sensitive_intercept_enalbe: values.sensitive_intercept_enalbe ? 1 : 0, + ingroup_permission: permission, + group_ids: groupIds, + }); + createdId = String(created.kds_id || ""); + createdKbIdRef.current = createdId; + } + + // Upload only after a successful create; a failure here never creates + // another knowledge base. + if (createdId && !skipUpload && fileList.length > 0) { + const result = await aidpKnowledgeService.uploadDocs( + createdId, + fileList + ); + const failureDetails = result.failed_list.map((item) => { + const reason = i18n.language.startsWith("zh") + ? item.reason_zh || item.reason_en + : item.reason_en || item.reason_zh; + return `${item.file_name}: ${reason || t("aidpKnowledge.uploadFailed")}`; + }); + const failureLines = failureDetails.map((detail, index) => ( +
{detail}
+ )); + + if (result.summary.failed > 0 && result.summary.success === 0) { + message.warning( +
+
{t("aidpKnowledge.createKbSuccess")}
+ {failureLines.length > 0 ? ( + failureLines + ) : ( +
{t("aidpKnowledge.uploadFailed")}
+ )} +
+ ); + } else if (result.summary.failed > 0) { + message.info( +
+
{t("aidpKnowledge.createKbSuccess")}
+
+ {t("aidpKnowledge.uploadPartial", { + success: result.summary.success, + failed: result.summary.failed, + })} +
+ {failureLines} +
+ ); + } else { + message.success( + `${t("aidpKnowledge.createKbSuccess")} | ${t( + "aidpKnowledge.uploadSuccess", + { count: result.summary.success } + )}` + ); + } + void collectUploadedFileIds(result.success_list); + } else { + message.success(t("aidpKnowledge.createKbSuccess")); + } + + if (createdId) openCreatedKb(createdId); + } catch (error) { + log.error("Failed to submit AIDP knowledge base creation:", error); + const reason = + error instanceof Error && error.message.trim() + ? error.message + : createdId + ? t("aidpKnowledge.uploadFailed") + : t("aidpKnowledge.createKbFailed"); + message.error( + createdId ? `${t("aidpKnowledge.createKbSuccess")} | ${reason}` : reason + ); + // The knowledge base exists even though a later step failed: take the + // user to it instead of leaving a form that would create a duplicate. + if (createdId) openCreatedKb(createdId); + } finally { + setLoading(false); + submittingRef.current = false; + } + }; + + const renderStep0 = () => ( +
+

+ {t("aidpKnowledge.createSectionBasic")} +

+ + + {canConfigureGroupPermissions && ( + <> + +

+ {t("aidpKnowledge.createSectionPermission")} +

+ + + )} + + +

+ {t("aidpKnowledge.createSectionSafety")} +

+ + {t("aidpKnowledge.createSafetyGuard")} + + + + + } + > + + + + +

+ {t("aidpKnowledge.createSectionGraph")} +

+ + {t("aidpKnowledge.createGraphEnable")} + + + + + } + > + + + + {graphEnabled && ( + <> + + ({ + label: name, + value: name, + }))} + /> + + + {t("aidpKnowledge.createGraphThinking")} + + + + + } + > + + + + + + + + + + {t("aidpKnowledge.createOverlapPercent")} + + + + + } + rules={[ + { + required: true, + message: t("aidpKnowledge.createOverlapPercentRequired"), + }, + { + type: "number", + min: 0, + max: OVERLAP_PERCENT_MAX, + message: t("aidpKnowledge.createOverlapPercentRange", { + max: OVERLAP_PERCENT_MAX, + }), + }, + ]} + > + + + + + + ({ + label: name, + value: name, + }))} + /> + + )} + + + + + + + + ), + }, + ]} + /> + + ); + + const renderStep1 = () => ( +
+ ({ + uid: `${i}-${f.name}`, + name: f.name, + size: f.size, + status: "done" as const, + originFileObj: f as unknown as File & { + uid: string; + lastModifiedDate: Date; + }, + }))} + beforeUpload={(_file) => { + // Antd fires beforeUpload once per file in a batch; queue them and + // flush validation plus state once per frame. + pendingFilesRef.current.push(_file); + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(() => { + const batch = pendingFilesRef.current; + pendingFilesRef.current = []; + rafIdRef.current = null; + + const currentFiles = fileListRef.current; + const existing = new Set(currentFiles.map((file) => file.name)); + const uniqueBatch = batch.filter( + (file) => !existing.has(file.name) + ); + const { valid } = partitionAidpFiles( + uniqueBatch, + t, + message, + currentFiles.length + ); + if (valid.length > 0) { + setFileList([...currentFiles, ...valid]); + } + }); + } + return false; + }} + onRemove={(file) => { + setFileList((prev) => + prev.filter((f) => f.name !== (file as { name?: string }).name) + ); + }} + > +

+ +

+

{t("aidpKnowledge.uploadHint")}

+
+
{t("aidpKnowledge.uploadHintCount")}
+
{t("aidpKnowledge.uploadHintSize")}
+
+ {t("aidpKnowledge.uploadHintFormats")} +
+
+
+ + {fileList.length === 0 && ( +
+ {t("aidpKnowledge.createNoFiles")} +
+ )} +
+ ); + + return ( +
+
+
+ +

+ {t("aidpKnowledge.createPageTitle")} +

+
+ +
+ + + {/* Keep the first step mounted: its field values must survive moving + back and forth between the two steps. */} +
{renderStep0()}
+ {current === 1 && renderStep1()} +
+ +
+
+ {current === 1 && ( + + )} +
+ + + {current === 0 && ( + + )} + {current === 1 && ( + + )} + {current === 1 && fileList.length > 0 && ( + + )} + +
+ {loading && ( +
+ {t("aidpKnowledge.createSubmitting")} +
+ )} +
+
+ ); +}; + +export default AidpCreateKbPage; diff --git a/frontend/ext_components/aidp/components/AidpDocumentList.tsx b/frontend/ext_components/aidp/components/AidpDocumentList.tsx index b8131da270..8e1d725657 100644 --- a/frontend/ext_components/aidp/components/AidpDocumentList.tsx +++ b/frontend/ext_components/aidp/components/AidpDocumentList.tsx @@ -1,12 +1,21 @@ import React, { useState, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Pagination, Tag, Upload, message, Tooltip } from "antd"; +import { + Button, + Pagination, + Popconfirm, + Tag, + Upload, + message, + Tooltip, +} from "antd"; import { UploadOutlined, InboxOutlined, ReloadOutlined, } from "@ant-design/icons"; +import { Download, Trash2 } from "lucide-react"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import type { AidpDocumentItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; @@ -19,6 +28,7 @@ import { normalizeAidpDocStatus, } from "@/lib/aidpDocumentStatus"; import { partitionAidpFiles } from "@/services/uploadService"; +import log from "@/lib/logger"; const { Dragger } = Upload; @@ -51,6 +61,40 @@ const isDuplicateUploadReason = ( ); }; +/** + * Resolve the download filename from the response headers. + * + * The backend forwards the AIDP file name through `X-File-Name` and a + * standard `Content-Disposition`; either may carry a percent-encoded UTF-8 + * value, so both are decoded and the listed document name is only used as a + * last resort. + */ +const resolveDownloadFilename = ( + response: Response, + fallback: string +): string => { + const headerName = response.headers.get("X-File-Name"); + if (headerName) { + try { + return decodeURIComponent(headerName); + } catch { + return headerName; + } + } + const disposition = response.headers.get("Content-Disposition") || ""; + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + // fall through to the plain form + } + } + const plainMatch = disposition.match(/filename="?([^";]+)"?/i); + if (plainMatch?.[1]) return plainMatch[1]; + return fallback; +}; + /** Table cell showing a document name above its AIDP file id. */ const DocumentNameCell: React.FC<{ fileName: string; fileInoNo: string }> = ({ fileName, @@ -172,6 +216,10 @@ const AidpDocumentList: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const [uploading, setUploading] = useState(false); + /** File uuid currently downloading; drives that row's button spinner. */ + const [downloadingFileUuid, setDownloadingFileUuid] = useState( + null + ); // Antd fires beforeUpload once per file in a multi-select batch. // The `fileList` array may-or-may-not be the same reference across the N // calls (behavior differs between and and antd versions), @@ -252,6 +300,80 @@ const AidpDocumentList: React.FC = ({ [activeKb, i18n.language, onDocsUploaded, t] ); + // ---- Download and delete ---- + // Download requires read access to the knowledge base and delete requires + // edit access. An unavailable or orphaned knowledge base cannot be serviced + // by AIDP at all, so both operations stay disabled there regardless of the + // effective permission. + const isKbUnavailable = + activeKb?.resource_status === "UNAVAILABLE" || + activeKb?.resource_status === "ORPHANED"; + const canDeleteDocuments = + !!activeKb && !isKbUnavailable && activeKb.permission === "EDIT"; + const canDownloadDocuments = !!activeKb && !isKbUnavailable; + + const handleDownload = useCallback( + async (doc: AidpDocumentItem) => { + if (!activeKb) return; + setDownloadingFileUuid(doc.file_uuid); + try { + const response = await aidpKnowledgeService.downloadDoc( + activeKb.kds_id, + doc.file_uuid + ); + const blob = await response.blob(); + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = resolveDownloadFilename(response, doc.file_name); + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(downloadUrl); + message.success(t("aidpKnowledge.downloadSuccess")); + } catch (error) { + log.error("Failed to download AIDP document:", error); + message.error(t("aidpKnowledge.downloadFailed")); + } finally { + setDownloadingFileUuid(null); + } + }, + [activeKb, t] + ); + + const handleDeleteDocument = useCallback( + async (doc: AidpDocumentItem) => { + if (!activeKb) return; + try { + const result = await aidpKnowledgeService.removeDoc( + activeKb.kds_id, + doc.file_uuid + ); + if (result.summary.failed > 0 && result.summary.success === 0) { + message.error(t("aidpKnowledge.deleteDocFailed")); + } else if (result.summary.failed > 0) { + message.warning( + t("aidpKnowledge.deleteDocPartial", { + success: result.summary.success, + failed: result.summary.failed, + }) + ); + } else { + message.success(t("aidpKnowledge.deleteDocSuccess")); + } + // The single-document UI always sends one uuid, so a partial result + // means that one file failed; refresh either way to keep the list + // consistent with the upstream state. + onRefresh(); + } catch (error) { + log.error("Failed to delete AIDP document:", error); + message.error(t("aidpKnowledge.deleteDocFailed")); + } + }, + [activeKb, onRefresh, t] + ); + // Format file size for display const formatSize = (bytes?: number): string => { if (!bytes || bytes === 0) return "-"; @@ -319,6 +441,9 @@ const AidpDocumentList: React.FC = ({ {t("aidpKnowledge.docCreatedAt")} + + {t("aidpKnowledge.docActions")} + @@ -340,6 +465,42 @@ const AidpDocumentList: React.FC = ({ ? new Date(doc.created_at).toLocaleString() : "-"} + +
+ {canDownloadDocuments && ( + +
+ ))} diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx index bef0cf94e7..8fe1f89d74 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx @@ -1,16 +1,19 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { + useState, + useEffect, + useCallback, + useMemo, + useRef, +} 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, Button, Modal, Tag } from "antd"; +import { ArrowLeftOutlined, InfoCircleFilled } from "@ant-design/icons"; -import { - SETUP_PAGE_CONTAINER, - TWO_COLUMN_LAYOUT, - STANDARD_CARD, -} from "@/const/layoutConstants"; +import { SETUP_PAGE_CONTAINER, STANDARD_CARD } from "@/const/layoutConstants"; import { KB_SEARCH_DEBOUNCE_MS } from "@/const/knowledgeBase"; import { AIDP_DOC_STATUS_POLL_MS, @@ -24,30 +27,60 @@ import aidpKnowledgeService, { } from "@/ext_components/aidp/services/aidpKnowledgeService"; import log from "@/lib/logger"; -import AidpKnowledgeList from "./AidpKnowledgeList"; +import AidpKnowledgeList, { + AIDP_KB_DEFAULT_COLUMNS, + type AidpKbColumnKey, + type AidpKbViewMode, +} from "./AidpKnowledgeList"; import AidpDocumentList from "./AidpDocumentList"; -import AidpCreateKbModal from "./AidpCreateKbModal"; import AidpUpdateKbModal from "./AidpUpdateKbModal"; +/** + * Overview and file view orchestration for AIDP knowledge bases. + * + * The two views replace each other inside this component instead of using a + * route: the overview state (search, page, view mode, column setting) lives + * here, so returning from a file view restores it untouched. Entering a file + * view clears the previous knowledge base documents and stops its upload + * watch, so a late response can never land in another base. + * + * Creation is a separate page (`knowledges/create`); a knowledge base created + * there is opened here through the `kb` query parameter. + */ const AidpKnowledgeConfiguration: React.FC = () => { const { t } = useTranslation(); const { message: appMessage } = App.useApp(); + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const locale = (params?.locale as string) || "zh"; // ---- KB list state ---- const [kbs, setKbs] = useState([]); const [loadingKbs, setLoadingKbs] = useState(false); + const [kbsLoadFailed, setKbsLoadFailed] = useState(false); const [kbTotal, setKbTotal] = useState(0); const [kbHasMore, setKbHasMore] = useState(false); const [kbTotalReliable, setKbTotalReliable] = useState(true); + // ---- Overview presentation state (preserved across the file view) ---- + const [viewMode, setViewMode] = useState("cards"); + const [visibleColumns, setVisibleColumns] = useState([ + ...AIDP_KB_DEFAULT_COLUMNS, + ]); + // ---- 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 [selectedKb, setSelectedKb] = useState( + null + ); + const [activeKbDetail, setActiveKbDetail] = useState( + null + ); const [documents, setDocuments] = useState([]); const [totalDocs, setTotalDocs] = useState(0); const [docHasMore, setDocHasMore] = useState(false); @@ -64,9 +97,10 @@ const AidpKnowledgeConfiguration: React.FC = () => { const [docPage, setDocPage] = useState(1); // ---- Modal state ---- - const [createModalOpen, setCreateModalOpen] = useState(false); const [updateModalOpen, setUpdateModalOpen] = useState(false); - const [editingKb, setEditingKb] = useState(null); + const [editingKb, setEditingKb] = useState( + null + ); // ---- Keyword search state ---- // `kbKeyword` is the raw input value and keeps the text field responsive; @@ -86,11 +120,12 @@ const AidpKnowledgeConfiguration: React.FC = () => { const fetchKbs = useCallback( async (page: number = 1, keyword: string = "") => { setLoadingKbs(true); + setKbsLoadFailed(false); try { const result = await aidpKnowledgeService.listKbs( page, KB_PAGE_SIZE, - keyword, + keyword ); setKbs(result.value); setKbTotal(result.total_count ?? result.value.length); @@ -104,6 +139,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { setKbTotal(0); setKbHasMore(false); setKbTotalReliable(false); + setKbsLoadFailed(true); } finally { setLoadingKbs(false); } @@ -148,7 +184,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { const result = await aidpKnowledgeService.listDocs( kbId, page, - DOC_PAGE_SIZE, + DOC_PAGE_SIZE ); const count = result.total_count ?? result.value.length; setDocuments(result.value); @@ -245,7 +281,24 @@ const AidpKnowledgeConfiguration: React.FC = () => { stopUploadWatch, ]); - // ---- Handle KB selection ---- + // ---- Clear the file view state ---- + // Used on return to the overview and whenever another base is opened: the + // previous documents and its upload watch must never survive into the next + // knowledge base. + const clearFileView = useCallback(() => { + stopUploadWatch(); + setActiveKbId(null); + setSelectedKb(null); + setActiveKbDetail(null); + setDocuments([]); + setTotalDocs(0); + setDocHasMore(false); + setDocTotalReliable(true); + setDocProcessingCount(0); + setDocPage(1); + }, [stopUploadWatch]); + + // ---- Handle KB selection (enter the full-page file view) ---- const handleSelectKb = useCallback( (kb: AidpKnowledgeBaseItem) => { stopUploadWatch(); @@ -255,11 +308,51 @@ const AidpKnowledgeConfiguration: React.FC = () => { setDocHasMore(false); setDocTotalReliable(true); setDocProcessingCount(0); - fetchDocs(kb.kds_id, 1); + void fetchDocs(kb.kds_id, 1); }, [fetchDocs, stopUploadWatch] ); + // ---- Open a knowledge base by id (used by the `kb` query parameter) ---- + // The creation page sends the user here right after a successful create, so + // the files just uploaded are visible without a manual search. + const openKbById = useCallback( + async (kbId: string) => { + try { + const detail = await aidpKnowledgeService.getKb(kbId); + const item = { + ...detail, + kds_id: kbId, + kds_name: detail.kds_name || kbId, + } as AidpKnowledgeBaseItem; + handleSelectKb(item); + } catch (error) { + log.error("Failed to open AIDP knowledge base from query:", error); + } + }, + [handleSelectKb] + ); + + const requestedKbId = searchParams?.get("kb") || null; + const openedRequestedKbRef = useRef(null); + useEffect(() => { + // Only react to a new `kb` value; a manual return to the overview while + // the parameter is still present must not reopen it. + if (!requestedKbId) return; + if (openedRequestedKbRef.current === requestedKbId) return; + openedRequestedKbRef.current = requestedKbId; + void openKbById(requestedKbId); + }, [openKbById, requestedKbId]); + + // ---- Return to the overview ---- + const handleBackToList = useCallback(() => { + clearFileView(); + if (requestedKbId && typeof window !== "undefined") { + // Drop the parameter so a refresh does not reopen the file view. + window.history.replaceState(null, "", `/${locale}/knowledges`); + } + }, [clearFileView, locale, requestedKbId]); + // ---- Handle KB deletion ---- const handleDeleteKb = useCallback( (kb: AidpKnowledgeBaseItem) => { @@ -277,22 +370,13 @@ const AidpKnowledgeConfiguration: React.FC = () => { await aidpKnowledgeService.deleteKb(kb.kds_id); appMessage.success(t("aidpKnowledge.deleteKbSuccess")); - // If the deleted KB was active, clear selection + // If the deleted KB was open, leave the file view. if (activeKbId === kb.kds_id) { - stopUploadWatch(); - setActiveKbId(null); - setSelectedKb(null); - setActiveKbDetail(null); - setDocuments([]); - setTotalDocs(0); - setDocHasMore(false); - setDocTotalReliable(true); - setDocProcessingCount(0); - setDocPage(1); + clearFileView(); } // Refresh list, keeping the active search filter applied - fetchKbs(kbPage, debouncedKbKeyword); + void fetchKbs(kbPage, debouncedKbKeyword); } catch (error) { appMessage.error(t("aidpKnowledge.deleteKbFailed")); } @@ -306,7 +390,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { fetchKbs, kbPage, debouncedKbKeyword, - stopUploadWatch, + clearFileView, ] ); @@ -333,40 +417,6 @@ const AidpKnowledgeConfiguration: React.FC = () => { [activeKbId] ); - // ---- 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; @@ -414,112 +464,105 @@ const AidpKnowledgeConfiguration: React.FC = () => { refreshActiveKbDetail(); }, [activeKbId, docPage, fetchDocs, refreshActiveKbDetail]); + // ---- Navigate to the dedicated creation page ---- + const handleCreateNew = useCallback(() => { + router.push(`/${locale}/knowledges/create`); + }, [locale, router]); + // 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; + const containerStyle = { + maxWidth: SETUP_PAGE_CONTAINER.MAX_WIDTH, + padding: `0 ${SETUP_PAGE_CONTAINER.HORIZONTAL_PADDING}`, + }; + + // ---- File view: one knowledge base, full page ---- + if (activeKbId && activeKbItem) { + const isUnavailable = + activeKbItem.resource_status === "UNAVAILABLE" || + activeKbItem.resource_status === "ORPHANED"; + return ( +
+
+
+ +

+ {activeKbItem.kds_name} +

+ {isUnavailable && ( + {t("aidpKnowledge.kbUnavailable")} + )} + {activeKbItem.permission === "READ_ONLY" && !isUnavailable && ( + {t("aidpKnowledge.kbReadOnly")} + )} +
+ { + // 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} + /> +
+
+ ); + } + + // ---- Overview: guide plus the card or table list ---- 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")} -

-
-
-
- )} - -
+ fetchKbs(page, debouncedKbKeyword)} + onViewModeChange={setViewMode} + onVisibleColumnsChange={setVisibleColumns} + onSelect={handleSelectKb} + onRefresh={() => fetchKbs(kbPage, debouncedKbKeyword)} + onCreateNew={handleCreateNew} + onEdit={handleEditKb} + onDelete={handleDeleteKb} + onRetry={() => fetchKbs(kbPage, debouncedKbKeyword)} + />
- {/* Create KB Modal */} - setCreateModalOpen(false)} - onSuccess={handleCreateKbSuccess} - /> - {/* Update KB Modal */} { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(true); + + return ( +
+ + + {expanded && ( +
+

+ {t("aidpKnowledge.guideIntro")} +

+ +
+ )} +
+ ); +}; + +export default AidpKnowledgeGuide; diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index 1b54706264..eb83f1ebcd 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -1,23 +1,88 @@ import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Input, Pagination, Tag, Tooltip } from "antd"; import { + Button, + Checkbox, + Input, + Pagination, + Popover, + Segmented, + Table, + Tag, + Tooltip, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + AppstoreOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, + SettingOutlined, + TableOutlined, } from "@ant-design/icons"; import { SquarePen, Trash2 } from "lucide-react"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; +import { + AIDP_UNKNOWN_VALUE, + formatAidpCapacity, + formatAidpCreator, + formatAidpDateTime, + formatAidpDocumentCount, + normalizeAidpKbType, +} from "@/lib/aidpKnowledgeDisplay"; import { useGroupList } from "@/hooks/group/useGroupList"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { Can } from "@/components/permission/Can"; +import AidpKnowledgeGuide from "./AidpKnowledgeGuide"; + +/** Overview presentation modes. Cards are the default. */ +export type AidpKbViewMode = "cards" | "table"; + +/** Every column the overview can render. */ +export type AidpKbColumnKey = + | "name" + | "type" + | "description" + | "documents" + | "capacity" + | "creator" + | "created_at" + | "actions"; + +/** Columns the user cannot hide: the entry point and the row actions. */ +export const AIDP_KB_REQUIRED_COLUMNS: AidpKbColumnKey[] = ["name", "actions"]; + +/** Default visible column set, used on first render and by "restore". */ +export const AIDP_KB_DEFAULT_COLUMNS: AidpKbColumnKey[] = [ + "name", + "type", + "description", + "documents", + "capacity", + "creator", + "created_at", + "actions", +]; + +/** Column labels shared by the table header and the visibility setting. */ +const COLUMN_LABEL_KEYS: Record = { + name: "aidpKnowledge.columnName", + type: "aidpKnowledge.columnType", + description: "aidpKnowledge.columnDescription", + documents: "aidpKnowledge.columnDocuments", + capacity: "aidpKnowledge.columnCapacity", + creator: "aidpKnowledge.columnCreator", + created_at: "aidpKnowledge.columnCreatedAt", + actions: "aidpKnowledge.columnActions", +}; interface AidpKnowledgeListProps { kbs: AidpKnowledgeBaseItem[]; - activeKbId: string | null; isLoading: boolean; + /** True when the last list request failed; drives the failure state. */ + loadFailed: boolean; total: number; /** True when `total` came from AIDP Count API (reliable). When false we * show a simple prev/next pagination without "共 N 条". */ @@ -28,32 +93,42 @@ interface AidpKnowledgeListProps { /** Raw search box value. The parent debounces it before querying, so this is * intentionally the un-debounced text the user is currently typing. */ keyword: string; + viewMode: AidpKbViewMode; + visibleColumns: AidpKbColumnKey[]; onKeywordChange: (value: string) => void; onPageChange: (page: number) => void; + onViewModeChange: (mode: AidpKbViewMode) => void; + onVisibleColumnsChange: (columns: AidpKbColumnKey[]) => void; onSelect: (kb: AidpKnowledgeBaseItem) => void; onRefresh: () => void; onCreateNew: () => void; onEdit: (kb: AidpKnowledgeBaseItem) => void; onDelete: (kb: AidpKnowledgeBaseItem) => void; + onRetry: () => void; } const AidpKnowledgeList: React.FC = ({ kbs, - activeKbId, isLoading, + loadFailed, total, totalReliable, hasMore, currentPage, pageSize, keyword, + viewMode, + visibleColumns, onKeywordChange, onPageChange, + onViewModeChange, + onVisibleColumnsChange, onSelect, onRefresh, onCreateNew, onEdit, onDelete, + onRetry, }) => { const { t } = useTranslation(); @@ -78,7 +153,9 @@ const AidpKnowledgeList: React.FC = ({ if (!Array.isArray(groupIds) || groupIds.length === 0) return []; return groupIds .map((id) => groupById.get(id)) - .filter((name): name is string => typeof name === "string" && name.length > 0); + .filter( + (name): name is string => typeof name === "string" && name.length > 0 + ); }; // Sort alphabetically by name @@ -88,204 +165,401 @@ const AidpKnowledgeList: React.FC = ({ ); }, [kbs]); - return ( -
- {/* Header */} -
-
-

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

-
-
+ ); + + const renderCards = () => ( +
+ {displayedKbs.map((kb) => ( +
onSelect(kb)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(kb); + } + }} + className="border border-gray-200 rounded-md p-3 hover:border-blue-400 hover:shadow-sm transition-all cursor-pointer flex flex-col" + > +
+

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

+
+ {renderKbTypeTag(kb)} + {renderStatusTags(kb)} +
+

+ {kb.description?.trim() ? kb.description : AIDP_UNKNOWN_VALUE} +

+
+ + {t("aidpKnowledge.cardDocuments", { + count: formatAidpDocumentCount( + kb.document_count, + kb.document_count_reliable + ), + })} + + + {t("aidpKnowledge.cardCapacity", { + value: formatAidpCapacity(kb.current_cap), + })} + + {renderGroupTags(kb)} +
+
+ {formatAidpCreator(kb.user_name)} + {formatAidpDateTime(kb.created_at)}
- {/* Search box. `keyword` is the raw input value and the parent debounces - it, so typing stays responsive and only the settled text triggers a - request. Clearing the field restores the unfiltered list. */} - } - value={keyword} - onChange={(e) => onKeywordChange(e.target.value)} - size="small" - /> -
+ ))} +
+ ); - {/* 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; - - 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 && ( - -
-
-
- ); - })} -
- ) : ( -
- {keyword.trim() - ? t("aidpKnowledge.searchEmpty") - : t("aidpKnowledge.listEmpty")} + const renderTable = () => { + const allColumns: Record< + AidpKbColumnKey, + ColumnsType[number] + > = { + name: { + title: t("aidpKnowledge.columnName"), + dataIndex: "kds_name", + key: "name", + render: (_value, kb) => ( + + {kb.kds_name} + + ), + }, + type: { + title: t("aidpKnowledge.columnType"), + key: "type", + width: 110, + render: (_value, kb) => renderKbTypeTag(kb), + }, + description: { + title: t("aidpKnowledge.columnDescription"), + dataIndex: "description", + key: "description", + ellipsis: true, + render: (value: string | undefined) => + value?.trim() ? value : AIDP_UNKNOWN_VALUE, + }, + documents: { + title: t("aidpKnowledge.columnDocuments"), + key: "documents", + width: 90, + render: (_value, kb) => + formatAidpDocumentCount( + kb.document_count, + kb.document_count_reliable + ), + }, + capacity: { + title: t("aidpKnowledge.columnCapacity"), + key: "capacity", + width: 110, + render: (_value, kb) => formatAidpCapacity(kb.current_cap), + }, + creator: { + title: t("aidpKnowledge.columnCreator"), + key: "creator", + width: 130, + render: (_value, kb) => formatAidpCreator(kb.user_name), + }, + created_at: { + title: t("aidpKnowledge.columnCreatedAt"), + key: "created_at", + width: 130, + render: (_value, kb) => formatAidpDateTime(kb.created_at), + }, + actions: { + title: t("aidpKnowledge.columnActions"), + key: "actions", + width: 90, + align: "right", + render: (_value, kb) => renderRowActions(kb), + }, + }; + + // The required columns always render; the rest follow the column setting. + const activeKeys = AIDP_KB_DEFAULT_COLUMNS.filter( + (key) => + AIDP_KB_REQUIRED_COLUMNS.includes(key) || visibleColumns.includes(key) + ); + + return ( + + rowKey="kds_id" + size="small" + columns={activeKeys.map((key) => allColumns[key])} + dataSource={displayedKbs} + pagination={false} + onRow={(kb) => ({ + onClick: () => onSelect(kb), + style: { cursor: "pointer" }, + })} + /> + ); + }; + + const renderEmptyState = () => ( +
+ {keyword.trim() + ? t("aidpKnowledge.searchEmpty") + : t("aidpKnowledge.listEmpty")} +
+ ); + + const renderFailedState = () => ( +
+

+ {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 ( +
+ {/* Guide sits above the overview card and owns its own collapse state. */} + + +
+ {/* Header */} +
+
+

+ {t("aidpKnowledge.kbListTitle")} +

+
+ + +
- )} -
- {/* 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 - } +
+ } + value={keyword} + onChange={(e) => onKeywordChange(e.target.value)} + size="small" + /> + onViewModeChange(value as AidpKbViewMode)} + options={[ + { + value: "cards", + label: t("aidpKnowledge.viewCards"), + icon: , + }, + { + value: "table", + label: t("aidpKnowledge.viewTable"), + icon: , + }, + ]} /> + {/* Column visibility only affects the table view. */} + {viewMode === "table" && ( + + +
- ); - })()} +
+ + {/* Body */} +
{renderBody()}
+ + {/* 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: value }) + : undefined + } + size="small" + /> +
+ ); + })()} +
); }; diff --git a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx index b9597a39f8..6256e1b345 100644 --- a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx +++ b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx @@ -3,8 +3,17 @@ 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 { + Collapse, + Modal, + Form, + Input, + Space, + Switch, + Tooltip, + message, +} from "antd"; +import { QuestionCircleOutlined, SettingOutlined } from "@ant-design/icons"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; @@ -39,6 +48,11 @@ const AidpUpdateKbModal: React.FC = ({ const [advancedOpen, setAdvancedOpen] = React.useState(false); const ingroupPermission = Form.useWatch("ingroup_permission", form); + // Whether the response actually reported the safety guard state. A missing + // value is unknown, never a confirmed disabled state, so the hint below + // tells the user that saving will state the value explicitly. + const guardKnown = + typeof knowledgeBase?.sensitive_intercept_enalbe === "number"; // Pre-fill form when opening. ``group_ids`` may be null/undefined on rows // that predate the column — normalize to an empty array so the Select @@ -58,6 +72,10 @@ const AidpUpdateKbModal: React.FC = ({ : Array.isArray(knowledgeBase.group_ids) ? knowledgeBase.group_ids : [], + // An absent value stays unknown: the switch defaults to off and the + // hint below says the remote state was never reported. + sensitive_intercept_enalbe: + knowledgeBase.sensitive_intercept_enalbe === 1, }); }, [open, knowledgeBase, form, isUser]); @@ -105,7 +123,20 @@ const AidpUpdateKbModal: React.FC = ({ (id, idx) => id !== [...originalGroupIds].sort((a, b) => a - b)[idx] ); - if (!nameChanged && !descriptionChanged && !permissionChanged) { + // The safety guard is synchronized whenever the user changed it, and + // also when the remote value was never reported: an explicit 0 or 1 must + // reach the upstream request instead of being silently omitted. + const originalGuard = + knowledgeBase.sensitive_intercept_enalbe === 1 ? 1 : 0; + const newGuard = values.sensitive_intercept_enalbe ? 1 : 0; + const guardChanged = !guardKnown || newGuard !== originalGuard; + + if ( + !nameChanged && + !descriptionChanged && + !permissionChanged && + !guardChanged + ) { form.resetFields(); onSuccess(knowledgeBase); return; @@ -119,6 +150,7 @@ const AidpUpdateKbModal: React.FC = ({ group_ids: normalizedNewGroupIds, ...(nameChanged ? { name } : {}), ...(descriptionChanged ? { description } : {}), + ...(guardChanged ? { sensitive_intercept_enalbe: newGuard } : {}), } ); const metadataFailed = result.metadata_status === "failed"; @@ -141,6 +173,10 @@ const AidpUpdateKbModal: React.FC = ({ : knowledgeBase.description, ingroup_permission: newPermission, group_ids: normalizedNewGroupIds, + sensitive_intercept_enalbe: + !metadataFailed && guardChanged + ? newGuard + : knowledgeBase.sensitive_intercept_enalbe, resource_status: result.metadata_status === "updated" ? "ACTIVE" @@ -196,6 +232,23 @@ const AidpUpdateKbModal: React.FC = ({ style={{ padding: "20px 24px 8px" }} > + + {t("aidpKnowledge.createSafetyGuard")} + + + + + } + extra={ + guardKnown ? undefined : t("aidpKnowledge.safetyGuardUnknown") + } + > + + {canConfigureGroupPermissions ? ( ": { temperature?: number|null, top_p?: number|null, extra_params?: Record|null } } * NULL/undefined means inherit the model's default values. */ - model_params_override?: Record< - string, - ModelParamsOverrideEntry - > | null; + model_params_override?: Record | null; is_main_agent?: boolean; provide_run_summary: boolean; allow_chat_metadata?: boolean; @@ -293,6 +290,27 @@ export interface AidpKnowledgeBaseItem { embedding_model?: string; /** Whether this AIDP knowledge base supports multimodal content. */ is_multimodal?: boolean; + /** + * AIDP personal/enterprise flag. AIDP returns it as boolean, 0/1 or a + * string, so it stays unnormalized here and is read through the AIDP + * display helpers rather than compared directly. + */ + is_private?: boolean | number | string | null; + /** Personal knowledge base capacity in GB as reported by AIDP. */ + current_cap?: number | null; + /** Creator display name from AIDP; absent when the response omits it. */ + user_name?: string | null; + /** + * False when the backend could not confirm a real document count, so the + * overview must not present the value as a trustworthy statistic. + */ + document_count_reliable?: boolean; + /** + * Safety guard state: 1 enabled, 0 disabled, null or absent means the + * response did not report it (never treat that as a confirmed disabled + * state). + */ + sensitive_intercept_enalbe?: number | null; } export interface AidpKnowledgeBaseListResponse { diff --git a/test/cases/d1/aidp-knowledge-pages-refactor.yaml b/test/cases/d1/aidp-knowledge-pages-refactor.yaml new file mode 100644 index 0000000000..ddb33fcf11 --- /dev/null +++ b/test/cases/d1/aidp-knowledge-pages-refactor.yaml @@ -0,0 +1,502 @@ +schema_version: '1.0' +stage: D1 +module: AIDP-KNOWLEDGE +cases: + - case_id: AKPR-D1-001 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-001] + title: Overview guide expands, collapses and keeps the fixed three steps + type: FE-COMP + priority: P0 + status: active + automation: automated + objective: Verify the AIDP-only guide renders the knowledge base explanation with the fixed steps and toggles without disturbing page state. + preconditions: + - Render the AIDP overview component with an empty knowledge base list. + test_data: {profile: mock, locale: zh-CN} + steps: + - order: 1 + action: Mount the overview page. + expected: The guide is expanded and lists create knowledge base, upload files and configure the knowledge base in an agent. + - order: 2 + action: Collapse the guide. + expected: The guide body is hidden while the reopen entry stays visible and operable. + - order: 3 + action: Reopen the guide. + expected: The explanation and the three steps are visible again with unchanged search and view state. + expected_results: + - The explanation and the three documented steps are visible when expanded. + - Collapse hides only the guide body. + - Reopening restores the same content order. + forbidden_side_effects: + - No backend request is issued by expanding or collapsing the guide. + - No search, page or view mode change. + unit_boundary: AIDP knowledge guide expansion state and fixed step labels + inputs: + - initial expanded state + - collapse click + - reopen click + assertions: + - Guide content is visible on first entry. + - The reopen entry remains visible after collapse. + - The three step labels match the fixed order. + - Guide toggling issues no request. + - case_id: AKPR-D1-002 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-002, AKPR-RULE-004] + title: Cards are the default view and switching or hiding columns keeps the result set + type: FE-COMP + priority: P0 + status: active + automation: automated + objective: Verify default card rendering, table switching and column visibility control over one shared paginated result set. + preconditions: + - Render the overview with a fixed page of knowledge bases and a recorded search and page number. + test_data: {profile: mock, page_size: 10, total: 23} + steps: + - order: 1 + action: Mount the overview with existing results. + expected: Knowledge bases render as cards. + - order: 2 + action: Switch to the table view. + expected: The same records render as table rows with the current keyword and page preserved. + - order: 3 + action: Hide one optional column and then restore defaults. + expected: The column disappears and returns while the name and action columns stay present throughout. + expected_results: + - The card view is the default. + - Switching views keeps keyword, page and totals identical. + - Hiding and restoring columns changes only local visibility. + forbidden_side_effects: + - No mutating endpoint is called by view or column changes. + - No record is dropped or duplicated by switching views. + - Required columns cannot be hidden. + unit_boundary: Overview view mode and column visibility state + inputs: + - default view mode + - card to table switch + - hidden column set + - restore defaults action + assertions: + - Initial view mode is cards. + - Table switch preserves keyword, page and total. + - Hidden optional column is absent and restorable. + - Name and action columns stay visible. + - case_id: AKPR-D1-003 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-003] + title: Independent name search drives the keyword query and resets the page + type: FE-COMP + priority: P0 + status: active + automation: automated + objective: Verify the single search box reuses the existing keyword query, resets to page one and keeps table headers free of search controls. + preconditions: + - Render the overview with a recorded non-first page and a stubbed list service. + test_data: {profile: mock, keyword: finance, page: 3} + steps: + - order: 1 + action: Type a keyword into the page search box and wait for the debounce. + expected: The list service is called with the keyword and page one for both views. + - order: 2 + action: Clear the search box. + expected: The unfiltered permission-scoped set is requested again. + - order: 3 + action: Inspect the table header row. + expected: No column exposes a search or filter control. + expected_results: + - The keyword query is reused with a page reset. + - Clearing restores the unfiltered set. + - Column headers contain no search entry. + forbidden_side_effects: + - No request per keystroke outside the debounce. + - No column filter parameter is sent. + unit_boundary: Overview search debounce and page reset with the existing keyword parameter + inputs: + - keyword text + - debounce interval elapse + - clear action + - table header render + assertions: + - listKbs receives keyword and page one after debounce. + - Clearing the box requests the unfiltered set. + - No header search control exists. + - case_id: AKPR-D1-004 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-005, AKPR-RULE-009] + title: File view targets one knowledge base and restores the previous list state on return + type: FE-COMP + priority: P0 + status: active + automation: automated + objective: Verify the full-page file view scoping, the preserved list state on return and the cleanup of the previous base document state. + preconditions: + - Render the overview with two knowledge bases and a recorded search, page, view mode and column setting. + test_data: {profile: mock, selected_base: kb-alpha, other_base: kb-beta} + steps: + - order: 1 + action: Select a knowledge base. + expected: Only that base files render and the overview is replaced by the full-page file view. + - order: 2 + action: Return to the overview. + expected: The previous keyword, page, view mode and column setting are intact. + - order: 3 + action: Resolve a delayed document response from the first base after selecting the second. + expected: The delayed payload is discarded and the second base files remain. + expected_results: + - Files belong to the selected base only. + - Returning restores the previous list state. + - A stale response never overwrites the current base. + forbidden_side_effects: + - No cross-base document leakage. + - No polling left running after leaving the file view. + - No list state reset on return. + unit_boundary: List and file view switching, document state reset and polling cleanup + inputs: + - selected base id + - return action + - delayed response for a previous base + assertions: + - File list requests carry the selected base id. + - Return renders the preserved list state. + - A late response for the previous base is ignored. + - Polling state is cleared when the view is left. + - case_id: AKPR-D1-005 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-006] + title: AIDP list metadata normalizes real values and marks unknown ones as absent + type: BE-UT + priority: P0 + status: active + automation: automated + objective: Verify the list response normalization of private and public type, capacity and missing values without fabricating data. + preconditions: + - Provide raw AIDP list rows with mixed boolean, numeric, string and missing metadata values. + test_data: {profile: mock, cases: ['is_private true', 'is_private 0', 'is_private string true', 'missing is_private', 'current_cap 2.5', 'missing current_cap', 'missing document count']} + steps: + - order: 1 + action: Normalize rows with boolean, numeric and string forms of is_private. + expected: A stable personal or enterprise type is produced for every form. + - order: 2 + action: Normalize rows without a type, capacity or count value. + expected: Those fields are reported as unknown instead of a fabricated default. + - order: 3 + action: Normalize a valid personal capacity. + expected: The documented unit value is preserved unchanged. + expected_results: + - Real values survive normalization. + - Unknown values are distinguishable from a real zero. + - Capacity is not estimated from file sizes. + forbidden_side_effects: + - No Nexent group permission is used to infer the base type. + - No synthetic zero count or capacity is produced. + - No extra upstream request is issued per row. + unit_boundary: AIDP list metadata normalization helpers + inputs: + - is_private variants + - current_cap values + - document count presence + assertions: + - Boolean, integer and string is_private forms normalize consistently. + - A missing count is distinguishable from zero. + - A missing capacity is reported as unknown. + - A valid capacity keeps its unit. + - case_id: AKPR-D1-006 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-007] + title: Read-only and unavailable knowledge bases expose no modification entry + type: FE-COMP + priority: P0 + status: active + automation: automated + objective: Verify that permission and lifecycle restrictions remove every executable modification entry in cards, table rows and the file view. + preconditions: + - Render the overview with one editable, one read-only and one unavailable knowledge base. + test_data: {profile: mock, permissions: [EDIT, READ_ONLY], resource_status: [available, unavailable]} + steps: + - order: 1 + action: Render the card menu and table actions for each permission. + expected: Only the editable base offers executable edit and delete entries. + - order: 2 + action: Open the file view of a read-only base. + expected: Upload and delete entries are absent or disabled while browsing stays possible. + - order: 3 + action: Render an unavailable base. + expected: No executable modification entry is offered and its state stays visible. + expected_results: + - Modification entries follow the effective permission. + - Unavailable bases stay visible without modification entries. + - No request is emitted from a disabled entry. + forbidden_side_effects: + - No modification request for read-only or unavailable bases. + - No hidden menu item remains clickable. + - No downgrade of the existing visibility rules. + unit_boundary: Card menu and table action rendering under permission and lifecycle states + inputs: + - effective permission + - resource_status + - menu open state + assertions: + - Read-only rows render no executable modification entry. + - Unavailable rows keep their lifecycle indicator. + - No request leaves a disabled entry. + - case_id: AKPR-D1-007 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-002] + title: First-load empty, filtered empty, loading and failure states stay distinct + type: FE-COMP + priority: P1 + status: active + automation: automated + objective: Verify the four overview states render distinct messages and remain localized. + preconditions: + - Render the overview with a stubbed service returning an empty set, an empty filtered set, a pending promise and an error. + test_data: {profile: mock, states: ['first empty', 'filtered empty', loading, failed], locales: [zh, en]} + steps: + - order: 1 + action: Resolve an empty list without a keyword. + expected: A first-use empty state renders instead of a search-empty state. + - order: 2 + action: Resolve an empty list with an active keyword. + expected: A search-empty state renders with the keyword context. + - order: 3 + action: Keep the request pending and then reject it. + expected: Loading and failure states render distinctly and the failure state offers a retry path. + expected_results: + - All four states are distinguishable. + - Both locales resolve every state string. + forbidden_side_effects: + - No fabricated placeholder rows. + - No silent failure rendering as an empty state. + unit_boundary: Overview empty, loading and failure state rendering + inputs: + - empty payload + - empty filtered payload + - pending request + - rejected request + - locale + assertions: + - First empty and filtered empty differ. + - Loading state precedes data. + - Failure state is distinct and localized. + - No placeholder rows are invented. + - case_id: AKPR-D1-008 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-010] + title: Two-step creation validates locally, preserves state and creates exactly once + type: FE-COMP + priority: P0 + status: active + automation: automated + objective: Verify step navigation without creation, state preservation across steps and single creation before upload. + preconditions: + - Render the AIDP creation page with stubbed create and upload services and a selected local file. + test_data: {profile: mock, files: 2, failing_uploads: 1} + steps: + - order: 1 + action: Fill step one and press next. + expected: Validation runs with no create request issued and step two renders. + - order: 2 + action: Return to step one. + expected: Form values, conditional sections and the selected files are preserved. + - order: 3 + action: Submit with one failing upload. + expected: Creation happens once and the created base and the failed upload are reported separately. + - order: 4 + action: Submit again after the failed upload. + expected: No second knowledge base is created and the retry targets the failed file only. + expected_results: + - Step one never creates a resource. + - State survives backward navigation. + - Creation happens exactly once before uploads. + forbidden_side_effects: + - No create request from the next action. + - No duplicate base after retry or double click. + - No upload before a successful create. + unit_boundary: Creation page step state, submit guard and create-then-upload sequencing + inputs: + - step one form values + - selected files + - upload failure + - repeated submit + assertions: + - Next issues no create request. + - Backward navigation keeps values and files. + - Exactly one create request precedes uploads. + - A failed upload does not trigger a second create. + - case_id: AKPR-D1-009 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-011] + title: Creation defaults and chunking mapping follow the confirmed values + type: BE-UT + priority: P0 + status: active + automation: automated + objective: Verify preserved creation defaults, the chunk mode enumeration and the overlap-to-token conversion with its bounds. + preconditions: + - Prepare create payloads with missing defaults, both chunk modes and boundary overlap percentages. + test_data: {profile: mock, chunk_tokens: [1024, 512], percents: [12.5, 50], modes: [smart, legal]} + steps: + - order: 1 + action: Submit a payload without explicit chunking or retrieval values. + expected: The existing defaults apply unchanged and the new graph parameters use their documented defaults. + - order: 2 + action: Submit each chunk mode. + expected: The chunk mode serializes as 0 for smart splitting and 1 for legal clauses. + - order: 3 + action: Submit overlap percentages including the boundaries. + expected: The overlap becomes the floor conversion in integer tokens and any value above half the chunk size is rejected. + expected_results: + - Defaults remain 1024 tokens, 128 overlap, 0.0 similarity and Top K 10. + - Chunk modes serialize 0 and 1. + - Overlap conversion is exact and bounded. + forbidden_side_effects: + - No silent clamping of an invalid overlap. + - No renamed legacy field in the outgoing payload. + - No change to the ES knowledge base contract. + unit_boundary: Create default application, chunk mode mapping and overlap conversion + inputs: + - payload without defaults + - chunk mode selection + - chunk tokens and overlap percent + assertions: + - Defaults match the existing Nexent values. + - Overlap equals the floor conversion. + - Overlap above 50 percent of the chunk size is rejected. + - Legacy field names stay unchanged. + - case_id: AKPR-D1-010 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-013] + title: Graph configuration serializes documented keys with an inverse thinking switch + type: BE-UT + priority: P0 + status: active + automation: automated + objective: Verify graph configuration serialization, the inverse thinking mapping, the independent Top K values and the prompt byte limit. + preconditions: + - Prepare graph payloads with the switch on and off, both languages and prompt lengths around the byte limit. + test_data: {profile: mock, domains: [general, medical], prompt_bytes: [2048, 2049], languages: [chinese, english]} + steps: + - order: 1 + action: Submit a graph configuration with the thinking switch enabled. + expected: The thinking mode field is false and the graph keys use their documented names. + - order: 2 + action: Submit a knowledge base Top K together with a graph Top K. + expected: The two values are serialized independently without overwriting each other. + - order: 3 + action: Submit prompts at and beyond the byte boundary together with a disabled graph. + expected: The limit passes at the boundary, longer input is rejected and a disabled graph omits its configuration entirely. + expected_results: + - The thinking switch maps inversely onto the thinking mode field. + - Both Top K values survive independently. + - The prompt limit is enforced in UTF-8 bytes. + forbidden_side_effects: + - No hidden previous graph configuration is submitted when the graph is off. + - No graph Top K overwrites the knowledge base Top K. + - No invented JSON key names. + unit_boundary: Graph configuration validation and serialization + inputs: + - graph switch + - domain + - graph top k + - hop count + - thinking switch + - prompt language + - prompt text + assertions: + - The thinking mode field is false when thinking is enabled. + - Graph and knowledge base Top K stay independent. + - Prompt length is measured in UTF-8 bytes. + - Disabled graph omits its configuration. + - case_id: AKPR-D1-011 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-012] + title: Graph and multimodal models are queried from separate categories with independent caches + type: FE-COMP + priority: P0 + status: active + automation: automated + objective: Verify the model category split, the independent query caches and the omission of hidden model values. + preconditions: + - Render the creation form with stubbed model queries per category and both capability switches toggled. + test_data: {profile: mock, categories: [llm, vlm, embedding]} + steps: + - order: 1 + action: Enable the graph section. + expected: The graph model query uses the llm category and fills the graph model field. + - order: 2 + action: Enable multimodal captioning. + expected: The multimodal model query uses the vlm category and fills the multimodal field independently. + - order: 3 + action: Disable each capability after a selection. + expected: The hidden model value is absent from the submitted payload. + expected_results: + - Each model field is populated from its own category. + - Caches and selections never mix. + - Disabled capabilities submit no model value. + forbidden_side_effects: + - No llm result used for the multimodal field. + - No hidden previous model resubmitted. + - No unverified model name silently defaulted. + unit_boundary: Creation form model queries, caches and hidden value handling + inputs: + - graph switch + - multimodal switch + - selected models + - query cache keys + assertions: + - Graph query uses llm and multimodal uses vlm. + - Caches are keyed independently. + - Disabled capability clears its submitted model. + - case_id: AKPR-D1-012 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-014] + title: Safety guard serializes integer 1 and 0 and never omits an explicit disabled value + type: BE-UT + priority: P0 + status: active + automation: automated + objective: Verify create and update serialization of the safety guard including the explicit zero and the absent-value distinction. + preconditions: + - Prepare create and update payloads with the guard enabled, explicitly disabled and absent. + test_data: {profile: mock, values: ['enabled 1', 'explicit 0', absent]} + steps: + - order: 1 + action: Build a create payload with the guard enabled and disabled. + expected: The field is present as integer 1 or explicit 0. + - order: 2 + action: Build an update payload that only changes the guard to off. + expected: The field survives the update whitelist and is forwarded as 0. + - order: 3 + action: Map a query response that lacks the field. + expected: The state is reported as unknown rather than confirmed off. + expected_results: + - Integer 1 and 0 are both serialized. + - An explicit 0 is never dropped by filtering. + - Missing data is not reported as a confirmed disabled state. + forbidden_side_effects: + - No boolean or string encoding of the field. + - No field nesting inside graph configuration. + - No Nexent-side content interception added. + unit_boundary: Safety guard request serialization and query mapping + inputs: + - safety switch state + - create payload + - update payload + - query response without the field + assertions: + - Enabled serializes 1 and disabled serializes 0. + - The update whitelist forwards the field. + - A missing field maps to unknown. diff --git a/test/cases/d2/aidp-knowledge-pages-refactor.yaml b/test/cases/d2/aidp-knowledge-pages-refactor.yaml new file mode 100644 index 0000000000..dd398a8ee1 --- /dev/null +++ b/test/cases/d2/aidp-knowledge-pages-refactor.yaml @@ -0,0 +1,176 @@ +schema_version: '1.0' +stage: D2 +module: AIDP-KNOWLEDGE +cases: + - case_id: AKPR-D2-001 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-002, AKPR-RULE-006] + title: Knowledge base list keeps its contract and adds optional display fields + type: CONTRACT + priority: P0 + status: active + automation: automated + objective: Verify the paginated AIDP list proxy keeps its existing request and response contract while exposing the optional display metadata. + preconditions: + - An authenticated request reaches the AIDP management proxy with a controllable upstream fixture. + test_data: {profile: mock, page: 1, page_size: 10, keyword: alpha} + steps: + - order: 1 + action: Request the first page with a keyword. + expected: The proxy forwards page, page_size and keyword and applies the existing permission intersection. + - order: 2 + action: Inspect the response payload. + expected: Existing fields keep their names and types and the optional display fields appear only when the upstream returns them. + - order: 3 + action: Request a later page. + expected: Ordering and totals remain consistent with the unfiltered permission-scoped set. + expected_results: + - Existing request parameters and response fields are unchanged. + - Optional display metadata is additive. + - Pagination order and totals stay stable. + forbidden_side_effects: + - No column filter parameter is introduced. + - No fabricated default replaces a missing metadata value. + - No ES knowledge base endpoint is touched. + contract: + protocol: HTTP + method: GET + endpoint: /aidp-mgmt/knowledge-bases + request_expectations: + - page, page_size and keyword behave as before. + - The permission-scoped access set is resolved before pagination. + - No new required parameter is added. + response_expectations: + - Existing fields keep their current names and types. + - Optional display fields are additive and omit a value the upstream did not return. + - Page totals equal the permission-scoped count. + - case_id: AKPR-D2-002 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-010, AKPR-RULE-011, AKPR-RULE-012, AKPR-RULE-013] + title: Creation request carries the new configuration and keeps legacy field names + type: API-IT + priority: P0 + status: active + automation: automated + objective: Verify the create proxy forwards chunking, retrieval, graph, model and safety fields while preserving legacy field names and existing defaults. + preconditions: + - An authenticated create request reaches the AIDP management proxy with a recorded upstream payload. + test_data: {profile: mock, chunk_mode: [0, 1], graph_enabled: [true, false], multimodal: [true, false]} + steps: + - order: 1 + action: Submit the minimal creation payload. + expected: Existing defaults still apply and the legacy field names remain unchanged. + - order: 2 + action: Submit a payload with chunking, retrieval and graph configuration. + expected: The chunk mode, chunk token fields, similarity, knowledge base Top K and a serialized graph configuration reach the upstream payload. + - order: 3 + action: Submit with the graph and multimodal capabilities disabled. + expected: The graph configuration and the hidden model fields are absent from the upstream payload. + expected_results: + - New configuration fields reach the upstream request. + - Legacy field names and defaults are preserved. + - Disabled capabilities submit no hidden configuration. + forbidden_side_effects: + - No ES knowledge base request shape change. + - No invented graph JSON keys. + - No model value submitted for a disabled capability. + contract: + protocol: HTTP + method: POST + endpoint: /aidp-mgmt/knowledge-bases + request_expectations: + - Chunk mode, chunk token and overlap values follow the documented types and bounds. + - Graph configuration is serialized as the documented string payload. + - Legacy field names remain in place for existing callers. + response_expectations: + - The created knowledge base identifier is returned as before. + - Permission and group handling keeps its existing behavior. + - A failed upstream create surfaces a stable error without partial local state. + - case_id: AKPR-D2-003 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-014] + title: Update request forwards an explicit disabled safety guard and reports partial failure + type: CONTRACT + priority: P0 + status: active + automation: automated + objective: Verify the safety guard survives the update whitelist, an explicit zero is forwarded and permission success stays distinguishable from metadata failure. + preconditions: + - An authenticated update reaches the AIDP management proxy for a base the caller may edit. + test_data: {profile: mock, guard: ['enabled to disabled', unchanged, 'disabled to enabled'], metadata_failure: true} + steps: + - order: 1 + action: Disable the safety guard and save. + expected: The upstream metadata payload contains the field as integer 0. + - order: 2 + action: Save again without changing the guard. + expected: The field is not resent when nothing changed. + - order: 3 + action: Make the upstream metadata update fail after the permission write. + expected: The response distinguishes saved permissions from a failed metadata update and the readback keeps the previous guard value. + expected_results: + - An explicit 0 is forwarded and an unchanged value is omitted. + - Permission and metadata outcomes are separately reported. + - A metadata failure is never presented as a saved guard value. + forbidden_side_effects: + - No silent drop of the disabled value by the field whitelist. + - No boolean encoding. + - No claimed success on upstream failure. + contract: + protocol: HTTP + method: PATCH + endpoint: /aidp-mgmt/knowledge-bases/{kds_id}/permission + request_expectations: + - Only explicitly changed metadata fields are sent. + - The safety guard is forwarded as an integer when it changed. + - Permission and group fields keep their existing rules. + response_expectations: + - Permission save status and metadata update status are reported separately. + - A failed metadata update yields a non-success result with the unchanged readback value. + - Cache invalidation follows a successful metadata update. + - case_id: AKPR-D2-004 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-012] + title: Model queries select a single category per control + type: CONTRACT + priority: P0 + status: active + automation: automated + objective: Verify the model query proxy passes one category per request and returns only models applicable to the knowledge base application. + preconditions: + - A controllable upstream model fixture serves distinct payloads per category. + test_data: {profile: mock, categories: [llm, vlm, embedding]} + steps: + - order: 1 + action: Query the graph model list. + expected: The request carries the llm category and the response exposes only llm models. + - order: 2 + action: Query the multimodal model list. + expected: The request carries the vlm category and the response exposes only vlm models. + - order: 3 + action: Query an unsupported or failing category. + expected: A stable error or an empty result is returned without falling back to another category. + expected_results: + - One category is requested per control. + - Responses are not reused across categories. + - Failures do not silently substitute another category. + forbidden_side_effects: + - No shared cache entry across categories. + - No fabricated model identifier on failure. + - No change to existing non-AIDP model endpoints. + contract: + protocol: HTTP + method: GET + endpoint: /aidp-mgmt/models + request_expectations: + - The service parameter carries exactly one requested category. + - The application parameter keeps the knowledge base scope. + - No credential is returned to the browser. + response_expectations: + - Only models of the requested category are returned. + - An unavailable category yields a stable error or an empty list. + - Results are filtered to the knowledge base application scope. diff --git a/test/cases/d3/aidp-knowledge-pages-refactor.yaml b/test/cases/d3/aidp-knowledge-pages-refactor.yaml new file mode 100644 index 0000000000..e62180f3a6 --- /dev/null +++ b/test/cases/d3/aidp-knowledge-pages-refactor.yaml @@ -0,0 +1,99 @@ +schema_version: '1.0' +stage: D3 +module: AIDP-KNOWLEDGE +cases: + - case_id: AKPR-D3-001 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-011, AKPR-RULE-013, AKPR-RULE-014] + title: Real AIDP create, readback and safety guard update close the loop + type: INTEGRATION + priority: P0 + status: blocked + automation: automated + objective: Verify against a reachable AIDP service that a created knowledge base returns the submitted chunking, graph and safety configuration and that a later safety guard change is visible on readback. This case stays blocked until the upstream safety guard contract is available on the target environment. + preconditions: + - A reachable AIDP service accepts Nexent credentials. + - The upstream build that exposes the safety guard field is deployed. + - A disposable tenant and user group are available. + test_data: {profile: real_smoke, chunk_mode: 0, guard: [1, 0]} + steps: + - order: 1 + action: Create a knowledge base with chunking, retrieval, graph and safety configuration through the Nexent proxy. + expected: The upstream accepts the payload and returns a knowledge base identifier. + - order: 2 + action: Read the created knowledge base back. + expected: The chunking, retrieval, graph and safety values match the submitted configuration. + - order: 3 + action: Disable the safety guard and read it back again. + expected: The readback reports the disabled state instead of the previous value. + expected_results: + - Submitted configuration is readable from the upstream. + - The safety guard round trip works in both directions. + - Failure of the upstream metadata update is distinguishable from success. + forbidden_side_effects: + - No duplicate knowledge base created by retries. + - No permission row created without its knowledge base. + - No test data left in a shared tenant. + integration_boundary: + - Nexent AIDP knowledge base proxy + - Upstream AIDP knowledge base API + - Nexent AIDP permission store + observations: + - Outgoing create payload field names and types + - Readback values per configuration group + - Permission row and metadata update status after each step + cleanup: + - Delete the created knowledge base through the proxy. + - Remove local permission rows and document tag associations created by the case. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] + - case_id: AKPR-D3-002 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-012] + title: Real model categories return only their own models and survive readback + type: INTEGRATION + priority: P0 + status: blocked + automation: automated + objective: Verify against a reachable AIDP service that each model control receives its own category and that a configuration selected from real models is accepted and readable. This case stays blocked until a real AIDP environment with a model inventory is reachable. + preconditions: + - A reachable AIDP service exposes llm, vlm and embedding model inventories. + - At least one model exists in the llm and vlm categories. + test_data: {profile: real_smoke, categories: [llm, vlm, embedding]} + steps: + - order: 1 + action: Query each model category through the Nexent proxy. + expected: Each response contains only models of the requested category and scope. + - order: 2 + action: Create a knowledge base using a real llm model for the graph and a real vlm model for multimodal captioning. + expected: The upstream accepts both model fields. + - order: 3 + action: Read the knowledge base back. + expected: The graph and multimodal model fields match the submitted values. + expected_results: + - Category queries stay separated. + - Both model fields are accepted and readable. + - No unverified default model replaces a missing selection. + forbidden_side_effects: + - No model from one category submitted through another category control. + - No credential exposed to the browser. + - No change to non-AIDP model configuration. + integration_boundary: + - Nexent AIDP model query proxy + - Upstream AIDP model inventory API + - Nexent AIDP knowledge base create path + observations: + - Requested category per query + - Returned model identifiers and their categories + - Readback values for both model fields + cleanup: + - Delete the created knowledge base through the proxy. + - Discard recorded model identifiers that are environment specific. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] diff --git a/test/cases/d4/aidp-knowledge-pages-refactor.yaml b/test/cases/d4/aidp-knowledge-pages-refactor.yaml new file mode 100644 index 0000000000..945bb80327 --- /dev/null +++ b/test/cases/d4/aidp-knowledge-pages-refactor.yaml @@ -0,0 +1,202 @@ +schema_version: '1.0' +stage: D4 +module: AIDP-KNOWLEDGE +cases: + - case_id: AKPR-D4-001 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-001, AKPR-RULE-002, AKPR-RULE-003, AKPR-RULE-004, AKPR-RULE-005] + title: Overview journey keeps the guide, search, view and column state across the file view + type: E2E + priority: P0 + status: blocked + automation: automated + objective: Verify the complete overview journey in a browser against a deployed instance. This case stays blocked until a disposable Nexent deployment with the AIDP flag enabled and reachable AIDP data is available. + preconditions: + - A deployed Nexent instance runs this worktree with the AIDP flag enabled. + - The test tenant can see more than one knowledge base. + - A writable knowledge base with at least one document exists. + test_data: {profile: real_smoke, keyword: alpha, page_size: 10} + steps: + - order: 1 + action: Open the knowledge base page and inspect the guide. + expected: The explanation and the three steps are visible, collapse and reopen work, and cards are the default view. + - order: 2 + action: Search a name, move to a later page, switch to the table and hide one column. + expected: The table shows the filtered result set on the same page and the hidden column disappears. + - order: 3 + action: Open a knowledge base from the table. + expected: The full-page file view shows only that base files. + - order: 4 + action: Return to the overview. + expected: The keyword, page, table view and hidden column state are restored. + expected_results: + - The guide, card default, search, view switch and column setting all work in the browser. + - The file view is scoped to the selected base. + - Returning restores the earlier overview state. + forbidden_side_effects: + - No modification request from view or column changes. + - No state reset after returning. + - No ES knowledge base page rendered while the AIDP flag is enabled. + actor: Authenticated test tenant user + journey: + - Open the knowledge base overview. + - Expand and collapse the guide. + - Search, page and switch views while hiding a column. + - Open a knowledge base and return to the overview. + final_business_result: + - A user can browse, filter and configure the knowledge base overview and reach one base file view without losing context. + failure_evidence: + - Retain browser screenshot, network log and console trace on failure. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] + - case_id: AKPR-D4-002 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-009] + title: File view journey uploads, polls, downloads and deletes under permission control + type: E2E + priority: P0 + status: blocked + automation: automated + objective: Verify the restored file operations in a browser against a deployed instance. This case stays blocked until a disposable Nexent deployment with reachable AIDP upload and document services is available. + preconditions: + - A deployed Nexent instance runs this worktree with the AIDP flag enabled. + - The test user owns an editable knowledge base and can read another one. + - A small local document is prepared for upload. + test_data: {profile: real_smoke, documents: 2, one_failing_upload: true} + steps: + - order: 1 + action: Open the writable knowledge base and upload a document. + expected: The document appears and its processing status reaches a terminal state through polling. + - order: 2 + action: Download the processed document. + expected: The browser receives the original file with its name. + - order: 3 + action: Delete the document and confirm the dialog. + expected: The document disappears from the list and the deletion reports its own result. + - order: 4 + action: Open the read-only knowledge base and attempt the same operations. + expected: Upload and delete are unavailable while browsing and download remain permitted. + expected_results: + - Upload reaches a terminal processing state. + - The downloaded file matches the uploaded original. + - Deletion removes the document after confirmation. + - Permission gates match the documented read and edit rules. + forbidden_side_effects: + - No upload or delete entry for a read-only base. + - No polling continues after leaving the file view. + - No document of another base rendered in the current view. + actor: Authenticated test tenant user + journey: + - Open a writable knowledge base file view. + - Upload and wait for processing to finish. + - Download the document. + - Delete the document with confirmation. + - Check the same controls on a read-only base. + final_business_result: + - A user with edit permission can add, retrieve and remove documents while a read-only user can only browse and download them. + failure_evidence: + - Retain browser screenshot, network log, downloaded file metadata and console trace on failure. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] + - case_id: AKPR-D4-003 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-010, AKPR-RULE-011, AKPR-RULE-012, AKPR-RULE-013, AKPR-RULE-014] + title: Creation page journey configures two steps and reports creation and upload results separately + type: E2E + priority: P0 + status: blocked + automation: automated + objective: Verify the dedicated two-step creation page in a browser including conditional graph and multimodal sections and the separate creation and upload outcomes. This case stays blocked until a disposable Nexent deployment with reachable AIDP services is available. + preconditions: + - A deployed Nexent instance runs this worktree with the AIDP flag enabled. + - The test user belongs to a valid group for a shared permission. + - A local document and an intentionally rejected document are prepared. + test_data: {profile: real_smoke, chunk_mode: 1, graph_enabled: true, multimodal: true} + steps: + - order: 1 + action: Open the creation page, fill the first step and toggle the graph and multimodal sections. + expected: The conditional sections appear with their own model selections and permission fields stay in the first step. + - order: 2 + action: Press next and then return to the first step. + expected: No knowledge base exists yet and the form values and selected files are preserved. + - order: 3 + action: Submit with one accepted and one rejected file. + expected: The knowledge base is created once, the accepted file processes and the rejected file is reported as a separate failure. + - order: 4 + action: Retry only the failed file. + expected: No second knowledge base is created and the retry targets the failed file only. + expected_results: + - The two-step flow holds its state and creates nothing early. + - Conditional graph and multimodal configuration is submitted with the chosen models. + - Creation and upload outcomes are reported separately and retrying never duplicates the base. + forbidden_side_effects: + - No knowledge base created before final submission or twice after a retry. + - No hidden graph or multimodal configuration submitted when disabled. + - No upload attempted before a successful creation. + actor: Authenticated test tenant user + journey: + - Open the dedicated creation page. + - Configure the first step including permissions and capabilities. + - Advance to the upload step and back. + - Submit with a partially failing file set. + - Retry the failed file. + final_business_result: + - A user can create one properly configured knowledge base and see which files were accepted and which failed without creating duplicates. + failure_evidence: + - Retain browser screenshot, network log of create and upload requests, and console trace on failure. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] + - case_id: AKPR-D4-004 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-008] + title: ES deployment still renders the original knowledge base page + type: E2E + priority: P0 + status: blocked + automation: automated + objective: Verify that with the AIDP flag disabled the original ES knowledge base page renders unchanged. This case stays blocked until a disposable deployment or a flag configuration switch is available for browser verification. + preconditions: + - A deployed Nexent instance can run with the AIDP flag disabled. + - The same instance serves the ES knowledge base data. + test_data: {profile: real_smoke, aidp_flag: false} + steps: + - order: 1 + action: Open the knowledge base page with the AIDP flag disabled. + expected: The original ES knowledge base page renders without the AIDP guide, card overview or AIDP file view. + - order: 2 + action: Exercise the original list, creation and document interactions. + expected: Existing ES behavior is unchanged. + - order: 3 + action: Inspect the requests issued by the page. + expected: No AIDP management endpoint is called. + expected_results: + - The ES page is untouched. + - No AIDP component renders in the ES path. + - No AIDP request is issued. + forbidden_side_effects: + - No AIDP request from the ES page. + - No visual or behavioral change to the ES list and document flows. + - No new ES write request. + actor: Authenticated test tenant user + journey: + - Switch the deployment flag off. + - Open the knowledge base page. + - Exercise the original list and document interactions. + final_business_result: + - An ES deployment keeps its original knowledge base experience with no AIDP leakage. + failure_evidence: + - Retain browser screenshot, network log and console trace on failure. + execution: + profiles: + - name: real_smoke + required_services: [] diff --git a/test/cases/d5/aidp-knowledge-pages-refactor.yaml b/test/cases/d5/aidp-knowledge-pages-refactor.yaml new file mode 100644 index 0000000000..108e2d9b5a --- /dev/null +++ b/test/cases/d5/aidp-knowledge-pages-refactor.yaml @@ -0,0 +1,206 @@ +schema_version: '1.0' +stage: D5 +module: AIDP-KNOWLEDGE +cases: + - case_id: AKPR-D5-001 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-007, AKPR-RULE-014] + title: Restricted users never reach a modification endpoint + type: SECURITY + priority: P0 + status: blocked + automation: automated + objective: Verify across the overview, file view and creation page that permission and group rules block modification requests at the boundary. This case stays blocked until a disposable deployment with multiple permission roles is available. + preconditions: + - A deployed instance has a read-only user, a group-less user and an edit user on one knowledge base. + - Request logging is available. + test_data: {profile: real_smoke, roles: [READ_ONLY, 'USER without group', EDIT]} + steps: + - order: 1 + action: As the read-only user, open the base and attempt edit, upload, delete and safety guard changes. + expected: No modification entry is executable and no modification request is issued. + - order: 2 + action: As a user without a valid group, submit the creation first step with a shared permission. + expected: The submission is rejected and no knowledge base is created. + - order: 3 + action: As the edit user, perform the same operations. + expected: Only the permitted operations issue requests and they succeed. + expected_results: + - Disabled or hidden entries emit no request. + - Group validation rejects an invalid shared permission before creation. + - Permitted operations behave normally. + forbidden_side_effects: + - No modification request from a restricted role. + - No local-only permission change without the upstream counterpart. + - No exposure of upstream credentials in any error path. + risk_type: SECURITY + risk: A user without edit permission could modify a knowledge base or its safety configuration through a stale or unguarded UI entry. + condition: + - Read-only permission on the selected knowledge base. + - Shared permission without a valid same-tenant group. + - Unavailable resource state. + metrics: + - Count of modification requests by a restricted role. + - Count of knowledge bases created from an invalid group submission. + thresholds: + - Zero modification requests from restricted roles. + - Zero resources created from an invalid group submission. + recovery_expectations: + - After the rejected attempt the UI keeps the previous state and reports the failure without claiming success. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] + - case_id: AKPR-D5-002 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-005, AKPR-RULE-009] + title: Delayed responses and polling never cross knowledge base boundaries + type: RELIABILITY + priority: P1 + status: blocked + automation: automated + objective: Verify that switching or leaving the file view discards in-flight results, stops polling and never renders another base document list. This case stays blocked until a deployment with a controllable slow upstream is available. + preconditions: + - A deployed instance can delay document responses. + - Two knowledge bases with distinct documents exist. + test_data: {profile: real_smoke, delay_seconds: 5, bases: 2} + steps: + - order: 1 + action: Open the first base and immediately switch to the second. + expected: The first base documents never render in the second and the later response is discarded. + - order: 2 + action: Upload a document and leave the file view before processing completes. + expected: Polling stops after leaving and no late update reaches the overview. + - order: 3 + action: Repeat the switch several times and inspect the rendered list. + expected: Only the current base documents appear and the list state matches the last selection. + expected_results: + - No stale document list is rendered. + - Polling stops when the view is left. + - The current base is always the one displayed. + forbidden_side_effects: + - No orphan polling loop after navigation. + - No document count or status from another base. + - No duplicate document rows after repeated switching. + risk_type: RELIABILITY + risk: A late response from a previously opened knowledge base can overwrite the current file view or leave polling running. + condition: + - Slow upstream document responses. + - Rapid switching between knowledge bases. + - Navigating away while uploads are processing. + metrics: + - Count of discarded late responses. + - Polling cycles after leaving the file view. + - Rendered document count per base. + thresholds: + - Zero cross-base document renders. + - Zero polling cycles after leaving the view. + recovery_expectations: + - Reopening a base shows its own current documents and a correct processing state. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] + - case_id: AKPR-D5-003 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-012, AKPR-RULE-013, AKPR-RULE-014] + title: Disabled capabilities and missing remote values are never reported as active or saved + type: SECURITY + priority: P0 + status: blocked + automation: automated + objective: Verify that hidden configuration is not resubmitted, that missing remote values stay unknown and that a failed save is never presented as success. This case stays blocked until a deployment with a controllable upstream failure mode and the upstream safety contract is available. + preconditions: + - A deployed instance can reject a metadata update on demand. + - A knowledge base created before the safety field existed is available for readback. + test_data: {profile: real_smoke, graph: [true, false], multimodal: [true, false], guard_absent: true} + steps: + - order: 1 + action: Create a knowledge base with the graph and multimodal capabilities disabled. + expected: No graph configuration and no hidden model field reaches the upstream request. + - order: 2 + action: Read back a knowledge base whose response omits the safety field. + expected: The UI reports the state as unknown instead of confirmed disabled. + - order: 3 + action: Fail the metadata update after the guard change and inspect the UI. + expected: The change is reported as not saved and the previous value remains displayed. + expected_results: + - Hidden configuration stays out of requests. + - Missing remote values are not converted into confirmed states. + - Failed saves are visible as failures. + forbidden_side_effects: + - No resubmission of a previously selected hidden model. + - No success message after a failed upstream update. + - No safety field nested inside graph configuration. + risk_type: SECURITY + risk: A user could believe a safety guard or graph configuration is active while the remote state is unknown or a save failed. + condition: + - Graph or multimodal capability disabled after a selection. + - Upstream response without the safety field. + - Metadata update failure after a permission save. + metrics: + - Count of hidden configuration fields in outgoing payloads. + - Count of success messages after failed updates. + thresholds: + - Zero hidden configuration fields in requests. + - Zero false success reports. + recovery_expectations: + - After a failed save the previous remote value stays displayed and a retry re-sends the explicit value. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] + - case_id: AKPR-D5-004 + feature_id: AKPR-001 + requirement_ids: [AKPR-001] + business_rule_ids: [AKPR-RULE-010] + title: Creation and upload failures leave exactly one consistent knowledge base + type: RELIABILITY + priority: P1 + status: blocked + automation: automated + objective: Verify that failures around creation and upload never produce duplicate knowledge bases or permission rows without their knowledge base. This case stays blocked until a deployment with controllable upstream create and upload failures is available. + preconditions: + - A deployed instance can fail creation and file uploads on demand. + - Permission rows are inspectable for the test tenant. + test_data: {profile: real_smoke, upload_failures: 2, create_failures: 1} + steps: + - order: 1 + action: Fail the create request and inspect local state. + expected: No knowledge base and no orphan permission row remain. + - order: 2 + action: Create successfully with two failing uploads and retry them. + expected: One knowledge base exists and the retries never create another. + - order: 3 + action: Interrupt the submission and reload the creation page. + expected: No duplicate resource appears and the user can identify the outcome of the previous attempt. + expected_results: + - Exactly one knowledge base exists after retries. + - Permission rows always belong to an existing knowledge base. + - Interrupted submissions do not silently duplicate resources. + forbidden_side_effects: + - No duplicate knowledge base after retry or reload. + - No orphan permission row. + - No upload executed before a successful create. + risk_type: RELIABILITY + risk: Retrying a partially failed creation can create duplicate knowledge bases or inconsistent permission records. + condition: + - Create failure. + - Partial upload failure with retry. + - Submission interrupted before a response arrives. + metrics: + - Knowledge bases created per submission attempt. + - Orphan permission rows. + - Upload attempts before a successful create. + thresholds: + - At most one knowledge base per intended submission. + - Zero orphan permission rows. + recovery_expectations: + - The created knowledge base stays usable and the failed files can be retried without creating a new base. + execution: + profiles: + - name: real_smoke + required_services: [knowledge-provider] diff --git a/test/changes/requirements/aidp-knowledge-pages-refactor.yaml b/test/changes/requirements/aidp-knowledge-pages-refactor.yaml new file mode 100644 index 0000000000..069494306c --- /dev/null +++ b/test/changes/requirements/aidp-knowledge-pages-refactor.yaml @@ -0,0 +1,41 @@ +schema_version: '1.0' +changes: + - change_id: AKPR-REQ-001 + change_type: requirement + requirement_id: AKPR-001 + title: AIDP knowledge base list and creation page refactor + description: Split the AIDP knowledge base overview and its file management into separate views with a card or table overview, an independent name search, a collapsible three-step guide, a column visibility setting and a full-page file view that restores list state. Creation moves to a dedicated two-step page carrying permission, safety guard, graph, chunking, model and retrieval configuration, and the previously hidden document download and delete entries become available again under the documented permission gates. + feature_contract_changed: true + affected_features: {existing: [], added: [AKPR-001], modified: [], retired: []} + affected_cases: + existing: [] + added: + - AKPR-D1-001 + - AKPR-D1-002 + - AKPR-D1-003 + - AKPR-D1-004 + - AKPR-D1-005 + - AKPR-D1-006 + - AKPR-D1-007 + - AKPR-D1-008 + - AKPR-D1-009 + - AKPR-D1-010 + - AKPR-D1-011 + - AKPR-D1-012 + - AKPR-D2-001 + - AKPR-D2-002 + - AKPR-D2-003 + - AKPR-D2-004 + - AKPR-D3-001 + - AKPR-D3-002 + - AKPR-D4-001 + - AKPR-D4-002 + - AKPR-D4-003 + - AKPR-D4-004 + - AKPR-D5-001 + - AKPR-D5-002 + - AKPR-D5-003 + - AKPR-D5-004 + modified: [] + retired: [] + notes: The AIDP-only scope is preserved; the ES knowledge base path keeps its current components and endpoints. D3, D4 and D5 cases remain blocked until a disposable deployment and the upstream safety guard contract are available. diff --git a/test/features/aidp-knowledge-pages-refactor.yaml b/test/features/aidp-knowledge-pages-refactor.yaml new file mode 100644 index 0000000000..683f2b91d4 --- /dev/null +++ b/test/features/aidp-knowledge-pages-refactor.yaml @@ -0,0 +1,43 @@ +schema_version: '1.0' +module: + id: AIDP-KNOWLEDGE + name: AIDP knowledge base + description: AIDP knowledge base overview, file view and two-step creation page inside the Nexent frontend, including the AIDP-only branch selected by the deployment flag. +features: + - feature_id: AKPR-001 + name: AIDP knowledge base list and creation page refactor + status: active + description: AIDP knowledge bases are listed in a card or table overview with an independent name search, a collapsible three-step guide and a full-page file view per knowledge base; creation moves to a dedicated two-step page that carries permission, chunking, retrieval, graph, model and safety-guard configuration. The ES knowledge base path stays untouched. + business_rules: + - rule_id: AKPR-RULE-001 + description: The overview renders a knowledge base guide with the fixed steps "create knowledge base, upload files, configure the knowledge base in an agent"; the guide collapses and reopens without losing the rest of the page state. + - rule_id: AKPR-RULE-002 + description: The overview defaults to the card view and can switch to a table; both views project the same permission-scoped, server-paginated result set and switching never calls a mutating endpoint. + - rule_id: AKPR-RULE-003 + description: The independent name search reuses the existing keyword query, resets to the first page on change and restores the unfiltered set when cleared; no column-level search or filter exists in this version. + - rule_id: AKPR-RULE-004 + description: The table exposes a column visibility setting that hides and restores optional columns, keeps the name and action columns available, and never mutates backend state. + - rule_id: AKPR-RULE-005 + description: Selecting a knowledge base opens a full-page file view for that base only; returning restores the previous search, page, view mode and column setting, and a delayed response from a previously opened base never overwrites the current one. + - rule_id: AKPR-RULE-006 + description: Displayed metadata uses only real response values; unknown values render as an em dash, the personal/enterprise type comes from the AIDP is_private field instead of Nexent group permission, and personal capacity uses only a valid current_cap value in the documented unit. + - rule_id: AKPR-RULE-007 + description: Existing EDIT and READ_ONLY permissions, unavailable resource states and the hidden modification entry points for them are preserved in both overview and file view. + - rule_id: AKPR-RULE-008 + description: The AIDP page selection by the deployment flag stays isolated; ES deployments keep rendering the original ES knowledge base components and the new AIDP guide, overview and file view never enter that render path. + - rule_id: AKPR-RULE-009 + description: File upload, processing-status polling, download and deletion inside the AIDP file view keep their permission gates, expose partial-failure feedback, and clean up their polling state when the view is left. + - rule_id: AKPR-RULE-010 + description: Creation uses a dedicated two-step page; the first step owns name, description, permission and user groups, the next step only validates and caches local form state, and the knowledge base is created once at final submission before any file upload. + - rule_id: AKPR-RULE-011 + description: Chunking submits chunk_mode 0 for smart splitting and 1 for legal clauses, converts the overlap percentage to floor(tokens * percent / 100) integer tokens bounded by 50 percent of the chunk size, and keeps the existing default chunking values. + - rule_id: AKPR-RULE-012 + description: The graph model is queried from the llm model category and the multimodal model from the vlm category, with independent fields and query caches; disabling either capability omits its hidden previous configuration instead of resubmitting it. + - rule_id: AKPR-RULE-013 + description: Graph configuration serializes into the documented graph_config payload with documented defaults, maps the thinking switch inversely onto no_think_mode, keeps the graph Top K independent from the knowledge base Top K, and limits the editable prompt to 2048 UTF-8 bytes. + - rule_id: AKPR-RULE-014 + description: The safety guard uses the exact field sensitive_intercept_enalbe with integer values 1 and 0 across create, update and query; an explicit 0 is always forwarded, an absent value is never reported as confirmed off, and a failed update is never presented as saved. + required_test_stages: [D1, D2, D3, D4, D5] + source: + requirement_ids: [AKPR-001] + spec_path: nexent-doc/nexent/knowledge-base-pages-refactor/proposal.md diff --git "a/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx" "b/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx" index 7e356c6091e9d7a643d511e15234aea12dac2288..a96b32a742d51ce207b515f712e8164d2a7bb037 100644 GIT binary patch delta 32901 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 Date: Mon, 28 Sep 2026 20:05:42 +0800 Subject: [PATCH 02/36] fix(aidp): interpolate card counts and capacity with i18next syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: The new knowledge base cards rendered "文件 {count}" and "容量 {value}" literally, and the same single-brace placeholders broke two validation messages and the partial deletion summary. i18next only interpolates {{name}} placeholders, so those strings were shown verbatim. Fix: Rewrote the five affected keys in both locales with {{...}} placeholders and reformatted the locale files. Verification: - Browser check on /zh/knowledges: cards now render "文件 0" / "文件 2" and "容量 —" instead of the raw placeholders. - Locale files pass prettier formatting. Co-authored-by: ZCode Generated-by: deepseek-flash --- frontend/public/locales/en/common.json | 10 +++++----- frontend/public/locales/zh/common.json | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 06dfb9764c..1a5222bf50 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -4607,8 +4607,8 @@ "aidpKnowledge.fileTooLarge": "File “{{fileName}}” exceeds the {{maxSize}} MB limit", "aidpKnowledge.tooManyFiles": "You can upload up to {{maxCount}} files at a time. The selection would contain {{count}} files", "aidpKnowledge.backToList": "Back to knowledge bases", - "aidpKnowledge.cardCapacity": "Capacity {value}", - "aidpKnowledge.cardDocuments": "Files {count}", + "aidpKnowledge.cardCapacity": "Capacity {{value}}", + "aidpKnowledge.cardDocuments": "Files {{count}}", "aidpKnowledge.columnName": "Name", "aidpKnowledge.columnType": "Type", "aidpKnowledge.columnDescription": "Description", @@ -4623,7 +4623,7 @@ "aidpKnowledge.createChunkMode": "Chunking mode", "aidpKnowledge.createChunkModeSmart": "Smart splitting", "aidpKnowledge.createChunkModeLegal": "Legal clauses", - "aidpKnowledge.createChunkTokenNumRange": "Chunk size must be between {min} and {max} tokens", + "aidpKnowledge.createChunkTokenNumRange": "Chunk size must be between {{min}} and {{max}} tokens", "aidpKnowledge.createEmbeddingModel": "Embedding model", "aidpKnowledge.createGraphDisambiguation": "Semantic disambiguation", "aidpKnowledge.createGraphDomain": "Extraction domain", @@ -4651,7 +4651,7 @@ "aidpKnowledge.createModelSearch": "Search models", "aidpKnowledge.createOverlapPercent": "Chunk overlap ratio", "aidpKnowledge.createOverlapPercentHint": "Converted from the chunk size into an integer token overlap", - "aidpKnowledge.createOverlapPercentRange": "Overlap cannot exceed {max}%", + "aidpKnowledge.createOverlapPercentRange": "Overlap cannot exceed {{max}}%", "aidpKnowledge.createOverlapPercentRequired": "Enter the chunk overlap ratio", "aidpKnowledge.createPageTitle": "Create knowledge base", "aidpKnowledge.createSafetyGuard": "Safety guard", @@ -4664,7 +4664,7 @@ "aidpKnowledge.createSimilarity": "Similarity threshold", "aidpKnowledge.createSubmitting": "Submitting, please wait…", "aidpKnowledge.createTopk": "Knowledge base Top K", - "aidpKnowledge.deleteDocPartial": "Deletion finished: {success} succeeded, {failed} failed", + "aidpKnowledge.deleteDocPartial": "Deletion finished: {{success}} succeeded, {{failed}} failed", "aidpKnowledge.guideCollapse": "Collapse", "aidpKnowledge.guideExpand": "Expand", "aidpKnowledge.guideIntro": "Knowledge bases store the material your agents retrieve: create a knowledge base, upload files, then connect it to an agent.", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 47ee9c5fc7..2754082ffe 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -4618,8 +4618,8 @@ "aidpKnowledge.fileTooLarge": "文件“{{fileName}}”超过 {{maxSize}} MB 限制", "aidpKnowledge.tooManyFiles": "单次最多上传 {{maxCount}} 个文件,当前选择后共 {{count}} 个", "aidpKnowledge.backToList": "返回知识库列表", - "aidpKnowledge.cardCapacity": "容量 {value}", - "aidpKnowledge.cardDocuments": "文件 {count}", + "aidpKnowledge.cardCapacity": "容量 {{value}}", + "aidpKnowledge.cardDocuments": "文件 {{count}}", "aidpKnowledge.columnName": "名称", "aidpKnowledge.columnType": "类型", "aidpKnowledge.columnDescription": "描述", @@ -4634,7 +4634,7 @@ "aidpKnowledge.createChunkMode": "切片方式", "aidpKnowledge.createChunkModeSmart": "智能分片", "aidpKnowledge.createChunkModeLegal": "法律条文", - "aidpKnowledge.createChunkTokenNumRange": "切片大小需在 {min}-{max} token 之间", + "aidpKnowledge.createChunkTokenNumRange": "切片大小需在 {{min}}-{{max}} token 之间", "aidpKnowledge.createEmbeddingModel": "向量化模型", "aidpKnowledge.createGraphDisambiguation": "语义消歧", "aidpKnowledge.createGraphDomain": "抽取领域", @@ -4662,7 +4662,7 @@ "aidpKnowledge.createModelSearch": "搜索模型", "aidpKnowledge.createOverlapPercent": "切片重叠比例", "aidpKnowledge.createOverlapPercentHint": "按切片大小的百分比换算为整数 token 重叠", - "aidpKnowledge.createOverlapPercentRange": "重叠比例不能超过 {max}%", + "aidpKnowledge.createOverlapPercentRange": "重叠比例不能超过 {{max}}%", "aidpKnowledge.createOverlapPercentRequired": "请填写切片重叠比例", "aidpKnowledge.createPageTitle": "创建知识库", "aidpKnowledge.createSafetyGuard": "安全护栏", @@ -4675,7 +4675,7 @@ "aidpKnowledge.createSimilarity": "相似度阈值", "aidpKnowledge.createSubmitting": "正在提交,请勿重复操作…", "aidpKnowledge.createTopk": "知识库检索 Top K", - "aidpKnowledge.deleteDocPartial": "删除完成:成功 {success} 个,失败 {failed} 个", + "aidpKnowledge.deleteDocPartial": "删除完成:成功 {{success}} 个,失败 {{failed}} 个", "aidpKnowledge.guideCollapse": "收起", "aidpKnowledge.guideExpand": "展开", "aidpKnowledge.guideIntro": "知识库用于存放供智能体检索的资料:先创建知识库,上传文件完成入库,再到智能体中配置使用。", From 0bca5efb24b571b8230ebfdb29ccad52a80e8643 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Mon, 28 Sep 2026 20:10:03 +0800 Subject: [PATCH 03/36] refactor(aidp): share pagination and field label rendering Sonar reported 4.8% duplicated lines on the new code (threshold 3%). Two blocks were responsible: the knowledge base and document lists carried a line-for-line identical pagination block, and the creation page repeated the same label-with-tooltip JSX six times. - Extracted AidpPagination, now used by both lists, which also removes the duplicated "unreliable total" fallback rule. - Replaced the repeated label blocks with a local fieldLabel() helper. - Removed AidpCreateKbModal, which the dedicated creation page replaced. Verification: tsc --noEmit reports no error in the changed files, and the browser check on /zh/knowledges still renders the guide, card view, document counts and the unknown-value placeholders. Co-authored-by: ZCode Generated-by: deepseek-flash --- .../aidp/components/AidpCreateKbModal.tsx | 776 ------------------ .../aidp/components/AidpCreateKbPage.tsx | 84 +- .../aidp/components/AidpDocumentList.tsx | 61 +- .../aidp/components/AidpKnowledgeList.tsx | 48 +- .../aidp/components/AidpPagination.tsx | 62 ++ 5 files changed, 127 insertions(+), 904 deletions(-) delete mode 100644 frontend/ext_components/aidp/components/AidpCreateKbModal.tsx create mode 100644 frontend/ext_components/aidp/components/AidpPagination.tsx diff --git a/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx b/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx deleted file mode 100644 index 53d94f68af..0000000000 --- a/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx +++ /dev/null @@ -1,776 +0,0 @@ -"use client"; - -import React, { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; - -import { - Modal, - Form, - Input, - InputNumber, - Steps, - Upload, - Button, - message, - Space, - Divider, - Collapse, - Switch, - Tooltip, - Select, -} from "antd"; -import { InboxOutlined, QuestionCircleOutlined } from "@ant-design/icons"; - -import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; -import type { AidpModelItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; -import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; -import { USER_ROLES } from "@/const/auth"; - -/** - * Antd's Upload component (Dragger) requires ``originFileObj`` to satisfy - * the ``RcFile`` shape (``File`` + ``uid`` + ``lastModifiedDate``). We - * store raw ``File`` objects in component state, so we cast at the - * render boundary. The structural cast is sufficient because antd does - * not read the extra fields — it only requires them to exist for type - * compatibility. - */ -type RcFileLike = File & { uid: string; lastModifiedDate: Date }; -import { - AIDP_ACCEPT_STRING, - AIDP_KNOWLEDGE_BASE_NAME_PATTERN, -} from "@/const/knowledgeBase"; -import { collectUploadedFileIds } from "@/lib/aidpDocumentStatus"; -import { - partitionAidpFiles, - validateAidpFiles, -} from "@/services/uploadService"; -import { useGroupList } from "@/hooks/group/useGroupList"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; - -const { Dragger } = Upload; - -// Preferred VLM model name when present in AIDP's available list. -// Falls back to the first model in the list if this specific one is absent. -const PREFERRED_VLM_MODEL = "Qwen3-VL-8B-Instruct"; - -/** - * Default AIDP knowledge base configuration. - * Aligned with sdk/nexent/core/knowledge_base/config.py (build_create_payload defaults). - * - * Required fields per AIDP schema: - * chunk_token_num (> 0), chunk_overlap_num (>= 0) - * Reference fills the rest (is_personal, topk, similarity, smartsplit, caption_enable). - * ``vlm_model`` is no longer a hardcoded constant — it is resolved at runtime - * from the list of models AIDP advertises as applicable to the KnowledgeBase - * application (see ``useQuery(["aidp-models"])``). - */ -const AIDP_CREATE_DEFAULTS = { - chunk_token_num: 1024, - chunk_overlap_num: 128, - embedding_model: "default", - is_personal: 0, - topk: 10, - similarity: 0.0, - smartsplit: 1, - // caption_enable: int 0/1. - caption_enable: 0, -}; - -interface AidpCreateKbModalProps { - open: boolean; - existingKbs: AidpKnowledgeBaseItem[]; - onCancel: () => void; - onSuccess: ( - knowledgeBase: AidpKnowledgeBaseItem, - uploadedFileIds?: string[] - ) => void; -} - -const AidpCreateKbModal: React.FC = ({ - open, - existingKbs, - onCancel, - onSuccess, -}) => { - const { t, i18n } = useTranslation(); - const [form] = Form.useForm(); - const [current, setCurrent] = useState(0); - const [loading, setLoading] = useState(false); - const [fileList, setFileList] = useState([]); - const fileListRef = useRef([]); - - useEffect(() => { - fileListRef.current = fileList; - }, [fileList]); - - // Antd fires beforeUpload once per file in a multi-select batch. - // The `newFiles` array may-or-may-not be the same reference across the N - // calls (behavior differs between and and antd versions), - // so we cannot rely on reference-equality for a single-call-per-batch guard. - // Instead, we collect each file in beforeUpload and schedule a single - // requestAnimationFrame flush that runs validate/add once per batch. - // This guarantees partitionAidpFiles + toast execute exactly ONCE per - // user selection, regardless of antd's internal dispatch count. - const pendingFilesRef = useRef([]); - const rafIdRef = useRef(null); - - // Load the tenant's groups so the user can pick which groups may access - // the new KB. When no tenant context is available we fall back to an - // empty list and disable the group picker. - // NOTE: ``useAuthorizationContext`` is required — it is the only context - // that exposes ``user: User | null`` (with ``tenantId``). The similarly- - // named ``useAuthenticationContext`` only carries ``session`` and the - // plain ``useAuthentication`` hook doesn't carry ``user`` at all. - const { user } = useAuthorizationContext(); - const isUser = user?.role === USER_ROLES.USER; - const canConfigureGroupPermissions = !!user && !isUser; - const tenantId = user?.tenantId ?? null; - const { data: groupListData } = useGroupList( - canConfigureGroupPermissions ? tenantId : null - ); - const groupOptions = useMemo( - () => - (groupListData?.groups ?? []).map((g) => ({ - value: g.group_id, - label: g.group_name, - })), - [groupListData] - ); - const [formValues, setFormValues] = useState<{ - name: string; - description?: string; - vlm_model?: string; - chunk_token_num: number; - chunk_overlap_num: number; - caption_enable: number; - ingroup_permission: "EDIT" | "READ_ONLY" | "PRIVATE"; - group_ids: number[]; - }>({ - name: "", - chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, - chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, - caption_enable: AIDP_CREATE_DEFAULTS.caption_enable, - ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", - group_ids: [], - }); - - // Drive the vlm_model dropdown's visibility off the live Switch value. - // useWatch gives us a re-render whenever caption_enable toggles, without - // forcing the user to manually sync the form value to local state. - const captionEnabled = Form.useWatch("caption_enable", form); - // Track in-group permission live so the group_ids picker can be disabled - // at PRIVATE without calling Form.useWatch inside a conditional sub-render - // (which would violate the Rules of Hooks). - const ingroupPermission = Form.useWatch("ingroup_permission", form); - - useEffect(() => { - if (!open) return; - form.setFieldsValue({ - ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", - group_ids: [], - }); - setFormValues((previous) => ({ - ...previous, - ingroup_permission: isUser ? "PRIVATE" : previous.ingroup_permission, - group_ids: isUser ? [] : previous.group_ids, - })); - }, [form, isUser, open]); - - // Fetch applicable VLM models from AIDP. Only run when modal is open to - // avoid hitting the (relatively slow) admin endpoint unnecessarily. - const { data: vlmModelsData, isLoading: vlmModelsLoading } = useQuery({ - queryKey: ["aidp-models", "llm", "KnowledgeBase"], - queryFn: () => aidpKnowledgeService.listModels("llm", "KnowledgeBase"), - enabled: open, - staleTime: 5 * 60 * 1000, // 5 min - }); - - const vlmModelOptions = useMemo(() => { - const models: AidpModelItem[] = vlmModelsData?.models ?? []; - return models - .map((m) => m.model_name) - .filter( - (name): name is string => typeof name === "string" && name.length > 0 - ); - }, [vlmModelsData]); - - // Resolve the default VLM model: prefer the hardcoded - // PREFERRED_VLM_MODEL if present, otherwise the first in the list. - // Falls back to PREFERRED_VLM_MODEL (sent to AIDP as-is) when the - // models endpoint returns empty, matching the previous behavior. - const defaultVlmModel = useMemo(() => { - if (vlmModelOptions.length === 0) return PREFERRED_VLM_MODEL; - if (vlmModelOptions.includes(PREFERRED_VLM_MODEL)) - return PREFERRED_VLM_MODEL; - return vlmModelOptions[0]; - }, [vlmModelOptions]); - - // Pre-populate vlm_model on the form whenever the default is resolved, - // so the user sees a meaningful default on first open. - useEffect(() => { - if (!open) return; - if (!defaultVlmModel) return; - const current = form.getFieldValue("vlm_model"); - if (!current || !vlmModelOptions.includes(current)) { - form.setFieldValue("vlm_model", defaultVlmModel); - } - }, [open, defaultVlmModel, vlmModelOptions, form]); - - // Duplicate name check against existing KBs - const existingNames = useMemo( - () => - new Set( - (existingKbs || []) - .map((kb) => kb.kds_name?.toLowerCase().trim()) - .filter((n): n is string => !!n) - ), - [existingKbs] - ); - - const handleNext = async () => { - try { - const values = await form.validateFields(); - const name = values.name.trim(); - - if (existingNames.has(name.toLowerCase())) { - message.error(t("aidpKnowledge.createDuplicateName", { name })); - return; - } - - // Save form values before fields unmount - setFormValues({ - name, - description: values.description?.trim() || undefined, - vlm_model: values.caption_enable - ? values.vlm_model || defaultVlmModel || undefined - : "", - chunk_token_num: - values.chunk_token_num ?? AIDP_CREATE_DEFAULTS.chunk_token_num, - chunk_overlap_num: - values.chunk_overlap_num ?? AIDP_CREATE_DEFAULTS.chunk_overlap_num, - caption_enable: values.caption_enable ? 1 : 0, - // The permission select is disabled at PRIVATE so users cannot pick - // group_ids while PRIVATE; we always coerce to [] for safety. - ingroup_permission: isUser - ? "PRIVATE" - : (values.ingroup_permission ?? "READ_ONLY"), - group_ids: - isUser || (values.ingroup_permission ?? "READ_ONLY") === "PRIVATE" - ? [] - : Array.isArray(values.group_ids) - ? values.group_ids - : [], - }); - setCurrent(1); - } catch { - // form validation error, do nothing - } - }; - - const handleBack = () => { - // Restore formValues into the Form when remounting Step 0, - // since antd Form clears field values when the Form is unmounted. - form.setFieldsValue(formValues); - setCurrent(0); - }; - - const handleSubmit = async (skipUpload: boolean) => { - let knowledgeBaseCreated = false; - // Files accepted by AIDP while creating the KB. Reported to the parent so - // it can keep refreshing the document list until they finish processing. - let uploadedFileIds: string[] = []; - let createdKdsId = ""; - let createdKnowledgeBase: AidpKnowledgeBaseItem | null = null; - try { - if (!formValues.name?.trim()) { - message.error(t("aidpKnowledge.kbNameRequired")); - setCurrent(0); - return; - } - setLoading(true); - - const permission = isUser ? "PRIVATE" : formValues.ingroup_permission; - const groupIds = isUser ? [] : formValues.group_ids; - - // Defense-in-depth: re-validate every file in case beforeUpload was bypassed - if (!skipUpload && fileList.length > 0) { - const validation = validateAidpFiles(fileList); - if (validation.valid.length !== fileList.length) { - setLoading(false); - partitionAidpFiles(fileList, t, message); - return; - } - } - - // Step 1: Create KB - // Aligned with sdk/nexent/core/knowledge_base/mapper.py#build_create_payload - const created = await aidpKnowledgeService.createKb({ - name: formValues.name.trim(), - description: formValues.description || "", - chunk_token_num: formValues.chunk_token_num, - chunk_overlap_num: formValues.chunk_overlap_num, - embedding_model: AIDP_CREATE_DEFAULTS.embedding_model, - vlm_model: - formValues.caption_enable === 1 - ? formValues.vlm_model || defaultVlmModel || "" - : "", - is_personal: AIDP_CREATE_DEFAULTS.is_personal, - topk: AIDP_CREATE_DEFAULTS.topk, - similarity: AIDP_CREATE_DEFAULTS.similarity, - smartsplit: AIDP_CREATE_DEFAULTS.smartsplit, - caption_enable: formValues.caption_enable, - // v7.1: forward in-group permission + groups to the backend so the - // knowledge-base permission row is created in lockstep with the KB. - ingroup_permission: permission, - group_ids: groupIds, - }); - knowledgeBaseCreated = true; - createdKdsId = String(created.kds_id || ""); - createdKnowledgeBase = { - ...created, - kds_id: createdKdsId, - kds_name: created.kds_name || formValues.name.trim(), - description: created.description ?? formValues.description ?? "", - permission: "EDIT", - ingroup_permission: permission, - group_ids: permission === "PRIVATE" ? [] : groupIds, - resource_status: "ACTIVE", - is_multimodal: formValues.caption_enable === 1, - }; - - // Step 2: Upload files (if any and not skipped) - if (!skipUpload && fileList.length > 0 && created.kds_id) { - const result = await aidpKnowledgeService.uploadDocs( - created.kds_id, - fileList - ); - uploadedFileIds = collectUploadedFileIds(result.success_list); - - const failureDetails = result.failed_list.map((item) => { - const reason = i18n.language.startsWith("zh") - ? item.reason_zh || item.reason_en - : item.reason_en || item.reason_zh; - return `${item.file_name}: ${reason || t("aidpKnowledge.uploadFailed")}`; - }); - const failureLines = failureDetails.map((detail, index) => ( -
{detail}
- )); - - if (result.summary.failed > 0 && result.summary.success === 0) { - message.warning( -
-
{t("aidpKnowledge.createKbSuccess")}
- {failureLines.length > 0 ? ( - failureLines - ) : ( -
{t("aidpKnowledge.uploadFailed")}
- )} -
- ); - } else if (result.summary.failed > 0) { - message.info( -
-
{t("aidpKnowledge.createKbSuccess")}
-
- {t("aidpKnowledge.uploadPartial", { - success: result.summary.success, - failed: result.summary.failed, - })} -
- {failureLines} -
- ); - } else { - message.success( - t("aidpKnowledge.createKbSuccess") + - " | " + - t("aidpKnowledge.uploadSuccess", { - count: result.summary.success, - }) - ); - } - } else { - message.success(t("aidpKnowledge.createKbSuccess")); - } - - handleReset(); - if (createdKnowledgeBase) { - onSuccess(createdKnowledgeBase, uploadedFileIds); - } - } catch (error) { - const reason = - error instanceof Error && error.message.trim() - ? error.message - : knowledgeBaseCreated - ? t("aidpKnowledge.uploadFailed") - : t("aidpKnowledge.createKbFailed"); - message.error( - knowledgeBaseCreated - ? `${t("aidpKnowledge.createKbSuccess")} | ${reason}` - : reason - ); - if (knowledgeBaseCreated) { - handleReset(); - if (createdKnowledgeBase) { - onSuccess(createdKnowledgeBase, uploadedFileIds); - } - } - } finally { - setLoading(false); - } - }; - - const handleReset = () => { - form.resetFields(); - setCurrent(0); - setFileList([]); - pendingFilesRef.current = []; - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } - setFormValues({ - name: "", - chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, - chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, - caption_enable: AIDP_CREATE_DEFAULTS.caption_enable, - ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", - group_ids: [], - }); - }; - - const handleCancel = () => { - handleReset(); - onCancel(); - }; - - // ---- Render steps ---- - - const renderStep0 = () => ( - <> -
- - - - - - - - {canConfigureGroupPermissions && ( - <> - {/* USER creation is always a personal PRIVATE KB. */} - - - - - )} - - - {t("aidpKnowledge.createCaptionEnable")} - - - - - } - > - - - - {/* VLM model picker is only relevant when multimodal captioning is - enabled. Hide the dropdown entirely when the Switch is off so - users aren't shown an irrelevant choice, and so the backend - receives an empty ``vlm_model`` (see handleSubmit). */} - {captionEnabled && ( - - {t("aidpKnowledge.createVlmModel")} - - - - - } - > - - - - - - - - - - - - { - if (byteLength(value || "") <= GRAPH_PROMPT_MAX_BYTES) { - return Promise.resolve(); - } - return Promise.reject( - new Error(t("aidpKnowledge.createGraphPromptTooLong")) - ); - }, - }, - ]} - > - - - - - - - - - - )} - - -

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

- - ({ - label: name, - value: name, - }))} - /> - - - {t("aidpKnowledge.createCaptionEnable")} - - - - - } - > - - - {captionEnabled && ( - - + + + + + {canConfigureGroupPermissions && ( + <> +
+ {t("aidpKnowledge.createSectionPermission")} +
+ + + )} +
+ + +
+
+
+

+ {t("aidpKnowledge.createSectionSafety")} +

+ + + +
+ + + +
+
+ +
+ {sectionHeading( + "graph", + t("aidpKnowledge.createSectionGraph"), + t("aidpKnowledge.createGraphEnableHint"), + + + + )} + +
+ +
+ {sectionHeading("chunk", t("aidpKnowledge.createSectionChunk"))} + +
+ +
+ {sectionHeading("vector", t("aidpKnowledge.createSectionVector"))} + +
+ + +
+ {sectionHeading("retrieval", t("aidpKnowledge.createSectionRetrieval"))} + +
+ + ); +}; + +export default AidpCreateKbSections; diff --git a/frontend/ext_components/aidp/components/AidpDocumentList.tsx b/frontend/ext_components/aidp/components/AidpDocumentList.tsx index 2453717308..3865fbf73a 100644 --- a/frontend/ext_components/aidp/components/AidpDocumentList.tsx +++ b/frontend/ext_components/aidp/components/AidpDocumentList.tsx @@ -192,6 +192,8 @@ interface AidpDocumentListProps { * each uploaded file reports a terminal processing status. */ onDocsUploaded: (uploadedFileIds: string[]) => void; onRefresh: () => void; + /** Render only the existing permission-gated uploader inside an overview modal. */ + uploadOnly?: boolean; } const AidpDocumentList: React.FC = ({ @@ -206,6 +208,7 @@ const AidpDocumentList: React.FC = ({ onPageChange, onDocsUploaded, onRefresh, + uploadOnly = false, }) => { const { t, i18n } = useTranslation(); const [uploading, setUploading] = useState(false); @@ -380,7 +383,7 @@ const AidpDocumentList: React.FC = ({ return (
{/* Header */} -
+
@@ -404,7 +407,7 @@ const AidpDocumentList: React.FC = ({
{/* Document table */} -
+
{isLoading ? (
@@ -507,7 +510,7 @@ const AidpDocumentList: React.FC = ({
{/* Server-side pagination; see AidpPagination for the fallback rule. */} - {documents.length > 0 && ( + {!uploadOnly && documents.length > 0 && (
{ const KB_PAGE_SIZE = 10; const DOC_PAGE_SIZE = 10; const [kbPage, setKbPage] = useState(1); + const [kbPageSize, setKbPageSize] = useState(KB_PAGE_SIZE); const [docPage, setDocPage] = useState(1); // ---- Modal state ---- @@ -101,6 +102,13 @@ const AidpKnowledgeConfiguration: React.FC = () => { const [editingKb, setEditingKb] = useState( null ); + const [quickImportKb, setQuickImportKb] = + useState(null); + const [quickImportWatchKbId, setQuickImportWatchKbId] = useState< + string | null + >(null); + const quickImportPendingIdsRef = useRef([]); + const quickImportWatchStartedAtRef = useRef(0); // ---- Keyword search state ---- // `kbKeyword` is the raw input value and keeps the text field responsive; @@ -118,13 +126,17 @@ const AidpKnowledgeConfiguration: React.FC = () => { // ---- Fetch KB list (server-side pagination: each page fetches page_size items + Count total) ---- const fetchKbs = useCallback( - async (page: number = 1, keyword: string = "") => { + async ( + page: number = 1, + keyword: string = "", + pageSize: number = KB_PAGE_SIZE + ) => { setLoadingKbs(true); setKbsLoadFailed(false); try { const result = await aidpKnowledgeService.listKbs( page, - KB_PAGE_SIZE, + pageSize, keyword ); setKbs(result.value); @@ -152,8 +164,8 @@ const AidpKnowledgeConfiguration: React.FC = () => { // 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]); + fetchKbs(1, debouncedKbKeyword, kbPageSize); + }, [fetchKbs, debouncedKbKeyword, kbPageSize]); // ---- Cleanup legacy localStorage credentials on mount ---- // v7.1: AIDP credentials moved backend-side; frontends that pre-date the @@ -376,7 +388,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { } // Refresh list, keeping the active search filter applied - void fetchKbs(kbPage, debouncedKbKeyword); + void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); } catch (error) { appMessage.error(t("aidpKnowledge.deleteKbFailed")); } @@ -389,6 +401,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { t, fetchKbs, kbPage, + kbPageSize, debouncedKbKeyword, clearFileView, ] @@ -455,6 +468,58 @@ const AidpKnowledgeConfiguration: React.FC = () => { [activeKbId, fetchDocs, refreshActiveKbDetail, startUploadWatch] ); + // Quick imports run from the overview card menu. Keep their status polling + // independent from the currently open document page so a late response can + // never replace another knowledge base's documents. + const handleQuickDocsUploaded = useCallback( + (uploadedFileIds: string[]) => { + if (!quickImportKb || uploadedFileIds.length === 0) return; + quickImportPendingIdsRef.current = uploadedFileIds; + quickImportWatchStartedAtRef.current = Date.now(); + setQuickImportWatchKbId(quickImportKb.kds_id); + void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); + }, + [quickImportKb, fetchKbs, kbPage, debouncedKbKeyword, kbPageSize] + ); + + useEffect(() => { + if (!quickImportWatchKbId) return; + const timer = window.setInterval(() => { + if ( + Date.now() - quickImportWatchStartedAtRef.current > + AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS + ) { + quickImportPendingIdsRef.current = []; + setQuickImportWatchKbId(null); + void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); + return; + } + void aidpKnowledgeService + .listDocs(quickImportWatchKbId, 1, DOC_PAGE_SIZE) + .then((result) => { + quickImportPendingIdsRef.current = findPendingUploadIds( + quickImportPendingIdsRef.current, + result.value + ); + if ( + quickImportPendingIdsRef.current.length === 0 && + (result.processing_count ?? 0) === 0 + ) { + setQuickImportWatchKbId(null); + void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); + } + }) + .catch((error) => + log.error("Failed to poll AIDP quick-import documents:", error) + ); + }, AIDP_DOC_STATUS_POLL_MS); + return () => window.clearInterval(timer); + }, [quickImportWatchKbId, fetchKbs, kbPage, debouncedKbKeyword, kbPageSize]); + + const handleKbPageSizeChange = useCallback((pageSize: number) => { + setKbPageSize(pageSize); + }, []); + // ---- 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. @@ -537,7 +602,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { className="w-full h-full mx-auto relative flex flex-col" style={containerStyle} > -
+
{ totalReliable={kbTotalReliable} hasMore={kbHasMore} currentPage={kbPage} - pageSize={KB_PAGE_SIZE} + pageSize={kbPageSize} keyword={kbKeyword} viewMode={viewMode} visibleColumns={visibleColumns} onKeywordChange={setKbKeyword} - onPageChange={(page) => fetchKbs(page, debouncedKbKeyword)} + onPageChange={(page) => + fetchKbs(page, debouncedKbKeyword, kbPageSize) + } + onPageSizeChange={handleKbPageSizeChange} onViewModeChange={setViewMode} onVisibleColumnsChange={setVisibleColumns} onSelect={handleSelectKb} - onRefresh={() => fetchKbs(kbPage, debouncedKbKeyword)} + onRefresh={() => fetchKbs(kbPage, debouncedKbKeyword, kbPageSize)} onCreateNew={handleCreateNew} + onImport={setQuickImportKb} onEdit={handleEditKb} onDelete={handleDeleteKb} - onRetry={() => fetchKbs(kbPage, debouncedKbKeyword)} + onRetry={() => fetchKbs(kbPage, debouncedKbKeyword, kbPageSize)} />
+ setQuickImportKb(null)} + footer={null} + destroyOnClose + width={760} + centered + > + {quickImportKb && ( + undefined} + onDocsUploaded={handleQuickDocsUploaded} + onRefresh={() => fetchKbs(kbPage, debouncedKbKeyword, kbPageSize)} + uploadOnly + /> + )} + + {/* Update KB Modal */} { const [expanded, setExpanded] = useState(true); return ( -
- + +
{expanded && ( -
-

+

+

{t("aidpKnowledge.guideIntro")}

{ />
)} -
+ ); }; diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index c3d2ab83fc..3696c25e9c 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -1,10 +1,14 @@ -import React, { useMemo } from "react"; +"use client"; + +import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Checkbox, + Dropdown, Input, + type MenuProps, Popover, Segmented, Table, @@ -14,13 +18,15 @@ import { import type { ColumnsType } from "antd/es/table"; import { AppstoreOutlined, + EllipsisOutlined, + FileAddOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TableOutlined, } from "@ant-design/icons"; -import { SquarePen, Trash2 } from "lucide-react"; +import { BookOpen, SquarePen, Trash2 } from "lucide-react"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import { @@ -66,7 +72,6 @@ export const AIDP_KB_DEFAULT_COLUMNS: AidpKbColumnKey[] = [ "actions", ]; -/** Column labels shared by the table header and the visibility setting. */ const COLUMN_LABEL_KEYS: Record = { name: "aidpKnowledge.columnName", type: "aidpKnowledge.columnType", @@ -81,32 +86,83 @@ const COLUMN_LABEL_KEYS: Record = { interface AidpKnowledgeListProps { kbs: AidpKnowledgeBaseItem[]; isLoading: boolean; - /** True when the last list request failed; drives the failure state. */ loadFailed: boolean; total: number; - /** True when `total` came from AIDP Count API (reliable). When false we - * show a simple prev/next pagination without "共 N 条". */ totalReliable: boolean; hasMore: boolean; currentPage: number; pageSize: number; - /** Raw search box value. The parent debounces it before querying, so this is - * intentionally the un-debounced text the user is currently typing. */ keyword: string; viewMode: AidpKbViewMode; visibleColumns: AidpKbColumnKey[]; onKeywordChange: (value: string) => void; onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; onViewModeChange: (mode: AidpKbViewMode) => void; onVisibleColumnsChange: (columns: AidpKbColumnKey[]) => void; onSelect: (kb: AidpKnowledgeBaseItem) => void; onRefresh: () => void; onCreateNew: () => void; + onImport: (kb: AidpKnowledgeBaseItem) => void; onEdit: (kb: AidpKnowledgeBaseItem) => void; onDelete: (kb: AidpKnowledgeBaseItem) => void; onRetry: () => void; } +interface ResizableTitleProps extends React.ThHTMLAttributes { + width?: number; + onResize?: (width: number) => void; +} + +const ResizableTitle: React.FC = ({ + width, + onResize, + children, + ...rest +}) => { + const { t } = useTranslation(); + const isFixedColumn = Boolean(rest.className?.includes("ant-table-cell-fix")); + const handlePointerDown = (event: React.PointerEvent) => { + if (!onResize || typeof width !== "number") return; + event.preventDefault(); + event.stopPropagation(); + const startX = event.clientX; + const startWidth = width; + const handleMove = (moveEvent: PointerEvent) => { + onResize(Math.max(88, startWidth + moveEvent.clientX - startX)); + }; + const handleUp = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp, { once: true }); + }; + + return ( + + {children} + {onResize && ( +
event.stopPropagation()} + /> + )} + + ); +}; + const AidpKnowledgeList: React.FC = ({ kbs, isLoading, @@ -121,261 +177,386 @@ const AidpKnowledgeList: React.FC = ({ visibleColumns, onKeywordChange, onPageChange, + onPageSizeChange, onViewModeChange, onVisibleColumnsChange, onSelect, onRefresh, onCreateNew, + onImport, onEdit, onDelete, onRetry, }) => { const { t } = useTranslation(); - - // Load groups for the current tenant so we can render group_ids as names. - // ``useAuthorizationContext`` is the right hook here (the similarly-named - // ``useAuthenticationContext`` carries only ``session`` — no ``user`` object). const { user } = useAuthorizationContext(); const tenantId = user?.tenantId ?? null; const { data: groupListData } = useGroupList(tenantId); const groupById = useMemo(() => { const map = new Map(); - (groupListData?.groups ?? []).forEach((g) => { - map.set(g.group_id, g.group_name); + (groupListData?.groups ?? []).forEach((group) => { + map.set(group.group_id, group.group_name); }); return map; }, [groupListData]); - - // Convert group ids to names, skipping any ids that don't resolve - // (e.g. the group was deleted or the list is not yet loaded). Aligned - // with the local-knowledge-base list renderer. - const getGroupNames = (groupIds: number[] | undefined): string[] => { - if (!Array.isArray(groupIds) || groupIds.length === 0) return []; - return groupIds - .map((id) => groupById.get(id)) - .filter( - (name): name is string => typeof name === "string" && name.length > 0 - ); - }; - - // Sort alphabetically by name - const displayedKbs = useMemo(() => { - return [...kbs].sort((a, b) => - (a.kds_name || "").localeCompare(b.kds_name || "") - ); - }, [kbs]); + const [columnWidths, setColumnWidths] = useState>({}); + + const displayedKbs = useMemo( + () => + [...kbs].sort((a, b) => + (a.kds_name || "").localeCompare(b.kds_name || "") + ), + [kbs] + ); const isRowUnavailable = (kb: AidpKnowledgeBaseItem) => kb.resource_status === "UNAVAILABLE" || kb.resource_status === "ORPHANED"; - - // Only EDIT-level callers may modify the KB or its files. const canModify = (kb: AidpKnowledgeBaseItem) => kb.permission === "EDIT" && !isRowUnavailable(kb); - const renderKbTypeTag = (kb: AidpKnowledgeBaseItem) => { + const getGroupNames = (ids: number[] | undefined) => + Array.isArray(ids) + ? ids + .map((id) => groupById.get(id)) + .filter((name): name is string => Boolean(name)) + : []; + + const renderScope = (kb: AidpKnowledgeBaseItem) => { + const isPrivate = kb.ingroup_permission === "PRIVATE"; + const isShared = + kb.ingroup_permission === "EDIT" || kb.ingroup_permission === "READ_ONLY"; const type = normalizeAidpKbType(kb.is_private); - if (type === "personal") { - return {t("aidpKnowledge.kbTypePersonal")}; - } - if (type === "enterprise") { - return {t("aidpKnowledge.kbTypeEnterprise")}; - } - return {AIDP_UNKNOWN_VALUE}; - }; - - const renderStatusTags = (kb: AidpKnowledgeBaseItem) => ( - <> - {isRowUnavailable(kb) && ( - {t("aidpKnowledge.kbUnavailable")} - )} - {kb.permission === "READ_ONLY" && !isRowUnavailable(kb) && ( - {t("aidpKnowledge.kbReadOnly")} - )} - - ); + const groupNames = isShared ? getGroupNames(kb.group_ids) : []; - const renderGroupTags = (kb: AidpKnowledgeBaseItem) => ( - - {kb.ingroup_permission !== "PRIVATE" && - getGroupNames(kb.group_ids).map((groupName, idx) => ( - - {groupName} + return ( +
+ {isPrivate ? ( + + {t("aidpKnowledge.scopePrivate")} - ))} - - ); + ) : isShared ? ( + <> + + {t("aidpKnowledge.scopeShared")} + + + {kb.ingroup_permission === "EDIT" + ? t("aidpKnowledge.scopeGroupEdit") + : t("aidpKnowledge.scopeGroupRead")} + + + ) : type ? ( + + {t( + type === "personal" + ? "aidpKnowledge.kbTypePersonal" + : "aidpKnowledge.kbTypeEnterprise" + )} + + ) : ( + {AIDP_UNKNOWN_VALUE} + )} + + {groupNames.length > 0 && ( + + + {t("aidpKnowledge.scopeGroupCount", { + count: groupNames.length, + })} + + + )} + + {kb.permission === "READ_ONLY" && !isRowUnavailable(kb) && ( + + {t("aidpKnowledge.kbReadOnly")} + + )} + {isRowUnavailable(kb) && ( + + {t("aidpKnowledge.kbUnavailable")} + + )} +
+ ); + }; - const renderRowActions = (kb: AidpKnowledgeBaseItem) => ( -
- {canModify(kb) && ( - -
- ); + const menuFor = (kb: AidpKnowledgeBaseItem): MenuProps => ({ + items: [ + { + key: "import", + icon: , + label: t("aidpKnowledge.importFile"), + }, + { + key: "edit", + icon: , + label: t("common.edit"), + }, + { 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 === "edit") onEdit(kb); + if (key === "delete") onDelete(kb); + }, + }); + + const renderMoreMenu = (kb: AidpKnowledgeBaseItem) => + canModify(kb) ? ( + + ), - }, - type: { + }), + type: makeColumn("type", { title: t("aidpKnowledge.columnType"), key: "type", - width: 110, - render: (_value, kb) => renderKbTypeTag(kb), - }, - description: { + render: (_value, kb) => renderScope(kb), + }), + description: makeColumn("description", { title: t("aidpKnowledge.columnDescription"), dataIndex: "description", key: "description", ellipsis: true, render: (value: string | undefined) => - value?.trim() ? value : AIDP_UNKNOWN_VALUE, - }, - documents: { + value?.trim() || AIDP_UNKNOWN_VALUE, + }), + documents: makeColumn("documents", { title: t("aidpKnowledge.columnDocuments"), key: "documents", - width: 90, render: (_value, kb) => formatAidpDocumentCount( kb.document_count, kb.document_count_reliable ), - }, - capacity: { + }), + capacity: makeColumn("capacity", { title: t("aidpKnowledge.columnCapacity"), key: "capacity", - width: 110, render: (_value, kb) => formatAidpCapacity(kb.current_cap), - }, - creator: { + }), + creator: makeColumn("creator", { title: t("aidpKnowledge.columnCreator"), key: "creator", - width: 130, render: (_value, kb) => formatAidpCreator(kb.user_name), - }, - created_at: { + }), + created_at: makeColumn("created_at", { title: t("aidpKnowledge.columnCreatedAt"), key: "created_at", - width: 130, render: (_value, kb) => formatAidpDateTime(kb.created_at), - }, - actions: { + }), + actions: makeColumn("actions", { title: t("aidpKnowledge.columnActions"), key: "actions", - width: 90, + fixed: "right", align: "right", - render: (_value, kb) => renderRowActions(kb), - }, + render: (_value, kb) => + canModify(kb) ? ( +
+ + + +
+ ) : null, + }), }; - - // The required columns always render; the rest follow the column setting. const activeKeys = AIDP_KB_DEFAULT_COLUMNS.filter( (key) => AIDP_KB_REQUIRED_COLUMNS.includes(key) || visibleColumns.includes(key) ); + const totalWidth = activeKeys.reduce( + (sum, key) => sum + (columnWidths[key] ?? baseWidths[key]), + 0 + ); return ( rowKey="kds_id" - size="small" + size="middle" + className="aidp-knowledge-table" columns={activeKeys.map((key) => allColumns[key])} dataSource={displayedKbs} pagination={false} + components={{ + header: { cell: ResizableTitle as React.ComponentType }, + }} + scroll={{ x: totalWidth }} onRow={(kb) => ({ onClick: () => onSelect(kb), style: { cursor: "pointer" }, @@ -385,15 +566,14 @@ const AidpKnowledgeList: React.FC = ({ }; const renderEmptyState = () => ( -
+
{keyword.trim() ? t("aidpKnowledge.searchEmpty") : t("aidpKnowledge.listEmpty")}
); - const renderFailedState = () => ( -
+

{t("aidpKnowledge.listLoadFailed")}

@@ -402,21 +582,16 @@ const AidpKnowledgeList: React.FC = ({
); - const renderBody = () => { if (isLoading && displayedKbs.length === 0) { return ( -
+
{t("aidpKnowledge.listLoading")}
); } - if (loadFailed && displayedKbs.length === 0) { - return renderFailedState(); - } - if (displayedKbs.length === 0) { - return renderEmptyState(); - } + if (loadFailed && displayedKbs.length === 0) return renderFailedState(); + if (displayedKbs.length === 0) return renderEmptyState(); return viewMode === "cards" ? renderCards() : renderTable(); }; @@ -451,64 +626,52 @@ const AidpKnowledgeList: React.FC = ({ ); return ( -
- {/* Guide sits above the overview card and owns its own collapse state. */} +
- -
- {/* Header */} -
-
-

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

-
- - -
-
- -
+
+
+
} value={keyword} - onChange={(e) => onKeywordChange(e.target.value)} - size="small" + onChange={(event) => onKeywordChange(event.target.value)} + size="middle" /> + +
+
+ onViewModeChange(value as AidpKbViewMode)} options={[ { value: "cards", - label: t("aidpKnowledge.viewCards"), - icon: , + title: t("aidpKnowledge.viewCards"), + label: , }, { value: "table", - label: t("aidpKnowledge.viewTable"), - icon: , + title: t("aidpKnowledge.viewTable"), + label: , }, ]} /> - {/* Column visibility only affects the table view. */} {viewMode === "table" && ( = ({ content={columnSettingContent} > -
- - {/* Body */} -
{renderBody()}
- - {/* Server-side pagination; see AidpPagination for the fallback rule. */} +
{renderBody()}
{kbs.length > 0 && ( -
+
= ({ totalReliable={totalReliable} hasMore={hasMore} onPageChange={onPageChange} + onPageSizeChange={onPageSizeChange} />
)} -
+
); }; diff --git a/frontend/ext_components/aidp/components/AidpPagination.tsx b/frontend/ext_components/aidp/components/AidpPagination.tsx index a94bbe0759..559cc4e75e 100644 --- a/frontend/ext_components/aidp/components/AidpPagination.tsx +++ b/frontend/ext_components/aidp/components/AidpPagination.tsx @@ -13,6 +13,7 @@ interface AidpPaginationProps { totalReliable: boolean; hasMore: boolean; onPageChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; } /** @@ -30,6 +31,7 @@ const AidpPagination: React.FC = ({ totalReliable, hasMore, onPageChange, + onPageSizeChange, }) => { const { t } = useTranslation(); // When the total is unreliable the control still needs a value that enables @@ -46,8 +48,15 @@ const AidpPagination: React.FC = ({ current={currentPage} pageSize={pageSize} total={effectiveTotal || 1} - onChange={onPageChange} - showSizeChanger={false} + onChange={(page, nextPageSize) => { + if (onPageSizeChange && nextPageSize !== pageSize) { + onPageSizeChange(nextPageSize); + return; + } + onPageChange(page); + }} + pageSizeOptions={onPageSizeChange ? [10, 20, 50, 100] : undefined} + showSizeChanger={Boolean(onPageSizeChange)} simple={!totalReliable} showTotal={ totalReliable diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 1a5222bf50..5ae0ffe2de 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -4120,7 +4120,7 @@ "aidpKnowledge.kbNameRequired": "Name is required", "aidpKnowledge.kbNameInvalid": "Invalid name format: the name must start with a Chinese character or English letter, followed only by Chinese or English letters, digits, or underscores, and be 1–256 characters long.", "aidpKnowledge.kbDescription": "Description", - "aidpKnowledge.kbDescriptionPlaceholder": "Optional description", + "aidpKnowledge.kbDescriptionPlaceholder": "Enter a description", "aidpKnowledge.kbDescriptionRequired": "Please enter a description", "aidpKnowledge.embeddingModel": "Embedding Model", "aidpKnowledge.embeddingModelPlaceholder": "Optional embedding model name", @@ -4173,7 +4173,7 @@ "aidpKnowledge.createSelectedFiles": "{{count}} file(s) selected", "aidpKnowledge.createNoFiles": "No files selected. Click \"Skip Upload\" to create without documents.", "aidpKnowledge.createAdvancedOptions": "Advanced Options", - "aidpKnowledge.createChunkTokenNum": "Chunk Token Count", + "aidpKnowledge.createChunkTokenNum": "Maximum text chunk tokens", "aidpKnowledge.createChunkTokenNumRequired": "Please enter the chunk token count", "aidpKnowledge.createChunkTokenNumMin": "Chunk token count must be greater than 0", "aidpKnowledge.createChunkOverlapNum": "Chunk Overlap Token Count", @@ -4181,7 +4181,7 @@ "aidpKnowledge.createChunkOverlapNumMin": "Chunk overlap token count must be ≥ 0", "aidpKnowledge.createCaptionEnable": "Enable Multimodal ", "aidpKnowledge.createCaptionEnableHint": "When enabled, text descriptions will be extracted from images in the knowledge base", - "aidpKnowledge.createVlmModel": "Vision-Language Model (VLM)", + "aidpKnowledge.createVlmModel": "VLM model", "aidpKnowledge.createVlmModelHint": "Used for image caption generation in multimodal knowledge bases", "aidpKnowledge.createVlmModelLoading": "Loading models…", "aidpKnowledge.createVlmModelNone": "No models available", @@ -4608,9 +4608,20 @@ "aidpKnowledge.tooManyFiles": "You can upload up to {{maxCount}} files at a time. The selection would contain {{count}} files", "aidpKnowledge.backToList": "Back to knowledge bases", "aidpKnowledge.cardCapacity": "Capacity {{value}}", - "aidpKnowledge.cardDocuments": "Files {{count}}", + "aidpKnowledge.cardDocuments": "Documents {{count}}", + "aidpKnowledge.cardUpdated": "Updated", + "aidpKnowledge.overviewTitle": "Knowledge bases", + "aidpKnowledge.importFile": "Import files", + "aidpKnowledge.importFileTitle": "Import files to {{name}}", + "aidpKnowledge.moreOperations": "More actions", + "aidpKnowledge.resizeColumn": "Resize column", + "aidpKnowledge.scopePrivate": "Private", + "aidpKnowledge.scopeShared": "Shared", + "aidpKnowledge.scopeGroupEdit": "Group edit access", + "aidpKnowledge.scopeGroupRead": "Group read-only", + "aidpKnowledge.scopeGroupCount": "{{count}} groups", "aidpKnowledge.columnName": "Name", - "aidpKnowledge.columnType": "Type", + "aidpKnowledge.columnType": "Visibility", "aidpKnowledge.columnDescription": "Description", "aidpKnowledge.columnDocuments": "Files", "aidpKnowledge.columnCapacity": "Storage", @@ -4625,31 +4636,31 @@ "aidpKnowledge.createChunkModeLegal": "Legal clauses", "aidpKnowledge.createChunkTokenNumRange": "Chunk size must be between {{min}} and {{max}} tokens", "aidpKnowledge.createEmbeddingModel": "Embedding model", - "aidpKnowledge.createGraphDisambiguation": "Semantic disambiguation", - "aidpKnowledge.createGraphDomain": "Extraction domain", + "aidpKnowledge.createGraphDisambiguation": "Enable semantic disambiguation", + "aidpKnowledge.createGraphDomain": "Domain", "aidpKnowledge.createGraphDomainFinance": "Finance", "aidpKnowledge.createGraphDomainGeneral": "General", "aidpKnowledge.createGraphDomainMedical": "Medical", "aidpKnowledge.createGraphEnable": "Enable knowledge graph", "aidpKnowledge.createGraphEnableHint": "Extracts entities and relations for graph-augmented retrieval; set at creation time only", "aidpKnowledge.createGraphHop": "Sub-graph hop count", - "aidpKnowledge.createGraphModel": "Graph extraction model", - "aidpKnowledge.createGraphModelHint": "Shows only llm models available to knowledge bases", + "aidpKnowledge.createGraphModel": "Graph construction LLM model", + "aidpKnowledge.createGraphModelHint": "Queries AIDP for LLM models separately from VLM models", "aidpKnowledge.createGraphModelRequired": "Select a graph extraction model", "aidpKnowledge.createGraphPromptEn": "English", "aidpKnowledge.createGraphPromptHint": "Editable here, limited to 2048 UTF-8 bytes", - "aidpKnowledge.createGraphPromptLanguage": "Prompt language", - "aidpKnowledge.createGraphPromptText": "Extraction prompt", + "aidpKnowledge.createGraphPromptLanguage": "Graph prompt language", + "aidpKnowledge.createGraphPromptText": "Graph prompt template", "aidpKnowledge.createGraphPromptTooLong": "The prompt exceeds the 2048 byte limit", "aidpKnowledge.createGraphPromptZh": "Chinese", - "aidpKnowledge.createGraphSynonymMerge": "Synonym merging", - "aidpKnowledge.createGraphThinking": "Enable model thinking", + "aidpKnowledge.createGraphSynonymMerge": "Enable synonym detection and merging", + "aidpKnowledge.createGraphThinking": "Enable LLM reasoning during graph construction", "aidpKnowledge.createGraphThinkingHint": "Lets the model reason before extracting; disabling speeds it up", - "aidpKnowledge.createGraphTopk": "Graph candidate Top K", + "aidpKnowledge.createGraphTopk": "Vector retrieval Top K", "aidpKnowledge.createModelLoading": "Loading…", "aidpKnowledge.createModelNone": "No models available", "aidpKnowledge.createModelSearch": "Search models", - "aidpKnowledge.createOverlapPercent": "Chunk overlap ratio", + "aidpKnowledge.createOverlapPercent": "Vector chunk overlap percentage", "aidpKnowledge.createOverlapPercentHint": "Converted from the chunk size into an integer token overlap", "aidpKnowledge.createOverlapPercentRange": "Overlap cannot exceed {{max}}%", "aidpKnowledge.createOverlapPercentRequired": "Enter the chunk overlap ratio", @@ -4661,12 +4672,23 @@ "aidpKnowledge.createSectionGraph": "Knowledge graph", "aidpKnowledge.createSectionPermission": "Permission and user groups", "aidpKnowledge.createSectionSafety": "Safety guard", + "aidpKnowledge.createSectionVector": "Vector generation", + "aidpKnowledge.createSectionRetrieval": "Retrieval matching", "aidpKnowledge.createSimilarity": "Similarity threshold", "aidpKnowledge.createSubmitting": "Submitting, please wait…", - "aidpKnowledge.createTopk": "Knowledge base Top K", + "aidpKnowledge.createTopk": "Vector retrieval Top K", + "aidpKnowledge.createVlmModelRequired": "Select a VLM model", + "aidpKnowledge.createChunkModeSmartHint": "Split automatically based on meaning and document structure", + "aidpKnowledge.createChunkModeLegalHint": "Split by legal hierarchy and clause structure", + "aidpKnowledge.createDefaultsHint": "Default settings are filled in and can be adjusted.", + "aidpKnowledge.createYes": "Yes", + "aidpKnowledge.createNo": "No", + "aidpKnowledge.createExpandSection": "Expand {{section}}", + "aidpKnowledge.createCollapseSection": "Collapse {{section}}", + "aidpKnowledge.createGraphTopkHint": "Top K value used for knowledge graph retrieval", "aidpKnowledge.deleteDocPartial": "Deletion finished: {{success}} succeeded, {{failed}} failed", - "aidpKnowledge.guideCollapse": "Collapse", - "aidpKnowledge.guideExpand": "Expand", + "aidpKnowledge.guideCollapse": "Collapse tutorial", + "aidpKnowledge.guideExpand": "Usage guide", "aidpKnowledge.guideIntro": "Knowledge bases store the material your agents retrieve: create a knowledge base, upload files, then connect it to an agent.", "aidpKnowledge.guideStepAgent": "Configure the knowledge base in an agent", "aidpKnowledge.guideStepCreate": "Create a knowledge base", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 2754082ffe..e7f0273b12 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -3830,7 +3830,7 @@ "aidpKnowledge.kbNameRequired": "名称是必填项", "aidpKnowledge.kbNameInvalid": "名称格式不正确:必须以中文或英文字母开头,后续只能包含中文、英文字母、数字或下划线,长度为 1-256 个字符", "aidpKnowledge.kbDescription": "描述", - "aidpKnowledge.kbDescriptionPlaceholder": "可选描述信息", + "aidpKnowledge.kbDescriptionPlaceholder": "请输入知识库描述", "aidpKnowledge.kbDescriptionRequired": "请输入知识库描述", "aidpKnowledge.embeddingModel": "向量模型", "aidpKnowledge.embeddingModelPlaceholder": "可选的向量模型名称", @@ -3883,7 +3883,7 @@ "aidpKnowledge.createSelectedFiles": "已选择 {{count}} 个文件", "aidpKnowledge.createNoFiles": "未选择文件,可点击「跳过上传」直接创建", "aidpKnowledge.createAdvancedOptions": "高级选项", - "aidpKnowledge.createChunkTokenNum": "分块 Token 数", + "aidpKnowledge.createChunkTokenNum": "文本切片最长token", "aidpKnowledge.createChunkTokenNumRequired": "请输入分块 Token 数", "aidpKnowledge.createChunkTokenNumMin": "分块 Token 数必须大于 0", "aidpKnowledge.createChunkOverlapNum": "分块重叠 Token 数", @@ -3891,7 +3891,7 @@ "aidpKnowledge.createChunkOverlapNumMin": "分块重叠 Token 数必须 ≥ 0", "aidpKnowledge.createCaptionEnable": "启用多模态", "aidpKnowledge.createCaptionEnableHint": "开启后将对知识库中的图片进行文字描述提取", - "aidpKnowledge.createVlmModel": "视觉语言模型 (VLM)", + "aidpKnowledge.createVlmModel": "VLM模型", "aidpKnowledge.createVlmModelHint": "用于多模态知识库中的图片 caption 生成", "aidpKnowledge.createVlmModelLoading": "加载模型列表中…", "aidpKnowledge.createVlmModelNone": "暂无可用模型", @@ -4619,9 +4619,20 @@ "aidpKnowledge.tooManyFiles": "单次最多上传 {{maxCount}} 个文件,当前选择后共 {{count}} 个", "aidpKnowledge.backToList": "返回知识库列表", "aidpKnowledge.cardCapacity": "容量 {{value}}", - "aidpKnowledge.cardDocuments": "文件 {{count}}", + "aidpKnowledge.cardDocuments": "文档 {{count}}", + "aidpKnowledge.cardUpdated": "更新于", + "aidpKnowledge.overviewTitle": "知识库", + "aidpKnowledge.importFile": "导入文件", + "aidpKnowledge.importFileTitle": "导入文件至 {{name}}", + "aidpKnowledge.moreOperations": "更多操作", + "aidpKnowledge.resizeColumn": "调整列宽", + "aidpKnowledge.scopePrivate": "私有", + "aidpKnowledge.scopeShared": "共享", + "aidpKnowledge.scopeGroupEdit": "用户组可编辑", + "aidpKnowledge.scopeGroupRead": "用户组只读", + "aidpKnowledge.scopeGroupCount": "{{count}} 个用户组", "aidpKnowledge.columnName": "名称", - "aidpKnowledge.columnType": "类型", + "aidpKnowledge.columnType": "权限范围", "aidpKnowledge.columnDescription": "描述", "aidpKnowledge.columnDocuments": "文件", "aidpKnowledge.columnCapacity": "存储占用", @@ -4636,31 +4647,31 @@ "aidpKnowledge.createChunkModeLegal": "法律条文", "aidpKnowledge.createChunkTokenNumRange": "切片大小需在 {{min}}-{{max}} token 之间", "aidpKnowledge.createEmbeddingModel": "向量化模型", - "aidpKnowledge.createGraphDisambiguation": "语义消歧", - "aidpKnowledge.createGraphDomain": "抽取领域", + "aidpKnowledge.createGraphDisambiguation": "开启语义消歧", + "aidpKnowledge.createGraphDomain": "领域", "aidpKnowledge.createGraphDomainFinance": "金融", "aidpKnowledge.createGraphDomainGeneral": "通用", "aidpKnowledge.createGraphDomainMedical": "医疗", "aidpKnowledge.createGraphEnable": "开启知识图谱", "aidpKnowledge.createGraphEnableHint": "开启后从文档中抽取实体与关系用于图谱增强检索;本次仅在创建时设置", "aidpKnowledge.createGraphHop": "子图扩展跳数", - "aidpKnowledge.createGraphModel": "构图模型", - "aidpKnowledge.createGraphModelHint": "仅展示 llm 类别中可用于知识库的模型", + "aidpKnowledge.createGraphModel": "构图过程LLM模型", + "aidpKnowledge.createGraphModelHint": "从 AIDP 查询 llm 类型模型,与 VLM 模型分开查询", "aidpKnowledge.createGraphModelRequired": "请选择构图模型", "aidpKnowledge.createGraphPromptEn": "英文", "aidpKnowledge.createGraphPromptHint": "可在此调整,最多 2048 字节(UTF-8)", - "aidpKnowledge.createGraphPromptLanguage": "提示词语言", - "aidpKnowledge.createGraphPromptText": "构图提示词", + "aidpKnowledge.createGraphPromptLanguage": "构图过程提示词语言", + "aidpKnowledge.createGraphPromptText": "构图过程提示词模板", "aidpKnowledge.createGraphPromptTooLong": "提示词超过 2048 字节上限", "aidpKnowledge.createGraphPromptZh": "中文", - "aidpKnowledge.createGraphSynonymMerge": "同义词合并", - "aidpKnowledge.createGraphThinking": "开启大模型思考", + "aidpKnowledge.createGraphSynonymMerge": "开启同义词检测合并", + "aidpKnowledge.createGraphThinking": "构图过程开启大模型思考", "aidpKnowledge.createGraphThinkingHint": "开启后模型在抽取前进行推理,关闭可提升速度", - "aidpKnowledge.createGraphTopk": "图谱候选 Top K", + "aidpKnowledge.createGraphTopk": "向量检索TOP K值", "aidpKnowledge.createModelLoading": "加载中…", "aidpKnowledge.createModelNone": "暂无可用模型", "aidpKnowledge.createModelSearch": "搜索模型", - "aidpKnowledge.createOverlapPercent": "切片重叠比例", + "aidpKnowledge.createOverlapPercent": "向量切片重叠字符百分比", "aidpKnowledge.createOverlapPercentHint": "按切片大小的百分比换算为整数 token 重叠", "aidpKnowledge.createOverlapPercentRange": "重叠比例不能超过 {{max}}%", "aidpKnowledge.createOverlapPercentRequired": "请填写切片重叠比例", @@ -4672,12 +4683,23 @@ "aidpKnowledge.createSectionGraph": "知识图谱", "aidpKnowledge.createSectionPermission": "权限与用户组", "aidpKnowledge.createSectionSafety": "安全护栏", + "aidpKnowledge.createSectionVector": "向量生成", + "aidpKnowledge.createSectionRetrieval": "检索匹配", "aidpKnowledge.createSimilarity": "相似度阈值", "aidpKnowledge.createSubmitting": "正在提交,请勿重复操作…", - "aidpKnowledge.createTopk": "知识库检索 Top K", + "aidpKnowledge.createTopk": "向量检索TOP K值", + "aidpKnowledge.createVlmModelRequired": "请选择 VLM 模型", + "aidpKnowledge.createChunkModeSmartHint": "按文本语义与内容结构自动切分", + "aidpKnowledge.createChunkModeLegalHint": "按法律条文层级与条款结构切分", + "aidpKnowledge.createDefaultsHint": "已为您填充默认设置,可按需修改。", + "aidpKnowledge.createYes": "是", + "aidpKnowledge.createNo": "否", + "aidpKnowledge.createExpandSection": "展开{{section}}", + "aidpKnowledge.createCollapseSection": "收起{{section}}", + "aidpKnowledge.createGraphTopkHint": "知识图谱检索使用的 Top K 值", "aidpKnowledge.deleteDocPartial": "删除完成:成功 {{success}} 个,失败 {{failed}} 个", - "aidpKnowledge.guideCollapse": "收起", - "aidpKnowledge.guideExpand": "展开", + "aidpKnowledge.guideCollapse": "收起教程", + "aidpKnowledge.guideExpand": "使用指南", "aidpKnowledge.guideIntro": "知识库用于存放供智能体检索的资料:先创建知识库,上传文件完成入库,再到智能体中配置使用。", "aidpKnowledge.guideStepAgent": "智能体配置知识库", "aidpKnowledge.guideStepCreate": "创建知识库", From 85fbbccb7ba7e5a4cd945aa70aec4be92a573ef2 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Tue, 29 Sep 2026 14:35:21 +0800 Subject: [PATCH 06/36] fix(aidp): make ResizableTitleProps compatible with React 19 React 19 already declares onResize as a DOM event handler on ThHTMLAttributes, so the custom onResize?: (width: number) => void member of ResizableTitleProps no longer composed with the base interface and broke compilation with TS2430. Omit the inherited member before adding the column-resize callback. Verification: tsc --noEmit reports no error in the AIDP sources; the refactored overview and creation pages were driven end to end in the browser against the AIDP mock (table/menu interactions, create flow submitting chunk_mode, is_exist_graph and an explicit 0 safety guard, then redirecting into the new base's file view). Co-authored-by: ZCode Generated-by: deepseek-flash --- .../ext_components/aidp/components/AidpKnowledgeList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index 3696c25e9c..77c7375215 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -109,7 +109,10 @@ interface AidpKnowledgeListProps { onRetry: () => void; } -interface ResizableTitleProps extends React.ThHTMLAttributes { +interface ResizableTitleProps extends Omit< + React.ThHTMLAttributes, + "onResize" +> { width?: number; onResize?: (width: number) => void; } From 01dad97b88f0b77ee421316ccb20dd3c8ec2e72d Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Tue, 29 Sep 2026 14:49:32 +0800 Subject: [PATCH 07/36] fix(aidp): drop the redundant click guard that violated S1082 The card's more-actions wrapper div carried onClick={stopPropagation} as a third bubble guard on top of the trigger button's own handler and the menu item handler. A visible non-interactive element with a click handler and no keyboard listener fails Sonar rule S1082, which pushed the new reliability rating to B. The inner guards already stop propagation, so the wrapper's handler is removed; the card keeps its own keyboard activation. Verification: browser regression - the dropdown still opens with edit/delete/import and clicking a card still opens its file view; tsc --noEmit is clean for the AIDP sources. Co-authored-by: ZCode Generated-by: deepseek-flash --- .../ext_components/aidp/components/AidpKnowledgeList.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index 77c7375215..4bdf991e00 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -359,12 +359,7 @@ const AidpKnowledgeList: React.FC = ({ {kb.description?.trim() || t("aidpKnowledge.noDescription")}

-
event.stopPropagation()} - > - {renderMoreMenu(kb)} -
+
{renderMoreMenu(kb)}
From 576c194a2e4bf185579eafb99892b38a971b48e1 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Tue, 29 Sep 2026 14:57:30 +0800 Subject: [PATCH 08/36] fix(aidp): use breadcrumbs on the file view and fix the creation breadcrumb key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file view opened with a "back to knowledge base list" button. The user asked for the breadcrumb form already used by the creation page, so the header now reads 知识库 / 上传文件: the base segment navigates back to the overview and clears the kb query parameter, and the knowledge base name and lifecycle tags stay below it. The creation breadcrumb referenced aidpKnowledge.createBreadcrumbKnowledge, a key that exists in neither locale, so the page rendered the raw key; both pages now share the new breadcrumbKnowledgeBase entry. Verification: browser - the file view shows 知识库 / 上传文件 with the base name below, clicking 知识库 returns to the overview, the creation page no longer leaks the raw key, and tsc --noEmit is clean for the AIDP sources. Co-authored-by: ZCode Generated-by: deepseek-flash --- .../aidp/components/AidpCreateKbPage.tsx | 2 +- .../components/AidpKnowledgeConfiguration.tsx | 49 ++++++++++++------- frontend/public/locales/en/common.json | 2 + frontend/public/locales/zh/common.json | 2 + 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx b/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx index d8fe5bba6a..dbadadb3f4 100644 --- a/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx +++ b/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx @@ -497,7 +497,7 @@ const AidpCreateKbPage: React.FC = () => { onClick={goBackToList} className="text-gray-500 hover:text-blue-500 disabled:cursor-not-allowed" > - {t("aidpKnowledge.createBreadcrumbKnowledge")} + {t("aidpKnowledge.breadcrumbKnowledgeBase")} ), }, diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx index 6b08348c16..fcd9aa8fd6 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx @@ -10,8 +10,8 @@ import React, { import { useTranslation } from "react-i18next"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { App, Button, Modal, Tag } from "antd"; -import { ArrowLeftOutlined, InfoCircleFilled } from "@ant-design/icons"; +import { App, Breadcrumb, Button, Modal, Tag } from "antd"; +import { InfoCircleFilled } from "@ant-design/icons"; import { SETUP_PAGE_CONTAINER, STANDARD_CARD } from "@/const/layoutConstants"; import { KB_SEARCH_DEBOUNCE_MS } from "@/const/knowledgeBase"; @@ -555,23 +555,34 @@ const AidpKnowledgeConfiguration: React.FC = () => { style={containerStyle} >
-
- -

- {activeKbItem.kds_name} -

- {isUnavailable && ( - {t("aidpKnowledge.kbUnavailable")} - )} - {activeKbItem.permission === "READ_ONLY" && !isUnavailable && ( - {t("aidpKnowledge.kbReadOnly")} - )} +
+ + {t("aidpKnowledge.breadcrumbKnowledgeBase")} + + ), + }, + { title: t("aidpKnowledge.breadcrumbUploadFiles") }, + ]} + /> +
+

+ {activeKbItem.kds_name} +

+ {isUnavailable && ( + {t("aidpKnowledge.kbUnavailable")} + )} + {activeKbItem.permission === "READ_ONLY" && !isUnavailable && ( + {t("aidpKnowledge.kbReadOnly")} + )} +
Date: Tue, 29 Sep 2026 15:10:57 +0800 Subject: [PATCH 09/36] fix(aidp): redirect locale-less paths so sidebar entries stop crashing Sidebar menu items push bare paths such as /knowledges. The [locale] route then receives 'knowledges' as the locale and the page crashes inside the i18n initializer (I18nProviderWrapper reads resourcesCustom[locale] with the garbage segment), leaving a blank screen on every sidebar entry. Added a Next.js middleware that redirects any page path without a zh/en prefix to its localized counterpart based on the NEXT_LOCALE cookie; API routes, Next.js internals and public assets bypass it. Verification: browser - /knowledges now redirects to /zh/knowledges and renders the overview; /zh/knowledges renders directly; the /locales JSON files and the frontend API proxy bypass the redirect; curl confirms 307 for bare paths and direct 200 for prefixed and asset paths. Co-authored-by: ZCode Generated-by: deepseek-flash --- frontend/middleware.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/middleware.ts diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 0000000000..1c3222aaed --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; + +/** + * Redirect locale-less page paths into the [locale] segment. + * + * The sidebar and several legacy entries push bare paths such as + * "/knowledges". Without a locale prefix the [locale] route receives a + * garbage locale and the page crashes inside the i18n initializer + * (I18nProviderWrapper reads resourcesCustom[locale]). Redirecting here + * sends every bare path to its localized counterpart based on the + * NEXT_LOCALE cookie instead. + */ + +const LOCALES = new Set(["zh", "en"]); + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + const firstSegment = pathname.split("/")[1]; + if (LOCALES.has(firstSegment)) { + return NextResponse.next(); + } + + const locale = + request.cookies.get("NEXT_LOCALE")?.value === "en" ? "en" : "zh"; + const url = request.nextUrl.clone(); + url.pathname = pathname === "/" ? `/${locale}` : `/${locale}${pathname}`; + return NextResponse.redirect(url); +} + +export const config = { + // Skip the API, Next.js internals, public assets (the /locales JSON files + // included) and anything with a file extension. + matcher: ["/((?!api|_next|locales|.*\\..*).*)"], +}; From c0462c53828b60ed0c310c2688f28ffc143d237b Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Tue, 29 Sep 2026 15:37:16 +0800 Subject: [PATCH 10/36] fix(aidp): remove middleware.ts, superseded by the develop proxy.ts The locale redirect already ships in origin/develop's frontend/proxy.ts (Next.js 16's middleware-to-proxy migration). Keeping my middleware.ts makes the build fail with 'Both middleware file and proxy file are detected' as soon as the branch merges with develop. Verification: local NODE_ENV=production npm run build compiles with proxy.ts present and middleware.ts removed. Co-authored-by: ZCode Generated-by: deepseek-flash --- frontend/middleware.ts | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 frontend/middleware.ts diff --git a/frontend/middleware.ts b/frontend/middleware.ts deleted file mode 100644 index 1c3222aaed..0000000000 --- a/frontend/middleware.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -/** - * Redirect locale-less page paths into the [locale] segment. - * - * The sidebar and several legacy entries push bare paths such as - * "/knowledges". Without a locale prefix the [locale] route receives a - * garbage locale and the page crashes inside the i18n initializer - * (I18nProviderWrapper reads resourcesCustom[locale]). Redirecting here - * sends every bare path to its localized counterpart based on the - * NEXT_LOCALE cookie instead. - */ - -const LOCALES = new Set(["zh", "en"]); - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - const firstSegment = pathname.split("/")[1]; - if (LOCALES.has(firstSegment)) { - return NextResponse.next(); - } - - const locale = - request.cookies.get("NEXT_LOCALE")?.value === "en" ? "en" : "zh"; - const url = request.nextUrl.clone(); - url.pathname = pathname === "/" ? `/${locale}` : `/${locale}${pathname}`; - return NextResponse.redirect(url); -} - -export const config = { - // Skip the API, Next.js internals, public assets (the /locales JSON files - // included) and anything with a file extension. - matcher: ["/((?!api|_next|locales|.*\\..*).*)"], -}; From b9e607fc02148bb7c841dd724be0b059a501fc31 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Tue, 29 Sep 2026 16:04:34 +0800 Subject: [PATCH 11/36] fix(navigation): prefix sidebar menu paths with the active locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar menu entries push bare paths (/knowledges and friends). The production proxy redirects those, but the dev server does not run it, so every sidebar entry crashed the i18n initializer and rendered a blank page (resourcesCustom["knowledges"] is undefined). Prefix each pushed path with the active i18n language; paths that already carry a locale pass through unchanged. Verification: browser - clicking 知识库配置 in the sidebar stays on /zh/knowledges and renders the overview; tsc --noEmit is clean. Co-authored-by: ZCode Generated-by: deepseek-flash --- frontend/components/navigation/SideNavigation.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index b361d324f7..4fa6603648 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -183,7 +183,7 @@ const ROUTE_PATHS = ROUTE_CONFIG.map((route) => route.path); * Displays main navigation items for the application based on user's accessible routes */ export function SideNavigation({ collapsed }: SideNavigationProps) { - const { t } = useTranslation("common"); + const { t, i18n } = useTranslation("common"); const { accessibleRoutes } = useAuthorizationContext(); const { isAuthenticated, openAuthPromptModal } = useAuthenticationContext(); const { isSpeedMode, enableAgentWorkbench } = useDeployment(); @@ -225,7 +225,7 @@ export function SideNavigation({ collapsed }: SideNavigationProps) { if (pendingNavigationPath && isAuthenticated) { // Small delay to ensure authentication state is fully updated setTimeout(() => { - router.push(pendingNavigationPath); + router.push(localized(pendingNavigationPath)); setPendingNavigationPath(null); }, 200); } @@ -291,6 +291,13 @@ export function SideNavigation({ collapsed }: SideNavigationProps) { * Create a menu item from route configuration * Pre-check authentication before navigation to avoid unnecessary route changes */ + // Menu paths are stored without the locale prefix. The production proxy + // covers that, but prefixing here keeps every entry working in dev too. + const localized = (path: string) => { + const locale = i18n.language?.split("-")[0] === "en" ? "en" : "zh"; + return /^\/(zh|en)(\/|$)/.test(path) ? path : `/${locale}${path}`; + }; + const createMenuItem = ( route: RouteConfig ): NonNullable[number] => { @@ -309,7 +316,7 @@ export function SideNavigation({ collapsed }: SideNavigationProps) { return; // Prevent navigation } - router.push(navigationPath); + router.push(localized(navigationPath)); }, }; }; @@ -334,7 +341,7 @@ export function SideNavigation({ collapsed }: SideNavigationProps) { openAuthPromptModal(child.path); return; } - router.push(child.path); + router.push(localized(child.path)); }, })), }; From b0fdfe69be7e90294d2e274b361949cd7884341b Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Thu, 8 Oct 2026 11:24:32 +0800 Subject: [PATCH 12/36] fix(aidp): align knowledge overview layout and colors with UCD --- .../aidp/components/AidpKnowledgeGuide.tsx | 79 +++- .../aidp/components/AidpKnowledgeList.tsx | 298 ++++++------- .../AidpKnowledgeVisuals.module.css | 406 ++++++++++++++++++ .../aidp/components/AidpPagination.tsx | 103 +++-- .../components/aidpKnowledgeVisualTheme.ts | 34 ++ frontend/public/locales/en/common.json | 92 +++- frontend/public/locales/zh/common.json | 92 +++- 7 files changed, 908 insertions(+), 196 deletions(-) create mode 100644 frontend/ext_components/aidp/components/AidpKnowledgeVisuals.module.css create mode 100644 frontend/ext_components/aidp/components/aidpKnowledgeVisualTheme.ts diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeGuide.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeGuide.tsx index 52542448b6..29f6ba161a 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeGuide.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeGuide.tsx @@ -3,7 +3,10 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Steps } from "antd"; +import { Button } from "antd"; +import { DownOutlined, UpOutlined } from "@ant-design/icons"; +import { BookOpen, FolderPlus, Link2, Upload, Workflow } from "lucide-react"; +import styles from "./AidpKnowledgeVisuals.module.css"; /** * Collapsible guide shown above the AIDP knowledge base overview. @@ -19,14 +22,14 @@ const AidpKnowledgeGuide: React.FC = () => { return (
-
-

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

+
+

{t("aidpKnowledge.overviewTitle")}

{expanded && ( -
-

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

- +
+
+

+

+

{t("aidpKnowledge.guideIntro")}

+
+
+

+

+
+ {[ + { + title: t("aidpKnowledge.guideStepCreate"), + description: t("aidpKnowledge.guideStepCreateDescription"), + icon: FolderPlus, + }, + { + title: t("aidpKnowledge.guideStepUpload"), + description: t("aidpKnowledge.guideStepUploadDescription"), + icon: Upload, + }, + { + title: t("aidpKnowledge.guideStepAgent"), + description: t("aidpKnowledge.guideStepAgentDescription"), + icon: Link2, + }, + ].map((step, index) => ( +
+

+ {t("aidpKnowledge.guideStepLabel", { + step: index + 1, + title: step.title, + })} +

+

{step.description}

+
+ ))} +
+
)}
diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index 4bdf991e00..bbacde8d5d 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { Button, Checkbox, + ConfigProvider, Dropdown, Input, type MenuProps, @@ -18,15 +19,17 @@ import { import type { ColumnsType } from "antd/es/table"; import { AppstoreOutlined, + ClockCircleOutlined, + DatabaseOutlined, EllipsisOutlined, FileAddOutlined, - PlusOutlined, + FileTextOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TableOutlined, } from "@ant-design/icons"; -import { BookOpen, SquarePen, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import { @@ -42,6 +45,15 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { Can } from "@/components/permission/Can"; import AidpKnowledgeGuide from "./AidpKnowledgeGuide"; import AidpPagination from "./AidpPagination"; +import { aidpKnowledgeVisualTheme } from "./aidpKnowledgeVisualTheme"; +import styles from "./AidpKnowledgeVisuals.module.css"; + +/** Keep the compact card date in the UCD year-month-day format. */ +function formatCardDate(value: unknown): string { + if (formatAidpDateTime(value) === AIDP_UNKNOWN_VALUE) + return AIDP_UNKNOWN_VALUE; + return new Date(value as string).toLocaleDateString("sv-SE"); +} /** Overview presentation modes. Cards are the default. */ export type AidpKbViewMode = "cards" | "table"; @@ -104,7 +116,6 @@ interface AidpKnowledgeListProps { onRefresh: () => void; onCreateNew: () => void; onImport: (kb: AidpKnowledgeBaseItem) => void; - onEdit: (kb: AidpKnowledgeBaseItem) => void; onDelete: (kb: AidpKnowledgeBaseItem) => void; onRetry: () => void; } @@ -187,7 +198,6 @@ const AidpKnowledgeList: React.FC = ({ onRefresh, onCreateNew, onImport, - onEdit, onDelete, onRetry, }) => { @@ -292,11 +302,8 @@ const AidpKnowledgeList: React.FC = ({ label: t("aidpKnowledge.importFile"), }, { - key: "edit", - icon: , - label: t("common.edit"), + type: "divider" as const, }, - { type: "divider" as const }, { key: "delete", danger: true, @@ -307,7 +314,6 @@ const AidpKnowledgeList: React.FC = ({ onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (key === "import") onImport(kb); - if (key === "edit") onEdit(kb); if (key === "delete") onDelete(kb); }, }); @@ -318,6 +324,7 @@ const AidpKnowledgeList: React.FC = ({ - - onViewModeChange(value as AidpKbViewMode)} - options={[ - { - value: "cards", - title: t("aidpKnowledge.viewCards"), - label: , - }, - { - value: "table", - title: t("aidpKnowledge.viewTable"), - label: , - }, - ]} - /> - {viewMode === "table" && ( - +
+
+
-
-
{renderBody()}
- {kbs.length > 0 && ( -
- + {t("aidpKnowledge.createKb")} + + 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 && ( +
+ +
+ )} + +
+ ); }; 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..d4712f1744 --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpKnowledgeVisuals.module.css @@ -0,0 +1,406 @@ +.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); +} + +.tableName:hover { + color: #0052a8; +} + +.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 index 559cc4e75e..7e4fdad88d 100644 --- a/frontend/ext_components/aidp/components/AidpPagination.tsx +++ b/frontend/ext_components/aidp/components/AidpPagination.tsx @@ -1,9 +1,13 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Pagination } from "antd"; +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; @@ -33,7 +37,9 @@ const AidpPagination: React.FC = ({ onPageChange, onPageSizeChange, }) => { - const { t } = useTranslation(); + 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. @@ -44,27 +50,76 @@ const AidpPagination: React.FC = ({ : currentPage * pageSize; return ( - { - if (onPageSizeChange && nextPageSize !== pageSize) { - onPageSizeChange(nextPageSize); - return; - } - onPageChange(page); - }} - pageSizeOptions={onPageSizeChange ? [10, 20, 50, 100] : undefined} - showSizeChanger={Boolean(onPageSizeChange)} - simple={!totalReliable} - showTotal={ - totalReliable - ? (value) => t("aidpKnowledge.showTotal", { count: value }) - : undefined - } - size="small" - /> + +
+ + {totalReliable + ? t("aidpKnowledge.showTotal", { count: total }) + : t("aidpKnowledge.showLoadedCount", { + count: Math.max(0, total), + })} + +
+ {onPageSizeChange && ( + + setJumpPage(event.target.value.replace(/\D/g, "")) + } + /> + + + )} +
+
+
); }; diff --git a/frontend/ext_components/aidp/components/aidpKnowledgeVisualTheme.ts b/frontend/ext_components/aidp/components/aidpKnowledgeVisualTheme.ts new file mode 100644 index 0000000000..1f9c8c5ec4 --- /dev/null +++ b/frontend/ext_components/aidp/components/aidpKnowledgeVisualTheme.ts @@ -0,0 +1,34 @@ +import type { ThemeConfig } from "antd"; + +/** UCD colors scoped to the AIDP overview and its shared pagination. */ +export const aidpKnowledgeVisualTheme: ThemeConfig = { + token: { + colorPrimary: "#0067d1", + colorText: "#191919", + colorTextSecondary: "#777777", + colorTextPlaceholder: "#aeaeae", + colorBorder: "#c9c9c9", + borderRadius: 4, + controlHeight: 32, + fontSize: 14, + }, + components: { + Segmented: { + trackBg: "rgba(25, 25, 25, 0.05)", + trackPadding: 2, + itemSelectedBg: "#0067d1", + itemSelectedColor: "#ffffff", + itemColor: "#777777", + }, + Pagination: { + itemSize: 32, + itemActiveBg: "#e6f2fd", + itemActiveColor: "#0067d1", + }, + Table: { + headerBg: "#f5f5f5", + headerColor: "#191919", + borderColor: "#ededed", + }, + }, +}; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index c8b511c327..f1d5d6be8b 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -4096,7 +4096,7 @@ "aidpKnowledge.kbListTitle": "Knowledge Bases", "aidpKnowledge.kbReadOnly": "Read-only", "aidpKnowledge.kbUnavailable": "Unavailable", - "aidpKnowledge.createKb": "Create Knowledge Base", + "aidpKnowledge.createKb": "New knowledge base", "aidpKnowledge.refresh": "Refresh", "aidpKnowledge.searchPlaceholder": "Search knowledge bases", "aidpKnowledge.searchEmpty": "No matching knowledge bases", @@ -4200,6 +4200,10 @@ "aidpKnowledge.createAccessGroupsRequired": "Please select at least one user group, or switch to private", "aidpKnowledge.invalidFileType": "{{count}} file(s) with unsupported format have been filtered. Only Text (txt/json/markdown), Web (html), Docs (pdf/docx/doc/ppt/pptx), Sheets (xlsx/xls/csv), Images (png/jpeg/jpg/bmp) are supported", "aidpKnowledge.showTotal": "Total {{count}} items", + "aidpKnowledge.pageSize": "Items per page", + "aidpKnowledge.pageSizeOption": "{{size}} / page", + "aidpKnowledge.pageNumber": "Page to jump to", + "aidpKnowledge.jumpPage": "Go", "project.config": "System config", "project.config.title": "System name", "project.config.language.en": "Please enter the English configuration.", @@ -4693,10 +4697,17 @@ "aidpKnowledge.deleteDocPartial": "Deletion finished: {{success}} succeeded, {{failed}} failed", "aidpKnowledge.guideCollapse": "Collapse tutorial", "aidpKnowledge.guideExpand": "Usage guide", - "aidpKnowledge.guideIntro": "Knowledge bases store the material your agents retrieve: create a knowledge base, upload files, then connect it to an agent.", + "aidpKnowledge.guideWhatIs": "What is a knowledge base?", + "aidpKnowledge.guideFlowTitle": "Configuration flow", + "aidpKnowledge.guideStepLabel": "Step {{step}}: {{title}}", + "aidpKnowledge.guideIntro": "A knowledge base organizes documents into searchable knowledge, giving agents business material and domain context for more accurate, evidence-based answers.", "aidpKnowledge.guideStepAgent": "Configure the knowledge base in an agent", + "aidpKnowledge.guideStepAgentDescription": "Choose which knowledge base the agent should use to support its answers.", "aidpKnowledge.guideStepCreate": "Create a knowledge base", + "aidpKnowledge.guideStepCreateDescription": "Set its name, processing options and access permissions.", "aidpKnowledge.guideStepUpload": "Upload files", + "aidpKnowledge.guideStepUploadDescription": "Import source files and let AIDP parse and index them.", + "aidpKnowledge.showLoadedCount": "Loaded {{count}} items", "aidpKnowledge.guideTitle": "Getting started", "aidpKnowledge.kbTypeEnterprise": "Enterprise", "aidpKnowledge.kbTypePersonal": "Personal", @@ -5412,5 +5423,80 @@ "chat.clarification.hint": "Submit your answers as a new message in this conversation.", "chat.clarification.saving": "Saving these questions. Submit when this response finishes.", "chat.clarification.history": "Read-only questions. Answers appear in the following user message.", - "chat.clarification.unavailable": "This card cannot be submitted yet. Wait for the response to finish." + "chat.clarification.unavailable": "This card cannot be submitted yet. Wait for the response to finish.", + "aidpKnowledge.detailBreadcrumb": "Knowledge base details", + "aidpKnowledge.detailEditNameDescription": "Edit name and description", + "aidpKnowledge.detailFilesCount": "Files {{count}}", + "aidpKnowledge.detailCreator": "Created by", + "aidpKnowledge.detailGroups": "Groups {{count}}", + "aidpKnowledge.detailTabFiles": "Files", + "aidpKnowledge.detailTabTasks": "Upload tasks", + "aidpKnowledge.detailTabInformation": "Details", + "aidpKnowledge.detailFileSearch": "Enter a file name", + "aidpKnowledge.detailFilesEmpty": "No ingested files", + "aidpKnowledge.detailTasksEmpty": "No upload tasks in the last 30 days", + "aidpKnowledge.detailLoadFailed": "Failed to load knowledge base details", + "aidpKnowledge.detailFilesLoadFailed": "Failed to load files", + "aidpKnowledge.detailTasksLoadFailed": "Failed to load upload tasks", + "aidpKnowledge.detailFileName": "Name", + "aidpKnowledge.detailFileType": "Type", + "aidpKnowledge.detailFileSize": "Size", + "aidpKnowledge.detailImportMode": "Import method", + "aidpKnowledge.detailFirstUploadTime": "First uploaded", + "aidpKnowledge.detailUpdateTime": "Updated", + "aidpKnowledge.detailOperation": "Actions", + "aidpKnowledge.detailTaskStatus": "Status", + "aidpKnowledge.taskRetention": "Only upload tasks from the last {{days}} days are shown", + "aidpKnowledge.taskStatTotal": "Total {{count}}", + "aidpKnowledge.taskStatExtracting": "Extracting {{count}}", + "aidpKnowledge.taskStatFailed": "Ingestion failed {{count}}", + "aidpKnowledge.taskStatSuccess": "Succeeded {{count}}", + "aidpKnowledge.taskAll": "All", + "aidpKnowledge.taskSuccess": "Succeeded", + "aidpKnowledge.taskExtracting": "Extracting", + "aidpKnowledge.taskVectorFailed": "Vector ingestion failed", + "aidpKnowledge.taskQueued": "Queued", + "aidpKnowledge.taskGraphFailed": "Graph ingestion failed", + "aidpKnowledge.retryTask": "Retry extraction", + "aidpKnowledge.failureDetails": "Failure details", + "aidpKnowledge.retryTaskSuccess": "Retry submitted", + "aidpKnowledge.retryTaskFailed": "Retry failed", + "aidpKnowledge.detailTaskUpdatedAt": "Task updated", + "aidpKnowledge.detailErrorCode": "Error code", + "aidpKnowledge.detailFailureReason": "Reason", + "aidpKnowledge.detailConfiguration": "Configuration", + "aidpKnowledge.detailKbId": "Knowledge base ID", + "aidpKnowledge.detailCreatedAt": "Created", + "aidpKnowledge.detailUpdatedAt": "Updated", + "aidpKnowledge.detailChunkMode": "Chunking method", + "aidpKnowledge.detailEmbeddingModel": "Embedding model", + "aidpKnowledge.detailVlmState": "VLM status", + "aidpKnowledge.detailVlmModel": "VLM model", + "aidpKnowledge.detailRerankModel": "Rerank model", + "aidpKnowledge.detailSimilarity": "Similarity threshold", + "aidpKnowledge.detailVectorTopK": "Vector retrieval TOP K", + "aidpKnowledge.detailSafetyGuard": "Safety guard", + "aidpKnowledge.chunkModeSmart": "Smart chunking", + "aidpKnowledge.chunkModeLegal": "Legal clauses", + "aidpKnowledge.detailGraph": "Knowledge graph", + "aidpKnowledge.detailGraphDisabled": "Knowledge graph is disabled", + "aidpKnowledge.detailGraphModel": "LLM model", + "aidpKnowledge.detailGraphHops": "Subgraph expansion hops", + "aidpKnowledge.detailGraphDomain": "Domain", + "aidpKnowledge.detailGraphTopK": "Graph retrieval TOP K", + "aidpKnowledge.detailGraphThinking": "Enable model thinking during graph construction", + "aidpKnowledge.detailGraphSynonym": "Merge detected synonyms", + "aidpKnowledge.detailGraphDisambiguation": "Enable semantic disambiguation", + "aidpKnowledge.detailGraphPromptLanguage": "Graph prompt language", + "aidpKnowledge.detailGraphPrompt": "Graph prompt template", + "aidpKnowledge.detailPermissions": "Permissions and groups", + "aidpKnowledge.detailScope": "Visibility", + "aidpKnowledge.detailGroupPermission": "Group permission", + "aidpKnowledge.detailAllowedGroups": "Allowed groups", + "aidpKnowledge.detailOwnerOnly": "Visible only to the owner", + "aidpKnowledge.detailEditChunkMode": "Edit chunking method", + "aidpKnowledge.detailEditTopK": "Edit vector retrieval TOP K", + "aidpKnowledge.detailSettingSaved": "Configuration updated", + "aidpKnowledge.detailSettingSaveFailed": "Failed to update configuration", + "aidpKnowledge.deleteDocTitle": "Delete this file?" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index aac76a3fca..31314e11bd 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -3806,7 +3806,7 @@ "aidpKnowledge.kbListTitle": "知识库列表", "aidpKnowledge.kbReadOnly": "只读", "aidpKnowledge.kbUnavailable": "不可用", - "aidpKnowledge.createKb": "创建知识库", + "aidpKnowledge.createKb": "新建知识库", "aidpKnowledge.refresh": "刷新", "aidpKnowledge.searchPlaceholder": "搜索知识库名称", "aidpKnowledge.searchEmpty": "没有匹配的知识库", @@ -3910,6 +3910,10 @@ "aidpKnowledge.createAccessGroupsRequired": "请选择至少一个用户组,或切换为私有", "aidpKnowledge.invalidFileType": "{{count}} 个文件格式不支持,已自动过滤。仅支持文本(txt/json/markdown)、网页(html)、文档(pdf/docx/doc/ppt/pptx)、表格(xlsx/xls/csv)、图片(png/jpeg/jpg/bmp)格式", "aidpKnowledge.showTotal": "共 {{count}} 条", + "aidpKnowledge.pageSize": "每页条数", + "aidpKnowledge.pageSizeOption": "{{size}} 条/页", + "aidpKnowledge.pageNumber": "跳转页码", + "aidpKnowledge.jumpPage": "跳转", "quota.title": "配额管理", "quota.hardLimit": "硬配额", "quota.unlimited": "无限制", @@ -4704,10 +4708,17 @@ "aidpKnowledge.deleteDocPartial": "删除完成:成功 {{success}} 个,失败 {{failed}} 个", "aidpKnowledge.guideCollapse": "收起教程", "aidpKnowledge.guideExpand": "使用指南", - "aidpKnowledge.guideIntro": "知识库用于存放供智能体检索的资料:先创建知识库,上传文件完成入库,再到智能体中配置使用。", + "aidpKnowledge.guideWhatIs": "什么是知识库?", + "aidpKnowledge.guideFlowTitle": "配置流程", + "aidpKnowledge.guideStepLabel": "第{{step}}步:{{title}}", + "aidpKnowledge.guideIntro": "知识库将文档整理为可检索的知识,为智能体提供业务资料和专业背景,帮助回答更准确、更有依据。", "aidpKnowledge.guideStepAgent": "智能体配置知识库", + "aidpKnowledge.guideStepAgentDescription": "为智能体选择需要使用的知识库,让回答有对应资料支撑。", "aidpKnowledge.guideStepCreate": "创建知识库", + "aidpKnowledge.guideStepCreateDescription": "设置知识库名称、处理参数和访问权限。", "aidpKnowledge.guideStepUpload": "上传文件", + "aidpKnowledge.guideStepUploadDescription": "导入资料,等待文件解析并完成索引。", + "aidpKnowledge.showLoadedCount": "已加载 {{count}} 条", "aidpKnowledge.guideTitle": "知识库使用引导", "aidpKnowledge.kbTypeEnterprise": "企业", "aidpKnowledge.kbTypePersonal": "个人", @@ -5426,5 +5437,80 @@ "chat.clarification.hint": "填写后提交,将作为同一会话的新消息继续。", "chat.clarification.saving": "正在保存本轮问题,完成后即可提交。", "chat.clarification.history": "历史问题只读,回答可在后续用户消息中查看。", - "chat.clarification.unavailable": "当前卡片暂不可提交,请等待本轮完成。" + "chat.clarification.unavailable": "当前卡片暂不可提交,请等待本轮完成。", + "aidpKnowledge.detailBreadcrumb": "知识库详情", + "aidpKnowledge.detailEditNameDescription": "编辑名称和描述", + "aidpKnowledge.detailFilesCount": "文件 {{count}}", + "aidpKnowledge.detailCreator": "创建人", + "aidpKnowledge.detailGroups": "用户组 {{count}}", + "aidpKnowledge.detailTabFiles": "文件", + "aidpKnowledge.detailTabTasks": "上传任务", + "aidpKnowledge.detailTabInformation": "详细信息", + "aidpKnowledge.detailFileSearch": "请输入文件名称", + "aidpKnowledge.detailFilesEmpty": "暂无已入库文件", + "aidpKnowledge.detailTasksEmpty": "近 30 天没有上传任务", + "aidpKnowledge.detailLoadFailed": "读取知识库详情失败", + "aidpKnowledge.detailFilesLoadFailed": "读取文件列表失败", + "aidpKnowledge.detailTasksLoadFailed": "读取上传任务失败", + "aidpKnowledge.detailFileName": "名称", + "aidpKnowledge.detailFileType": "类型", + "aidpKnowledge.detailFileSize": "大小", + "aidpKnowledge.detailImportMode": "导入方式", + "aidpKnowledge.detailFirstUploadTime": "首次上传时间", + "aidpKnowledge.detailUpdateTime": "更新时间", + "aidpKnowledge.detailOperation": "操作", + "aidpKnowledge.detailTaskStatus": "状态", + "aidpKnowledge.taskRetention": "仅显示近 {{days}} 天的上传任务记录", + "aidpKnowledge.taskStatTotal": "总数 {{count}}", + "aidpKnowledge.taskStatExtracting": "提取中 {{count}}", + "aidpKnowledge.taskStatFailed": "入库失败 {{count}}", + "aidpKnowledge.taskStatSuccess": "成功 {{count}}", + "aidpKnowledge.taskAll": "全部", + "aidpKnowledge.taskSuccess": "成功", + "aidpKnowledge.taskExtracting": "提取中", + "aidpKnowledge.taskVectorFailed": "向量入库失败", + "aidpKnowledge.taskQueued": "排队中", + "aidpKnowledge.taskGraphFailed": "图谱入库失败", + "aidpKnowledge.retryTask": "重新提取", + "aidpKnowledge.failureDetails": "失败详情", + "aidpKnowledge.retryTaskSuccess": "已提交重新提取", + "aidpKnowledge.retryTaskFailed": "重新提取失败", + "aidpKnowledge.detailTaskUpdatedAt": "任务更新时间", + "aidpKnowledge.detailErrorCode": "错误码", + "aidpKnowledge.detailFailureReason": "原因", + "aidpKnowledge.detailConfiguration": "配置参数", + "aidpKnowledge.detailKbId": "知识库 ID", + "aidpKnowledge.detailCreatedAt": "创建时间", + "aidpKnowledge.detailUpdatedAt": "更新时间", + "aidpKnowledge.detailChunkMode": "切片方式", + "aidpKnowledge.detailEmbeddingModel": "向量化模型", + "aidpKnowledge.detailVlmState": "VLM 模型状态", + "aidpKnowledge.detailVlmModel": "VLM 模型", + "aidpKnowledge.detailRerankModel": "Rerank 模型", + "aidpKnowledge.detailSimilarity": "相似度阈值", + "aidpKnowledge.detailVectorTopK": "向量检索 TOP K 值", + "aidpKnowledge.detailSafetyGuard": "安全护栏", + "aidpKnowledge.chunkModeSmart": "智能分片", + "aidpKnowledge.chunkModeLegal": "法律条文", + "aidpKnowledge.detailGraph": "知识图谱", + "aidpKnowledge.detailGraphDisabled": "未开启知识图谱", + "aidpKnowledge.detailGraphModel": "LLM 模型", + "aidpKnowledge.detailGraphHops": "子图扩展跳数", + "aidpKnowledge.detailGraphDomain": "领域", + "aidpKnowledge.detailGraphTopK": "向量检索 TOP K 值", + "aidpKnowledge.detailGraphThinking": "构图过程开启大模型思考", + "aidpKnowledge.detailGraphSynonym": "开启同义词检测合并", + "aidpKnowledge.detailGraphDisambiguation": "开启语义消歧", + "aidpKnowledge.detailGraphPromptLanguage": "构图过程提示词语言", + "aidpKnowledge.detailGraphPrompt": "构图过程提示词模板", + "aidpKnowledge.detailPermissions": "权限与用户组", + "aidpKnowledge.detailScope": "可见范围", + "aidpKnowledge.detailGroupPermission": "组内权限", + "aidpKnowledge.detailAllowedGroups": "可访问用户组", + "aidpKnowledge.detailOwnerOnly": "仅创建者可见", + "aidpKnowledge.detailEditChunkMode": "编辑切片方式", + "aidpKnowledge.detailEditTopK": "编辑向量检索 TOP K 值", + "aidpKnowledge.detailSettingSaved": "配置已更新", + "aidpKnowledge.detailSettingSaveFailed": "配置更新失败", + "aidpKnowledge.deleteDocTitle": "确定删除此文件吗?" } From d14fe5e2d9e62ee1bb784e8f3cd16a7740a6261f Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Thu, 8 Oct 2026 11:42:10 +0800 Subject: [PATCH 13/36] fix(aidp): match UCD table density and preserve resized column widths --- .../aidp/components/AidpKnowledgeList.tsx | 117 ++++++++++++------ .../AidpKnowledgeVisuals.module.css | 104 ++++++++++++++++ .../components/aidpKnowledgeVisualTheme.ts | 4 +- 3 files changed, 187 insertions(+), 38 deletions(-) diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index bbacde8d5d..74496e3481 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -125,11 +125,13 @@ interface ResizableTitleProps extends Omit< "onResize" > { width?: number; - onResize?: (width: number) => void; + minWidth?: number; + onResize?: (width: number, renderedWidths: Record) => void; } const ResizableTitle: React.FC = ({ width, + minWidth = 88, onResize, children, ...rest @@ -141,9 +143,22 @@ const ResizableTitle: React.FC = ({ event.preventDefault(); event.stopPropagation(); const startX = event.clientX; - const startWidth = width; + const startWidth = + event.currentTarget.parentElement?.getBoundingClientRect().width ?? width; + const renderedWidths: Record = {}; + event.currentTarget + .closest("table") + ?.querySelectorAll("th[data-column-key]") + .forEach((cell) => { + if (cell.dataset.columnKey) + renderedWidths[cell.dataset.columnKey] = + cell.getBoundingClientRect().width; + }); const handleMove = (moveEvent: PointerEvent) => { - onResize(Math.max(88, startWidth + moveEvent.clientX - startX)); + onResize( + Math.max(minWidth, startWidth + moveEvent.clientX - startX), + renderedWidths + ); }; const handleUp = () => { window.removeEventListener("pointermove", handleMove); @@ -168,7 +183,7 @@ const ResizableTitle: React.FC = ({ role="separator" aria-orientation="vertical" aria-label={t("aidpKnowledge.resizeColumn")} - className="absolute right-0 top-0 z-10 h-full w-2 cursor-col-resize touch-none select-none after:absolute after:right-[3px] after:top-1/4 after:h-1/2 after:w-px after:bg-gray-300 hover:after:bg-blue-500" + className={styles.columnResizeHandle} onPointerDown={handlePointerDown} onClick={(event) => event.stopPropagation()} /> @@ -242,7 +257,7 @@ const AidpKnowledgeList: React.FC = ({ const groupNames = isShared ? getGroupNames(kb.group_ids) : []; return ( -
+
{isPrivate ? ( {t("aidpKnowledge.scopePrivate")} @@ -272,7 +287,7 @@ const AidpKnowledgeList: React.FC = ({ {groupNames.length > 0 && ( - + {t("aidpKnowledge.scopeGroupCount", { count: groupNames.length, })} @@ -412,31 +427,50 @@ const AidpKnowledgeList: React.FC = ({
); - const handleResize = (key: AidpKbColumnKey, width: number) => { - setColumnWidths((current) => ({ ...current, [key]: width })); + const handleResize = ( + key: AidpKbColumnKey, + width: number, + renderedWidths: Record + ) => { + // Freeze the rendered widths before resizing so the browser cannot + // redistribute spare space across the other columns. + setColumnWidths((current) => ({ + ...current, + ...renderedWidths, + [key]: width, + })); }; const renderTable = () => { const baseWidths: Record = { - name: 220, - type: 210, - description: 280, - documents: 100, - capacity: 120, - creator: 140, - created_at: 140, - actions: 220, + name: 280, + type: 260, + description: 418, + documents: 120, + capacity: 160, + creator: 100, + created_at: 160, + actions: 144, }; const makeColumn = ( key: AidpKbColumnKey, column: ColumnsType[number] ) => ({ ...column, + ellipsis: key !== "actions", width: columnWidths[key] ?? baseWidths[key], onHeaderCell: () => ({ width: columnWidths[key] ?? baseWidths[key], - onResize: (width: number) => handleResize(key, width), + minWidth: + key === "actions" + ? 144 + : key === "name" || key === "type" + ? 120 + : 88, + "data-column-key": key, + onResize: (width: number, renderedWidths: Record) => + handleResize(key, width, renderedWidths), }) as ResizableTitleProps, }); const allColumns: Record< @@ -451,7 +485,7 @@ const AidpKnowledgeList: React.FC = ({ render: (_value, kb) => ( )}
- + diff --git a/frontend/ext_components/aidp/components/AidpCreateKbSections.module.css b/frontend/ext_components/aidp/components/AidpCreateKbSections.module.css new file mode 100644 index 0000000000..0b3b9a9176 --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpCreateKbSections.module.css @@ -0,0 +1,304 @@ +.createForm { + width: 100%; + color: #191919; +} + +.infoBanner:global(.ant-alert) { + box-sizing: border-box; + min-height: 46px; + padding: 12px 16px; + border: 0; + border-radius: 8px; + background: #eef3fe; + color: #191919; + font-size: 14px; + line-height: 22px; +} + +.infoBanner :global(.ant-alert-content) { + min-width: 0; +} + +.infoBanner :global(.ant-alert-message) { + color: inherit; + font-size: inherit; + line-height: inherit; +} + +.infoBanner :global(.ant-alert-icon) { + margin-inline-end: 16px; + color: #2067d1; +} + +.createIntro { + margin-bottom: 24px; +} + +.formSection { + padding: 20px 0; + border-bottom: 1px solid #e5e7eb; +} + +.basicSection { + padding-top: 8px; + padding-bottom: 20px; +} + +.lastSection { + border-bottom: 0; +} + +.sectionHeading { + display: flex; + min-height: 36px; + align-items: center; + justify-content: flex-start; + gap: 8px; + margin-bottom: 16px; +} + +.sectionHeadingTitle { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.sectionAction { + display: flex; + flex: none; + align-items: center; +} + +.defaultsHint { + margin: -8px 0 16px; + color: #8c8c8c; + font-size: 12px; + line-height: 20px; +} + +.formLabel { + display: inline-flex; + align-items: center; + gap: 6px; + line-height: 20px; +} + +.createForm :global(.anticon-question-circle) { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + transform: translateY(-1px); +} + +.createForm :global(.ant-form-item) { + margin-bottom: 24px; +} + +.createForm :global(.ant-form-item-label) { + padding-bottom: 4px; +} + +.createForm :global(.ant-form-item-label > label) { + min-height: 40px; + align-items: flex-end; + color: #191919; + font-size: 14px; + line-height: 20px; +} + +.createForm :global(.ant-form-item-control-input) { + min-height: 32px; +} + +.formGrid { + display: grid; + grid-template-columns: minmax(0, 1fr); + column-gap: 24px; +} + +.fullSpan { + grid-column: 1 / -1; +} + +.firstColumn { + grid-column: 1; +} + +.desktopSpacer { + display: none; +} + +.chunkModeSection { + margin-bottom: 16px; +} + +.chunkChoices { + container-type: inline-size; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.chunkOption:global(.ant-radio-wrapper) { + box-sizing: border-box; + display: flex; + width: 100%; + min-height: 80px; + align-items: flex-start; + margin: 0 !important; + padding: 16px !important; + border: 1px solid #d9d9d9; + border-radius: 8px; + color: #191919; + white-space: normal; +} + +.chunkOption:global(.ant-radio-wrapper):hover { + border-color: #1677ff; +} + +.chunkOption:global(.ant-radio-wrapper) > :global(.ant-radio) { + visibility: hidden; + width: 0; + height: 0; + margin: 0; +} + +.chunkOption:global(.ant-radio-wrapper) > :global(.ant-radio) + span { + padding-inline: 0; +} + +.chunkOption:global(.ant-radio-wrapper):focus-within { + outline: 2px solid #1677ff; + outline-offset: 2px; +} + +.chunkOptionSelected:global(.ant-radio-wrapper) { + border-color: #1677ff; + background: #f0f5ff; +} + +.chunkOptionUnselected:global(.ant-radio-wrapper) { + background: #fff; +} + +.chunkOptionContent { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; + white-space: normal; +} + +.chunkOptionContent > span:first-child { + color: #191919; + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +.chunkOptionHint { + color: #595959; + font-size: 12px; + line-height: 20px; + text-wrap: balance; +} + +.sliderValueGroup { + display: flex; + width: 108px; + flex: none; + align-items: center; + gap: 8px; +} + +.sliderTrackGroup { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.sliderTrackGroup :global(.ant-slider) { + margin-bottom: 2px; +} + +.sliderMarks { + display: flex; + justify-content: space-between; + padding: 0 6px; + color: #8c8c8c; + font-size: 12px; + line-height: 20px; +} + +.sliderValueInput { + width: 76px; +} + +.sliderValueInput:global(.ant-input-number) { + width: 76px; +} + +.sliderUnit { + width: 24px; + flex: none; + color: #595959; + font-size: 12px; + text-align: right; +} + +.vlmLabelRow { + display: flex; + min-height: 20px; + align-items: center; + gap: 8px; + line-height: 20px; +} + +.createFooter { + box-sizing: border-box; + display: flex; + min-height: 80px; + flex: none; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 20px 32px 20px 13px; + border-top: 1px solid #e5e7eb; + background: #fff; +} + +@container (max-width: 480px) { + .chunkChoices { + grid-template-columns: minmax(0, 1fr); + } + + .chunkOption:global(.ant-radio-wrapper) { + padding: 16px 12px !important; + } +} + +@media (min-width: 768px) { + .formGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 32px; + } + + .desktopSpacer { + display: block; + } +} + +@media (max-width: 767px) { + .sectionHeading { + flex-wrap: wrap; + } + + .createFooter { + min-height: 72px; + padding: 16px; + } +} diff --git a/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx b/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx index 2f19960045..e51543e781 100644 --- a/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx +++ b/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import type { TFunction } from "i18next"; import { + Alert, Form, Input, InputNumber, @@ -16,12 +17,14 @@ import { DownOutlined, QuestionCircleOutlined } from "@ant-design/icons"; import { AIDP_KNOWLEDGE_BASE_NAME_PATTERN } from "@/const/knowledgeBase"; import { AidpKnowledgeBasePermissionFields } from "./AidpKnowledgeBaseModalParts"; +import styles from "./AidpCreateKbSections.module.css"; import type { AidpGroupOption } from "../hooks/useAidpGroupOptions"; const GRAPH_PROMPT_MAX_BYTES = 2048; const CHUNK_TOKEN_MIN = 256; const CHUNK_TOKEN_MAX = 4096; -const OVERLAP_PERCENT_MAX = 50; +const OVERLAP_PERCENT_MIN = 1; +const OVERLAP_PERCENT_MAX = 40; const byteLength = (value: string): number => typeof TextEncoder === "undefined" @@ -68,23 +71,29 @@ const SliderNumberField: React.FC = ({ const safeValue = typeof value === "number" ? value : min; return (
-
- `${sliderValue}${unit || ""}`, - }} - onChange={(nextValue) => onChange?.(nextValue)} - aria-label={ariaLabel} - /> -
+
+
+ `${sliderValue}${unit || ""}`, + }} + onChange={(nextValue) => onChange?.(nextValue)} + aria-label={ariaLabel} + /> +
+ {marks[min]} + {marks[max]} +
+
+
= ({ value={value} onChange={onChange} /> - {unit && ( - {unit} - )} + {unit || ""}
-
- {marks[min]} - {marks[max]} -
); }; @@ -135,6 +138,9 @@ const AidpCreateKbSections: React.FC = ({ useEffect(() => { if (graphEnabled) { + // The graph toggle lives in the Ant Design form, so sync its enabled state + // to the independent accordion UI when a form value is loaded. + // eslint-disable-next-line react-hooks/set-state-in-effect setExpanded((current) => ({ ...current, graph: true })); } }, [graphEnabled]); @@ -143,7 +149,7 @@ const AidpCreateKbSections: React.FC = ({ setExpanded((current) => ({ ...current, [section]: !current[section] })); const label = (text: string, hint?: string) => ( - + {text} {hint && ( @@ -168,11 +174,11 @@ const AidpCreateKbSections: React.FC = ({ const panelId = `aidp-create-${section}-panel`; return ( -
-
+
+
- {action} + {action &&
{action}
}
); }; const sectionHint = ( -

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

+ ); return ( -
-
-

+ + + +
+

{t("aidpKnowledge.createSectionBasic")}

-
+
= ({ = ({
-
-
-
-

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

- - - -
- - - -
-
- -
+
{sectionHeading( "graph", t("aidpKnowledge.createSectionGraph"), @@ -301,13 +298,13 @@ const AidpCreateKbSections: React.FC = ({ hidden={!expanded.graph || !graphEnabled} > {graphEnabled && sectionHint} -
+
{graphEnabled && ( <> = ({ = ({ = ({ }))} /> -
-
- - - - -
+ + + + + } + > } + 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%" }} + /> + + value={taskStatus} + options={taskStatusOptions} + onChange={(value) => setTaskStatus(value)} + style={{ width: 136, maxWidth: "100%" }} + aria-label={t("aidpKnowledge.detailTaskStatus")} + /> +
+ + + {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} + pagination={false} + scroll={{ x: 885 }} + locale={{ + emptyText: ( + + ), + }} + size="middle" + /> +
+
+ + void fetchTasks(page, taskKeyword, taskStatus, taskPageSize) + } + onPageSizeChange={(size) => setTaskPageSize(size)} + /> +
+
+ )} + + {activeTab === "information" && + (loadingDetail ? ( + + ) : ( + setEditingMetadata(true)} + onEditSetting={(setting) => { + settingForm.setFieldsValue({ + value: + setting === "chunk_mode" + ? (detail.chunk_mode ?? 0) + : (detail.topk ?? 10), + }); + setSettingModal(setting); + }} + /> + ))} + + setEditingMetadata(false)} + onSuccess={(updated) => { + setEditingMetadata(false); + setDetail((current) => ({ ...current, ...updated })); + onUpdated(updated); + void refreshDetail(); + }} + /> + + setSettingModal(null)} + onOk={() => void saveSetting()} + confirmLoading={savingSetting} + destroyOnClose + > + {settingModal && ( + + + {settingModal === "chunk_mode" ? ( + , + "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/lib/aidpKnowledgeColumnWidths.ts b/frontend/lib/aidpKnowledgeColumnWidths.ts new file mode 100644 index 0000000000..8a2560e7d7 --- /dev/null +++ b/frontend/lib/aidpKnowledgeColumnWidths.ts @@ -0,0 +1,48 @@ +export const AIDP_MIN_COLUMN_WIDTH = 64; + +/** Distribute available space proportionally, freezing columns at the minimum. */ +export function fitAidpColumnWidths( + keys: readonly K[], + weights: Record, + containerWidth: number +): Record { + const widths = {} as Record; + let remaining = [...keys]; + let space = Math.max(containerWidth, keys.length * AIDP_MIN_COLUMN_WIDTH); + while (remaining.length) { + const totalWeight = remaining.reduce((sum, key) => sum + weights[key], 0); + const constrained = remaining.filter( + (key) => (space * weights[key]) / totalWeight < AIDP_MIN_COLUMN_WIDTH + ); + if (!constrained.length) { + remaining.forEach((key) => { + widths[key] = (space * weights[key]) / totalWeight; + }); + break; + } + constrained.forEach((key) => { + widths[key] = AIDP_MIN_COLUMN_WIDTH; + space -= AIDP_MIN_COLUMN_WIDTH; + }); + remaining = remaining.filter((key) => !constrained.includes(key)); + } + return widths; +} + +/** Move one boundary without changing the total width or unrelated columns. */ +export function resizeAidpColumnPair( + widths: Record, + leftKey: string, + rightKey: string, + delta: number +): Record { + const movement = Math.max( + AIDP_MIN_COLUMN_WIDTH - widths[leftKey], + Math.min(delta, widths[rightKey] - AIDP_MIN_COLUMN_WIDTH) + ); + return { + ...widths, + [leftKey]: widths[leftKey] + movement, + [rightKey]: widths[rightKey] - movement, + }; +} diff --git a/frontend/tests/aidpKnowledgeColumnWidths.test.ts b/frontend/tests/aidpKnowledgeColumnWidths.test.ts new file mode 100644 index 0000000000..403697e81c --- /dev/null +++ b/frontend/tests/aidpKnowledgeColumnWidths.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + fitAidpColumnWidths, + resizeAidpColumnPair, + // @ts-expect-error -- Node requires the extension for this standalone test. +} from "../lib/aidpKnowledgeColumnWidths.ts"; + +test("dragging either way redistributes only the two boundary columns", () => { + const widths = { name: 240, type: 120, description: 300 }; + assert.deepEqual(resizeAidpColumnPair(widths, "name", "type", 40), { + name: 280, + type: 80, + description: 300, + }); + assert.deepEqual(resizeAidpColumnPair(widths, "name", "type", -40), { + name: 200, + type: 160, + description: 300, + }); + assert.equal(widths.name, 240); +}); + +test("dragging stops at 64px on either side, without squeezing a third column", () => { + const widths = { name: 240, type: 120, description: 300 }; + assert.deepEqual(resizeAidpColumnPair(widths, "name", "type", 1000), { + name: 296, + type: 64, + description: 300, + }); + assert.deepEqual(resizeAidpColumnPair(widths, "name", "type", -1000), { + name: 64, + type: 296, + description: 300, + }); +}); + +test("container resizing preserves user proportions before hitting the minimum", () => { + const keys = ["name", "type", "actions"] as const; + const weights = { name: 300, type: 200, actions: 100 }; + assert.deepEqual(fitAidpColumnWidths(keys, weights, 1200), { + name: 600, + type: 400, + actions: 200, + }); + assert.deepEqual(fitAidpColumnWidths(keys, weights, 600), weights); +}); + +test("minimum widths are frozen and the remaining space is redistributed", () => { + const keys = ["name", "type", "actions"] as const; + const result = fitAidpColumnWidths( + keys, + { name: 500, type: 100, actions: 50 }, + 300 + ); + assert.deepEqual(result, { name: 172, type: 64, actions: 64 }); + assert.equal( + Object.values(result).reduce((sum, width) => sum + width, 0), + 300 + ); +}); + +test("narrow containers overflow horizontally instead of reducing the minimum", () => { + assert.deepEqual( + fitAidpColumnWidths( + ["name", "type", "actions"], + { + name: 300, + type: 200, + actions: 100, + }, + 100 + ), + { name: 64, type: 64, actions: 64 } + ); +}); + +test("hidden columns are excluded from layout without losing their preferences", () => { + const weights = { name: 300, type: 200, actions: 100 }; + assert.deepEqual(fitAidpColumnWidths(["name", "actions"], weights, 800), { + name: 600, + actions: 200, + }); + assert.deepEqual( + fitAidpColumnWidths(["name", "type", "actions"], weights, 600), + weights + ); +}); From 0cf1f44b4f0066b69c630daf3791b92ade1c118a Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Fri, 9 Oct 2026 15:41:41 +0800 Subject: [PATCH 26/36] fix(aidp): restore widths when dragging back to origin --- .../ext_components/aidp/components/AidpKnowledgeList.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index 5b8c151daa..2a2cd80e45 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -402,7 +402,11 @@ const AidpKnowledgeList: React.FC = ({ renderedWidths: Record ) => { const resized = resizeAidpColumnPair(renderedWidths, key, nextKey, delta); - if (resized[key] === renderedWidths[key]) return; + 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( From b09867e44979e13050d951bdf249b4e914d73a8c Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Fri, 9 Oct 2026 16:06:19 +0800 Subject: [PATCH 27/36] docs(aidp): record retrieval metadata investigation --- .../aidp-retrieval-metadata-investigation.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/design/aidp-retrieval-metadata-investigation.md 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 知识库现有行为。 + +本记录不代表已确定最终页面设计,也不代表已经实施该需求。 From 53110dc1f0249fb23a899731df0cd40bc9ab2173 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Fri, 9 Oct 2026 17:11:53 +0800 Subject: [PATCH 28/36] docs(aidp): record create page tooltip copy --- docs/design/aidp-create-knowledge-tooltips.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/design/aidp-create-knowledge-tooltips.md diff --git a/docs/design/aidp-create-knowledge-tooltips.md b/docs/design/aidp-create-knowledge-tooltips.md new file mode 100644 index 0000000000..b37e6dcb8c --- /dev/null +++ b/docs/design/aidp-create-knowledge-tooltips.md @@ -0,0 +1,45 @@ +# AIDP 创建知识库:问号提示文案补充 + +记录日期:2026-10-09。 + +来源:用户提供的八张 AIDP 页面截图。用户要求先记录这些问号提示,供后续开发使用;本次没有修改页面。 + +适用范围:AIDP 创建知识库页面。已有界面字段名称尽量保持;下面的位置名称用于定位提示,不代表要求重命名字段。ES 场景不在本次范围内。 + +## 提示原文与位置 + +以下按截图原文记录,包括原文的术语和标点,不自行改写。 + +| 编号 | 问号所在位置 | 提示原文 | +| --- | --- | --- | +| 1 | 文档切片模块标题 | 智能分析文档内容结构进行切片,保留段落完整性的切片策略 | +| 2 | 文本切片最长 Token | 文档切片允许包含的最长字符数量,仅针对智能分片模式生效。 | +| 3 | 相邻切片重叠字符百分比 | 在文本分块过程中,前一个切片的末尾部分与后一个切片的开头部分重复的字符数量占切片总字符数(或重叠部分长度)的比例,仅针对智能分片模式生效。 | +| 4 | 向量生成模块标题 | 设置向量生成,可选择向量化模型以及配置VLM模型。 | +| 5 | 向量化模型 | 一种将现实世界中的非结构化数据(比如文字、图片、声音)转换成计算机能理解的数字形式。 | +| 6 | VLM 模型 | VLM模型是融合图像理解与文本生产的多模态大模型,可统一处理图片、文字输入并输出自然语言回答。 | +| 7 | 检索匹配模块标题 | 设置检索匹配值,可设置相似度阈值以及向量检索TopK值。 | +| 8 | 相似度阈值 | 在检索任务中,从向量数据库或索引中检索出的与查询向量相似的百分比。 | + +## 使用约定 + +- 将上述文案放到对应问号的悬浮提示中;本记录不要求更改字段名称或页面布局。 +- 第 2、3 项原文明确说明仅针对智能分片模式生效。这里记录的是提示文案,不另行推导法律条文模式下的接口行为。 +- 第 2 项原文写“字符数量”,字段名称写 Token;第 6 项原文写“文本生产”。先保留截图文案,不在本次记录中自行更正。 +- 第 7 项提到的是“检索匹配”模块中的向量检索 TOP K,不是已经决定移除的知识图谱 TOP K。 +- 图谱参数、模型查询、创建请求及提示词模板联动属于另一组接口适配事项,不因本次文案记录而视为已经实施。 + +## 截图来源索引 + +用户附件原始目录:`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` | From 2c22d1d9889c43e94a0822bdf02306f7e6c65dca Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Fri, 9 Oct 2026 17:13:52 +0800 Subject: [PATCH 29/36] docs(aidp): add graph and retrieval tooltip copy --- docs/design/aidp-create-knowledge-tooltips.md | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/design/aidp-create-knowledge-tooltips.md b/docs/design/aidp-create-knowledge-tooltips.md index b37e6dcb8c..f62213e4a4 100644 --- a/docs/design/aidp-create-knowledge-tooltips.md +++ b/docs/design/aidp-create-knowledge-tooltips.md @@ -2,7 +2,7 @@ 记录日期:2026-10-09。 -来源:用户提供的八张 AIDP 页面截图。用户要求先记录这些问号提示,供后续开发使用;本次没有修改页面。 +来源:用户分两次提供的十八张 AIDP 页面截图。用户要求先记录这些问号提示,供后续开发使用;本次没有修改页面。 适用范围:AIDP 创建知识库页面。已有界面字段名称尽量保持;下面的位置名称用于定位提示,不代表要求重命名字段。ES 场景不在本次范围内。 @@ -20,6 +20,24 @@ | 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 张截图还包含知识图谱模块下方的蓝色说明条,原文为: + +> 系统将在文件入库时提取实体及其关联关系,支持追溯实体的上下游知识。知识库建库后支持关闭知识图谱。 + +一并记录供后续使用;本次没有实施或验证创建后的知识图谱关闭功能。 ## 使用约定 @@ -27,6 +45,9 @@ - 第 2、3 项原文明确说明仅针对智能分片模式生效。这里记录的是提示文案,不另行推导法律条文模式下的接口行为。 - 第 2 项原文写“字符数量”,字段名称写 Token;第 6 项原文写“文本生产”。先保留截图文案,不在本次记录中自行更正。 - 第 7 项提到的是“检索匹配”模块中的向量检索 TOP K,不是已经决定移除的知识图谱 TOP K。 +- 第 9 项同样属于检索匹配模块,不恢复知识图谱 TOP K。 +- 第 13 项按截图记录“禁用思考”的说明。Nexent 已有界面字段“构图过程开启大模型思考”与接口 `no_think_mode` 的含义相反;后续接入提示时必须保持语义一致,不能因沿用原文而把开启、禁用的对应关系弄反。本记录不要求改名。 +- 第 15 项的竖线为原文分隔符,Markdown 中仅作转义,展示内容不变。 - 图谱参数、模型查询、创建请求及提示词模板联动属于另一组接口适配事项,不因本次文案记录而视为已经实施。 ## 截图来源索引 @@ -43,3 +64,13 @@ | 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` | From 9dd6d280d5d26f33cb3256d729f4f66bdbb8f06d Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Fri, 9 Oct 2026 17:50:47 +0800 Subject: [PATCH 30/36] fix(aidp): align knowledge creation with live API contract --- backend/database/user_tenant_db.py | 17 +- .../ext_components/aidp/apps/aidp_mgmt_app.py | 36 ++- .../aidp/services/aidp_creator_service.py | 22 ++ .../aidp/services/aidp_service.py | 91 ++++-- docs/design/aidp-create-live-contract.md | 58 ++++ .../aidp/components/AidpCreateKbPage.tsx | 203 ++++++++----- .../aidp/components/AidpCreateKbSections.tsx | 234 ++++++++++----- .../aidp/components/AidpKnowledgeDetail.tsx | 5 +- .../AidpKnowledgeDetailInformation.tsx | 21 +- .../aidp/components/AidpKnowledgeList.tsx | 2 +- .../aidp/services/aidpKnowledgeService.ts | 31 +- frontend/lib/aidpGraphConfig.ts | 76 +++++ frontend/public/locales/en/common.json | 34 ++- frontend/public/locales/zh/common.json | 34 ++- frontend/services/api.ts | 1 + frontend/types/agentConfig.ts | 2 + frontend/types/aidpGraph.ts | 18 ++ frontend/vitest.aidp.config.ts | 4 + test/automation/d1/AidpCreateKbPage.test.tsx | 281 ++++++++++++++++++ .../d1/AidpCreateKbSections.test.tsx | 119 ++++++++ test/backend/database/test_user_tenant_db.py | 13 + .../d1/aidp-knowledge-pages-refactor.yaml | 48 +-- .../d2/aidp-knowledge-pages-refactor.yaml | 24 +- .../bugs/aidp-live-create-contract.yaml | 16 + .../mock_servers/aidp_mgmt_mock_server.py | 82 ++++- .../aidp/test_aidp_creator_service.py | 36 +++ .../aidp/test_aidp_live_create_contract.py | 109 +++++++ .../ext_components/aidp/test_aidp_mgmt_app.py | 33 ++ test/ext_components/aidp/test_aidp_service.py | 7 +- .../aidp-knowledge-pages-refactor.yaml | 6 +- ...\350\257\225\345\237\272\347\272\277.xlsx" | Bin 48909 -> 49478 bytes test/manifests/aidp-create-live-contract.yaml | 20 ++ 32 files changed, 1411 insertions(+), 272 deletions(-) create mode 100644 backend/ext_components/aidp/services/aidp_creator_service.py create mode 100644 docs/design/aidp-create-live-contract.md create mode 100644 frontend/lib/aidpGraphConfig.ts create mode 100644 frontend/types/aidpGraph.ts create mode 100644 test/automation/d1/AidpCreateKbPage.test.tsx create mode 100644 test/automation/d1/AidpCreateKbSections.test.tsx create mode 100644 test/changes/bugs/aidp-live-create-contract.yaml create mode 100644 test/ext_components/aidp/test_aidp_creator_service.py create mode 100644 test/ext_components/aidp/test_aidp_live_create_contract.py create mode 100644 test/manifests/aidp-create-live-contract.yaml 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 735c5c6e2b..6f64c1c704 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,6 +59,7 @@ 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, @@ -838,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, @@ -883,6 +899,11 @@ 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", + ) detail_semaphore = asyncio.Semaphore(5) @@ -955,6 +976,7 @@ async def resolve_detail(row: dict) -> tuple[dict, str]: "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 @@ -1159,6 +1181,11 @@ async def get_knowledge_base( 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) @@ -1683,11 +1710,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 4bb778a491..64c4d27542 100644 --- a/backend/ext_components/aidp/services/aidp_service.py +++ b/backend/ext_components/aidp/services/aidp_service.py @@ -872,7 +872,6 @@ def count_aidp_kbs_impl(server_url: str, api_key: str) -> int: "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 @@ -886,9 +885,9 @@ def count_aidp_kbs_impl(server_url: str, api_key: str) -> int: # an invalid overlap would otherwise be forwarded as-is. _CHUNK_TOKEN_MIN = 256 _CHUNK_TOKEN_MAX = 4096 -_GRAPH_DOMAINS = {"medical", "finance", "general"} -_GRAPH_PROMPT_LANGUAGES = {"chinese", "english"} -_GRAPH_PROMPT_MAX_BYTES = 2048 +_GRAPH_DOMAINS = {"医疗", "金融", "常规", "法律法规"} +_GRAPH_PROMPT_LANGUAGES = {"中文", "英文"} +_GRAPH_PROMPT_MAX_CHARS = 4096 def _validate_chunking(result: Dict[str, Any]) -> None: @@ -922,14 +921,20 @@ def _serialize_graph_config(config: Dict[str, Any]) -> str: 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 "general" + 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 "chinese" + 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, @@ -942,14 +947,16 @@ def _serialize_graph_config(config: Dict[str, Any]) -> str: ErrorCode.COMMON_PARAMETER_INVALID, "Graph prompt text must be a string", ) - if len(prompt_text.encode("utf-8")) > _GRAPH_PROMPT_MAX_BYTES: + if not 1 <= len(prompt_text) <= _GRAPH_PROMPT_MAX_CHARS: raise AppException( ErrorCode.COMMON_PARAMETER_INVALID, - f"Graph prompt exceeds {_GRAPH_PROMPT_MAX_BYTES} UTF-8 bytes", + f"Graph prompt must contain 1 to {_GRAPH_PROMPT_MAX_CHARS} characters", ) - def _bounded_int(key: str, default: int, minimum: int, maximum: int) -> int: + 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) @@ -959,17 +966,24 @@ def _bounded_int(key: str, default: int, minimum: int, maximum: int) -> int: ErrorCode.COMMON_PARAMETER_INVALID, f"Graph parameter {key} must be an integer between {minimum} and {maximum}", ) - return value + 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_default_topk": _bounded_int("retrieve_default_topk", 5, 1, 100), "retrieve_subgraph_hop": _bounded_int("retrieve_subgraph_hop", 2, 1, 3), - "no_think_mode": bool(config.get("no_think_mode", True)), + "no_think_mode": _yes_no("no_think_mode", True), "prompt_language": prompt_language, "prompt_text": prompt_text, - "synonym_merge_enable": bool(config.get("synonym_merge_enable", False)), - "disambiguation_enable": bool(config.get("disambiguation_enable", False)), + "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: @@ -1007,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 @@ -1047,6 +1063,16 @@ def _apply_create_defaults(payload: Dict[str, Any]) -> Dict[str, Any]: 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 @@ -2150,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) @@ -2166,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, @@ -2221,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-live-contract.md b/docs/design/aidp-create-live-contract.md new file mode 100644 index 0000000000..f24bf7182b --- /dev/null +++ b/docs/design/aidp-create-live-contract.md @@ -0,0 +1,58 @@ +# 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 联调通过。 + +全局正式测试资产检查另有历史缺失绑定、已有脚本哈希不匹配等问题;本次新增的 `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/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx b/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx index 0479414d3d..b21f4f0c2b 100644 --- a/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx +++ b/frontend/ext_components/aidp/components/AidpCreateKbPage.tsx @@ -11,18 +11,20 @@ import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; -import { Button, Breadcrumb, Form, Space, message } from "antd"; +import { Alert, Button, Breadcrumb, Form, Space, message } from "antd"; -import type { - AidpGraphConfig, - AidpModelItem, -} from "@/ext_components/aidp/services/aidpKnowledgeService"; +import type { AidpGraphConfig } from "@/ext_components/aidp/services/aidpKnowledgeService"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; import { USER_ROLES } from "@/const/auth"; import { useGroupList } from "@/hooks/group/useGroupList"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { useDeployment } from "@/components/providers/deploymentProvider"; import log from "@/lib/logger"; +import { + graphParameter, + graphPrompt, + modelOptions, +} from "@/lib/aidpGraphConfig"; import AidpCreateKbSections from "./AidpCreateKbSections"; import styles from "./AidpCreateKbSections.module.css"; @@ -31,40 +33,28 @@ import styles from "./AidpCreateKbSections.module.css"; * Default AIDP knowledge base configuration. * * The creation screen uses the agreed product defaults (1024 tokens, 128 token - * overlap, similarity 0.6, Top K 10, embedding "default", captioning off). The - * graph parameters use the documented AIDP defaults, which are not the - * prototype sample values. + * overlap, similarity 0.6, Top K 10 and captioning off). Model identifiers and + * graph defaults come from AIDP's model and graph template endpoints. */ const AIDP_CREATE_DEFAULTS = { chunk_token_num: 1024, /** Overlap as a percentage of the chunk size; 128/1024 = 12.5%. */ chunk_overlap_percent: 12.5, chunk_mode: 0, - embedding_model: "default", is_personal: 0, topk: 10, similarity: 0.6, - smartsplit: 1, caption_enable: 0, is_exist_graph: false, - graph_domain: "general" as const, - graph_topk: 5, + graph_domain: "常规" as const, graph_hop: 2, /** UI switch; the submitted `no_think_mode` is its inverse. */ graph_thinking: false, - graph_prompt_language: "chinese" as const, + graph_prompt_language: "中文" as const, graph_synonym_merge: false, graph_disambiguation: false, }; -/** Editable default extraction prompt (UTF-8 length is validated on submit). */ -const DEFAULT_GRAPH_PROMPT = `你作为专业知识图谱抽取引擎,仅执行输入文本中实体、关系、属性三元组的标准化提取。 -严格遵循构图任务约定的输出字段与结构,输出合法、固定格式的 JSON 数组,不输出 Markdown 标记、解释、推理过程或其他冗余文本。 -仅依据输入文本和其中明确的上下文提取信息,不编造实体、关系或属性,不补充原文没有提供的事实。 -实体名称应保持一致;同一实体使用统一表述,不合并无法确认是同一对象的实体。 -关系和属性使用明确、简洁的表述,保留原文中的否定、条件和范围限定。 -没有可提取信息时输出空数组 []。`; - /** * Dedicated AIDP knowledge base creation page. * @@ -110,53 +100,88 @@ const AidpCreateKbPage: React.FC = () => { ); const ingroupPermission = Form.useWatch("ingroup_permission", form); - // ---- Model queries: one category per control, independent caches ---- - const { data: llmModelsData, isLoading: llmModelsLoading } = useQuery({ - queryKey: ["aidp-models", "llm", "KnowledgeBase"], - queryFn: () => aidpKnowledgeService.listModels("llm", "KnowledgeBase"), - staleTime: 5 * 60 * 1000, - }); - const { data: vlmModelsData, isLoading: vlmModelsLoading } = useQuery({ - queryKey: ["aidp-models", "vlm", "KnowledgeBase"], - queryFn: () => aidpKnowledgeService.listModels("vlm", "KnowledgeBase"), + const modelsQuery = useQuery({ + queryKey: ["aidp-models", "all", "KnowledgeBase"], + queryFn: () => aidpKnowledgeService.listModels("", "KnowledgeBase"), + enabled: isDeploymentReady && enableAidpKnowledge, staleTime: 5 * 60 * 1000, }); - const { data: embeddingModelsData, isLoading: embeddingModelsLoading } = - useQuery({ - queryKey: ["aidp-models", "embedding", "KnowledgeBase"], - queryFn: () => - aidpKnowledgeService.listModels("embedding", "KnowledgeBase"), - staleTime: 5 * 60 * 1000, - }); - - const modelNames = useCallback( - (data: { models?: AidpModelItem[] } | undefined) => { - const models: AidpModelItem[] = data?.models ?? []; - return models - .map((m) => m.model_name) - .filter( - (name): name is string => typeof name === "string" && name.length > 0 - ); - }, - [] - ); - const llmModelOptions = useMemo( - () => modelNames(llmModelsData), - [llmModelsData, modelNames] + () => modelOptions(modelsQuery.data?.models ?? [], "llm"), + [modelsQuery.data] ); const vlmModelOptions = useMemo( - () => modelNames(vlmModelsData), - [vlmModelsData, modelNames] + () => modelOptions(modelsQuery.data?.models ?? [], "vlm"), + [modelsQuery.data] + ); + const embeddingModelOptions = useMemo( + () => modelOptions(modelsQuery.data?.models ?? [], "embedding"), + [modelsQuery.data] ); - // `default` keeps the established semantics of the existing create call, so - // it stays selectable even when AIDP advertises other embedding models. - const embeddingModelOptions = useMemo(() => { - const advertised = modelNames(embeddingModelsData); - return advertised.includes(AIDP_CREATE_DEFAULTS.embedding_model) - ? advertised - : [AIDP_CREATE_DEFAULTS.embedding_model, ...advertised]; - }, [embeddingModelsData, modelNames]); + const graphEnabled = Form.useWatch("is_exist_graph", form) === true; + const promptLanguage = Form.useWatch("graph_prompt_language", form) || "中文"; + const graphDomain = Form.useWatch("graph_domain", form) || "常规"; + const templateQuery = useQuery({ + queryKey: ["aidp-graph-template", promptLanguage], + queryFn: () => + aidpKnowledgeService.graphTemplate( + promptLanguage === "英文" ? "english" : "chinese" + ), + enabled: graphEnabled && isDeploymentReady && enableAidpKnowledge, + staleTime: 5 * 60 * 1000, + }); + const initializedGraph = useRef(false); + const previousDefaultPrompt = useRef(""); + useEffect(() => { + const data = templateQuery.data; + if (!data || !graphEnabled) return; + if (!initializedGraph.current) { + const value = (key: string) => graphParameter(data, key)?.param_value; + const defaults: Record = { + graph_domain: value("domain"), + graph_hop: Number(value("retrieve_subgraph_hop")), + graph_thinking: value("no_think_mode") === "否", + graph_synonym_merge: value("synonym_merge_enable") === "是", + graph_disambiguation: value("disambiguation_enable") === "是", + }; + Object.entries(defaults).forEach(([field, initial]) => { + if (!form.isFieldTouched(field)) form.setFieldValue(field, initial); + }); + initializedGraph.current = true; + } + const nextPrompt = graphPrompt( + data, + form.getFieldValue("graph_domain") || graphDomain + ); + const currentPrompt = form.getFieldValue("graph_prompt_text") || ""; + if ( + !form.isFieldTouched("graph_prompt_text") || + currentPrompt === previousDefaultPrompt.current + ) { + form.setFieldValue("graph_prompt_text", nextPrompt); + } + previousDefaultPrompt.current = nextPrompt; + }, [form, graphDomain, graphEnabled, templateQuery.data]); + const restoreDefaultPrompt = useCallback(() => { + const prompt = graphPrompt(templateQuery.data, graphDomain); + form.setFieldValue("graph_prompt_text", prompt); + previousDefaultPrompt.current = prompt; + }, [form, graphDomain, templateQuery.data]); + useEffect(() => { + for (const [field, options] of [ + ["embedding_model", embeddingModelOptions], + ["llm_model_name", llmModelOptions], + ["vlm_model", vlmModelOptions], + ] as const) { + if ( + options.length && + !form.isFieldTouched(field) && + !options.some((option) => option.value === form.getFieldValue(field)) + ) { + form.setFieldValue(field, options[0].value); + } + } + }, [embeddingModelOptions, form, llmModelOptions, vlmModelOptions]); // ---- Defaults ---- useEffect(() => { @@ -166,17 +191,15 @@ const AidpCreateKbPage: React.FC = () => { chunk_mode: AIDP_CREATE_DEFAULTS.chunk_mode, topk: AIDP_CREATE_DEFAULTS.topk, similarity: AIDP_CREATE_DEFAULTS.similarity, - embedding_model: AIDP_CREATE_DEFAULTS.embedding_model, caption_enable: AIDP_CREATE_DEFAULTS.caption_enable === 1, is_exist_graph: AIDP_CREATE_DEFAULTS.is_exist_graph, graph_domain: AIDP_CREATE_DEFAULTS.graph_domain, - graph_topk: AIDP_CREATE_DEFAULTS.graph_topk, graph_hop: AIDP_CREATE_DEFAULTS.graph_hop, graph_thinking: AIDP_CREATE_DEFAULTS.graph_thinking, graph_prompt_language: AIDP_CREATE_DEFAULTS.graph_prompt_language, graph_synonym_merge: AIDP_CREATE_DEFAULTS.graph_synonym_merge, graph_disambiguation: AIDP_CREATE_DEFAULTS.graph_disambiguation, - graph_prompt_text: DEFAULT_GRAPH_PROMPT, + graph_prompt_text: "", ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", group_ids: [], }); @@ -217,18 +240,17 @@ const AidpCreateKbPage: React.FC = () => { const graphConfig: AidpGraphConfig | undefined = values.is_exist_graph ? { domain: values.graph_domain || AIDP_CREATE_DEFAULTS.graph_domain, - retrieve_default_topk: - values.graph_topk ?? AIDP_CREATE_DEFAULTS.graph_topk, - retrieve_subgraph_hop: - values.graph_hop ?? AIDP_CREATE_DEFAULTS.graph_hop, + retrieve_subgraph_hop: String( + values.graph_hop ?? AIDP_CREATE_DEFAULTS.graph_hop + ), // The UI switch is the inverse of the submitted thinking mode. - no_think_mode: !values.graph_thinking, + no_think_mode: values.graph_thinking ? "否" : "是", prompt_language: values.graph_prompt_language || AIDP_CREATE_DEFAULTS.graph_prompt_language, - prompt_text: values.graph_prompt_text || DEFAULT_GRAPH_PROMPT, - synonym_merge_enable: !!values.graph_synonym_merge, - disambiguation_enable: !!values.graph_disambiguation, + prompt_text: values.graph_prompt_text, + synonym_merge_enable: values.graph_synonym_merge ? "是" : "否", + disambiguation_enable: values.graph_disambiguation ? "是" : "否", llm_model_name: values.llm_model_name, } : undefined; @@ -239,11 +261,9 @@ const AidpCreateKbPage: React.FC = () => { chunk_token_num: chunkTokens, chunk_overlap_num: overlapTokens, chunk_mode: values.chunk_mode ?? AIDP_CREATE_DEFAULTS.chunk_mode, - embedding_model: - values.embedding_model || AIDP_CREATE_DEFAULTS.embedding_model, + embedding_model: values.embedding_model, topk: values.topk ?? AIDP_CREATE_DEFAULTS.topk, similarity: values.similarity ?? AIDP_CREATE_DEFAULTS.similarity, - smartsplit: AIDP_CREATE_DEFAULTS.smartsplit, is_personal: AIDP_CREATE_DEFAULTS.is_personal, caption_enable: values.caption_enable ? 1 : 0, vlm_model: values.caption_enable ? values.vlm_model || "" : "", @@ -305,6 +325,23 @@ const AidpCreateKbPage: React.FC = () => {
+ {(modelsQuery.isError || (graphEnabled && templateQuery.isError)) && ( + { + void modelsQuery.refetch(); + if (graphEnabled) void templateQuery.refetch(); + }} + > + {t("common.retry")} + + } + /> + )} { groupOptions={groupOptions} ingroupPermission={ingroupPermission} llmModelOptions={llmModelOptions} - llmModelsLoading={llmModelsLoading} + llmModelsLoading={modelsQuery.isLoading} vlmModelOptions={vlmModelOptions} - vlmModelsLoading={vlmModelsLoading} + vlmModelsLoading={modelsQuery.isLoading} embeddingModelOptions={embeddingModelOptions} - embeddingModelsLoading={embeddingModelsLoading} + embeddingModelsLoading={modelsQuery.isLoading} + graphTemplate={templateQuery.data} + onRestorePrompt={restoreDefaultPrompt} />
@@ -329,6 +368,12 @@ const AidpCreateKbPage: React.FC = () => { {hint && ( - + )}
@@ -224,7 +243,7 @@ const AidpCreateKbSections: React.FC = ({ {sectionHeading( "graph", t("aidpKnowledge.createSectionGraph"), - t("aidpKnowledge.createGraphEnableHint"), + t("aidpKnowledge.createGraphSectionHint"), @@ -239,46 +258,52 @@ const AidpCreateKbSections: React.FC = ({ <> = ({ = ({ + value ? "是" : "否" + ), + ]} > = ({ ? t("aidpKnowledge.createModelLoading") : t("aidpKnowledge.createModelNone") } - options={embeddingModelOptions.map((name) => ({ - label: name, - value: name, - }))} + optionFilterProp="label" + options={embeddingModelOptions} /> = ({ : t("aidpKnowledge.createModelNone") } placeholder={t("aidpKnowledge.createModelSearch")} - options={vlmModelOptions.map((name) => ({ - label: name, - value: name, - }))} + optionFilterProp="label" + options={vlmModelOptions} />
@@ -590,7 +652,11 @@ const AidpCreateKbSections: React.FC = ({
- {sectionHeading("retrieval", t("aidpKnowledge.createSectionRetrieval"))} + {sectionHeading( + "retrieval", + t("aidpKnowledge.createSectionRetrieval"), + t("aidpKnowledge.createRetrievalSectionHint") + )}

{t("aidpKnowledge.createDefaultsHint")}

@@ -598,7 +664,10 @@ const AidpCreateKbSections: React.FC = ({
= ({ = ({ }, { title: t("aidpKnowledge.detailImportMode"), - dataIndex: "import_mode", key: "import_mode", width: 120, ellipsis: true, - render: (value?: string) => value || UNKNOWN, + render: () => t("aidpKnowledge.detailLocalImport"), }, { title: t("aidpKnowledge.detailFirstUploadTime"), @@ -785,7 +784,7 @@ const AidpKnowledgeDetail: React.FC = ({
- {detail.created_by || detail.user_name || UNKNOWN} + {detail.creator_name || UNKNOWN}
diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeDetailInformation.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeDetailInformation.tsx index 4035e5fbe1..9caf93653c 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeDetailInformation.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeDetailInformation.tsx @@ -7,6 +7,7 @@ import { EditOutlined } from "@ant-design/icons"; import type { AidpKbDetail } from "../services/aidpKnowledgeService"; import { AIDP_UNKNOWN_VALUE } from "@/lib/aidpKnowledgeDisplay"; +import { graphBoolean } from "@/lib/aidpGraphConfig"; import AidpGroupNamesDisplay from "./AidpGroupNamesDisplay"; export const AidpDetailField = ({ @@ -90,7 +91,7 @@ const AidpKnowledgeDetailInformation = ({ {formatDateTime(detail.updated_at)} - {detail.created_by || detail.user_name || AIDP_UNKNOWN_VALUE} + {detail.creator_name || AIDP_UNKNOWN_VALUE}
@@ -145,7 +146,9 @@ const AidpKnowledgeDetailInformation = ({ ) : (
- {detail.llm_model_name || AIDP_UNKNOWN_VALUE} + {displayValue( + graphConfig.llm_model_name || detail.llm_model_name + )} {displayValue(graphConfig.retrieve_subgraph_hop)} @@ -153,26 +156,20 @@ const AidpKnowledgeDetailInformation = ({ {displayValue(graphConfig.domain)} - - {displayValue(graphConfig.retrieve_default_topk)} - {displayValue( - graphConfig.no_think_mode === undefined + graphBoolean(graphConfig.no_think_mode) === undefined ? undefined - : !graphConfig.no_think_mode + : !graphBoolean(graphConfig.no_think_mode) )} - {displayValue(graphConfig.synonym_merge_enable)} + {displayValue(graphBoolean(graphConfig.synonym_merge_enable))} - {displayValue(graphConfig.disambiguation_enable)} - - - {detail.llm_model_name || AIDP_UNKNOWN_VALUE} + {displayValue(graphBoolean(graphConfig.disambiguation_enable))} = ({ creator: makeColumn("creator", { title: t("aidpKnowledge.columnCreator"), key: "creator", - render: (_value, kb) => renderText(formatAidpCreator(kb.user_name)), + render: (_value, kb) => renderText(formatAidpCreator(kb.creator_name)), }), created_at: makeColumn("created_at", { title: t("aidpKnowledge.columnCreatedAt"), diff --git a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts index e34180fc99..ef8841a4d2 100644 --- a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts +++ b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts @@ -9,6 +9,8 @@ import { API_ENDPOINTS, fetchWithErrorHandling } from "@/services/api"; import type { AidpKnowledgeBaseListResponse } from "@/types/agentConfig"; import { getAuthHeaders } from "@/lib/auth"; import log from "@/lib/logger"; +import { parseAidpGraphTemplate } from "@/lib/aidpGraphConfig"; +import type { AidpGraphTemplate } from "@/types/aidpGraph"; import { handleSessionExpired } from "@/lib/session"; // ---------- Additional types for AIDP management ---------- @@ -48,6 +50,7 @@ export interface AidpKbDetail { is_exist_graph?: boolean | null; graph_config?: string | Record | null; created_by?: string | null; + creator_name?: string | null; } export interface AidpDocumentItem { @@ -172,6 +175,8 @@ const normalizeAidpOperationResponse = ( export interface AidpModelItem { /** Display / identifier used for the model (sent to AIDP as ``vlm_model``). */ model_name: string; + display_name?: string; + model_type?: string; /** "llm", "embedding", etc. — informational only on the frontend. */ service?: string; /** @@ -208,21 +213,19 @@ export interface AidpGraphConfig { /** Optional graph extraction LLM model; nested in AIDP graph_config. */ llm_model_name?: string; /** Extraction domain. */ - domain?: "medical" | "finance" | "general"; - /** Graph candidate Top K, independent from the knowledge base Top K. */ - retrieve_default_topk?: number; + domain?: "医疗" | "金融" | "常规" | "法律法规"; /** Sub-graph expansion hop count. */ - retrieve_subgraph_hop?: number; + retrieve_subgraph_hop?: string; /** Inverse of the "enable model thinking" switch. */ - no_think_mode?: boolean; + no_think_mode?: "是" | "否"; /** Extraction prompt language. */ - prompt_language?: "chinese" | "english"; - /** Editable extraction prompt, limited to 2048 UTF-8 bytes. */ + prompt_language?: "中文" | "英文"; + /** Editable extraction prompt, limited to 4096 characters. */ prompt_text?: string; /** Whether synonym merging is enabled. */ - synonym_merge_enable?: boolean; + synonym_merge_enable?: "是" | "否"; /** Whether semantic disambiguation is enabled. */ - disambiguation_enable?: boolean; + disambiguation_enable?: "是" | "否"; } export interface AidpCreateKbPayload { @@ -628,6 +631,16 @@ class AidpKnowledgeService { }; } + async graphTemplate( + language: "chinese" | "english" + ): Promise { + const response = await fetchWithErrorHandling( + buildUrl(API_ENDPOINTS.aidpMgmt.graphTemplate, { language }), + { method: "GET", headers: getAuthHeaders() } + ); + return parseAidpGraphTemplate(await response.json()); + } + /** * Save local permissions first, then synchronize supplied metadata changes. */ diff --git a/frontend/lib/aidpGraphConfig.ts b/frontend/lib/aidpGraphConfig.ts new file mode 100644 index 0000000000..41aa0b9dde --- /dev/null +++ b/frontend/lib/aidpGraphConfig.ts @@ -0,0 +1,76 @@ +import type { AidpGraphTemplate, AidpModelOption } from "@/types/aidpGraph"; + +const parameterKeys = [ + "retrieve_subgraph_hop", + "no_think_mode", + "prompt_language", + "domain", + "prompt_text", + "synonym_merge_enable", + "disambiguation_enable", +]; + +export function parseAidpGraphTemplate(raw: unknown): AidpGraphTemplate { + const data = raw as AidpGraphTemplate | undefined; + if ( + !Array.isArray(data?.value) || + !parameterKeys.every((key) => + data.value.some( + (p) => + p.param_key === key && + typeof p.param_value === "string" && + typeof p.regexp === "string" && + typeof p.is_modifiable === "boolean" + ) + ) + ) + throw new Error("Incomplete AIDP graph configuration template"); + // Reject invalid constraints before they reach form validation. + data.value.forEach((p) => new RegExp(p.regexp, "u")); + return data; +} + +export function graphParameter( + data: AidpGraphTemplate | undefined, + key: string +) { + return data?.value.find((parameter) => parameter.param_key === key); +} + +export function graphChoices( + data: AidpGraphTemplate | undefined, + key: string +): string[] { + const parameter = graphParameter(data, key); + const match = parameter?.regexp.match(/^\^\(([^()]+)\)\$$/); + return match ? match[1].split("|") : parameter ? [parameter.param_value] : []; +} + +export function graphPrompt( + data: AidpGraphTemplate | undefined, + domain: string +): string { + const parameter = graphParameter(data, "prompt_text"); + return parameter?.template?.[domain] ?? parameter?.param_value ?? ""; +} + +export function modelOptions( + models: { model_name: string; display_name?: string; model_type?: string }[], + type: string +): AidpModelOption[] { + return models + .filter((m) => m.model_type === type && m.model_name) + .map((m) => ({ + value: m.model_name, + label: m.display_name || m.model_name, + })); +} + +/** Normalize both the verified Chinese values and older detail responses. */ +export function graphBoolean(value: unknown): boolean | undefined { + if (value === true || value === "是" || value === "true" || value === "yes") + return true; + if (value === false || value === "否" || value === "false" || value === "no") + return false; + return undefined; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 1fe1f61d79..def496c371 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -4176,7 +4176,7 @@ "aidpKnowledge.createChunkOverlapNumRequired": "Please enter the chunk overlap token count", "aidpKnowledge.createChunkOverlapNumMin": "Chunk overlap token count must be ≥ 0", "aidpKnowledge.createCaptionEnable": "Enable Multimodal ", - "aidpKnowledge.createCaptionEnableHint": "When enabled, text descriptions will be extracted from images in the knowledge base", + "aidpKnowledge.createCaptionEnableHint": "A VLM combines image understanding and text generation to process image and text inputs and answer in natural language.", "aidpKnowledge.createVlmModel": "VLM model", "aidpKnowledge.createVlmModelHint": "Used for image caption generation in multimodal knowledge bases", "aidpKnowledge.createVlmModelLoading": "Loading models…", @@ -4650,26 +4650,26 @@ "aidpKnowledge.createGraphDomainGeneral": "General", "aidpKnowledge.createGraphDomainMedical": "Medical", "aidpKnowledge.createGraphEnable": "Enable knowledge graph", - "aidpKnowledge.createGraphEnableHint": "Extracts entities and relations for graph-augmented retrieval; set at creation time only", + "aidpKnowledge.createGraphEnableHint": "Extract entities and relationships during file ingestion to trace related knowledge upstream and downstream. The knowledge graph can be disabled after creation.", "aidpKnowledge.createGraphHop": "Sub-graph hop count", "aidpKnowledge.createGraphModel": "Graph construction LLM model", - "aidpKnowledge.createGraphModelHint": "Queries AIDP for LLM models separately from VLM models", + "aidpKnowledge.createGraphModelHint": "Understand and process text to extract entities and their relationships from the knowledge base.", "aidpKnowledge.createGraphModelRequired": "Select a graph extraction model", "aidpKnowledge.createGraphPromptEn": "English", - "aidpKnowledge.createGraphPromptHint": "Editable here, limited to 2048 UTF-8 bytes", + "aidpKnowledge.createGraphPromptHint": "Customize the triple extraction prompt using the templates as a reference. Length: 1–4096 characters.", "aidpKnowledge.createGraphPromptLanguage": "Graph prompt language", "aidpKnowledge.createGraphPromptText": "Graph prompt template", - "aidpKnowledge.createGraphPromptTooLong": "The prompt exceeds the 2048 byte limit", + "aidpKnowledge.createGraphPromptTooLong": "Prompt length must be 1–4096 characters", "aidpKnowledge.createGraphPromptZh": "Chinese", "aidpKnowledge.createGraphSynonymMerge": "Enable synonym detection and merging", "aidpKnowledge.createGraphThinking": "Enable LLM reasoning during graph construction", - "aidpKnowledge.createGraphThinkingHint": "Lets the model reason before extracting; disabling speeds it up", + "aidpKnowledge.createGraphThinkingHint": "Disabling LLM thinking returns extracted triples without the full reasoning process to improve extraction efficiency. Yes here enables thinking, which submits No for disabling thinking.", "aidpKnowledge.createGraphTopk": "Vector retrieval Top K", "aidpKnowledge.createModelLoading": "Loading…", "aidpKnowledge.createModelNone": "No models available", "aidpKnowledge.createModelSearch": "Search models", "aidpKnowledge.createOverlapPercent": "Vector chunk overlap percentage", - "aidpKnowledge.createOverlapPercentHint": "Converted from the chunk size into an integer token overlap", + "aidpKnowledge.createOverlapPercentHint": "The proportion of overlapping content between the end of one chunk and the beginning of the next. Applies only to intelligent chunking.", "aidpKnowledge.createOverlapPercentRange": "Overlap must be between {{min}}% and {{max}}%", "aidpKnowledge.createOverlapPercentRequired": "Enter the chunk overlap ratio", "aidpKnowledge.createPageTitle": "Create knowledge base", @@ -5499,5 +5499,23 @@ "aidpKnowledge.detailEditTopK": "Edit vector retrieval TOP K", "aidpKnowledge.detailSettingSaved": "Configuration updated", "aidpKnowledge.detailSettingSaveFailed": "Failed to update configuration", - "aidpKnowledge.deleteDocTitle": "Delete this file?" + "aidpKnowledge.deleteDocTitle": "Delete this file?", + "aidpKnowledge.createChunkSectionHint": "Analyze document structure to split content while preserving complete paragraphs.", + "aidpKnowledge.createChunkTokenHint": "Maximum length of a document chunk. Applies only to intelligent chunking.", + "aidpKnowledge.createVectorSectionHint": "Configure vector generation by selecting an embedding model and configuring a VLM.", + "aidpKnowledge.createEmbeddingHint": "Convert unstructured data, such as text, images and audio, into numerical representations that computers can understand.", + "aidpKnowledge.createRetrievalSectionHint": "Set the similarity threshold and vector retrieval Top K.", + "aidpKnowledge.createSimilarityHint": "The similarity of results retrieved from a vector database or index to the query vector.", + "aidpKnowledge.createTopkHint": "Retrieve the first K results most similar to the query vector from a vector database or index.", + "aidpKnowledge.createGraphSectionHint": "Build entity relationships to trace related knowledge upstream and downstream.", + "aidpKnowledge.createGraphHopHint": "Number of subgraph expansion hops for PPR retrieval, from 1 to 3.", + "aidpKnowledge.createGraphLanguageHint": "Language of the triple extraction prompt: Chinese or English.", + "aidpKnowledge.createGraphDomainHint": "Business domain: medical, finance, general, or laws and regulations.", + "aidpKnowledge.createGraphSynonymHint": "Enable synonym detection when building the graph to connect similar entities.", + "aidpKnowledge.createGraphDisambiguationHint": "Enable disambiguation of entities with the same name when building the graph to merge similar entities.", + "aidpKnowledge.createGraphDomainLegal": "Laws and regulations", + "aidpKnowledge.createGraphUseDefaultPrompt": "Use default template", + "aidpKnowledge.createGraphValueInvalid": "The value does not meet the AIDP template requirements. Check your input.", + "aidpKnowledge.createConfigurationLoadFailed": "Unable to load AIDP models or graph configuration. Please retry.", + "aidpKnowledge.detailLocalImport": "Local import" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 69fe13498a..78cd290b4d 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -3886,7 +3886,7 @@ "aidpKnowledge.createChunkOverlapNumRequired": "请输入分块重叠 Token 数", "aidpKnowledge.createChunkOverlapNumMin": "分块重叠 Token 数必须 ≥ 0", "aidpKnowledge.createCaptionEnable": "启用多模态", - "aidpKnowledge.createCaptionEnableHint": "开启后将对知识库中的图片进行文字描述提取", + "aidpKnowledge.createCaptionEnableHint": "VLM模型是融合图像理解与文本生产的多模态大模型,可统一处理图片、文字输入并输出自然语言回答。", "aidpKnowledge.createVlmModel": "VLM模型", "aidpKnowledge.createVlmModelHint": "用于多模态知识库中的图片 caption 生成", "aidpKnowledge.createVlmModelLoading": "加载模型列表中…", @@ -4661,26 +4661,26 @@ "aidpKnowledge.createGraphDomainGeneral": "通用", "aidpKnowledge.createGraphDomainMedical": "医疗", "aidpKnowledge.createGraphEnable": "开启知识图谱", - "aidpKnowledge.createGraphEnableHint": "开启后从文档中抽取实体与关系用于图谱增强检索;本次仅在创建时设置", + "aidpKnowledge.createGraphEnableHint": "系统将在文件入库时提取实体及其关联关系,支持追溯实体的上下游知识。知识库建库后支持关闭知识图谱。", "aidpKnowledge.createGraphHop": "子图扩展跳数", "aidpKnowledge.createGraphModel": "构图过程LLM模型", - "aidpKnowledge.createGraphModelHint": "从 AIDP 查询 llm 类型模型,与 VLM 模型分开查询", + "aidpKnowledge.createGraphModelHint": "用于理解和处理文本,帮助系统从知识库内容中提取实体及其关联关系。", "aidpKnowledge.createGraphModelRequired": "请选择构图模型", "aidpKnowledge.createGraphPromptEn": "英文", - "aidpKnowledge.createGraphPromptHint": "可在此调整,最多 2048 字节(UTF-8)", + "aidpKnowledge.createGraphPromptHint": "自定义三元组抽取提示词,可参考模板来编写,长度限制为1-4096", "aidpKnowledge.createGraphPromptLanguage": "构图过程提示词语言", "aidpKnowledge.createGraphPromptText": "构图过程提示词模板", - "aidpKnowledge.createGraphPromptTooLong": "提示词超过 2048 字节上限", + "aidpKnowledge.createGraphPromptTooLong": "提示词长度必须为 1–4096 个字符", "aidpKnowledge.createGraphPromptZh": "中文", "aidpKnowledge.createGraphSynonymMerge": "开启同义词检测合并", "aidpKnowledge.createGraphThinking": "构图过程开启大模型思考", - "aidpKnowledge.createGraphThinkingHint": "开启后模型在抽取前进行推理,关闭可提升速度", + "aidpKnowledge.createGraphThinkingHint": "禁用LLM思考过程,LLM提取三元组时只返回提取结果,不输出完整思考过程,提升三元组提取效率,可选是或否 此处选择“是”表示开启思考,对应接口禁用思考为“否”。", "aidpKnowledge.createGraphTopk": "向量检索TOP K值", "aidpKnowledge.createModelLoading": "加载中…", "aidpKnowledge.createModelNone": "暂无可用模型", "aidpKnowledge.createModelSearch": "搜索模型", "aidpKnowledge.createOverlapPercent": "向量切片重叠字符百分比", - "aidpKnowledge.createOverlapPercentHint": "按切片大小的百分比换算为整数 token 重叠", + "aidpKnowledge.createOverlapPercentHint": "在文本分块过程中,前一个切片的末尾部分与后一个切片的开头部分重复的字符数量占切片总字符数(或重叠部分长度)的比例,仅针对智能分片模式生效。", "aidpKnowledge.createOverlapPercentRange": "重叠比例需在 {{min}}%-{{max}}% 之间", "aidpKnowledge.createOverlapPercentRequired": "请填写切片重叠比例", "aidpKnowledge.createPageTitle": "创建知识库", @@ -5513,5 +5513,23 @@ "aidpKnowledge.detailEditTopK": "编辑向量检索 TOP K 值", "aidpKnowledge.detailSettingSaved": "配置已更新", "aidpKnowledge.detailSettingSaveFailed": "配置更新失败", - "aidpKnowledge.deleteDocTitle": "确定删除此文件吗?" + "aidpKnowledge.deleteDocTitle": "确定删除此文件吗?", + "aidpKnowledge.createChunkSectionHint": "智能分析文档内容结构进行切片,保留段落完整性的切片策略", + "aidpKnowledge.createChunkTokenHint": "文档切片允许包含的最长字符数量,仅针对智能分片模式生效。", + "aidpKnowledge.createVectorSectionHint": "设置向量生成,可选择向量化模型以及配置VLM模型。", + "aidpKnowledge.createEmbeddingHint": "一种将现实世界中的非结构化数据(比如文字、图片、声音)转换成计算机能理解的数字形式。", + "aidpKnowledge.createRetrievalSectionHint": "设置检索匹配值,可设置相似度阈值以及向量检索TopK值。", + "aidpKnowledge.createSimilarityHint": "在检索任务中,从向量数据库或索引中检索出的与查询向量相似的百分比。", + "aidpKnowledge.createTopkHint": "在检索任务中,从向量数据库或索引中检索出的与查询向量最相似的前K个结果。", + "aidpKnowledge.createGraphSectionHint": "开启知识图谱后生成实体逻辑链路,可追溯实体上下游关联知识。", + "aidpKnowledge.createGraphHopHint": "PPR检索时子图扩展的跳数,范围1-3", + "aidpKnowledge.createGraphLanguageHint": "三元组抽取提示词语言,可选中文或英文", + "aidpKnowledge.createGraphDomainHint": "业务领域,可选医疗|金融|常规|法律法规", + "aidpKnowledge.createGraphSynonymHint": "建图时是否启用同义词检测,启用后相似实体间建立关系,可选是或否", + "aidpKnowledge.createGraphDisambiguationHint": "建图时是否启用同名实体消歧,启用后相似实体会合并,可选是或否", + "aidpKnowledge.createGraphDomainLegal": "法律法规", + "aidpKnowledge.createGraphUseDefaultPrompt": "使用默认模板", + "aidpKnowledge.createGraphValueInvalid": "参数不符合 AIDP 模板要求,请检查输入。", + "aidpKnowledge.createConfigurationLoadFailed": "无法获取 AIDP 模型或图谱配置,请重试。", + "aidpKnowledge.detailLocalImport": "本地导入" } diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 08503c9d24..e0cd46e736 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -396,6 +396,7 @@ export const API_ENDPOINTS = { downloadKbDocument: (id: string) => `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents/download`, models: `${API_BASE_URL}/aidp-mgmt/models`, + graphTemplate: `${API_BASE_URL}/aidp-mgmt/knowledge-bases/graph-template`, /** PATCH endpoint for the per-KB in-group permission. */ kbPermission: (id: string) => `${API_BASE_URL}/aidp-mgmt/aidp-permissions/${id}`, diff --git a/frontend/types/agentConfig.ts b/frontend/types/agentConfig.ts index f38d2cc8c2..777cd1303e 100644 --- a/frontend/types/agentConfig.ts +++ b/frontend/types/agentConfig.ts @@ -302,6 +302,8 @@ export interface AidpKnowledgeBaseItem { current_cap?: number | null; /** Creator display name from AIDP; absent when the response omits it. */ user_name?: string | null; + /** Nexent account name resolved from the local permission owner. */ + creator_name?: string | null; /** * False when the backend could not confirm a real document count, so the * overview must not present the value as a trustworthy statistic. diff --git a/frontend/types/aidpGraph.ts b/frontend/types/aidpGraph.ts new file mode 100644 index 0000000000..4594ea03bb --- /dev/null +++ b/frontend/types/aidpGraph.ts @@ -0,0 +1,18 @@ +export interface AidpGraphParameter { + param_key: string; + param_name: string; + param_value: string; + param_desc: string; + regexp: string; + is_modifiable: boolean; + template?: Record; +} + +export interface AidpGraphTemplate { + value: AidpGraphParameter[]; +} + +export interface AidpModelOption { + label: string; + value: string; +} diff --git a/frontend/vitest.aidp.config.ts b/frontend/vitest.aidp.config.ts index 88ea2e9877..39f1fc240f 100644 --- a/frontend/vitest.aidp.config.ts +++ b/frontend/vitest.aidp.config.ts @@ -23,6 +23,8 @@ export default defineConfig({ "react/jsx-runtime", "react/jsx-dev-runtime", "react-i18next", + "@tanstack/react-query", + "next/navigation", "antd", "vitest", "@testing-library/react", @@ -46,6 +48,8 @@ export default defineConfig({ "test/automation/d1/AidpImportDrawer.test.tsx", "test/automation/d1/AidpUploadService.test.tsx", "test/automation/d1/AidpGroupNamesDisplay.test.tsx", + "test/automation/d1/AidpCreateKbPage.test.tsx", + "test/automation/d1/AidpCreateKbSections.test.tsx", ], clearMocks: true, }, diff --git a/test/automation/d1/AidpCreateKbPage.test.tsx b/test/automation/d1/AidpCreateKbPage.test.tsx new file mode 100644 index 0000000000..5627e2b610 --- /dev/null +++ b/test/automation/d1/AidpCreateKbPage.test.tsx @@ -0,0 +1,281 @@ +import React from "react"; +import type { FormInstance } from "antd"; +import type { AidpModelOption } from "@/types/aidpGraph"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AidpCreateKbPage from "@/ext_components/aidp/components/AidpCreateKbPage"; +import { + graphChoices, + graphBoolean, + parseAidpGraphTemplate, +} from "@/lib/aidpGraphConfig"; + +const api = vi.hoisted(() => ({ + listModels: vi.fn(), + graphTemplate: vi.fn(), + createKb: vi.fn(), + push: vi.fn(), +})); +vi.mock("@/ext_components/aidp/services/aidpKnowledgeService", () => ({ + default: api, +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: api.push, replace: vi.fn() }), + useParams: () => ({ locale: "zh" }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/providers/deploymentProvider", () => ({ + useDeployment: () => ({ enableAidpKnowledge: true, isDeploymentReady: true }), +})); +vi.mock("@/components/providers/AuthorizationProvider", () => ({ + useAuthorizationContext: () => ({ user: undefined }), +})); +vi.mock("@/hooks/group/useGroupList", () => ({ + useGroupList: () => ({ data: { groups: [] } }), +})); +vi.mock("@/lib/logger", () => ({ default: { error: vi.fn() } })); + +// Keep Ant Design's real form state and validation while isolating visual controls. +interface SectionsHarnessProps { + form: FormInstance; + embeddingModelOptions: AidpModelOption[]; + llmModelOptions: AidpModelOption[]; + vlmModelOptions: AidpModelOption[]; + onRestorePrompt: () => void; +} + +vi.mock("@/ext_components/aidp/components/AidpCreateKbSections", async () => { + const { Form, Input, Switch } = await import("antd"); + return { + default: (props: SectionsHarnessProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }; +}); + +function template(language = "chinese") { + const entries = [ + ["retrieve_subgraph_hop", "2", "^[1-3]$"], + ["no_think_mode", "是", "^(是|否)$"], + [ + "prompt_language", + language === "english" ? "英文" : "中文", + "^(中文|英文)$", + ], + ["domain", "常规", "^(医疗|金融|常规|法律法规)$"], + ["prompt_text", `${language} general`, "^[\\s\\S]{1,4096}$"], + ["synonym_merge_enable", "否", "^(是|否)$"], + ["disambiguation_enable", "否", "^(是|否)$"], + ]; + return parseAidpGraphTemplate({ + value: entries.map(([param_key, param_value, regexp]) => ({ + param_key, + param_value, + regexp, + param_name: param_key, + param_desc: "", + is_modifiable: true, + ...(param_key === "prompt_text" + ? { + template: { + 常规: `${language} general`, + 金融: `${language} finance`, + }, + } + : {}), + })), + }); +} + +function mount() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +beforeEach(() => { + api.listModels.mockResolvedValue({ + models: [ + { + model_name: "/models/embed", + display_name: "bge-m3", + model_type: "embedding", + }, + { model_name: "graph-llm", display_name: "Graph LLM", model_type: "llm" }, + { + model_name: "caption-vlm", + display_name: "Caption VLM", + model_type: "vlm", + }, + ], + }); + api.graphTemplate.mockImplementation(async (language) => template(language)); + api.createKb.mockResolvedValue({ kds_id: "new-kb" }); +}); + +describe("AKPR-D1-011 unified model query and graph templates", () => { + it("loads all three categories once, displays labels and submits model identifiers", async () => { + const user = userEvent.setup(); + mount(); + await screen.findByRole("option", { name: "bge-m3" }); + await waitFor(() => + expect(screen.getByLabelText("embedding")).toHaveValue("/models/embed"), + ); + expect(api.listModels).toHaveBeenCalledExactlyOnceWith("", "KnowledgeBase"); + expect(api.graphTemplate).not.toHaveBeenCalled(); + await user.type(screen.getByLabelText("name"), "资料库"); + await user.click(screen.getByLabelText("graph")); + await waitFor(() => + expect(screen.getByLabelText("prompt")).toHaveValue("chinese general"), + ); + await user.click(screen.getByText("aidpKnowledge.createSubmit")); + await waitFor(() => expect(api.createKb).toHaveBeenCalledOnce()); + const body = api.createKb.mock.calls[0][0]; + expect(body.embedding_model).toBe("/models/embed"); + expect(body.graph_config).toEqual({ + domain: "常规", + retrieve_subgraph_hop: "2", + no_think_mode: "是", + prompt_language: "中文", + prompt_text: "chinese general", + synonym_merge_enable: "否", + disambiguation_enable: "否", + llm_model_name: "graph-llm", + }); + expect(body.topk).toBe(10); + expect(body.similarity).toBe(0.6); + expect(body.vlm_model).toBe(""); + expect(body).not.toHaveProperty("smartsplit"); + }); + + it("switches server templates by domain and language, preserves manual text and restores on request", async () => { + const user = userEvent.setup(); + mount(); + await screen.findByRole("option", { name: "bge-m3" }); + await user.click(screen.getByLabelText("graph")); + await waitFor(() => + expect(screen.getByLabelText("prompt")).toHaveValue("chinese general"), + ); + await user.selectOptions(screen.getByLabelText("domain"), "金融"); + await waitFor(() => + expect(screen.getByLabelText("prompt")).toHaveValue("chinese finance"), + ); + await user.clear(screen.getByLabelText("prompt")); + await user.type(screen.getByLabelText("prompt"), "手工提示词"); + await user.selectOptions(screen.getByLabelText("language"), "英文"); + await waitFor(() => + expect(api.graphTemplate).toHaveBeenCalledWith("english"), + ); + expect(screen.getByLabelText("prompt")).toHaveValue("手工提示词"); + await user.click(screen.getByText("restore")); + expect(screen.getByLabelText("prompt")).toHaveValue("english finance"); + }); + + it("omits hidden graph and VLM selections after disabling both capabilities", async () => { + const user = userEvent.setup(); + mount(); + await screen.findByRole("option", { name: "bge-m3" }); + await user.type(screen.getByLabelText("name"), "资料库"); + await user.click(screen.getByLabelText("graph")); + await waitFor(() => + expect(screen.getByLabelText("prompt")).toHaveValue("chinese general"), + ); + await user.click(screen.getByLabelText("graph")); + await user.click(screen.getByLabelText("caption")); + await user.click(screen.getByLabelText("caption")); + await user.click(screen.getByText("aidpKnowledge.createSubmit")); + await waitFor(() => expect(api.createKb).toHaveBeenCalledOnce()); + expect(api.createKb.mock.calls[0][0].graph_config).toBeUndefined(); + expect(api.createKb.mock.calls[0][0].vlm_model).toBe(""); + }); + + it("blocks submission if the graph template fails to load", async () => { + api.graphTemplate.mockRejectedValue(new Error("unavailable")); + const user = userEvent.setup(); + mount(); + await screen.findByRole("option", { name: "bge-m3" }); + await user.click(screen.getByLabelText("graph")); + await screen.findByText("aidpKnowledge.createConfigurationLoadFailed"); + expect( + screen.getByText("aidpKnowledge.createSubmit").closest("button"), + ).toBeDisabled(); + expect(api.createKb).not.toHaveBeenCalled(); + }); + + it("validates complete templates and reads Chinese choices and booleans", () => { + expect(graphChoices(template(), "domain")).toEqual([ + "医疗", + "金融", + "常规", + "法律法规", + ]); + expect(() => parseAidpGraphTemplate({ value: [] })).toThrow(); + expect(graphBoolean("否")).toBe(false); + expect(graphBoolean("是")).toBe(true); + expect(graphBoolean(undefined)).toBeUndefined(); + }); +}); diff --git a/test/automation/d1/AidpCreateKbSections.test.tsx b/test/automation/d1/AidpCreateKbSections.test.tsx new file mode 100644 index 0000000000..e2644b96c5 --- /dev/null +++ b/test/automation/d1/AidpCreateKbSections.test.tsx @@ -0,0 +1,119 @@ +import React from "react"; +import { Form } from "antd"; +import type { FormInstance } from "antd"; +import type { TFunction } from "i18next"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import AidpCreateKbSections from "@/ext_components/aidp/components/AidpCreateKbSections"; +import zh from "@/public/locales/zh/common.json"; +import { parseAidpGraphTemplate } from "@/lib/aidpGraphConfig"; + +const hints = [ + "createChunkSectionHint", + "createChunkTokenHint", + "createOverlapPercentHint", + "createVectorSectionHint", + "createEmbeddingHint", + "createCaptionEnableHint", + "createRetrievalSectionHint", + "createSimilarityHint", + "createTopkHint", + "createGraphSectionHint", + "createGraphModelHint", + "createGraphHopHint", + "createGraphThinkingHint", + "createGraphLanguageHint", + "createGraphDomainHint", + "createGraphPromptHint", + "createGraphSynonymHint", + "createGraphDisambiguationHint", +]; +const t = ((key: string) => + (zh as Record)[key] || key) as TFunction; +const entries = [ + ["retrieve_subgraph_hop", "2", "^[1-3]$"], + ["no_think_mode", "是", "^(是|否)$"], + ["prompt_language", "中文", "^(中文|英文)$"], + ["domain", "常规", "^(医疗|金融|常规|法律法规)$"], + ["prompt_text", "提取实体", "^[\\s\\S]{1,4096}$"], + ["synonym_merge_enable", "否", "^(是|否)$"], + ["disambiguation_enable", "否", "^(是|否)$"], +]; +const template = parseAidpGraphTemplate({ + value: entries.map(([param_key, param_value, regexp]) => ({ + param_key, + param_value, + regexp, + param_name: param_key, + param_desc: "", + is_modifiable: true, + })), +}); + +describe("AKPR-D1-011 real creation controls and tooltip copy", () => { + it("renders all 18 localized hints and enforces the server character constraint", async () => { + let form: FormInstance; + function Wrapper() { + const [instance] = Form.useForm(); + form = instance; + return ( + {}} + /> + ); + } + render(); + await act(async () => { + form.setFieldsValue({ + graph_prompt_text: "提取实体", + graph_domain: "常规", + graph_prompt_language: "中文", + graph_thinking: false, + }); + }); + await userEvent + .setup() + .click( + screen.getByRole("switch", { + name: t("aidpKnowledge.createGraphEnable"), + }), + ); + for (const hint of hints) + expect( + screen.getByLabelText(t(`aidpKnowledge.${hint}`)), + ).toBeInTheDocument(); + expect( + document.getElementById("aidp-create-graph-panel"), + ).not.toHaveTextContent(t("aidpKnowledge.createGraphTopk")); + await act(async () => { + form.setFieldValue("graph_prompt_text", "字😀".repeat(2048)); + }); + await expect( + form!.validateFields(["graph_prompt_text"]), + ).resolves.toMatchObject({ graph_prompt_text: "字😀".repeat(2048) }); + await act(async () => { + form.setFieldValue("graph_prompt_text", "字".repeat(4097)); + }); + await expect( + form!.validateFields(["graph_prompt_text"]), + ).rejects.toHaveProperty("errorFields"); + await act(async () => { + form.setFieldValue("graph_prompt_text", ""); + }); + await expect( + form!.validateFields(["graph_prompt_text"]), + ).rejects.toHaveProperty("errorFields"); + }); +}); diff --git a/test/backend/database/test_user_tenant_db.py b/test/backend/database/test_user_tenant_db.py index b8b8bcb153..41c8accabe 100644 --- a/test/backend/database/test_user_tenant_db.py +++ b/test/backend/database/test_user_tenant_db.py @@ -175,6 +175,19 @@ def test_get_user_email_map_returns_only_non_empty_emails(monkeypatch, mock_sess } query.filter.assert_called_once() +def test_get_user_email_map_applies_optional_tenant_filter(monkeypatch, mock_session): + session, query = mock_session + filtered = query.filter.return_value + scoped = filtered.filter.return_value + scoped.all.return_value = [("user-1", "creator@example.com")] + context = MagicMock() + context.__enter__.return_value = session + monkeypatch.setattr("backend.database.user_tenant_db.get_db_session", lambda: context) + assert get_user_email_map(["user-1"], tenant_id="tenant-a") == {"user-1": "creator@example.com"} + filtered.filter.assert_called_once() + scoped.all.assert_called_once() + + def test_get_user_tenant_by_user_id_not_found(monkeypatch, mock_session): """Test retrieval of user tenant relationship when record does not exist""" session, query = mock_session diff --git a/test/cases/d1/aidp-knowledge-pages-refactor.yaml b/test/cases/d1/aidp-knowledge-pages-refactor.yaml index 657eb541f2..873d16f123 100644 --- a/test/cases/d1/aidp-knowledge-pages-refactor.yaml +++ b/test/cases/d1/aidp-knowledge-pages-refactor.yaml @@ -382,71 +382,71 @@ cases: priority: P0 status: active automation: automated - objective: Verify graph configuration serialization, the inverse thinking mapping, the independent Top K values and the prompt byte limit. + objective: Verify live graph configuration serialization, the inverse Chinese thinking mapping, absence of graph Top K and the 1 to 4096 character prompt limit. preconditions: - - Prepare graph payloads with the switch on and off, both languages and prompt lengths around the byte limit. - test_data: {profile: mock, domains: [general, medical], prompt_bytes: [2048, 2049], languages: [chinese, english]} + - Prepare graph payloads with the switch on and off, both languages and Unicode prompt lengths around the character limit. + test_data: {profile: mock, domains: [常规, 医疗, 法律法规], prompt_characters: [0, 1, 4096, 4097], languages: [中文, 英文]} steps: - order: 1 action: Submit a graph configuration with the thinking switch enabled. - expected: The thinking mode field is false and the graph keys use their documented names. + expected: The no_think_mode field is the Chinese string 否 and only the seven template keys plus llm_model_name are forwarded. - order: 2 - action: Submit a knowledge base Top K together with a graph Top K. - expected: The two values are serialized independently without overwriting each other. + action: Submit a knowledge base Top K with an obsolete graph Top K key. + expected: The outer topk is preserved and the obsolete graph key is not forwarded. - order: 3 - action: Submit prompts at and beyond the byte boundary together with a disabled graph. + action: Submit prompts at and beyond the character boundary together with a disabled graph. expected: The limit passes at the boundary, longer input is rejected and a disabled graph omits its configuration entirely. expected_results: - The thinking switch maps inversely onto the thinking mode field. - - Both Top K values survive independently. - - The prompt limit is enforced in UTF-8 bytes. + - Only the outer knowledge-base topk is submitted. + - The prompt limit is enforced as 1 to 4096 Unicode characters. forbidden_side_effects: - No hidden previous graph configuration is submitted when the graph is off. - - No graph Top K overwrites the knowledge base Top K. + - No graph Top K key reaches AIDP. - No invented JSON key names. unit_boundary: Graph configuration validation and serialization inputs: - graph switch - domain - - graph top k + - obsolete graph top k - hop count - thinking switch - prompt language - prompt text assertions: - - The thinking mode field is false when thinking is enabled. - - Graph and knowledge base Top K stay independent. - - Prompt length is measured in UTF-8 bytes. + - The thinking mode field is 否 when thinking is enabled. + - Only outer knowledge-base topk survives. + - Prompt length is measured in Unicode characters. - Disabled graph omits its configuration. - case_id: AKPR-D1-011 feature_id: AKPR-001 requirement_ids: [AKPR-001] business_rule_ids: [AKPR-RULE-012] - title: Graph and multimodal models are queried from separate categories with independent caches + title: One model query populates three categories and graph templates preserve edited prompts type: FE-COMP priority: P0 status: active automation: automated - objective: Verify the model category split, the independent query caches and the omission of hidden model values. + objective: Verify one model query, category separation by model_type, label and identifier mapping, hidden value omission and graph template interaction. preconditions: - - Render the creation form with stubbed model queries per category and both capability switches toggled. + - Render the creation form with one model response, graph templates in both languages and both capability switches toggled. test_data: {profile: mock, categories: [llm, vlm, embedding]} steps: - order: 1 action: Enable the graph section. - expected: The graph model query uses the llm category and fills the graph model field. + expected: One all-category query fills separate embedding, llm and vlm controls by model_type; graph activation reads the template endpoint. - order: 2 action: Enable multimodal captioning. - expected: The multimodal model query uses the vlm category and fills the multimodal field independently. + expected: The multimodal field uses the vlm entries from the existing model result without another request. - order: 3 action: Disable each capability after a selection. expected: The hidden model value is absent from the submitted payload. expected_results: - - Each model field is populated from its own category. - - Caches and selections never mix. + - Each field is populated from its model_type, displays display_name and submits model_name. + - A shared model cache never mixes category selections. Domain and language changes preserve manual prompts, and restore uses the current template. Template failures block graph-enabled creation. - Disabled capabilities submit no model value. forbidden_side_effects: - - No llm result used for the multimodal field. + - No llm result used for the multimodal or embedding field. - No hidden previous model resubmitted. - No unverified model name silently defaulted. unit_boundary: Creation form model queries, caches and hidden value handling @@ -456,8 +456,8 @@ cases: - selected models - query cache keys assertions: - - Graph query uses llm and multimodal uses vlm. - - Caches are keyed independently. + - One query returns all categories and controls filter by model_type. + - Model data uses one shared query cache; templates are cached by language. - Disabled capability clears its submitted model. - case_id: AKPR-D1-012 feature_id: AKPR-001 diff --git a/test/cases/d2/aidp-knowledge-pages-refactor.yaml b/test/cases/d2/aidp-knowledge-pages-refactor.yaml index 6d24bc4c26..6a1153031b 100644 --- a/test/cases/d2/aidp-knowledge-pages-refactor.yaml +++ b/test/cases/d2/aidp-knowledge-pages-refactor.yaml @@ -54,7 +54,7 @@ cases: priority: P0 status: active automation: automated - objective: Verify the create proxy forwards chunking, retrieval, graph, model and safety fields while preserving legacy field names and existing defaults. + objective: Verify the create proxy forwards the verified chunking, retrieval and model fields and graph JSON string, omits obsolete graph Top K and smartsplit, and preserves existing product defaults. preconditions: - An authenticated create request reaches the AIDP management proxy with a recorded upstream payload. test_data: {profile: mock, chunk_mode: [0, 1], graph_enabled: [true, false], multimodal: [true, false]} @@ -132,31 +132,31 @@ cases: feature_id: AKPR-001 requirement_ids: [AKPR-001] business_rule_ids: [AKPR-RULE-012] - title: Model queries select a single category per control + title: Model query returns all advertised types when no service filter is supplied type: CONTRACT priority: P0 status: active automation: automated - objective: Verify the model query proxy passes one category per request and returns only models applicable to the knowledge base application. + objective: Verify the model query proxy omits the service filter for the unified query and returns models applicable to KnowledgeBase with their types, display names and identifiers. preconditions: - - A controllable upstream model fixture serves distinct payloads per category. + - A controllable upstream model fixture serves an all-category payload and optional category-filtered responses. test_data: {profile: mock, categories: [llm, vlm, embedding]} steps: - order: 1 - action: Query the graph model list. - expected: The request carries the llm category and the response exposes only llm models. + action: Query all models without a service parameter. + expected: The request has app=KnowledgeBase without service and exposes embedding, llm and vlm models with model_type. - order: 2 - action: Query the multimodal model list. + action: Query models with an explicit vlm service filter. expected: The request carries the vlm category and the response exposes only vlm models. - order: 3 action: Query an unsupported or failing category. expected: A stable error or an empty result is returned without falling back to another category. expected_results: - - One category is requested per control. - - Responses are not reused across categories. + - One query populates all three controls. + - Controls split the returned model types without changing identifiers. - Failures do not silently substitute another category. forbidden_side_effects: - - No shared cache entry across categories. + - No model_type category mixing. - No fabricated model identifier on failure. - No change to existing non-AIDP model endpoints. contract: @@ -164,10 +164,10 @@ cases: method: GET endpoint: /aidp-mgmt/models request_expectations: - - The service parameter carries exactly one requested category. + - The service parameter is optional; omitting it retrieves all categories. - The application parameter keeps the knowledge base scope. - No credential is returned to the browser. response_expectations: - - Only models of the requested category are returned. + - Model response preserves model_type, model_name and display_name for applicable models. - An unavailable category yields a stable error or an empty list. - Results are filtered to the knowledge base application scope. diff --git a/test/changes/bugs/aidp-live-create-contract.yaml b/test/changes/bugs/aidp-live-create-contract.yaml new file mode 100644 index 0000000000..dc930c702f --- /dev/null +++ b/test/changes/bugs/aidp-live-create-contract.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +changes: + - change_id: AKPR-BUG-004 + change_type: bugfix + bug_id: AKPR-BUG-004 + title: AIDP live creation rejects graph keys and value formats accepted by the mock + description: Align models, graph templates, create serialization and mock validation with the live requests supplied during integration. Resolve creator names from Nexent permission owners and display local import in both languages. + observed_behavior: Creation returns 400 for graph Top K and boolean or English graph values. The mock drops graph fields without validation. The UI queries model categories separately, limits prompts to 2048 bytes and displays empty remote creator and import metadata. + expected_behavior: Creation submits seven template graph keys plus the LLM identifier using Chinese strings and a 1 to 4096 character prompt. Model categories share one query; server templates switch by language and domain without overwriting manual text. Creators resolve from Nexent users and import source is localized local import. Unknown creator records display an em dash. Update time, file count and capacity are deferred. + escaped_stage: D3 + coverage_gap: + type: missing_contract + description: The old baseline encoded graph Top K, booleans and a byte limit. The mock accepted these incorrect requests and ignored graph fields, so successful mock execution did not prove live request compatibility. + feature_contract_changed: true + affected_features: {existing: [], added: [], modified: [AKPR-001], retired: []} + affected_cases: {existing: [], added: [], modified: [AKPR-D1-010, AKPR-D1-011, AKPR-D2-002, AKPR-D2-004], retired: []} diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index 3bdbe19161..cda5c9de66 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -45,6 +45,7 @@ import logging import mimetypes import os +import re import time import uuid from datetime import datetime, timezone @@ -725,6 +726,59 @@ class CreateKbBody(BaseModel): embedding_model: Optional[str] = None is_multimodal: Optional[bool] = None vision_model: Optional[str] = None + chunk_token_num: int = 1024 + chunk_overlap_num: int = 128 + chunk_mode: int = 0 + is_personal: int = 0 + topk: int = 10 + similarity: float = 0.6 + caption_enable: int = 0 + vlm_model: str = "" + is_exist_graph: bool = False + graph_config: Optional[str] = None + + +def _graph_template(language: str) -> Dict[str, Any]: + """Contract fixtures; English templates are synthetic, not production defaults.""" + english = language == "english" + prompts = { + domain: (f"Extract entity relationship triples for the {domain} domain." + if english else f"你是{domain}领域的信息抽取专家,请抽取明确的实体及其关系三元组。") + for domain in ("医疗", "金融", "常规", "法律法规") + } + entries = [ + ("retrieve_subgraph_hop", "子图扩展跳数", "2", "^[1-3]$", True), + ("no_think_mode", "禁用LLM思考过程", "是", "^(是|否)$", True), + ("prompt_language", "提示词语言", "英文" if english else "中文", "^(中文|英文)$", False), + ("domain", "领域类型", "常规", "^(医疗|金融|常规|法律法规)$", False), + ("prompt_text", "知识抽取提示词", prompts["常规"], r"^[\s\S]{1,4096}$", False), + ("synonym_merge_enable", "同义词检测开关", "否", "^(是|否)$", True), + ("disambiguation_enable", "实体消歧开关", "否", "^(是|否)$", True), + ] + value = [dict(param_key=key, param_name=name, param_value=default, + regexp=regexp, is_modifiable=modifiable, param_desc=name) + for key, name, default, regexp, modifiable in entries] + value[4]["template"] = prompts + return {"value": value} + + +def _validate_graph_request(body: CreateKbBody) -> None: + if not body.is_exist_graph: + return + try: + config = json.loads(body.graph_config or "") + except (TypeError, ValueError) as exc: + raise HTTPException(400, "graph_config must be a JSON string") from exc + parameters = _graph_template("chinese")["value"] + allowed = {p["param_key"] for p in parameters} | {"llm_model_name"} + if not isinstance(config, dict) or set(config) - allowed: + raise HTTPException(400, "Unrecognized graph configuration parameter") + for parameter in parameters: + value = config.get(parameter["param_key"]) + if not isinstance(value, str) or re.fullmatch(parameter["regexp"], value) is None: + raise HTTPException(400, f"Invalid graph parameter: {parameter['param_key']}") + if "llm_model_name" in config and not isinstance(config["llm_model_name"], str): + raise HTTPException(400, "Invalid llm_model_name") class UpdateKbBody(BaseModel): @@ -1025,10 +1079,12 @@ def create_knowledge_base( ) -> JSONResponse: """Create a new knowledge base. AIDP uses PUT on the collection endpoint.""" _check_auth(authorization) + _validate_graph_request(body) kds_id = f"aidp-kb-{uuid.uuid4().hex[:8]}" now = int(time.time()) new_kb = { + **body.model_dump(exclude_none=True), "kds_id": kds_id, "kds_name": body.name, "description": body.description or "", @@ -1051,6 +1107,15 @@ def create_knowledge_base( return JSONResponse(content=new_kb) +@app.get(f"{_KB_PREFIX}/GraphConfigTemplate") +def graph_config_template( + language: str = Query("chinese", pattern="^(chinese|english)$"), + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + _check_auth(authorization) + return JSONResponse(content=_graph_template(language)) + + @app.get(f"{_KB_PREFIX}/{{kds_id}}") def get_knowledge_base( kds_id: str, @@ -1630,12 +1695,18 @@ def fusion_search( # ``application`` field determines which AIDP app a model can serve (the # backend's ``_is_kb_applicable`` post-filters by "All" or the requested app). _MOCK_MODELS: List[Dict[str, Any]] = [ + { + "model_name": "/models/FileEmbeddingModel_v0_1_1/v1.0.0/FileEmbeddingModel_v0_1_1", + "display_name": "bge-m3", "model_type": "embedding", + "application": ["KnowledgeBase", "MemoryBase"], "service": "embedding", + }, { "api_key": "", "application": "All", "created_at": 1782716626, "max_tokens": 32768, "model_name": "model_1", + "display_name": "Qwen3-8B", "model_type": "llm", "properties": {"description": "General purpose LLM.", "model_type": "external"}, "service": "llm", "temperature": 0.6, @@ -1646,6 +1717,7 @@ def fusion_search( { "application": ["KnowledgeBase"], "model_name": "Qwen3-VL-8B-Instruct", + "display_name": "Qwen3-VL-8B-Instruct", "model_type": "vlm", "properties": {"description": "Vision-language model served internally for caption generation.", "model_type": "internal"}, "service": "llm", "url": "http://caption-service.model-service.svc.cluster.local:8111/v1/chat/completions", @@ -1656,6 +1728,7 @@ def fusion_search( "created_at": 1783070801, "max_tokens": 32768, "model_name": "Qwen3-VL-32B-Instruct", + "display_name": "Qwen3-VL-32B-Instruct", "model_type": "vlm", "properties": {"description": "Larger vision-language model for high-quality captioning.", "model_type": "external"}, "service": "llm", "temperature": 0.6, @@ -1669,6 +1742,7 @@ def fusion_search( "created_at": 1783474808, "max_tokens": 32780, "model_name": "InternVL2-26B", + "display_name": "InternVL2-26B", "model_type": "vlm", "properties": {"description": "Open-source multimodal model.", "model_type": "external"}, "service": "llm", "temperature": 1.5, @@ -1683,6 +1757,7 @@ def fusion_search( "application": ["DocumentParsing"], "created_at": 1783062626, "model_name": "doc-parser-only", + "display_name": "Document parser", "model_type": "llm", "properties": {"description": "Should NOT appear for KnowledgeBase.", "model_type": "external"}, "service": "llm", }, @@ -1691,7 +1766,7 @@ def fusion_search( @app.get(_MODELS_PREFIX) def list_models( - service: str = Query("llm"), + service: str = Query(""), app: str = Query("KnowledgeBase"), authorization: Optional[str] = Header(default=None), ) -> JSONResponse: @@ -1701,10 +1776,11 @@ def list_models( raw seed data here. """ _check_auth(authorization) - logger.info("LIST MODELS service=%s app=%s returned=%d", service, app, len(_MOCK_MODELS)) + models = [m for m in _MOCK_MODELS if not service or m["model_type"] == service] + logger.info("LIST MODELS service=%s app=%s returned=%d", service, app, len(models)) return JSONResponse(content={ "service": service, - "models": _MOCK_MODELS, + "models": models, }) diff --git a/test/ext_components/aidp/test_aidp_creator_service.py b/test/ext_components/aidp/test_aidp_creator_service.py new file mode 100644 index 0000000000..4c289f1e8f --- /dev/null +++ b/test/ext_components/aidp/test_aidp_creator_service.py @@ -0,0 +1,36 @@ +"""Creator metadata remains local, batched and scoped to the current tenant.""" +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def creator_module(monkeypatch): + database = ModuleType("database.user_tenant_db") + database.get_user_email_map = MagicMock(return_value={"u-1": "admin@example.test"}) + monkeypatch.setitem(sys.modules, "database.user_tenant_db", database) + source = Path(__file__).parents[3] / "backend/ext_components/aidp/services/aidp_creator_service.py" + spec = importlib.util.spec_from_file_location("isolated_creator_service", source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_creator_lookup_is_batched_and_tenant_scoped(creator_module): + result = creator_module.get_nexent_creator_names(["u-1", None, "u-1", "u-2"], "tenant-a") + creator_module.get_user_email_map.assert_called_once_with(["u-1", "u-2"], tenant_id="tenant-a") + assert result == {"u-1": "admin@example.test"} + + +def test_missing_owners_skip_database_query(creator_module): + assert creator_module.get_nexent_creator_names([None, ""], "tenant-a") == {} + creator_module.get_user_email_map.assert_not_called() + + +def test_lookup_failure_does_not_replace_owner_with_remote_or_current_user(creator_module): + creator_module.get_user_email_map.side_effect = RuntimeError("database unavailable") + assert creator_module.get_nexent_creator_names(["deleted-owner"], "tenant-a") == {} diff --git a/test/ext_components/aidp/test_aidp_live_create_contract.py b/test/ext_components/aidp/test_aidp_live_create_contract.py new file mode 100644 index 0000000000..1d816e653c --- /dev/null +++ b/test/ext_components/aidp/test_aidp_live_create_contract.py @@ -0,0 +1,109 @@ +"""Regression coverage for the AIDP request captured during live integration.""" +import json +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from test.ext_components.aidp.test_aidp_service import aidp_service_module +from test.ext_components.aidp.test_aidp_mgmt_mock_server import mock_aidp_server + + +def graph_config(): + return { + "retrieve_subgraph_hop": "2", "no_think_mode": "是", + "prompt_language": "中文", "domain": "常规", "prompt_text": "提取实体关系。", + "synonym_merge_enable": "否", "disambiguation_enable": "否", + "llm_model_name": "qwen3_8b", + } + + +def client_for(module, data): + client = MagicMock() + response = MagicMock(status_code=200) + response.json.return_value = data + client.get.return_value = response + client.put.return_value = response + module.http_client_manager.get_sync_client.return_value = client + return client + + +def test_forwarded_create_matches_live_contract(aidp_service_module, mock_aidp_server): + module = aidp_service_module + upstream = TestClient(mock_aidp_server.app) + module.http_client_manager.get_sync_client.return_value = upstream + result = module.create_aidp_kb_impl("http://testserver", "mock-aidp-key", { + "name": "中文图谱知识库", "description": "中文测试资料", + "embedding_model": "/models/bge-m3", "chunk_token_num": 512, + "chunk_overlap_num": 51, "similarity": 0.6, "topk": 10, + "is_exist_graph": True, "graph_config": graph_config(), "smartsplit": 1, + }) + saved = upstream.get( + f"{mock_aidp_server._KB_PREFIX}/{result['kds_id']}", + headers={"Authorization": "Bearer mock-aidp-key"}, + ).json() + assert json.loads(saved["graph_config"]) == graph_config() + assert saved["embedding_model"] == "/models/bge-m3" + assert saved["topk"] == 10 and saved["is_personal"] == 0 + assert saved["vlm_model"] == "" and saved["caption_enable"] == 0 + assert "smartsplit" not in saved + + +@pytest.mark.parametrize("key,value", [ + ("retrieve_default_topk", "5"), ("retrieve_topk", "5"), + ("no_think_mode", True), ("synonym_merge_enable", False), + ("disambiguation_enable", False), ("domain", "general"), + ("prompt_language", "chinese"), ("retrieve_subgraph_hop", 2), + ("prompt_text", ""), ("prompt_text", "字" * 4097), +]) +def test_mock_rejects_the_live_failures(mock_aidp_server, key, value): + config = graph_config() + config[key] = value + response = TestClient(mock_aidp_server.app).put(mock_aidp_server._KB_PREFIX, + headers={"Authorization": "Bearer mock-aidp-key"}, + json={"name": "错误参数", "is_exist_graph": True, + "graph_config": json.dumps(config, ensure_ascii=False)}) + assert response.status_code == 400 + + +def test_prompt_limit_counts_characters_and_disabled_graph_omits_config(aidp_service_module): + module = aidp_service_module + config = graph_config() + config["prompt_text"] = "字😀" * 2048 + serialized = json.loads(module._serialize_graph_config(config)) + assert len(serialized["prompt_text"]) == 4096 + config["prompt_text"] += "字" + with pytest.raises(module.AppException): + module._serialize_graph_config(config) + disabled = module._apply_create_defaults({"name": "无图谱", "is_exist_graph": False, + "graph_config": config, "llm_model_name": "hidden"}) + assert "graph_config" not in disabled and "llm_model_name" not in disabled + + +@pytest.mark.parametrize("language", ["chinese", "english"]) +def test_template_query_and_model_query_use_single_endpoints(aidp_service_module, mock_aidp_server, language): + module = aidp_service_module + upstream = TestClient(mock_aidp_server.app) + module.http_client_manager.get_sync_client.return_value = upstream + template = module.get_aidp_graph_template_impl("http://testserver", "mock-aidp-key", language) + assert len(template["value"]) == 7 + assert next(p for p in template["value"] if p["param_key"] == "prompt_text")["template"]["法律法规"] + models = module.list_aidp_models_impl("http://testserver", "mock-aidp-key", "") + assert {"llm", "vlm", "embedding"} <= {m["model_type"] for m in models["models"]} + assert "doc-parser-only" not in {m["model_name"] for m in models["models"]} + + +def test_all_models_does_not_send_service_filter(aidp_service_module): + client = client_for(aidp_service_module, {"models": []}) + aidp_service_module.list_aidp_models_impl("http://example.test", "key", "") + assert "app=KnowledgeBase" in client.get.call_args.args[0] + assert "service=" not in client.get.call_args.args[0] + + +def test_old_internal_aliases_translate_without_graph_topk(aidp_service_module): + config = {**graph_config(), "domain": "general", "prompt_language": "chinese", + "no_think_mode": False, "retrieve_subgraph_hop": 3, "retrieve_default_topk": 5} + result = json.loads(aidp_service_module._serialize_graph_config(config)) + assert result["domain"] == "常规" and result["prompt_language"] == "中文" + assert result["no_think_mode"] == "否" and result["retrieve_subgraph_hop"] == "3" + assert "retrieve_default_topk" not in result and "retrieve_topk" not in result diff --git a/test/ext_components/aidp/test_aidp_mgmt_app.py b/test/ext_components/aidp/test_aidp_mgmt_app.py index 7581c9cc24..dd01ec0af1 100644 --- a/test/ext_components/aidp/test_aidp_mgmt_app.py +++ b/test/ext_components/aidp/test_aidp_mgmt_app.py @@ -202,6 +202,39 @@ def _bearer() -> dict: return {"Authorization": "Bearer fake-token"} +def test_detail_creator_comes_from_nexent_owner_in_current_tenant(monkeypatch): + from ext_components.aidp.apps import aidp_mgmt_app as module + lookup = MagicMock(return_value={"local-owner": "admin@example.test"}) + monkeypatch.setattr(module.perms, "require_permission", lambda *a, **kw: MagicMock(permission="EDIT")) + monkeypatch.setattr(module, "get_aidp_kb_impl", lambda *a: {"kds_name": "资料库", "user_name": "remote-user"}) + monkeypatch.setattr(module.aidp_permission_db, "get_permission_by_kb_id", + lambda *a: {"owner_user_id": "local-owner", "group_ids": []}) + monkeypatch.setattr(module, "get_nexent_creator_names", lookup) + result = _client().get("/aidp-mgmt/knowledge-bases/kb-1", headers=_bearer()).json() + assert result["creator_name"] == "admin@example.test" + assert result["created_by"] == "local-owner" + lookup.assert_called_once_with(["local-owner"], TENANT_ID) + + +def test_models_without_service_query_requests_all_categories(monkeypatch): + from ext_components.aidp.apps import aidp_mgmt_app as module + lookup = MagicMock(return_value={"models": [], "service": ""}) + monkeypatch.setattr(module, "list_aidp_models_impl", lookup) + response = _client().get("/aidp-mgmt/models?app=KnowledgeBase", headers=_bearer()) + assert response.status_code == 200 + assert lookup.call_args.args[2:] == ("", "KnowledgeBase") + + +def test_graph_template_route_is_authenticated_and_not_a_knowledge_base_id(monkeypatch): + from ext_components.aidp.apps import aidp_mgmt_app as module + lookup = MagicMock(return_value={"value": [{"param_key": "domain"}]}) + monkeypatch.setattr(module, "get_aidp_graph_template_impl", lookup) + result = _client().get("/aidp-mgmt/knowledge-bases/graph-template?language=english", headers=_bearer()) + assert result.status_code == 200 and result.json()["value"][0]["param_key"] == "domain" + lookup.assert_called_once_with(SERVER_URL, API_KEY, "english") + assert _client().get("/aidp-mgmt/knowledge-bases/graph-template").status_code == 401 + + # --- Auth (401) ----------------------------------------------------------- diff --git a/test/ext_components/aidp/test_aidp_service.py b/test/ext_components/aidp/test_aidp_service.py index 536f846158..d091b0b9bb 100644 --- a/test/ext_components/aidp/test_aidp_service.py +++ b/test/ext_components/aidp/test_aidp_service.py @@ -628,7 +628,7 @@ def test_fills_all_defaults_when_payload_is_minimal(self, aidp_mod): assert result["is_personal"] == 0 assert result["topk"] == 10 assert result["similarity"] == 0.0 - assert result["smartsplit"] == 1 + assert "smartsplit" not in result assert result["caption_enable"] == 0 def test_preserves_client_supplied_values(self, aidp_mod): @@ -688,6 +688,7 @@ def test_graph_llm_model_is_serialized_inside_aidp_graph_config(self, aidp_mod): "graph_config": { "domain": "general", "llm_model_name": "model-graph", + "prompt_text": "Extract entity relationships.", }, } ) @@ -700,7 +701,7 @@ def test_legacy_top_level_graph_llm_model_is_moved_into_graph_config(self, aidp_ { "name": "kb-graph", "is_exist_graph": True, - "graph_config": {"domain": "general"}, + "graph_config": {"domain": "general", "prompt_text": "Extract entity relationships."}, "llm_model_name": "model-graph", } ) @@ -2500,7 +2501,7 @@ def test_missing_models_key(self, aidp_service_module): assert result["total_count"] == 0 def test_non_dict_response_raises(self, aidp_service_module): - mock_resp = _make_success_response(["not-dict"]) + mock_resp = _make_success_response("not-an-object-or-array") _setup_mock_client(aidp_service_module, method="get", response=mock_resp) with pytest.raises(AppException) as exc_info: diff --git a/test/features/aidp-knowledge-pages-refactor.yaml b/test/features/aidp-knowledge-pages-refactor.yaml index a864e60bee..6960b97b88 100644 --- a/test/features/aidp-knowledge-pages-refactor.yaml +++ b/test/features/aidp-knowledge-pages-refactor.yaml @@ -20,7 +20,7 @@ features: - rule_id: AKPR-RULE-005 description: Selecting a knowledge base opens a full-page file view for that base only; returning restores the previous search, page, view mode and column setting, and a delayed response from a previously opened base never overwrites the current one. - rule_id: AKPR-RULE-006 - description: Displayed metadata uses only real response values; unknown values render as an em dash, the personal/enterprise type comes from the AIDP is_private field instead of Nexent group permission, and personal capacity uses only a valid current_cap value in the documented unit. + description: Displayed metadata uses real response values, except the creator name resolves the Nexent permission owner in the current tenant; unknown or missing owners render as an em dash. The personal/enterprise type comes from AIDP rather than Nexent group permission. Update time, file count and capacity remain unchanged in this integration fix. - rule_id: AKPR-RULE-007 description: Existing EDIT and READ_ONLY permissions, unavailable resource states and the hidden modification entry points for them are preserved in both overview and file view. - rule_id: AKPR-RULE-008 @@ -32,9 +32,9 @@ features: - rule_id: AKPR-RULE-011 description: Chunking submits chunk_mode 0 for smart splitting and 1 for legal clauses, converts the overlap percentage to floor(tokens * percent / 100) integer tokens bounded by 50 percent of the chunk size, and keeps the existing default chunking values. - rule_id: AKPR-RULE-012 - description: The graph model is queried from the llm model category and the multimodal model from the vlm category, with independent fields and query caches; disabling either capability omits its hidden previous configuration instead of resubmitting it. + description: One KnowledgeBase ModelService query populates LLM, VLM and embedding controls by model_type; display_name is displayed and model_name is submitted. Graph defaults, choices, constraints and domain prompts come from GraphConfigTemplate, queried by language. Domain or language changes update an unchanged default prompt and preserve manually edited text, with an explicit restore action. Configuration errors block graph-enabled creation. Disabling a capability omits its hidden configuration. - rule_id: AKPR-RULE-013 - description: Graph configuration serializes into the documented graph_config payload with documented defaults, maps the thinking switch inversely onto no_think_mode, keeps the graph Top K independent from the knowledge base Top K, and limits the editable prompt to 2048 UTF-8 bytes. + description: Graph configuration is a JSON string containing the seven live template keys plus llm_model_name; hop count is a string, switches are Chinese 是 or 否, language and domain use Chinese values, and prompt length is 1 to 4096 characters. Thinking maps inversely onto no_think_mode. No graph Top K is displayed or submitted; the outer knowledge-base topk remains supported. Tooltips use the recorded AIDP screenshots without renaming existing fields. - rule_id: AKPR-RULE-015 description: The overview guide uses a rounded pale-blue gradient introduction area, a configuration-flow heading and three 156px-high step cards separated by 16px gaps; the knowledge-base cards keep a fixed Nexent icon, a full-width two-line description, and update time, document count and capacity on one aligned footer row, with stable card and right-aligned pagination geometry. Fonts and icons retain Nexent conventions. - rule_id: AKPR-RULE-016 diff --git "a/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx" "b/test/generated/Nexent_\346\265\213\350\257\225\345\237\272\347\272\277.xlsx" index 952855d7b82d2431e371d2a5bb259aa7d28ebc9b..8fea6f9c245c860b7f890fa2597387c3b9d242bb 100644 GIT binary patch delta 23583 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� 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 ZKkUMJa(DOWyrwz+c{d{(aqE-be*hCDPJI9X diff --git a/test/manifests/aidp-create-live-contract.yaml b/test/manifests/aidp-create-live-contract.yaml new file mode 100644 index 0000000000..a3b89be88a --- /dev/null +++ b/test/manifests/aidp-create-live-contract.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' +cases: +- case_id: AKPR-D1-011 + stage: D1 + status: implemented + contract_hash: sha256:f2872957a3d2f7e49abdaf84e15a0bb4b0ac6bc46b8d5116ac7c58ced7988633 + implementation_hash: sha256:0074537ecfe51f517f3d749371770d0d8b5e1f7a3b96bef2a13e16f63fc18cbd + implementation: + - framework: vitest + file: test/automation/d1/AidpCreateKbPage.test.tsx + selector: AKPR-D1-011 + profiles: + - mock + - framework: vitest + file: test/automation/d1/AidpCreateKbSections.test.tsx + selector: AKPR-D1-011 + profiles: + - mock + notes: Run from frontend with vitest.aidp.config.ts. Mock component coverage; live + AIDP verification is still required. From e75c8d11f6cac54ee0900c93cf64fd03e180dcc3 Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Fri, 9 Oct 2026 18:32:00 +0800 Subject: [PATCH 31/36] fix(aidp): return full captured Chinese graph prompts in mock --- docs/design/aidp-create-live-contract.md | 2 + .../mock_servers/aidp_mgmt_mock_server.py | 7 +- .../graph_config_template_chinese.json | 66 +++++++++++++++++++ .../aidp/test_aidp_live_create_contract.py | 18 +++++ 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 test/ext_components/aidp/mock_servers/graph_config_template_chinese.json diff --git a/docs/design/aidp-create-live-contract.md b/docs/design/aidp-create-live-contract.md index f24bf7182b..968a2981b9 100644 --- a/docs/design/aidp-create-live-contract.md +++ b/docs/design/aidp-create-live-contract.md @@ -45,6 +45,8 @@ Mock 新增七项图谱模板返回、三类模型及其显示名和类型,创 英文 mock 提示词是合成测试数据,并非正式 AIDP 的模板。另一台电脑联调时仍需验证英文模板真实返回、正式模型标识以及实际创建响应。本地 mock 通过不等同于正式 AIDP 联调通过。 +2026-10-09 补充:中文 mock 原先使用一句话测试数据,造成页面默认提示词过短。现改为用户提供的完整中文接口响应,保存在 `test/ext_components/aidp/mock_servers/graph_config_template_chinese.json`,包含四个领域的完整模板。页面仍从接口读取,没有在产品前端或 Nexent 后端内置这四份提示词。 + 全局正式测试资产检查另有历史缺失绑定、已有脚本哈希不匹配等问题;本次新增的 `AKPR-D1-011` 实现单独登记,不将全局资产检查报告为通过。 ## 本次检查结果 diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index cda5c9de66..917ae77d45 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -739,8 +739,11 @@ class CreateKbBody(BaseModel): def _graph_template(language: str) -> Dict[str, Any]: - """Contract fixtures; English templates are synthetic, not production defaults.""" - english = language == "english" + """Use the captured Chinese response; English templates remain synthetic fixtures.""" + if language == "chinese": + fixture = Path(__file__).with_name("graph_config_template_chinese.json") + return json.loads(fixture.read_text(encoding="utf-8")) + english = True prompts = { domain: (f"Extract entity relationship triples for the {domain} domain." if english else f"你是{domain}领域的信息抽取专家,请抽取明确的实体及其关系三元组。") diff --git a/test/ext_components/aidp/mock_servers/graph_config_template_chinese.json b/test/ext_components/aidp/mock_servers/graph_config_template_chinese.json new file mode 100644 index 0000000000..c400770058 --- /dev/null +++ b/test/ext_components/aidp/mock_servers/graph_config_template_chinese.json @@ -0,0 +1,66 @@ +{ + "value": [ + { + "param_key": "retrieve_subgraph_hop", + "param_name": "子图扩展跳数", + "param_value": "2", + "regexp": "^[1-3]$", + "param_desc": "PPR检索时子图扩展的跳数,范围1-3", + "is_modifiable": true + }, + { + "param_key": "no_think_mode", + "param_name": "禁用LLM思考过程", + "param_value": "是", + "regexp": "^(是|否)$", + "param_desc": "禁用LLM思考过程,LLM提取三元组时只返回提取结果,不输出完整思考过程,提升三元组提取效率,可选是或否", + "is_modifiable": true + }, + { + "param_key": "prompt_language", + "param_name": "提示词语言", + "param_value": "中文", + "regexp": "^(中文|英文)$", + "param_desc": "三元组抽取提示词语言,可选中文或英文", + "is_modifiable": false + }, + { + "param_key": "domain", + "param_name": "领域类型", + "param_value": "常规", + "regexp": "^(医疗|金融|常规|法律法规)$", + "param_desc": "业务领域,可选医疗|金融|常规|法律法规", + "is_modifiable": false + }, + { + "param_key": "prompt_text", + "param_name": "知识抽取提示词", + "param_value": "---Role---\n你是一个信息抽取专家,请从给定文本中按要求抽取出实体关系三元组。\n\n---任务要求---\n**实体要求:** \n- 实体必须是尽可能明确和具体的词语或短语(人名、机构名、物品名、任务、计划等),不要抽取时间。\n- 不能使用代词(如\"他\"、\"她\"、\"它\"),如果遇到代词,使用具体指代的事物作为实体。\n- 不能使用类别名作为实体(如\"公司\"、\"单位\"、\"信息\"、\"问题\"等),必须使用明确指代的事物作为实体。\n**关系要求:**\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 关系中不重复包含实体信息。\n", + "template": { + "医疗": "---Role---\n你是一个资深的医学自然语言处理(NLP)专家。你的任务是从提供的非结构化医疗文本中,准确地抽取实体(Entities)和它们之间的关系(Relations),以便用于构建医疗知识图谱。\n\n---实体要求---\n- 实体必须是尽可能明确和具体的词语或短语;不能使用代词(如\"他\"、\"她\"、\"它\");不能使用抽象概念作为实体(如\"公司\"、\"单位\"、\"信息\"、\"问题\"等)。\n- 请严格按照以下定义的实体类型进行抽取:\n * **Disease (疾病)**: 明确的疾病名称或综合征(例:高血压、2型糖尿病、支气管炎)。\n * **Symptom (症状)**: 疾病的临床表现或患者主诉(例:头晕、发热、恶心)。\n * **Drug (药品)**: 治疗所用的具体药物名称或有效成分(例:阿司匹林、二甲双胍)。\n * **Treatment (治疗方案)**: 非药物的治疗手段、手术或生活方式干预(例:放射治疗、阑尾切除术、低盐饮食)。\n * **Test (检查项目)**: 用于诊断或监测的医疗设备检查、化验(例:血常规、胸部CT扫描、核磁共振)。\n * **Anatomy (身体部位)**: 相关的解剖学结构、器官或系统(例:心脏、上呼吸道、胃黏膜)。\n\n---关系要求---\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 常见医疗关系类型:症状表现、治疗、诊断、发生部位、并发症、禁忌症等。\n", + "金融": "---Role---\n你是一个资深的金融自然语言处理(NLP)专家。你的任务是从提供的非结构化金融文本中,准确地抽取实体(Entities)和它们之间的关系(Relations),以便用于构建金融知识图谱。\n\n---实体要求---\n- 实体必须是尽可能明确和具体的词语或短语;不能使用代词(如\"他\"、\"她\"、\"它\");不能使用抽象概念作为实体(如\"公司\"、\"单位\"、\"信息\"等),必须使用具体名称。\n- 请严格按照以下定义的实体类型进行抽取:\n * **Company (公司)**: 企业、机构名称(例:腾讯、阿里巴巴、华为)。\n * **Stock (股票)**: 股票名称或代码(例:茅台、000001平安银行)。\n * **Fund (基金)**: 基金产品名称(例:华夏成长混合、易方达蓝筹精选)。\n * **Bank (银行)**: 银行机构名称(例:工商银行、招商银行)。\n * **Index (指数)**: 证券市场指数(例:上证指数、深证成指、纳斯达克指数)。\n * **Indicator (经济指标)**: 宏观经济数据指标(例:GDP、CPI、PPI、失业率)。\n * **Currency (货币)**: 货币类型(例:人民币、美元、欧元)。\n * **Person (人物)**: 金融领域关键人物(例:巴菲特、基金经理姓名)。\n\n---关系要求---\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 常见金融关系类型:投资、持股、关联、发行、交易、涨跌、合作等。\n", + "常规": "---Role---\n你是一个信息抽取专家,请从给定文本中按要求抽取出实体关系三元组。\n\n---任务要求---\n**实体要求:** \n- 实体必须是尽可能明确和具体的词语或短语(人名、机构名、物品名、任务、计划等),不要抽取时间。\n- 不能使用代词(如\"他\"、\"她\"、\"它\"),如果遇到代词,使用具体指代的事物作为实体。\n- 不能使用类别名作为实体(如\"公司\"、\"单位\"、\"信息\"、\"问题\"等),必须使用明确指代的事物作为实体。\n**关系要求:**\n- 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n- 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n- 关系中不重复包含实体信息。\n", + "法律法规": "---Role---\n 你是一个资深的法律法规自然语言处理(NLP)专家。你的任务是从提供的非结构化法律法规文本中,准确地抽取实体(Entities)和它们之间的关系(Relations),以便用于构建法律法规知识图谱。\n ---实体要求---\n - 实体必须是尽可能明确和具体的词语或短语;不能使用代词(如\"他\"、\"她\"、\"它\");不能使用抽象概念作为实体(如\"组织\"、\"机构\"、\"事项\"等)。\n - 请严格按照以下定义的实体类型进行抽取:\n * **Law (法律法规)**: 明确的法律、法规、规章或规范性文件名称(例:《中华人民共和国民法典》、《个人信息保护法》、《道路交通安全法实施条例》)。\n * **Article (法条)**: 具体的法条编号或条款(例:第1064条、第十七条第二款、第一百四十三条)。\n * **LegalEntity (法律主体)**: 法律关系中享有权利或承担义务的具体主体(例:自然人、法人、用人单位、消费者、行政机关)。\n * **Obligation (法律义务)**: 主体依法或依约必须履行的具体义务(例:如实报告义务、纳税义务、安全保障义务)。\n * **Right (法律权利)**: 主体依法享有的具体权利(例:知情权、肖像权、合同解除权、行政复议申请权)。\n * **LegalAct (法律行为)**: 产生法律后果的具体行为或事件(例:签订合同、行政处罚、提起诉讼、登记备案)。\n * **Penalty (法律责任/处罚)**: 违法行为的具体法律后果或制裁措施(例:罚款、吊销许可证、有期徒刑、行政拘留)。\n * **RegulatedObject (规制对象)**: 法律所规制的具体活动、物品或场所(例:食品生产经营、机动车驾驶、危险化学品运输)。\n\n ---关系要求---\n - 关系应为能明确表示头实体与尾实体联系的最精炼和最有意义的词语或短语动词。\n - 避免使用没有实质意义的助动词或介词(如\"是\"、\"在\"、\"向\")。\n - 常见法律法规关系类型:规定于、适用条件、违反后果、权利归属、义务主体、规制范围、参照适用、修订替代、实施细则等。\n" + }, + "regexp": "^[\\s\\S]{1,4096}$", + "param_desc": "自定义三元组抽取提示词,可参考模板来编写,长度限制为1-4096", + "is_modifiable": false + }, + { + "param_key": "synonym_merge_enable", + "param_name": "同义词检测开关", + "param_value": "否", + "regexp": "^(是|否)$", + "param_desc": "建图时是否启用同义词检测,启用后相似实体间建立关系,可选是或否", + "is_modifiable": true + }, + { + "param_key": "disambiguation_enable", + "param_name": "实体消歧开关", + "param_value": "否", + "regexp": "^(是|否)$", + "param_desc": "建图时是否启用同名实体消歧,启用后相似实体会合并,可选是或否", + "is_modifiable": true + } + ] +} diff --git a/test/ext_components/aidp/test_aidp_live_create_contract.py b/test/ext_components/aidp/test_aidp_live_create_contract.py index 1d816e653c..5fa6a6895f 100644 --- a/test/ext_components/aidp/test_aidp_live_create_contract.py +++ b/test/ext_components/aidp/test_aidp_live_create_contract.py @@ -100,6 +100,24 @@ def test_all_models_does_not_send_service_filter(aidp_service_module): assert "service=" not in client.get.call_args.args[0] +@pytest.mark.parametrize("domain,expected", [ + ("常规", "不能使用代词"), ("医疗", "Disease (疾病)"), + ("金融", "Stock (股票)"), ("法律法规", "Law (法律法规)"), +]) +def test_chinese_template_returns_complete_captured_prompts(mock_aidp_server, domain, expected): + response = TestClient(mock_aidp_server.app).get( + f"{mock_aidp_server._KB_PREFIX}/GraphConfigTemplate?language=chinese", + headers={"Authorization": "Bearer mock-aidp-key"}, + ) + assert response.status_code == 200 + parameter = next(p for p in response.json()["value"] if p["param_key"] == "prompt_text") + text = parameter["template"][domain] + assert 200 < len(text) <= 4096 + assert text.startswith("---Role---\n") and expected in text + assert "关系" in text and text.count("\n") >= 10 + assert parameter["param_value"] == parameter["template"]["常规"] + + def test_old_internal_aliases_translate_without_graph_topk(aidp_service_module): config = {**graph_config(), "domain": "general", "prompt_language": "chinese", "no_think_mode": False, "retrieve_subgraph_hop": 3, "retrieve_default_topk": 5} From f1a9f3e52663d58011592ab962ab4c7e37c32c2b Mon Sep 17 00:00:00 2001 From: cj2026-bit <647646783@qq.com> Date: Fri, 9 Oct 2026 18:38:44 +0800 Subject: [PATCH 32/36] fix(aidp): default creation sections to collapsed --- .../AidpCreateKbSections.module.css | 4 ++ .../aidp/components/AidpCreateKbSections.tsx | 39 ++++++------------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/frontend/ext_components/aidp/components/AidpCreateKbSections.module.css b/frontend/ext_components/aidp/components/AidpCreateKbSections.module.css index 92b3d50b18..f1d393bbd0 100644 --- a/frontend/ext_components/aidp/components/AidpCreateKbSections.module.css +++ b/frontend/ext_components/aidp/components/AidpCreateKbSections.module.css @@ -3,6 +3,10 @@ color: #191919; } +.createForm [hidden] { + display: none !important; +} + .infoBanner:global(.ant-alert) { box-sizing: border-box; min-height: 46px; diff --git a/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx b/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx index 77e56a5e2a..d0a9ee6e5c 100644 --- a/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx +++ b/frontend/ext_components/aidp/components/AidpCreateKbSections.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import type { TFunction } from "i18next"; import { @@ -68,21 +68,12 @@ const AidpCreateKbSections: React.FC = ({ const [expanded, setExpanded] = useState>( { graph: false, - chunk: true, - vector: true, - retrieval: true, + chunk: false, + vector: false, + retrieval: false, } ); - useEffect(() => { - if (graphEnabled) { - // The graph toggle lives in the Ant Design form, so sync its enabled state - // to the independent accordion UI when a form value is loaded. - // eslint-disable-next-line react-hooks/set-state-in-effect - setExpanded((current) => ({ ...current, graph: true })); - } - }, [graphEnabled]); - const toggleSection = (section: CollapsibleSection) => setExpanded((current) => ({ ...current, [section]: !current[section] })); @@ -120,9 +111,7 @@ const AidpCreateKbSections: React.FC = ({ hint?: string, action?: React.ReactNode ) => { - const isGraph = section === "graph"; const isExpanded = expanded[section]; - const disabled = isGraph && !graphEnabled; const panelId = `aidp-create-${section}-panel`; return ( @@ -130,15 +119,14 @@ const AidpCreateKbSections: React.FC = ({
-

- - {/* Document table */} -
- {isLoading ? ( -
-
-
-

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

-
-
- ) : documents.length > 0 ? ( -
- - - - - - - - - - - - - {documents.map((doc) => ( - - - - - - - - - ))} - -
- {t("aidpKnowledge.docFileName")} - - {t("aidpKnowledge.docType")} - - {t("aidpKnowledge.docStatus")} - - {t("aidpKnowledge.docSize")} - - {t("aidpKnowledge.docCreatedAt")} - - {t("aidpKnowledge.docActions")} -
- {doc.file_type || "-"} - - {formatSize(doc.file_size)} - - {doc.created_at - ? new Date(doc.created_at).toLocaleString() - : "-"} - -
- {canDownloadDocuments && ( - -
-
-
- ) : ( -
- {t("aidpKnowledge.noDocuments")} -
- )} -
- - {/* Server-side pagination; see AidpPagination for the fallback rule. */} - {!uploadOnly && documents.length > 0 && ( -
- -
- )} - - {/* Upload area — gated by ``activeKb.permission`` and ``resource_status``. - - Per v7.1 §7.1, READ_ONLY callers may view existing documents but - must not be able to upload. UNAVAILABLE / ORPHANED KBs are - read-only regardless of permission because the AIDP backend cannot - service the request. The container is replaced with a hint instead - of disabling the Dragger so the visual structure stays consistent - and screen-reader users get an explicit reason. */} -
- {(() => { - const isUnavailable = - activeKb?.resource_status === "UNAVAILABLE" || - activeKb?.resource_status === "ORPHANED"; - const canUpload = - !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; - if (!canUpload) { - const reasonKey = !activeKb - ? "aidpKnowledge.uploadNoKb" - : isUnavailable - ? "aidpKnowledge.uploadKbUnavailable" - : "aidpKnowledge.uploadReadOnly"; - return ( -
-

{t(reasonKey)}

-
- ); - } - return ( - { - // Queue the file and defer validation + upload until the - // synchronous batch of beforeUpload calls finishes. Each batch - // flushes in a single frame so toasts and handleUpload run once. - pendingFilesRef.current.push(_file); - if (rafIdRef.current === null) { - rafIdRef.current = requestAnimationFrame(() => { - const batch = pendingFilesRef.current; - pendingFilesRef.current = []; - rafIdRef.current = null; - - const { valid } = partitionAidpFiles(batch, t, message); - if (valid.length > 0) { - handleUpload(valid); - } - }); - } - return false; - }} - disabled={uploading} - > -

- -

-

- {uploading - ? t("aidpKnowledge.uploading") - : t("aidpKnowledge.uploadHint")} -

-
-
{t("aidpKnowledge.uploadHintCount")}
-
{t("aidpKnowledge.uploadHintSize")}
-
- {t("aidpKnowledge.uploadHintFormats")} -
-
-
- ); - })()} -
-
- ); -}; - -export default AidpDocumentList; diff --git a/frontend/ext_components/aidp/components/AidpImportDrawer.tsx b/frontend/ext_components/aidp/components/AidpImportDrawer.tsx index c6b1a33e53..a45c03589c 100644 --- a/frontend/ext_components/aidp/components/AidpImportDrawer.tsx +++ b/frontend/ext_components/aidp/components/AidpImportDrawer.tsx @@ -18,6 +18,8 @@ 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"; @@ -53,12 +55,20 @@ const formatBytes = (bytes: number): string => { const getFailureReason = ( item: AidpUploadFailedItem | undefined, language: string, + translate: (key: string, options: { defaultValue: string }) => string, fallback: string ): string => { if (!item) return fallback; - return language.startsWith("zh") + 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 }) => { @@ -213,6 +223,7 @@ const AidpImportDrawer: React.FC = ({ error: getFailureReason( failure, i18n.language, + t, t("aidpKnowledge.uploadFailed") ), }; @@ -222,10 +233,11 @@ const AidpImportDrawer: React.FC = ({ updateBatch((row) => ({ ...row, status: "failed", - error: - error instanceof Error && error.message - ? error.message - : t("aidpKnowledge.uploadFailed"), + error: getAidpUploadErrorMessage( + error, + t, + t("aidpKnowledge.uploadFailed") + ), })); } } finally { diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx index 5794fd9976..e3fbc2e925 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx @@ -14,6 +14,13 @@ import { import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; import log from "@/lib/logger"; +import { ApiError } from "@/services/api"; +import { useErrorHandler } from "@/hooks/useErrorHandler"; +import { + useAidpKnowledgeCache, + useAidpKnowledgeList, + useDeleteAidpKnowledgeBase, +} from "../hooks/useAidpKnowledgeQueries"; import AidpKnowledgeDetail from "./AidpKnowledgeDetail"; import AidpKnowledgeList, { @@ -30,12 +37,10 @@ const AidpKnowledgeConfiguration: React.FC = () => { const params = useParams(); const searchParams = useSearchParams(); const locale = (params?.locale as string) || "zh"; - const [kbs, setKbs] = useState([]); - const [loadingKbs, setLoadingKbs] = useState(false); - const [kbsLoadFailed, setKbsLoadFailed] = useState(false); - const [kbTotal, setKbTotal] = useState(0); - const [kbHasMore, setKbHasMore] = useState(false); - const [kbTotalReliable, setKbTotalReliable] = useState(true); + 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, @@ -43,10 +48,13 @@ const AidpKnowledgeConfiguration: React.FC = () => { const [selectedKb, setSelectedKb] = useState( null ); - const [kbPage, setKbPage] = useState(1); - const [kbPageSize, setKbPageSize] = useState(10); + 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< @@ -55,50 +63,28 @@ const AidpKnowledgeConfiguration: React.FC = () => { 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 = window.setTimeout(() => { - setDebouncedKbKeyword(kbKeyword.trim()); + const keyword = kbKeyword.trim(); + setListParams((current) => + current.keyword === keyword ? current : { ...current, keyword, page: 1 } + ); }, KB_SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timer); }, [kbKeyword]); - const fetchKbs = useCallback( - async (page = 1, keyword = debouncedKbKeyword, pageSize = kbPageSize) => { - setLoadingKbs(true); - setKbsLoadFailed(false); - try { - const result = await aidpKnowledgeService.listKbs( - page, - pageSize, - 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); - message.error(t("aidpKnowledge.fetchKbsFailed")); - setKbs([]); - setKbTotal(0); - setKbHasMore(false); - setKbTotalReliable(false); - setKbsLoadFailed(true); - } finally { - setLoadingKbs(false); - } - }, - [debouncedKbKeyword, kbPageSize, message, t] - ); - - useEffect(() => { - // Fetch the list when the debounced query or page size changes. - // eslint-disable-next-line react-hooks/set-state-in-effect - void fetchKbs(1, debouncedKbKeyword, kbPageSize); - }, [fetchKbs, debouncedKbKeyword, kbPageSize]); - useEffect(() => { if (typeof window === "undefined") return; try { @@ -116,7 +102,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { const openKbById = useCallback( async (kbId: string) => { try { - const detail = await aidpKnowledgeService.getKb(kbId); + const detail = await fetchKnowledgeBase(kbId); const item = { ...detail, kds_id: kbId, @@ -125,10 +111,14 @@ const AidpKnowledgeConfiguration: React.FC = () => { setSelectedKb(item); } catch (error) { log.error("Failed to open AIDP knowledge base from query:", error); - message.error(t("aidpKnowledge.detailLoadFailed")); + const result = handleError(error, { + showMessage: false, + handleSession: false, + }); + message.error(result.message); } }, - [message, t] + [fetchKnowledgeBase, handleError, message] ); const requestedKbId = searchParams?.get("kb") || null; @@ -158,27 +148,30 @@ const AidpKnowledgeConfiguration: React.FC = () => { centered: true, onOk: async () => { try { - await aidpKnowledgeService.deleteKb(kb.kds_id); + await deleteMutation.mutateAsync(kb.kds_id); message.success(t("aidpKnowledge.deleteKbSuccess")); setSelectedKb(null); - void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); - } catch { - message.error(t("aidpKnowledge.deleteKbFailed")); + } catch (error) { + const result = handleError(error, { + showMessage: false, + handleSession: false, + }); + message.error(result.message); + throw error; } }, }); }, - [debouncedKbKeyword, fetchKbs, kbPage, kbPageSize, message, t] + [deleteMutation, handleError, message, t] ); - const handleUpdatedKb = useCallback((updatedKb: AidpKnowledgeBaseItem) => { - setSelectedKb(updatedKb); - setKbs((current) => - current.map((kb) => - kb.kds_id === updatedKb.kds_id ? { ...kb, ...updatedKb } : kb - ) - ); - }, []); + const handleUpdatedKb = useCallback( + (updatedKb: AidpKnowledgeBaseItem) => { + setSelectedKb(updatedKb); + updateKnowledgeBase(updatedKb); + }, + [updateKnowledgeBase] + ); const handleQuickDocsUploaded = useCallback( (uploadedFileIds: string[]) => { @@ -186,9 +179,9 @@ const AidpKnowledgeConfiguration: React.FC = () => { quickImportPendingIdsRef.current = uploadedFileIds; quickImportWatchStartedAtRef.current = Date.now(); setQuickImportWatchKbId(quickImportKb.kds_id); - void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); + void refreshLists(); }, - [debouncedKbKeyword, fetchKbs, kbPage, kbPageSize, quickImportKb] + [refreshLists, quickImportKb] ); useEffect(() => { @@ -200,7 +193,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { ) { quickImportPendingIdsRef.current = []; setQuickImportWatchKbId(null); - void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); + void refreshLists(); return; } void aidpKnowledgeService @@ -226,7 +219,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { quickImportPendingIdsRef.current = stillPending; if (stillPending.length === 0) { setQuickImportWatchKbId(null); - void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize); + void refreshLists(); } }) .catch((error) => @@ -234,7 +227,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { ); }, AIDP_DOC_STATUS_POLL_MS); return () => window.clearInterval(timer); - }, [quickImportWatchKbId, fetchKbs, kbPage, kbPageSize, debouncedKbKeyword]); + }, [quickImportWatchKbId, refreshLists]); const handleCreateNew = useCallback(() => { router.push(`/${locale}/knowledges/create`); @@ -261,11 +254,16 @@ const AidpKnowledgeConfiguration: React.FC = () => {
{ visibleColumns={visibleColumns} onKeywordChange={setKbKeyword} onPageChange={(page) => - void fetchKbs(page, debouncedKbKeyword, kbPageSize) + setListParams((current) => ({ ...current, page })) + } + onPageSizeChange={(pageSize) => + setListParams((current) => ({ ...current, pageSize, page: 1 })) } - onPageSizeChange={setKbPageSize} onViewModeChange={setViewMode} onVisibleColumnsChange={setVisibleColumns} onSelect={handleSelectKb} - onRefresh={() => - void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize) - } + onRefresh={() => void refreshLists()} onCreateNew={handleCreateNew} onImport={setQuickImportKb} onDelete={handleDeleteKb} - onRetry={() => void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize)} + onRetry={() => void listQuery.refetch()} />
@@ -296,9 +294,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { knowledgeBase={quickImportKb} onClose={() => setQuickImportKb(null)} onDocsUploaded={handleQuickDocsUploaded} - onRefresh={() => - void fetchKbs(kbPage, debouncedKbKeyword, kbPageSize) - } + onRefresh={() => void refreshLists()} /> )}
diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index aeb2c3bfff..76483eca3c 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -94,6 +94,7 @@ interface AidpKnowledgeListProps { kbs: AidpKnowledgeBaseItem[]; isLoading: boolean; loadFailed: boolean; + loadError?: string; total: number; totalReliable: boolean; hasMore: boolean; @@ -128,6 +129,7 @@ const AidpKnowledgeList: React.FC = ({ kbs, isLoading, loadFailed, + loadError, total, totalReliable, hasMore, @@ -592,7 +594,7 @@ const AidpKnowledgeList: React.FC = ({ const renderFailedState = () => (

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