Compare commits

..

14 Commits

Author SHA1 Message Date
CaIon 1baf4a6337 feat(i18n): update localization files with new currency display options and descriptions
Added translations for custom currency display settings across multiple languages, including descriptions for quota display types and exchange rates. This enhances user experience by providing clear information on how quotas are displayed and calculated in different currencies.
2026-04-02 21:56:36 +08:00
Calcium-Ion 9816ad87e3 feat: add HEIC/HEIF image format support for Gemini channel (#4049)
* feat: add HEIC/HEIF image format support

Add detection, MIME type mapping, and dimension parsing for HEIC/HEIF
images via ISOBMFF ftyp brand inspection and ispe box parsing. Update
Gemini relay to accept these formats and refactor getImageConfig to
properly retry decoders using buffered data.

* fix: handle ISOBMFF extended sizes in HEIF dimension parser

parseHEIFDimensions now correctly handles boxSize==1 (64-bit extended
size) and boxSize==0 (box-to-EOF), preventing the parser from breaking
out of the loop when encountering these valid ISOBMFF box headers
before reaching the meta box.
2026-04-02 21:32:42 +08:00
CaIon 50249f581c refactor(middleware): enhance performance error messages
Updated performance checks to provide more detailed error messages, including current usage and thresholds for CPU, memory, and disk. Additionally. Updated localization files to reflect changes in retry options across multiple languages.
2026-04-02 21:31:35 +08:00
Calcium-Ion 0193018af6 Merge pull request #4042 from feitianbubu/pr/fe9713dcbf8795e127fbea2fcb1f3011da86ad54
新增seedance2.0视频接口支持
2026-04-02 21:30:31 +08:00
RedwindA f449e06b9d fix: handle ISOBMFF extended sizes in HEIF dimension parser
parseHEIFDimensions now correctly handles boxSize==1 (64-bit extended
size) and boxSize==0 (box-to-EOF), preventing the parser from breaking
out of the loop when encountering these valid ISOBMFF box headers
before reaching the meta box.
2026-04-02 17:01:21 +08:00
RedwindA 79527c0ab1 feat: add HEIC/HEIF image format support
Add detection, MIME type mapping, and dimension parsing for HEIC/HEIF
images via ISOBMFF ftyp brand inspection and ispe box parsing. Update
Gemini relay to accept these formats and refactor getImageConfig to
properly retry decoders using buffered data.
2026-04-02 16:40:45 +08:00
Calcium-Ion 41cd051ea9 Merge pull request #3505 from seefs001/fix/claude-media-support
fix: add basic inline file support for Claude relay
2026-04-02 13:29:21 +08:00
Seefs c04f82bfb5 TODO: fix chat -> messages file type 2026-04-02 13:16:58 +08:00
feitianbubu dafc7618c3 feat: add seedance fail reason 2026-04-02 12:26:44 +08:00
feitianbubu 22692b3f87 feat: seedance support seconds 2026-04-02 12:26:37 +08:00
feitianbubu d36e892905 fix: seedance only one text 2026-04-02 12:26:33 +08:00
feitianbubu 3cd1ba4673 fix: seedance metadata override prompt 2026-04-02 12:26:27 +08:00
feitianbubu b7c0f754ad feat: add seedance2.0 video api 2026-04-02 12:24:24 +08:00
Seefs 263b9bc695 fix: add basic inline file support for Claude relay 2026-03-30 19:37:21 +08:00
20 changed files with 662 additions and 99 deletions
+10 -4
View File
@@ -1,7 +1,7 @@
package middleware
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -48,17 +48,23 @@ func checkSystemPerformance() *types.NewAPIError {
// 检查 CPU
if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold {
return types.NewErrorWithStatusCode(errors.New("system cpu overloaded"), "system_cpu_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system cpu overloaded (current: %.1f%%, threshold: %d%%)", status.CPUUsage, config.CPUThreshold),
"system_cpu_overloaded", http.StatusServiceUnavailable)
}
// 检查内存
if config.MemoryThreshold > 0 && int(status.MemoryUsage) > config.MemoryThreshold {
return types.NewErrorWithStatusCode(errors.New("system memory overloaded"), "system_memory_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system memory overloaded (current: %.1f%%, threshold: %d%%)", status.MemoryUsage, config.MemoryThreshold),
"system_memory_overloaded", http.StatusServiceUnavailable)
}
// 检查磁盘
if config.DiskThreshold > 0 && int(status.DiskUsage) > config.DiskThreshold {
return types.NewErrorWithStatusCode(errors.New("system disk overloaded"), "system_disk_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system disk overloaded (current: %.1f%%, threshold: %d%%)", status.DiskUsage, config.DiskThreshold),
"system_disk_overloaded", http.StatusServiceUnavailable)
}
return nil
+12 -1
View File
@@ -2,14 +2,25 @@ package middleware
import (
"context"
"crypto/sha256"
"encoding/hex"
"runtime/debug"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
var _bp = func() string {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Path != "" {
h := sha256.Sum256([]byte(bi.Main.Path))
return hex.EncodeToString(h[:4])
}
return common.GetRandomString(8)
}()
func RequestId() func(c *gin.Context) {
return func(c *gin.Context) {
id := common.GetTimeString() + common.GetRandomString(8)
id := common.GetTimeString() + _bp + common.GetRandomString(8)
c.Set(common.RequestIdKey, id)
ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id)
c.Request = c.Request.WithContext(ctx)
+84 -10
View File
@@ -1,10 +1,12 @@
package claude
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -44,6 +46,61 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
}
}
func createClaudeFileSource(file *dto.MessageFile) *types.FileSource {
if file == nil || file.FileData == "" {
return nil
}
if strings.HasPrefix(file.FileData, "http://") || strings.HasPrefix(file.FileData, "https://") {
return types.NewURLFileSource(file.FileData)
}
mimeType := ""
if ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), "."); ext != "" {
if detected := service.GetMimeTypeByExtension(ext); detected != "application/octet-stream" {
mimeType = detected
}
}
return types.NewBase64FileSource(file.FileData, mimeType)
}
func buildClaudeFileMessage(c *gin.Context, file *dto.MessageFile) (*dto.ClaudeMediaMessage, error) {
source := createClaudeFileSource(file)
if source == nil {
return nil, nil
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting document for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %w", err)
}
switch strings.ToLower(mimeType) {
case "application/pdf":
return &dto.ClaudeMediaMessage{
Type: "document",
Source: &dto.ClaudeMessageSource{
Type: "base64",
MediaType: mimeType,
Data: base64Data,
},
}, nil
case "text/plain":
decodedData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, fmt.Errorf("decode text file data failed: %w", err)
}
return &dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer(string(decodedData)),
}, nil
default:
msg := fmt.Sprintf("claude: skip unsupported file content, filename=%q, mime=%q", file.FileName, mimeType)
if c != nil {
logger.LogInfo(c, msg)
} else {
common.SysLog(msg)
}
return nil, nil
}
}
func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
claudeTools := make([]any, 0, len(textRequest.Tools))
@@ -343,16 +400,22 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
claudeMediaMessage := dto.ClaudeMediaMessage{
Type: mediaMessage.Type,
}
if mediaMessage.Type == "text" {
claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text)
} else {
switch mediaMessage.Type {
case "text":
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](mediaMessage.Text),
})
case dto.ContentTypeImageURL:
claudeMediaMessage := dto.ClaudeMediaMessage{
Type: "image",
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
imageUrl := mediaMessage.GetImageMedia()
claudeMediaMessage.Type = "image"
claudeMediaMessage.Source = &dto.ClaudeMessageSource{
Type: "base64",
if imageUrl == nil {
continue
}
// 使用统一的文件服务获取图片数据
var source *types.FileSource
@@ -367,8 +430,19 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
// FIXME
//case dto.ContentTypeFile:
// claudeFileMessage, err := buildClaudeFileMessage(c, mediaMessage.GetFile())
// if err != nil {
// return nil, err
// }
// if claudeFileMessage != nil {
// claudeMediaMessages = append(claudeMediaMessages, *claudeFileMessage)
// }
default:
continue
}
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
+108
View File
@@ -1,10 +1,12 @@
package claude
import (
"encoding/base64"
"strings"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/require"
)
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
@@ -255,3 +257,109 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
})
}
}
func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "see attachment",
},
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "blob.bin",
FileData: "JVBERi0xLjQK",
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "see attachment", *content[0].Text)
}
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "spec.pdf",
FileData: "JVBERi0xLjQK",
},
},
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "summarize it",
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 2)
require.Equal(t, "document", content[0].Type)
require.NotNil(t, content[0].Source)
require.Equal(t, "base64", content[0].Source.Type)
require.Equal(t, "application/pdf", content[0].Source.MediaType)
require.Equal(t, "JVBERi0xLjQK", content[0].Source.Data)
require.Equal(t, "text", content[1].Type)
require.NotNil(t, content[1].Text)
require.Equal(t, "summarize it", *content[1].Text)
}
func TestRequestOpenAI2ClaudeMessage_ConvertsTextFileContentToText(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "notes.txt",
FileData: base64.StdEncoding.EncodeToString([]byte("alpha\nbeta")),
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "alpha\nbeta", *content[0].Text)
}
+2
View File
@@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/jpg": true, // support old image/jpeg
"image/webp": true,
"image/heic": true,
"image/heif": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
+50 -36
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
@@ -13,12 +14,13 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel"
taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/samber/lo"
)
// ============================
@@ -26,37 +28,37 @@ import (
// ============================
type ContentItem struct {
Type string `json:"type"` // "text", "image_url" or "video"
Text string `json:"text,omitempty"` // for text type
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
Video *VideoReference `json:"video,omitempty"` // for video (sample) type
Role string `json:"role,omitempty"` // reference_image / first_frame / last_frame
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
ImageURL *MediaURL `json:"image_url,omitempty"`
VideoURL *MediaURL `json:"video_url,omitempty"`
AudioURL *MediaURL `json:"audio_url,omitempty"`
Role string `json:"role,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type VideoReference struct {
URL string `json:"url"` // Draft video URL
type MediaURL struct {
URL string `json:"url,omitempty"`
}
type requestPayload struct {
Model string `json:"model"`
Content []ContentItem `json:"content"`
Content []ContentItem `json:"content,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ExecutionExpiresAfter dto.IntValue `json:"execution_expires_after,omitempty"`
ExecutionExpiresAfter *dto.IntValue `json:"execution_expires_after,omitempty"`
GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"`
Draft *dto.BoolValue `json:"draft,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
Frames dto.IntValue `json:"frames,omitempty"`
Seed dto.IntValue `json:"seed,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
Watermark *dto.BoolValue `json:"watermark,omitempty"`
Tools []struct {
Type string `json:"type,omitempty"`
} `json:"tools,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Duration *dto.IntValue `json:"duration,omitempty"`
Frames *dto.IntValue `json:"frames,omitempty"`
Seed *dto.IntValue `json:"seed,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
Watermark *dto.BoolValue `json:"watermark,omitempty"`
}
type responsePayload struct {
@@ -76,10 +78,20 @@ type responseTask struct {
Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"`
ServiceTier string `json:"service_tier"`
Usage struct {
Tools []struct {
Type string `json:"type"`
} `json:"tools"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ToolUsage struct {
WebSearch int `json:"web_search"`
} `json:"tool_usage"`
} `json:"usage"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@@ -108,12 +120,12 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+a.apiKey)
@@ -218,20 +230,12 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
Content: []ContentItem{},
}
// Add text prompt
if req.Prompt != "" {
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}
// Add images if present
if req.HasImage() {
for _, imgURL := range req.Images {
r.Content = append(r.Content, ContentItem{
Type: "image_url",
ImageURL: &ImageURL{
ImageURL: &MediaURL{
URL: imgURL,
},
})
@@ -243,6 +247,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
r.Duration = lo.ToPtr(dto.IntValue(sec))
}
r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
return &r, nil
}
@@ -274,7 +288,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
case "failed":
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = "task failed"
taskResult.Reason = resTask.Error.Message
default:
// Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress
@@ -302,8 +316,8 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
if dResp.Status == "failed" {
openAIVideo.Error = &dto.OpenAIVideoError{
Message: "task failed",
Code: "failed",
Message: dResp.Error.Message,
Code: dResp.Error.Code,
}
}
+2
View File
@@ -5,6 +5,8 @@ var ModelList = []string{
"doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
}
var ChannelName = "doubao-video"
+9
View File
@@ -104,6 +104,11 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
return sniffed, nil
}
// Try HEIF/HEIC detection (Go standard library doesn't recognize it)
if heifMime := detectHEIF(readData); heifMime != "" {
return heifMime, nil
}
if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
switch strings.ToLower(format) {
case "jpeg", "jpg":
@@ -168,6 +173,10 @@ func GetMimeTypeByExtension(ext string) string {
return "image/gif"
case "jfif":
return "image/jpeg"
case "heic":
return "image/heic"
case "heif":
return "image/heif"
// Audio files
case "mp3":
+115
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"image"
_ "image/gif"
@@ -275,6 +276,11 @@ func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) stri
}
return sniffed
}
// 4.5 尝试 HEIF/HEIC 检测(Go 标准库不识别)
if heifMime := detectHEIF(fileBytes); heifMime != "" {
return heifMime
}
}
// 5. 尝试作为图片解码获取格式
@@ -449,9 +455,118 @@ func decodeImageConfig(data []byte) (image.Config, string, error) {
return config, "webp", nil
}
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
}
// detectHEIF checks ISOBMFF magic bytes to detect HEIC/HEIF files.
// Returns "image/heic", "image/heif", or "" if not recognized.
func detectHEIF(data []byte) string {
if len(data) < 12 {
return ""
}
// ISOBMFF: bytes[4:8] must be "ftyp"
if string(data[4:8]) != "ftyp" {
return ""
}
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "heim", "heis":
return "image/heic"
case "mif1", "msf1":
return "image/heif"
default:
return ""
}
}
// parseHEIFDimensions parses ISOBMFF box tree to find the ispe box
// and extract image width/height. Returns (width, height, ok).
func parseHEIFDimensions(data []byte) (int, int, bool) {
size := len(data)
if size < 12 {
return 0, 0, false
}
// Walk top-level boxes to find "meta"
offset := 0
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
headerLen := 8
if boxSize == 1 {
// 64-bit extended size
if offset+16 > size {
break
}
boxSize = int(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
headerLen = 16
} else if boxSize == 0 {
// box extends to end of data
boxSize = size - offset
}
if boxSize < headerLen || offset+boxSize > size {
break
}
if boxType == "meta" {
// meta is a full box: 4 bytes version/flags after header
metaData := data[offset+headerLen : offset+boxSize]
if len(metaData) < 4 {
return 0, 0, false
}
return findISPE(metaData[4:])
}
offset += boxSize
}
return 0, 0, false
}
// findISPE recursively searches for the ispe box within container boxes.
// Path: meta -> iprp -> ipco -> ispe
func findISPE(data []byte) (int, int, bool) {
offset := 0
size := len(data)
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
if boxSize < 8 || offset+boxSize > size {
break
}
content := data[offset+8 : offset+boxSize]
switch boxType {
case "iprp", "ipco":
if w, h, ok := findISPE(content); ok {
return w, h, true
}
case "ispe":
// ispe is a full box: 4 bytes version/flags, then 4 bytes width, 4 bytes height
if len(content) >= 12 {
w := int(binary.BigEndian.Uint32(content[4:8]))
h := int(binary.BigEndian.Uint32(content[8:12]))
if w > 0 && h > 0 {
return w, h, true
}
}
}
offset += boxSize
}
return 0, 0, false
}
// guessMimeTypeFromURL 从 URL 猜测 MIME 类型
func guessMimeTypeFromURL(url string) string {
cleanedURL := url
+29 -13
View File
@@ -159,20 +159,36 @@ func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
}
func getImageConfig(reader io.Reader) (image.Config, string, error) {
// Read all data so we can retry with different decoders
data, readErr := io.ReadAll(reader)
if readErr != nil {
return image.Config{}, "", fmt.Errorf("failed to read image data: %w", readErr)
}
// 读取图片的头部信息来获取图片尺寸
config, format, err := image.DecodeConfig(reader)
if err != nil {
err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
common.SysLog(err.Error())
config, err = webp.DecodeConfig(reader)
if err != nil {
err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
common.SysLog(err.Error())
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, format, nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
config, err = webp.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, "webp", nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
format = "webp"
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
if err != nil {
return image.Config{}, "", err
}
return config, format, nil
return image.Config{}, "", err
}
+16 -1
View File
@@ -1004,7 +1004,9 @@
"天前": "days ago",
"失败": "Failed",
"失败原因": "Failure Reason",
"失败后不重试": "No retry after failure",
"失败后不重试": "No Retry on Failure",
"失败后是否重试": "Retry on Failure",
"不重试": "No Retry",
"失败时自动禁用通道": "Automatically disable channel on failure",
"失败重试次数": "Failed retry times",
"奖励说明": "Reward description",
@@ -2468,6 +2470,19 @@
"自定义请求体模式": "Custom Request Body Mode",
"自定义货币": "Custom currency",
"自定义货币符号": "Custom currency symbol",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "Custom currency symbol will appear before all quota amounts, e.g. €1.50",
"额度展示类型": "Quota display type",
"站点所有额度将以美元 ($) 显示": "All site quotas will be displayed in USD ($)",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "All site quotas will be converted to CNY (¥) using the exchange rate",
"站点所有额度将以原始 Token 数显示,不做货币换算": "All site quotas will be displayed as raw token counts without currency conversion",
"站点所有额度将按汇率换算为自定义货币显示": "All site quotas will be converted to custom currency using the exchange rate",
"汇率": "Exchange rate",
"每美元对应 Token 数": "Tokens per USD",
"预览效果": "Preview",
"请输入汇率": "Please enter the exchange rate",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "The system uses USD as the base pricing unit. User balance, top-up amounts, model pricing, usage logs — all displayed amounts will be converted to CNY at this rate. Internal billing is not affected.",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "Internal billing precision, default 500000. Changing this may cause billing anomalies — proceed with caution.",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "The system uses USD as the base pricing unit. User balance, top-up amounts, model pricing, usage logs — all displayed amounts will be converted to custom currency at this rate. Internal billing is not affected.",
"自定义错误响应": "Custom Error Response",
"自定义镜像": "Custom Image",
"自用模式": "Self-use mode",
+15
View File
@@ -997,6 +997,8 @@
"失败": "Échec",
"失败原因": "Raison de l'échec",
"失败后不重试": "Pas de nouvelle tentative après échec",
"失败后是否重试": "Réessayer après échec",
"不重试": "Ne pas réessayer",
"失败时自动禁用通道": "Désactiver automatiquement le canal en cas d'échec",
"失败重试次数": "Nombre de tentatives en cas d'échec",
"奖励说明": "Description de la récompense",
@@ -2435,6 +2437,19 @@
"自定义请求体模式": "Mode de corps de requête personnalisé",
"自定义货币": "Devise personnalisée",
"自定义货币符号": "Symbole de devise personnalisé",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "Le symbole de devise personnalisé sera affiché avant tous les montants de quota, par ex. €1.50",
"额度展示类型": "Type d'affichage du quota",
"站点所有额度将以美元 ($) 显示": "Tous les quotas du site seront affichés en USD ($)",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "Tous les quotas du site seront convertis en CNY (¥) selon le taux de change",
"站点所有额度将以原始 Token 数显示,不做货币换算": "Tous les quotas du site seront affichés en nombre brut de tokens sans conversion monétaire",
"站点所有额度将按汇率换算为自定义货币显示": "Tous les quotas du site seront convertis en devise personnalisée selon le taux de change",
"汇率": "Taux de change",
"每美元对应 Token 数": "Tokens par USD",
"预览效果": "Aperçu",
"请输入汇率": "Veuillez saisir le taux de change",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "Le système utilise l'USD comme unité de tarification de base. Solde utilisateur, montants de recharge, tarification des modèles, journaux d'utilisation — tous les montants affichés seront convertis en CNY à ce taux. La facturation interne n'est pas affectée.",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "Précision de facturation interne, par défaut 500000. Modifier cette valeur peut provoquer des anomalies de facturation — procédez avec prudence.",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "Le système utilise l'USD comme unité de tarification de base. Solde utilisateur, montants de recharge, tarification des modèles, journaux d'utilisation — tous les montants affichés seront convertis en devise personnalisée à ce taux. La facturation interne n'est pas affectée.",
"自定义错误响应": "Réponse d'erreur personnalisée",
"自定义镜像": "Custom Image",
"自用模式": "Mode auto-utilisation",
+15
View File
@@ -988,6 +988,8 @@
"失败": "失敗",
"失败原因": "失敗の原因",
"失败后不重试": "失敗後リトライしない",
"失败后是否重试": "失敗後リトライ",
"不重试": "リトライしない",
"失败时自动禁用通道": "失敗時にチャネルを自動的に無効にする",
"失败重试次数": "再試行回数",
"奖励说明": "特典説明",
@@ -2416,6 +2418,19 @@
"自定义请求体模式": "カスタムリクエストボディモード",
"自定义货币": "カスタム通貨",
"自定义货币符号": "カスタム通貨記号",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "カスタム通貨記号はすべてのクォータ金額の前に表示されます(例:€1.50)",
"额度展示类型": "クォータ表示タイプ",
"站点所有额度将以美元 ($) 显示": "サイトのすべてのクォータは米ドル ($) で表示されます",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "サイトのすべてのクォータは為替レートで人民元 (¥) に換算して表示されます",
"站点所有额度将以原始 Token 数显示,不做货币换算": "サイトのすべてのクォータは通貨換算なしで元のトークン数で表示されます",
"站点所有额度将按汇率换算为自定义货币显示": "サイトのすべてのクォータは為替レートでカスタム通貨に換算して表示されます",
"汇率": "為替レート",
"每美元对应 Token 数": "1米ドルあたりのトークン数",
"预览效果": "プレビュー",
"请输入汇率": "為替レートを入力してください",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "システムは米ドル (USD) を基準通貨として使用しています。ユーザー残高、チャージ金額、モデル価格、使用ログなど、すべての金額表示がこのレートで人民元に換算されます。内部課金には影響しません。",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "システム内部の課金精度、デフォルト500000。変更すると課金異常が発生する可能性があります — 慎重に操作してください。",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "システムは米ドル (USD) を基準通貨として使用しています。ユーザー残高、チャージ金額、モデル価格、使用ログなど、すべての金額表示がこのレートでカスタム通貨に換算されます。内部課金には影響しません。",
"自定义错误响应": "カスタムエラーレスポンス",
"自定义镜像": "Custom Image",
"自用模式": "個人モード",
+15
View File
@@ -1003,6 +1003,8 @@
"失败": "Неудача",
"失败原因": "Причина ошибки",
"失败后不重试": "Не повторять после ошибки",
"失败后是否重试": "Повторить при ошибке",
"不重试": "Не повторять",
"失败时自动禁用通道": "Автоматически отключать канал при неудаче",
"失败重试次数": "Количество повторных попыток при неудаче",
"奖励说明": "Описание награды",
@@ -2449,6 +2451,19 @@
"自定义请求体模式": "Режим пользовательского тела запроса",
"自定义货币": "Пользовательская валюта",
"自定义货币符号": "Пользовательский символ валюты",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "Пользовательский символ валюты будет отображаться перед всеми суммами квот, например €1.50",
"额度展示类型": "Тип отображения квоты",
"站点所有额度将以美元 ($) 显示": "Все квоты сайта будут отображаться в долларах США ($)",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "Все квоты сайта будут конвертированы в юани (¥) по курсу обмена",
"站点所有额度将以原始 Token 数显示,不做货币换算": "Все квоты сайта будут отображаться в виде необработанного количества токенов без валютной конвертации",
"站点所有额度将按汇率换算为自定义货币显示": "Все квоты сайта будут конвертированы в пользовательскую валюту по курсу обмена",
"汇率": "Обменный курс",
"每美元对应 Token 数": "Токенов за 1 доллар",
"预览效果": "Предпросмотр",
"请输入汇率": "Пожалуйста, введите обменный курс",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "Система использует USD как базовую валюту ценообразования. Баланс пользователя, суммы пополнения, цены моделей, журналы использования — все отображаемые суммы конвертируются в юани по этому курсу. Внутренняя тарификация не затрагивается.",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "Внутренняя точность тарификации, по умолчанию 500000. Изменение может привести к аномалиям тарификации — действуйте осторожно.",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "Система использует USD как базовую валюту ценообразования. Баланс пользователя, суммы пополнения, цены моделей, журналы использования — все отображаемые суммы конвертируются в пользовательскую валюту по этому курсу. Внутренняя тарификация не затрагивается.",
"自定义错误响应": "Пользовательский ответ об ошибке",
"自定义镜像": "Custom Image",
"自用模式": "Режим личного использования",
+14
View File
@@ -989,6 +989,8 @@
"失败": "Thất bại",
"失败原因": "Nguyên nhân thất bại",
"失败后不重试": "Không thử lại sau khi thất bại",
"失败后是否重试": "Thử lại khi thất bại",
"不重试": "Không thử lại",
"失败时自动禁用通道": "Tự động vô hiệu hóa kênh khi thất bại",
"失败重试次数": "Số lần thử lại thất bại",
"奖励说明": "Mô tả phần thưởng",
@@ -2749,6 +2751,18 @@
"自定义请求体模式": "Chế độ nội dung yêu cầu tùy chỉnh",
"自定义货币": "Tiền tệ tùy chỉnh",
"自定义货币符号": "Ký hiệu tiền tệ tùy chỉnh",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "Ký hiệu tiền tệ tùy chỉnh sẽ hiển thị trước tất cả số tiền hạn mức, ví dụ €1.50",
"额度展示类型": "Loại hiển thị hạn mức",
"站点所有额度将以美元 ($) 显示": "Tất cả hạn mức trang web sẽ được hiển thị bằng USD ($)",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "Tất cả hạn mức trang web sẽ được chuyển đổi sang CNY (¥) theo tỷ giá hối đoái",
"站点所有额度将以原始 Token 数显示,不做货币换算": "Tất cả hạn mức trang web sẽ được hiển thị dưới dạng số token thô, không chuyển đổi tiền tệ",
"站点所有额度将按汇率换算为自定义货币显示": "Tất cả hạn mức trang web sẽ được chuyển đổi sang tiền tệ tùy chỉnh theo tỷ giá hối đoái",
"每美元对应 Token 数": "Số token trên mỗi USD",
"预览效果": "Xem trước",
"请输入汇率": "Vui lòng nhập tỷ giá hối đoái",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "Hệ thống sử dụng USD làm đơn vị định giá cơ sở. Số dư người dùng, số tiền nạp, giá mô hình, nhật ký sử dụng — tất cả số tiền hiển thị được chuyển đổi sang CNY theo tỷ giá này. Thanh toán nội bộ không bị ảnh hưởng.",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "Độ chính xác thanh toán nội bộ, mặc định 500000. Thay đổi có thể gây ra lỗi thanh toán — hãy thận trọng.",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "Hệ thống sử dụng USD làm đơn vị định giá cơ sở. Số dư người dùng, số tiền nạp, giá mô hình, nhật ký sử dụng — tất cả số tiền hiển thị được chuyển đổi sang tiền tệ tùy chỉnh theo tỷ giá này. Thanh toán nội bộ không bị ảnh hưởng.",
"自定义错误响应": "Phản hồi lỗi tùy chỉnh",
"自定义镜像": "Custom Image",
"自用模式": "Chế độ tự dùng",
+15
View File
@@ -1985,6 +1985,19 @@
"自定义请求体模式": "自定义请求体模式",
"自定义货币": "自定义货币",
"自定义货币符号": "自定义货币符号",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "自定义货币符号将显示在所有额度数值前,例如 €1.50",
"额度展示类型": "额度展示类型",
"站点所有额度将以美元 ($) 显示": "站点所有额度将以美元 ($) 显示",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "站点所有额度将按汇率换算为人民币 (¥) 显示",
"站点所有额度将以原始 Token 数显示,不做货币换算": "站点所有额度将以原始 Token 数显示,不做货币换算",
"站点所有额度将按汇率换算为自定义货币显示": "站点所有额度将按汇率换算为自定义货币显示",
"汇率": "汇率",
"每美元对应 Token 数": "每美元对应 Token 数",
"预览效果": "预览效果",
"请输入汇率": "请输入汇率",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费",
"自定义镜像": "自定义镜像",
"自用模式": "自用模式",
"自适应列表": "自适应列表",
@@ -2559,6 +2572,8 @@
"重置配置": "重置配置",
"重要提醒": "重要提醒",
"重试": "重试",
"不重试": "不重试",
"失败后是否重试": "失败后是否重试",
"重试连接": "重试连接",
"钱包管理": "钱包管理",
"链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1",
+15
View File
@@ -1995,6 +1995,19 @@
"自定义请求体模式": "自訂請求體模式",
"自定义货币": "自訂貨幣",
"自定义货币符号": "自訂貨幣符號",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "自訂貨幣符號將顯示在所有額度數值前,例如 €1.50",
"额度展示类型": "額度展示類型",
"站点所有额度将以美元 ($) 显示": "站點所有額度將以美元 ($) 顯示",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "站點所有額度將按匯率換算為人民幣 (¥) 顯示",
"站点所有额度将以原始 Token 数显示,不做货币换算": "站點所有額度將以原始 Token 數顯示,不做貨幣換算",
"站点所有额度将按汇率换算为自定义货币显示": "站點所有額度將按匯率換算為自訂貨幣顯示",
"汇率": "匯率",
"每美元对应 Token 数": "每美元對應 Token 數",
"预览效果": "預覽效果",
"请输入汇率": "請輸入匯率",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "系統內部以美元 (USD) 為基準計價。用戶餘額、充值金額、模型定價、用量日誌等所有金額顯示均按此匯率換算為人民幣,不影響內部計費",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "系統內部計費精度,預設 500000,修改可能導致計費異常,請謹慎操作",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "系統內部以美元 (USD) 為基準計價。用戶餘額、充值金額、模型定價、用量日誌等所有金額顯示均按此匯率換算為自訂貨幣,不影響內部計費",
"自定义镜像": "自訂鏡像",
"自用模式": "自用模式",
"自适应列表": "動態列表",
@@ -2569,6 +2582,8 @@
"重置配置": "重置設定",
"重要提醒": "重要提醒",
"重试": "重試",
"不重试": "不重試",
"失败后是否重试": "失敗後是否重試",
"重试连接": "重試連接",
"钱包管理": "錢包管理",
"链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "連結中的{key}將自動替換為sk-xxxx{address}將自動替換為系統設定的伺服器位址,末尾不帶/和/v1",
@@ -540,11 +540,11 @@ export default function SettingsChannelAffinity(props) {
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
},
{
title: t('失败后重试'),
title: t('失败后是否重试'),
dataIndex: 'skip_retry_on_failure',
render: (value) => (
<Tag color={value ? 'orange' : 'grey'} style={{ marginRight: 4 }}>
{value ? t('') : t('')}
<Tag color={value ? 'orange' : 'green'} style={{ marginRight: 4 }}>
{value ? t('不重试') : t('重试')}
</Tag>
),
},
@@ -26,9 +26,8 @@ import {
Row,
Spin,
Modal,
Select,
InputGroup,
Input,
Typography,
} from '@douyinfe/semi-ui';
import {
compareObjects,
@@ -39,6 +38,8 @@ import {
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export default function GeneralSettings(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
@@ -126,6 +127,77 @@ export default function GeneralSettings(props) {
}
};
const showTokensOption = useMemo(() => {
const initialType = props.options?.['general_setting.quota_display_type'];
const initialQuotaPerUnit = parseFloat(props.options?.QuotaPerUnit);
const legacyTokensMode =
initialType === undefined &&
props.options?.DisplayInCurrencyEnabled !== undefined &&
!props.options.DisplayInCurrencyEnabled;
return (
initialType === 'TOKENS' ||
legacyTokensMode ||
(!isNaN(initialQuotaPerUnit) && initialQuotaPerUnit !== 500000)
);
}, [props.options]);
const quotaDisplayType = inputs['general_setting.quota_display_type'];
const quotaDisplayTypeDesc = useMemo(() => {
const descMap = {
USD: t('站点所有额度将以美元 ($) 显示'),
CNY: t('站点所有额度将按汇率换算为人民币 (¥) 显示'),
TOKENS: t('站点所有额度将以原始 Token 数显示,不做货币换算'),
CUSTOM: t('站点所有额度将按汇率换算为自定义货币显示'),
};
return descMap[quotaDisplayType] || '';
}, [quotaDisplayType, t]);
const rateLabel = useMemo(() => {
if (quotaDisplayType === 'CNY') return t('汇率');
if (quotaDisplayType === 'TOKENS') return t('每美元对应 Token 数');
if (quotaDisplayType === 'CUSTOM') return t('汇率');
return '';
}, [quotaDisplayType, t]);
const rateSuffix = useMemo(() => {
if (quotaDisplayType === 'CNY') return 'CNY (¥)';
if (quotaDisplayType === 'TOKENS') return 'Tokens';
if (quotaDisplayType === 'CUSTOM')
return inputs['general_setting.custom_currency_symbol'] || '¤';
return '';
}, [quotaDisplayType, inputs]);
const rateExtraText = useMemo(() => {
if (quotaDisplayType === 'CNY')
return t(
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费',
);
if (quotaDisplayType === 'TOKENS')
return t(
'系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作',
);
if (quotaDisplayType === 'CUSTOM')
return t(
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费',
);
return '';
}, [quotaDisplayType, t]);
const previewText = useMemo(() => {
if (quotaDisplayType === 'USD') return '$1.00';
const rate = parseFloat(combinedRate);
if (!rate || isNaN(rate)) return t('请输入汇率');
if (quotaDisplayType === 'CNY') return `$1.00 → ¥${rate.toFixed(2)}`;
if (quotaDisplayType === 'TOKENS')
return `$1.00 → ${Number(rate).toLocaleString()} Tokens`;
if (quotaDisplayType === 'CUSTOM') {
const symbol = inputs['general_setting.custom_currency_symbol'] || '¤';
return `$1.00 → ${symbol}${rate.toFixed(2)}`;
}
return '';
}, [quotaDisplayType, combinedRate, inputs, t]);
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
@@ -202,48 +274,79 @@ export default function GeneralSettings(props) {
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={t('站点额度展示类型及汇率')}>
<InputGroup style={{ width: '100%' }}>
<Form.Select
field='general_setting.quota_display_type'
label={t('额度展示类型')}
extraText={quotaDisplayTypeDesc}
onChange={handleFieldChange(
'general_setting.quota_display_type',
)}
>
<Form.Select.Option value='USD'>
USD ($)
</Form.Select.Option>
<Form.Select.Option value='CNY'>
CNY (¥)
</Form.Select.Option>
{showTokensOption && (
<Form.Select.Option value='TOKENS'>
Tokens
</Form.Select.Option>
)}
<Form.Select.Option value='CUSTOM'>
{t('自定义货币')}
</Form.Select.Option>
</Form.Select>
</Col>
{quotaDisplayType !== 'USD' && (
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={rateLabel}>
<Input
prefix={'1 USD = '}
style={{ width: '50%' }}
prefix='1 USD = '
suffix={rateSuffix}
value={combinedRate}
onChange={onCombinedRateChange}
disabled={
inputs['general_setting.quota_display_type'] === 'USD'
}
/>
<Select
style={{ width: '50%' }}
value={inputs['general_setting.quota_display_type']}
onChange={handleFieldChange(
'general_setting.quota_display_type',
)}
<Text
type='tertiary'
size='small'
style={{ marginTop: 4, display: 'block' }}
>
<Select.Option value='USD'>USD ($)</Select.Option>
<Select.Option value='CNY'>CNY (¥)</Select.Option>
<Select.Option value='TOKENS'>Tokens</Select.Option>
<Select.Option value='CUSTOM'>
{t('自定义货币')}
</Select.Option>
</Select>
</InputGroup>
</Form.Slot>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
{rateExtraText}
</Text>
</Form.Slot>
</Col>
)}
<Col
xs={24}
sm={12}
md={8}
lg={8}
xl={8}
style={
quotaDisplayType !== 'CUSTOM'
? { display: 'none' }
: undefined
}
>
<Form.Input
field={'general_setting.custom_currency_symbol'}
field='general_setting.custom_currency_symbol'
label={t('自定义货币符号')}
extraText={t(
'自定义货币符号将显示在所有额度数值前,例如 €1.50',
)}
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
onChange={handleFieldChange(
'general_setting.custom_currency_symbol',
)}
showClear
disabled={
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
}
/>
</Col>
<Col span={24}>
<Text type='tertiary' size='small'>
{t('预览效果')}{previewText}
</Text>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
@@ -356,7 +356,6 @@ export default function SettingsPerformance(props) {
label={t('CPU 阈值 (%)')}
extraText={t('CPU 使用率超过此值时拒绝请求')}
min={0}
max={100}
onChange={handleFieldChange(
'performance_setting.monitor_cpu_threshold',
)}