Files
Enterprise-Knowledge-Base/internal/handler/tenant.go
T
wizardchen 1e8b5c8d40 refactor(api): drop deprecated tenant ConversationConfig field and KV write path
The Tenant.ConversationConfig field was marked deprecated when
CustomAgent (builtin-quick-answer) replaced the per-tenant defaults
path, but the JSONB column, GET/PUT KV handlers, write helpers and
read fall-throughs all stayed wired. This had two concrete failure
modes:

  - Input-field.vue silently PUT-ed per-user model picks back into
    /tenants/kv/conversation-config, which requires Admin+ — Viewers
    and Contributors got a 403 every time they switched chat models.
  - The same write also overwrote the tenant-shared default for every
    other user, turning a per-user UX into a per-tenant footgun.

Remove the field, the struct, the validate helper, the PUT case and
the two dead Summary helpers in session/helpers.go. KnowledgeSearch
loses its tenant-conversation fallback (one chunk-tool reader); the
global config + hardcoded defaults still cover it. session_agent_qa
already required agents to declare their own rerank_model_id, so no
behaviour change there.

Frontend follows:
  - Input-field.vue: per-user 'last selected chat model' moves to
    localStorage, no API call.
  - AgentEditorModal: defaults for builtin-quick-answer creation now
    come from prompt-templates (system/context/rewrite/fallback) and
    retrieval-config (top_k / threshold), the two endpoints that
    actually own those settings now.
  - AgentSettings.vue: orphan settings page with no router/import
    referencing it; deleted.
  - api/system: getConversationConfig / updateConversationConfig and
    the ConversationConfig interface go away.

Net: 137 insertions, 2761 deletions.
2026-05-18 17:28:58 +08:00

1069 lines
35 KiB
Go

package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/agent"
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// TenantHandler implements HTTP request handlers for tenant management
// Provides functionality for creating, retrieving, updating, and deleting tenants
// through the REST API endpoints
type TenantHandler struct {
service interfaces.TenantService
userService interfaces.UserService
kbService interfaces.KnowledgeBaseService
config *config.Config
}
// NewTenantHandler creates a new tenant handler instance with the provided service
// Parameters:
// - service: An implementation of the TenantService interface for business logic
// - userService: An implementation of the UserService interface for user operations
// - config: Application configuration
//
// Returns a pointer to the newly created TenantHandler
//
// Note on RBAC: cross-tenant gating (CanAccessAllTenants /
// EnableCrossTenantAccess) and per-tenant path matching (URL :id ==
// active tenant) used to live in `authorizeTenantAccess` and the if
// blocks at the top of ListAllTenants / SearchTenants. Both moved to
// `middleware/access.go` (RequireCrossTenantAccess /
// RequirePathTenantMatch) and are wired in `router.go` so the handler
// stays focused on business logic.
func NewTenantHandler(service interfaces.TenantService, userService interfaces.UserService, kbService interfaces.KnowledgeBaseService, config *config.Config) *TenantHandler {
return &TenantHandler{
service: service,
userService: userService,
kbService: kbService,
config: config,
}
}
// CreateTenant godoc
// @Summary 创建租户
// @Description 创建新的租户
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param request body types.Tenant true "租户信息"
// @Success 201 {object} map[string]interface{} "创建的租户"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Router /tenants [post]
func (h *TenantHandler) CreateTenant(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start creating tenant")
var tenantData types.Tenant
if err := c.ShouldBindJSON(&tenantData); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
appErr := errors.NewValidationError("Invalid request parameters").WithDetails(err.Error())
c.Error(appErr)
return
}
logger.Infof(ctx, "Creating tenant, name: %s", secutils.SanitizeForLog(tenantData.Name))
createdTenant, err := h.service.CreateTenant(ctx, &tenantData)
if err != nil {
// Check if this is an application-specific error
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to create tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to create tenant").WithDetails(err.Error()))
}
return
}
logger.Infof(
ctx,
"Tenant created successfully, ID: %d, name: %s",
createdTenant.ID,
secutils.SanitizeForLog(createdTenant.Name),
)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": createdTenant,
})
}
// GetTenant godoc
// @Summary 获取租户详情
// @Description 根据ID获取租户详情
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param id path int true "租户ID"
// @Success 200 {object} map[string]interface{} "租户详情"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "租户不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/{id} [get]
func (h *TenantHandler) GetTenant(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
logger.Errorf(ctx, "Invalid tenant ID: %s", secutils.SanitizeForLog(c.Param("id")))
c.Error(errors.NewBadRequestError("Invalid tenant ID"))
return
}
tenant, err := h.service.GetTenantByID(ctx, id)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to retrieve tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to retrieve tenant").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tenant,
})
}
// UpdateTenant godoc
// @Summary 更新租户
// @Description 更新租户信息
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param id path int true "租户ID"
// @Param request body types.Tenant true "租户信息"
// @Success 200 {object} map[string]interface{} "更新后的租户"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Router /tenants/{id} [put]
func (h *TenantHandler) UpdateTenant(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating tenant")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
logger.Errorf(ctx, "Invalid tenant ID: %s", secutils.SanitizeForLog(c.Param("id")))
c.Error(errors.NewBadRequestError("Invalid tenant ID"))
return
}
var tenantData types.Tenant
if err := c.ShouldBindJSON(&tenantData); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
logger.Infof(ctx, "Updating tenant, ID: %d, Name: %s", id, secutils.SanitizeForLog(tenantData.Name))
tenantData.ID = id
updatedTenant, err := h.service.UpdateTenant(ctx, &tenantData)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to update tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update tenant").WithDetails(err.Error()))
}
return
}
logger.Infof(
ctx,
"Tenant updated successfully, ID: %d, Name: %s",
updatedTenant.ID,
secutils.SanitizeForLog(updatedTenant.Name),
)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": updatedTenant,
})
}
// ResetAPIKey godoc
// @Summary 重置租户 API Key
// @Description 为指定租户生成一个新的 API Key,旧 Key 立即失效
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param id path int true "租户ID"
// @Success 200 {object} map[string]interface{} "新生成的 API Key"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 403 {object} errors.AppError "权限不足"
// @Security Bearer
// @Router /tenants/{id}/api-key [post]
func (h *TenantHandler) ResetAPIKey(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
logger.Errorf(ctx, "Invalid tenant ID: %s", secutils.SanitizeForLog(c.Param("id")))
c.Error(errors.NewBadRequestError("Invalid tenant ID"))
return
}
logger.Infof(ctx, "Resetting API key for tenant, ID: %d", id)
apiKey, err := h.service.UpdateAPIKey(ctx, id)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to reset API key: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to reset API key").WithDetails(err.Error()))
}
return
}
logger.Infof(ctx, "API key reset successfully, tenant ID: %d", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"api_key": apiKey,
},
})
}
// DeleteTenant godoc
// @Summary 删除租户
// @Description 删除指定的租户
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param id path int true "租户ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Router /tenants/{id} [delete]
func (h *TenantHandler) DeleteTenant(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting tenant")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
logger.Errorf(ctx, "Invalid tenant ID: %s", secutils.SanitizeForLog(c.Param("id")))
c.Error(errors.NewBadRequestError("Invalid tenant ID"))
return
}
logger.Infof(ctx, "Deleting tenant, ID: %d", id)
if err := h.service.DeleteTenant(ctx, id); err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to delete tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to delete tenant").WithDetails(err.Error()))
}
return
}
logger.Infof(ctx, "Tenant deleted successfully, ID: %d", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Tenant deleted successfully",
})
}
// ListTenants godoc
// @Summary 获取租户列表
// @Description 获取当前用户可访问的租户列表
// @Tags 租户管理
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "租户列表"
// @Failure 500 {object} errors.AppError "服务器错误"
// @Security Bearer
// @Router /tenants [get]
func (h *TenantHandler) ListTenants(c *gin.Context) {
ctx := c.Request.Context()
tenant, ok := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if !ok || tenant == nil {
c.Error(errors.NewUnauthorizedError("Authentication required"))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"items": []*types.Tenant{tenant},
},
})
}
// ListAllTenants godoc
// @Summary 获取所有租户列表
// @Description 获取系统中所有租户(需要跨租户访问权限)
// @Tags 租户管理
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "所有租户列表"
// @Failure 403 {object} errors.AppError "权限不足"
// @Security Bearer
// @Router /tenants/all [get]
func (h *TenantHandler) ListAllTenants(c *gin.Context) {
ctx := c.Request.Context()
// Cross-tenant gating (CanAccessAllTenants + EnableCrossTenantAccess)
// is enforced at the route layer via middleware.RequireCrossTenantAccess
// (router.go). The handler stays focused on listing.
tenants, err := h.service.ListAllTenants(ctx)
if err != nil {
// Check if this is an application-specific error
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to retrieve all tenants list: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to retrieve all tenants list").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"items": tenants,
},
})
}
// SearchTenants godoc
// @Summary 搜索租户
// @Description 分页搜索租户(需要跨租户访问权限)
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param keyword query string false "搜索关键词"
// @Param tenant_id query int false "租户ID筛选"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} map[string]interface{} "搜索结果"
// @Failure 403 {object} errors.AppError "权限不足"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/search [get]
func (h *TenantHandler) SearchTenants(c *gin.Context) {
ctx := c.Request.Context()
// Cross-tenant gating is enforced at the route layer via
// middleware.RequireCrossTenantAccess (router.go); the handler only
// parses query params and delegates to the service.
// Parse query parameters
keyword := c.Query("keyword")
tenantIDStr := c.Query("tenant_id")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20")
var tenantID uint64
if tenantIDStr != "" {
parsedID, err := strconv.ParseUint(tenantIDStr, 10, 64)
if err == nil {
tenantID = parsedID
}
}
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100 // Limit max page size
}
tenants, total, err := h.service.SearchTenants(ctx, keyword, tenantID, page, pageSize)
if err != nil {
// Check if this is an application-specific error
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to search tenants: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to search tenants").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"items": tenants,
"total": total,
"page": page,
"page_size": pageSize,
},
})
}
// AgentConfigRequest represents the request body for updating agent configuration
type AgentConfigRequest struct {
MaxIterations int `json:"max_iterations"`
AllowedTools []string `json:"allowed_tools"`
Temperature float64 `json:"temperature"`
SystemPrompt string `json:"system_prompt,omitempty"` // Unified system prompt (uses {{web_search_status}} placeholder)
}
// GetTenantAgentConfig godoc
// @Summary 获取租户Agent配置
// @Description 获取租户的全局Agent配置(默认应用于所有会话)
// @Tags 租户管理
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "Agent配置"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/kv/agent-config [get]
func (h *TenantHandler) GetTenantAgentConfig(c *gin.Context) {
ctx := c.Request.Context()
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
// 从 tools 包集中配置可用工具列表
availableTools := make([]gin.H, 0)
for _, t := range agenttools.AvailableToolDefinitions() {
availableTools = append(availableTools, gin.H{
"name": t.Name,
"label": t.Label,
"description": t.Description,
})
}
// 从 agent 包获取占位符定义
availablePlaceholders := make([]gin.H, 0)
for _, p := range agent.AvailablePlaceholders() {
availablePlaceholders = append(availablePlaceholders, gin.H{
"name": p.Name,
"label": p.Label,
"description": p.Description,
})
}
if tenant.AgentConfig == nil {
// Return default config if not set
logger.Info(ctx, "Tenant has no agent config, returning defaults")
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"max_iterations": agent.DefaultAgentMaxIterations,
"allowed_tools": agenttools.DefaultAllowedTools(),
"temperature": agent.DefaultAgentTemperature,
"system_prompt": agent.GetProgressiveRAGSystemPrompt(h.config),
"use_custom_system_prompt": false,
"available_tools": availableTools,
"available_placeholders": availablePlaceholders,
},
})
return
}
// Get system prompt, use default if empty
systemPrompt := tenant.AgentConfig.ResolveSystemPrompt(true) // webSearchEnabled doesn't matter for unified prompt
if systemPrompt == "" {
systemPrompt = agent.GetProgressiveRAGSystemPrompt(h.config)
}
logger.Infof(ctx, "Retrieved tenant agent config successfully, Tenant ID: %d", tenant.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"max_iterations": tenant.AgentConfig.MaxIterations,
"allowed_tools": agenttools.DefaultAllowedTools(),
"temperature": tenant.AgentConfig.Temperature,
"system_prompt": systemPrompt,
"use_custom_system_prompt": tenant.AgentConfig.UseCustomSystemPrompt,
"available_tools": availableTools,
"available_placeholders": availablePlaceholders,
},
})
}
// updateTenantAgentConfigInternal updates the agent configuration for a tenant
// This sets the global agent configuration for all sessions in this tenant
func (h *TenantHandler) updateTenantAgentConfigInternal(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating tenant agent config")
var req AgentConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
// Validate configuration
if req.MaxIterations <= 0 || req.MaxIterations > 30 {
c.Error(errors.NewAgentInvalidMaxIterationsError())
return
}
if req.Temperature < 0 || req.Temperature > 2 {
c.Error(errors.NewAgentInvalidTemperatureError())
return
}
// Get existing tenant
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
// Update agent configuration
// Determine if using custom prompt based on whether custom prompts are set
// Support both new unified SystemPrompt and deprecated separate prompts
systemPrompt := req.SystemPrompt
useCustomPrompt := systemPrompt != ""
agentConfig := &types.AgentConfig{
MaxIterations: req.MaxIterations,
AllowedTools: agenttools.DefaultAllowedTools(),
Temperature: req.Temperature,
SystemPrompt: systemPrompt,
UseCustomSystemPrompt: useCustomPrompt,
}
_, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to update tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update tenant agent config").WithDetails(err.Error()))
}
return
}
logger.Infof(ctx, "Tenant agent config updated successfully, Tenant ID: %d", tenant.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": agentConfig,
"message": "Agent configuration updated successfully",
})
}
// GetTenantKV godoc
// @Summary 获取租户KV配置
// @Description 获取租户级别的KV配置(支持agent-config、web-search-config 等)
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param key path string true "配置键名"
// @Success 200 {object} map[string]interface{} "配置值"
// @Failure 400 {object} errors.AppError "不支持的键"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/kv/{key} [get]
func (h *TenantHandler) GetTenantKV(c *gin.Context) {
ctx := c.Request.Context()
key := secutils.SanitizeForLog(c.Param("key"))
switch key {
case "agent-config":
h.GetTenantAgentConfig(c)
return
case "web-search-config":
h.GetTenantWebSearchConfig(c)
return
case "prompt-templates":
h.GetPromptTemplates(c)
return
case "parser-engine-config":
h.GetTenantParserEngineConfig(c)
return
case "storage-engine-config":
h.GetTenantStorageEngineConfig(c)
return
case "chat-history-config":
h.GetTenantChatHistoryConfig(c)
return
case "retrieval-config":
h.GetTenantRetrievalConfig(c)
return
default:
logger.Info(ctx, "KV key not supported", "key", key)
c.Error(errors.NewBadRequestError("unsupported key"))
return
}
}
// UpdateTenantKV godoc
// @Summary 更新租户KV配置
// @Description 更新租户级别的KV配置(支持agent-config、web-search-config 等)
// @Tags 租户管理
// @Accept json
// @Produce json
// @Param key path string true "配置键名"
// @Param request body object true "配置值"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "不支持的键"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/kv/{key} [put]
func (h *TenantHandler) UpdateTenantKV(c *gin.Context) {
ctx := c.Request.Context()
key := secutils.SanitizeForLog(c.Param("key"))
switch key {
case "agent-config":
h.updateTenantAgentConfigInternal(c)
return
case "web-search-config":
h.updateTenantWebSearchConfigInternal(c)
return
case "parser-engine-config":
h.updateTenantParserEngineConfigInternal(c)
return
case "storage-engine-config":
h.updateTenantStorageEngineConfigInternal(c)
return
case "chat-history-config":
h.updateTenantChatHistoryConfigInternal(c)
return
case "retrieval-config":
h.updateTenantRetrievalConfigInternal(c)
return
default:
logger.Info(ctx, "KV key not supported", "key", key)
c.Error(errors.NewBadRequestError("unsupported key"))
return
}
}
// updateTenantWebSearchConfigInternal updates tenant's web search config
func (h *TenantHandler) updateTenantWebSearchConfigInternal(c *gin.Context) {
ctx := c.Request.Context()
// Bind directly into the strong typed struct
var cfg types.WebSearchConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
cfg = *types.EffectiveWebSearchConfig(&cfg)
// Validate configuration
if cfg.MaxResults < 1 || cfg.MaxResults > 50 {
c.Error(errors.NewBadRequestError("max_results must be between 1 and 50"))
return
}
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
tenant.WebSearchConfig = &cfg
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to update tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update tenant web search config").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": types.EffectiveWebSearchConfig(updatedTenant.WebSearchConfig),
"message": "Web search configuration updated successfully",
})
}
// GetTenantWebSearchConfig godoc
// @Summary 获取租户网络搜索配置
// @Description 获取租户的网络搜索配置
// @Tags 租户管理
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "网络搜索配置"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/kv/web-search-config [get]
func (h *TenantHandler) GetTenantWebSearchConfig(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start getting tenant web search config")
// Get tenant
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
logger.Infof(ctx, "Tenant web search config retrieved successfully, Tenant ID: %d", tenant.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": types.EffectiveWebSearchConfig(tenant.WebSearchConfig),
})
}
// GetTenantParserEngineConfig returns the tenant's parser engine config (MinerU endpoint, API key, etc.).
func (h *TenantHandler) GetTenantParserEngineConfig(c *gin.Context) {
ctx := c.Request.Context()
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
data := tenant.ParserEngineConfig
if data == nil {
data = &types.ParserEngineConfig{}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": data,
})
}
// updateTenantParserEngineConfigInternal updates the tenant's parser engine config.
func (h *TenantHandler) updateTenantParserEngineConfigInternal(c *gin.Context) {
ctx := c.Request.Context()
var cfg types.ParserEngineConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
tenant.ParserEngineConfig = &cfg
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update tenant parser engine config").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": updatedTenant.ParserEngineConfig,
"message": "解析引擎配置已更新",
})
}
// GetTenantStorageEngineConfig returns the tenant's storage engine config (Local, MinIO, COS parameters).
func (h *TenantHandler) GetTenantStorageEngineConfig(c *gin.Context) {
ctx := c.Request.Context()
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
data := tenant.StorageEngineConfig
if data == nil {
data = &types.StorageEngineConfig{}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": data,
})
}
// updateTenantStorageEngineConfigInternal updates the tenant's storage engine config.
func (h *TenantHandler) updateTenantStorageEngineConfigInternal(c *gin.Context) {
ctx := c.Request.Context()
var cfg types.StorageEngineConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
provider := strings.ToLower(strings.TrimSpace(cfg.DefaultProvider))
if provider == "" {
provider = firstAllowedStorageProvider()
}
if provider == "" {
c.Error(errors.NewBadRequestError("No storage provider is allowed by STORAGE_ALLOW_LIST"))
return
}
if !isStorageProviderAllowed(provider) {
c.Error(errors.NewBadRequestError("Storage provider is not allowed by STORAGE_ALLOW_LIST"))
return
}
cfg.DefaultProvider = provider
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
tenant.StorageEngineConfig = &cfg
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update tenant storage engine config").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": updatedTenant.StorageEngineConfig,
"message": "存储引擎配置已更新",
})
}
// GetPromptTemplates godoc
// @Summary 获取提示词模板
// @Description 获取系统配置的提示词模板列表
// @Tags 租户管理
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "提示词模板配置"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/kv/prompt-templates [get]
func (h *TenantHandler) GetPromptTemplates(c *gin.Context) {
// Return prompt templates from config.yaml
templates := h.config.PromptTemplates
if templates == nil {
templates = &config.PromptTemplatesConfig{}
}
// Determine user language from context (set by Language middleware)
lang, _ := types.LanguageFromContext(c.Request.Context())
// Build a localized copy so the original config is never mutated
localized := &config.PromptTemplatesConfig{
SystemPrompt: config.LocalizeTemplates(templates.SystemPrompt, lang),
ContextTemplate: config.LocalizeTemplates(templates.ContextTemplate, lang),
Rewrite: config.LocalizeTemplates(templates.Rewrite, lang),
Fallback: config.LocalizeTemplates(templates.Fallback, lang),
GenerateSessionTitle: templates.GenerateSessionTitle,
GenerateSummary: templates.GenerateSummary,
KeywordsExtraction: templates.KeywordsExtraction,
AgentSystemPrompt: config.LocalizeTemplates(templates.AgentSystemPrompt, lang),
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": localized,
})
}
// GetTenantChatHistoryConfig returns the tenant's chat history KB configuration.
func (h *TenantHandler) GetTenantChatHistoryConfig(c *gin.Context) {
ctx := c.Request.Context()
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
data := tenant.ChatHistoryConfig
if data == nil {
data = &types.ChatHistoryConfig{}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": data,
})
}
// updateTenantChatHistoryConfigInternal updates the tenant's chat history KB configuration.
// When enabled with an embedding model and no KB exists yet, it auto-creates a hidden KB.
func (h *TenantHandler) updateTenantChatHistoryConfigInternal(c *gin.Context) {
ctx := c.Request.Context()
// The frontend sends: enabled, embedding_model_id
// knowledge_base_id is managed internally.
var req types.ChatHistoryConfig
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
existing := tenant.ChatHistoryConfig
// Build the new config, preserving the internally-managed knowledge_base_id
cfg := &types.ChatHistoryConfig{
Enabled: req.Enabled,
EmbeddingModelID: req.EmbeddingModelID,
KnowledgeBaseID: "", // will be set below
}
// Carry over existing KB ID if the embedding model hasn't changed
if existing != nil && existing.KnowledgeBaseID != "" {
if existing.EmbeddingModelID == req.EmbeddingModelID {
cfg.KnowledgeBaseID = existing.KnowledgeBaseID
} else {
// Embedding model changed — the old KB is incompatible.
// We'll create a new one below. The old KB remains but is orphaned (can be cleaned up later).
logger.Infof(ctx, "Embedding model changed from %s to %s, will create new chat history KB", existing.EmbeddingModelID, req.EmbeddingModelID)
}
}
// Auto-create hidden KB if enabled + model set + no KB yet
if cfg.Enabled && cfg.EmbeddingModelID != "" && cfg.KnowledgeBaseID == "" {
kb := &types.KnowledgeBase{
Name: "__chat_history__",
Type: types.KnowledgeBaseTypeDocument,
IsTemporary: true,
Description: "Auto-managed knowledge base for chat history message indexing",
EmbeddingModelID: cfg.EmbeddingModelID,
}
createdKB, err := h.kbService.CreateKnowledgeBase(ctx, kb)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to create chat history knowledge base").WithDetails(err.Error()))
return
}
cfg.KnowledgeBaseID = createdKB.ID
logger.Infof(ctx, "Auto-created chat history KB: id=%s, embedding_model=%s", createdKB.ID, cfg.EmbeddingModelID)
}
tenant.ChatHistoryConfig = cfg
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update chat history config").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": updatedTenant.ChatHistoryConfig,
"message": "Chat history configuration updated successfully",
})
}
// GetTenantRetrievalConfig returns the tenant's global retrieval configuration.
func (h *TenantHandler) GetTenantRetrievalConfig(c *gin.Context) {
ctx := c.Request.Context()
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
data := tenant.RetrievalConfig
if data == nil {
data = &types.RetrievalConfig{}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": data,
})
}
// updateTenantRetrievalConfigInternal updates the tenant's global retrieval configuration.
func (h *TenantHandler) updateTenantRetrievalConfigInternal(c *gin.Context) {
ctx := c.Request.Context()
var cfg types.RetrievalConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
// Validate thresholds
if cfg.VectorThreshold < 0 || cfg.VectorThreshold > 1 {
c.Error(errors.NewBadRequestError("vector_threshold must be between 0 and 1"))
return
}
if cfg.KeywordThreshold < 0 || cfg.KeywordThreshold > 1 {
c.Error(errors.NewBadRequestError("keyword_threshold must be between 0 and 1"))
return
}
if cfg.RerankThreshold < -10 || cfg.RerankThreshold > 10 {
c.Error(errors.NewBadRequestError("rerank_threshold must be between -10 and 10"))
return
}
if cfg.EmbeddingTopK < 0 || cfg.EmbeddingTopK > 200 {
c.Error(errors.NewBadRequestError("embedding_top_k must be between 0 and 200"))
return
}
if cfg.RerankTopK < 0 || cfg.RerankTopK > 200 {
c.Error(errors.NewBadRequestError("rerank_top_k must be between 0 and 200"))
return
}
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
tenant.RetrievalConfig = &cfg
updatedTenant, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update retrieval config").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": updatedTenant.RetrievalConfig,
"message": "Retrieval configuration updated successfully",
})
}