Browse Source

Merge branch 'alpha' into 'feat/support-native-gemini-embedding'

RedwindA 5 months ago
parent
commit
03b670971b
37 changed files with 1078 additions and 680 deletions
  1. 2 0
      constant/context_key.go
  2. 30 0
      controller/midjourney.go
  3. 1 0
      dto/channel_settings.go
  4. 2 0
      dto/openai_request.go
  5. 2 0
      middleware/distributor.go
  6. 23 0
      model/main.go
  7. 2 2
      model/model_meta.go
  8. 2 2
      model/vendor_meta.go
  9. 24 17
      relay/channel/gemini/adaptor.go
  10. 11 9
      relay/channel/gemini/relay-gemini.go
  11. 1 2
      relay/channel/zhipu_4v/adaptor.go
  12. 0 59
      relay/channel/zhipu_4v/relay-zhipu_v4.go
  13. 24 6
      relay/relay-text.go
  14. 6 0
      service/log_info_generate.go
  15. 330 315
      web/src/components/common/ui/JSONEditor.js
  16. 1 1
      web/src/components/layout/HeaderBar.js
  17. 6 6
      web/src/components/layout/SiderBar.js
  18. 17 0
      web/src/components/table/channels/modals/EditChannelModal.jsx
  19. 2 1
      web/src/components/table/model-pricing/layout/PricingSidebar.jsx
  20. 4 0
      web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  21. 3 0
      web/src/components/table/models/modals/EditModelModal.jsx
  22. 3 0
      web/src/components/table/models/modals/EditPrefillGroupModal.jsx
  23. 8 1
      web/src/components/table/task-logs/TaskLogsColumnDefs.js
  24. 3 0
      web/src/components/table/task-logs/TaskLogsTable.jsx
  25. 8 1
      web/src/components/table/task-logs/index.jsx
  26. 6 1
      web/src/components/table/task-logs/modals/ContentModal.jsx
  27. 114 96
      web/src/components/table/tokens/TokensColumnDefs.js
  28. 240 4
      web/src/components/table/tokens/index.jsx
  29. 13 4
      web/src/components/table/usage-logs/UsageLogsColumnDefs.js
  30. 69 56
      web/src/components/table/users/UsersColumnDefs.js
  31. 1 1
      web/src/constants/channel.constants.js
  32. 8 6
      web/src/helpers/api.js
  33. 83 87
      web/src/helpers/render.js
  34. 16 0
      web/src/hooks/task-logs/useTaskLogsData.js
  35. 5 1
      web/src/hooks/tokens/useTokensData.js
  36. 7 1
      web/src/i18n/locales/en.json
  37. 1 1
      web/src/index.css

+ 2 - 0
constant/context_key.go

@@ -40,4 +40,6 @@ const (
 	ContextKeyUserGroup   ContextKey = "user_group"
 	ContextKeyUsingGroup  ContextKey = "group"
 	ContextKeyUserName    ContextKey = "username"
+
+	ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
 )

+ 30 - 0
controller/midjourney.go

@@ -145,6 +145,22 @@ func UpdateMidjourneyTaskBulk() {
 					buttonStr, _ := json.Marshal(responseItem.Buttons)
 					task.Buttons = string(buttonStr)
 				}
+				// 映射 VideoUrl
+				task.VideoUrl = responseItem.VideoUrl
+				
+				// 映射 VideoUrls - 将数组序列化为 JSON 字符串
+				if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
+					videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
+					if err != nil {
+						common.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
+						task.VideoUrls = "[]" // 失败时设置为空数组
+					} else {
+						task.VideoUrls = string(videoUrlsStr)
+					}
+				} else {
+					task.VideoUrls = "" // 空值时清空字段
+				}
+				
 				shouldReturnQuota := false
 				if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
 					common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
@@ -208,6 +224,20 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
 	if oldTask.Progress != "100%" && newTask.FailReason != "" {
 		return true
 	}
+	// 检查 VideoUrl 是否需要更新
+	if oldTask.VideoUrl != newTask.VideoUrl {
+		return true
+	}
+	// 检查 VideoUrls 是否需要更新
+	if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
+		newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls)
+		if oldTask.VideoUrls != string(newVideoUrlsStr) {
+			return true
+		}
+	} else if oldTask.VideoUrls != "" {
+		// 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空)
+		return true
+	}
 
 	return false
 }

+ 1 - 0
dto/channel_settings.go

@@ -6,4 +6,5 @@ type ChannelSettings struct {
 	Proxy                  string `json:"proxy"`
 	PassThroughBodyEnabled bool   `json:"pass_through_body_enabled,omitempty"`
 	SystemPrompt           string `json:"system_prompt,omitempty"`
+	SystemPromptOverride   bool   `json:"system_prompt_override,omitempty"`
 }

+ 2 - 0
dto/openai_request.go

@@ -78,6 +78,8 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
 		if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
 			return "developer"
 		}
+	} else if strings.HasPrefix(r.Model, "gpt-5") {
+		return "developer"
 	}
 	return "system"
 }

+ 2 - 0
middleware/distributor.go

@@ -267,6 +267,8 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
 	common.SetContextKey(c, constant.ContextKeyChannelKey, key)
 	common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL())
 
+	common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false)
+
 	// TODO: api_version统一
 	switch channel.Type {
 	case constant.ChannelTypeAzure:

+ 23 - 0
model/main.go

@@ -64,6 +64,22 @@ var DB *gorm.DB
 
 var LOG_DB *gorm.DB
 
+// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
+func dropIndexIfExists(tableName string, indexName string) {
+    if !common.UsingMySQL {
+        return
+    }
+    var count int64
+    // Check index existence via information_schema
+    err := DB.Raw(
+        "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
+        tableName, indexName,
+    ).Scan(&count).Error
+    if err == nil && count > 0 {
+        _ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
+    }
+}
+
 func createRootAccountIfNeed() error {
 	var user User
 	//if user.Status != common.UserStatusEnabled {
@@ -235,6 +251,9 @@ func InitLogDB() (err error) {
 }
 
 func migrateDB() error {
+	// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
+	dropIndexIfExists("models", "uk_model_name")
+	dropIndexIfExists("vendors", "uk_vendor_name")
 	if !common.UsingPostgreSQL {
 		return migrateDBFast()
 	}
@@ -264,6 +283,10 @@ func migrateDB() error {
 }
 
 func migrateDBFast() error {
+	// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
+	dropIndexIfExists("models", "uk_model_name")
+	dropIndexIfExists("vendors", "uk_vendor_name")
+
 	var wg sync.WaitGroup
 
 	migrations := []struct {

+ 2 - 2
model/model_meta.go

@@ -36,7 +36,7 @@ type BoundChannel struct {
 
 type Model struct {
     Id          int            `json:"id"`
-    ModelName   string         `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"`
+    ModelName   string         `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
     Description string         `json:"description,omitempty" gorm:"type:text"`
     Tags        string         `json:"tags,omitempty" gorm:"type:varchar(255)"`
     VendorID    int            `json:"vendor_id,omitempty" gorm:"index"`
@@ -44,7 +44,7 @@ type Model struct {
     Status      int            `json:"status" gorm:"default:1"`
     CreatedTime int64          `json:"created_time" gorm:"bigint"`
     UpdatedTime int64          `json:"updated_time" gorm:"bigint"`
-    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index"`
+    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
 
     BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
     EnableGroups []string       `json:"enable_groups,omitempty" gorm:"-"`

+ 2 - 2
model/vendor_meta.go

@@ -14,13 +14,13 @@ import (
 
 type Vendor struct {
     Id          int            `json:"id"`
-    Name        string         `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"`
+    Name        string         `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"`
     Description string         `json:"description,omitempty" gorm:"type:text"`
     Icon        string         `json:"icon,omitempty" gorm:"type:varchar(128)"`
     Status      int            `json:"status" gorm:"default:1"`
     CreatedTime int64          `json:"created_time" gorm:"bigint"`
     UpdatedTime int64          `json:"updated_time" gorm:"bigint"`
-    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index"`
+    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
 }
 
 // Insert 创建新的供应商记录

+ 24 - 17
relay/channel/gemini/adaptor.go

@@ -119,6 +119,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 			action = "batchEmbedContents"
 		}
 		return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil
+		return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", info.BaseUrl, version, info.UpstreamModelName), nil
 	}
 
 	action := "generateContent"
@@ -163,29 +164,35 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
 	if len(inputs) == 0 {
 		return nil, errors.New("input is empty")
 	}
-
-	// only process the first input
-	geminiRequest := dto.GeminiEmbeddingRequest{
-		Content: dto.GeminiChatContent{
-			Parts: []dto.GeminiPart{
-				{
-					Text: inputs[0],
+	// process all inputs
+	geminiRequests := make([]map[string]interface{}, 0, len(inputs))
+	for _, input := range inputs {
+		geminiRequest := map[string]interface{}{
+			"model": fmt.Sprintf("models/%s", info.UpstreamModelName),
+			"content": dto.GeminiChatContent{
+				Parts: []dto.GeminiPart{
+					{
+						Text: input,
+					},
 				},
 			},
-		},
-	}
+		}
 
-	// set specific parameters for different models
-	// https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent
-	switch info.UpstreamModelName {
-	case "text-embedding-004":
-		// except embedding-001 supports setting `OutputDimensionality`
-		if request.Dimensions > 0 {
-			geminiRequest.OutputDimensionality = request.Dimensions
+		// set specific parameters for different models
+		// https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent
+		switch info.UpstreamModelName {
+		case "text-embedding-004", "gemini-embedding-exp-03-07", "gemini-embedding-001":
+			// Only newer models introduced after 2024 support OutputDimensionality
+			if request.Dimensions > 0 {
+				geminiRequest["outputDimensionality"] = request.Dimensions
+			}
 		}
+		geminiRequests = append(geminiRequests, geminiRequest)
 	}
 
-	return geminiRequest, nil
+	return map[string]interface{}{
+		"requests": geminiRequests,
+	}, nil
 }
 
 func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {

+ 11 - 9
relay/channel/gemini/relay-gemini.go

@@ -1071,7 +1071,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
 		return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
-	var geminiResponse dto.GeminiEmbeddingResponse
+	var geminiResponse dto.GeminiBatchEmbeddingResponse
 	if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
 		return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
@@ -1079,14 +1079,16 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
 	// convert to openai format response
 	openAIResponse := dto.OpenAIEmbeddingResponse{
 		Object: "list",
-		Data: []dto.OpenAIEmbeddingResponseItem{
-			{
-				Object:    "embedding",
-				Embedding: geminiResponse.Embedding.Values,
-				Index:     0,
-			},
-		},
-		Model: info.UpstreamModelName,
+		Data:   make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)),
+		Model:  info.UpstreamModelName,
+	}
+
+	for i, embedding := range geminiResponse.Embeddings {
+		openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{
+			Object:    "embedding",
+			Embedding: embedding.Values,
+			Index:     i,
+		})
 	}
 
 	// calculate usage

+ 1 - 2
relay/channel/zhipu_4v/adaptor.go

@@ -54,8 +54,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 
 func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
 	channel.SetupApiRequestHeader(info, c, req)
-	token := getZhipuToken(info.ApiKey)
-	req.Set("Authorization", token)
+	req.Set("Authorization", "Bearer "+info.ApiKey)
 	return nil
 }
 

+ 0 - 59
relay/channel/zhipu_4v/relay-zhipu_v4.go

@@ -1,69 +1,10 @@
 package zhipu_4v
 
 import (
-	"github.com/golang-jwt/jwt"
-	"one-api/common"
 	"one-api/dto"
 	"strings"
-	"sync"
-	"time"
 )
 
-// https://open.bigmodel.cn/doc/api#chatglm_std
-// chatglm_std, chatglm_lite
-// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke
-// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke
-
-var zhipuTokens sync.Map
-var expSeconds int64 = 24 * 3600
-
-func getZhipuToken(apikey string) string {
-	data, ok := zhipuTokens.Load(apikey)
-	if ok {
-		tokenData := data.(tokenData)
-		if time.Now().Before(tokenData.ExpiryTime) {
-			return tokenData.Token
-		}
-	}
-
-	split := strings.Split(apikey, ".")
-	if len(split) != 2 {
-		common.SysError("invalid zhipu key: " + apikey)
-		return ""
-	}
-
-	id := split[0]
-	secret := split[1]
-
-	expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6
-	expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second)
-
-	timestamp := time.Now().UnixNano() / 1e6
-
-	payload := jwt.MapClaims{
-		"api_key":   id,
-		"exp":       expMillis,
-		"timestamp": timestamp,
-	}
-
-	token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)
-
-	token.Header["alg"] = "HS256"
-	token.Header["sign_type"] = "SIGN"
-
-	tokenString, err := token.SignedString([]byte(secret))
-	if err != nil {
-		return ""
-	}
-
-	zhipuTokens.Store(apikey, tokenData{
-		Token:      tokenString,
-		ExpiryTime: expiryTime,
-	})
-
-	return tokenString
-}
-
 func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
 	messages := make([]dto.Message, 0, len(request.Messages))
 	for _, message := range request.Messages {

+ 24 - 6
relay/relay-text.go

@@ -140,10 +140,10 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 			returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
 		}
 	}()
-	includeUsage := false
+	includeUsage := true
 	// 判断用户是否需要返回使用情况
-	if textRequest.StreamOptions != nil && textRequest.StreamOptions.IncludeUsage {
-		includeUsage = true
+	if textRequest.StreamOptions != nil {
+		includeUsage = textRequest.StreamOptions.IncludeUsage
 	}
 
 	// 如果不支持StreamOptions,将StreamOptions设置为nil
@@ -158,9 +158,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		}
 	}
 
-	if includeUsage {
-		relayInfo.ShouldIncludeUsage = true
-	}
+	relayInfo.ShouldIncludeUsage = includeUsage
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
@@ -201,6 +199,26 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 					Content: relayInfo.ChannelSetting.SystemPrompt,
 				}
 				request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
+			} else if relayInfo.ChannelSetting.SystemPromptOverride {
+				common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+				// 如果有系统提示,且允许覆盖,则拼接到前面
+				for i, message := range request.Messages {
+					if message.Role == request.GetSystemRoleName() {
+						if message.IsStringContent() {
+							request.Messages[i].SetStringContent(relayInfo.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
+						} else {
+							contents := message.ParseContent()
+							contents = append([]dto.MediaContent{
+								{
+									Type: dto.ContentTypeText,
+									Text: relayInfo.ChannelSetting.SystemPrompt,
+								},
+							}, contents...)
+							request.Messages[i].Content = contents
+						}
+						break
+					}
+				}
 			}
 		}
 

+ 6 - 0
service/log_info_generate.go

@@ -28,6 +28,12 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
 		other["is_model_mapped"] = true
 		other["upstream_model_name"] = relayInfo.UpstreamModelName
 	}
+
+	isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride)
+	if isSystemPromptOverwritten {
+		other["is_system_prompt_overwritten"] = true
+	}
+
 	adminInfo := make(map[string]interface{})
 	adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
 	isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)

+ 330 - 315
web/src/components/common/ui/JSONEditor.js

@@ -1,4 +1,23 @@
-import React, { useState, useEffect, useCallback } from 'react';
+/*
+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, { useState, useEffect, useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
@@ -15,16 +34,22 @@ import {
   Row,
   Col,
   Divider,
+  Tooltip,
 } from '@douyinfe/semi-ui';
 import {
-  IconCode,
   IconPlus,
   IconDelete,
-  IconRefresh,
+  IconAlertTriangle,
 } from '@douyinfe/semi-icons';
 
 const { Text } = Typography;
 
+// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
+const generateUniqueId = (() => {
+  let counter = 0;
+  return () => `kv_${counter++}`;
+})();
+
 const JSONEditor = ({
   value = '',
   onChange,
@@ -43,24 +68,51 @@ const JSONEditor = ({
 }) => {
   const { t } = useTranslation();
 
-  // 初始化JSON数据
-  const [jsonData, setJsonData] = useState(() => {
-    // 初始化时解析JSON数据
+  // 将对象转换为键值对数组(包含唯一ID)
+  const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
+    if (!obj || typeof obj !== 'object') return [];
+
+    const entries = Object.entries(obj);
+    return entries.map(([key, value], index) => {
+      // 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
+      const prev = prevPairs[index];
+      const shouldReuseId = prev && prev.key === key;
+      return {
+        id: shouldReuseId ? prev.id : generateUniqueId(),
+        key,
+        value,
+      };
+    });
+  }, []);
+
+  // 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
+  const keyValueArrayToObject = useCallback((arr) => {
+    const result = {};
+    arr.forEach(item => {
+      if (item.key) {
+        result[item.key] = item.value;
+      }
+    });
+    return result;
+  }, []);
+
+  // 初始化键值对数组
+  const [keyValuePairs, setKeyValuePairs] = useState(() => {
     if (typeof value === 'string' && value.trim()) {
       try {
         const parsed = JSON.parse(value);
-        return parsed;
+        return objectToKeyValueArray(parsed);
       } catch (error) {
-        return {};
+        return [];
       }
     }
     if (typeof value === 'object' && value !== null) {
-      return value;
+      return objectToKeyValueArray(value);
     }
-    return {};
+    return [];
   });
 
-  // 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
+  // 手动模式下的本地文本缓冲
   const [manualText, setManualText] = useState(() => {
     if (typeof value === 'string') return value;
     if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
@@ -69,22 +121,38 @@ const JSONEditor = ({
 
   // 根据键数量决定默认编辑模式
   const [editMode, setEditMode] = useState(() => {
-    // 如果初始JSON数据的键数量大于10个,则默认使用手动模式
     if (typeof value === 'string' && value.trim()) {
       try {
         const parsed = JSON.parse(value);
         const keyCount = Object.keys(parsed).length;
         return keyCount > 10 ? 'manual' : 'visual';
       } catch (error) {
-        // JSON无效时默认显示手动编辑模式
         return 'manual';
       }
     }
     return 'visual';
   });
+
   const [jsonError, setJsonError] = useState('');
 
-  // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
+  // 计算重复的键
+  const duplicateKeys = useMemo(() => {
+    const keyCount = {};
+    const duplicates = new Set();
+
+    keyValuePairs.forEach(pair => {
+      if (pair.key) {
+        keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
+        if (keyCount[pair.key] > 1) {
+          duplicates.add(pair.key);
+        }
+      }
+    });
+
+    return duplicates;
+  }, [keyValuePairs]);
+
+  // 数据同步 - 当value变化时更新键值对数组
   useEffect(() => {
     try {
       let parsed = {};
@@ -93,16 +161,20 @@ const JSONEditor = ({
       } else if (typeof value === 'object' && value !== null) {
         parsed = value;
       }
-      setJsonData(parsed);
+
+      // 只在外部值真正改变时更新,避免循环更新
+      const currentObj = keyValueArrayToObject(keyValuePairs);
+      if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
+        setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
+      }
       setJsonError('');
     } catch (error) {
       console.log('JSON解析失败:', error.message);
       setJsonError(error.message);
-      // JSON格式错误时不更新jsonData
     }
   }, [value]);
 
-  // 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
+  // 外部 value 变化时,若不在手动模式,则同步手动文本
   useEffect(() => {
     if (editMode !== 'manual') {
       if (typeof value === 'string') setManualText(value);
@@ -112,45 +184,47 @@ const JSONEditor = ({
   }, [value, editMode]);
 
   // 处理可视化编辑的数据变化
-  const handleVisualChange = useCallback((newData) => {
-    setJsonData(newData);
+  const handleVisualChange = useCallback((newPairs) => {
+    setKeyValuePairs(newPairs);
+    const jsonObject = keyValueArrayToObject(newPairs);
+    const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
+
     setJsonError('');
-    const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
 
-    // 通过formApi设置值(如果提供的话)
+    // 通过formApi设置值
     if (formApi && field) {
       formApi.setValue(field, jsonString);
     }
 
     onChange?.(jsonString);
-  }, [onChange, formApi, field]);
+  }, [onChange, formApi, field, keyValueArrayToObject]);
 
-  // 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
+  // 处理手动编辑的数据变化
   const handleManualChange = useCallback((newValue) => {
     setManualText(newValue);
     if (newValue && newValue.trim()) {
       try {
-        JSON.parse(newValue);
+        const parsed = JSON.parse(newValue);
+        setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
         setJsonError('');
         onChange?.(newValue);
       } catch (error) {
         setJsonError(error.message);
-        // 无效 JSON 时不回传,避免外部值把输入重置
       }
     } else {
+      setKeyValuePairs([]);
       setJsonError('');
       onChange?.('');
     }
-  }, [onChange]);
+  }, [onChange, objectToKeyValueArray, keyValuePairs]);
 
   // 切换编辑模式
   const toggleEditMode = useCallback(() => {
     if (editMode === 'visual') {
-      // 从可视化模式切换到手动模式
-      setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
+      const jsonObject = keyValueArrayToObject(keyValuePairs);
+      setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2));
       setEditMode('manual');
     } else {
-      // 从手动模式切换到可视化模式,需要验证JSON
       try {
         let parsed = {};
         if (manualText && manualText.trim()) {
@@ -160,177 +234,74 @@ const JSONEditor = ({
         } else if (typeof value === 'object' && value !== null) {
           parsed = value;
         }
-        setJsonData(parsed);
+        setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
         setJsonError('');
         setEditMode('visual');
       } catch (error) {
         setJsonError(error.message);
-        // JSON格式错误时不切换模式
         return;
       }
     }
-  }, [editMode, value, manualText, jsonData]);
+  }, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]);
 
   // 添加键值对
   const addKeyValue = useCallback(() => {
-    const newData = { ...jsonData };
-    const keys = Object.keys(newData);
+    const newPairs = [...keyValuePairs];
+    const existingKeys = newPairs.map(p => p.key);
     let counter = 1;
     let newKey = `field_${counter}`;
-    while (newData.hasOwnProperty(newKey)) {
+    while (existingKeys.includes(newKey)) {
       counter += 1;
       newKey = `field_${counter}`;
     }
-    newData[newKey] = '';
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
+    newPairs.push({
+      id: generateUniqueId(),
+      key: newKey,
+      value: ''
+    });
+    handleVisualChange(newPairs);
+  }, [keyValuePairs, handleVisualChange]);
 
   // 删除键值对
-  const removeKeyValue = useCallback((keyToRemove) => {
-    const newData = { ...jsonData };
-    delete newData[keyToRemove];
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
+  const removeKeyValue = useCallback((id) => {
+    const newPairs = keyValuePairs.filter(pair => pair.id !== id);
+    handleVisualChange(newPairs);
+  }, [keyValuePairs, handleVisualChange]);
 
   // 更新键名
-  const updateKey = useCallback((oldKey, newKey) => {
-    if (oldKey === newKey || !newKey) return;
-    const newData = {};
-    Object.entries(jsonData).forEach(([k, v]) => {
-      if (k === oldKey) {
-        newData[newKey] = v;
-      } else {
-        newData[k] = v;
-      }
-    });
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
+  const updateKey = useCallback((id, newKey) => {
+    const newPairs = keyValuePairs.map(pair =>
+      pair.id === id ? { ...pair, key: newKey } : pair
+    );
+    handleVisualChange(newPairs);
+  }, [keyValuePairs, handleVisualChange]);
 
   // 更新值
-  const updateValue = useCallback((key, newValue) => {
-    const newData = { ...jsonData };
-    newData[key] = newValue;
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
+  const updateValue = useCallback((id, newValue) => {
+    const newPairs = keyValuePairs.map(pair =>
+      pair.id === id ? { ...pair, value: newValue } : pair
+    );
+    handleVisualChange(newPairs);
+  }, [keyValuePairs, handleVisualChange]);
 
   // 填入模板
   const fillTemplate = useCallback(() => {
     if (template) {
       const templateString = JSON.stringify(template, null, 2);
 
-      // 通过formApi设置值(如果提供的话)
       if (formApi && field) {
         formApi.setValue(field, templateString);
       }
 
-      // 同步内部与外部值,避免出现杂字符
       setManualText(templateString);
-      setJsonData(template);
+      setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
       onChange?.(templateString);
-
-      // 清除错误状态
       setJsonError('');
     }
-  }, [template, onChange, editMode, formApi, field]);
-
-  // 渲染键值对编辑器
-  const renderKeyValueEditor = () => {
-    if (typeof jsonData !== 'object' || jsonData === null) {
-      return (
-        <div className="text-center py-6 px-4">
-          <div className="text-gray-400 mb-2">
-            <IconCode size={32} />
-          </div>
-          <Text type="tertiary" className="text-gray-500 text-sm">
-            {t('无效的JSON数据,请检查格式')}
-          </Text>
-        </div>
-      );
-    }
-    const entries = Object.entries(jsonData);
-
-    return (
-      <div className="space-y-1">
-        {entries.length === 0 && (
-          <div className="text-center py-6 px-4">
-            <Text type="tertiary" className="text-gray-500 text-sm">
-              {t('暂无数据,点击下方按钮添加键值对')}
-            </Text>
-          </div>
-        )}
-
-        {entries.map(([key, value], index) => (
-          <Row key={index} gutter={8} align="middle">
-            <Col span={6}>
-              <Input
-                placeholder={t('键名')}
-                value={key}
-                onChange={(newKey) => updateKey(key, newKey)}
-              />
-            </Col>
-            <Col span={16}>
-              {renderValueInput(key, value)}
-            </Col>
-            <Col span={2}>
-              <Button
-                icon={<IconDelete />}
-                type="danger"
-                theme="borderless"
-                onClick={() => removeKeyValue(key)}
-                style={{ width: '100%' }}
-              />
-            </Col>
-          </Row>
-        ))}
-
-        <div className="mt-2 flex justify-center">
-          <Button
-            icon={<IconPlus />}
-            type="primary"
-            theme="outline"
-            onClick={addKeyValue}
-          >
-            {t('添加键值对')}
-          </Button>
-        </div>
-      </div>
-    );
-  };
-
-  // 添加嵌套对象
-  const flattenObject = useCallback((parentKey) => {
-    const newData = { ...jsonData };
-    let primitive = '';
-    const obj = newData[parentKey];
-    if (obj && typeof obj === 'object') {
-      const firstKey = Object.keys(obj)[0];
-      if (firstKey !== undefined) {
-        const firstVal = obj[firstKey];
-        if (typeof firstVal !== 'object') primitive = firstVal;
-      }
-    }
-    newData[parentKey] = primitive;
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
-
-  const addNestedObject = useCallback((parentKey) => {
-    const newData = { ...jsonData };
-    if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
-      newData[parentKey] = {};
-    }
-    const existingKeys = Object.keys(newData[parentKey]);
-    let counter = 1;
-    let newKey = `field_${counter}`;
-    while (newData[parentKey].hasOwnProperty(newKey)) {
-      counter += 1;
-      newKey = `field_${counter}`;
-    }
-    newData[parentKey][newKey] = '';
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
+  }, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
 
-  // 渲染参数值输入控件(支持嵌套)
-  const renderValueInput = (key, value) => {
+  // 渲染值输入控件(支持嵌套)
+  const renderValueInput = (pairId, value) => {
     const valueType = typeof value;
 
     if (valueType === 'boolean') {
@@ -338,7 +309,7 @@ const JSONEditor = ({
         <div className="flex items-center">
           <Switch
             checked={value}
-            onChange={(newValue) => updateValue(key, newValue)}
+            onChange={(newValue) => updateValue(pairId, newValue)}
           />
           <Text type="tertiary" className="ml-2">
             {value ? t('true') : t('false')}
@@ -351,195 +322,239 @@ const JSONEditor = ({
       return (
         <InputNumber
           value={value}
-          onChange={(newValue) => updateValue(key, newValue)}
+          onChange={(newValue) => updateValue(pairId, newValue)}
           style={{ width: '100%' }}
-          step={key === 'temperature' ? 0.1 : 1}
-          precision={key === 'temperature' ? 2 : 0}
           placeholder={t('输入数字')}
         />
       );
     }
 
     if (valueType === 'object' && value !== null) {
-      // 渲染嵌套对象
-      const entries = Object.entries(value);
+      // 简化嵌套对象的处理,使用TextArea
       return (
-        <Card className="!rounded-2xl">
-          {entries.length === 0 && (
-            <Text type="tertiary" className="text-gray-500 text-xs">
-              {t('空对象,点击下方加号添加字段')}
+        <TextArea
+          rows={2}
+          value={JSON.stringify(value, null, 2)}
+          onChange={(txt) => {
+            try {
+              const obj = txt.trim() ? JSON.parse(txt) : {};
+              updateValue(pairId, obj);
+            } catch {
+              // 忽略解析错误
+            }
+          }}
+          placeholder={t('输入JSON对象')}
+        />
+      );
+    }
+
+    // 字符串或其他原始类型
+    return (
+      <Input
+        placeholder={t('参数值')}
+        value={String(value)}
+        onChange={(newValue) => {
+          let convertedValue = newValue;
+          if (newValue === 'true') convertedValue = true;
+          else if (newValue === 'false') convertedValue = false;
+          else if (!isNaN(newValue) && newValue !== '') {
+            const num = Number(newValue);
+            // 检查是否为整数
+            if (Number.isInteger(num)) {
+              convertedValue = num;
+            }
+          }
+          updateValue(pairId, convertedValue);
+        }}
+      />
+    );
+  };
+
+  // 渲染键值对编辑器
+  const renderKeyValueEditor = () => {
+    return (
+      <div className="space-y-1">
+        {/* 重复键警告 */}
+        {duplicateKeys.size > 0 && (
+          <Banner
+            type="warning"
+            icon={<IconAlertTriangle />}
+            description={
+              <div>
+                <Text strong>{t('存在重复的键名:')}</Text>
+                <Text>{Array.from(duplicateKeys).join(', ')}</Text>
+                <br />
+                <Text type="tertiary" size="small">
+                  {t('注意:JSON中重复的键只会保留最后一个同名键的值')}
+                </Text>
+              </div>
+            }
+            className="mb-3"
+          />
+        )}
+
+        {keyValuePairs.length === 0 && (
+          <div className="text-center py-6 px-4">
+            <Text type="tertiary" className="text-gray-500 text-sm">
+              {t('暂无数据,点击下方按钮添加键值对')}
             </Text>
-          )}
-
-          {entries.map(([nestedKey, nestedValue], index) => (
-            <Row key={index} gutter={4} align="middle" className="mb-1">
-              <Col span={8}>
-                <Input
-                  size="small"
-                  placeholder={t('键名')}
-                  value={nestedKey}
-                  onChange={(newKey) => {
-                    const newData = { ...jsonData };
-                    const oldValue = newData[key][nestedKey];
-                    delete newData[key][nestedKey];
-                    newData[key][newKey] = oldValue;
-                    handleVisualChange(newData);
-                  }}
-                />
-              </Col>
-              <Col span={14}>
-                {typeof nestedValue === 'object' && nestedValue !== null ? (
-                  <TextArea
-                    size="small"
-                    rows={2}
-                    value={JSON.stringify(nestedValue, null, 2)}
-                    onChange={(txt) => {
-                      try {
-                        const obj = txt.trim() ? JSON.parse(txt) : {};
-                        const newData = { ...jsonData };
-                        newData[key][nestedKey] = obj;
-                        handleVisualChange(newData);
-                      } catch {
-                        // ignore parse error
-                      }
-                    }}
-                  />
-                ) : (
+          </div>
+        )}
+
+        {keyValuePairs.map((pair, index) => {
+          const isDuplicate = duplicateKeys.has(pair.key);
+          const isLastDuplicate = isDuplicate &&
+            keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
+
+          return (
+            <Row key={pair.id} gutter={8} align="middle">
+              <Col span={6}>
+                <div className="relative">
                   <Input
-                    size="small"
-                    placeholder={t('值')}
-                    value={String(nestedValue)}
-                    onChange={(newValue) => {
-                      const newData = { ...jsonData };
-                      let convertedValue = newValue;
-                      if (newValue === 'true') convertedValue = true;
-                      else if (newValue === 'false') convertedValue = false;
-                      else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
-                        convertedValue = Number(newValue);
-                      }
-                      newData[key][nestedKey] = convertedValue;
-                      handleVisualChange(newData);
-                    }}
+                    placeholder={t('键名')}
+                    value={pair.key}
+                    onChange={(newKey) => updateKey(pair.id, newKey)}
+                    status={isDuplicate ? 'warning' : undefined}
                   />
-                )}
+                  {isDuplicate && (
+                    <Tooltip
+                      content={
+                        isLastDuplicate
+                          ? t('这是重复键中的最后一个,其值将被使用')
+                          : t('重复的键名,此值将被后面的同名键覆盖')
+                      }
+                    >
+                      <IconAlertTriangle
+                        className="absolute right-2 top-1/2 transform -translate-y-1/2"
+                        style={{
+                          color: isLastDuplicate ? '#ff7d00' : '#faad14',
+                          fontSize: '14px'
+                        }}
+                      />
+                    </Tooltip>
+                  )}
+                </div>
+              </Col>
+              <Col span={16}>
+                {renderValueInput(pair.id, pair.value)}
               </Col>
               <Col span={2}>
                 <Button
-                  size="small"
                   icon={<IconDelete />}
                   type="danger"
                   theme="borderless"
-                  onClick={() => {
-                    const newData = { ...jsonData };
-                    delete newData[key][nestedKey];
-                    handleVisualChange(newData);
-                  }}
+                  onClick={() => removeKeyValue(pair.id)}
                   style={{ width: '100%' }}
                 />
               </Col>
             </Row>
-          ))}
-
-          <div className="flex justify-center mt-1 gap-2">
-            <Button
-              size="small"
-              icon={<IconPlus />}
-              type="tertiary"
-              onClick={() => addNestedObject(key)}
-            >
-              {t('添加字段')}
-            </Button>
-            <Button
-              size="small"
-              icon={<IconRefresh />}
-              type="tertiary"
-              onClick={() => flattenObject(key)}
-            >
-              {t('转换为值')}
-            </Button>
-          </div>
-        </Card>
-      );
-    }
+          );
+        })}
 
-    // 字符串或其他原始类型
-    return (
-      <div className="flex items-center gap-1">
-        <Input
-          placeholder={t('参数值')}
-          value={String(value)}
-          onChange={(newValue) => {
-            let convertedValue = newValue;
-            if (newValue === 'true') convertedValue = true;
-            else if (newValue === 'false') convertedValue = false;
-            else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
-              convertedValue = Number(newValue);
-            }
-            updateValue(key, convertedValue);
-          }}
-        />
-        <Button
-          icon={<IconPlus />}
-          type="tertiary"
-          onClick={() => {
-            // 将当前值转换为对象
-            const newData = { ...jsonData };
-            newData[key] = { '1': value };
-            handleVisualChange(newData);
-          }}
-          title={t('转换为对象')}
-        />
+        <div className="mt-2 flex justify-center">
+          <Button
+            icon={<IconPlus />}
+            type="primary"
+            theme="outline"
+            onClick={addKeyValue}
+          >
+            {t('添加键值对')}
+          </Button>
+        </div>
       </div>
     );
   };
 
-  // 渲染区域编辑器(特殊格式)
+  // 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
   const renderRegionEditor = () => {
-    const entries = Object.entries(jsonData);
-    const defaultEntry = entries.find(([key]) => key === 'default');
-    const modelEntries = entries.filter(([key]) => key !== 'default');
+    const defaultPair = keyValuePairs.find(pair => pair.key === 'default');
+    const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default');
 
     return (
       <div className="space-y-2">
+        {/* 重复键警告 */}
+        {duplicateKeys.size > 0 && (
+          <Banner
+            type="warning"
+            icon={<IconAlertTriangle />}
+            description={
+              <div>
+                <Text strong>{t('存在重复的键名:')}</Text>
+                <Text>{Array.from(duplicateKeys).join(', ')}</Text>
+                <br />
+                <Text type="tertiary" size="small">
+                  {t('注意:JSON中重复的键只会保留最后一个同名键的值')}
+                </Text>
+              </div>
+            }
+            className="mb-3"
+          />
+        )}
+
         {/* 默认区域 */}
         <Form.Slot label={t('默认区域')}>
           <Input
             placeholder={t('默认区域,如: us-central1')}
-            value={defaultEntry ? defaultEntry[1] : ''}
-            onChange={(value) => updateValue('default', value)}
+            value={defaultPair ? defaultPair.value : ''}
+            onChange={(value) => {
+              if (defaultPair) {
+                updateValue(defaultPair.id, value);
+              } else {
+                const newPairs = [...keyValuePairs, {
+                  id: generateUniqueId(),
+                  key: 'default',
+                  value: value
+                }];
+                handleVisualChange(newPairs);
+              }
+            }}
           />
         </Form.Slot>
 
         {/* 模型专用区域 */}
         <Form.Slot label={t('模型专用区域')}>
           <div>
-            {modelEntries.map(([modelName, region], index) => (
-              <Row key={index} gutter={8} align="middle" className="mb-2">
-                <Col span={10}>
-                  <Input
-                    placeholder={t('模型名称')}
-                    value={modelName}
-                    onChange={(newKey) => updateKey(modelName, newKey)}
-                  />
-                </Col>
-                <Col span={12}>
-                  <Input
-                    placeholder={t('区域')}
-                    value={region}
-                    onChange={(newValue) => updateValue(modelName, newValue)}
-                  />
-                </Col>
-                <Col span={2}>
-                  <Button
-                    icon={<IconDelete />}
-                    type="danger"
-                    theme="borderless"
-                    onClick={() => removeKeyValue(modelName)}
-                    style={{ width: '100%' }}
-                  />
-                </Col>
-              </Row>
-            ))}
+            {modelPairs.map((pair) => {
+              const isDuplicate = duplicateKeys.has(pair.key);
+              return (
+                <Row key={pair.id} gutter={8} align="middle" className="mb-2">
+                  <Col span={10}>
+                    <div className="relative">
+                      <Input
+                        placeholder={t('模型名称')}
+                        value={pair.key}
+                        onChange={(newKey) => updateKey(pair.id, newKey)}
+                        status={isDuplicate ? 'warning' : undefined}
+                      />
+                      {isDuplicate && (
+                        <Tooltip content={t('重复的键名')}>
+                          <IconAlertTriangle
+                            className="absolute right-2 top-1/2 transform -translate-y-1/2"
+                            style={{ color: '#faad14', fontSize: '14px' }}
+                          />
+                        </Tooltip>
+                      )}
+                    </div>
+                  </Col>
+                  <Col span={12}>
+                    <Input
+                      placeholder={t('区域')}
+                      value={pair.value}
+                      onChange={(newValue) => updateValue(pair.id, newValue)}
+                    />
+                  </Col>
+                  <Col span={2}>
+                    <Button
+                      icon={<IconDelete />}
+                      type="danger"
+                      theme="borderless"
+                      onClick={() => removeKeyValue(pair.id)}
+                      style={{ width: '100%' }}
+                    />
+                  </Col>
+                </Row>
+              );
+            })}
 
             <div className="mt-2 flex justify-center">
               <Button
@@ -666,4 +681,4 @@ const JSONEditor = ({
   );
 };
 
-export default JSONEditor; 
+export default JSONEditor;

+ 1 - 1
web/src/components/layout/HeaderBar.js

@@ -458,7 +458,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   };
 
   return (
-    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
+    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
       <NoticeModal
         visible={noticeVisible}
         onClose={handleNoticeClose}

+ 6 - 6
web/src/components/layout/SiderBar.js

@@ -128,18 +128,18 @@ const SiderBar = ({ onNavigate = () => { } }) => {
 
   const adminItems = useMemo(
     () => [
-      {
-        text: t('模型管理'),
-        itemKey: 'models',
-        to: '/console/models',
-        className: isAdmin() ? '' : 'tableHiddle',
-      },
       {
         text: t('渠道管理'),
         itemKey: 'channel',
         to: '/channel',
         className: isAdmin() ? '' : 'tableHiddle',
       },
+      {
+        text: t('模型管理'),
+        itemKey: 'models',
+        to: '/console/models',
+        className: isAdmin() ? '' : 'tableHiddle',
+      },
       {
         text: t('兑换码管理'),
         itemKey: 'redemption',

+ 17 - 0
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -131,6 +131,7 @@ const EditChannelModal = (props) => {
     proxy: '',
     pass_through_body_enabled: false,
     system_prompt: '',
+    system_prompt_override: false,
   };
   const [batch, setBatch] = useState(false);
   const [multiToSingle, setMultiToSingle] = useState(false);
@@ -340,12 +341,15 @@ const EditChannelModal = (props) => {
           data.proxy = parsedSettings.proxy || '';
           data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false;
           data.system_prompt = parsedSettings.system_prompt || '';
+          data.system_prompt_override = parsedSettings.system_prompt_override || false;
         } catch (error) {
           console.error('解析渠道设置失败:', error);
           data.force_format = false;
           data.thinking_to_content = false;
           data.proxy = '';
           data.pass_through_body_enabled = false;
+          data.system_prompt = '';
+          data.system_prompt_override = false;
         }
       } else {
         data.force_format = false;
@@ -353,6 +357,7 @@ const EditChannelModal = (props) => {
         data.proxy = '';
         data.pass_through_body_enabled = false;
         data.system_prompt = '';
+        data.system_prompt_override = false;
       }
 
       setInputs(data);
@@ -372,6 +377,7 @@ const EditChannelModal = (props) => {
         proxy: data.proxy,
         pass_through_body_enabled: data.pass_through_body_enabled,
         system_prompt: data.system_prompt,
+        system_prompt_override: data.system_prompt_override || false,
       });
       // console.log(data);
     } else {
@@ -573,6 +579,7 @@ const EditChannelModal = (props) => {
         proxy: '',
         pass_through_body_enabled: false,
         system_prompt: '',
+        system_prompt_override: false,
       });
       // 重置密钥模式状态
       setKeyMode('append');
@@ -721,6 +728,7 @@ const EditChannelModal = (props) => {
       proxy: localInputs.proxy || '',
       pass_through_body_enabled: localInputs.pass_through_body_enabled || false,
       system_prompt: localInputs.system_prompt || '',
+      system_prompt_override: localInputs.system_prompt_override || false,
     };
     localInputs.setting = JSON.stringify(channelExtraSettings);
 
@@ -730,6 +738,7 @@ const EditChannelModal = (props) => {
     delete localInputs.proxy;
     delete localInputs.pass_through_body_enabled;
     delete localInputs.system_prompt;
+    delete localInputs.system_prompt_override;
 
     let res;
     localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
@@ -1722,6 +1731,14 @@ const EditChannelModal = (props) => {
                     showClear
                     extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')}
                   />
+                  <Form.Switch
+                    field='system_prompt_override'
+                    label={t('系统提示词拼接')}
+                    checkedText={t('开')}
+                    uncheckedText={t('关')}
+                    onChange={(value) => handleChannelSettingsChange('system_prompt_override', value)}
+                    extraText={t('如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面')}
+                  />
                 </Card>
               </div>
             </Spin>

+ 2 - 1
web/src/components/table/model-pricing/layout/PricingSidebar.jsx

@@ -40,6 +40,7 @@ const PricingSidebar = ({
   setViewMode,
   filterGroup,
   setFilterGroup,
+  handleGroupClick,
   filterQuotaType,
   setFilterQuotaType,
   filterEndpointType,
@@ -126,7 +127,7 @@ const PricingSidebar = ({
 
       <PricingGroups
         filterGroup={filterGroup}
-        setFilterGroup={setFilterGroup}
+        setFilterGroup={handleGroupClick}
         usableGroup={categoryProps.usableGroup}
         groupRatio={categoryProps.groupRatio}
         models={groupCountModels}

+ 4 - 0
web/src/components/table/model-pricing/view/card/PricingCardView.jsx

@@ -25,6 +25,7 @@ import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } f
 import PricingCardSkeleton from './PricingCardSkeleton';
 import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
 import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
+import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
 
 const CARD_STYLES = {
   container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
@@ -59,6 +60,7 @@ const PricingCardView = ({
   const startIndex = (currentPage - 1) * pageSize;
   const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize);
   const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
+  const isMobile = useIsMobile();
 
   const handleCheckboxChange = (model, checked) => {
     if (!setSelectedRowKeys) return;
@@ -311,6 +313,8 @@ const PricingCardView = ({
             total={filteredModels.length}
             showSizeChanger={true}
             pageSizeOptions={[10, 20, 50, 100]}
+            size={isMobile ? 'small' : 'default'}
+            showQuickJumper={isMobile}
             onPageChange={(page) => setCurrentPage(page)}
             onPageSizeChange={(size) => {
               setPageSize(size);

+ 3 - 0
web/src/components/table/models/modals/EditModelModal.jsx

@@ -42,7 +42,10 @@ const { Text, Title } = Typography;
 // Example endpoint template for quick fill
 const ENDPOINT_TEMPLATE = {
   openai: { path: '/v1/chat/completions', method: 'POST' },
+  'openai-response': { path: '/v1/responses', method: 'POST' },
   anthropic: { path: '/v1/messages', method: 'POST' },
+  gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
+  'jina-rerank': { path: '/rerank', method: 'POST' },
   'image-generation': { path: '/v1/images/generations', method: 'POST' },
 };
 

+ 3 - 0
web/src/components/table/models/modals/EditPrefillGroupModal.jsx

@@ -46,7 +46,10 @@ const { Text, Title } = Typography;
 // Example endpoint template for quick fill
 const ENDPOINT_TEMPLATE = {
   openai: { path: '/v1/chat/completions', method: 'POST' },
+  'openai-response': { path: '/v1/responses', method: 'POST' },
   anthropic: { path: '/v1/messages', method: 'POST' },
+  gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
+  'jina-rerank': { path: '/rerank', method: 'POST' },
   'image-generation': { path: '/v1/images/generations', method: 'POST' },
 };
 

+ 8 - 1
web/src/components/table/task-logs/TaskLogsColumnDefs.js

@@ -211,6 +211,7 @@ export const getTaskLogsColumns = ({
   copyText,
   openContentModal,
   isAdminUser,
+  openVideoModal,
 }) => {
   return [
     {
@@ -342,7 +343,13 @@ export const getTaskLogsColumns = ({
         const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
         if (isSuccess && isVideoTask && isUrl) {
           return (
-            <a href={text} target="_blank" rel="noopener noreferrer">
+            <a
+              href="#"
+              onClick={e => {
+                e.preventDefault();
+                openVideoModal(text);
+              }}
+            >
               {t('点击预览视频')}
             </a>
           );

+ 3 - 0
web/src/components/table/task-logs/TaskLogsTable.jsx

@@ -39,6 +39,7 @@ const TaskLogsTable = (taskLogsData) => {
     handlePageSizeChange,
     copyText,
     openContentModal,
+    openVideoModal,
     isAdminUser,
     t,
     COLUMN_KEYS,
@@ -51,6 +52,7 @@ const TaskLogsTable = (taskLogsData) => {
       COLUMN_KEYS,
       copyText,
       openContentModal,
+      openVideoModal,
       isAdminUser,
     });
   }, [
@@ -58,6 +60,7 @@ const TaskLogsTable = (taskLogsData) => {
     COLUMN_KEYS,
     copyText,
     openContentModal,
+    openVideoModal,
     isAdminUser,
   ]);
 

+ 8 - 1
web/src/components/table/task-logs/index.jsx

@@ -37,7 +37,14 @@ const TaskLogsPage = () => {
     <>
       {/* Modals */}
       <ColumnSelectorModal {...taskLogsData} />
-      <ContentModal {...taskLogsData} />
+      <ContentModal {...taskLogsData} isVideo={false} />
+      {/* 新增:视频预览弹窗 */}
+      <ContentModal
+        isModalOpen={taskLogsData.isVideoModalOpen}
+        setIsModalOpen={taskLogsData.setIsVideoModalOpen}
+        modalContent={taskLogsData.videoUrl}
+        isVideo={true}
+      />
 
       <Layout>
         <CardPro

+ 6 - 1
web/src/components/table/task-logs/modals/ContentModal.jsx

@@ -24,6 +24,7 @@ const ContentModal = ({
   isModalOpen,
   setIsModalOpen,
   modalContent,
+  isVideo,
 }) => {
   return (
     <Modal
@@ -34,7 +35,11 @@ const ContentModal = ({
       bodyStyle={{ height: '400px', overflow: 'auto' }}
       width={800}
     >
-      <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
+      {isVideo ? (
+        <video src={modalContent} controls style={{ width: '100%' }} autoPlay />
+      ) : (
+        <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
+      )}
     </Modal>
   );
 };

+ 114 - 96
web/src/components/table/tokens/TokensColumnDefs.js

@@ -28,7 +28,8 @@ import {
   Avatar,
   Tooltip,
   Progress,
-  Switch,
+  Popover,
+  Typography,
   Input,
   Modal
 } from '@douyinfe/semi-ui';
@@ -46,21 +47,22 @@ import {
   IconEyeClosed,
 } from '@douyinfe/semi-icons';
 
+// progress color helper
+const getProgressColor = (pct) => {
+  if (pct === 100) return 'var(--semi-color-success)';
+  if (pct <= 10) return 'var(--semi-color-danger)';
+  if (pct <= 30) return 'var(--semi-color-warning)';
+  return undefined;
+};
+
 // Render functions
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;
 }
 
-// Render status column with switch and progress bar
-const renderStatus = (text, record, manageToken, t) => {
+// Render status column only (no usage)
+const renderStatus = (text, record, t) => {
   const enabled = text === 1;
-  const handleToggle = (checked) => {
-    if (checked) {
-      manageToken(record.id, 'enable', record);
-    } else {
-      manageToken(record.id, 'disable', record);
-    }
-  };
 
   let tagColor = 'black';
   let tagText = t('未知状态');
@@ -78,69 +80,11 @@ const renderStatus = (text, record, manageToken, t) => {
     tagText = t('已耗尽');
   }
 
-  const used = parseInt(record.used_quota) || 0;
-  const remain = parseInt(record.remain_quota) || 0;
-  const total = used + remain;
-  const percent = total > 0 ? (remain / total) * 100 : 0;
-
-  const getProgressColor = (pct) => {
-    if (pct === 100) return 'var(--semi-color-success)';
-    if (pct <= 10) return 'var(--semi-color-danger)';
-    if (pct <= 30) return 'var(--semi-color-warning)';
-    return undefined;
-  };
-
-  const quotaSuffix = record.unlimited_quota ? (
-    <div className='text-xs'>{t('无限额度')}</div>
-  ) : (
-    <div className='flex flex-col items-end'>
-      <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
-      <Progress
-        percent={percent}
-        stroke={getProgressColor(percent)}
-        aria-label='quota usage'
-        format={() => `${percent.toFixed(0)}%`}
-        style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
-      />
-    </div>
-  );
-
-  const content = (
-    <Tag
-      color={tagColor}
-      shape='circle'
-      size='large'
-      prefixIcon={
-        <Switch
-          size='small'
-          checked={enabled}
-          onChange={handleToggle}
-          aria-label='token status switch'
-        />
-      }
-      suffixIcon={quotaSuffix}
-    >
+  return (
+    <Tag color={tagColor} shape='circle' size='small'>
       {tagText}
     </Tag>
   );
-
-  const tooltipContent = record.unlimited_quota ? (
-    <div className='text-xs'>
-      <div>{t('已用额度')}: {renderQuota(used)}</div>
-    </div>
-  ) : (
-    <div className='text-xs'>
-      <div>{t('已用额度')}: {renderQuota(used)}</div>
-      <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
-      <div>{t('总额度')}: {renderQuota(total)}</div>
-    </div>
-  );
-
-  return (
-    <Tooltip content={tooltipContent}>
-      {content}
-    </Tooltip>
-  );
 };
 
 // Render group column
@@ -292,35 +236,81 @@ const renderAllowIps = (text, t) => {
   return <Space wrap>{ipTags}</Space>;
 };
 
+// Render separate quota usage column
+const renderQuotaUsage = (text, record, t) => {
+  const { Paragraph } = Typography;
+  const used = parseInt(record.used_quota) || 0;
+  const remain = parseInt(record.remain_quota) || 0;
+  const total = used + remain;
+  if (record.unlimited_quota) {
+    const popoverContent = (
+      <div className='text-xs p-2'>
+        <Paragraph copyable={{ content: renderQuota(used) }}>
+          {t('已用额度')}: {renderQuota(used)}
+        </Paragraph>
+      </div>
+    );
+    return (
+      <Popover content={popoverContent} position='top'>
+        <Tag color='white' shape='circle'>
+          {t('无限额度')}
+        </Tag>
+      </Popover>
+    );
+  }
+  const percent = total > 0 ? (remain / total) * 100 : 0;
+  const popoverContent = (
+    <div className='text-xs p-2'>
+      <Paragraph copyable={{ content: renderQuota(used) }}>
+        {t('已用额度')}: {renderQuota(used)}
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(remain) }}>
+        {t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(total) }}>
+        {t('总额度')}: {renderQuota(total)}
+      </Paragraph>
+    </div>
+  );
+  return (
+    <Popover content={popoverContent} position='top'>
+      <Tag color='white' shape='circle'>
+        <div className='flex flex-col items-end'>
+          <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
+          <Progress
+            percent={percent}
+            stroke={getProgressColor(percent)}
+            aria-label='quota usage'
+            format={() => `${percent.toFixed(0)}%`}
+            style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
+          />
+        </div>
+      </Tag>
+    </Popover>
+  );
+};
+
 // Render operations column
 const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
-  let chats = localStorage.getItem('chats');
   let chatsArray = [];
-  let shouldUseCustom = true;
-
-  if (shouldUseCustom) {
-    try {
-      chats = JSON.parse(chats);
-      if (Array.isArray(chats)) {
-        for (let i = 0; i < chats.length; i++) {
-          let chat = {};
-          chat.node = 'item';
-          for (let key in chats[i]) {
-            if (chats[i].hasOwnProperty(key)) {
-              chat.key = i;
-              chat.name = key;
-              chat.onClick = () => {
-                onOpenLink(key, chats[i][key], record);
-              };
-            }
-          }
-          chatsArray.push(chat);
-        }
+  try {
+    const raw = localStorage.getItem('chats');
+    const parsed = JSON.parse(raw);
+    if (Array.isArray(parsed)) {
+      for (let i = 0; i < parsed.length; i++) {
+        const item = parsed[i];
+        const name = Object.keys(item)[0];
+        if (!name) continue;
+        chatsArray.push({
+          node: 'item',
+          key: i,
+          name,
+          onClick: () => onOpenLink(name, item[name], record),
+        });
       }
-    } catch (e) {
-      console.log(e);
-      showError(t('聊天链接配置错误,请联系管理员'));
     }
+  } catch (_) {
+    showError(t('聊天链接配置错误,请联系管理员'));
   }
 
   return (
@@ -338,7 +328,7 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
             } else {
               onOpenLink(
                 'default',
-                chats[0][Object.keys(chats[0])[0]],
+                chatsArray[0].name ? (parsed => parsed)(localStorage.getItem('chats')) : '',
                 record,
               );
             }
@@ -359,6 +349,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
         </Dropdown>
       </SplitButtonGroup>
 
+      {record.status === 1 ? (
+        <Button
+          type='danger'
+          size="small"
+          onClick={async () => {
+            await manageToken(record.id, 'disable', record);
+            await refresh();
+          }}
+        >
+          {t('禁用')}
+        </Button>
+      ) : (
+        <Button
+          size="small"
+          onClick={async () => {
+            await manageToken(record.id, 'enable', record);
+            await refresh();
+          }}
+        >
+          {t('启用')}
+        </Button>
+      )}
+
       <Button
         type='tertiary'
         size="small"
@@ -412,7 +425,12 @@ export const getTokensColumns = ({
       title: t('状态'),
       dataIndex: 'status',
       key: 'status',
-      render: (text, record) => renderStatus(text, record, manageToken, t),
+      render: (text, record) => renderStatus(text, record, t),
+    },
+    {
+      title: t('剩余额度/总额度'),
+      key: 'quota_usage',
+      render: (text, record) => renderQuotaUsage(text, record, t),
     },
     {
       title: t('分组'),

+ 240 - 4
web/src/components/table/tokens/index.jsx

@@ -17,7 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-import React from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import { Notification, Button, Space, Toast, Typography, Select } from '@douyinfe/semi-ui';
+import { API, showError, getModelCategories, selectFilter } from '../../../helpers';
 import CardPro from '../../common/ui/CardPro';
 import TokensTable from './TokensTable.jsx';
 import TokensActions from './TokensActions.jsx';
@@ -28,9 +30,243 @@ import { useTokensData } from '../../../hooks/tokens/useTokensData';
 import { useIsMobile } from '../../../hooks/common/useIsMobile';
 import { createCardProPagination } from '../../../helpers/utils';
 
-const TokensPage = () => {
-  const tokensData = useTokensData();
+function TokensPage() {
+  // Define the function first, then pass it into the hook to avoid TDZ errors
+  const openFluentNotificationRef = useRef(null);
+  const tokensData = useTokensData((key) => openFluentNotificationRef.current?.(key));
   const isMobile = useIsMobile();
+  const latestRef = useRef({ tokens: [], selectedKeys: [], t: (k) => k, selectedModel: '', prefillKey: '' });
+  const [modelOptions, setModelOptions] = useState([]);
+  const [selectedModel, setSelectedModel] = useState('');
+  const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
+  const [prefillKey, setPrefillKey] = useState('');
+
+  // Keep latest data for handlers inside notifications
+  useEffect(() => {
+    latestRef.current = {
+      tokens: tokensData.tokens,
+      selectedKeys: tokensData.selectedKeys,
+      t: tokensData.t,
+      selectedModel,
+      prefillKey,
+    };
+  }, [tokensData.tokens, tokensData.selectedKeys, tokensData.t, selectedModel, prefillKey]);
+
+  const loadModels = async () => {
+    try {
+      const res = await API.get('/api/user/models');
+      const { success, message, data } = res.data || {};
+      if (success) {
+        const categories = getModelCategories(tokensData.t);
+        const options = (data || []).map((model) => {
+          let icon = null;
+          for (const [key, category] of Object.entries(categories)) {
+            if (key !== 'all' && category.filter({ model_name: model })) {
+              icon = category.icon;
+              break;
+            }
+          }
+          return {
+            label: (
+              <span className="flex items-center gap-1">
+                {icon}
+                {model}
+              </span>
+            ),
+            value: model,
+          };
+        });
+        setModelOptions(options);
+      } else {
+        showError(tokensData.t(message));
+      }
+    } catch (e) {
+      showError(e.message || 'Failed to load models');
+    }
+  };
+
+  function openFluentNotification(key) {
+    const { t } = latestRef.current;
+    const SUPPRESS_KEY = 'fluent_notify_suppressed';
+    if (localStorage.getItem(SUPPRESS_KEY) === '1') return;
+    const container = document.getElementById('fluent-new-api-container');
+    if (!container) {
+      Toast.warning(t('未检测到 Fluent 容器,请确认扩展已启用'));
+      return;
+    }
+    setPrefillKey(key || '');
+    setFluentNoticeOpen(true);
+    if (modelOptions.length === 0) {
+      // fire-and-forget; a later effect will refresh the notice content
+      loadModels()
+    }
+    Notification.info({
+      id: 'fluent-detected',
+      title: t('检测到 Fluent(流畅阅读)'),
+      content: (
+        <div>
+          <div style={{ marginBottom: 8 }}>
+            {prefillKey
+              ? t('已检测到 Fluent 扩展,已从操作中指定密钥,将使用该密钥进行填充。请选择模型后继续。')
+              : t('已检测到 Fluent 扩展,请选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
+          </div>
+          <div style={{ marginBottom: 8 }}>
+            <Select
+              placeholder={t('请选择模型')}
+              optionList={modelOptions}
+              onChange={setSelectedModel}
+              filter={selectFilter}
+              style={{ width: 320 }}
+              showClear
+              searchable
+              emptyContent={t('暂无数据')}
+            />
+          </div>
+          <Space>
+            <Button theme="solid" type="primary" onClick={handlePrefillToFluent}>
+              {t('一键填充到 Fluent')}
+            </Button>
+            <Button type="warning" onClick={() => {
+              localStorage.setItem(SUPPRESS_KEY, '1');
+              Notification.close('fluent-detected');
+              Toast.info(t('已关闭后续提醒'));
+            }}>
+              {t('不再提醒')}
+            </Button>
+            <Button type="tertiary" onClick={() => Notification.close('fluent-detected')}>
+              {t('关闭')}
+            </Button>
+          </Space>
+        </div>
+      ),
+      duration: 0,
+    });
+  }
+  // assign after definition so hook callback can call it safely
+  openFluentNotificationRef.current = openFluentNotification;
+
+  // Prefill to Fluent handler
+  const handlePrefillToFluent = () => {
+    const { tokens, selectedKeys, t, selectedModel: chosenModel, prefillKey: overrideKey } = latestRef.current;
+    const container = document.getElementById('fluent-new-api-container');
+    if (!container) {
+      Toast.error(t('未检测到 Fluent 容器'));
+      return;
+    }
+
+    if (!chosenModel) {
+      Toast.warning(t('请选择模型'));
+      return;
+    }
+
+    let status = localStorage.getItem('status');
+    let serverAddress = '';
+    if (status) {
+      try {
+        status = JSON.parse(status);
+        serverAddress = status.server_address || '';
+      } catch (_) { }
+    }
+    if (!serverAddress) serverAddress = window.location.origin;
+
+    let apiKeyToUse = '';
+    if (overrideKey) {
+      apiKeyToUse = 'sk-' + overrideKey;
+    } else {
+      const token = (selectedKeys && selectedKeys.length === 1)
+        ? selectedKeys[0]
+        : (tokens && tokens.length > 0 ? tokens[0] : null);
+      if (!token) {
+        Toast.warning(t('没有可用令牌用于填充'));
+        return;
+      }
+      apiKeyToUse = 'sk-' + token.key;
+    }
+
+    const payload = {
+      id: 'new-api',
+      baseUrl: serverAddress,
+      apiKey: apiKeyToUse,
+      model: chosenModel,
+    };
+
+    container.dispatchEvent(new CustomEvent('fluent:prefill', { detail: payload }));
+    Toast.success(t('已发送到 Fluent'));
+    Notification.close('fluent-detected');
+  };
+
+  // Show notification when Fluent container is available
+  useEffect(() => {
+    const onAppeared = () => {
+      openFluentNotification();
+    };
+    const onRemoved = () => {
+      setFluentNoticeOpen(false);
+      Notification.close('fluent-detected');
+    };
+
+    window.addEventListener('fluent-container:appeared', onAppeared);
+    window.addEventListener('fluent-container:removed', onRemoved);
+    return () => {
+      window.removeEventListener('fluent-container:appeared', onAppeared);
+      window.removeEventListener('fluent-container:removed', onRemoved);
+    };
+  }, []);
+
+  // When modelOptions or language changes while the notice is open, refresh the content
+  useEffect(() => {
+    if (fluentNoticeOpen) {
+      openFluentNotification();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
+  
+  useEffect(() => {
+    const selector = '#fluent-new-api-container';
+    const root = document.body || document.documentElement;
+
+    const existing = document.querySelector(selector);
+    if (existing) {
+      console.log('Fluent container detected (initial):', existing);
+      window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: existing }));
+    }
+
+    const isOrContainsTarget = (node) => {
+      if (!(node && node.nodeType === 1)) return false;
+      if (node.id === 'fluent-new-api-container') return true;
+      return typeof node.querySelector === 'function' && !!node.querySelector(selector);
+    };
+
+    const observer = new MutationObserver((mutations) => {
+      for (const m of mutations) {
+        // appeared
+        for (const added of m.addedNodes) {
+          if (isOrContainsTarget(added)) {
+            const el = document.querySelector(selector);
+            if (el) {
+              console.log('Fluent container appeared:', el);
+              window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: el }));
+            }
+            break;
+          }
+        }
+        // removed
+        for (const removed of m.removedNodes) {
+          if (isOrContainsTarget(removed)) {
+            const elNow = document.querySelector(selector);
+            if (!elNow) {
+              console.log('Fluent container removed');
+              window.dispatchEvent(new CustomEvent('fluent-container:removed'));
+            }
+            break;
+          }
+        }
+      }
+    });
+
+    observer.observe(root, { childList: true, subtree: true });
+    return () => observer.disconnect();
+  }, []);
 
   const {
     // Edit state
@@ -119,6 +355,6 @@ const TokensPage = () => {
       </CardPro>
     </>
   );
-};
+}
 
 export default TokensPage; 

+ 13 - 4
web/src/components/table/usage-logs/UsageLogsColumnDefs.js

@@ -34,7 +34,6 @@ import {
   getLogOther,
   renderModelTag,
   renderClaudeLogContent,
-  renderClaudeModelPriceSimple,
   renderLogContent,
   renderModelPriceSimple,
   renderAudioModelPrice,
@@ -538,7 +537,7 @@ export const getLogsColumns = ({
           );
         }
         let content = other?.claude
-          ? renderClaudeModelPriceSimple(
+          ? renderModelPriceSimple(
             other.model_ratio,
             other.model_price,
             other.group_ratio,
@@ -547,6 +546,10 @@ export const getLogsColumns = ({
             other.cache_ratio || 1.0,
             other.cache_creation_tokens || 0,
             other.cache_creation_ratio || 1.0,
+            false,
+            1.0,
+            other?.is_system_prompt_overwritten,
+            'claude'
           )
           : renderModelPriceSimple(
             other.model_ratio,
@@ -555,13 +558,19 @@ export const getLogsColumns = ({
             other?.user_group_ratio,
             other.cache_tokens || 0,
             other.cache_ratio || 1.0,
+            0,
+            1.0,
+            false,
+            1.0,
+            other?.is_system_prompt_overwritten,
+            'openai'
           );
         return (
           <Typography.Paragraph
             ellipsis={{
-              rows: 2,
+              rows: 3,
             }}
-            style={{ maxWidth: 240 }}
+            style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
           >
             {content}
           </Typography.Paragraph>

+ 69 - 56
web/src/components/table/users/UsersColumnDefs.js

@@ -24,7 +24,8 @@ import {
   Tag,
   Tooltip,
   Progress,
-  Switch,
+  Popover,
+  Typography,
 } from '@douyinfe/semi-ui';
 import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
 
@@ -89,7 +90,6 @@ const renderUsername = (text, record) => {
  * Render user statistics
  */
 const renderStatistics = (text, record, showEnableDisableModal, t) => {
-  const enabled = record.status === 1;
   const isDeleted = record.DeletedAt !== null;
 
   // Determine tag text & color like original status column
@@ -100,60 +100,17 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
     tagText = t('已注销');
   } else if (record.status === 1) {
     tagColor = 'green';
-    tagText = t('已激活');
+    tagText = t('已启用');
   } else if (record.status === 2) {
     tagColor = 'red';
-    tagText = t('已禁');
+    tagText = t('已禁');
   }
 
-  const handleToggle = (checked) => {
-    if (checked) {
-      showEnableDisableModal(record, 'enable');
-    } else {
-      showEnableDisableModal(record, 'disable');
-    }
-  };
-
-  const used = parseInt(record.used_quota) || 0;
-  const remain = parseInt(record.quota) || 0;
-  const total = used + remain;
-  const percent = total > 0 ? (remain / total) * 100 : 0;
-
-  const getProgressColor = (pct) => {
-    if (pct === 100) return 'var(--semi-color-success)';
-    if (pct <= 10) return 'var(--semi-color-danger)';
-    if (pct <= 30) return 'var(--semi-color-warning)';
-    return undefined;
-  };
-
-  const quotaSuffix = (
-    <div className='flex flex-col items-end'>
-      <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
-      <Progress
-        percent={percent}
-        stroke={getProgressColor(percent)}
-        aria-label='quota usage'
-        format={() => `${percent.toFixed(0)}%`}
-        style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
-      />
-    </div>
-  );
-
   const content = (
     <Tag
       color={tagColor}
       shape='circle'
-      size='large'
-      prefixIcon={
-        <Switch
-          size='small'
-          checked={enabled}
-          onChange={handleToggle}
-          disabled={isDeleted}
-          aria-label='user status switch'
-        />
-      }
-      suffixIcon={quotaSuffix}
+      size='small'
     >
       {tagText}
     </Tag>
@@ -161,9 +118,6 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
 
   const tooltipContent = (
     <div className='text-xs'>
-      <div>{t('已用额度')}: {renderQuota(used)}</div>
-      <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
-      <div>{t('总额度')}: {renderQuota(total)}</div>
       <div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
     </div>
   );
@@ -175,6 +129,43 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
   );
 };
 
+// Render separate quota usage column
+const renderQuotaUsage = (text, record, t) => {
+  const { Paragraph } = Typography;
+  const used = parseInt(record.used_quota) || 0;
+  const remain = parseInt(record.quota) || 0;
+  const total = used + remain;
+  const percent = total > 0 ? (remain / total) * 100 : 0;
+  const popoverContent = (
+    <div className='text-xs p-2'>
+      <Paragraph copyable={{ content: renderQuota(used) }}>
+        {t('已用额度')}: {renderQuota(used)}
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(remain) }}>
+        {t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+      </Paragraph>
+      <Paragraph copyable={{ content: renderQuota(total) }}>
+        {t('总额度')}: {renderQuota(total)}
+      </Paragraph>
+    </div>
+  );
+  return (
+    <Popover content={popoverContent} position='top'>
+      <Tag color='white' shape='circle'>
+        <div className='flex flex-col items-end'>
+          <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
+          <Progress
+            percent={percent}
+            aria-label='quota usage'
+            format={() => `${percent.toFixed(0)}%`}
+            style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
+          />
+        </div>
+      </Tag>
+    </Popover>
+  );
+};
+
 /**
  * Render invite information
  */
@@ -204,6 +195,7 @@ const renderOperations = (text, record, {
   setShowEditUser,
   showPromoteModal,
   showDemoteModal,
+  showEnableDisableModal,
   showDeleteModal,
   t
 }) => {
@@ -213,6 +205,22 @@ const renderOperations = (text, record, {
 
   return (
     <Space>
+      {record.status === 1 ? (
+        <Button
+          type='danger'
+          size="small"
+          onClick={() => showEnableDisableModal(record, 'disable')}
+        >
+          {t('禁用')}
+        </Button>
+      ) : (
+        <Button
+          size="small"
+          onClick={() => showEnableDisableModal(record, 'enable')}
+        >
+          {t('启用')}
+        </Button>
+      )}
       <Button
         type='tertiary'
         size="small"
@@ -270,6 +278,16 @@ export const getUsersColumns = ({
       dataIndex: 'username',
       render: (text, record) => renderUsername(text, record),
     },
+    {
+      title: t('状态'),
+      dataIndex: 'info',
+      render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
+    },
+    {
+      title: t('剩余额度/总额度'),
+      key: 'quota_usage',
+      render: (text, record) => renderQuotaUsage(text, record, t),
+    },
     {
       title: t('分组'),
       dataIndex: 'group',
@@ -284,11 +302,6 @@ export const getUsersColumns = ({
         return <div>{renderRole(text, t)}</div>;
       },
     },
-    {
-      title: t('状态'),
-      dataIndex: 'info',
-      render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
-    },
     {
       title: t('邀请信息'),
       dataIndex: 'invite',

+ 1 - 1
web/src/constants/channel.constants.js

@@ -81,7 +81,7 @@ export const CHANNEL_OPTIONS = [
   {
     value: 16,
     color: 'violet',
-    label: '智谱 ChatGLM',
+    label: '智谱 ChatGLM(已经弃用,请使用智谱 GLM-4V)',
   },
   {
     value: 26,

+ 8 - 6
web/src/helpers/api.js

@@ -215,14 +215,16 @@ export async function getOAuthState() {
 export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
   const state = await getOAuthState();
   if (!state) return;
-  const redirect_uri = `${window.location.origin}/oauth/oidc`;
-  const response_type = 'code';
-  const scope = 'openid profile email';
-  const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
+  const url = new URL(auth_url);
+  url.searchParams.set('client_id', client_id);
+  url.searchParams.set('redirect_uri', `${window.location.origin}/oauth/oidc`);
+  url.searchParams.set('response_type', 'code');
+  url.searchParams.set('scope', 'openid profile email');
+  url.searchParams.set('state', state);
   if (openInNewTab) {
-    window.open(url);
+    window.open(url.toString(), '_blank');
   } else {
-    window.location.href = url;
+    window.location.href = url.toString();
   }
 }
 

+ 83 - 87
web/src/helpers/render.js

@@ -953,6 +953,71 @@ function getEffectiveRatio(groupRatio, user_group_ratio) {
   };
 }
 
+// Shared core for simple price rendering (used by OpenAI-like and Claude-like variants)
+function renderPriceSimpleCore({
+  modelRatio,
+  modelPrice = -1,
+  groupRatio,
+  user_group_ratio,
+  cacheTokens = 0,
+  cacheRatio = 1.0,
+  cacheCreationTokens = 0,
+  cacheCreationRatio = 1.0,
+  image = false,
+  imageRatio = 1.0,
+  isSystemPromptOverride = false
+}) {
+  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
+    groupRatio,
+    user_group_ratio,
+  );
+  const finalGroupRatio = effectiveGroupRatio;
+
+  if (modelPrice !== -1) {
+    return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
+      price: modelPrice,
+      ratioType: ratioLabel,
+      ratio: finalGroupRatio,
+    });
+  }
+
+  const parts = [];
+  // base: model ratio
+  parts.push(i18next.t('模型: {{ratio}}'));
+
+  // cache part (label differs when with image)
+  if (cacheTokens !== 0) {
+    parts.push(i18next.t('缓存: {{cacheRatio}}'));
+  }
+
+  // cache creation part (Claude specific if passed)
+  if (cacheCreationTokens !== 0) {
+    parts.push(i18next.t('缓存创建: {{cacheCreationRatio}}'));
+  }
+
+  // image part
+  if (image) {
+    parts.push(i18next.t('图片输入: {{imageRatio}}'));
+  }
+
+  parts.push(`{{ratioType}}: {{groupRatio}}`);
+
+  let result = i18next.t(parts.join(' * '), {
+    ratio: modelRatio,
+    ratioType: ratioLabel,
+    groupRatio: finalGroupRatio,
+    cacheRatio: cacheRatio,
+    cacheCreationRatio: cacheCreationRatio,
+    imageRatio: imageRatio,
+  })
+
+  if (isSystemPromptOverride) {
+    result += '\n\r' + i18next.t('系统提示覆盖');
+  }
+
+  return result;
+}
+
 export function renderModelPrice(
   inputTokens,
   completionTokens,
@@ -1245,56 +1310,26 @@ export function renderModelPriceSimple(
   user_group_ratio,
   cacheTokens = 0,
   cacheRatio = 1.0,
+  cacheCreationTokens = 0,
+  cacheCreationRatio = 1.0,
   image = false,
   imageRatio = 1.0,
+  isSystemPromptOverride = false,
+  provider = 'openai',
 ) {
-  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
-  groupRatio = effectiveGroupRatio;
-  if (modelPrice !== -1) {
-    return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
-      price: modelPrice,
-      ratioType: ratioLabel,
-      ratio: groupRatio,
-    });
-  } else {
-    if (image && cacheTokens !== 0) {
-      return i18next.t(
-        '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存倍率: {{cacheRatio}} * 图片输入倍率: {{imageRatio}}',
-        {
-          ratio: modelRatio,
-          ratioType: ratioLabel,
-          groupRatio: groupRatio,
-          cacheRatio: cacheRatio,
-          imageRatio: imageRatio,
-        },
-      );
-    } else if (image) {
-      return i18next.t(
-        '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 图片输入倍率: {{imageRatio}}',
-        {
-          ratio: modelRatio,
-          ratioType: ratioLabel,
-          groupRatio: groupRatio,
-          imageRatio: imageRatio,
-        },
-      );
-    } else if (cacheTokens !== 0) {
-      return i18next.t(
-        '模型: {{ratio}} * 分组: {{groupRatio}} * 缓存: {{cacheRatio}}',
-        {
-          ratio: modelRatio,
-          groupRatio: groupRatio,
-          cacheRatio: cacheRatio,
-        },
-      );
-    } else {
-      return i18next.t('模型: {{ratio}} * {{ratioType}}:{{groupRatio}}', {
-        ratio: modelRatio,
-        ratioType: ratioLabel,
-        groupRatio: groupRatio,
-      });
-    }
-  }
+  return renderPriceSimpleCore({
+    modelRatio,
+    modelPrice,
+    groupRatio,
+    user_group_ratio,
+    cacheTokens,
+    cacheRatio,
+    cacheCreationTokens,
+    cacheCreationRatio,
+    image,
+    imageRatio,
+    isSystemPromptOverride
+  });
 }
 
 export function renderAudioModelPrice(
@@ -1635,46 +1670,7 @@ export function renderClaudeLogContent(
   }
 }
 
-export function renderClaudeModelPriceSimple(
-  modelRatio,
-  modelPrice = -1,
-  groupRatio,
-  user_group_ratio,
-  cacheTokens = 0,
-  cacheRatio = 1.0,
-  cacheCreationTokens = 0,
-  cacheCreationRatio = 1.0,
-) {
-  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
-  groupRatio = effectiveGroupRatio;
-
-  if (modelPrice !== -1) {
-    return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
-      price: modelPrice,
-      ratioType: ratioLabel,
-      ratio: groupRatio,
-    });
-  } else {
-    if (cacheTokens !== 0 || cacheCreationTokens !== 0) {
-      return i18next.t(
-        '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存: {{cacheRatio}}',
-        {
-          ratio: modelRatio,
-          ratioType: ratioLabel,
-          groupRatio: groupRatio,
-          cacheRatio: cacheRatio,
-          cacheCreationRatio: cacheCreationRatio,
-        },
-      );
-    } else {
-      return i18next.t('模型: {{ratio}} * {{ratioType}}: {{groupRatio}}', {
-        ratio: modelRatio,
-        ratioType: ratioLabel,
-        groupRatio: groupRatio,
-      });
-    }
-  }
-}
+// 已统一至 renderModelPriceSimple,若仍有遗留引用,请改为传入 provider='claude'
 
 /**
  * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。

+ 16 - 0
web/src/hooks/task-logs/useTaskLogsData.js

@@ -65,6 +65,10 @@ export const useTaskLogsData = () => {
   const [isModalOpen, setIsModalOpen] = useState(false);
   const [modalContent, setModalContent] = useState('');
 
+  // 新增:视频预览弹窗状态
+  const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
+  const [videoUrl, setVideoUrl] = useState('');
+
   // Form state
   const [formApi, setFormApi] = useState(null);
   let now = new Date();
@@ -250,6 +254,12 @@ export const useTaskLogsData = () => {
     setIsModalOpen(true);
   };
 
+  // 新增:打开视频预览弹窗
+  const openVideoModal = (url) => {
+    setVideoUrl(url);
+    setIsVideoModalOpen(true);
+  };
+
   // Initialize data
   useEffect(() => {
     const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
@@ -271,6 +281,11 @@ export const useTaskLogsData = () => {
     setIsModalOpen,
     modalContent,
 
+    // 新增:视频弹窗状态
+    isVideoModalOpen,
+    setIsVideoModalOpen,
+    videoUrl,
+
     // Form state
     formApi,
     setFormApi,
@@ -297,6 +312,7 @@ export const useTaskLogsData = () => {
     refresh,
     copyText,
     openContentModal,
+    openVideoModal, // 新增
     enrichLogs,
     syncPageData,
 

+ 5 - 1
web/src/hooks/tokens/useTokensData.js

@@ -29,7 +29,7 @@ import {
 import { ITEMS_PER_PAGE } from '../../constants';
 import { useTableCompactMode } from '../common/useTableCompactMode';
 
-export const useTokensData = () => {
+export const useTokensData = (openFluentNotification) => {
   const { t } = useTranslation();
 
   // Basic state
@@ -121,6 +121,10 @@ export const useTokensData = () => {
 
   // Open link function for chat integrations
   const onOpenLink = async (type, url, record) => {
+    if (url && url.startsWith('fluent')) {
+      openFluentNotification(record.key);
+      return;
+    }
     let status = localStorage.getItem('status');
     let serverAddress = '';
     if (status) {

+ 7 - 1
web/src/i18n/locales/en.json

@@ -1804,5 +1804,11 @@
   "已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}",
   "新获取的模型": "New models",
   "已有的模型": "Existing models",
-  "搜索模型": "Search models"
+  "搜索模型": "Search models",
+  "缓存: {{cacheRatio}}": "Cache: {{cacheRatio}}",
+  "缓存创建: {{cacheCreationRatio}}": "Cache creation: {{cacheCreationRatio}}",
+  "图片输入: {{imageRatio}}": "Image input: {{imageRatio}}",
+  "系统提示覆盖": "System prompt override",
+  "模型: {{ratio}}": "Model: {{ratio}}",
+  "专属倍率": "Exclusive group ratio"
 }

+ 1 - 1
web/src/index.css

@@ -655,7 +655,7 @@ html:not(.dark) .blur-ball-teal {
 }
 
 .pricing-search-header {
-  padding: 16px 24px;
+  padding: 1rem;
   border-bottom: 1px solid var(--semi-color-border);
   background-color: var(--semi-color-bg-0);
   flex-shrink: 0;