package handler import ( "context" "encoding/json" stderrors "errors" "net/http" "strconv" "strings" "time" "github.com/Tencent/WeKnora/internal/agent/tools" "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/application/service" "github.com/Tencent/WeKnora/internal/errors" apperrors "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/storageurl" "github.com/Tencent/WeKnora/internal/tracing/langfuse" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" "github.com/Tencent/WeKnora/internal/utils" secutils "github.com/Tencent/WeKnora/internal/utils" "github.com/gin-gonic/gin" "github.com/hibiken/asynq" ) // KnowledgeBaseHandler defines the HTTP handler for knowledge base operations type KnowledgeBaseHandler struct { service interfaces.KnowledgeBaseService knowledgeService interfaces.KnowledgeService kbShareService interfaces.KBShareService agentShareService interfaces.AgentShareService asynqClient interfaces.TaskEnqueuer vectorStoreService interfaces.VectorStoreService // enriches KB responses with bound store display // userService 仅在 list 类接口里用于批量回填 creator_name; // 真正的鉴权由 RBAC 中间件 + Lookup 完成,这里不参与决策。 userService interfaces.UserService // fileService and storageResolver back the optional `resource_urls=public` // mode on hybrid-search. Both may be nil in tests, in which case only the // default handle mode is available. fileService interfaces.FileService storageResolver interfaces.StorageBackendResolver } // NewKnowledgeBaseHandler creates a new knowledge base handler instance func NewKnowledgeBaseHandler( service interfaces.KnowledgeBaseService, knowledgeService interfaces.KnowledgeService, kbShareService interfaces.KBShareService, agentShareService interfaces.AgentShareService, asynqClient interfaces.TaskEnqueuer, vectorStoreService interfaces.VectorStoreService, userService interfaces.UserService, fileService interfaces.FileService, storageResolver interfaces.StorageBackendResolver, ) *KnowledgeBaseHandler { return &KnowledgeBaseHandler{ service: service, knowledgeService: knowledgeService, kbShareService: kbShareService, agentShareService: agentShareService, asynqClient: asynqClient, vectorStoreService: vectorStoreService, userService: userService, fileService: fileService, storageResolver: storageResolver, } } // resolveResourceRewriter builds the storage-reference rewriter for one response // from the request's `resource_urls` parameter, falling back to the deployment // default. The returned error is already an AppError the caller can hand to // c.Error: a rejected scope is a 403, a typo in the parameter is a 400. func (h *KnowledgeBaseHandler) resolveResourceRewriter(c *gin.Context) (*storageurl.Rewriter, error) { ctx := c.Request.Context() mode, err := storageurl.ResolveMode(ctx, c.Query(storageurl.QueryParam)) if err != nil { if stderrors.Is(err, storageurl.ErrPublicModeForbidden) { return nil, apperrors.NewForbiddenError(err.Error()) } return nil, apperrors.NewBadRequestError(err.Error()) } return storageurl.NewRequestRewriter(ctx, mode, h.fileService, h.storageResolver), nil } // buildKBResponse turns a knowledge base into a JSON-ready response shape, // merging the bound vector store's display metadata and any caller-supplied // extras (e.g., my_permission for shared KBs). Returns the kb pointer // unchanged on serialization failure so the request still succeeds. // // The map-merge approach (rather than a wrapper struct embedding the kb) // is deliberate: KnowledgeBase has a custom MarshalJSON, and embedding // would promote it to any wrapper struct and silently swallow the extra // fields. The same pattern is already used by GetKnowledgeBase to add the // my_permission field for shared knowledge bases. // // Shared-KB suppression: when storeView.Source == StoreSourceShared (the // caller is not the KB owner), the raw vector_store_id UUID is stripped // from the response so the owner-tenant's store inventory cannot be // correlated across multiple shared KBs. Name / engine type / status are // already empty in the SharedStoreDisplay payload; suppressing the UUID // completes the cross-tenant metadata hiding. func buildKBResponse( kb *types.KnowledgeBase, storeView types.StoreDisplay, extras map[string]interface{}, ) interface{} { b, err := json.Marshal(kb) if err != nil { return kb } var m map[string]interface{} if err := json.Unmarshal(b, &m); err != nil || m == nil { return kb } if storeView.Source == types.StoreSourceShared { delete(m, "vector_store_id") } if storeView.Name != "" { m["vector_store_name"] = storeView.Name } if storeView.Source != "" { m["vector_store_source"] = storeView.Source } if storeView.EngineType != "" { m["vector_store_engine_type"] = storeView.EngineType } if storeView.Status != "" { m["vector_store_status"] = storeView.Status } for k, v := range extras { m[k] = v } return m } // buildKBListResponse turns a slice of knowledge bases into a JSON-ready // slice that mirrors the single-KB enrichment in buildKBResponse. Store // views are batch-resolved once via BatchResolveStoreView to keep the // list endpoint O(1) in vector-store service calls — the per-KB // resolveKBStoreView would otherwise be N+1. // // Caller-vs-owner semantics match the single-KB path: // - KB has no binding → DefaultStoreDisplay() // - KB is owned by another tenant (cross-tenant shared) → SharedStoreDisplay() // - KB is own-tenant bound → look up in the batch result; misses // fall back to UnavailableStoreDisplay() // // Resolver failures degrade gracefully: every own-tenant bound KB renders // as unavailable instead of breaking the list response. func (h *KnowledgeBaseHandler) buildKBListResponse( ctx context.Context, kbs []*types.KnowledgeBase, callerTenantID uint64, ) []interface{} { defaultView := h.envDefaultStoreView(ctx) storeViews := h.batchResolveKBStoreViews(ctx, kbs, callerTenantID) out := make([]interface{}, 0, len(kbs)) for _, kb := range kbs { var view types.StoreDisplay switch { case !kb.HasVectorStore(): view = defaultView case kb.TenantID != callerTenantID: view = types.SharedStoreDisplay() default: v, ok := storeViews[*kb.VectorStoreID] if !ok || v.Source == "" { view = types.UnavailableStoreDisplay() } else { view = v } } out = append(out, buildKBResponse(kb, view, nil)) } return out } // sharedKBRow projects a SharedKnowledgeBaseInfo into a response payload // that respects the cross-tenant strip rule: the embedded KnowledgeBase // row runs through buildKBResponse with SharedStoreDisplay() so its // vector_store_id and any owner-tenant store metadata never reach the // wire. The share-record fields (share_id, organization_id, etc.) are // kept intact alongside the stripped KB. Callers can pass extras to // merge view-specific keys such as is_mine or source_from_agent. // // Always uses SharedStoreDisplay() regardless of whether the caller is // the owner; the cross-tenant share endpoints serve mixed audiences and // the owner's "rich" view of their own bindings is already served by // ListKnowledgeBases / single-KB GET on the standard knowledge-base // routes. Trying to enrich own-row entries here would either require // threading the vector-store service through the organization handler // or duplicating the lookup logic — both larger than the security fix // warrants and easy to follow up on once needed. func sharedKBRow( info *types.SharedKnowledgeBaseInfo, extras map[string]interface{}, ) map[string]interface{} { kbView := buildKBResponse(info.KnowledgeBase, types.SharedStoreDisplay(), nil) row := map[string]interface{}{ "knowledge_base": kbView, "share_id": info.ShareID, "organization_id": info.OrganizationID, "org_name": info.OrgName, "permission": info.Permission, "source_tenant_id": info.SourceTenantID, "shared_at": info.SharedAt, } for k, v := range extras { row[k] = v } return row } // envDefaultStoreView returns the env-fallback store display enriched with // the configured env-store engine type when the service is available. The // service path populates EngineType so the caller can show "postgres" or // "qdrant" on the env-default badge instead of leaving it blank. A nil // service (e.g. in narrow unit-test setups) falls back to the bare default // display rather than failing the list response. func (h *KnowledgeBaseHandler) envDefaultStoreView(ctx context.Context) types.StoreDisplay { if h.vectorStoreService == nil { return types.DefaultStoreDisplay() } return h.vectorStoreService.EnvDefaultStoreView(ctx) } // batchResolveKBStoreViews collects the unique own-tenant store IDs across // the KB slice and resolves them in one BatchResolveStoreView call. // Cross-tenant shared KBs never enter the batch — they always render // via SharedStoreDisplay, which deliberately suppresses the owner // tenant's store name and engine type so cross-tenant viewers cannot // correlate the owner's store inventory from KB responses alone. func (h *KnowledgeBaseHandler) batchResolveKBStoreViews( ctx context.Context, kbs []*types.KnowledgeBase, callerTenantID uint64, ) map[string]types.StoreDisplay { if h.vectorStoreService == nil { return nil } storeIDs := make([]string, 0, len(kbs)) seen := make(map[string]bool, len(kbs)) for _, kb := range kbs { if !kb.HasVectorStore() || kb.TenantID != callerTenantID { continue } sid := *kb.VectorStoreID if !seen[sid] { seen[sid] = true storeIDs = append(storeIDs, sid) } } if len(storeIDs) == 0 { return nil } views, err := h.vectorStoreService.BatchResolveStoreView(ctx, callerTenantID, storeIDs) if err != nil { logger.WarnWithFields(ctx, logger.Fields{ "tenant_id": callerTenantID, "store_count": len(storeIDs), }, "[kb.list] batch store view resolve failed; rendering bound KBs as unavailable") return nil } return views } // resolveKBStoreView returns the store display payload to embed in the KB // response. It applies two policies on top of the service-level resolver: // // - When the KB does not have a DB-managed vector store binding, // the env-fallback display is returned without touching the service. // - When the caller is not the KB owner (shared access), the underlying // store's name and engine are suppressed so operator-chosen names do // not leak across tenants. The Source value is set to "shared". // // On resolution error, an unavailable display is returned and the failure // is logged for ops; the request itself still succeeds. func (h *KnowledgeBaseHandler) resolveKBStoreView( ctx context.Context, kb *types.KnowledgeBase, callerTenantID uint64, ) types.StoreDisplay { if !kb.HasVectorStore() { return h.envDefaultStoreView(ctx) } if kb.TenantID != callerTenantID { return types.SharedStoreDisplay() } if h.vectorStoreService == nil { return types.UnavailableStoreDisplay() } view, err := h.vectorStoreService.ResolveStoreView(ctx, kb.TenantID, *kb.VectorStoreID) if err != nil { logger.WarnWithFields(ctx, logger.Fields{ "kb_id": secutils.SanitizeForLog(kb.ID), "tenant_id": kb.TenantID, }, "[kb.view] vector store resolve failed; returning unavailable") return types.UnavailableStoreDisplay() } return view } // HybridSearch godoc // @Summary 混合搜索 // @Description 在知识库中执行向量和关键词混合搜索。推荐使用 POST;GET 携带 JSON 请求体仍受支持(兼容旧客户端)。 // @Tags 知识库 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Param request body types.SearchParams true "搜索参数" // @Param resource_urls query string false "文件引用形式,public 返回可加载直链" Enums(handle, public) default(handle) // @Success 200 {object} map[string]interface{} "搜索结果" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id}/hybrid-search [post] // @Router /knowledge-bases/{id}/hybrid-search [get] func (h *KnowledgeBaseHandler) HybridSearch(c *gin.Context) { ctx := c.Request.Context() logger.Info(ctx, "Start hybrid search") // Validate and check permission for knowledge base access _, id, effectiveTenantID, _, err := h.validateAndGetKnowledgeBase(c) if err != nil { c.Error(err) return } // Parse request body var req types.SearchParams if err := c.ShouldBindJSON(&req); err != nil { logger.Error(ctx, "Failed to parse request parameters", err) c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error())) return } precomputedVectorOnly := len(req.QueryEmbedding) > 0 && req.DisableKeywordsMatch && !req.DisableVectorMatch if strings.TrimSpace(req.QueryText) == "" && !precomputedVectorOnly { _ = c.Error(apperrors.NewBadRequestError("query_text is required")) return } logger.Infof(ctx, "Executing hybrid search, knowledge base ID: %s, query: %s, effectiveTenantID: %d", secutils.SanitizeForLog(id), secutils.SanitizeForLog(req.QueryText), effectiveTenantID) // Resolve before retrieving so a typo or a rejected scope costs nothing. rewriter, err := h.resolveResourceRewriter(c) if err != nil { logger.Warnf(ctx, "Rejected resource URL mode: %v", err) _ = c.Error(err) return } // Execute hybrid search with default search parameters // Note: For shared KBs, the service uses effectiveTenantID internally via context results, err := h.service.HybridSearch(ctx, id, req) if err != nil { // Service-layer typed AppErrors (e.g. ErrVectorStoreBindingInvalid, // ErrVectorStoreUnavailable, BadRequest from multi-store fan-out) // must reach the client with their original code rather than be // downgraded to InternalServerError. Mirrors the pattern used in // CreateKnowledgeBase. if appErr, ok := apperrors.IsAppError(err); ok { c.Error(appErr) return } logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } logger.Infof(ctx, "Hybrid search completed, knowledge base ID: %s, result count: %d", secutils.SanitizeForLog(id), len(results)) c.JSON(http.StatusOK, gin.H{ "success": true, "data": rewriter.CopyReferences(ctx, results), }) } // CreateKnowledgeBase godoc // @Summary 创建知识库 // @Description 创建新的知识库 // @Tags 知识库 // @Accept json // @Produce json // @Param request body types.KnowledgeBase true "知识库信息" // @Success 201 {object} map[string]interface{} "创建的知识库" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases [post] func (h *KnowledgeBaseHandler) CreateKnowledgeBase(c *gin.Context) { ctx := c.Request.Context() logger.Info(ctx, "Start creating knowledge base") // Parse request body var req types.KnowledgeBase if err := c.ShouldBindJSON(&req); err != nil { logger.Error(ctx, "Failed to parse request parameters", err) c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error())) return } if err := validateExtractConfig(req.ExtractConfig); err != nil { logger.Error(ctx, "Invalid extract configuration", err) c.Error(err) return } types.NormalizeKnowledgeBasePromptInstructions(&req) if err := validateKnowledgeBasePromptInstructions(&req); err != nil { c.Error(err) return } provider := strings.ToLower(strings.TrimSpace(req.GetStorageProvider())) if provider != "" && !isStorageProviderAllowed(provider) { c.Error(apperrors.NewBadRequestError("Storage provider is not allowed by STORAGE_ALLOW_LIST")) return } logger.Infof(ctx, "Creating knowledge base, name: %s", secutils.SanitizeForLog(req.Name)) // Create knowledge base using the service kb, err := h.service.CreateKnowledgeBase(ctx, &req) if err != nil { // Surface typed AppErrors (notably the 400-class codes // ErrVectorStoreBindingInvalid and ErrVectorStoreUnavailable // returned by validateVectorStoreBinding) instead of wrapping them // into a generic 500. The middleware renders the original code and // HTTP status verbatim. Falls through to 500 only for raw infra // errors that the service did not classify. if appErr, ok := apperrors.IsAppError(err); ok { c.Error(appErr) return } logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } logger.Infof(ctx, "Knowledge base created successfully, ID: %s, name: %s", secutils.SanitizeForLog(kb.ID), secutils.SanitizeForLog(kb.Name)) callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) c.JSON(http.StatusCreated, gin.H{ "success": true, "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), nil), }) } // validateAndGetKnowledgeBase validates request parameters and retrieves the knowledge base. // Enforces per-API-key KB scope before tenant/share/agent resolution. // Returns the knowledge base, knowledge base ID, effective tenant ID for embedding, permission level, and any errors encountered // For owned KBs, effectiveTenantID is the caller's tenant ID // For shared KBs, effectiveTenantID is the source tenant ID (owner's tenant) func (h *KnowledgeBaseHandler) validateAndGetKnowledgeBase(c *gin.Context) (*types.KnowledgeBase, string, uint64, types.OrgMemberRole, error) { ctx := c.Request.Context() // Get tenant ID from context tenantID, exists := c.Get(types.TenantIDContextKey.String()) if !exists { logger.Error(ctx, "Failed to get tenant ID") return nil, "", 0, "", apperrors.NewUnauthorizedError("Unauthorized") } // Get user ID from context (needed for shared KB permission check) userID, userExists := c.Get(types.UserIDContextKey.String()) callerTenantRole := types.TenantRoleFromContext(ctx) // Get knowledge base ID from URL parameter id := secutils.SanitizeForLog(c.Param("id")) if id == "" { logger.Error(ctx, "Knowledge base ID is empty") return nil, "", 0, "", apperrors.NewBadRequestError("Knowledge base ID cannot be empty") } if err := requireTenantAPIKeyKnowledgeBase(ctx, id); err != nil { return nil, id, 0, "", err } // Verify tenant has permission to access this knowledge base kb, err := h.service.GetKnowledgeBaseByID(ctx, id) if err != nil { // repo.GetKnowledgeBaseByID surfaces ErrKnowledgeBaseNotFound for // missing or cross-tenant rows. Map it to 404 here so the four // callers (Get / Update / Delete / TogglePin / Copy / Hybrid-search // path) don't have to wrap NewInternalServerError into a 500 for // every probe of a non-existent id. if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) { return nil, id, 0, "", apperrors.NewNotFoundError("knowledge base not found") } logger.ErrorWithFields(ctx, err, nil) return nil, id, 0, "", apperrors.NewInternalServerError(err.Error()) } // Check 1: Verify tenant ownership (owner has full access) if kb.TenantID == tenantID.(uint64) { return kb, id, tenantID.(uint64), types.OrgRoleAdmin, nil } // Check 2: If not owner, check organization shared access if h.kbShareService != nil { // Check if caller's tenant has shared access through organization permission, isShared, permErr := h.kbShareService.CheckTenantKBPermission(ctx, id, tenantID.(uint64), callerTenantRole) if permErr == nil && isShared { // Tenant has shared access, get the source tenant ID for embedding queries sourceTenantID, srcErr := h.kbShareService.GetKBSourceTenant(ctx, id) if srcErr == nil { logger.Infof(ctx, "Tenant %d accessing shared KB %s with permission %s, source tenant: %d", tenantID.(uint64), id, permission, sourceTenantID) return kb, id, sourceTenantID, permission, nil } } } // Check 3: Shared agent — allow if request has agent_id (and agent can access this KB) OR caller's tenant has any shared agent that can access this KB (e.g. opened from "通过智能体可见" list without agent_id) if h.agentShareService != nil { currentTenantID := tenantID.(uint64) agentID := c.Query("agent_id") if agentID != "" { sourceTenantID, parseErr := types.ParseAgentSourceTenantID(c.Query(types.AgentSourceTenantIDParam)) if parseErr != nil { return kb, id, 0, types.OrgMemberRole(""), apperrors.NewBadRequestError(parseErr.Error()) } agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, sourceTenantID) if err == nil && agent != nil { if kb.TenantID != agent.TenantID { logger.Warnf(ctx, "Shared agent workspace mismatch, KB %s tenant: %d, agent tenant: %d", id, kb.TenantID, agent.TenantID) } else { mode := agent.Config.KBSelectionMode if mode == "none" { // no-op, fall through } else if mode == "all" { logger.Infof(ctx, "Tenant %d accessing KB %s via shared agent %s (mode=all)", currentTenantID, id, agentID) return kb, id, kb.TenantID, types.OrgRoleViewer, nil } else if mode == "selected" { for _, allowedID := range agent.Config.KnowledgeBases { if allowedID == id { logger.Infof(ctx, "Tenant %d accessing KB %s via shared agent %s (mode=selected)", currentTenantID, id, agentID) return kb, id, kb.TenantID, types.OrgRoleViewer, nil } } } } } } else { // No agent_id in query: allow if caller's tenant has any shared agent that can access this KB (e.g. from space list "通过智能体可见") can, err := h.agentShareService.TenantCanAccessKBViaSomeSharedAgent(ctx, currentTenantID, callerTenantRole, kb) if err == nil && can { logger.Infof(ctx, "Tenant %d accessing KB %s via some shared agent (no agent_id in query)", currentTenantID, id) return kb, id, kb.TenantID, types.OrgRoleViewer, nil } } } _ = userID _ = userExists // No permission: not owner and no shared access logger.Warnf( ctx, "Tenant has no permission to access this knowledge base, knowledge base ID: %s, "+ "request tenant ID: %d, knowledge base tenant ID: %d", id, tenantID.(uint64), kb.TenantID, ) return nil, id, 0, "", apperrors.NewForbiddenError("No permission to operate") } // GetKnowledgeBase godoc // @Summary 获取知识库详情 // @Description 根据ID获取知识库详情。当使用共享智能体时,可传 agent_id 以校验该智能体是否有权访问该知识库。 // @Tags 知识库 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Param agent_id query string false "共享智能体 ID(用于校验智能体是否有权访问该知识库)" // @Success 200 {object} map[string]interface{} "知识库详情" // @Failure 400 {object} errors.AppError "请求参数错误" // @Failure 404 {object} errors.AppError "知识库不存在" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id} [get] func (h *KnowledgeBaseHandler) GetKnowledgeBase(c *gin.Context) { // Validate and get the knowledge base kb, _, _, permission, err := h.validateAndGetKnowledgeBase(c) if err != nil { c.Error(err) return } // Fill counts (knowledge_count, chunk_count, is_processing) so hover/detail shows correct numbers if fillErr := h.service.FillKnowledgeBaseCounts(c.Request.Context(), kb); fillErr != nil { logger.Warnf(c.Request.Context(), "Failed to fill KB counts for %s: %v", kb.ID, fillErr) } tenantID := c.GetUint64(types.TenantIDContextKey.String()) storeView := h.resolveKBStoreView(c.Request.Context(), kb, tenantID) var extras map[string]interface{} if kb.TenantID != tenantID && permission != "" { // Include my_permission in data so frontend can show role (e.g. "只读") instead of "--" for agent-visible KBs extras = map[string]interface{}{"my_permission": permission} } c.JSON(http.StatusOK, gin.H{"success": true, "data": buildKBResponse(kb, storeView, extras)}) } // ListKnowledgeBases godoc // @Summary 获取知识库列表 // @Description 获取当前空间的所有知识库;或当传入 agent_id(共享智能体)时,校验权限后返回该智能体配置的知识库范围(用于 @ 提及) // @Tags 知识库 // @Accept json // @Produce json // @Param agent_id query string false "共享智能体 ID(传入时返回该智能体可用的知识库)" // @Success 200 {object} map[string]interface{} "知识库列表" // @Failure 500 {object} errors.AppError "服务器错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases [get] func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) { ctx := c.Request.Context() agentID := c.Query("agent_id") if agentID != "" { userIDVal, ok := c.Get(types.UserIDContextKey.String()) if !ok { c.Error(apperrors.NewUnauthorizedError("user ID not found")) return } _ = userIDVal currentTenantID := c.GetUint64(types.TenantIDContextKey.String()) if currentTenantID == 0 { c.Error(apperrors.NewUnauthorizedError("workspace ID not found")) return } callerTenantRole := types.TenantRoleFromContext(ctx) requestedSourceTenantID, parseErr := types.ParseAgentSourceTenantID(c.Query(types.AgentSourceTenantIDParam)) if parseErr != nil { c.Error(apperrors.NewBadRequestError(parseErr.Error())) return } agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, requestedSourceTenantID) if err != nil { if stderrors.Is(err, service.ErrAgentShareNotFound) || stderrors.Is(err, service.ErrAgentSharePermission) || stderrors.Is(err, service.ErrAgentNotFoundForShare) { c.Error(apperrors.NewForbiddenError("no permission for this shared agent")) return } logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } mode := agent.Config.KBSelectionMode if mode == "none" { c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) return } sourceTenantID := agent.TenantID kbs, err := h.service.ListKnowledgeBasesByTenantID(ctx, sourceTenantID) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } if mode == "selected" && len(agent.Config.KnowledgeBases) > 0 { allowed := make(map[string]bool) for _, id := range agent.Config.KnowledgeBases { allowed[id] = true } filtered := make([]*types.KnowledgeBase, 0, len(kbs)) for _, kb := range kbs { if allowed[kb.ID] { filtered = append(filtered, kb) } } kbs = filtered } kbs = filterKnowledgeBasesForAPIKeyScope(ctx, kbs) // `all` mode: authoritative server-side capability filter so a client // that bypassed the frontend (old tab, curl, rogue plugin) can't @ a // KB whose capabilities don't match this agent. The filter combines // tool-derived requirements (smart-reasoning) with the implicit // RAG-only requirement of quick-answer mode (which has no // `allowed_tools` but still needs vector/keyword chunks to work). // Non-`all` modes already constrain the scope explicitly. if mode == "all" { filter := tools.DeriveKBFilterForAgent(agent.Config.AgentMode, agent.Config.AllowedTools) if !filter.IsEmpty() { before := len(kbs) kept := make([]*types.KnowledgeBase, 0, before) for _, kb := range kbs { if tools.KBSatisfiesAgentRequirements(kb.Capabilities(), agent.Config.AgentMode, agent.Config.AllowedTools) { kept = append(kept, kb) } } if removed := before - len(kept); removed > 0 { logger.Infof(ctx, "ListKnowledgeBases(agent=%s, mode=all): capability filter removed %d of %d KBs", agentID, removed, before) } kbs = kept } } c.JSON(http.StatusOK, gin.H{ "success": true, "data": h.buildKBListResponse(ctx, kbs, currentTenantID), }) return } // Get all knowledge bases for this tenant kbs, err := h.service.ListKnowledgeBases(ctx) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } // Optional creator filter — drives the [All | Mine | Others] segmented // control on the list page. We filter in-process rather than pushing // down into SQL because the tenant-bounded KB list is small (typically // <100 rows) and adding a creator predicate to ListKnowledgeBases would // ripple through every other caller (chat pipeline, agent editor, …). // Rows with empty CreatorID predate the RBAC migration (PR 5); we treat // them as "not anyone in particular" so they never appear under "mine" // or "others" — they fall out of both filters cleanly. creatorFilter := strings.ToLower(strings.TrimSpace(c.Query("creator"))) if creatorFilter == "mine" || creatorFilter == "others" { callerUserID, _ := c.Get(types.UserIDContextKey.String()) callerUserIDStr, _ := callerUserID.(string) filtered := make([]*types.KnowledgeBase, 0, len(kbs)) for _, kb := range kbs { if kb.CreatorID == "" { continue } if creatorFilter == "mine" && kb.CreatorID == callerUserIDStr { filtered = append(filtered, kb) } else if creatorFilter == "others" && kb.CreatorID != callerUserIDStr { filtered = append(filtered, kb) } } kbs = filtered } kbs = filterKnowledgeBasesForAPIKeyScope(ctx, kbs) // Get share counts for all knowledge bases if len(kbs) > 0 && h.kbShareService != nil { kbIDs := make([]string, len(kbs)) for i, kb := range kbs { kbIDs[i] = kb.ID } shareCounts, err := h.kbShareService.CountSharesByKnowledgeBaseIDs(ctx, kbIDs) if err != nil { logger.Warnf(ctx, "Failed to get share counts: %v", err) } else { for _, kb := range kbs { if count, ok := shareCounts[kb.ID]; ok { kb.ShareCount = count } } } } // 批量回填 creator_name,让前端列表能区分「我创建」与「同空间其他成员创建」。 // 仅在 list 接口里回填,详情 / 编辑场景不依赖这个字段;解析失败(用户已删除、 // CreatorID 为空的老数据)就让字段为空,前端按 fallback 渲染。 enrichKBCreatorNames(ctx, h.userService, kbs) callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) c.JSON(http.StatusOK, gin.H{ "success": true, "data": h.buildKBListResponse(ctx, kbs, callerTenantID), }) } func filterKnowledgeBasesForAPIKeyScope(ctx context.Context, kbs []*types.KnowledgeBase) []*types.KnowledgeBase { scope, ok := types.TenantAPIKeyScopeFromContext(ctx) if !ok || len(scope.KnowledgeBaseIDs) == 0 { return kbs } filtered := make([]*types.KnowledgeBase, 0, len(kbs)) for _, kb := range kbs { if kb != nil && scope.AllowsKnowledgeBase(kb.ID) { filtered = append(filtered, kb) } } return filtered } // enrichKBCreatorNames 把 KB 列表里的 CreatorID 批量解析成展示名(username // 优先,退化到 email)。任意一步失败都吞掉错误:creator_name 缺失只会影响 // 卡片右下角的徽章展示,不该影响列表本身可用。 func enrichKBCreatorNames(ctx context.Context, userSvc interfaces.UserService, kbs []*types.KnowledgeBase) { if userSvc == nil || len(kbs) == 0 { return } idSet := make(map[string]struct{}, len(kbs)) for _, kb := range kbs { if kb.CreatorID != "" { idSet[kb.CreatorID] = struct{}{} } } if len(idSet) == 0 { return } ids := make([]string, 0, len(idSet)) for id := range idSet { ids = append(ids, id) } users, err := userSvc.GetUsersByIDs(ctx, ids) if err != nil { logger.Warnf(ctx, "Failed to resolve KB creator names: %v", err) return } for _, kb := range kbs { if kb.CreatorID == "" { continue } u, ok := users[kb.CreatorID] if !ok || u == nil { continue } kb.CreatorName = pickUserDisplayName(u) } } // pickUserDisplayName picks the field most users will recognise: Username // if present (it's required at registration), Email as a fallback. Used by // both KB and Agent list enrichment so the badge text stays consistent. func pickUserDisplayName(u *types.User) string { if u == nil { return "" } if u.Username != "" { return u.Username } return u.Email } // TogglePinKnowledgeBase godoc // @Summary 置顶/取消置顶知识库 // @Description 切换知识库的置顶状态 // @Tags 知识库 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Success 200 {object} map[string]interface{} "更新后的知识库" // @Failure 404 {object} errors.AppError "知识库不存在" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id}/pin [put] func (h *KnowledgeBaseHandler) TogglePinKnowledgeBase(c *gin.Context) { ctx := c.Request.Context() id := c.Param("id") if id == "" { c.Error(apperrors.NewBadRequestError("knowledge base ID is required")) return } kb, err := h.service.TogglePinKnowledgeBase(ctx, id) if err != nil { if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) { c.Error(apperrors.NewNotFoundError("knowledge base not found")) return } logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) c.JSON(http.StatusOK, gin.H{ "success": true, "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), nil), }) } // UpdateKnowledgeBaseRequest defines the request body structure for updating a knowledge base type UpdateKnowledgeBaseRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Config *types.KnowledgeBaseConfig `json:"config"` } // UpdateKnowledgeBase godoc // @Summary 更新知识库 // @Description 更新知识库的名称、描述和配置 // @Tags 知识库 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Param request body UpdateKnowledgeBaseRequest true "更新请求" // @Success 200 {object} map[string]interface{} "更新后的知识库" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id} [put] func (h *KnowledgeBaseHandler) UpdateKnowledgeBase(c *gin.Context) { ctx := c.Request.Context() logger.Info(ctx, "Start updating knowledge base") // Validate and get the knowledge base _, id, _, permission, err := h.validateAndGetKnowledgeBase(c) if err != nil { c.Error(err) return } // Only admin/editor can update knowledge base if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor { c.Error(apperrors.NewForbiddenError("No permission to update knowledge base")) return } // Parse request body var req UpdateKnowledgeBaseRequest if err := c.ShouldBindJSON(&req); err != nil { logger.Error(ctx, "Failed to parse request parameters", err) c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error())) return } if req.Config != nil { probe := &types.KnowledgeBase{ ChunkingConfig: req.Config.ChunkingConfig, ImageProcessingConfig: req.Config.ImageProcessingConfig, WikiConfig: req.Config.WikiConfig, } if err := validateKnowledgeBasePromptInstructions(probe); err != nil { c.Error(err) return } } logger.Infof(ctx, "Updating knowledge base, ID: %s, name: %s", secutils.SanitizeForLog(id), secutils.SanitizeForLog(req.Name)) // Update the knowledge base kb, err := h.service.UpdateKnowledgeBase(ctx, id, req.Name, req.Description, req.Config) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } logger.Infof(ctx, "Knowledge base updated successfully, ID: %s", secutils.SanitizeForLog(id)) callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) c.JSON(http.StatusOK, gin.H{ "success": true, "data": buildKBResponse(kb, h.resolveKBStoreView(ctx, kb, callerTenantID), nil), }) } // DeleteKnowledgeBase godoc // @Summary 删除知识库 // @Description 删除指定的知识库及其所有内容 // @Tags 知识库 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Success 200 {object} map[string]interface{} "删除成功" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id} [delete] func (h *KnowledgeBaseHandler) DeleteKnowledgeBase(c *gin.Context) { ctx := c.Request.Context() logger.Info(ctx, "Start deleting knowledge base") // Validate and get the knowledge base kb, id, _, permission, err := h.validateAndGetKnowledgeBase(c) if err != nil { c.Error(err) return } // Only owner (admin with matching tenant) can delete knowledge base tenantID, _ := c.Get(types.TenantIDContextKey.String()) if kb.TenantID != tenantID.(uint64) || permission != types.OrgRoleAdmin { c.Error(apperrors.NewForbiddenError("Only knowledge base owner can delete")) return } logger.Infof(ctx, "Deleting knowledge base, ID: %s, name: %s", secutils.SanitizeForLog(id), secutils.SanitizeForLog(kb.Name)) // Delete the knowledge base if err := h.service.DeleteKnowledgeBase(ctx, id); err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(apperrors.NewInternalServerError(err.Error())) return } logger.Infof(ctx, "Knowledge base deleted successfully, ID: %s", secutils.SanitizeForLog(id)) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Knowledge base deleted successfully", }) } type CopyKnowledgeBaseRequest struct { TaskID string `json:"task_id"` SourceID string `json:"source_id" binding:"required"` TargetID string `json:"target_id"` } // CopyKnowledgeBaseResponse defines the response for copy knowledge base type CopyKnowledgeBaseResponse struct { TaskID string `json:"task_id"` SourceID string `json:"source_id"` TargetID string `json:"target_id"` Message string `json:"message"` } type DuplicateKnowledgeBaseResponse struct { SourceID string `json:"source_id"` TargetID string `json:"target_id"` Message string `json:"message"` KnowledgeBase interface{} `json:"knowledge_base"` } // CopyKnowledgeBase godoc // @Summary 复制知识库 // @Description 将一个知识库的内容复制到另一个知识库(异步任务) // @Tags 知识库 // @Accept json // @Produce json // @Param request body CopyKnowledgeBaseRequest true "复制请求" // @Success 200 {object} map[string]interface{} "任务ID" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/copy [post] func (h *KnowledgeBaseHandler) CopyKnowledgeBase(c *gin.Context) { ctx := c.Request.Context() var req CopyKnowledgeBaseRequest if err := c.ShouldBindJSON(&req); err != nil { logger.Error(ctx, "Failed to parse request parameters", err) c.Error(apperrors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error())) return } // Get tenant ID from context tenantID, exists := c.Get(types.TenantIDContextKey.String()) if !exists { logger.Error(ctx, "Failed to get tenant ID") c.Error(apperrors.NewUnauthorizedError("Unauthorized")) return } kbIDs := []string{req.SourceID} if req.TargetID != "" { kbIDs = append(kbIDs, req.TargetID) } if err := requireTenantAPIKeyKnowledgeBases(ctx, kbIDs...); err != nil { c.Error(err) return } // Validate source knowledge base exists and belongs to caller's tenant (prevent cross-tenant clone) sourceKB, err := h.service.GetKnowledgeBaseByID(ctx, req.SourceID) if err != nil { if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) { c.Error(errors.NewNotFoundError("Source knowledge base not found")) return } logger.ErrorWithFields(ctx, err, nil) c.Error(errors.NewInternalServerError(err.Error())) return } if sourceKB.TenantID != tenantID.(uint64) { logger.Warnf(ctx, "Copy rejected: source knowledge base belongs to another tenant, source_id: %s, caller_tenant: %d, kb_tenant: %d", secutils.SanitizeForLog(req.SourceID), tenantID.(uint64), sourceKB.TenantID) c.Error(errors.NewForbiddenError("No permission to copy this knowledge base")) return } if err := service.CheckKnowledgeBaseCopyPermission(ctx, sourceKB); err != nil { c.Error(err) return } // If target_id provided, validate target belongs to caller's tenant // and run the pre-flight defenses synchronously so a mismatched // clone is rejected with 400 before the task is enqueued. The same // checks are re-applied inside the async worker (service.CopyKnowledgeBase) // as defense in depth. if req.TargetID != "" { targetKB, err := h.service.GetKnowledgeBaseByID(ctx, req.TargetID) if err != nil { if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) { c.Error(errors.NewNotFoundError("Target knowledge base not found")) return } logger.ErrorWithFields(ctx, err, nil) c.Error(errors.NewInternalServerError(err.Error())) return } if targetKB.TenantID != tenantID.(uint64) { logger.Warnf(ctx, "Copy rejected: target knowledge base belongs to another tenant, target_id: %s", secutils.SanitizeForLog(req.TargetID)) c.Error(errors.NewForbiddenError("No permission to copy to this knowledge base")) return } // Pre-flight defense 1: embedding model must match. if err := service.CheckKnowledgeBaseCopyPermission(ctx, targetKB); err != nil { c.Error(err) return } // Without this check the async clone would run with incompatible // vector spaces and produce semantically broken results. if sourceKB.EmbeddingModelID != targetKB.EmbeddingModelID { c.Error(apperrors.NewBadRequestError( "source and target knowledge bases use different embedding models; " + "clone into a target with the same embedding model")) return } // Pre-flight defense 2: vector store binding must match. // Cross-store cloning would require copying physical vector data // between stores, which is not yet supported. if !sourceKB.SharesStoreWith(targetKB) { c.Error(apperrors.NewBadRequestError( "source and target knowledge bases are bound to different vector stores; " + "cross-store cloning is not yet supported")) return } // Pre-flight defense 3: compare concrete instance IDs, not just the // provider type (COS-A and COS-B are different physical stores). if tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant); tenant != nil { defaultID, defaultProvider := "", "" if tenant.DefaultStorageBackendID != nil { defaultID = *tenant.DefaultStorageBackendID } if tenant.StorageEngineConfig != nil { defaultProvider = tenant.StorageEngineConfig.DefaultProvider } if !sourceKB.SharesStorageBackendWith(targetKB, defaultID, defaultProvider) { c.Error(apperrors.NewBadRequestError( "source and target knowledge bases use different storage instances; cross-storage-backend cloning is not supported")) return } } } // Generate task ID if not provided taskID := req.TaskID if taskID == "" { taskID = utils.GenerateTaskID("kb_clone", tenantID.(uint64), req.SourceID) } // Create KB clone payload payload := types.KBClonePayload{ TenantID: tenantID.(uint64), TaskID: taskID, SourceID: req.SourceID, TargetID: req.TargetID, Initiator: types.TaskInitiatorFromContext(ctx), } langfuse.InjectTracing(ctx, &payload) payloadBytes, err := json.Marshal(payload) if err != nil { logger.Errorf(ctx, "Failed to marshal KB clone payload: %v", err) c.Error(apperrors.NewInternalServerError("Failed to create task")) return } // Enqueue KB clone task to Asynq task := asynq.NewTask(types.TypeKBClone, payloadBytes, asynq.TaskID(taskID), asynq.Queue(types.QueueMaintenance), asynq.MaxRetry(3), asynq.Timeout(2*time.Hour)) info, err := h.asynqClient.Enqueue(task) if err != nil { logger.Errorf(ctx, "Failed to enqueue KB clone task: %v", err) c.Error(apperrors.NewInternalServerError("Failed to enqueue task")) return } logger.Infof(ctx, "KB clone task enqueued: %s, asynq task ID: %s, source: %s, target: %s", taskID, info.ID, secutils.SanitizeForLog(req.SourceID), secutils.SanitizeForLog(req.TargetID)) // Save initial progress to Redis so frontend can query immediately initialProgress := &types.KBCloneProgress{ TaskID: taskID, SourceID: req.SourceID, TargetID: req.TargetID, Status: types.KBCloneStatusPending, Progress: 0, Message: "Task queued, waiting to start...", CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), } if err := h.knowledgeService.SaveKBCloneProgress(ctx, initialProgress); err != nil { logger.Warnf(ctx, "Failed to save initial KB clone progress: %v", err) // Don't fail the request, task is already enqueued } c.JSON(http.StatusOK, gin.H{ "success": true, "data": CopyKnowledgeBaseResponse{ TaskID: taskID, SourceID: req.SourceID, TargetID: req.TargetID, Message: "Knowledge base copy task started", }, }) } // DuplicateKnowledgeBase godoc // @Summary 创建知识库副本 // @Description 创建一个只包含设置的新知识库副本,不复制知识、FAQ 内容、分块、索引、Wiki 页面、分享或置顶状态 // @Tags 知识库 // @Accept json // @Produce json // @Param id path string true "源知识库 ID" // @Success 201 {object} map[string]interface{} "创建后的知识库副本" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Router /knowledge-bases/{id}/duplicate [post] func (h *KnowledgeBaseHandler) DuplicateKnowledgeBase(c *gin.Context) { ctx := c.Request.Context() sourceID := c.Param("id") if sourceID == "" { c.Error(apperrors.NewBadRequestError("Knowledge base ID cannot be empty")) return } callerTenantID := c.GetUint64(types.TenantIDContextKey.String()) sourceKB, err := h.service.GetKnowledgeBaseByID(ctx, sourceID) if err != nil { if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) { c.Error(errors.NewNotFoundError("Source knowledge base not found")) return } logger.ErrorWithFields(ctx, err, nil) c.Error(errors.NewInternalServerError(err.Error())) return } if sourceKB.TenantID != callerTenantID { logger.Warnf(ctx, "Knowledge base duplicate rejected: source belongs to another tenant, source_id: %s, caller_tenant: %d, kb_tenant: %d", secutils.SanitizeForLog(sourceID), callerTenantID, sourceKB.TenantID) c.Error(errors.NewForbiddenError("No permission to duplicate this knowledge base")) return } targetKB, err := h.service.DuplicateKnowledgeBase(ctx, sourceID) if err != nil { if appErr, ok := apperrors.IsAppError(err); ok { c.Error(appErr) return } if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) { c.Error(errors.NewNotFoundError("Source knowledge base not found")) return } logger.ErrorWithFields(ctx, err, nil) c.Error(errors.NewInternalServerError(err.Error())) return } logger.Infof(ctx, "Knowledge base duplicate created, source: %s, target: %s", secutils.SanitizeForLog(sourceID), secutils.SanitizeForLog(targetKB.ID)) c.JSON(http.StatusCreated, gin.H{ "success": true, "data": DuplicateKnowledgeBaseResponse{ SourceID: sourceID, TargetID: targetKB.ID, Message: "Knowledge base duplicate created", KnowledgeBase: buildKBResponse(targetKB, h.resolveKBStoreView(ctx, targetKB, callerTenantID), nil), }, }) } // GetKBCloneProgress godoc // @Summary 获取知识库复制进度 // @Description 获取知识库复制任务的进度 // @Tags 知识库 // @Accept json // @Produce json // @Param task_id path string true "任务ID" // @Success 200 {object} map[string]interface{} "进度信息" // @Failure 404 {object} errors.AppError "任务不存在" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/copy/progress/{task_id} [get] func (h *KnowledgeBaseHandler) GetKBCloneProgress(c *gin.Context) { ctx := c.Request.Context() taskID := c.Param("task_id") if taskID == "" { logger.Error(ctx, "Task ID is empty") c.Error(apperrors.NewBadRequestError("Task ID cannot be empty")) return } if err := requireTaskProgressTenant(ctx, taskID); err != nil { c.Error(err) return } progress, err := h.knowledgeService.GetKBCloneProgress(ctx, taskID) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": progress, }) } // validateExtractConfig validates the graph configuration parameters func validateExtractConfig(config *types.ExtractConfig) error { if config == nil { return nil } if !config.Enabled { config.Enabled = false return nil } // Validate text field if config.Text == "" { return apperrors.NewBadRequestError("text cannot be empty") } // Validate tags field if len(config.Tags) == 0 { return apperrors.NewBadRequestError("tags cannot be empty") } for i, tag := range config.Tags { if tag == "" { return apperrors.NewBadRequestError("tag cannot be empty at index " + strconv.Itoa(i)) } } // Validate nodes if len(config.Nodes) == 0 { return apperrors.NewBadRequestError("nodes cannot be empty") } nodeNames := make(map[string]bool) for i, node := range config.Nodes { if node.Name == "" { return apperrors.NewBadRequestError("node name cannot be empty at index " + strconv.Itoa(i)) } // Check for duplicate node names if nodeNames[node.Name] { return apperrors.NewBadRequestError("duplicate node name: " + node.Name) } nodeNames[node.Name] = true } if len(config.Relations) == 0 { return apperrors.NewBadRequestError("relations cannot be empty") } // Validate relations for i, relation := range config.Relations { if relation.Node1 == "" { return apperrors.NewBadRequestError("relation node1 cannot be empty at index " + strconv.Itoa(i)) } if relation.Node2 == "" { return apperrors.NewBadRequestError("relation node2 cannot be empty at index " + strconv.Itoa(i)) } if relation.Type == "" { return apperrors.NewBadRequestError("relation type cannot be empty at index " + strconv.Itoa(i)) } // Check if referenced nodes exist if !nodeNames[relation.Node1] { return apperrors.NewBadRequestError("relation references non-existent node1: " + relation.Node1) } if !nodeNames[relation.Node2] { return apperrors.NewBadRequestError("relation references non-existent node2: " + relation.Node2) } } return nil } func validateKnowledgeBasePromptInstructions(kb *types.KnowledgeBase) error { if err := types.ValidateKnowledgeBasePromptInstructions(kb); err != nil { return apperrors.NewBadRequestError(err.Error()) } return nil } // ListMoveTargets returns knowledge bases eligible as move targets for the given source KB. // Filters: same Type, same EmbeddingModelID, different ID, not temporary. // // ListMoveTargets godoc // @Summary 获取可移动目标知识库列表 // @Description 返回与源知识库 Type 一致、EmbeddingModelID 一致、非临时且不是自身的目标知识库列表 // @Tags 知识库 // @Produce json // @Param id path string true "源知识库 ID" // @Success 200 {object} map[string]interface{} "可移动目标列表" // @Failure 400 {object} errors.AppError "请求参数错误" // @Failure 404 {object} errors.AppError "知识库不存在" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id}/move-targets [get] func (h *KnowledgeBaseHandler) ListMoveTargets(c *gin.Context) { ctx := c.Request.Context() sourceKBID := c.Param("id") if sourceKBID == "" { c.Error(apperrors.NewBadRequestError("Knowledge base ID is required")) return } tenantID, exists := c.Get(types.TenantIDContextKey.String()) if !exists { c.Error(apperrors.NewUnauthorizedError("Unauthorized")) return } // Get source knowledge base sourceKB, err := h.service.GetKnowledgeBaseByID(ctx, sourceKBID) if err != nil { if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) { c.Error(errors.NewNotFoundError("Source knowledge base not found")) return } c.Error(errors.NewInternalServerError(err.Error())) return } if sourceKB.TenantID != tenantID.(uint64) { c.Error(errors.NewForbiddenError("No permission to access this knowledge base")) return } // Get all knowledge bases allKBs, err := h.service.ListKnowledgeBases(ctx) if err != nil { c.Error(errors.NewInternalServerError(err.Error())) return } // Filter eligible targets targets := make([]*types.KnowledgeBase, 0) for _, kb := range allKBs { if kb.ID == sourceKBID { continue } if kb.IsTemporary { continue } if kb.Type != sourceKB.Type { continue } if kb.EmbeddingModelID != sourceKB.EmbeddingModelID { continue } targets = append(targets, kb) } c.JSON(http.StatusOK, gin.H{ "success": true, "data": targets, }) }