From 8c652644741fb5eebccdb90519f07f06d3b0cb53 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 2 Sep 2025 18:49:37 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sync):=20multi-language=20sync?= =?UTF-8?q?=20wizard,=20backend=20locale=20support,=20and=20conflict=20mod?= =?UTF-8?q?al=20UX=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend (web) - ModelsActions.jsx - Replace “Sync Official” with “Sync” and open a new two-step SyncWizard. - Pass selected locale through to preview, sync, and overwrite flows. - Keep conflict resolution flow; inject locale into overwrite submission. - New: models/modals/SyncWizardModal.jsx - Two-step wizard: (1) method selection (config-sync disabled for now), (2) language selection (en/zh/ja). - Horizontal, centered Radio cards; returns { option, locale } via onConfirm. - UpstreamConflictModal.jsx - Add search input (model fuzzy search) and native pagination. - Column header checkbox now only applies to rows in the current filtered result. - Fix “Cannot access ‘filteredDataSource’ before initialization”. - Refactor with useMemo/useCallback; extract helpers to remove duplicated logic: - getPresentRowsForField, getHeaderState, applyHeaderChange - Minor code cleanups and stability improvements. - i18n (en.json) - Add strings for the sync wizard and related actions (Sync, Sync Wizard, Select method/source/language, etc.). - Adjust minor translations. Hooks - useModelsData.jsx - Extend previewUpstreamDiff, syncUpstream, applyUpstreamOverwrite to accept options with locale. - Send locale via query/body accordingly. Backend (Go) - controller/model_sync.go - Accept locale from query/body and resolve i18n upstream URLs. - Add SYNC_UPSTREAM_BASE for upstream base override (default: https://basellm.github.io/llm-metadata). - Make HTTP timeouts/retries/limits configurable: - SYNC_HTTP_TIMEOUT_SECONDS, SYNC_HTTP_RETRY, SYNC_HTTP_MAX_MB - Add ETag-based caching and support both envelope and pure array JSON formats. - Concurrently fetch vendors and models; improve error responses with locale and source URLs. - Include source meta (locale, models_url, vendors_url) in success payloads. Notes - No breaking changes expected. - Lint passes for touched files. --- controller/model_sync.go | 915 ++++++++++-------- model/model_meta.go | 24 +- web/src/components/dashboard/ApiInfoPanel.jsx | 2 +- .../components/table/models/ModelsActions.jsx | 37 +- .../models/modals/MissingModelsModal.jsx | 2 +- .../table/models/modals/SyncWizardModal.jsx | 132 +++ .../models/modals/UpstreamConflictModal.jsx | 169 +++- web/src/hooks/models/useModelsData.jsx | 22 +- web/src/i18n/locales/en.json | 15 +- 9 files changed, 856 insertions(+), 462 deletions(-) create mode 100644 web/src/components/table/models/modals/SyncWizardModal.jsx diff --git a/controller/model_sync.go b/controller/model_sync.go index 5e2803c5d..74034b51a 100644 --- a/controller/model_sync.go +++ b/controller/model_sync.go @@ -1,463 +1,604 @@ package controller import ( - "context" - "encoding/json" - "errors" - "io" - "net" - "net/http" - "strings" - "time" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "strings" + "sync" + "time" - "one-api/model" + "one-api/common" + "one-api/model" - "github.com/gin-gonic/gin" - "gorm.io/gorm" + "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // 上游地址 const ( - upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json" - upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json" + upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json" + upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json" ) +func normalizeLocale(locale string) (string, bool) { + l := strings.ToLower(strings.TrimSpace(locale)) + switch l { + case "en", "zh", "ja": + return l, true + default: + return "", false + } +} + +func getUpstreamBase() string { + return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata") +} + +func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) { + base := strings.TrimRight(getUpstreamBase(), "/") + if l, ok := normalizeLocale(locale); ok && l != "" { + return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l), + fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l) + } + return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base) +} + type upstreamEnvelope[T any] struct { - Success bool `json:"success"` - Message string `json:"message"` - Data []T `json:"data"` + Success bool `json:"success"` + Message string `json:"message"` + Data []T `json:"data"` } type upstreamModel struct { - Description string `json:"description"` - Endpoints json.RawMessage `json:"endpoints"` - Icon string `json:"icon"` - ModelName string `json:"model_name"` - NameRule int `json:"name_rule"` - Status int `json:"status"` - Tags string `json:"tags"` - VendorName string `json:"vendor_name"` + Description string `json:"description"` + Endpoints json.RawMessage `json:"endpoints"` + Icon string `json:"icon"` + ModelName string `json:"model_name"` + NameRule int `json:"name_rule"` + Status int `json:"status"` + Tags string `json:"tags"` + VendorName string `json:"vendor_name"` } type upstreamVendor struct { - Description string `json:"description"` - Icon string `json:"icon"` - Name string `json:"name"` - Status int `json:"status"` + Description string `json:"description"` + Icon string `json:"icon"` + Name string `json:"name"` + Status int `json:"status"` } +var ( + etagCache = make(map[string]string) + bodyCache = make(map[string][]byte) + cacheMutex sync.RWMutex +) + type overwriteField struct { - ModelName string `json:"model_name"` - Fields []string `json:"fields"` + ModelName string `json:"model_name"` + Fields []string `json:"fields"` } type syncRequest struct { - Overwrite []overwriteField `json:"overwrite"` + Overwrite []overwriteField `json:"overwrite"` + Locale string `json:"locale"` } func newHTTPClient() *http.Client { - dialer := &net.Dialer{Timeout: 10 * time.Second} - transport := &http.Transport{ - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - ResponseHeaderTimeout: 10 * time.Second, - } - transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - host, _, err := net.SplitHostPort(addr) - if err != nil { - host = addr - } - if strings.HasSuffix(host, "github.io") { - if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { - return conn, nil - } - return dialer.DialContext(ctx, "tcp6", addr) - } - return dialer.DialContext(ctx, network, addr) - } - return &http.Client{Transport: transport} + timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10) + dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second} + transport := &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second, + } + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + return &http.Client{Transport: transport} } var httpClient = newHTTPClient() func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error { - var lastErr error - for attempt := 0; attempt < 3; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - resp, err := httpClient.Do(req) - if err != nil { - lastErr = err - time.Sleep(time.Duration(200*(1< id + vendorIDCache := make(map[string]int) - // 本地缓存:vendorName -> id - vendorIDCache := make(map[string]int) + for _, name := range missing { + up, ok := modelByName[name] + if !ok { + skipped = append(skipped, name) + continue + } - for _, name := range missing { - up, ok := modelByName[name] - if !ok { - skipped = append(skipped, name) - continue - } + // 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时) + var existing model.Model + if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil { + if existing.SyncOfficial == 0 { + skipped = append(skipped, name) + continue + } + } - // 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时) - var existing model.Model - if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil { - if existing.SyncOfficial == 0 { - skipped = append(skipped, name) - continue - } - } + // 确保 vendor 存在 + vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) - // 确保 vendor 存在 - vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + // 创建模型 + mi := &model.Model{ + ModelName: name, + Description: up.Description, + Icon: up.Icon, + Tags: up.Tags, + VendorID: vendorID, + Status: chooseStatus(up.Status, 1), + NameRule: up.NameRule, + } + if err := mi.Insert(); err == nil { + createdModels++ + createdList = append(createdList, name) + } else { + skipped = append(skipped, name) + } + } - // 创建模型 - mi := &model.Model{ - ModelName: name, - Description: up.Description, - Icon: up.Icon, - Tags: up.Tags, - VendorID: vendorID, - Status: chooseStatus(up.Status, 1), - NameRule: up.NameRule, - } - if err := mi.Insert(); err == nil { - createdModels++ - createdList = append(createdList, name) - } else { - skipped = append(skipped, name) - } - } + // 4) 处理可选覆盖(更新本地已有模型的差异字段) + if len(req.Overwrite) > 0 { + // vendorIDCache 已用于创建阶段,可复用 + for _, ow := range req.Overwrite { + up, ok := modelByName[ow.ModelName] + if !ok { + continue + } + var local model.Model + if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil { + continue + } - // 4) 处理可选覆盖(更新本地已有模型的差异字段) - if len(req.Overwrite) > 0 { - // vendorIDCache 已用于创建阶段,可复用 - for _, ow := range req.Overwrite { - up, ok := modelByName[ow.ModelName] - if !ok { - continue - } - var local model.Model - if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil { - continue - } + // 跳过被禁用官方同步的模型 + if local.SyncOfficial == 0 { + continue + } - // 跳过被禁用官方同步的模型 - if local.SyncOfficial == 0 { - continue - } + // 映射 vendor + newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) - // 映射 vendor - newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + // 应用字段覆盖(事务) + _ = model.DB.Transaction(func(tx *gorm.DB) error { + needUpdate := false + if containsField(ow.Fields, "description") { + local.Description = up.Description + needUpdate = true + } + if containsField(ow.Fields, "icon") { + local.Icon = up.Icon + needUpdate = true + } + if containsField(ow.Fields, "tags") { + local.Tags = up.Tags + needUpdate = true + } + if containsField(ow.Fields, "vendor") { + local.VendorID = newVendorID + needUpdate = true + } + if containsField(ow.Fields, "name_rule") { + local.NameRule = up.NameRule + needUpdate = true + } + if containsField(ow.Fields, "status") { + local.Status = chooseStatus(up.Status, local.Status) + needUpdate = true + } + if !needUpdate { + return nil + } + if err := tx.Save(&local).Error; err != nil { + return err + } + updatedModels++ + updatedList = append(updatedList, ow.ModelName) + return nil + }) + } + } - // 应用字段覆盖(事务) - _ = model.DB.Transaction(func(tx *gorm.DB) error { - needUpdate := false - if containsField(ow.Fields, "description") { - local.Description = up.Description - needUpdate = true - } - if containsField(ow.Fields, "icon") { - local.Icon = up.Icon - needUpdate = true - } - if containsField(ow.Fields, "tags") { - local.Tags = up.Tags - needUpdate = true - } - if containsField(ow.Fields, "vendor") { - local.VendorID = newVendorID - needUpdate = true - } - if containsField(ow.Fields, "name_rule") { - local.NameRule = up.NameRule - needUpdate = true - } - if containsField(ow.Fields, "status") { - local.Status = chooseStatus(up.Status, local.Status) - needUpdate = true - } - if !needUpdate { - return nil - } - if err := tx.Save(&local).Error; err != nil { - return err - } - updatedModels++ - updatedList = append(updatedList, ow.ModelName) - return nil - }) - } - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": gin.H{ - "created_models": createdModels, - "created_vendors": createdVendors, - "updated_models": updatedModels, - "skipped_models": skipped, - "created_list": createdList, - "updated_list": updatedList, - }, - }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "created_models": createdModels, + "created_vendors": createdVendors, + "updated_models": updatedModels, + "skipped_models": skipped, + "created_list": createdList, + "updated_list": updatedList, + "source": gin.H{ + "locale": req.Locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) } func containsField(fields []string, key string) bool { - key = strings.ToLower(strings.TrimSpace(key)) - for _, f := range fields { - if strings.ToLower(strings.TrimSpace(f)) == key { - return true - } - } - return false + key = strings.ToLower(strings.TrimSpace(key)) + for _, f := range fields { + if strings.ToLower(strings.TrimSpace(f)) == key { + return true + } + } + return false } func coalesce(a, b string) string { - if strings.TrimSpace(a) != "" { - return a - } - return b + if strings.TrimSpace(a) != "" { + return a + } + return b } func chooseStatus(primary, fallback int) int { - if primary == 0 && fallback != 0 { - return fallback - } - if primary != 0 { - return primary - } - return 1 + if primary == 0 && fallback != 0 { + return fallback + } + if primary != 0 { + return primary + } + return 1 } // SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择) func SyncUpstreamPreview(c *gin.Context) { - // 1) 拉取上游数据 - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() + // 1) 拉取上游数据 + timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15) + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second) + defer cancel() - var vendorsEnv upstreamEnvelope[upstreamVendor] - _ = fetchJSON(ctx, upstreamVendorsURL, &vendorsEnv) + locale := c.Query("locale") + modelsURL, vendorsURL := getUpstreamURLs(locale) - var modelsEnv upstreamEnvelope[upstreamModel] - if err := fetchJSON(ctx, upstreamModelsURL, &modelsEnv); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + err.Error()}) - return - } + var vendorsEnv upstreamEnvelope[upstreamVendor] + var modelsEnv upstreamEnvelope[upstreamModel] + var fetchErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = fetchJSON(ctx, vendorsURL, &vendorsEnv) + }() + go func() { + defer wg.Done() + if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil { + fetchErr = err + } + }() + wg.Wait() + if fetchErr != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}}) + return + } - vendorByName := make(map[string]upstreamVendor) - for _, v := range vendorsEnv.Data { - if v.Name != "" { - vendorByName[v.Name] = v - } - } - modelByName := make(map[string]upstreamModel) - upstreamNames := make([]string, 0, len(modelsEnv.Data)) - for _, m := range modelsEnv.Data { - if m.ModelName != "" { - modelByName[m.ModelName] = m - upstreamNames = append(upstreamNames, m.ModelName) - } - } + vendorByName := make(map[string]upstreamVendor) + for _, v := range vendorsEnv.Data { + if v.Name != "" { + vendorByName[v.Name] = v + } + } + modelByName := make(map[string]upstreamModel) + upstreamNames := make([]string, 0, len(modelsEnv.Data)) + for _, m := range modelsEnv.Data { + if m.ModelName != "" { + modelByName[m.ModelName] = m + upstreamNames = append(upstreamNames, m.ModelName) + } + } - // 2) 本地已有模型 - var locals []model.Model - if len(upstreamNames) > 0 { - _ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error - } + // 2) 本地已有模型 + var locals []model.Model + if len(upstreamNames) > 0 { + _ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error + } - // 本地 vendor 名称映射 - vendorIdSet := make(map[int]struct{}) - for _, m := range locals { - if m.VendorID != 0 { - vendorIdSet[m.VendorID] = struct{}{} - } - } - vendorIDs := make([]int, 0, len(vendorIdSet)) - for id := range vendorIdSet { - vendorIDs = append(vendorIDs, id) - } - idToVendorName := make(map[int]string) - if len(vendorIDs) > 0 { - var dbVendors []model.Vendor - _ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error - for _, v := range dbVendors { - idToVendorName[v.Id] = v.Name - } - } + // 本地 vendor 名称映射 + vendorIdSet := make(map[int]struct{}) + for _, m := range locals { + if m.VendorID != 0 { + vendorIdSet[m.VendorID] = struct{}{} + } + } + vendorIDs := make([]int, 0, len(vendorIdSet)) + for id := range vendorIdSet { + vendorIDs = append(vendorIDs, id) + } + idToVendorName := make(map[int]string) + if len(vendorIDs) > 0 { + var dbVendors []model.Vendor + _ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error + for _, v := range dbVendors { + idToVendorName[v.Id] = v.Name + } + } - // 3) 缺失且上游存在的模型 - missingList, _ := model.GetMissingModels() - var missing []string - for _, name := range missingList { - if _, ok := modelByName[name]; ok { - missing = append(missing, name) - } - } + // 3) 缺失且上游存在的模型 + missingList, _ := model.GetMissingModels() + var missing []string + for _, name := range missingList { + if _, ok := modelByName[name]; ok { + missing = append(missing, name) + } + } - // 4) 计算冲突字段 - type conflictField struct { - Field string `json:"field"` - Local interface{} `json:"local"` - Upstream interface{} `json:"upstream"` - } - type conflictItem struct { - ModelName string `json:"model_name"` - Fields []conflictField `json:"fields"` - } + // 4) 计算冲突字段 + type conflictField struct { + Field string `json:"field"` + Local interface{} `json:"local"` + Upstream interface{} `json:"upstream"` + } + type conflictItem struct { + ModelName string `json:"model_name"` + Fields []conflictField `json:"fields"` + } - var conflicts []conflictItem - for _, local := range locals { - up, ok := modelByName[local.ModelName] - if !ok { - continue - } - fields := make([]conflictField, 0, 6) - if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) { - fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description}) - } - if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) { - fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon}) - } - if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) { - fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags}) - } - // vendor 对比使用名称 - localVendor := idToVendorName[local.VendorID] - if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) { - fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName}) - } - if local.NameRule != up.NameRule { - fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule}) - } - if local.Status != chooseStatus(up.Status, local.Status) { - fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status}) - } - if len(fields) > 0 { - conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields}) - } - } + var conflicts []conflictItem + for _, local := range locals { + up, ok := modelByName[local.ModelName] + if !ok { + continue + } + fields := make([]conflictField, 0, 6) + if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) { + fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description}) + } + if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) { + fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon}) + } + if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) { + fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags}) + } + // vendor 对比使用名称 + localVendor := idToVendorName[local.VendorID] + if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) { + fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName}) + } + if local.NameRule != up.NameRule { + fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule}) + } + if local.Status != chooseStatus(up.Status, local.Status) { + fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status}) + } + if len(fields) > 0 { + conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields}) + } + } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": gin.H{ - "missing": missing, - "conflicts": conflicts, - }, - }) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "missing": missing, + "conflicts": conflicts, + "source": gin.H{ + "locale": locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) } - - diff --git a/model/model_meta.go b/model/model_meta.go index a6230553b..e41cbd090 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -20,18 +20,18 @@ type BoundChannel struct { } type Model struct { - Id int `json:"id"` - ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` - Description string `json:"description,omitempty" gorm:"type:text"` - Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` - Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` - VendorID int `json:"vendor_id,omitempty" gorm:"index"` - Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` - Status int `json:"status" gorm:"default:1"` - SyncOfficial int `json:"sync_official" gorm:"default:1"` - CreatedTime int64 `json:"created_time" gorm:"bigint"` - UpdatedTime int64 `json:"updated_time" gorm:"bigint"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` + Id int `json:"id"` + ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` + VendorID int `json:"vendor_id,omitempty" gorm:"index"` + Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` + Status int `json:"status" gorm:"default:1"` + SyncOfficial int `json:"sync_official" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` diff --git a/web/src/components/dashboard/ApiInfoPanel.jsx b/web/src/components/dashboard/ApiInfoPanel.jsx index 63b6def48..1c3c3dd3d 100644 --- a/web/src/components/dashboard/ApiInfoPanel.jsx +++ b/web/src/components/dashboard/ApiInfoPanel.jsx @@ -100,7 +100,7 @@ const ApiInfoPanel = ({ )) ) : ( -
+
} darkModeImage={ diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index cc6c9afed..929e3557b 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -21,11 +21,12 @@ import React, { useState } from 'react'; import MissingModelsModal from './modals/MissingModelsModal'; import PrefillGroupManagement from './modals/PrefillGroupManagement'; import EditPrefillGroupModal from './modals/EditPrefillGroupModal'; -import { Button, Modal, Popover } from '@douyinfe/semi-ui'; +import { Button, Modal, Popover, RadioGroup, Radio } from '@douyinfe/semi-ui'; import { showSuccess, showError, copy } from '../../../helpers'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; import SelectionNotification from './components/SelectionNotification'; import UpstreamConflictModal from './modals/UpstreamConflictModal'; +import SyncWizardModal from './modals/SyncWizardModal'; const ModelsActions = ({ selectedKeys, @@ -50,10 +51,12 @@ const ModelsActions = ({ const [prefillInit, setPrefillInit] = useState({ id: undefined }); const [showConflict, setShowConflict] = useState(false); const [conflicts, setConflicts] = useState([]); + const [showSyncModal, setShowSyncModal] = useState(false); + const [syncLocale, setSyncLocale] = useState('zh'); - const handleSyncUpstream = async () => { + const handleSyncUpstream = async (locale) => { // 先预览 - const data = await previewUpstreamDiff?.(); + const data = await previewUpstreamDiff?.({ locale }); const conflictItems = data?.conflicts || []; if (conflictItems.length > 0) { setConflicts(conflictItems); @@ -61,7 +64,7 @@ const ModelsActions = ({ return; } // 无冲突,直接同步缺失 - await syncUpstream?.(); + await syncUpstream?.({ locale }); }; // Handle delete selected models with confirmation @@ -151,9 +154,12 @@ const ModelsActions = ({ className='flex-1 md:flex-initial' size='small' loading={syncing || previewing} - onClick={handleSyncUpstream} + onClick={() => { + setSyncLocale('zh'); + setShowSyncModal(true); + }} > - {t('同步官方')} + {t('同步')} @@ -196,6 +202,20 @@ const ModelsActions = ({
+ setShowSyncModal(false)} + loading={syncing || previewing} + t={t} + onConfirm={async ({ option, locale }) => { + setSyncLocale(locale); + if (option === 'official') { + await handleSyncUpstream(locale); + } + setShowSyncModal(false); + }} + /> + setShowMissingModal(false)} @@ -224,7 +244,10 @@ const ModelsActions = ({ onClose={() => setShowConflict(false)} conflicts={conflicts} onSubmit={async (payload) => { - return await applyUpstreamOverwrite?.(payload); + return await applyUpstreamOverwrite?.({ + ...payload, + locale: syncLocale, + }); }} t={t} loading={syncing} diff --git a/web/src/components/table/models/modals/MissingModelsModal.jsx b/web/src/components/table/models/modals/MissingModelsModal.jsx index 0f9462b1e..f8fa78fdd 100644 --- a/web/src/components/table/models/modals/MissingModelsModal.jsx +++ b/web/src/components/table/models/modals/MissingModelsModal.jsx @@ -96,7 +96,7 @@ const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => { title: '', dataIndex: 'operate', fixed: 'right', - width: 100, + width: 120, render: (text, record) => ( + )} + + {step === 0 && ( + + )} + {step === 1 && ( + + )} +
+ } + width={isMobile ? '100%' : 'small'} + > +
+ + + + +
+ + {step === 0 && ( +
+ setOption(e?.target?.value ?? e)} + type='card' + direction='horizontal' + aria-label='同步方式选择' + name='sync-mode-selection' + > + + {t('官方模型同步')} + + + {t('配置文件同步')} + + +
+ )} + + {step === 1 && ( +
+
+ {t('请选择同步语言')} +
+
+ setLocale(e?.target?.value ?? e)} + type='card' + direction='horizontal' + aria-label='语言选择' + name='sync-locale-selection' + > + + EN + + + ZH + + + JA + + +
+
+ )} + + ); +}; + +export default SyncWizardModal; diff --git a/web/src/components/table/models/modals/UpstreamConflictModal.jsx b/web/src/components/table/models/modals/UpstreamConflictModal.jsx index 439166ee6..8682ccb64 100644 --- a/web/src/components/table/models/modals/UpstreamConflictModal.jsx +++ b/web/src/components/table/models/modals/UpstreamConflictModal.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useCallback } from 'react'; import { Modal, Table, @@ -26,9 +26,12 @@ import { Empty, Tag, Popover, + Input, } from '@douyinfe/semi-ui'; import { MousePointerClick } from 'lucide-react'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants'; +import { IconSearch } from '@douyinfe/semi-icons'; const { Text } = Typography; @@ -52,6 +55,8 @@ const UpstreamConflictModal = ({ }) => { const [selections, setSelections] = useState({}); const isMobile = useIsMobile(); + const [currentPage, setCurrentPage] = useState(1); + const [searchKeyword, setSearchKeyword] = useState(''); const formatValue = (v) => { if (v === null || v === undefined) return '-'; @@ -70,12 +75,14 @@ const UpstreamConflictModal = ({ init[item.model_name] = new Set(); }); setSelections(init); + setCurrentPage(1); + setSearchKeyword(''); } else { setSelections({}); } }, [visible, conflicts]); - const toggleField = (modelName, field, checked) => { + const toggleField = useCallback((modelName, field, checked) => { setSelections((prev) => { const next = { ...prev }; const set = new Set(next[modelName] || []); @@ -84,7 +91,67 @@ const UpstreamConflictModal = ({ next[modelName] = set; return next; }); - }; + }, []); + + // 构造数据源与过滤后的数据源 + const dataSource = useMemo( + () => + (conflicts || []).map((c) => ({ + key: c.model_name, + model_name: c.model_name, + fields: c.fields || [], + })), + [conflicts], + ); + + const filteredDataSource = useMemo(() => { + const kw = (searchKeyword || '').toLowerCase(); + if (!kw) return dataSource; + return dataSource.filter((item) => + (item.model_name || '').toLowerCase().includes(kw), + ); + }, [dataSource, searchKeyword]); + + // 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置 + const getPresentRowsForField = useCallback( + (fieldKey) => + (filteredDataSource || []).filter((row) => + (row.fields || []).some((f) => f.field === fieldKey), + ), + [filteredDataSource], + ); + + const getHeaderState = useCallback( + (fieldKey) => { + const presentRows = getPresentRowsForField(fieldKey); + const selectedCount = presentRows.filter((row) => + selections[row.model_name]?.has(fieldKey), + ).length; + const allCount = presentRows.length; + return { + headerChecked: allCount > 0 && selectedCount === allCount, + headerIndeterminate: selectedCount > 0 && selectedCount < allCount, + hasAny: allCount > 0, + }; + }, + [getPresentRowsForField, selections], + ); + + const applyHeaderChange = useCallback( + (fieldKey, checked) => { + setSelections((prev) => { + const next = { ...prev }; + getPresentRowsForField(fieldKey).forEach((row) => { + const set = new Set(next[row.model_name] || []); + if (checked) set.add(fieldKey); + else set.delete(fieldKey); + next[row.model_name] = set; + }); + return next; + }); + }, + [getPresentRowsForField], + ); const columns = useMemo(() => { const base = [ @@ -100,37 +167,11 @@ const UpstreamConflictModal = ({ const rawLabel = FIELD_LABELS[fieldKey] || fieldKey; const label = t(rawLabel); - // 统计列头复选框状态(仅统计存在该字段冲突的行) - const presentRows = (conflicts || []).filter((row) => - (row.fields || []).some((f) => f.field === fieldKey), - ); - const selectedCount = presentRows.filter((row) => - selections[row.model_name]?.has(fieldKey), - ).length; - const allCount = presentRows.length; - if (allCount === 0) { - return null; // 若此字段在所有行中都不存在,则不展示该列 - } - const headerChecked = allCount > 0 && selectedCount === allCount; - const headerIndeterminate = selectedCount > 0 && selectedCount < allCount; - - const onHeaderChange = (e) => { - const checked = e?.target?.checked; - setSelections((prev) => { - const next = { ...prev }; - (conflicts || []).forEach((row) => { - const hasField = (row.fields || []).some( - (f) => f.field === fieldKey, - ); - if (!hasField) return; - const set = new Set(next[row.model_name] || []); - if (checked) set.add(fieldKey); - else set.delete(fieldKey); - next[row.model_name] = set; - }); - return next; - }); - }; + const { headerChecked, headerIndeterminate, hasAny } = + getHeaderState(fieldKey); + if (!hasAny) return null; + const onHeaderChange = (e) => + applyHeaderChange(fieldKey, e?.target?.checked); return { title: ( @@ -194,13 +235,20 @@ const UpstreamConflictModal = ({ }); return [...base, ...cols.filter(Boolean)]; - }, [t, selections, conflicts]); + }, [ + t, + selections, + filteredDataSource, + getHeaderState, + applyHeaderChange, + toggleField, + ]); - const dataSource = conflicts.map((c) => ({ - key: c.model_name, - model_name: c.model_name, - fields: c.fields || [], - })); + const pagedDataSource = useMemo(() => { + const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE; + const end = start + MODEL_TABLE_PAGE_SIZE; + return filteredDataSource.slice(start, end); + }, [filteredDataSource, currentPage]); const handleOk = async () => { const payload = Object.entries(selections) @@ -236,12 +284,41 @@ const UpstreamConflictModal = ({
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
- + {/* 搜索框 */} +
+ { + setSearchKeyword(v); + setCurrentPage(1); + }} + className='!w-full' + prefix={} + showClear + /> +
+ {filteredDataSource.length > 0 ? ( +
setCurrentPage(page), + }} + scroll={{ x: 'max-content' }} + /> + ) : ( + + )} )} diff --git a/web/src/hooks/models/useModelsData.jsx b/web/src/hooks/models/useModelsData.jsx index e2068840e..57b4bea3d 100644 --- a/web/src/hooks/models/useModelsData.jsx +++ b/web/src/hooks/models/useModelsData.jsx @@ -166,10 +166,13 @@ export const useModelsData = () => { }; // Sync upstream models/vendors for missing models only - const syncUpstream = async () => { + const syncUpstream = async (opts = {}) => { + const locale = opts?.locale; setSyncing(true); try { - const res = await API.post('/api/models/sync_upstream'); + const body = {}; + if (locale) body.locale = locale; + const res = await API.post('/api/models/sync_upstream', body); const { success, message, data } = res.data || {}; if (success) { const createdModels = data?.created_models || 0; @@ -192,10 +195,12 @@ export const useModelsData = () => { }; // Preview upstream differences - const previewUpstreamDiff = async () => { + const previewUpstreamDiff = async (opts = {}) => { + const locale = opts?.locale; setPreviewing(true); try { - const res = await API.get('/api/models/sync_upstream/preview'); + const url = `/api/models/sync_upstream/preview${locale ? `?locale=${locale}` : ''}`; + const res = await API.get(url); const { success, message, data } = res.data || {}; if (success) { return data || { missing: [], conflicts: [] }; @@ -211,10 +216,15 @@ export const useModelsData = () => { }; // Apply selected overwrite - const applyUpstreamOverwrite = async (overwrite = []) => { + const applyUpstreamOverwrite = async (payloadOrArray = []) => { + const isArray = Array.isArray(payloadOrArray); + const overwrite = isArray ? payloadOrArray : payloadOrArray.overwrite || []; + const locale = isArray ? undefined : payloadOrArray.locale; setSyncing(true); try { - const res = await API.post('/api/models/sync_upstream', { overwrite }); + const body = { overwrite }; + if (locale) body.locale = locale; + const res = await API.post('/api/models/sync_upstream', body); const { success, message, data } = res.data || {}; if (success) { const createdModels = data?.created_models || 0; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0271efdb0..2574c1832 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1801,7 +1801,7 @@ "已绑定渠道": "Bound channels", "更新时间": "Update time", "未配置模型": "No model configured", - "预填组管理": "Pre-filled group management", + "预填组管理": "Pre-filled group", "搜索供应商": "Search vendor", "新增供应商": "Add vendor", "创建新的模型": "Create new model", @@ -2057,9 +2057,20 @@ "侧边栏设置保存成功": "Sidebar settings saved successfully", "需要登录访问": "Require Login", "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace", - "同步官方": "Sync official", "参与官方同步": "Participate in official sync", "关闭后,此模型将不会被“同步官方”自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)", + "同步": "Sync", + "同步向导": "Sync Wizard", + "选择方式": "Select method", + "选择同步来源": "Select sync source", + "选择语言": "Select language", + "选择同步语言": "Select sync language", + "请选择同步语言": "Please select sync language", + "从官方模型库同步": "Sync from official model library", + "官方模型同步": "Official models sync", + "从配置文件同步": "Sync from config file", + "配置文件同步": "Config file sync", + "开始同步": "Start sync", "选择要覆盖的冲突项": "Select conflict items to overwrite", "点击查看差异": "Click to view differences", "无冲突项": "No conflict items",