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,
|
|||
|
|
})
|
|||
|
|
}
|