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

This commit is contained in:
RedwindA
2025-08-09 18:05:11 +08:00
37 changed files with 1095 additions and 697 deletions

View File

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

View File

@@ -145,6 +145,22 @@ func UpdateMidjourneyTaskBulk() {
buttonStr, _ := json.Marshal(responseItem.Buttons) buttonStr, _ := json.Marshal(responseItem.Buttons)
task.Buttons = string(buttonStr) 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 shouldReturnQuota := false
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") { if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason) 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 != "" { if oldTask.Progress != "100%" && newTask.FailReason != "" {
return true 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 return false
} }

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,22 @@ var DB *gorm.DB
var LOG_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 { func createRootAccountIfNeed() error {
var user User var user User
//if user.Status != common.UserStatusEnabled { //if user.Status != common.UserStatusEnabled {
@@ -235,6 +251,9 @@ func InitLogDB() (err error) {
} }
func migrateDB() error { func migrateDB() error {
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
dropIndexIfExists("models", "uk_model_name")
dropIndexIfExists("vendors", "uk_vendor_name")
if !common.UsingPostgreSQL { if !common.UsingPostgreSQL {
return migrateDBFast() return migrateDBFast()
} }
@@ -264,6 +283,10 @@ func migrateDB() error {
} }
func migrateDBFast() error { func migrateDBFast() error {
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
dropIndexIfExists("models", "uk_model_name")
dropIndexIfExists("vendors", "uk_vendor_name")
var wg sync.WaitGroup var wg sync.WaitGroup
migrations := []struct { migrations := []struct {

View File

@@ -36,7 +36,7 @@ type BoundChannel struct {
type Model struct { type Model struct {
Id int `json:"id"` 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"` Description string `json:"description,omitempty" gorm:"type:text"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"` VendorID int `json:"vendor_id,omitempty" gorm:"index"`
@@ -44,7 +44,7 @@ type Model struct {
Status int `json:"status" gorm:"default:1"` Status int `json:"status" 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"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`

View File

@@ -14,13 +14,13 @@ import (
type Vendor struct { type Vendor struct {
Id int `json:"id"` 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"` Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Status int `json:"status" gorm:"default:1"` Status int `json:"status" 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"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
} }
// Insert 创建新的供应商记录 // Insert 创建新的供应商记录

View File

@@ -119,6 +119,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
action = "batchEmbedContents" action = "batchEmbedContents"
} }
return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil 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" action := "generateContent"
@@ -163,29 +164,35 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
if len(inputs) == 0 { if len(inputs) == 0 {
return nil, errors.New("input is empty") return nil, errors.New("input is empty")
} }
// process all inputs
// only process the first input geminiRequests := make([]map[string]interface{}, 0, len(inputs))
geminiRequest := dto.GeminiEmbeddingRequest{ for _, input := range inputs {
Content: dto.GeminiChatContent{ geminiRequest := map[string]interface{}{
Parts: []dto.GeminiPart{ "model": fmt.Sprintf("models/%s", info.UpstreamModelName),
{ "content": dto.GeminiChatContent{
Text: inputs[0], 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) { func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {

View File

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

View File

@@ -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 { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req) channel.SetupApiRequestHeader(info, c, req)
token := getZhipuToken(info.ApiKey) req.Set("Authorization", "Bearer "+info.ApiKey)
req.Set("Authorization", token)
return nil return nil
} }

View File

@@ -1,69 +1,10 @@
package zhipu_4v package zhipu_4v
import ( import (
"github.com/golang-jwt/jwt"
"one-api/common"
"one-api/dto" "one-api/dto"
"strings" "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 { func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
messages := make([]dto.Message, 0, len(request.Messages)) messages := make([]dto.Message, 0, len(request.Messages))
for _, message := range request.Messages { for _, message := range request.Messages {

View File

@@ -140,10 +140,10 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
} }
}() }()
includeUsage := false includeUsage := true
// 判断用户是否需要返回使用情况 // 判断用户是否需要返回使用情况
if textRequest.StreamOptions != nil && textRequest.StreamOptions.IncludeUsage { if textRequest.StreamOptions != nil {
includeUsage = true includeUsage = textRequest.StreamOptions.IncludeUsage
} }
// 如果不支持StreamOptions将StreamOptions设置为nil // 如果不支持StreamOptions将StreamOptions设置为nil
@@ -158,9 +158,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
} }
} }
if includeUsage { relayInfo.ShouldIncludeUsage = includeUsage
relayInfo.ShouldIncludeUsage = true
}
adaptor := GetAdaptor(relayInfo.ApiType) adaptor := GetAdaptor(relayInfo.ApiType)
if adaptor == nil { if adaptor == nil {
@@ -201,6 +199,26 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
Content: relayInfo.ChannelSetting.SystemPrompt, Content: relayInfo.ChannelSetting.SystemPrompt,
} }
request.Messages = append([]dto.Message{systemMessage}, request.Messages...) 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
}
}
} }
} }

View File

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

View File

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

View File

@@ -458,7 +458,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
}; };
return ( 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 <NoticeModal
visible={noticeVisible} visible={noticeVisible}
onClose={handleNoticeClose} onClose={handleNoticeClose}

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,10 @@ const { Text, Title } = Typography;
// Example endpoint template for quick fill // Example endpoint template for quick fill
const ENDPOINT_TEMPLATE = { const ENDPOINT_TEMPLATE = {
openai: { path: '/v1/chat/completions', method: 'POST' }, openai: { path: '/v1/chat/completions', method: 'POST' },
'openai-response': { path: '/v1/responses', method: 'POST' },
anthropic: { path: '/v1/messages', 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' }, 'image-generation': { path: '/v1/images/generations', method: 'POST' },
}; };

View File

@@ -46,7 +46,10 @@ const { Text, Title } = Typography;
// Example endpoint template for quick fill // Example endpoint template for quick fill
const ENDPOINT_TEMPLATE = { const ENDPOINT_TEMPLATE = {
openai: { path: '/v1/chat/completions', method: 'POST' }, openai: { path: '/v1/chat/completions', method: 'POST' },
'openai-response': { path: '/v1/responses', method: 'POST' },
anthropic: { path: '/v1/messages', 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' }, 'image-generation': { path: '/v1/images/generations', method: 'POST' },
}; };

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ const ContentModal = ({
isModalOpen, isModalOpen,
setIsModalOpen, setIsModalOpen,
modalContent, modalContent,
isVideo,
}) => { }) => {
return ( return (
<Modal <Modal
@@ -34,7 +35,11 @@ const ContentModal = ({
bodyStyle={{ height: '400px', overflow: 'auto' }} bodyStyle={{ height: '400px', overflow: 'auto' }}
width={800} 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> </Modal>
); );
}; };

View File

@@ -28,7 +28,8 @@ import {
Avatar, Avatar,
Tooltip, Tooltip,
Progress, Progress,
Switch, Popover,
Typography,
Input, Input,
Modal Modal
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
@@ -46,21 +47,22 @@ import {
IconEyeClosed, IconEyeClosed,
} from '@douyinfe/semi-icons'; } 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 // Render functions
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>; return <>{timestamp2string(timestamp)}</>;
} }
// Render status column with switch and progress bar // Render status column only (no usage)
const renderStatus = (text, record, manageToken, t) => { const renderStatus = (text, record, t) => {
const enabled = text === 1; const enabled = text === 1;
const handleToggle = (checked) => {
if (checked) {
manageToken(record.id, 'enable', record);
} else {
manageToken(record.id, 'disable', record);
}
};
let tagColor = 'black'; let tagColor = 'black';
let tagText = t('未知状态'); let tagText = t('未知状态');
@@ -78,69 +80,11 @@ const renderStatus = (text, record, manageToken, t) => {
tagText = t('已耗尽'); tagText = t('已耗尽');
} }
const used = parseInt(record.used_quota) || 0; return (
const remain = parseInt(record.remain_quota) || 0; <Tag color={tagColor} shape='circle' size='small'>
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}
>
{tagText} {tagText}
</Tag> </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 // Render group column
@@ -292,35 +236,81 @@ const renderAllowIps = (text, t) => {
return <Space wrap>{ipTags}</Space>; 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 // Render operations column
const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => { const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
let chats = localStorage.getItem('chats');
let chatsArray = []; let chatsArray = [];
let shouldUseCustom = true; try {
const raw = localStorage.getItem('chats');
if (shouldUseCustom) { const parsed = JSON.parse(raw);
try { if (Array.isArray(parsed)) {
chats = JSON.parse(chats); for (let i = 0; i < parsed.length; i++) {
if (Array.isArray(chats)) { const item = parsed[i];
for (let i = 0; i < chats.length; i++) { const name = Object.keys(item)[0];
let chat = {}; if (!name) continue;
chat.node = 'item'; chatsArray.push({
for (let key in chats[i]) { node: 'item',
if (chats[i].hasOwnProperty(key)) { key: i,
chat.key = i; name,
chat.name = key; onClick: () => onOpenLink(name, item[name], record),
chat.onClick = () => { });
onOpenLink(key, chats[i][key], record);
};
}
}
chatsArray.push(chat);
}
} }
} catch (e) {
console.log(e);
showError(t('聊天链接配置错误,请联系管理员'));
} }
} catch (_) {
showError(t('聊天链接配置错误,请联系管理员'));
} }
return ( return (
@@ -338,7 +328,7 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
} else { } else {
onOpenLink( onOpenLink(
'default', 'default',
chats[0][Object.keys(chats[0])[0]], chatsArray[0].name ? (parsed => parsed)(localStorage.getItem('chats')) : '',
record, record,
); );
} }
@@ -359,6 +349,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
</Dropdown> </Dropdown>
</SplitButtonGroup> </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 <Button
type='tertiary' type='tertiary'
size="small" size="small"
@@ -412,7 +425,12 @@ export const getTokensColumns = ({
title: t('状态'), title: t('状态'),
dataIndex: 'status', dataIndex: 'status',
key: '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('分组'), title: t('分组'),

View File

@@ -17,7 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
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 CardPro from '../../common/ui/CardPro';
import TokensTable from './TokensTable.jsx'; import TokensTable from './TokensTable.jsx';
import TokensActions from './TokensActions.jsx'; import TokensActions from './TokensActions.jsx';
@@ -28,9 +30,243 @@ import { useTokensData } from '../../../hooks/tokens/useTokensData';
import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils'; import { createCardProPagination } from '../../../helpers/utils';
const TokensPage = () => { function TokensPage() {
const tokensData = useTokensData(); // 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 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 { const {
// Edit state // Edit state
@@ -119,6 +355,6 @@ const TokensPage = () => {
</CardPro> </CardPro>
</> </>
); );
}; }
export default TokensPage; export default TokensPage;

View File

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

View File

@@ -24,7 +24,8 @@ import {
Tag, Tag,
Tooltip, Tooltip,
Progress, Progress,
Switch, Popover,
Typography,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { renderGroup, renderNumber, renderQuota } from '../../../helpers'; import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
@@ -89,7 +90,6 @@ const renderUsername = (text, record) => {
* Render user statistics * Render user statistics
*/ */
const renderStatistics = (text, record, showEnableDisableModal, t) => { const renderStatistics = (text, record, showEnableDisableModal, t) => {
const enabled = record.status === 1;
const isDeleted = record.DeletedAt !== null; const isDeleted = record.DeletedAt !== null;
// Determine tag text & color like original status column // Determine tag text & color like original status column
@@ -100,60 +100,17 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
tagText = t('已注销'); tagText = t('已注销');
} else if (record.status === 1) { } else if (record.status === 1) {
tagColor = 'green'; tagColor = 'green';
tagText = t('已激活'); tagText = t('已启用');
} else if (record.status === 2) { } else if (record.status === 2) {
tagColor = 'red'; 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 = ( const content = (
<Tag <Tag
color={tagColor} color={tagColor}
shape='circle' shape='circle'
size='large' size='small'
prefixIcon={
<Switch
size='small'
checked={enabled}
onChange={handleToggle}
disabled={isDeleted}
aria-label='user status switch'
/>
}
suffixIcon={quotaSuffix}
> >
{tagText} {tagText}
</Tag> </Tag>
@@ -161,9 +118,6 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
const tooltipContent = ( const tooltipContent = (
<div className='text-xs'> <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>{t('调用次数')}: {renderNumber(record.request_count)}</div>
</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 * Render invite information
*/ */
@@ -204,6 +195,7 @@ const renderOperations = (text, record, {
setShowEditUser, setShowEditUser,
showPromoteModal, showPromoteModal,
showDemoteModal, showDemoteModal,
showEnableDisableModal,
showDeleteModal, showDeleteModal,
t t
}) => { }) => {
@@ -213,6 +205,22 @@ const renderOperations = (text, record, {
return ( return (
<Space> <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 <Button
type='tertiary' type='tertiary'
size="small" size="small"
@@ -270,6 +278,16 @@ export const getUsersColumns = ({
dataIndex: 'username', dataIndex: 'username',
render: (text, record) => renderUsername(text, record), 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('分组'), title: t('分组'),
dataIndex: 'group', dataIndex: 'group',
@@ -284,11 +302,6 @@ export const getUsersColumns = ({
return <div>{renderRole(text, t)}</div>; return <div>{renderRole(text, t)}</div>;
}, },
}, },
{
title: t('状态'),
dataIndex: 'info',
render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
},
{ {
title: t('邀请信息'), title: t('邀请信息'),
dataIndex: 'invite', dataIndex: 'invite',

View File

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

View File

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

View File

@@ -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( export function renderModelPrice(
inputTokens, inputTokens,
completionTokens, completionTokens,
@@ -1245,56 +1310,26 @@ export function renderModelPriceSimple(
user_group_ratio, user_group_ratio,
cacheTokens = 0, cacheTokens = 0,
cacheRatio = 1.0, cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
image = false, image = false,
imageRatio = 1.0, imageRatio = 1.0,
isSystemPromptOverride = false,
provider = 'openai',
) { ) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio); return renderPriceSimpleCore({
groupRatio = effectiveGroupRatio; modelRatio,
if (modelPrice !== -1) { modelPrice,
return i18next.t('价格:${{price}} * {{ratioType}}{{ratio}}', { groupRatio,
price: modelPrice, user_group_ratio,
ratioType: ratioLabel, cacheTokens,
ratio: groupRatio, cacheRatio,
}); cacheCreationTokens,
} else { cacheCreationRatio,
if (image && cacheTokens !== 0) { image,
return i18next.t( imageRatio,
'模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存倍率: {{cacheRatio}} * 图片输入倍率: {{imageRatio}}', isSystemPromptOverride
{ });
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,
});
}
}
} }
export function renderAudioModelPrice( export function renderAudioModelPrice(
@@ -1635,46 +1670,7 @@ export function renderClaudeLogContent(
} }
} }
export function renderClaudeModelPriceSimple( // 已统一至 renderModelPriceSimple若仍有遗留引用请改为传入 provider='claude'
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,
});
}
}
}
/** /**
* rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。 * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。

View File

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

View File

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

View File

@@ -1804,5 +1804,11 @@
"已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}", "已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}",
"新获取的模型": "New models", "新获取的模型": "New models",
"已有的模型": "Existing 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"
} }

View File

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