Bläddra i källkod

✨ feat(models-sync): official upstream sync with conflict resolution UI, opt‑out flag, and backend resiliency

Backend
- Add endpoints:
  - GET /api/models/sync_upstream/preview — diff preview (filters out models with sync_official = 0)
  - POST /api/models/sync_upstream — apply sync (create missing; optionally overwrite selected fields)
- Respect opt‑out: skip models with sync_official = 0 in both preview and apply
- Return detailed stats: created_models, created_vendors, updated_models, skipped_models, plus created_list / updated_list
- Add model.Model.SyncOfficial (default 1); auto‑migrated by GORM
- Make HTTP fetching robust:
  - Shared http.Client (connection reuse) with 3x exponential backoff retry
  - 10MB response cap; keep existing IPv4‑first for *.github.io
- Vendor handling:
  - New ensureVendorID helper (cache lookup → DB lookup → create), reduces round‑trips
  - Transactional overwrite to avoid partial updates
- Small cleanups and clearer helpers (containsField, coalesce, chooseStatus)

Frontend
- ModelsActions: add “Sync official” button with Popover (p‑2) explaining community contribution; loading = syncing || previewing; preview → conflict modal → apply flow
- New UpstreamConflictModal:
  - Per‑field columns (description/icon/tags/vendor/name_rule/status) with column‑level checkbox to select all
  - Cell with Checkbox + Tag (“Click to view differences”) and Popover (p‑2) showing Local vs Official values
  - Auto‑hide columns with no conflicts; responsive width; use native Semi Modal footer
  - Full i18n coverage
- useModelsData: add syncing/previewing states; new methods previewUpstreamDiff, applyUpstreamOverwrite, syncUpstream; refresh vendors/models after apply
- EditModelModal: add “Participate in official sync” switch; persisted as sync_official
- ModelsColumnDefs: add “Participate in official sync” column

i18n
- Add missing English keys for the new UI and messages; fix quoting issues

Refs
- Upstream metadata: https://github.com/basellm/llm-metadata
t0ng7u 3 månader sedan
förälder
incheckning
fbc19abd28

+ 463 - 0
controller/model_sync.go

@@ -0,0 +1,463 @@
+package controller
+
+import (
+    "context"
+    "encoding/json"
+    "errors"
+    "io"
+    "net"
+    "net/http"
+    "strings"
+    "time"
+
+    "one-api/model"
+
+    "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"
+)
+
+type upstreamEnvelope[T any] struct {
+    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"`
+}
+
+type upstreamVendor struct {
+    Description string `json:"description"`
+    Icon        string `json:"icon"`
+    Name        string `json:"name"`
+    Status      int    `json:"status"`
+}
+
+type overwriteField struct {
+    ModelName string   `json:"model_name"`
+    Fields    []string `json:"fields"`
+}
+
+type syncRequest struct {
+    Overwrite []overwriteField `json:"overwrite"`
+}
+
+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}
+}
+
+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<<attempt)) * time.Millisecond)
+            continue
+        }
+        func() {
+            defer resp.Body.Close()
+            if resp.StatusCode != http.StatusOK {
+                lastErr = errors.New(resp.Status)
+                return
+            }
+            limited := io.LimitReader(resp.Body, 10<<20)
+            if err := json.NewDecoder(limited).Decode(out); err != nil {
+                lastErr = err
+                return
+            }
+            if !out.Success && len(out.Data) == 0 && out.Message == "" {
+                out.Success = true
+            }
+            lastErr = nil
+        }()
+        if lastErr == nil {
+            return nil
+        }
+        time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
+    }
+    return lastErr
+}
+
+func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
+    if vendorName == "" {
+        return 0
+    }
+    if id, ok := vendorIDCache[vendorName]; ok {
+        return id
+    }
+    var existing model.Vendor
+    if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
+        vendorIDCache[vendorName] = existing.Id
+        return existing.Id
+    }
+    uv := vendorByName[vendorName]
+    v := &model.Vendor{
+        Name:        vendorName,
+        Description: uv.Description,
+        Icon:        coalesce(uv.Icon, ""),
+        Status:      chooseStatus(uv.Status, 1),
+    }
+    if err := v.Insert(); err == nil {
+        *createdVendors++
+        vendorIDCache[vendorName] = v.Id
+        return v.Id
+    }
+    vendorIDCache[vendorName] = 0
+    return 0
+}
+
+// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
+func SyncUpstreamModels(c *gin.Context) {
+    var req syncRequest
+    // 允许空体
+    _ = c.ShouldBindJSON(&req)
+    // 1) 获取未配置模型列表
+    missing, err := model.GetMissingModels()
+    if err != nil {
+        c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+        return
+    }
+    if len(missing) == 0 {
+        c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
+            "created_models":  0,
+            "created_vendors": 0,
+            "skipped_models":  []string{},
+        }})
+        return
+    }
+
+    // 2) 拉取上游 vendors 与 models
+    ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
+    defer cancel()
+
+    var vendorsEnv upstreamEnvelope[upstreamVendor]
+    _ = fetchJSON(ctx, upstreamVendorsURL, &vendorsEnv) // 若失败不拦截,后续降级
+
+    var modelsEnv upstreamEnvelope[upstreamModel]
+    if err := fetchJSON(ctx, upstreamModelsURL, &modelsEnv); err != nil {
+        c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + err.Error()})
+        return
+    }
+
+    // 建立映射
+    vendorByName := make(map[string]upstreamVendor)
+    for _, v := range vendorsEnv.Data {
+        if v.Name != "" {
+            vendorByName[v.Name] = v
+        }
+    }
+    modelByName := make(map[string]upstreamModel)
+    for _, m := range modelsEnv.Data {
+        if m.ModelName != "" {
+            modelByName[m.ModelName] = m
+        }
+    }
+
+    // 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
+    createdModels := 0
+    createdVendors := 0
+    updatedModels := 0
+    var skipped []string
+    var createdList []string
+    var updatedList []string
+
+    // 本地缓存:vendorName -> id
+    vendorIDCache := make(map[string]int)
+
+    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
+            }
+        }
+
+        // 确保 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)
+        }
+    }
+
+    // 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
+            }
+
+            // 映射 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
+            })
+        }
+    }
+
+    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,
+        },
+    })
+}
+
+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
+}
+
+func coalesce(a, b string) string {
+    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
+}
+
+// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
+func SyncUpstreamPreview(c *gin.Context) {
+    // 1) 拉取上游数据
+    ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
+    defer cancel()
+
+    var vendorsEnv upstreamEnvelope[upstreamVendor]
+    _ = fetchJSON(ctx, upstreamVendorsURL, &vendorsEnv)
+
+    var modelsEnv upstreamEnvelope[upstreamModel]
+    if err := fetchJSON(ctx, upstreamModelsURL, &modelsEnv); err != nil {
+        c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + err.Error()})
+        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)
+        }
+    }
+
+    // 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
+        }
+    }
+
+    // 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"`
+    }
+
+    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,
+        },
+    })
+}
+
+

+ 1 - 0
model/model_meta.go

@@ -28,6 +28,7 @@ type Model struct {
 	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"`

+ 2 - 0
router/api-router.go

@@ -224,6 +224,8 @@ func SetApiRouter(router *gin.Engine) {
 		modelsRoute := apiRouter.Group("/models")
 		modelsRoute.Use(middleware.AdminAuth())
 		{
+			modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
+			modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
 			modelsRoute.GET("/missing", controller.GetMissingModels)
 			modelsRoute.GET("/", controller.GetAllModelsMeta)
 			modelsRoute.GET("/search", controller.SearchModelsMeta)

+ 65 - 1
web/src/components/table/models/ModelsActions.jsx

@@ -21,10 +21,11 @@ import React, { useState } from 'react';
 import MissingModelsModal from './modals/MissingModelsModal';
 import PrefillGroupManagement from './modals/PrefillGroupManagement';
 import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
-import { Button, Modal } from '@douyinfe/semi-ui';
+import { Button, Modal, Popover } 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';
 
 const ModelsActions = ({
   selectedKeys,
@@ -32,6 +33,11 @@ const ModelsActions = ({
   setEditingModel,
   setShowEdit,
   batchDeleteModels,
+  syncing,
+  previewing,
+  syncUpstream,
+  previewUpstreamDiff,
+  applyUpstreamOverwrite,
   compactMode,
   setCompactMode,
   t,
@@ -42,6 +48,21 @@ const ModelsActions = ({
   const [showGroupManagement, setShowGroupManagement] = useState(false);
   const [showAddPrefill, setShowAddPrefill] = useState(false);
   const [prefillInit, setPrefillInit] = useState({ id: undefined });
+  const [showConflict, setShowConflict] = useState(false);
+  const [conflicts, setConflicts] = useState([]);
+
+  const handleSyncUpstream = async () => {
+    // 先预览
+    const data = await previewUpstreamDiff?.();
+    const conflictItems = data?.conflicts || [];
+    if (conflictItems.length > 0) {
+      setConflicts(conflictItems);
+      setShowConflict(true);
+      return;
+    }
+    // 无冲突,直接同步缺失
+    await syncUpstream?.();
+  };
 
   // Handle delete selected models with confirmation
   const handleDeleteSelectedModels = () => {
@@ -104,6 +125,38 @@ const ModelsActions = ({
           {t('未配置模型')}
         </Button>
 
+        <Popover
+          position='bottom'
+          trigger='hover'
+          content={
+            <div className='p-2 max-w-[360px]'>
+              <div className='text-[var(--semi-color-text-2)] text-sm'>
+                {t(
+                  '模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:',
+                )}
+              </div>
+              <a
+                href='https://github.com/basellm/llm-metadata'
+                target='_blank'
+                rel='noreferrer'
+                className='text-blue-600 underline'
+              >
+                https://github.com/basellm/llm-metadata
+              </a>
+            </div>
+          }
+        >
+          <Button
+            type='secondary'
+            className='flex-1 md:flex-initial'
+            size='small'
+            loading={syncing || previewing}
+            onClick={handleSyncUpstream}
+          >
+            {t('同步官方')}
+          </Button>
+        </Popover>
+
         <Button
           type='secondary'
           className='flex-1 md:flex-initial'
@@ -165,6 +218,17 @@ const ModelsActions = ({
         editingGroup={prefillInit}
         onSuccess={() => setShowAddPrefill(false)}
       />
+
+      <UpstreamConflictModal
+        visible={showConflict}
+        onClose={() => setShowConflict(false)}
+        conflicts={conflicts}
+        onSubmit={async (payload) => {
+          return await applyUpstreamOverwrite?.(payload);
+        }}
+        t={t}
+        loading={syncing}
+      />
     </>
   );
 };

+ 9 - 0
web/src/components/table/models/ModelsColumnDefs.jsx

@@ -303,6 +303,15 @@ export const getModelsColumns = ({
       dataIndex: 'name_rule',
       render: (val, record) => renderNameRule(val, record, t),
     },
+    {
+      title: t('参与官方同步'),
+      dataIndex: 'sync_official',
+      render: (val) => (
+        <Tag size='small' shape='circle' color={val === 1 ? 'green' : 'orange'}>
+          {val === 1 ? t('是') : t('否')}
+        </Tag>
+      ),
+    },
     {
       title: t('描述'),
       dataIndex: 'description',

+ 5 - 0
web/src/components/table/models/index.jsx

@@ -105,6 +105,11 @@ const ModelsPage = () => {
               setEditingModel={setEditingModel}
               setShowEdit={setShowEdit}
               batchDeleteModels={batchDeleteModels}
+              syncing={modelsData.syncing}
+              syncUpstream={modelsData.syncUpstream}
+              previewing={modelsData.previewing}
+              previewUpstreamDiff={modelsData.previewUpstreamDiff}
+              applyUpstreamOverwrite={modelsData.applyUpstreamOverwrite}
               compactMode={compactMode}
               setCompactMode={setCompactMode}
               t={t}

+ 14 - 1
web/src/components/table/models/modals/EditModelModal.jsx

@@ -121,6 +121,7 @@ const EditModelModal = (props) => {
     endpoints: '',
     name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
     status: true,
+    sync_official: true,
   });
 
   const handleCancel = () => {
@@ -145,8 +146,9 @@ const EditModelModal = (props) => {
         if (!data.endpoints) {
           data.endpoints = '';
         }
-        // 处理status,将数字转为布尔值
+        // 处理status/sync_official,将数字转为布尔值
         data.status = data.status === 1;
+        data.sync_official = (data.sync_official ?? 1) === 1;
         if (formApiRef.current) {
           formApiRef.current.setValues({ ...getInitValues(), ...data });
         }
@@ -193,6 +195,7 @@ const EditModelModal = (props) => {
         tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
         endpoints: values.endpoints || '',
         status: values.status ? 1 : 0,
+        sync_official: values.sync_official ? 1 : 0,
       };
 
       if (isEdit) {
@@ -505,6 +508,16 @@ const EditModelModal = (props) => {
                       }
                     />
                   </Col>
+                  <Col span={24}>
+                    <Form.Switch
+                      field='sync_official'
+                      label={t('参与官方同步')}
+                      extraText={t(
+                        '关闭后,此模型将不会被“同步官方”自动覆盖或创建',
+                      )}
+                      size='large'
+                    />
+                  </Col>
                   <Col span={24}>
                     <Form.Switch
                       field='status'

+ 245 - 0
web/src/components/table/models/modals/UpstreamConflictModal.jsx

@@ -0,0 +1,245 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+  Modal,
+  Table,
+  Checkbox,
+  Typography,
+  Empty,
+  Tag,
+  Popover,
+} from '@douyinfe/semi-ui';
+import { MousePointerClick } from 'lucide-react';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const { Text } = Typography;
+
+const FIELD_LABELS = {
+  description: '描述',
+  icon: '图标',
+  tags: '标签',
+  vendor: '供应商',
+  name_rule: '命名规则',
+  status: '状态',
+};
+const FIELD_KEYS = Object.keys(FIELD_LABELS);
+
+const UpstreamConflictModal = ({
+  visible,
+  onClose,
+  conflicts = [],
+  onSubmit,
+  t,
+  loading = false,
+}) => {
+  const [selections, setSelections] = useState({});
+  const isMobile = useIsMobile();
+
+  const formatValue = (v) => {
+    if (v === null || v === undefined) return '-';
+    if (typeof v === 'string') return v || '-';
+    try {
+      return JSON.stringify(v, null, 2);
+    } catch (_) {
+      return String(v);
+    }
+  };
+
+  useEffect(() => {
+    if (visible) {
+      const init = {};
+      conflicts.forEach((item) => {
+        init[item.model_name] = new Set();
+      });
+      setSelections(init);
+    } else {
+      setSelections({});
+    }
+  }, [visible, conflicts]);
+
+  const toggleField = (modelName, field, checked) => {
+    setSelections((prev) => {
+      const next = { ...prev };
+      const set = new Set(next[modelName] || []);
+      if (checked) set.add(field);
+      else set.delete(field);
+      next[modelName] = set;
+      return next;
+    });
+  };
+
+  const columns = useMemo(() => {
+    const base = [
+      {
+        title: t('模型'),
+        dataIndex: 'model_name',
+        render: (text) => <Text strong>{text}</Text>,
+      },
+    ];
+
+    const cols = FIELD_KEYS.map((fieldKey) => {
+      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;
+        });
+      };
+
+      return {
+        title: (
+          <div className='flex items-center gap-2'>
+            <Checkbox
+              checked={headerChecked}
+              indeterminate={headerIndeterminate}
+              onChange={onHeaderChange}
+            />
+            <Text>{label}</Text>
+          </div>
+        ),
+        dataIndex: fieldKey,
+        render: (_, record) => {
+          const f = (record.fields || []).find((x) => x.field === fieldKey);
+          if (!f) return <Text type='tertiary'>-</Text>;
+          const checked = selections[record.model_name]?.has(fieldKey) || false;
+          return (
+            <Checkbox
+              checked={checked}
+              onChange={(e) =>
+                toggleField(record.model_name, fieldKey, e?.target?.checked)
+              }
+            >
+              <Popover
+                trigger='hover'
+                position='top'
+                content={
+                  <div className='p-2 max-w-[520px]'>
+                    <div className='mb-2'>
+                      <Text type='tertiary' size='small'>
+                        {t('本地')}
+                      </Text>
+                      <pre className='whitespace-pre-wrap m-0'>
+                        {formatValue(f.local)}
+                      </pre>
+                    </div>
+                    <div>
+                      <Text type='tertiary' size='small'>
+                        {t('官方')}
+                      </Text>
+                      <pre className='whitespace-pre-wrap m-0'>
+                        {formatValue(f.upstream)}
+                      </pre>
+                    </div>
+                  </div>
+                }
+              >
+                <Tag
+                  color='white'
+                  size='small'
+                  prefixIcon={<MousePointerClick size={14} />}
+                >
+                  {t('点击查看差异')}
+                </Tag>
+              </Popover>
+            </Checkbox>
+          );
+        },
+      };
+    });
+
+    return [...base, ...cols.filter(Boolean)];
+  }, [t, selections, conflicts]);
+
+  const dataSource = conflicts.map((c) => ({
+    key: c.model_name,
+    model_name: c.model_name,
+    fields: c.fields || [],
+  }));
+
+  const handleOk = async () => {
+    const payload = Object.entries(selections)
+      .map(([modelName, set]) => ({
+        model_name: modelName,
+        fields: Array.from(set || []),
+      }))
+      .filter((x) => x.fields.length > 0);
+
+    if (payload.length === 0) {
+      onClose?.();
+      return;
+    }
+    const ok = await onSubmit?.(payload);
+    if (ok) onClose?.();
+  };
+
+  return (
+    <Modal
+      title={t('选择要覆盖的冲突项')}
+      visible={visible}
+      onCancel={onClose}
+      onOk={handleOk}
+      confirmLoading={loading}
+      okText={t('应用覆盖')}
+      cancelText={t('取消')}
+      width={isMobile ? '100%' : 1000}
+    >
+      {dataSource.length === 0 ? (
+        <Empty description={t('无冲突项')} className='p-6' />
+      ) : (
+        <>
+          <div className='mb-3 text-[var(--semi-color-text-2)]'>
+            {t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
+          </div>
+          <Table columns={columns} dataSource={dataSource} pagination={false} />
+        </>
+      )}
+    </Modal>
+  );
+};
+
+export default UpstreamConflictModal;

+ 84 - 0
web/src/hooks/models/useModelsData.jsx

@@ -95,6 +95,8 @@ export const useModelsData = () => {
   const [showAddVendor, setShowAddVendor] = useState(false);
   const [showEditVendor, setShowEditVendor] = useState(false);
   const [editingVendor, setEditingVendor] = useState({ id: undefined });
+  const [syncing, setSyncing] = useState(false);
+  const [previewing, setPreviewing] = useState(false);
 
   const vendorMap = useMemo(() => {
     const map = {};
@@ -163,6 +165,81 @@ export const useModelsData = () => {
     await loadModels(page, pageSize);
   };
 
+  // Sync upstream models/vendors for missing models only
+  const syncUpstream = async () => {
+    setSyncing(true);
+    try {
+      const res = await API.post('/api/models/sync_upstream');
+      const { success, message, data } = res.data || {};
+      if (success) {
+        const createdModels = data?.created_models || 0;
+        const createdVendors = data?.created_vendors || 0;
+        const skipped = (data?.skipped_models || []).length || 0;
+        showSuccess(
+          t(
+            `已同步:新增 ${createdModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`,
+          ),
+        );
+        await loadVendors();
+        await refresh();
+      } else {
+        showError(message || t('同步失败'));
+      }
+    } catch (e) {
+      showError(t('同步失败'));
+    }
+    setSyncing(false);
+  };
+
+  // Preview upstream differences
+  const previewUpstreamDiff = async () => {
+    setPreviewing(true);
+    try {
+      const res = await API.get('/api/models/sync_upstream/preview');
+      const { success, message, data } = res.data || {};
+      if (success) {
+        return data || { missing: [], conflicts: [] };
+      }
+      showError(message || t('预览失败'));
+      return { missing: [], conflicts: [] };
+    } catch (e) {
+      showError(t('预览失败'));
+      return { missing: [], conflicts: [] };
+    } finally {
+      setPreviewing(false);
+    }
+  };
+
+  // Apply selected overwrite
+  const applyUpstreamOverwrite = async (overwrite = []) => {
+    setSyncing(true);
+    try {
+      const res = await API.post('/api/models/sync_upstream', { overwrite });
+      const { success, message, data } = res.data || {};
+      if (success) {
+        const createdModels = data?.created_models || 0;
+        const updatedModels = data?.updated_models || 0;
+        const createdVendors = data?.created_vendors || 0;
+        const skipped = (data?.skipped_models || []).length || 0;
+        showSuccess(
+          t(
+            `完成:新增 ${createdModels} 模型,更新 ${updatedModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped} 项`,
+          ),
+        );
+        await loadVendors();
+        await refresh();
+        return true;
+      }
+      showError(message || t('同步失败'));
+      return false;
+    } catch (e) {
+      showError(t('同步失败'));
+      return false;
+    } finally {
+      setSyncing(false);
+    }
+  };
+
   // Search models with keyword and vendor
   const searchModels = async () => {
     const { searchKeyword = '', searchVendor = '' } = getFormValues();
@@ -398,5 +475,12 @@ export const useModelsData = () => {
 
     // Translation
     t,
+
+    // Upstream sync
+    syncing,
+    previewing,
+    syncUpstream,
+    previewUpstreamDiff,
+    applyUpstreamOverwrite,
   };
 };

+ 14 - 20
web/src/i18n/locales/en.json

@@ -1,6 +1,5 @@
 {
   "主页": "Home",
-  "文档": "Docs",
   "控制台": "Console",
   "$%.6f 额度": "$%.6f quota",
   "或": "or",
@@ -165,7 +164,6 @@
   "出现错误,第 ${count} 次重试中...": "Error occurred, retry attempt ${count}...",
   "首页": "Home",
   "渠道": "Channel",
-  "渠道管理": "Channels",
   "令牌": "Tokens",
   "兑换额度": "Redeem",
   "充值": "Recharge",
@@ -174,7 +172,6 @@
   "设置": "Settings",
   "关于": "About",
   "价格": "Pricing",
-  "聊天": "Chat",
   "注销成功!": "Logout successful!",
   "注销": "Logout",
   "登录": "Sign in",
@@ -443,9 +440,7 @@
   "兑换码": "Redeem Code",
   "管理用户": "Manage Users",
   "额度明细": "Quota Details",
-  "个人设置": "Personal Settings",
   "运营设置": "Operation Settings",
-  "系统设置": "System Settings",
   "其他设置": "Other Settings",
   "项目仓库地址": "Project Repository Address",
   "可在设置页面设置关于内容,支持 HTML & Markdown": "The About content can be set on the settings page, supporting HTML & Markdown",
@@ -585,10 +580,7 @@
   "确定是否要修复数据库一致性?": "Are you sure you want to repair database consistency?",
   "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "When performing this operation, it may cause channel access errors. Please only use it when there is a problem with the database.",
   "当前没有可用的启用令牌,请确认是否有令牌处于启用状态!": "There are currently no enablement tokens available, please confirm if one is enabled!",
-  "令牌管理": "API Keys",
-  "使用日志": "Usage log",
   "Midjourney日志": "Midjourney",
-  "数据看板": "Dashboard",
   "模型列表": "Model list",
   "常见问题": "FAQ",
   "免费体验": "Free trial",
@@ -628,7 +620,6 @@
   "重置成功": "Reset successful",
   "加载数据出错:": "Error loading data:",
   "加载数据时发生错误: ": "An error occurred while loading data:",
-  "保存成功": "Saved successfully",
   "部分保存失败,请重试": "Partial saving failed, please try again",
   "请检查输入": "Please check your input",
   "如何区分不同分组不同模型的价格:供参考的配置方式": "How to distinguish the prices of different models in different groups: configuration method for reference",
@@ -653,7 +644,6 @@
   "窗口等待": "window wait",
   "失败": "Failed",
   "绘图": "Drawing",
-  "绘图日志": "Drawing log",
   "放大": "Upscalers",
   "微妙放大": "Upscale (Subtle)",
   "创造放大": "Upscale (Creative)",
@@ -793,7 +783,6 @@
   "邮箱": "Email",
   "已有账户?": "Already have an account?",
   "创意任务": "Tasks",
-  "用户管理": "User Management",
   "任务ID(点击查看详情)": "Task ID (click to view details)",
   "进度": "schedule",
   "花费时间": "spend time",
@@ -943,7 +932,6 @@
   "不是合法的 JSON 字符串": "Not a valid JSON string",
   "个人中心": "Personal center",
   "代理商": "Agent",
-  "钱包管理": "Wallet",
   "备注": "Remark",
   "工作台": "Workbench",
   "已复制:": "Copied:",
@@ -957,7 +945,6 @@
   "黑夜模式": "Dark mode",
   "管理员设置": "Admin",
   "待更新": "To be updated",
-  "模型广场": "Pricing",
   "支付中..": "Paying",
   "查看图片": "View pictures",
   "并发限制": "Concurrency limit",
@@ -1043,7 +1030,6 @@
   "在iframe中加载": "Load in iframe",
   "补全倍率": "Completion ratio",
   "保存分组数据失败": "Failed to save group data",
-  "保存失败,请重试": "Save failed, please try again",
   "没有可用的使用信息": "No usage information available",
   "使用详情": "Usage details",
   "收起": "Collapse",
@@ -1187,7 +1173,6 @@
   "知识库 ID": "Knowledge Base ID",
   "请输入知识库 ID,例如:123456": "Please enter knowledge base ID, e.g.: 123456",
   "可选值": "Optional value",
-  "任务日志": "Task log",
   "你好": "Hello",
   "你好,请问有什么可以帮助您的吗?": "Hello, how may I help you?",
   "用户分组": "Your default group",
@@ -1329,7 +1314,6 @@
   "当剩余额度低于此数值时,系统将通过选择的方式发送通知": "When the remaining quota is lower than this value, the system will send a notification through the selected method",
   "Webhook请求结构": "Webhook request structure",
   "只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求": "Only https is supported, the system will send a notification through POST, please ensure the address can receive POST requests",
-  "保存设置": "Save settings",
   "通知邮箱": "Notification email",
   "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used",
   "留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used",
@@ -1501,7 +1485,6 @@
   "收益": "Earnings",
   "无邀请人": "No Inviter",
   "邀请人": "Inviter",
-  "兑换码管理": "Redemption Code",
   "设置兑换码的基本信息": "Set redemption code basic information",
   "设置兑换码的额度和数量": "Set redemption code quota and quantity",
   "编辑用户": "Edit User",
@@ -1595,7 +1578,6 @@
   "加载中...": "Loading...",
   "正在跳转...": "Redirecting...",
   "暂无公告": "No Notice",
-  "操练场": "Playground",
   "欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system",
   "数据库检查": "Database Check",
   "验证数据库连接状态": "Verify database connection status",
@@ -1811,7 +1793,6 @@
   "系统提示覆盖": "System prompt override",
   "模型: {{ratio}}": "Model: {{ratio}}",
   "专属倍率": "Exclusive group ratio",
-  "模型管理": "Models",
   "匹配类型": "Matching type",
   "描述": "Description",
   "供应商": "Vendor",
@@ -2075,5 +2056,18 @@
   "保存边栏设置": "Save Sidebar Settings",
   "侧边栏设置保存成功": "Sidebar settings saved successfully",
   "需要登录访问": "Require Login",
-  "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace"
+  "开启后未登录用户无法访问模型广场": "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)",
+  "选择要覆盖的冲突项": "Select conflict items to overwrite",
+  "点击查看差异": "Click to view differences",
+  "无冲突项": "No conflict items",
+  "应用覆盖": "Apply overwrite",
+  "仅会覆盖你勾选的字段,未勾选的字段保持本地不变。": "Only selected fields will be overwritten; unselected fields remain unchanged.",
+  "本地": "Local",
+  "官方": "Official",
+  "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "The model community needs everyone's contribution. If you find incorrect data or want to contribute new models, please visit:",
+  "是": "Yes",
+  "否": "No"
 }