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"
ContextKeyUsingGroup ContextKey = "group"
ContextKeyUserName ContextKey = "username"
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
)

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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"
}

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.ContextKeyChannelBaseUrl, channel.GetBaseURL())
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false)
// TODO: api_version统一
switch channel.Type {
case constant.ChannelTypeAzure:

View File

@@ -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 {

View File

@@ -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:"-"`

View File

@@ -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 创建新的供应商记录

View File

@@ -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) {

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)
}
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

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 {
channel.SetupApiRequestHeader(info, c, req)
token := getZhipuToken(info.ApiKey)
req.Set("Authorization", token)
req.Set("Authorization", "Bearer "+info.ApiKey)
return nil
}

View File

@@ -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 {

View File

@@ -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
}
}
}
}

View File

@@ -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)

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 {
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);
setJsonError('');
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
const handleVisualChange = useCallback((newPairs) => {
setKeyValuePairs(newPairs);
const jsonObject = keyValueArrayToObject(newPairs);
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
// 通过formApi设置值如果提供的话
setJsonError('');
// 通过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,98 +234,166 @@ 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]);
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
// 渲染键值对编辑器
const renderKeyValueEditor = () => {
if (typeof jsonData !== 'object' || jsonData === null) {
// 渲染值输入控件(支持嵌套)
const renderValueInput = (pairId, value) => {
const valueType = typeof value;
if (valueType === 'boolean') {
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数据请检查格式')}
<div className="flex items-center">
<Switch
checked={value}
onChange={(newValue) => updateValue(pairId, newValue)}
/>
<Text type="tertiary" className="ml-2">
{value ? t('true') : t('false')}
</Text>
</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 (
<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">
<Text type="tertiary" className="text-gray-500 text-sm">
{t('暂无数据,点击下方按钮添加键值对')}
@@ -259,29 +401,55 @@ const JSONEditor = ({
</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>
))}
{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
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
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }}
/>
</Col>
</Row>
);
})}
<div className="mt-2 flex justify-center">
<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 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;

View File

@@ -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}

View File

@@ -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',

View File

@@ -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>

View File

@@ -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}

View File

@@ -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);

View File

@@ -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' },
};

View File

@@ -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' },
};

View File

@@ -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>
);

View File

@@ -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,
]);

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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('分组'),

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
*/
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;

View File

@@ -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>

View File

@@ -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',

View File

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

View File

@@ -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();
}
}

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(
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。

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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"
}

View File

@@ -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;