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
1451 lines
53 KiB
Go
1451 lines
53 KiB
Go
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,
|
||
})
|
||
}
|