Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1baf4a6337 | |||
| 9816ad87e3 | |||
| 50249f581c | |||
| 0193018af6 | |||
| f449e06b9d | |||
| 79527c0ab1 | |||
| 41cd051ea9 | |||
| c04f82bfb5 | |||
| dafc7618c3 | |||
| 22692b3f87 | |||
| d36e892905 | |||
| 3cd1ba4673 | |||
| b7c0f754ad | |||
| 263b9bc695 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+16
-1
@@ -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",
|
||||
|
||||
Vendored
+15
@@ -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",
|
||||
|
||||
Vendored
+15
@@ -988,6 +988,8 @@
|
||||
"失败": "失敗",
|
||||
"失败原因": "失敗の原因",
|
||||
"失败后不重试": "失敗後リトライしない",
|
||||
"失败后是否重试": "失敗後リトライ",
|
||||
"不重试": "リトライしない",
|
||||
"失败时自动禁用通道": "失敗時にチャネルを自動的に無効にする",
|
||||
"失败重试次数": "再試行回数",
|
||||
"奖励说明": "特典説明",
|
||||
@@ -2416,6 +2418,19 @@
|
||||
"自定义请求体模式": "カスタムリクエストボディモード",
|
||||
"自定义货币": "カスタム通貨",
|
||||
"自定义货币符号": "カスタム通貨記号",
|
||||
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "カスタム通貨記号はすべてのクォータ金額の前に表示されます(例:€1.50)",
|
||||
"额度展示类型": "クォータ表示タイプ",
|
||||
"站点所有额度将以美元 ($) 显示": "サイトのすべてのクォータは米ドル ($) で表示されます",
|
||||
"站点所有额度将按汇率换算为人民币 (¥) 显示": "サイトのすべてのクォータは為替レートで人民元 (¥) に換算して表示されます",
|
||||
"站点所有额度将以原始 Token 数显示,不做货币换算": "サイトのすべてのクォータは通貨換算なしで元のトークン数で表示されます",
|
||||
"站点所有额度将按汇率换算为自定义货币显示": "サイトのすべてのクォータは為替レートでカスタム通貨に換算して表示されます",
|
||||
"汇率": "為替レート",
|
||||
"每美元对应 Token 数": "1米ドルあたりのトークン数",
|
||||
"预览效果": "プレビュー",
|
||||
"请输入汇率": "為替レートを入力してください",
|
||||
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "システムは米ドル (USD) を基準通貨として使用しています。ユーザー残高、チャージ金額、モデル価格、使用ログなど、すべての金額表示がこのレートで人民元に換算されます。内部課金には影響しません。",
|
||||
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "システム内部の課金精度、デフォルト500000。変更すると課金異常が発生する可能性があります — 慎重に操作してください。",
|
||||
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "システムは米ドル (USD) を基準通貨として使用しています。ユーザー残高、チャージ金額、モデル価格、使用ログなど、すべての金額表示がこのレートでカスタム通貨に換算されます。内部課金には影響しません。",
|
||||
"自定义错误响应": "カスタムエラーレスポンス",
|
||||
"自定义镜像": "Custom Image",
|
||||
"自用模式": "個人モード",
|
||||
|
||||
Vendored
+15
@@ -1003,6 +1003,8 @@
|
||||
"失败": "Неудача",
|
||||
"失败原因": "Причина ошибки",
|
||||
"失败后不重试": "Не повторять после ошибки",
|
||||
"失败后是否重试": "Повторить при ошибке",
|
||||
"不重试": "Не повторять",
|
||||
"失败时自动禁用通道": "Автоматически отключать канал при неудаче",
|
||||
"失败重试次数": "Количество повторных попыток при неудаче",
|
||||
"奖励说明": "Описание награды",
|
||||
@@ -2449,6 +2451,19 @@
|
||||
"自定义请求体模式": "Режим пользовательского тела запроса",
|
||||
"自定义货币": "Пользовательская валюта",
|
||||
"自定义货币符号": "Пользовательский символ валюты",
|
||||
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "Пользовательский символ валюты будет отображаться перед всеми суммами квот, например €1.50",
|
||||
"额度展示类型": "Тип отображения квоты",
|
||||
"站点所有额度将以美元 ($) 显示": "Все квоты сайта будут отображаться в долларах США ($)",
|
||||
"站点所有额度将按汇率换算为人民币 (¥) 显示": "Все квоты сайта будут конвертированы в юани (¥) по курсу обмена",
|
||||
"站点所有额度将以原始 Token 数显示,不做货币换算": "Все квоты сайта будут отображаться в виде необработанного количества токенов без валютной конвертации",
|
||||
"站点所有额度将按汇率换算为自定义货币显示": "Все квоты сайта будут конвертированы в пользовательскую валюту по курсу обмена",
|
||||
"汇率": "Обменный курс",
|
||||
"每美元对应 Token 数": "Токенов за 1 доллар",
|
||||
"预览效果": "Предпросмотр",
|
||||
"请输入汇率": "Пожалуйста, введите обменный курс",
|
||||
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "Система использует USD как базовую валюту ценообразования. Баланс пользователя, суммы пополнения, цены моделей, журналы использования — все отображаемые суммы конвертируются в юани по этому курсу. Внутренняя тарификация не затрагивается.",
|
||||
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "Внутренняя точность тарификации, по умолчанию 500000. Изменение может привести к аномалиям тарификации — действуйте осторожно.",
|
||||
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "Система использует USD как базовую валюту ценообразования. Баланс пользователя, суммы пополнения, цены моделей, журналы использования — все отображаемые суммы конвертируются в пользовательскую валюту по этому курсу. Внутренняя тарификация не затрагивается.",
|
||||
"自定义错误响应": "Пользовательский ответ об ошибке",
|
||||
"自定义镜像": "Custom Image",
|
||||
"自用模式": "Режим личного использования",
|
||||
|
||||
Vendored
+14
@@ -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",
|
||||
|
||||
Vendored
+15
@@ -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",
|
||||
|
||||
Vendored
+15
@@ -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',
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user