From bb6dff80b5e49b05c162a0d4eeab15f319dec4a2 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Fri, 14 Nov 2025 12:09:52 +0800 Subject: [PATCH] feat: Add support for builtin models in the application - Introduced `is_builtin` property in the model configuration to identify builtin models. - Updated frontend components to display a tag for builtin models in the ModelSelector and ModelSettings. - Enhanced the ModelEditorDialog to prevent editing and deletion of builtin models. - Implemented logic in the backend to hide sensitive information for builtin models and restrict their modification. - Added informative UI elements to clarify the nature of builtin models in the settings and editor views. --- docs/BUILTIN_MODELS.md | 173 ++++++++++++++++ frontend/src/api/model/index.ts | 1 + frontend/src/components/ModelEditorDialog.vue | 98 ++++++++-- frontend/src/components/ModelSelector.vue | 1 + .../knowledge/KnowledgeBaseEditorModal.vue | 10 + frontend/src/views/settings/ModelSettings.vue | 184 ++++++++++++++++-- internal/application/repository/model.go | 4 +- internal/application/service/model.go | 31 ++- internal/handler/initialization.go | 24 ++- internal/handler/model.go | 64 +++++- internal/types/model.go | 2 + .../000007_add_builtin_models.down.sql | 14 ++ .../000007_add_builtin_models.up.sql | 14 ++ 13 files changed, 571 insertions(+), 49 deletions(-) create mode 100644 docs/BUILTIN_MODELS.md create mode 100644 migrations/versioned/000007_add_builtin_models.down.sql create mode 100644 migrations/versioned/000007_add_builtin_models.up.sql diff --git a/docs/BUILTIN_MODELS.md b/docs/BUILTIN_MODELS.md new file mode 100644 index 00000000..99e5b739 --- /dev/null +++ b/docs/BUILTIN_MODELS.md @@ -0,0 +1,173 @@ +# 内置模型管理指南 + +## 概述 + +内置模型是系统级别的模型配置,对所有租户可见,但敏感信息会被隐藏,且不可编辑或删除。内置模型通常用于提供系统默认的模型配置,确保所有租户都能使用统一的模型服务。 + +## 内置模型特性 + +- **所有租户可见**:内置模型对所有租户都可见,无需单独配置 +- **安全保护**:内置模型的敏感信息(API Key、Base URL)会被隐藏,无法查看详情 +- **只读保护**:内置模型不能被编辑或删除,只能设置为默认模型 +- **统一管理**:由系统管理员统一维护,确保配置一致性和安全性 + +## 如何添加内置模型 + +内置模型需要通过数据库直接插入。以下是添加内置模型的步骤: + +### 1. 准备模型数据 + +首先,确保你已经有了要设置为内置模型的模型配置信息,包括: +- 模型名称(name) +- 模型类型(type):`KnowledgeQA`、`Embedding`、`Rerank` 或 `VLLM` +- 模型来源(source):`local` 或 `remote` +- 模型参数(parameters):包括 base_url、api_key 等 +- 租户ID(tenant_id):建议使用小于10000的租户ID,避免冲突 + +### 2. 执行 SQL 插入语句 + +使用以下 SQL 语句插入内置模型: + +```sql +-- 示例:插入一个 LLM 内置模型 +INSERT INTO models ( + id, + tenant_id, + name, + type, + source, + description, + parameters, + is_default, + status, + is_builtin +) VALUES ( + 'builtin-llm-001', -- 使用固定ID,建议使用 builtin- 前缀 + 10000, -- 租户ID(使用第一个租户) + 'GPT-4', -- 模型名称 + 'KnowledgeQA', -- 模型类型 + 'remote', -- 模型来源 + '内置 LLM 模型', -- 描述 + '{"base_url": "https://api.openai.com/v1", "api_key": "sk-xxx"}'::jsonb, -- 参数(JSON格式) + false, -- 是否默认 + 'active', -- 状态 + true -- 标记为内置模型 +) ON CONFLICT (id) DO NOTHING; + +-- 示例:插入一个 Embedding 内置模型 +INSERT INTO models ( + id, + tenant_id, + name, + type, + source, + description, + parameters, + is_default, + status, + is_builtin +) VALUES ( + 'builtin-embedding-001', + 10000, + 'text-embedding-ada-002', + 'Embedding', + 'remote', + '内置 Embedding 模型', + '{"base_url": "https://api.openai.com/v1", "api_key": "sk-xxx", "embedding_parameters": {"dimension": 1536, "truncate_prompt_tokens": 0}}'::jsonb, + false, + 'active', + true +) ON CONFLICT (id) DO NOTHING; + +-- 示例:插入一个 ReRank 内置模型 +INSERT INTO models ( + id, + tenant_id, + name, + type, + source, + description, + parameters, + is_default, + status, + is_builtin +) VALUES ( + 'builtin-rerank-001', + 10000, + 'bge-reranker-base', + 'Rerank', + 'remote', + '内置 ReRank 模型', + '{"base_url": "https://api.example.com/v1", "api_key": "xxx"}'::jsonb, + false, + 'active', + true +) ON CONFLICT (id) DO NOTHING; + +-- 示例:插入一个 VLLM 内置模型 +INSERT INTO models ( + id, + tenant_id, + name, + type, + source, + description, + parameters, + is_default, + status, + is_builtin +) VALUES ( + 'builtin-vllm-001', + 10000, + 'gpt-4-vision', + 'VLLM', + 'remote', + '内置 VLLM 模型', + '{"base_url": "https://api.openai.com/v1", "api_key": "sk-xxx"}'::jsonb, + false, + 'active', + true +) ON CONFLICT (id) DO NOTHING; +``` + +### 3. 验证插入结果 + +执行以下 SQL 查询验证内置模型是否成功插入: + +```sql +SELECT id, name, type, is_builtin, status +FROM models +WHERE is_builtin = true +ORDER BY type, created_at; +``` + +## 注意事项 + +1. **ID 命名规范**:建议使用 `builtin-{type}-{序号}` 的格式,例如 `builtin-llm-001`、`builtin-embedding-001` +2. **租户ID**:内置模型可以属于任意租户,但建议使用第一个租户ID(通常是 10000) +3. **参数格式**:`parameters` 字段必须是有效的 JSON 格式 +4. **幂等性**:使用 `ON CONFLICT (id) DO NOTHING` 确保重复执行不会报错 +5. **安全性**:内置模型的 API Key 和 Base URL 在前端会被自动隐藏,但数据库中的原始数据仍然存在,请妥善保管数据库访问权限 + +## 将现有模型设置为内置模型 + +如果你已经有一个模型,想将其设置为内置模型,可以使用 UPDATE 语句: + +```sql +UPDATE models +SET is_builtin = true +WHERE id = '模型ID' AND name = '模型名称'; +``` + +## 移除内置模型 + +如果需要移除内置模型标记(恢复为普通模型),执行: + +```sql +UPDATE models +SET is_builtin = false +WHERE id = '模型ID'; +``` + +注意:移除内置模型标记后,该模型将恢复为普通模型,可以被编辑和删除。 + diff --git a/frontend/src/api/model/index.ts b/frontend/src/api/model/index.ts index 16f42cd5..95de6ad9 100644 --- a/frontend/src/api/model/index.ts +++ b/frontend/src/api/model/index.ts @@ -19,6 +19,7 @@ export interface ModelConfig { parameter_size?: string; // Ollama模型参数大小 (e.g., "7B", "13B", "70B") }; is_default?: boolean; + is_builtin?: boolean; status?: string; created_at?: string; updated_at?: string; diff --git a/frontend/src/components/ModelEditorDialog.vue b/frontend/src/components/ModelEditorDialog.vue index 39a4a042..e99b1da9 100644 --- a/frontend/src/components/ModelEditorDialog.vue +++ b/frontend/src/components/ModelEditorDialog.vue @@ -781,12 +781,12 @@ const handleCancel = () => { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; z-index: 1000; - backdrop-filter: blur(4px); + backdrop-filter: blur(6px); overflow: hidden; // 防止背景滚动 } @@ -798,7 +798,7 @@ const handleCancel = () => { max-height: 85vh; background: #fff; border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; overflow: hidden; @@ -813,41 +813,43 @@ const handleCancel = () => { height: 32px; border: none; background: #f5f5f5; - border-radius: 6px; + border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #666; - transition: all 0.2s ease; + transition: background-color 0.2s ease, color 0.2s ease; z-index: 10; &:hover { - background: #e5e5e5; - color: #000; + background: rgba(7, 192, 95, 0.1); + color: #07C05F; } } // 标题区域 .modal-header { - padding: 24px 32px 20px; - border-bottom: 1px solid #e5e5e5; + padding: 28px 32px 20px; + border-bottom: 1px solid #f0f0f0; flex-shrink: 0; + background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%); } .modal-title { margin: 0 0 8px 0; font-family: "PingFang SC"; - font-size: 18px; + font-size: 20px; font-weight: 600; - color: #000000e6; + color: #1a1a1a; + letter-spacing: -0.3px; } .modal-desc { margin: 0; font-family: "PingFang SC"; font-size: 14px; - color: #00000066; + color: #666666; line-height: 22px; } @@ -855,7 +857,8 @@ const handleCancel = () => { .modal-body { flex: 1; overflow-y: auto; - padding: 24px 32px; + padding: 28px 32px; + background: #ffffff; // 自定义滚动条 &::-webkit-scrollbar { @@ -870,9 +873,10 @@ const handleCancel = () => { &::-webkit-scrollbar-thumb { background: #d0d0d0; border-radius: 3px; + transition: background 0.2s; &:hover { - background: #b0b0b0; + background: #07C05F; } } @@ -894,16 +898,18 @@ const handleCancel = () => { .form-label { display: block; - margin-bottom: 8px; + margin-bottom: 10px; font-family: "PingFang SC"; font-size: 14px; font-weight: 500; - color: #000000e6; + color: #333333; + letter-spacing: -0.2px; &.required::after { content: '*'; color: #FA5151; margin-left: 4px; + font-weight: 600; } } @@ -914,26 +920,53 @@ const handleCancel = () => { :deep(.t-input-number) { width: 100%; font-size: 14px; + transition: all 0.2s ease; .t-input__inner, input { font-size: 14px; + border-radius: 6px; + transition: all 0.2s ease; + } + + &:hover .t-input__inner, + &:hover input { + border-color: #07C05F; + } + + &.t-is-focused .t-input__inner, + &.t-is-focused input { + border-color: #07C05F; + box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1); } } // 单选按钮组 :deep(.t-radio-group) { display: flex; - gap: 16px; + gap: 20px; .t-radio { margin-right: 0; font-size: 14px; + transition: all 0.2s ease; + + &:hover { + .t-radio__label { + color: #07C05F; + } + } } .t-radio__label { font-size: 14px; - color: #000000e6; + color: #333333; + transition: color 0.2s ease; + } + + .t-radio__input:checked + .t-radio__label { + color: #07C05F; + font-weight: 500; } } @@ -949,12 +982,37 @@ const handleCancel = () => { // 底部按钮区域 .modal-footer { - padding: 16px 32px; - border-top: 1px solid #e5e5e5; + padding: 20px 32px; + border-top: 1px solid #f0f0f0; display: flex; justify-content: flex-end; gap: 12px; flex-shrink: 0; + background: #fafafa; + + :deep(.t-button) { + min-width: 88px; + height: 36px; + font-weight: 500; + border-radius: 6px; + + &.t-button--theme-primary { + background: #07C05F; + border-color: #07C05F; + + &:hover { + background: #06b04d; + border-color: #06b04d; + } + } + + &.t-button--variant-outline { + &:hover { + border-color: #07C05F; + color: #07C05F; + } + } + } } // 过渡动画 diff --git a/frontend/src/components/ModelSelector.vue b/frontend/src/components/ModelSelector.vue index 49ce1201..98f112b2 100644 --- a/frontend/src/components/ModelSelector.vue +++ b/frontend/src/components/ModelSelector.vue @@ -19,6 +19,7 @@
{{ model.name }} + 内置 {{ $t('model.defaultTag') }}
diff --git a/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue b/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue index 9a7a1bc4..e6222786 100644 --- a/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue +++ b/frontend/src/views/knowledge/KnowledgeBaseEditorModal.vue @@ -535,6 +535,16 @@ watch(() => props.visible, async (newVal) => { }, 300) } }) + +// 监听全局设置弹窗关闭后刷新模型列表 +watch( + () => uiStore.showSettingsModal, + async (visible, previous) => { + if (!visible && previous && props.visible) { + await loadAllModels() + } + } +) diff --git a/internal/application/repository/model.go b/internal/application/repository/model.go index 7dab452d..e5f4627f 100644 --- a/internal/application/repository/model.go +++ b/internal/application/repository/model.go @@ -28,7 +28,7 @@ func (r *modelRepository) Create(ctx context.Context, m *types.Model) error { func (r *modelRepository) GetByID(ctx context.Context, tenantID uint, id string) (*types.Model, error) { var m types.Model if err := r.db.WithContext(ctx).Where("id = ?", id).Where( - "tenant_id = ? or tenant_id = 0", tenantID, + "tenant_id = ? OR is_builtin = true", tenantID, ).First(&m).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil @@ -44,7 +44,7 @@ func (r *modelRepository) List( ) ([]*types.Model, error) { var models []*types.Model query := r.db.WithContext(ctx).Where( - "tenant_id = ? or tenant_id = 0", tenantID, + "tenant_id = ? OR is_builtin = true", tenantID, ) if modelType != "" { diff --git a/internal/application/service/model.go b/internal/application/service/model.go index 8fc09b21..180f7cb2 100644 --- a/internal/application/service/model.go +++ b/internal/application/service/model.go @@ -178,6 +178,20 @@ func (s *modelService) UpdateModel(ctx context.Context, model *types.Model) erro logger.Info(ctx, "Start updating model") logger.Infof(ctx, "Updating model ID: %s, name: %s", model.ID, model.Name) + // Check if the model is builtin - builtin models cannot be updated + tenantID := ctx.Value(types.TenantIDContextKey).(uint) + existingModel, err := s.repo.GetByID(ctx, tenantID, model.ID) + if err != nil { + logger.ErrorWithFields(ctx, err, map[string]interface{}{ + "model_id": model.ID, + }) + return err + } + if existingModel != nil && existingModel.IsBuiltin { + logger.Warnf(ctx, "Attempted to update builtin model: %s", model.ID) + return errors.New("builtin models cannot be updated") + } + // If this model is set as default, unset other default models of the same type if model.IsDefault { logger.Infof(ctx, "Model is set as default, clearing other default models of type: %s", model.Type) @@ -192,7 +206,7 @@ func (s *modelService) UpdateModel(ctx context.Context, model *types.Model) erro } // Update model in repository - err := s.repo.Update(ctx, model) + err = s.repo.Update(ctx, model) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "model_id": model.ID, @@ -213,8 +227,21 @@ func (s *modelService) DeleteModel(ctx context.Context, id string) error { tenantID := ctx.Value(types.TenantIDContextKey).(uint) logger.Infof(ctx, "Tenant ID: %d", tenantID) + // Check if the model is builtin - builtin models cannot be deleted + existingModel, err := s.repo.GetByID(ctx, tenantID, id) + if err != nil { + logger.ErrorWithFields(ctx, err, map[string]interface{}{ + "model_id": id, + }) + return err + } + if existingModel != nil && existingModel.IsBuiltin { + logger.Warnf(ctx, "Attempted to delete builtin model: %s", id) + return errors.New("builtin models cannot be deleted") + } + // Delete model from repository - err := s.repo.Delete(ctx, tenantID, id) + err = s.repo.Delete(ctx, tenantID, id) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "model_id": id, diff --git a/internal/handler/initialization.go b/internal/handler/initialization.go index 082d52f6..761d84a4 100644 --- a/internal/handler/initialization.go +++ b/internal/handler/initialization.go @@ -1155,28 +1155,36 @@ func (h *InitializationHandler) buildConfigResponse(ctx context.Context, models // 按类型分组模型 for _, model := range models { + // Hide sensitive information for builtin models + baseURL := model.Parameters.BaseURL + apiKey := model.Parameters.APIKey + if model.IsBuiltin { + baseURL = "" + apiKey = "" + } + switch model.Type { case types.ModelTypeKnowledgeQA: config["llm"] = map[string]interface{}{ "source": string(model.Source), "modelName": model.Name, - "baseUrl": model.Parameters.BaseURL, - "apiKey": model.Parameters.APIKey, + "baseUrl": baseURL, + "apiKey": apiKey, } case types.ModelTypeEmbedding: config["embedding"] = map[string]interface{}{ "source": string(model.Source), "modelName": model.Name, - "baseUrl": model.Parameters.BaseURL, - "apiKey": model.Parameters.APIKey, + "baseUrl": baseURL, + "apiKey": apiKey, "dimension": model.Parameters.EmbeddingParameters.Dimension, } case types.ModelTypeRerank: config["rerank"] = map[string]interface{}{ "enabled": true, "modelName": model.Name, - "baseUrl": model.Parameters.BaseURL, - "apiKey": model.Parameters.APIKey, + "baseUrl": baseURL, + "apiKey": apiKey, } case types.ModelTypeVLLM: if config["multimodal"] == nil { @@ -1187,8 +1195,8 @@ func (h *InitializationHandler) buildConfigResponse(ctx context.Context, models multimodal := config["multimodal"].(map[string]interface{}) multimodal["vlm"] = map[string]interface{}{ "modelName": model.Name, - "baseUrl": model.Parameters.BaseURL, - "apiKey": model.Parameters.APIKey, + "baseUrl": baseURL, + "apiKey": apiKey, } } } diff --git a/internal/handler/model.go b/internal/handler/model.go index 83086a41..db051fd1 100644 --- a/internal/handler/model.go +++ b/internal/handler/model.go @@ -27,6 +27,37 @@ func NewModelHandler(service interfaces.ModelService) *ModelHandler { return &ModelHandler{service: service} } +// hideSensitiveInfo hides sensitive information (APIKey, BaseURL) for builtin models +// Returns a copy of the model with sensitive fields cleared if it's a builtin model +func hideSensitiveInfo(model *types.Model) *types.Model { + if !model.IsBuiltin { + return model + } + + // Create a copy with sensitive information hidden + return &types.Model{ + ID: model.ID, + TenantID: model.TenantID, + Name: model.Name, + Type: model.Type, + Source: model.Source, + Description: model.Description, + Parameters: types.ModelParameters{ + // Hide APIKey and BaseURL for builtin models + BaseURL: "", + APIKey: "", + // Keep other parameters like embedding dimensions + EmbeddingParameters: model.Parameters.EmbeddingParameters, + ParameterSize: model.Parameters.ParameterSize, + }, + IsDefault: model.IsDefault, + IsBuiltin: model.IsBuiltin, + Status: model.Status, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} + // CreateModelRequest defines the structure for model creation requests // Contains all fields required to create a new model in the system type CreateModelRequest struct { @@ -82,9 +113,13 @@ func (h *ModelHandler) CreateModel(c *gin.Context) { } logger.Infof(ctx, "Model created successfully, ID: %s, Name: %s", model.ID, model.Name) + + // Hide sensitive information for builtin models (though newly created models are unlikely to be builtin) + responseModel := hideSensitiveInfo(model) + c.JSON(http.StatusCreated, gin.H{ "success": true, - "data": model, + "data": responseModel, }) } @@ -119,9 +154,16 @@ func (h *ModelHandler) GetModel(c *gin.Context) { } logger.Infof(ctx, "Retrieved model successfully, ID: %s, Name: %s", model.ID, model.Name) + + // Hide sensitive information for builtin models + responseModel := hideSensitiveInfo(model) + if model.IsBuiltin { + logger.Infof(ctx, "Builtin model detected, hiding sensitive information for model: %s", model.ID) + } + c.JSON(http.StatusOK, gin.H{ "success": true, - "data": model, + "data": responseModel, }) } @@ -150,9 +192,19 @@ func (h *ModelHandler) ListModels(c *gin.Context) { } logger.Infof(ctx, "Retrieved model list successfully, Tenant ID: %d, Total: %d models", tenantID, len(models)) + + // Hide sensitive information for builtin models in the list + responseModels := make([]*types.Model, len(models)) + for i, model := range models { + responseModels[i] = hideSensitiveInfo(model) + if model.IsBuiltin { + logger.Infof(ctx, "Builtin model detected in list, hiding sensitive information for model: %s", model.ID) + } + } + c.JSON(http.StatusOK, gin.H{ "success": true, - "data": models, + "data": responseModels, }) } @@ -224,9 +276,13 @@ func (h *ModelHandler) UpdateModel(c *gin.Context) { } logger.Infof(ctx, "Model updated successfully, ID: %s", id) + + // Hide sensitive information for builtin models (though builtin models cannot be updated) + responseModel := hideSensitiveInfo(model) + c.JSON(http.StatusOK, gin.H{ "success": true, - "data": model, + "data": responseModel, }) } diff --git a/internal/types/model.go b/internal/types/model.go index f6542604..eb7d36e0 100644 --- a/internal/types/model.go +++ b/internal/types/model.go @@ -66,6 +66,8 @@ type Model struct { Parameters ModelParameters `yaml:"parameters" json:"parameters" gorm:"type:json"` // Whether the model is the default model IsDefault bool `yaml:"is_default" json:"is_default"` + // Whether the model is a builtin model (visible to all tenants) + IsBuiltin bool `yaml:"is_builtin" json:"is_builtin" gorm:"default:false"` // Model status, default: active, possible: downloading, download_failed Status ModelStatus `yaml:"status" json:"status"` // Creation time of the model diff --git a/migrations/versioned/000007_add_builtin_models.down.sql b/migrations/versioned/000007_add_builtin_models.down.sql new file mode 100644 index 00000000..dc37d724 --- /dev/null +++ b/migrations/versioned/000007_add_builtin_models.down.sql @@ -0,0 +1,14 @@ +-- 07-add-builtin-models.sql (rollback) +-- Remove is_builtin field from models table + +BEGIN; + +-- Drop index +DROP INDEX IF EXISTS idx_models_is_builtin; + +-- Remove is_builtin column +ALTER TABLE models +DROP COLUMN IF EXISTS is_builtin; + +COMMIT; + diff --git a/migrations/versioned/000007_add_builtin_models.up.sql b/migrations/versioned/000007_add_builtin_models.up.sql new file mode 100644 index 00000000..1d566a5d --- /dev/null +++ b/migrations/versioned/000007_add_builtin_models.up.sql @@ -0,0 +1,14 @@ +-- 07-add-builtin-models.sql +-- Add is_builtin field to models table to support builtin models visible to all tenants + +BEGIN; + +-- Add is_builtin column to models table +ALTER TABLE models +ADD COLUMN IF NOT EXISTS is_builtin BOOLEAN NOT NULL DEFAULT false; + +-- Add index for is_builtin field to improve query performance +CREATE INDEX IF NOT EXISTS idx_models_is_builtin ON models(is_builtin); + +COMMIT; +