1e8b5c8d40
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.
1069 lines
35 KiB
Go
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",
|
|
})
|
|
}
|