mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 04:57:26 +00:00
✨ 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
This commit is contained in:
463
controller/model_sync.go
Normal file
463
controller/model_sync.go
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ type Model struct {
|
|||||||
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
||||||
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
|
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
|
||||||
Status int `json:"status" gorm:"default:1"`
|
Status int `json:"status" gorm:"default:1"`
|
||||||
|
SyncOfficial int `json:"sync_official" gorm:"default:1"`
|
||||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
|
||||||
|
|||||||
@@ -224,6 +224,8 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
modelsRoute := apiRouter.Group("/models")
|
modelsRoute := apiRouter.Group("/models")
|
||||||
modelsRoute.Use(middleware.AdminAuth())
|
modelsRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
|
modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
|
||||||
|
modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
|
||||||
modelsRoute.GET("/missing", controller.GetMissingModels)
|
modelsRoute.GET("/missing", controller.GetMissingModels)
|
||||||
modelsRoute.GET("/", controller.GetAllModelsMeta)
|
modelsRoute.GET("/", controller.GetAllModelsMeta)
|
||||||
modelsRoute.GET("/search", controller.SearchModelsMeta)
|
modelsRoute.GET("/search", controller.SearchModelsMeta)
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ import React, { useState } from 'react';
|
|||||||
import MissingModelsModal from './modals/MissingModelsModal';
|
import MissingModelsModal from './modals/MissingModelsModal';
|
||||||
import PrefillGroupManagement from './modals/PrefillGroupManagement';
|
import PrefillGroupManagement from './modals/PrefillGroupManagement';
|
||||||
import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
|
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 { showSuccess, showError, copy } from '../../../helpers';
|
||||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||||
import SelectionNotification from './components/SelectionNotification';
|
import SelectionNotification from './components/SelectionNotification';
|
||||||
|
import UpstreamConflictModal from './modals/UpstreamConflictModal';
|
||||||
|
|
||||||
const ModelsActions = ({
|
const ModelsActions = ({
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
@@ -32,6 +33,11 @@ const ModelsActions = ({
|
|||||||
setEditingModel,
|
setEditingModel,
|
||||||
setShowEdit,
|
setShowEdit,
|
||||||
batchDeleteModels,
|
batchDeleteModels,
|
||||||
|
syncing,
|
||||||
|
previewing,
|
||||||
|
syncUpstream,
|
||||||
|
previewUpstreamDiff,
|
||||||
|
applyUpstreamOverwrite,
|
||||||
compactMode,
|
compactMode,
|
||||||
setCompactMode,
|
setCompactMode,
|
||||||
t,
|
t,
|
||||||
@@ -42,6 +48,21 @@ const ModelsActions = ({
|
|||||||
const [showGroupManagement, setShowGroupManagement] = useState(false);
|
const [showGroupManagement, setShowGroupManagement] = useState(false);
|
||||||
const [showAddPrefill, setShowAddPrefill] = useState(false);
|
const [showAddPrefill, setShowAddPrefill] = useState(false);
|
||||||
const [prefillInit, setPrefillInit] = useState({ id: undefined });
|
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
|
// Handle delete selected models with confirmation
|
||||||
const handleDeleteSelectedModels = () => {
|
const handleDeleteSelectedModels = () => {
|
||||||
@@ -104,6 +125,38 @@ const ModelsActions = ({
|
|||||||
{t('未配置模型')}
|
{t('未配置模型')}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
type='secondary'
|
type='secondary'
|
||||||
className='flex-1 md:flex-initial'
|
className='flex-1 md:flex-initial'
|
||||||
@@ -165,6 +218,17 @@ const ModelsActions = ({
|
|||||||
editingGroup={prefillInit}
|
editingGroup={prefillInit}
|
||||||
onSuccess={() => setShowAddPrefill(false)}
|
onSuccess={() => setShowAddPrefill(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UpstreamConflictModal
|
||||||
|
visible={showConflict}
|
||||||
|
onClose={() => setShowConflict(false)}
|
||||||
|
conflicts={conflicts}
|
||||||
|
onSubmit={async (payload) => {
|
||||||
|
return await applyUpstreamOverwrite?.(payload);
|
||||||
|
}}
|
||||||
|
t={t}
|
||||||
|
loading={syncing}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -303,6 +303,15 @@ export const getModelsColumns = ({
|
|||||||
dataIndex: 'name_rule',
|
dataIndex: 'name_rule',
|
||||||
render: (val, record) => renderNameRule(val, record, t),
|
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('描述'),
|
title: t('描述'),
|
||||||
dataIndex: 'description',
|
dataIndex: 'description',
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ const ModelsPage = () => {
|
|||||||
setEditingModel={setEditingModel}
|
setEditingModel={setEditingModel}
|
||||||
setShowEdit={setShowEdit}
|
setShowEdit={setShowEdit}
|
||||||
batchDeleteModels={batchDeleteModels}
|
batchDeleteModels={batchDeleteModels}
|
||||||
|
syncing={modelsData.syncing}
|
||||||
|
syncUpstream={modelsData.syncUpstream}
|
||||||
|
previewing={modelsData.previewing}
|
||||||
|
previewUpstreamDiff={modelsData.previewUpstreamDiff}
|
||||||
|
applyUpstreamOverwrite={modelsData.applyUpstreamOverwrite}
|
||||||
compactMode={compactMode}
|
compactMode={compactMode}
|
||||||
setCompactMode={setCompactMode}
|
setCompactMode={setCompactMode}
|
||||||
t={t}
|
t={t}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ const EditModelModal = (props) => {
|
|||||||
endpoints: '',
|
endpoints: '',
|
||||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||||
status: true,
|
status: true,
|
||||||
|
sync_official: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -145,8 +146,9 @@ const EditModelModal = (props) => {
|
|||||||
if (!data.endpoints) {
|
if (!data.endpoints) {
|
||||||
data.endpoints = '';
|
data.endpoints = '';
|
||||||
}
|
}
|
||||||
// 处理status,将数字转为布尔值
|
// 处理status/sync_official,将数字转为布尔值
|
||||||
data.status = data.status === 1;
|
data.status = data.status === 1;
|
||||||
|
data.sync_official = (data.sync_official ?? 1) === 1;
|
||||||
if (formApiRef.current) {
|
if (formApiRef.current) {
|
||||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||||
}
|
}
|
||||||
@@ -193,6 +195,7 @@ const EditModelModal = (props) => {
|
|||||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||||
endpoints: values.endpoints || '',
|
endpoints: values.endpoints || '',
|
||||||
status: values.status ? 1 : 0,
|
status: values.status ? 1 : 0,
|
||||||
|
sync_official: values.sync_official ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -505,6 +508,16 @@ const EditModelModal = (props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Switch
|
||||||
|
field='sync_official'
|
||||||
|
label={t('参与官方同步')}
|
||||||
|
extraText={t(
|
||||||
|
'关闭后,此模型将不会被“同步官方”自动覆盖或创建',
|
||||||
|
)}
|
||||||
|
size='large'
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
field='status'
|
field='status'
|
||||||
|
|||||||
245
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
245
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
@@ -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 support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -95,6 +95,8 @@ export const useModelsData = () => {
|
|||||||
const [showAddVendor, setShowAddVendor] = useState(false);
|
const [showAddVendor, setShowAddVendor] = useState(false);
|
||||||
const [showEditVendor, setShowEditVendor] = useState(false);
|
const [showEditVendor, setShowEditVendor] = useState(false);
|
||||||
const [editingVendor, setEditingVendor] = useState({ id: undefined });
|
const [editingVendor, setEditingVendor] = useState({ id: undefined });
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
|
||||||
const vendorMap = useMemo(() => {
|
const vendorMap = useMemo(() => {
|
||||||
const map = {};
|
const map = {};
|
||||||
@@ -163,6 +165,81 @@ export const useModelsData = () => {
|
|||||||
await loadModels(page, pageSize);
|
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
|
// Search models with keyword and vendor
|
||||||
const searchModels = async () => {
|
const searchModels = async () => {
|
||||||
const { searchKeyword = '', searchVendor = '' } = getFormValues();
|
const { searchKeyword = '', searchVendor = '' } = getFormValues();
|
||||||
@@ -398,5 +475,12 @@ export const useModelsData = () => {
|
|||||||
|
|
||||||
// Translation
|
// Translation
|
||||||
t,
|
t,
|
||||||
|
|
||||||
|
// Upstream sync
|
||||||
|
syncing,
|
||||||
|
previewing,
|
||||||
|
syncUpstream,
|
||||||
|
previewUpstreamDiff,
|
||||||
|
applyUpstreamOverwrite,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"主页": "Home",
|
"主页": "Home",
|
||||||
"文档": "Docs",
|
|
||||||
"控制台": "Console",
|
"控制台": "Console",
|
||||||
"$%.6f 额度": "$%.6f quota",
|
"$%.6f 额度": "$%.6f quota",
|
||||||
"或": "or",
|
"或": "or",
|
||||||
@@ -165,7 +164,6 @@
|
|||||||
"出现错误,第 ${count} 次重试中...": "Error occurred, retry attempt ${count}...",
|
"出现错误,第 ${count} 次重试中...": "Error occurred, retry attempt ${count}...",
|
||||||
"首页": "Home",
|
"首页": "Home",
|
||||||
"渠道": "Channel",
|
"渠道": "Channel",
|
||||||
"渠道管理": "Channels",
|
|
||||||
"令牌": "Tokens",
|
"令牌": "Tokens",
|
||||||
"兑换额度": "Redeem",
|
"兑换额度": "Redeem",
|
||||||
"充值": "Recharge",
|
"充值": "Recharge",
|
||||||
@@ -174,7 +172,6 @@
|
|||||||
"设置": "Settings",
|
"设置": "Settings",
|
||||||
"关于": "About",
|
"关于": "About",
|
||||||
"价格": "Pricing",
|
"价格": "Pricing",
|
||||||
"聊天": "Chat",
|
|
||||||
"注销成功!": "Logout successful!",
|
"注销成功!": "Logout successful!",
|
||||||
"注销": "Logout",
|
"注销": "Logout",
|
||||||
"登录": "Sign in",
|
"登录": "Sign in",
|
||||||
@@ -443,9 +440,7 @@
|
|||||||
"兑换码": "Redeem Code",
|
"兑换码": "Redeem Code",
|
||||||
"管理用户": "Manage Users",
|
"管理用户": "Manage Users",
|
||||||
"额度明细": "Quota Details",
|
"额度明细": "Quota Details",
|
||||||
"个人设置": "Personal Settings",
|
|
||||||
"运营设置": "Operation Settings",
|
"运营设置": "Operation Settings",
|
||||||
"系统设置": "System Settings",
|
|
||||||
"其他设置": "Other Settings",
|
"其他设置": "Other Settings",
|
||||||
"项目仓库地址": "Project Repository Address",
|
"项目仓库地址": "Project Repository Address",
|
||||||
"可在设置页面设置关于内容,支持 HTML & Markdown": "The About content can be set on the settings page, supporting HTML & Markdown",
|
"可在设置页面设置关于内容,支持 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?",
|
"确定是否要修复数据库一致性?": "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.",
|
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "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!",
|
"当前没有可用的启用令牌,请确认是否有令牌处于启用状态!": "There are currently no enablement tokens available, please confirm if one is enabled!",
|
||||||
"令牌管理": "API Keys",
|
|
||||||
"使用日志": "Usage log",
|
|
||||||
"Midjourney日志": "Midjourney",
|
"Midjourney日志": "Midjourney",
|
||||||
"数据看板": "Dashboard",
|
|
||||||
"模型列表": "Model list",
|
"模型列表": "Model list",
|
||||||
"常见问题": "FAQ",
|
"常见问题": "FAQ",
|
||||||
"免费体验": "Free trial",
|
"免费体验": "Free trial",
|
||||||
@@ -628,7 +620,6 @@
|
|||||||
"重置成功": "Reset successful",
|
"重置成功": "Reset successful",
|
||||||
"加载数据出错:": "Error loading data:",
|
"加载数据出错:": "Error loading data:",
|
||||||
"加载数据时发生错误: ": "An error occurred while loading data:",
|
"加载数据时发生错误: ": "An error occurred while loading data:",
|
||||||
"保存成功": "Saved successfully",
|
|
||||||
"部分保存失败,请重试": "Partial saving failed, please try again",
|
"部分保存失败,请重试": "Partial saving failed, please try again",
|
||||||
"请检查输入": "Please check your input",
|
"请检查输入": "Please check your input",
|
||||||
"如何区分不同分组不同模型的价格:供参考的配置方式": "How to distinguish the prices of different models in different groups: configuration method for reference",
|
"如何区分不同分组不同模型的价格:供参考的配置方式": "How to distinguish the prices of different models in different groups: configuration method for reference",
|
||||||
@@ -653,7 +644,6 @@
|
|||||||
"窗口等待": "window wait",
|
"窗口等待": "window wait",
|
||||||
"失败": "Failed",
|
"失败": "Failed",
|
||||||
"绘图": "Drawing",
|
"绘图": "Drawing",
|
||||||
"绘图日志": "Drawing log",
|
|
||||||
"放大": "Upscalers",
|
"放大": "Upscalers",
|
||||||
"微妙放大": "Upscale (Subtle)",
|
"微妙放大": "Upscale (Subtle)",
|
||||||
"创造放大": "Upscale (Creative)",
|
"创造放大": "Upscale (Creative)",
|
||||||
@@ -793,7 +783,6 @@
|
|||||||
"邮箱": "Email",
|
"邮箱": "Email",
|
||||||
"已有账户?": "Already have an account?",
|
"已有账户?": "Already have an account?",
|
||||||
"创意任务": "Tasks",
|
"创意任务": "Tasks",
|
||||||
"用户管理": "User Management",
|
|
||||||
"任务ID(点击查看详情)": "Task ID (click to view details)",
|
"任务ID(点击查看详情)": "Task ID (click to view details)",
|
||||||
"进度": "schedule",
|
"进度": "schedule",
|
||||||
"花费时间": "spend time",
|
"花费时间": "spend time",
|
||||||
@@ -943,7 +932,6 @@
|
|||||||
"不是合法的 JSON 字符串": "Not a valid JSON string",
|
"不是合法的 JSON 字符串": "Not a valid JSON string",
|
||||||
"个人中心": "Personal center",
|
"个人中心": "Personal center",
|
||||||
"代理商": "Agent",
|
"代理商": "Agent",
|
||||||
"钱包管理": "Wallet",
|
|
||||||
"备注": "Remark",
|
"备注": "Remark",
|
||||||
"工作台": "Workbench",
|
"工作台": "Workbench",
|
||||||
"已复制:": "Copied:",
|
"已复制:": "Copied:",
|
||||||
@@ -957,7 +945,6 @@
|
|||||||
"黑夜模式": "Dark mode",
|
"黑夜模式": "Dark mode",
|
||||||
"管理员设置": "Admin",
|
"管理员设置": "Admin",
|
||||||
"待更新": "To be updated",
|
"待更新": "To be updated",
|
||||||
"模型广场": "Pricing",
|
|
||||||
"支付中..": "Paying",
|
"支付中..": "Paying",
|
||||||
"查看图片": "View pictures",
|
"查看图片": "View pictures",
|
||||||
"并发限制": "Concurrency limit",
|
"并发限制": "Concurrency limit",
|
||||||
@@ -1043,7 +1030,6 @@
|
|||||||
"在iframe中加载": "Load in iframe",
|
"在iframe中加载": "Load in iframe",
|
||||||
"补全倍率": "Completion ratio",
|
"补全倍率": "Completion ratio",
|
||||||
"保存分组数据失败": "Failed to save group data",
|
"保存分组数据失败": "Failed to save group data",
|
||||||
"保存失败,请重试": "Save failed, please try again",
|
|
||||||
"没有可用的使用信息": "No usage information available",
|
"没有可用的使用信息": "No usage information available",
|
||||||
"使用详情": "Usage details",
|
"使用详情": "Usage details",
|
||||||
"收起": "Collapse",
|
"收起": "Collapse",
|
||||||
@@ -1187,7 +1173,6 @@
|
|||||||
"知识库 ID": "Knowledge Base ID",
|
"知识库 ID": "Knowledge Base ID",
|
||||||
"请输入知识库 ID,例如:123456": "Please enter knowledge base ID, e.g.: 123456",
|
"请输入知识库 ID,例如:123456": "Please enter knowledge base ID, e.g.: 123456",
|
||||||
"可选值": "Optional value",
|
"可选值": "Optional value",
|
||||||
"任务日志": "Task log",
|
|
||||||
"你好": "Hello",
|
"你好": "Hello",
|
||||||
"你好,请问有什么可以帮助您的吗?": "Hello, how may I help you?",
|
"你好,请问有什么可以帮助您的吗?": "Hello, how may I help you?",
|
||||||
"用户分组": "Your default group",
|
"用户分组": "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",
|
"当剩余额度低于此数值时,系统将通过选择的方式发送通知": "When the remaining quota is lower than this value, the system will send a notification through the selected method",
|
||||||
"Webhook请求结构": "Webhook request structure",
|
"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",
|
"只支持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",
|
"通知邮箱": "Notification email",
|
||||||
"设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used",
|
"设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "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",
|
"留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used",
|
||||||
@@ -1501,7 +1485,6 @@
|
|||||||
"收益": "Earnings",
|
"收益": "Earnings",
|
||||||
"无邀请人": "No Inviter",
|
"无邀请人": "No Inviter",
|
||||||
"邀请人": "Inviter",
|
"邀请人": "Inviter",
|
||||||
"兑换码管理": "Redemption Code",
|
|
||||||
"设置兑换码的基本信息": "Set redemption code basic information",
|
"设置兑换码的基本信息": "Set redemption code basic information",
|
||||||
"设置兑换码的额度和数量": "Set redemption code quota and quantity",
|
"设置兑换码的额度和数量": "Set redemption code quota and quantity",
|
||||||
"编辑用户": "Edit User",
|
"编辑用户": "Edit User",
|
||||||
@@ -1595,7 +1578,6 @@
|
|||||||
"加载中...": "Loading...",
|
"加载中...": "Loading...",
|
||||||
"正在跳转...": "Redirecting...",
|
"正在跳转...": "Redirecting...",
|
||||||
"暂无公告": "No Notice",
|
"暂无公告": "No Notice",
|
||||||
"操练场": "Playground",
|
|
||||||
"欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system",
|
"欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system",
|
||||||
"数据库检查": "Database Check",
|
"数据库检查": "Database Check",
|
||||||
"验证数据库连接状态": "Verify database connection status",
|
"验证数据库连接状态": "Verify database connection status",
|
||||||
@@ -1811,7 +1793,6 @@
|
|||||||
"系统提示覆盖": "System prompt override",
|
"系统提示覆盖": "System prompt override",
|
||||||
"模型: {{ratio}}": "Model: {{ratio}}",
|
"模型: {{ratio}}": "Model: {{ratio}}",
|
||||||
"专属倍率": "Exclusive group ratio",
|
"专属倍率": "Exclusive group ratio",
|
||||||
"模型管理": "Models",
|
|
||||||
"匹配类型": "Matching type",
|
"匹配类型": "Matching type",
|
||||||
"描述": "Description",
|
"描述": "Description",
|
||||||
"供应商": "Vendor",
|
"供应商": "Vendor",
|
||||||
@@ -2075,5 +2056,18 @@
|
|||||||
"保存边栏设置": "Save Sidebar Settings",
|
"保存边栏设置": "Save Sidebar Settings",
|
||||||
"侧边栏设置保存成功": "Sidebar settings saved successfully",
|
"侧边栏设置保存成功": "Sidebar settings saved successfully",
|
||||||
"需要登录访问": "Require Login",
|
"需要登录访问": "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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user