Files
xinmi/internal/handler/knowledgebase.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

1451 lines
53 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
})
}