Merge branch 'alpha' into feat-vertex-veo

This commit is contained in:
Seefs 2025-09-13 13:10:39 +08:00 committed by GitHub
commit 8d32b08d44
368 changed files with 17707 additions and 8730 deletions

View File

@ -56,8 +56,6 @@
# SESSION_SECRET=random_string # SESSION_SECRET=random_string
# 其他配置 # 其他配置
# 渠道测试频率(单位:秒)
# CHANNEL_TEST_FREQUENCY=10
# 生成默认token # 生成默认token
# GENERATE_DEFAULT_TOKEN=false # GENERATE_DEFAULT_TOKEN=false
# Cohere 安全设置 # Cohere 安全设置

View File

@ -96,7 +96,11 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`) - 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 思考转内容功能 16. 🔄 思考转内容功能
17. 🔄 针对用户的模型限流功能 17. 🔄 针对用户的模型限流功能
18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: 18. 🔄 请求格式转换功能,支持以下三种格式转换:
1. OpenAI Chat Completions => Claude Messages
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
3. OpenAI Chat Completions => Gemini Chat
19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项 1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
2. 在渠道中设置 `提示缓存倍率`,范围 0-1例如设置为 0.5 表示缓存命中时按照 50% 计费 2. 在渠道中设置 `提示缓存倍率`,范围 0-1例如设置为 0.5 表示缓存命中时按照 50% 计费
3. 支持的渠道: 3. 支持的渠道:

View File

@ -2,7 +2,8 @@ package common
import ( import (
"fmt" "fmt"
"github.com/antlabs/pcopy"
"github.com/jinzhu/copier"
) )
func DeepCopy[T any](src *T) (*T, error) { func DeepCopy[T any](src *T) (*T, error) {
@ -10,12 +11,9 @@ func DeepCopy[T any](src *T) (*T, error) {
return nil, fmt.Errorf("copy source cannot be nil") return nil, fmt.Errorf("copy source cannot be nil")
} }
var dst T var dst T
err := pcopy.Copy(&dst, src) err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
if &dst == nil {
return nil, fmt.Errorf("copy result cannot be nil")
}
return &dst, nil return &dst, nil
} }

View File

@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
func Marshal(v any) ([]byte, error) { func Marshal(v any) ([]byte, error) {
return json.Marshal(v) return json.Marshal(v)
} }
func GetJsonType(data json.RawMessage) string {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return "unknown"
}
firstChar := bytes.TrimSpace(data)[0]
switch firstChar {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
case 'n':
return "null"
default:
return "number"
}
}

View File

@ -123,8 +123,16 @@ func Interface2String(inter interface{}) string {
return fmt.Sprintf("%d", inter.(int)) return fmt.Sprintf("%d", inter.(int))
case float64: case float64:
return fmt.Sprintf("%f", inter.(float64)) return fmt.Sprintf("%f", inter.(float64))
case bool:
if inter.(bool) {
return "true"
} else {
return "false"
}
case nil:
return ""
} }
return "Not Implemented" return fmt.Sprintf("%v", inter)
} }
func UnescapeHTML(x string) interface{} { func UnescapeHTML(x string) interface{} {
@ -257,32 +265,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
if err != nil { if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration") return 0, errors.Wrap(err, "failed to get audio duration")
} }
durationStr := string(bytes.TrimSpace(output)) durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" { if durationStr == "N/A" {
// Create a temporary output file name // Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext) tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil { if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file") return 0, errors.Wrap(err, "failed to create temporary file")
} }
tmpName := tmpFp.Name() tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows. // Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close() _ = tmpFp.Close()
defer os.Remove(tmpName) defer os.Remove(tmpName)
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName> // ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName) ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil { if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg") return 0, errors.Wrap(err, "failed to run ffmpeg")
} }
// Recalculate the duration of the new file // Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName) c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output() output, err := c.Output()
if err != nil { if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg") return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
} }
durationStr = string(bytes.TrimSpace(output)) durationStr = string(bytes.TrimSpace(output))
} }
return strconv.ParseFloat(durationStr, 64) return strconv.ParseFloat(durationStr, 64)
} }

View File

@ -20,6 +20,7 @@ import (
relayconstant "one-api/relay/constant" relayconstant "one-api/relay/constant"
"one-api/relay/helper" "one-api/relay/helper"
"one-api/service" "one-api/service"
"one-api/setting/operation_setting"
"one-api/types" "one-api/types"
"strconv" "strconv"
"strings" "strings"
@ -234,7 +235,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if resp != nil { if resp != nil {
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(httpResp, true) err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
return testResult{ return testResult{
context: c, context: c,
localErr: err, localErr: err,
@ -477,15 +478,26 @@ func TestAllChannels(c *gin.Context) {
return return
} }
func AutomaticallyTestChannels(frequency int) { var autoTestChannelsOnce sync.Once
if frequency <= 0 {
common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test") func AutomaticallyTestChannels() {
return autoTestChannelsOnce.Do(func() {
} for {
for { if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
time.Sleep(time.Duration(frequency) * time.Minute) time.Sleep(10 * time.Minute)
common.SysLog("testing all channels") continue
_ = testAllChannels(false) }
common.SysLog("channel test finished") frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
} common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("automatically testing all channels")
_ = testAllChannels(false)
common.SysLog("automatically channel test finished")
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
break
}
}
}
})
} }

View File

@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) {
return return
} }
// GetChannelKey 验证2FA后获取渠道密钥
func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code" binding:"required"`
}
var req GetChannelKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
return
}
// 获取2FA记录并验证
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
return
}
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, fmt.Errorf("用户未启用2FA无法查看密钥"))
return
}
// 统一的2FA验证逻辑
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 统一的成功响应格式
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": map[string]interface{}{
"key": channel.Key,
},
})
}
// validateTwoFactorAuth 统一的2FA验证函数
func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
// 尝试验证TOTP
if cleanCode, err := common.ValidateNumericCode(code); err == nil {
if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
return true
}
}
// 尝试验证备用码
if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
return true
}
return false
}
// validateChannel 通用的渠道校验函数 // validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error { func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings // 校验 channel settings

View File

@ -39,6 +39,8 @@ func TestStatus(c *gin.Context) {
func GetStatus(c *gin.Context) { func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting() cs := console_setting.GetConsoleSetting()
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
data := gin.H{ data := gin.H{
"version": common.Version, "version": common.Version,
@ -89,6 +91,10 @@ func GetStatus(c *gin.Context) {
"announcements_enabled": cs.AnnouncementsEnabled, "announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled, "faq_enabled": cs.FAQEnabled,
// 模块管理配置
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
"oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,

View File

@ -207,6 +207,7 @@ func ListModels(c *gin.Context, modelType int) {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"success": true, "success": true,
"data": userOpenAiModels, "data": userOpenAiModels,
"object": "list",
}) })
} }
} }

604
controller/model_sync.go Normal file
View File

@ -0,0 +1,604 @@
package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"strings"
"sync"
"time"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 上游地址
const (
upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
)
func normalizeLocale(locale string) (string, bool) {
l := strings.ToLower(strings.TrimSpace(locale))
switch l {
case "en", "zh", "ja":
return l, true
default:
return "", false
}
}
func getUpstreamBase() string {
return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
}
func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
base := strings.TrimRight(getUpstreamBase(), "/")
if l, ok := normalizeLocale(locale); ok && l != "" {
return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
}
return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
}
type upstreamEnvelope[T any] struct {
Success bool `json:"success"`
Message string `json:"message"`
Data []T `json:"data"`
}
type upstreamModel struct {
Description string `json:"description"`
Endpoints json.RawMessage `json:"endpoints"`
Icon string `json:"icon"`
ModelName string `json:"model_name"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
Tags string `json:"tags"`
VendorName string `json:"vendor_name"`
}
type upstreamVendor struct {
Description string `json:"description"`
Icon string `json:"icon"`
Name string `json:"name"`
Status int `json:"status"`
}
var (
etagCache = make(map[string]string)
bodyCache = make(map[string][]byte)
cacheMutex sync.RWMutex
)
type overwriteField struct {
ModelName string `json:"model_name"`
Fields []string `json:"fields"`
}
type syncRequest struct {
Overwrite []overwriteField `json:"overwrite"`
Locale string `json:"locale"`
}
func newHTTPClient() *http.Client {
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
return &http.Client{Transport: transport}
}
var httpClient = newHTTPClient()
func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
var lastErr error
attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
if attempts < 1 {
attempts = 1
}
baseDelay := 200 * time.Millisecond
maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
maxBytes := int64(maxMB) << 20
for attempt := 0; attempt < attempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// ETag conditional request
cacheMutex.RLock()
if et := etagCache[url]; et != "" {
req.Header.Set("If-None-Match", et)
}
cacheMutex.RUnlock()
resp, err := httpClient.Do(req)
if err != nil {
lastErr = err
// backoff with jitter
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
continue
}
func() {
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// read body into buffer for caching and flexible decode
limited := io.LimitReader(resp.Body, maxBytes)
buf, err := io.ReadAll(limited)
if err != nil {
lastErr = err
return
}
// cache body and ETag
cacheMutex.Lock()
if et := resp.Header.Get("ETag"); et != "" {
etagCache[url] = et
}
bodyCache[url] = buf
cacheMutex.Unlock()
// Try decode as envelope first
if err := json.Unmarshal(buf, out); err != nil {
// Try decode as pure array
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
case http.StatusNotModified:
// use cache
cacheMutex.RLock()
buf := bodyCache[url]
cacheMutex.RUnlock()
if len(buf) == 0 {
lastErr = errors.New("cache miss for 304 response")
return
}
if err := json.Unmarshal(buf, out); err != nil {
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
default:
lastErr = errors.New(resp.Status)
}
}()
if lastErr == nil {
return nil
}
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
}
return lastErr
}
func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
if vendorName == "" {
return 0
}
if id, ok := vendorIDCache[vendorName]; ok {
return id
}
var existing model.Vendor
if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
vendorIDCache[vendorName] = existing.Id
return existing.Id
}
uv := vendorByName[vendorName]
v := &model.Vendor{
Name: vendorName,
Description: uv.Description,
Icon: coalesce(uv.Icon, ""),
Status: chooseStatus(uv.Status, 1),
}
if err := v.Insert(); err == nil {
*createdVendors++
vendorIDCache[vendorName] = v.Id
return v.Id
}
vendorIDCache[vendorName] = 0
return 0
}
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
func SyncUpstreamModels(c *gin.Context) {
var req syncRequest
// 允许空体
_ = c.ShouldBindJSON(&req)
// 1) 获取未配置模型列表
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if len(missing) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"created_models": 0,
"created_vendors": 0,
"skipped_models": []string{},
}})
return
}
// 2) 拉取上游 vendors 与 models
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsEnv upstreamEnvelope[upstreamModel]
var fetchErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// vendor 失败不拦截
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
}()
go func() {
defer wg.Done()
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
fetchErr = err
}
}()
wg.Wait()
if fetchErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
return
}
// 建立映射
vendorByName := make(map[string]upstreamVendor)
for _, v := range vendorsEnv.Data {
if v.Name != "" {
vendorByName[v.Name] = v
}
}
modelByName := make(map[string]upstreamModel)
for _, m := range modelsEnv.Data {
if m.ModelName != "" {
modelByName[m.ModelName] = m
}
}
// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
createdModels := 0
createdVendors := 0
updatedModels := 0
var skipped []string
var createdList []string
var updatedList []string
// 本地缓存vendorName -> id
vendorIDCache := make(map[string]int)
for _, name := range missing {
up, ok := modelByName[name]
if !ok {
skipped = append(skipped, name)
continue
}
// 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
var existing model.Model
if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
if existing.SyncOfficial == 0 {
skipped = append(skipped, name)
continue
}
}
// 确保 vendor 存在
vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
// 创建模型
mi := &model.Model{
ModelName: name,
Description: up.Description,
Icon: up.Icon,
Tags: up.Tags,
VendorID: vendorID,
Status: chooseStatus(up.Status, 1),
NameRule: up.NameRule,
}
if err := mi.Insert(); err == nil {
createdModels++
createdList = append(createdList, name)
} else {
skipped = append(skipped, name)
}
}
// 4) 处理可选覆盖(更新本地已有模型的差异字段)
if len(req.Overwrite) > 0 {
// vendorIDCache 已用于创建阶段,可复用
for _, ow := range req.Overwrite {
up, ok := modelByName[ow.ModelName]
if !ok {
continue
}
var local model.Model
if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
continue
}
// 跳过被禁用官方同步的模型
if local.SyncOfficial == 0 {
continue
}
// 映射 vendor
newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
// 应用字段覆盖(事务)
_ = model.DB.Transaction(func(tx *gorm.DB) error {
needUpdate := false
if containsField(ow.Fields, "description") {
local.Description = up.Description
needUpdate = true
}
if containsField(ow.Fields, "icon") {
local.Icon = up.Icon
needUpdate = true
}
if containsField(ow.Fields, "tags") {
local.Tags = up.Tags
needUpdate = true
}
if containsField(ow.Fields, "vendor") {
local.VendorID = newVendorID
needUpdate = true
}
if containsField(ow.Fields, "name_rule") {
local.NameRule = up.NameRule
needUpdate = true
}
if containsField(ow.Fields, "status") {
local.Status = chooseStatus(up.Status, local.Status)
needUpdate = true
}
if !needUpdate {
return nil
}
if err := tx.Save(&local).Error; err != nil {
return err
}
updatedModels++
updatedList = append(updatedList, ow.ModelName)
return nil
})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"created_models": createdModels,
"created_vendors": createdVendors,
"updated_models": updatedModels,
"skipped_models": skipped,
"created_list": createdList,
"updated_list": updatedList,
"source": gin.H{
"locale": req.Locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
}
func containsField(fields []string, key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, f := range fields {
if strings.ToLower(strings.TrimSpace(f)) == key {
return true
}
}
return false
}
func coalesce(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
func chooseStatus(primary, fallback int) int {
if primary == 0 && fallback != 0 {
return fallback
}
if primary != 0 {
return primary
}
return 1
}
// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
func SyncUpstreamPreview(c *gin.Context) {
// 1) 拉取上游数据
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
locale := c.Query("locale")
modelsURL, vendorsURL := getUpstreamURLs(locale)
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsEnv upstreamEnvelope[upstreamModel]
var fetchErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
}()
go func() {
defer wg.Done()
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
fetchErr = err
}
}()
wg.Wait()
if fetchErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
return
}
vendorByName := make(map[string]upstreamVendor)
for _, v := range vendorsEnv.Data {
if v.Name != "" {
vendorByName[v.Name] = v
}
}
modelByName := make(map[string]upstreamModel)
upstreamNames := make([]string, 0, len(modelsEnv.Data))
for _, m := range modelsEnv.Data {
if m.ModelName != "" {
modelByName[m.ModelName] = m
upstreamNames = append(upstreamNames, m.ModelName)
}
}
// 2) 本地已有模型
var locals []model.Model
if len(upstreamNames) > 0 {
_ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
}
// 本地 vendor 名称映射
vendorIdSet := make(map[int]struct{})
for _, m := range locals {
if m.VendorID != 0 {
vendorIdSet[m.VendorID] = struct{}{}
}
}
vendorIDs := make([]int, 0, len(vendorIdSet))
for id := range vendorIdSet {
vendorIDs = append(vendorIDs, id)
}
idToVendorName := make(map[int]string)
if len(vendorIDs) > 0 {
var dbVendors []model.Vendor
_ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
for _, v := range dbVendors {
idToVendorName[v.Id] = v.Name
}
}
// 3) 缺失且上游存在的模型
missingList, _ := model.GetMissingModels()
var missing []string
for _, name := range missingList {
if _, ok := modelByName[name]; ok {
missing = append(missing, name)
}
}
// 4) 计算冲突字段
type conflictField struct {
Field string `json:"field"`
Local interface{} `json:"local"`
Upstream interface{} `json:"upstream"`
}
type conflictItem struct {
ModelName string `json:"model_name"`
Fields []conflictField `json:"fields"`
}
var conflicts []conflictItem
for _, local := range locals {
up, ok := modelByName[local.ModelName]
if !ok {
continue
}
fields := make([]conflictField, 0, 6)
if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
}
if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
}
if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
}
// vendor 对比使用名称
localVendor := idToVendorName[local.VendorID]
if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
}
if local.NameRule != up.NameRule {
fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
}
if local.Status != chooseStatus(up.Status, local.Status) {
fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
}
if len(fields) > 0 {
conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"missing": missing,
"conflicts": conflicts,
"source": gin.H{
"locale": locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
}

View File

@ -2,6 +2,7 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/model" "one-api/model"
@ -35,8 +36,13 @@ func GetOptions(c *gin.Context) {
return return
} }
type OptionUpdateRequest struct {
Key string `json:"key"`
Value any `json:"value"`
}
func UpdateOption(c *gin.Context) { func UpdateOption(c *gin.Context) {
var option model.Option var option OptionUpdateRequest
err := json.NewDecoder(c.Request.Body).Decode(&option) err := json.NewDecoder(c.Request.Body).Decode(&option)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@ -45,6 +51,16 @@ func UpdateOption(c *gin.Context) {
}) })
return return
} }
switch option.Value.(type) {
case bool:
option.Value = common.Interface2String(option.Value.(bool))
case float64:
option.Value = common.Interface2String(option.Value.(float64))
case int:
option.Value = common.Interface2String(option.Value.(int))
default:
option.Value = fmt.Sprintf("%v", option.Value)
}
switch option.Key { switch option.Key {
case "GitHubOAuthEnabled": case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" { if option.Value == "true" && common.GitHubClientId == "" {
@ -104,7 +120,7 @@ func UpdateOption(c *gin.Context) {
return return
} }
case "GroupRatio": case "GroupRatio":
err = ratio_setting.CheckGroupRatio(option.Value) err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@ -113,7 +129,7 @@ func UpdateOption(c *gin.Context) {
return return
} }
case "ModelRequestRateLimitGroup": case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value) err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@ -122,7 +138,7 @@ func UpdateOption(c *gin.Context) {
return return
} }
case "console_setting.api_info": case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo") err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@ -131,7 +147,7 @@ func UpdateOption(c *gin.Context) {
return return
} }
case "console_setting.announcements": case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements") err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@ -140,7 +156,7 @@ func UpdateOption(c *gin.Context) {
return return
} }
case "console_setting.faq": case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ") err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@ -149,7 +165,7 @@ func UpdateOption(c *gin.Context) {
return return
} }
case "console_setting.uptime_kuma_groups": case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups") err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@ -158,7 +174,7 @@ func UpdateOption(c *gin.Context) {
return return
} }
} }
err = model.UpdateOption(option.Key, option.Value) err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return

View File

@ -1,24 +1,24 @@
package controller package controller
import ( import (
"net/http" "net/http"
"one-api/setting/ratio_setting" "one-api/setting/ratio_setting"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func GetRatioConfig(c *gin.Context) { func GetRatioConfig(c *gin.Context) {
if !ratio_setting.IsExposeRatioEnabled() { if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"success": false, "success": false,
"message": "倍率配置接口未启用", "message": "倍率配置接口未启用",
}) })
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": ratio_setting.GetExposedData(), "data": ratio_setting.GetExposedData(),
}) })
} }

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net"
"net/http" "net/http"
"one-api/logger" "one-api/logger"
"strings" "strings"
@ -21,8 +23,26 @@ const (
defaultTimeoutSeconds = 10 defaultTimeoutSeconds = 10
defaultEndpoint = "/api/ratio_config" defaultEndpoint = "/api/ratio_config"
maxConcurrentFetches = 8 maxConcurrentFetches = 8
maxRatioConfigBytes = 10 << 20 // 10MB
floatEpsilon = 1e-9
) )
func nearlyEqual(a, b float64) bool {
if a > b {
return a-b < floatEpsilon
}
return b-a < floatEpsilon
}
func valuesEqual(a, b interface{}) bool {
af, aok := a.(float64)
bf, bok := b.(float64)
if aok && bok {
return nearlyEqual(af, bf)
}
return a == b
}
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
type upstreamResult struct { type upstreamResult struct {
@ -87,7 +107,23 @@ func FetchUpstreamRatios(c *gin.Context) {
sem := make(chan struct{}, maxConcurrentFetches) sem := make(chan struct{}, maxConcurrentFetches)
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}} dialer := &net.Dialer{Timeout: 10 * time.Second}
transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
// 对 github.io 优先尝试 IPv4失败则回退 IPv6
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
client := &http.Client{Transport: transport}
for _, chn := range upstreams { for _, chn := range upstreams {
wg.Add(1) wg.Add(1)
@ -98,12 +134,17 @@ func FetchUpstreamRatios(c *gin.Context) {
defer func() { <-sem }() defer func() { <-sem }()
endpoint := chItem.Endpoint endpoint := chItem.Endpoint
if endpoint == "" { var fullURL string
endpoint = defaultEndpoint if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
} else if !strings.HasPrefix(endpoint, "/") { fullURL = endpoint
endpoint = "/" + endpoint } else {
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
fullURL = chItem.BaseURL + endpoint
} }
fullURL := chItem.BaseURL + endpoint
uniqueName := chItem.Name uniqueName := chItem.Name
if chItem.ID != 0 { if chItem.ID != 0 {
@ -120,10 +161,19 @@ func FetchUpstreamRatios(c *gin.Context) {
return return
} }
resp, err := client.Do(httpReq) // 简单重试:最多 3 次,指数退避
if err != nil { var resp *http.Response
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) var lastErr error
ch <- upstreamResult{Name: uniqueName, Err: err.Error()} for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(httpReq)
if lastErr == nil {
break
}
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
}
if lastErr != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -132,6 +182,12 @@ func FetchUpstreamRatios(c *gin.Context) {
ch <- upstreamResult{Name: uniqueName, Err: resp.Status} ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return return
} }
// Content-Type 和响应体大小校验
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
}
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
// 兼容两种上游接口格式: // 兼容两种上游接口格式:
// type1: /api/ratio_config -> data 为 map[string]any包含 model_ratio/completion_ratio/cache_ratio/model_price // type1: /api/ratio_config -> data 为 map[string]any包含 model_ratio/completion_ratio/cache_ratio/model_price
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式 // type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
@ -141,7 +197,7 @@ func FetchUpstreamRatios(c *gin.Context) {
Message string `json:"message"` Message string `json:"message"`
} }
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { if err := json.NewDecoder(limited).Decode(&body); err != nil {
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()} ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return return
@ -152,6 +208,8 @@ func FetchUpstreamRatios(c *gin.Context) {
return return
} }
// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
// 尝试按 type1 解析 // 尝试按 type1 解析
var type1Data map[string]any var type1Data map[string]any
if err := json.Unmarshal(body.Data, &type1Data); err == nil { if err := json.Unmarshal(body.Data, &type1Data); err == nil {
@ -357,9 +415,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
upstreamValue = val upstreamValue = val
hasUpstreamValue = true hasUpstreamValue = true
if localValue != nil && localValue != val { if localValue != nil && !valuesEqual(localValue, val) {
hasDifference = true hasDifference = true
} else if localValue == val { } else if valuesEqual(localValue, val) {
upstreamValue = "same" upstreamValue = "same"
} }
} }
@ -466,6 +524,13 @@ func GetSyncableChannels(c *gin.Context) {
} }
} }
syncableChannels = append(syncableChannels, dto.SyncableChannel{
ID: -100,
Name: "官方倍率预设",
BaseURL: "https://basellm.github.io",
Status: 1,
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",

View File

@ -3,7 +3,6 @@ package controller
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"github.com/bytedance/gopkg/util/gopool"
"io" "io"
"log" "log"
"net/http" "net/http"
@ -22,6 +21,8 @@ import (
"one-api/types" "one-api/types"
"strings" "strings"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@ -138,15 +139,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta) // common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
preConsumedQuota, newAPIError := service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) newAPIError = service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
if newAPIError != nil { if newAPIError != nil {
return return
} }
defer func() { defer func() {
// Only return quota if downstream failed and quota was actually pre-consumed // Only return quota if downstream failed and quota was actually pre-consumed
if newAPIError != nil && preConsumedQuota != 0 { if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 {
service.ReturnPreConsumedQuota(c, relayInfo, preConsumedQuota) service.ReturnPreConsumedQuota(c, relayInfo)
} }
}() }()
@ -276,14 +277,13 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况
gopool.Go(func() { // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况 if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously gopool.Go(func() {
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
service.DisableChannel(channelError, err.Error()) service.DisableChannel(channelError, err.Error())
} })
}) }
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) { if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中 // 保存错误日志到mysql中
@ -383,11 +383,14 @@ func RelayNotFound(c *gin.Context) {
func RelayTask(c *gin.Context) { func RelayTask(c *gin.Context) {
retryTimes := common.RetryTimes retryTimes := common.RetryTimes
channelId := c.GetInt("channel_id") channelId := c.GetInt("channel_id")
relayMode := c.GetInt("relay_mode")
group := c.GetString("group") group := c.GetString("group")
originalModel := c.GetString("original_model") originalModel := c.GetString("original_model")
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)}) c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
taskErr := taskRelayHandler(c, relayMode) relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
return
}
taskErr := taskRelayHandler(c, relayInfo)
if taskErr == nil { if taskErr == nil {
retryTimes = 0 retryTimes = 0
} }
@ -407,7 +410,7 @@ func RelayTask(c *gin.Context) {
requestBody, _ := common.GetRequestBody(c) requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
taskErr = taskRelayHandler(c, relayMode) taskErr = taskRelayHandler(c, relayInfo)
} }
useChannel := c.GetStringSlice("use_channel") useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 { if len(useChannel) > 1 {
@ -422,13 +425,13 @@ func RelayTask(c *gin.Context) {
} }
} }
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError { func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError {
var err *dto.TaskError var err *dto.TaskError
switch relayMode { switch relayInfo.RelayMode {
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID: case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
err = relay.RelayTaskFetch(c, relayMode) err = relay.RelayTaskFetch(c, relayInfo.RelayMode)
default: default:
err = relay.RelayTaskSubmit(c, relayMode) err = relay.RelayTaskSubmit(c, relayInfo)
} }
return err return err
} }

View File

@ -31,7 +31,7 @@ type Monitor struct {
type UptimeGroupResult struct { type UptimeGroupResult struct {
CategoryName string `json:"categoryName"` CategoryName string `json:"categoryName"`
Monitors []Monitor `json:"monitors"` Monitors []Monitor `json:"monitors"`
} }
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
@ -57,29 +57,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
url, _ := groupConfig["url"].(string) url, _ := groupConfig["url"].(string)
slug, _ := groupConfig["slug"].(string) slug, _ := groupConfig["slug"].(string)
categoryName, _ := groupConfig["categoryName"].(string) categoryName, _ := groupConfig["categoryName"].(string)
result := UptimeGroupResult{ result := UptimeGroupResult{
CategoryName: categoryName, CategoryName: categoryName,
Monitors: []Monitor{}, Monitors: []Monitor{},
} }
if url == "" || slug == "" { if url == "" || slug == "" {
return result return result
} }
baseURL := strings.TrimSuffix(url, "/") baseURL := strings.TrimSuffix(url, "/")
var statusData struct { var statusData struct {
PublicGroupList []struct { PublicGroupList []struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
MonitorList []struct { MonitorList []struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} `json:"monitorList"` } `json:"monitorList"`
} `json:"publicGroupList"` } `json:"publicGroupList"`
} }
var heartbeatData struct { var heartbeatData struct {
HeartbeatList map[string][]struct { HeartbeatList map[string][]struct {
Status int `json:"status"` Status int `json:"status"`
@ -88,11 +88,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
} }
g, gCtx := errgroup.WithContext(ctx) g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error { g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
}) })
g.Go(func() error { g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
}) })
if g.Wait() != nil { if g.Wait() != nil {
@ -139,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
client := &http.Client{Timeout: httpTimeout} client := &http.Client{Timeout: httpTimeout}
results := make([]UptimeGroupResult, len(groups)) results := make([]UptimeGroupResult, len(groups))
g, gCtx := errgroup.WithContext(ctx) g, gCtx := errgroup.WithContext(ctx)
for i, group := range groups { for i, group := range groups {
i, group := i, group i, group := i, group
@ -148,7 +148,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
return nil return nil
}) })
} }
g.Wait() g.Wait()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results}) c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
} }

View File

@ -210,6 +210,7 @@ func Register(c *gin.Context) {
Password: user.Password, Password: user.Password,
DisplayName: user.Username, DisplayName: user.Username,
InviterId: inviterId, InviterId: inviterId,
Role: common.RoleCommonUser, // 明确设置角色为普通用户
} }
if common.EmailVerificationEnabled { if common.EmailVerificationEnabled {
cleanUser.Email = user.Email cleanUser.Email = user.Email
@ -426,6 +427,7 @@ func GetAffCode(c *gin.Context) {
func GetSelf(c *gin.Context) { func GetSelf(c *gin.Context) {
id := c.GetInt("id") id := c.GetInt("id")
userRole := c.GetInt("role")
user, err := model.GetUserById(id, false) user, err := model.GetUserById(id, false)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
@ -434,14 +436,134 @@ func GetSelf(c *gin.Context) {
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users // Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = "" user.Remark = ""
// 计算用户权限信息
permissions := calculateUserPermissions(userRole)
// 获取用户设置并提取sidebar_modules
userSetting := user.GetSetting()
// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"email": user.Email,
"group": user.Group,
"quota": user.Quota,
"used_quota": user.UsedQuota,
"request_count": user.RequestCount,
"aff_code": user.AffCode,
"aff_count": user.AffCount,
"aff_quota": user.AffQuota,
"aff_history_quota": user.AffHistoryQuota,
"inviter_id": user.InviterId,
"linux_do_id": user.LinuxDOId,
"setting": user.Setting,
"stripe_customer": user.StripeCustomer,
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
"permissions": permissions, // 新增权限字段
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": user, "data": responseData,
}) })
return return
} }
// 计算用户权限的辅助函数
func calculateUserPermissions(userRole int) map[string]interface{} {
permissions := map[string]interface{}{}
// 根据用户角色计算权限
if userRole == common.RoleRootUser {
// 超级管理员不需要边栏设置功能
permissions["sidebar_settings"] = false
permissions["sidebar_modules"] = map[string]interface{}{}
} else if userRole == common.RoleAdminUser {
// 管理员可以设置边栏,但不包含系统设置功能
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": map[string]interface{}{
"setting": false, // 管理员不能访问系统设置
},
}
} else {
// 普通用户只能设置个人功能,不包含管理员区域
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": false, // 普通用户不能访问管理员区域
}
}
return permissions
}
// 根据用户角色生成默认的边栏配置
func generateDefaultSidebarConfig(userRole int) string {
defaultConfig := map[string]interface{}{}
// 聊天区域 - 所有用户都可以访问
defaultConfig["chat"] = map[string]interface{}{
"enabled": true,
"playground": true,
"chat": true,
}
// 控制台区域 - 所有用户都可以访问
defaultConfig["console"] = map[string]interface{}{
"enabled": true,
"detail": true,
"token": true,
"log": true,
"midjourney": true,
"task": true,
}
// 个人中心区域 - 所有用户都可以访问
defaultConfig["personal"] = map[string]interface{}{
"enabled": true,
"topup": true,
"personal": true,
}
// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": false, // 管理员不能访问系统设置
}
} else if userRole == common.RoleRootUser {
// 超级管理员可以访问所有功能
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": true,
}
}
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
}
return string(configBytes)
}
func GetUserModels(c *gin.Context) { func GetUserModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
@ -528,8 +650,8 @@ func UpdateUser(c *gin.Context) {
} }
func UpdateSelf(c *gin.Context) { func UpdateSelf(c *gin.Context) {
var user model.User var requestData map[string]interface{}
err := json.NewDecoder(c.Request.Body).Decode(&user) err := json.NewDecoder(c.Request.Body).Decode(&requestData)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@ -537,6 +659,60 @@ func UpdateSelf(c *gin.Context) {
}) })
return return
} }
// 检查是否是sidebar_modules更新请求
if sidebarModules, exists := requestData["sidebar_modules"]; exists {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
// 获取当前用户设置
currentSetting := user.GetSetting()
// 更新sidebar_modules字段
if sidebarModulesStr, ok := sidebarModules.(string); ok {
currentSetting.SidebarModules = sidebarModulesStr
}
// 保存更新后的设置
user.SetSetting(currentSetting)
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "更新设置失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "设置更新成功",
})
return
}
// 原有的用户信息更新逻辑
var user model.User
requestDataBytes, err := json.Marshal(requestData)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
err = json.Unmarshal(requestDataBytes, &user)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if user.Password == "" { if user.Password == "" {
user.Password = "$I_LOVE_U" // make Validator happy :) user.Password = "$I_LOVE_U" // make Validator happy :)
} }
@ -679,6 +855,7 @@ func CreateUser(c *gin.Context) {
Username: user.Username, Username: user.Username,
Password: user.Password, Password: user.Password,
DisplayName: user.DisplayName, DisplayName: user.DisplayName,
Role: user.Role, // 保持管理员设置的角色
} }
if err := cleanUser.Insert(0); err != nil { if err := cleanUser.Insert(0); err != nil {
common.ApiError(c, err) common.ApiError(c, err)
@ -920,6 +1097,7 @@ type UpdateUserSettingRequest struct {
WebhookUrl string `json:"webhook_url,omitempty"` WebhookUrl string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"webhook_secret,omitempty"` WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"` NotificationEmail string `json:"notification_email,omitempty"`
BarkUrl string `json:"bark_url,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"` RecordIpLog bool `json:"record_ip_log"`
} }
@ -935,7 +1113,7 @@ func UpdateUserSetting(c *gin.Context) {
} }
// 验证预警类型 // 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook { if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "无效的预警类型", "message": "无效的预警类型",
@ -983,6 +1161,33 @@ func UpdateUserSetting(c *gin.Context) {
} }
} }
// 如果是Bark类型验证Bark URL
if req.QuotaWarningType == dto.NotifyTypeBark {
if req.BarkUrl == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Bark推送URL不能为空",
})
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的Bark推送URL",
})
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Bark推送URL必须以http://或https://开头",
})
return
}
}
userId := c.GetInt("id") userId := c.GetInt("id")
user, err := model.GetUserById(userId, true) user, err := model.GetUserById(userId, true)
if err != nil { if err != nil {
@ -1011,6 +1216,11 @@ func UpdateUserSetting(c *gin.Context) {
settings.NotificationEmail = req.NotificationEmail settings.NotificationEmail = req.NotificationEmail
} }
// 如果是Bark类型添加Bark URL到设置中
if req.QuotaWarningType == dto.NotifyTypeBark {
settings.BarkUrl = req.BarkUrl
}
// 更新用户设置 // 更新用户设置
user.SetSetting(settings) user.SetSetting(settings)
if err := user.Update(false); err != nil { if err := user.Update(false); err != nil {

View File

@ -488,14 +488,14 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
case string: case string:
// 处理简单字符串错误 // 处理简单字符串错误
return &types.ClaudeError{ return &types.ClaudeError{
Type: "error", Type: "upstream_error",
Message: err, Message: err,
} }
default: default:
// 未知类型,尝试转换为字符串 // 未知类型,尝试转换为字符串
return &types.ClaudeError{ return &types.ClaudeError{
Type: "unknown_error", Type: "unknown_upstream_error",
Message: fmt.Sprintf("%v", err), Message: fmt.Sprintf("unknown_error: %v", err),
} }
} }
} }

View File

@ -2,11 +2,12 @@ package dto
import ( import (
"encoding/json" "encoding/json"
"github.com/gin-gonic/gin"
"one-api/common" "one-api/common"
"one-api/logger" "one-api/logger"
"one-api/types" "one-api/types"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
type GeminiChatRequest struct { type GeminiChatRequest struct {
@ -268,14 +269,15 @@ type GeminiChatResponse struct {
} }
type GeminiUsageMetadata struct { type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"` PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"` TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"` ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"` PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails"`
CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"`
} }
type GeminiPromptTokensDetails struct { type GeminiModalityTokenCount struct {
Modality string `json:"modality"` Modality string `json:"modality"`
TokenCount int `json:"tokenCount"` TokenCount int `json:"tokenCount"`
} }

View File

@ -59,6 +59,31 @@ func (i *ImageRequest) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// 序列化时需要重新把字段平铺
func (r ImageRequest) MarshalJSON() ([]byte, error) {
// 将已定义字段转为 map
type Alias ImageRequest
alias := Alias(r)
base, err := common.Marshal(alias)
if err != nil {
return nil, err
}
var baseMap map[string]json.RawMessage
if err := common.Unmarshal(base, &baseMap); err != nil {
return nil, err
}
// 合并 ExtraFields
for k, v := range r.Extra {
if _, exists := baseMap[k]; !exists {
baseMap[k] = v
}
}
return json.Marshal(baseMap)
}
func GetJSONFieldNames(t reflect.Type) map[string]struct{} { func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
fields := make(map[string]struct{}) fields := make(map[string]struct{})
for i := 0; i < t.NumField(); i++ { for i := 0; i < t.NumField(); i++ {

View File

@ -57,18 +57,24 @@ type GeneralOpenAIRequest struct {
Dimensions int `json:"dimensions,omitempty"` Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"` Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali // gemini
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao,zhipu_v4 ExtraBody json.RawMessage `json:"extra_body,omitempty"`
ExtraBody json.RawMessage `json:"extra_body,omitempty"` //xai
SearchParameters any `json:"search_parameters,omitempty"` //xai SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` // claude
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params // OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"` Usage json.RawMessage `json:"usage,omitempty"`
Reasoning json.RawMessage `json:"reasoning,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"`
// Ali Qwen Params // Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"`
// ollama Params // ollama Params
Think json.RawMessage `json:"think,omitempty"` Think json.RawMessage `json:"think,omitempty"`
// baidu v2
WebSearch json.RawMessage `json:"web_search,omitempty"`
// doubao,zhipu_v4
THINKING json.RawMessage `json:"thinking,omitempty"`
} }
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
@ -760,27 +766,27 @@ type WebSearchOptions struct {
// https://platform.openai.com/docs/api-reference/responses/create // https://platform.openai.com/docs/api-reference/responses/create
type OpenAIResponsesRequest struct { type OpenAIResponsesRequest struct {
Model string `json:"model"` Model string `json:"model"`
Input any `json:"input,omitempty"` Input json.RawMessage `json:"input,omitempty"`
Include json.RawMessage `json:"include,omitempty"` Include json.RawMessage `json:"include,omitempty"`
Instructions json.RawMessage `json:"instructions,omitempty"` Instructions json.RawMessage `json:"instructions,omitempty"`
MaxOutputTokens uint `json:"max_output_tokens,omitempty"` MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"` ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"` ServiceTier string `json:"service_tier,omitempty"`
Store bool `json:"store,omitempty"` Store bool `json:"store,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"` Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"` ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools []map[string]any `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"` TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"` Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"` MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"` Prompt json.RawMessage `json:"prompt,omitempty"`
} }
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
@ -832,8 +838,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
} }
if len(r.Tools) > 0 { if len(r.Tools) > 0 {
toolStr, _ := common.Marshal(r.Tools) texts = append(texts, string(r.Tools))
texts = append(texts, string(toolStr))
} }
return &types.TokenCountMeta{ return &types.TokenCountMeta{
@ -853,6 +858,14 @@ func (r *OpenAIResponsesRequest) SetModelName(modelName string) {
} }
} }
func (r *OpenAIResponsesRequest) GetToolsMap() []map[string]any {
var toolsMap []map[string]any
if len(r.Tools) > 0 {
_ = common.Unmarshal(r.Tools, &toolsMap)
}
return toolsMap
}
type Reasoning struct { type Reasoning struct {
Effort string `json:"effort,omitempty"` Effort string `json:"effort,omitempty"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitempty"`
@ -879,13 +892,21 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
var inputs []MediaInput var inputs []MediaInput
// Try string first // Try string first
if str, ok := r.Input.(string); ok { // if str, ok := common.GetJsonType(r.Input); ok {
// inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
// return inputs
// }
if common.GetJsonType(r.Input) == "string" {
var str string
_ = common.Unmarshal(r.Input, &str)
inputs = append(inputs, MediaInput{Type: "input_text", Text: str}) inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
return inputs return inputs
} }
// Try array of parts // Try array of parts
if array, ok := r.Input.([]any); ok { if common.GetJsonType(r.Input) == "array" {
var array []any
_ = common.Unmarshal(r.Input, &array)
for _, itemAny := range array { for _, itemAny := range array {
// Already parsed MediaInput // Already parsed MediaInput
if media, ok := itemAny.(MediaInput); ok { if media, ok := itemAny.(MediaInput); ok {

View File

@ -1,23 +1,23 @@
package dto package dto
type UpstreamDTO struct { type UpstreamDTO struct {
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
BaseURL string `json:"base_url" binding:"required"` BaseURL string `json:"base_url" binding:"required"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
} }
type UpstreamRequest struct { type UpstreamRequest struct {
ChannelIDs []int64 `json:"channel_ids"` ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"` Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
} }
// TestResult 上游测试连通性结果 // TestResult 上游测试连通性结果
type TestResult struct { type TestResult struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// DifferenceItem 差异项 // DifferenceItem 差异项
@ -25,14 +25,14 @@ type TestResult struct {
// Upstreams 为各渠道的上游值,具体数值 / "same" / nil // Upstreams 为各渠道的上游值,具体数值 / "same" / nil
type DifferenceItem struct { type DifferenceItem struct {
Current interface{} `json:"current"` Current interface{} `json:"current"`
Upstreams map[string]interface{} `json:"upstreams"` Upstreams map[string]interface{} `json:"upstreams"`
Confidence map[string]bool `json:"confidence"` Confidence map[string]bool `json:"confidence"`
} }
type SyncableChannel struct { type SyncableChannel struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
Status int `json:"status"` Status int `json:"status"`
} }

View File

@ -6,11 +6,14 @@ type UserSetting struct {
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址 WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
} }
var ( var (
NotifyTypeEmail = "email" // Email 邮件 NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
) )

10
go.mod
View File

@ -23,6 +23,7 @@ require (
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
@ -44,11 +45,7 @@ require (
) )
require ( require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/antlabs/pcopy v0.1.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
@ -73,8 +70,6 @@ require (
github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect github.com/jackc/pgx/v5 v5.7.1 // indirect
@ -85,14 +80,11 @@ require (
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect

47
go.sum
View File

@ -1,19 +1,11 @@
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs= github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI= github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/antlabs/pcopy v0.1.5 h1:5Fa1ExY9T6ar3ysAi4rzB5jiYg72Innm+/ESEIOSHvQ=
github.com/antlabs/pcopy v0.1.5/go.mod h1:2FvdkPD3cFiM1CjGuXFCDQZqhKVcLI7IzeSJ2xUIOOI=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
@ -110,7 +102,6 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
@ -121,10 +112,6 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -133,6 +120,8 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@ -163,12 +152,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -201,19 +186,14 @@ github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -251,36 +231,25 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -289,29 +258,18 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
@ -326,7 +284,6 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

10
main.go
View File

@ -94,13 +94,9 @@ func main() {
} }
go controller.AutomaticallyUpdateChannels(frequency) go controller.AutomaticallyUpdateChannels(frequency)
} }
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY")) go controller.AutomaticallyTestChannels()
if err != nil {
common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error())
}
go controller.AutomaticallyTestChannels(frequency)
}
if common.IsMasterNode && constant.UpdateTask { if common.IsMasterNode && constant.UpdateTask {
gopool.Go(func() { gopool.Go(func() {
controller.UpdateMidjourneyTaskBulk() controller.UpdateMidjourneyTaskBulk()

View File

@ -0,0 +1,12 @@
package middleware
import "github.com/gin-gonic/gin"
func DisableCache() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Next()
}
}

View File

@ -18,12 +18,12 @@ func StatsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// 增加活跃连接数 // 增加活跃连接数
atomic.AddInt64(&globalStats.activeConnections, 1) atomic.AddInt64(&globalStats.activeConnections, 1)
// 确保在请求结束时减少连接数 // 确保在请求结束时减少连接数
defer func() { defer func() {
atomic.AddInt64(&globalStats.activeConnections, -1) atomic.AddInt64(&globalStats.activeConnections, -1)
}() }()
c.Next() c.Next()
} }
} }
@ -38,4 +38,4 @@ func GetStats() StatsInfo {
return StatsInfo{ return StatsInfo{
ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections), ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
} }
} }

View File

@ -47,6 +47,7 @@ type Channel struct {
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"` ParamOverride *string `json:"param_override" gorm:"type:text"`
HeaderOverride *string `json:"header_override" gorm:"type:text"` HeaderOverride *string `json:"header_override" gorm:"type:text"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
// add after v0.8.5 // add after v0.8.5
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
@ -112,6 +113,10 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey) return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
} }
lock := GetChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
statusList := channel.ChannelInfo.MultiKeyStatusList statusList := channel.ChannelInfo.MultiKeyStatusList
// helper to get key status, default to enabled when missing // helper to get key status, default to enabled when missing
getStatus := func(idx int) int { getStatus := func(idx int) int {
@ -143,9 +148,6 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
return keys[selectedIdx], selectedIdx, nil return keys[selectedIdx], selectedIdx, nil
case constant.MultiKeyModePolling: case constant.MultiKeyModePolling:
// Use channel-specific lock to ensure thread-safe polling // Use channel-specific lock to ensure thread-safe polling
lock := GetChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
channelInfo, err := CacheGetChannelInfo(channel.Id) channelInfo, err := CacheGetChannelInfo(channel.Id)
if err != nil { if err != nil {
@ -605,8 +607,12 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
return false return false
} }
if channelCache.ChannelInfo.IsMultiKey { if channelCache.ChannelInfo.IsMultiKey {
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
pollingLock := GetChannelPollingLock(channelId)
pollingLock.Lock()
// 如果是多Key模式更新缓存中的状态 // 如果是多Key模式更新缓存中的状态
handlerMultiKeyUpdate(channelCache, usingKey, status, reason) handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
pollingLock.Unlock()
//CacheUpdateChannel(channelCache) //CacheUpdateChannel(channelCache)
//return true //return true
} else { } else {
@ -637,7 +643,11 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
if channel.ChannelInfo.IsMultiKey { if channel.ChannelInfo.IsMultiKey {
beforeStatus := channel.Status beforeStatus := channel.Status
// Protect map writes with the same per-channel lock used by readers
pollingLock := GetChannelPollingLock(channelId)
pollingLock.Lock()
handlerMultiKeyUpdate(channel, usingKey, status, reason) handlerMultiKeyUpdate(channel, usingKey, status, reason)
pollingLock.Unlock()
if beforeStatus != channel.Status { if beforeStatus != channel.Status {
shouldUpdateAbilities = true shouldUpdateAbilities = true
} }

View File

@ -64,22 +64,6 @@ var DB *gorm.DB
var LOG_DB *gorm.DB var LOG_DB *gorm.DB
// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
func dropIndexIfExists(tableName string, indexName string) {
if !common.UsingMySQL {
return
}
var count int64
// Check index existence via information_schema
err := DB.Raw(
"SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
tableName, indexName,
).Scan(&count).Error
if err == nil && count > 0 {
_ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
}
}
func createRootAccountIfNeed() error { func createRootAccountIfNeed() error {
var user User var user User
//if user.Status != common.UserStatusEnabled { //if user.Status != common.UserStatusEnabled {
@ -263,16 +247,6 @@ func InitLogDB() (err error) {
} }
func migrateDB() error { func migrateDB() error {
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
// 删除单列唯一索引(列级 UNIQUE及早期命名方式防止与新复合唯一索引 (model_name, deleted_at) 冲突
dropIndexIfExists("models", "uk_model_name") // 新版复合索引名称(若已存在)
dropIndexIfExists("models", "model_name") // 旧版列级唯一索引名称
dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在)
dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称
//if !common.UsingPostgreSQL {
// return migrateDBFast()
//}
err := DB.AutoMigrate( err := DB.AutoMigrate(
&Channel{}, &Channel{},
&Token{}, &Token{},
@ -299,13 +273,6 @@ func migrateDB() error {
} }
func migrateDBFast() error { func migrateDBFast() error {
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
// 删除单列唯一索引(列级 UNIQUE及早期命名方式防止与新复合唯一索引冲突
dropIndexIfExists("models", "uk_model_name")
dropIndexIfExists("models", "model_name")
dropIndexIfExists("vendors", "uk_vendor_name")
dropIndexIfExists("vendors", "name")
var wg sync.WaitGroup var wg sync.WaitGroup

View File

@ -20,17 +20,18 @@ type BoundChannel struct {
} }
type Model struct { type Model struct {
Id int `json:"id"` Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"` ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"` Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"` VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"` Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"` SyncOfficial int `json:"sync_official" gorm:"default:1"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"` CreatedTime int64 `json:"created_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`

View File

@ -155,9 +155,12 @@ func updatePricing() {
vendorMap[vendors[i].Id] = &vendors[i] vendorMap[vendors[i].Id] = &vendors[i]
} }
// 初始化默认供应商映射
initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)
// 构建对前端友好的供应商列表 // 构建对前端友好的供应商列表
vendorsList = make([]PricingVendor, 0, len(vendors)) vendorsList = make([]PricingVendor, 0, len(vendorMap))
for _, v := range vendors { for _, v := range vendorMap {
vendorsList = append(vendorsList, PricingVendor{ vendorsList = append(vendorsList, PricingVendor{
ID: v.Id, ID: v.Id,
Name: v.Name, Name: v.Name,

128
model/pricing_default.go Normal file
View File

@ -0,0 +1,128 @@
package model
import (
"strings"
)
// 简化的供应商映射规则
var defaultVendorRules = map[string]string{
"gpt": "OpenAI",
"dall-e": "OpenAI",
"whisper": "OpenAI",
"o1": "OpenAI",
"o3": "OpenAI",
"claude": "Anthropic",
"gemini": "Google",
"moonshot": "Moonshot",
"kimi": "Moonshot",
"chatglm": "智谱",
"glm-": "智谱",
"qwen": "阿里巴巴",
"deepseek": "DeepSeek",
"abab": "MiniMax",
"ernie": "百度",
"spark": "讯飞",
"hunyuan": "腾讯",
"command": "Cohere",
"@cf/": "Cloudflare",
"360": "360",
"yi": "零一万物",
"jina": "Jina",
"mistral": "Mistral",
"grok": "xAI",
"llama": "Meta",
"doubao": "字节跳动",
"kling": "快手",
"jimeng": "即梦",
"vidu": "Vidu",
}
// 供应商默认图标映射
var defaultVendorIcons = map[string]string{
"OpenAI": "OpenAI",
"Anthropic": "Claude.Color",
"Google": "Gemini.Color",
"Moonshot": "Moonshot",
"智谱": "Zhipu.Color",
"阿里巴巴": "Qwen.Color",
"DeepSeek": "DeepSeek.Color",
"MiniMax": "Minimax.Color",
"百度": "Wenxin.Color",
"讯飞": "Spark.Color",
"腾讯": "Hunyuan.Color",
"Cohere": "Cohere.Color",
"Cloudflare": "Cloudflare.Color",
"360": "Ai360.Color",
"零一万物": "Yi.Color",
"Jina": "Jina",
"Mistral": "Mistral.Color",
"xAI": "XAI",
"Meta": "Ollama",
"字节跳动": "Doubao.Color",
"快手": "Kling.Color",
"即梦": "Jimeng.Color",
"Vidu": "Vidu",
"微软": "AzureAI",
"Microsoft": "AzureAI",
"Azure": "AzureAI",
}
// initDefaultVendorMapping 简化的默认供应商映射
func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) {
for _, ability := range enableAbilities {
modelName := ability.Model
if _, exists := metaMap[modelName]; exists {
continue
}
// 匹配供应商
vendorID := 0
modelLower := strings.ToLower(modelName)
for pattern, vendorName := range defaultVendorRules {
if strings.Contains(modelLower, pattern) {
vendorID = getOrCreateVendor(vendorName, vendorMap)
break
}
}
// 创建模型元数据
metaMap[modelName] = &Model{
ModelName: modelName,
VendorID: vendorID,
Status: 1,
NameRule: NameRuleExact,
}
}
}
// 查找或创建供应商
func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int {
// 查找现有供应商
for id, vendor := range vendorMap {
if vendor.Name == vendorName {
return id
}
}
// 创建新供应商
newVendor := &Vendor{
Name: vendorName,
Status: 1,
Icon: getDefaultVendorIcon(vendorName),
}
if err := newVendor.Insert(); err != nil {
return 0
}
vendorMap[newVendor.Id] = newVendor
return newVendor.Id
}
// 获取供应商默认图标
func getDefaultVendorIcon(vendorName string) string {
if icon, exists := defaultVendorIcons[vendorName]; exists {
return icon
}
return ""
}

View File

@ -77,7 +77,7 @@ type SyncTaskQueryParams struct {
UserIDs []int UserIDs []int
} }
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.TaskRelayInfo) *Task { func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task {
t := &Task{ t := &Task{
UserId: relayInfo.UserId, UserId: relayInfo.UserId,
SubmitTime: time.Now().Unix(), SubmitTime: time.Now().Unix(),

View File

@ -16,7 +16,7 @@ type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"` Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"unique;not null;index"` UserId int `json:"user_id" gorm:"unique;not null;index"`
Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥不返回给前端 Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥不返回给前端
IsEnabled bool `json:"is_enabled" gorm:"default:false"` IsEnabled bool `json:"is_enabled"`
FailedAttempts int `json:"failed_attempts" gorm:"default:0"` FailedAttempts int `json:"failed_attempts" gorm:"default:0"`
LockedUntil *time.Time `json:"locked_until,omitempty"` LockedUntil *time.Time `json:"locked_until,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"`
@ -30,7 +30,7 @@ type TwoFABackupCode struct {
Id int `json:"id" gorm:"primaryKey"` Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;index"` UserId int `json:"user_id" gorm:"not null;index"`
CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希 CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希
IsUsed bool `json:"is_used" gorm:"default:false"` IsUsed bool `json:"is_used"`
UsedAt *time.Time `json:"used_at,omitempty"` UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`

View File

@ -91,6 +91,68 @@ func (user *User) SetSetting(setting dto.UserSetting) {
user.Setting = string(settingBytes) user.Setting = string(settingBytes)
} }
// 根据用户角色生成默认的边栏配置
func generateDefaultSidebarConfigForRole(userRole int) string {
defaultConfig := map[string]interface{}{}
// 聊天区域 - 所有用户都可以访问
defaultConfig["chat"] = map[string]interface{}{
"enabled": true,
"playground": true,
"chat": true,
}
// 控制台区域 - 所有用户都可以访问
defaultConfig["console"] = map[string]interface{}{
"enabled": true,
"detail": true,
"token": true,
"log": true,
"midjourney": true,
"task": true,
}
// 个人中心区域 - 所有用户都可以访问
defaultConfig["personal"] = map[string]interface{}{
"enabled": true,
"topup": true,
"personal": true,
}
// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": false, // 管理员不能访问系统设置
}
} else if userRole == common.RoleRootUser {
// 超级管理员可以访问所有功能
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": true,
}
}
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
}
return string(configBytes)
}
// CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil // CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil
func CheckUserExistOrDeleted(username string, email string) (bool, error) { func CheckUserExistOrDeleted(username string, email string) (bool, error) {
var user User var user User
@ -320,10 +382,34 @@ func (user *User) Insert(inviterId int) error {
user.Quota = common.QuotaForNewUser user.Quota = common.QuotaForNewUser
//user.SetAccessToken(common.GetUUID()) //user.SetAccessToken(common.GetUUID())
user.AffCode = common.GetRandomString(4) user.AffCode = common.GetRandomString(4)
// 初始化用户设置,包括默认的边栏配置
if user.Setting == "" {
defaultSetting := dto.UserSetting{}
// 这里暂时不设置SidebarModules因为需要在用户创建后根据角色设置
user.SetSetting(defaultSetting)
}
result := DB.Create(user) result := DB.Create(user)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
// 用户创建成功后,根据角色初始化边栏配置
// 需要重新获取用户以确保有正确的ID和Role
var createdUser User
if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil {
// 生成基于角色的默认边栏配置
defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role)
if defaultSidebarConfig != "" {
currentSetting := createdUser.GetSetting()
currentSetting.SidebarModules = defaultSidebarConfig
createdUser.SetSetting(currentSetting)
createdUser.Update(false)
common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role))
}
}
if common.QuotaForNewUser > 0 { if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
} }

View File

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

View File

@ -53,7 +53,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if resp != nil { if resp != nil {
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError

View File

@ -30,16 +30,16 @@ type Adaptor interface {
} }
type TaskAdaptor interface { type TaskAdaptor interface {
Init(info *relaycommon.TaskRelayInfo) Init(info *relaycommon.RelayInfo)
ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError
BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) BuildRequestURL(info *relaycommon.RelayInfo) (string, error)
BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error
BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error)
DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error)
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, err *dto.TaskError) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, err *dto.TaskError)
GetModelList() []string GetModelList() []string
GetChannelName() string GetChannelName() string

View File

@ -264,9 +264,8 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
} }
if resp == nil { if resp == nil {
return nil, errors.New("resp is nil") return nil, errors.New("resp is nil")
@ -277,7 +276,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
return resp, nil return resp, nil
} }
func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
fullRequestURL, err := a.BuildRequestURL(info) fullRequestURL, err := a.BuildRequestURL(info)
if err != nil { if err != nil {
return nil, err return nil, err
@ -294,7 +293,7 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.TaskRelayInfo,
if err != nil { if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err) return nil, fmt.Errorf("setup request header failed: %w", err)
} }
resp, err := doRequest(c, req, info.RelayInfo) resp, err := doRequest(c, req, info)
if err != nil { if err != nil {
return nil, fmt.Errorf("do request failed: %w", err) return nil, fmt.Errorf("do request failed: %w", err)
} }

View File

@ -81,20 +81,23 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if strings.HasSuffix(info.UpstreamModelName, "-search") { if strings.HasSuffix(info.UpstreamModelName, "-search") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search") info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search")
request.Model = info.UpstreamModelName request.Model = info.UpstreamModelName
toMap := request.ToMap() if len(request.WebSearch) == 0 {
toMap["web_search"] = map[string]any{ toMap := request.ToMap()
"enable": true, toMap["web_search"] = map[string]any{
"enable_citation": true, "enable": true,
"enable_trace": true, "enable_citation": true,
"enable_status": false, "enable_trace": true,
"enable_status": false,
}
return toMap, nil
} }
return toMap, nil return request, nil
} }
return request, nil return request, nil
} }
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, nil return nil, errors.New("not implemented")
} }
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {

View File

@ -32,7 +32,7 @@ func stopReasonClaude2OpenAI(reason string) string {
case "end_turn": case "end_turn":
return "stop" return "stop"
case "max_tokens": case "max_tokens":
return "max_tokens" return "length"
case "tool_use": case "tool_use":
return "tool_calls" return "tool_calls"
default: default:
@ -274,19 +274,28 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages := make([]dto.ClaudeMessage, 0) claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true isFirstMessage := true
// 初始化system消息数组用于累积多个system消息
var systemMessages []dto.ClaudeMediaMessage
for _, message := range formatMessages { for _, message := range formatMessages {
if message.Role == "system" { if message.Role == "system" {
// 根据Claude API规范system字段使用数组格式更有通用性
if message.IsStringContent() { if message.IsStringContent() {
claudeRequest.System = message.StringContent() systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](message.StringContent()),
})
} else { } else {
contents := message.ParseContent() // 支持复合内容的system消息虽然不常见但需要考虑完整性
content := "" for _, ctx := range message.ParseContent() {
for _, ctx := range contents {
if ctx.Type == "text" { if ctx.Type == "text" {
content += ctx.Text systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](ctx.Text),
})
} }
// 未来可以在这里扩展对图片等其他类型的支持
} }
claudeRequest.System = content
} }
} else { } else {
if isFirstMessage { if isFirstMessage {
@ -392,6 +401,12 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages = append(claudeMessages, claudeMessage) claudeMessages = append(claudeMessages, claudeMessage)
} }
} }
// 设置累积的system消息
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Prompt = "" claudeRequest.Prompt = ""
claudeRequest.Messages = claudeMessages claudeRequest.Messages = claudeMessages
return &claudeRequest, nil return &claudeRequest, nil
@ -426,7 +441,10 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
choice.Delta.Role = "assistant" choice.Delta.Role = "assistant"
} else if claudeResponse.Type == "content_block_start" { } else if claudeResponse.Type == "content_block_start" {
if claudeResponse.ContentBlock != nil { if claudeResponse.ContentBlock != nil {
//choice.Delta.SetContentString(claudeResponse.ContentBlock.Text) // 如果是文本块,尽可能发送首段文本(若存在)
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
}
if claudeResponse.ContentBlock.Type == "tool_use" { if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{ tools = append(tools, dto.ToolCallResponse{
Index: common.GetPointer(fcIdx), Index: common.GetPointer(fcIdx),

View File

@ -46,6 +46,32 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
imageOutputCounts := 0
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") {
imageOutputCounts++
}
}
}
if imageOutputCounts != 0 {
usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290
usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290
c.Set("gemini_image_tokens", imageOutputCounts*1290)
}
}
// if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
// for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
// if detail.Modality == "IMAGE" {
// usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
// usage.TotalTokens = usage.TotalTokens - detail.TokenCount
// c.Set("gemini_image_tokens", detail.TokenCount)
// }
// }
// }
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" { if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount usage.PromptTokensDetails.AudioTokens = detail.TokenCount
@ -136,6 +162,16 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
usage.PromptTokensDetails.TextTokens = detail.TokenCount usage.PromptTokensDetails.TextTokens = detail.TokenCount
} }
} }
if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
if detail.Modality == "IMAGE" {
usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
usage.TotalTokens = usage.TotalTokens - detail.TokenCount
c.Set("gemini_image_tokens", detail.TokenCount)
}
}
}
} }
// 直接发送 GeminiChatResponse 响应 // 直接发送 GeminiChatResponse 响应

View File

@ -749,7 +749,16 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
var texts []string var texts []string
var toolCalls []dto.ToolCallResponse var toolCalls []dto.ToolCallResponse
for _, part := range candidate.Content.Parts { for _, part := range candidate.Content.Parts {
if part.FunctionCall != nil { if part.InlineData != nil {
// 媒体内容
if strings.HasPrefix(part.InlineData.MimeType, "image") {
imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")"
texts = append(texts, imgText)
} else {
// 其他媒体类型,直接显示链接
texts = append(texts, fmt.Sprintf("[media](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data))
}
} else if part.FunctionCall != nil {
choice.FinishReason = constant.FinishReasonToolCalls choice.FinishReason = constant.FinishReasonToolCalls
if call := getResponseToolCall(&part); call != nil { if call := getResponseToolCall(&part); call != nil {
toolCalls = append(toolCalls, *call) toolCalls = append(toolCalls, *call)

View File

@ -6,4 +6,4 @@ var ModelList = []string{
"m3e-small", "m3e-small",
} }
var ChannelName = "mokaai" var ChannelName = "mokaai"

View File

@ -537,8 +537,14 @@ func detectImageMimeType(filename string) string {
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// 转换模型推理力度后缀 // 转换模型推理力度后缀
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model) effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
if effort != "" && request.Reasoning != nil { if effort != "" {
request.Reasoning.Effort = effort if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{
Effort: effort,
}
} else {
request.Reasoning.Effort = effort
}
request.Model = originModel request.Model = originModel
} }
return request, nil return request, nil

View File

@ -2,6 +2,7 @@ package openai
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"math" "math"
@ -280,11 +281,6 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) { func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) {
defer service.CloseResponseBodyGracefully(resp) defer service.CloseResponseBodyGracefully(resp)
// count tokens by audio file duration
audioTokens, err := countAudioTokens(c)
if err != nil {
return types.NewError(err, types.ErrorCodeCountTokenFailed), nil
}
responseBody, err := io.ReadAll(resp.Body) responseBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
@ -292,6 +288,26 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
// 写入新的 response body // 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody) service.IOCopyBytesGracefully(c, resp, responseBody)
var responseData struct {
Usage *dto.Usage `json:"usage"`
}
if err := json.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil {
if responseData.Usage.TotalTokens > 0 {
usage := responseData.Usage
if usage.PromptTokens == 0 {
usage.PromptTokens = usage.InputTokens
}
if usage.CompletionTokens == 0 {
usage.CompletionTokens = usage.OutputTokens
}
return nil, usage
}
}
audioTokens, err := countAudioTokens(c)
if err != nil {
return types.NewError(err, types.ErrorCodeCountTokenFailed), nil
}
usage := &dto.Usage{} usage := &dto.Usage{}
usage.PromptTokens = audioTokens usage.PromptTokens = audioTokens
usage.CompletionTokens = 0 usage.CompletionTokens = 0

View File

@ -46,9 +46,17 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
} }
} }
if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil {
return &usage, nil
}
// 解析 Tools 用量 // 解析 Tools 用量
for _, tool := range responsesResponse.Tools { for _, tool := range responsesResponse.Tools {
info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++ buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])]
if !ok || buildToolinfo == nil {
logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"]))
continue
}
buildToolinfo.CallCount++
} }
return &usage, nil return &usage, nil
} }
@ -72,10 +80,16 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data) sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type { switch streamResponse.Type {
case "response.completed": case "response.completed":
if streamResponse.Response.Usage != nil { if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens if streamResponse.Response.Usage.InputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens usage.PromptTokens = streamResponse.Response.Usage.InputTokens
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens }
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil { if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
} }
@ -92,6 +106,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
} }
} }
} }
} else {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
} }
return true return true
}) })
@ -107,10 +123,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
} }
if usage.PromptTokens == 0 && usage.CompletionTokens != 0 { if usage.PromptTokens == 0 && usage.CompletionTokens != 0 {
usage.PromptTokens = usage.CompletionTokens usage.PromptTokens = info.PromptTokens
} else {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
} }
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
return usage, nil return usage, nil
} }

View File

@ -74,7 +74,7 @@ type TaskAdaptor struct {
baseURL string baseURL string
} }
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl a.baseURL = info.ChannelBaseUrl
@ -87,7 +87,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
} }
// ValidateRequestAndSetAction parses body, validates fields and sets default action. // ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action. // Accept only POST /v1/video/generations as "generate" action.
action := constant.TaskActionGenerate action := constant.TaskActionGenerate
info.Action = action info.Action = action
@ -108,19 +108,19 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
} }
// BuildRequestURL constructs the upstream URL. // BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
} }
// BuildRequestHeader sets required headers. // BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
return a.signRequest(req, a.accessKey, a.secretKey) return a.signRequest(req, a.accessKey, a.secretKey)
} }
// BuildRequestBody converts request into Jimeng specific format. // BuildRequestBody converts request into Jimeng specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request") v, exists := c.Get("task_request")
if !exists { if !exists {
return nil, fmt.Errorf("request not found in context") return nil, fmt.Errorf("request not found in context")
@ -139,12 +139,12 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
} }
// DoRequest delegates to common helper. // DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody) return channel.DoTaskApiRequest(a, c, info, requestBody)
} }
// DoResponse handles upstream response, returns taskID etc. // DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body) responseBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)

View File

@ -4,13 +4,14 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/samber/lo"
"io" "io"
"net/http" "net/http"
"one-api/model" "one-api/model"
"strings" "strings"
"time" "time"
"github.com/samber/lo"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -37,15 +38,46 @@ type SubmitReq struct {
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
type TrajectoryPoint struct {
X int `json:"x"`
Y int `json:"y"`
}
type DynamicMask struct {
Mask string `json:"mask,omitempty"`
Trajectories []TrajectoryPoint `json:"trajectories,omitempty"`
}
type CameraConfig struct {
Horizontal float64 `json:"horizontal,omitempty"`
Vertical float64 `json:"vertical,omitempty"`
Pan float64 `json:"pan,omitempty"`
Tilt float64 `json:"tilt,omitempty"`
Roll float64 `json:"roll,omitempty"`
Zoom float64 `json:"zoom,omitempty"`
}
type CameraControl struct {
Type string `json:"type,omitempty"`
Config *CameraConfig `json:"config,omitempty"`
}
type requestPayload struct { type requestPayload struct {
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
Mode string `json:"mode,omitempty"` ImageTail string `json:"image_tail,omitempty"`
Duration string `json:"duration,omitempty"` NegativePrompt string `json:"negative_prompt,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"` Mode string `json:"mode,omitempty"`
ModelName string `json:"model_name,omitempty"` Duration string `json:"duration,omitempty"`
Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model" AspectRatio string `json:"aspect_ratio,omitempty"`
CfgScale float64 `json:"cfg_scale,omitempty"` ModelName string `json:"model_name,omitempty"`
Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
CfgScale float64 `json:"cfg_scale,omitempty"`
StaticMask string `json:"static_mask,omitempty"`
DynamicMasks []DynamicMask `json:"dynamic_masks,omitempty"`
CameraControl *CameraControl `json:"camera_control,omitempty"`
CallbackUrl string `json:"callback_url,omitempty"`
ExternalTaskId string `json:"external_task_id,omitempty"`
} }
type responsePayload struct { type responsePayload struct {
@ -79,7 +111,7 @@ type TaskAdaptor struct {
baseURL string baseURL string
} }
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl a.baseURL = info.ChannelBaseUrl
a.apiKey = info.ApiKey a.apiKey = info.ApiKey
@ -88,7 +120,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
} }
// ValidateRequestAndSetAction parses body, validates fields and sets default action. // ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action. // Accept only POST /v1/video/generations as "generate" action.
action := constant.TaskActionGenerate action := constant.TaskActionGenerate
info.Action = action info.Action = action
@ -109,13 +141,13 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
} }
// BuildRequestURL constructs the upstream URL. // BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
return fmt.Sprintf("%s%s", a.baseURL, path), nil return fmt.Sprintf("%s%s", a.baseURL, path), nil
} }
// BuildRequestHeader sets required headers. // BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
token, err := a.createJWTToken() token, err := a.createJWTToken()
if err != nil { if err != nil {
return fmt.Errorf("failed to create JWT token: %w", err) return fmt.Errorf("failed to create JWT token: %w", err)
@ -129,7 +161,7 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
} }
// BuildRequestBody converts request into Kling specific format. // BuildRequestBody converts request into Kling specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request") v, exists := c.Get("task_request")
if !exists { if !exists {
return nil, fmt.Errorf("request not found in context") return nil, fmt.Errorf("request not found in context")
@ -140,6 +172,9 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
if err != nil { if err != nil {
return nil, err return nil, err
} }
if body.Image == "" && body.ImageTail == "" {
c.Set("action", constant.TaskActionTextGenerate)
}
data, err := json.Marshal(body) data, err := json.Marshal(body)
if err != nil { if err != nil {
return nil, err return nil, err
@ -148,7 +183,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
} }
// DoRequest delegates to common helper. // DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
if action := c.GetString("action"); action != "" { if action := c.GetString("action"); action != "" {
info.Action = action info.Action = action
} }
@ -156,7 +191,7 @@ func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo,
} }
// DoResponse handles upstream response, returns taskID etc. // DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body) responseBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
@ -222,14 +257,19 @@ func (a *TaskAdaptor) GetChannelName() string {
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) { func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
r := requestPayload{ r := requestPayload{
Prompt: req.Prompt, Prompt: req.Prompt,
Image: req.Image, Image: req.Image,
Mode: defaultString(req.Mode, "std"), Mode: defaultString(req.Mode, "std"),
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)), Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
AspectRatio: a.getAspectRatio(req.Size), AspectRatio: a.getAspectRatio(req.Size),
ModelName: req.Model, ModelName: req.Model,
Model: req.Model, // Keep consistent with model_name, double writing improves compatibility Model: req.Model, // Keep consistent with model_name, double writing improves compatibility
CfgScale: 0.5, CfgScale: 0.5,
StaticMask: "",
DynamicMasks: []DynamicMask{},
CameraControl: nil,
CallbackUrl: "",
ExternalTaskId: "",
} }
if r.ModelName == "" { if r.ModelName == "" {
r.ModelName = "kling-v1" r.ModelName = "kling-v1"

View File

@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"
@ -16,6 +15,8 @@ import (
"one-api/service" "one-api/service"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin"
) )
type TaskAdaptor struct { type TaskAdaptor struct {
@ -26,11 +27,11 @@ func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
return nil, fmt.Errorf("not implement") // todo implement this method if needed return nil, fmt.Errorf("not implement") // todo implement this method if needed
} }
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType a.ChannelType = info.ChannelType
} }
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
action := strings.ToUpper(c.Param("action")) action := strings.ToUpper(c.Param("action"))
var sunoRequest *dto.SunoSubmitReq var sunoRequest *dto.SunoSubmitReq
@ -58,20 +59,20 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
return nil return nil
} }
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseURL := info.ChannelBaseUrl baseURL := info.ChannelBaseUrl
fullRequestURL := fmt.Sprintf("%s%s", baseURL, "/suno/submit/"+info.Action) fullRequestURL := fmt.Sprintf("%s%s", baseURL, "/suno/submit/"+info.Action)
return fullRequestURL, nil return fullRequestURL, nil
} }
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept")) req.Header.Set("Accept", c.Request.Header.Get("Accept"))
req.Header.Set("Authorization", "Bearer "+info.ApiKey) req.Header.Set("Authorization", "Bearer "+info.ApiKey)
return nil return nil
} }
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
sunoRequest, ok := c.Get("task_request") sunoRequest, ok := c.Get("task_request")
if !ok { if !ok {
err := common.UnmarshalBodyReusable(c, &sunoRequest) err := common.UnmarshalBodyReusable(c, &sunoRequest)
@ -86,11 +87,11 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
return bytes.NewReader(data), nil return bytes.NewReader(data), nil
} }
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody) return channel.DoTaskApiRequest(a, c, info, requestBody)
} }
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body) responseBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)

View File

@ -84,12 +84,12 @@ type TaskAdaptor struct {
baseURL string baseURL string
} }
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl a.baseURL = info.ChannelBaseUrl
} }
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError { func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
var req SubmitReq var req SubmitReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
return service.TaskErrorWrapper(err, "invalid_request_body", http.StatusBadRequest) return service.TaskErrorWrapper(err, "invalid_request_body", http.StatusBadRequest)
@ -109,7 +109,7 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
return nil return nil
} }
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) { func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request") v, exists := c.Get("task_request")
if !exists { if !exists {
return nil, fmt.Errorf("request not found in context") return nil, fmt.Errorf("request not found in context")
@ -132,7 +132,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayI
return bytes.NewReader(data), nil return bytes.NewReader(data), nil
} }
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
var path string var path string
switch info.Action { switch info.Action {
case constant.TaskActionGenerate: case constant.TaskActionGenerate:
@ -143,21 +143,21 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string,
return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil
} }
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Token "+info.ApiKey) req.Header.Set("Authorization", "Token "+info.ApiKey)
return nil return nil
} }
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
if action := c.GetString("action"); action != "" { if action := c.GetString("action"); action != "" {
info.Action = action info.Action = action
} }
return channel.DoTaskApiRequest(a, c, info, requestBody) return channel.DoTaskApiRequest(a, c, info, requestBody)
} }
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body) responseBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)

View File

@ -2,6 +2,7 @@ package volcengine
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -214,6 +215,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil { if request == nil {
return nil, errors.New("request is nil") return nil, errors.New("request is nil")
} }
// 适配 方舟deepseek混合模型 的 thinking 后缀
if strings.HasSuffix(info.UpstreamModelName, "-thinking") && strings.HasPrefix(info.UpstreamModelName, "deepseek") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
request.THINKING = json.RawMessage(`{"type": "enabled"}`)
}
return request, nil return request, nil
} }

View File

@ -111,7 +111,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError

View File

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
"regexp"
"strconv"
"strings" "strings"
) )
@ -151,7 +153,9 @@ func checkConditions(jsonStr string, conditions []ConditionOperation, logic stri
} }
func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) { func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) {
value := gjson.Get(jsonStr, condition.Path) // 处理负数索引
path := processNegativeIndex(jsonStr, condition.Path)
value := gjson.Get(jsonStr, path)
if !value.Exists() { if !value.Exists() {
if condition.PassMissingKey { if condition.PassMissingKey {
return true, nil return true, nil
@ -177,6 +181,37 @@ func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, e
return result, nil return result, nil
} }
func processNegativeIndex(jsonStr string, path string) string {
re := regexp.MustCompile(`\.(-\d+)`)
matches := re.FindAllStringSubmatch(path, -1)
if len(matches) == 0 {
return path
}
result := path
for _, match := range matches {
negIndex := match[1]
index, _ := strconv.Atoi(negIndex)
arrayPath := strings.Split(path, negIndex)[0]
if strings.HasSuffix(arrayPath, ".") {
arrayPath = arrayPath[:len(arrayPath)-1]
}
array := gjson.Get(jsonStr, arrayPath)
if array.IsArray() {
length := len(array.Array())
actualIndex := length + index
if actualIndex >= 0 && actualIndex < length {
result = strings.Replace(result, match[0], "."+strconv.Itoa(actualIndex), 1)
}
}
}
return result
}
// compareGjsonValues 直接比较两个gjson.Result支持所有比较模式 // compareGjsonValues 直接比较两个gjson.Result支持所有比较模式
func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) { func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) {
switch mode { switch mode {
@ -274,21 +309,25 @@ func applyOperations(jsonStr string, operations []ParamOperation) (string, error
if !ok { if !ok {
continue // 条件不满足,跳过当前操作 continue // 条件不满足,跳过当前操作
} }
// 处理路径中的负数索引
opPath := processNegativeIndex(result, op.Path)
opFrom := processNegativeIndex(result, op.From)
opTo := processNegativeIndex(result, op.To)
switch op.Mode { switch op.Mode {
case "delete": case "delete":
result, err = sjson.Delete(result, op.Path) result, err = sjson.Delete(result, opPath)
case "set": case "set":
if op.KeepOrigin && gjson.Get(result, op.Path).Exists() { if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
continue continue
} }
result, err = sjson.Set(result, op.Path, op.Value) result, err = sjson.Set(result, opPath, op.Value)
case "move": case "move":
result, err = moveValue(result, op.From, op.To) result, err = moveValue(result, opFrom, opTo)
case "prepend": case "prepend":
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, true) result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
case "append": case "append":
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, false) result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
default: default:
return "", fmt.Errorf("unknown operation: %s", op.Mode) return "", fmt.Errorf("unknown operation: %s", op.Mode)
} }

View File

@ -116,6 +116,7 @@ type RelayInfo struct {
*RerankerInfo *RerankerInfo
*ResponsesUsageInfo *ResponsesUsageInfo
*ChannelMeta *ChannelMeta
*TaskRelayInfo
} }
func (info *RelayInfo) InitChannelMeta(c *gin.Context) { func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
@ -313,7 +314,7 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest)
BuiltInTools: make(map[string]*BuildInToolInfo), BuiltInTools: make(map[string]*BuildInToolInfo),
} }
if len(request.Tools) > 0 { if len(request.Tools) > 0 {
for _, tool := range request.Tools { for _, tool := range request.GetToolsMap() {
toolType := common.Interface2String(tool["type"]) toolType := common.Interface2String(tool["type"])
info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{ info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{
ToolName: toolType, ToolName: toolType,
@ -400,6 +401,10 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
}, },
} }
if info.RelayMode == relayconstant.RelayModeUnknown {
info.RelayMode = c.GetInt("relay_mode")
}
if strings.HasPrefix(c.Request.URL.Path, "/pg") { if strings.HasPrefix(c.Request.URL.Path, "/pg") {
info.IsPlayground = true info.IsPlayground = true
info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg") info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg")
@ -465,25 +470,12 @@ func (info *RelayInfo) HasSendResponse() bool {
} }
type TaskRelayInfo struct { type TaskRelayInfo struct {
*RelayInfo
Action string Action string
OriginTaskID string OriginTaskID string
ConsumeQuota bool ConsumeQuota bool
} }
func GenTaskRelayInfo(c *gin.Context) (*TaskRelayInfo, error) {
relayInfo, err := GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
return nil, err
}
info := &TaskRelayInfo{
RelayInfo: relayInfo,
}
info.InitChannelMeta(c)
return info, nil
}
type TaskSubmitReq struct { type TaskSubmitReq struct {
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`

View File

@ -2,12 +2,10 @@ package common
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"one-api/constant" "one-api/constant"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {

View File

@ -130,7 +130,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
jsonData, err := common.Marshal(convertedRequest) jsonData, err := common.Marshal(convertedRequest)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
} }
// apply param override // apply param override
@ -158,7 +158,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newApiErr := service.RelayErrorHandler(httpResp, false) newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newApiErr, statusCodeMappingStr) service.ResetStatusCode(newApiErr, statusCodeMappingStr)
return newApiErr return newApiErr
@ -195,6 +195,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
imageTokens := usage.PromptTokensDetails.ImageTokens imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens audioTokens := usage.PromptTokensDetails.AudioTokens
completionTokens := usage.CompletionTokens completionTokens := usage.CompletionTokens
cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
modelName := relayInfo.OriginModelName modelName := relayInfo.OriginModelName
tokenName := ctx.GetString("token_name") tokenName := ctx.GetString("token_name")
@ -204,6 +206,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
modelRatio := relayInfo.PriceData.ModelRatio modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice modelPrice := relayInfo.PriceData.ModelPrice
cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio
// Convert values to decimal for precise calculation // Convert values to decimal for precise calculation
dPromptTokens := decimal.NewFromInt(int64(promptTokens)) dPromptTokens := decimal.NewFromInt(int64(promptTokens))
@ -211,12 +214,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
dImageTokens := decimal.NewFromInt(int64(imageTokens)) dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens)) dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens)) dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio) dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio) dCacheRatio := decimal.NewFromFloat(cacheRatio)
dImageRatio := decimal.NewFromFloat(imageRatio) dImageRatio := decimal.NewFromFloat(imageRatio)
dModelRatio := decimal.NewFromFloat(modelRatio) dModelRatio := decimal.NewFromFloat(modelRatio)
dGroupRatio := decimal.NewFromFloat(groupRatio) dGroupRatio := decimal.NewFromFloat(groupRatio)
dModelPrice := decimal.NewFromFloat(modelPrice) dModelPrice := decimal.NewFromFloat(modelPrice)
dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
ratio := dModelRatio.Mul(dGroupRatio) ratio := dModelRatio.Mul(dGroupRatio)
@ -284,6 +289,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
baseTokens = baseTokens.Sub(dCacheTokens) baseTokens = baseTokens.Sub(dCacheTokens)
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
} }
var dCachedCreationTokensWithRatio decimal.Decimal
if !dCachedCreationTokens.IsZero() {
baseTokens = baseTokens.Sub(dCachedCreationTokens)
dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio)
}
// 减去 image tokens // 减去 image tokens
var imageTokensWithRatio decimal.Decimal var imageTokensWithRatio decimal.Decimal
@ -302,7 +312,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()) extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
} }
} }
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio) promptQuota := baseTokens.Add(cachedTokensWithRatio).
Add(imageTokensWithRatio).
Add(dCachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio) completionQuota := dCompletionTokens.Mul(dCompletionRatio)
@ -314,11 +326,22 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
} else { } else {
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
} }
var dGeminiImageOutputQuota decimal.Decimal
var imageOutputPrice float64
if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName)
if imageOutputPrice > 0 {
dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens")))
dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
}
// 添加 responses tools call 调用的配额 // 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费 // 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
// 添加 Gemini image output 计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart()) quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens totalTokens := promptTokens + completionTokens
@ -384,6 +407,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["image_ratio"] = imageRatio other["image_ratio"] = imageRatio
other["image_output"] = imageTokens other["image_output"] = imageTokens
} }
if cachedCreationTokens != 0 {
other["cache_creation_tokens"] = cachedCreationTokens
other["cache_creation_ratio"] = cachedCreationRatio
}
if !dWebSearchQuota.IsZero() { if !dWebSearchQuota.IsZero() {
if relayInfo.ResponsesUsageInfo != nil { if relayInfo.ResponsesUsageInfo != nil {
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists { if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {
@ -413,6 +440,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice other["audio_input_price"] = audioInputPrice
} }
if !dGeminiImageOutputQuota.IsZero() {
other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens")
other["image_output_price"] = imageOutputPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId, ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens, PromptTokens: promptTokens,

View File

@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if resp != nil { if resp != nil {
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError

View File

@ -152,7 +152,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError
@ -249,7 +249,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
if resp != nil { if resp != nil {
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError
} }

View File

@ -1,7 +1,6 @@
package helper package helper
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -42,7 +41,7 @@ func SetEventStreamHeaders(c *gin.Context) {
} }
func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error { func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error {
jsonData, err := json.Marshal(resp) jsonData, err := common.Marshal(resp)
if err != nil { if err != nil {
common.SysError("error marshalling stream response: " + err.Error()) common.SysError("error marshalling stream response: " + err.Error())
} else { } else {
@ -104,7 +103,7 @@ func WssString(c *gin.Context, ws *websocket.Conn, str string) error {
} }
func WssObject(c *gin.Context, ws *websocket.Conn, object interface{}) error { func WssObject(c *gin.Context, ws *websocket.Conn, object interface{}) error {
jsonData, err := json.Marshal(object) jsonData, err := common.Marshal(object)
if err != nil { if err != nil {
return fmt.Errorf("error marshalling object: %w", err) return fmt.Errorf("error marshalling object: %w", err)
} }

View File

@ -91,7 +91,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError
@ -120,7 +120,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
var logContent string var logContent string
if len(request.Size) > 0 { if len(request.Size) > 0 {
logContent = fmt.Sprintf("大小 %s, 品质 %s", request.Size, quality) logContent = fmt.Sprintf("大小 %s, 品质 %s, 张数 %d", request.Size, quality, request.N)
} }
postConsumeQuota(c, info, usage.(*dto.Usage), logContent) postConsumeQuota(c, info, usage.(*dto.Usage), logContent)

View File

@ -24,32 +24,32 @@ import (
/* /*
Task 任务通过平台Action 区分任务 Task 任务通过平台Action 区分任务
*/ */
func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
info.InitChannelMeta(c)
// ensure TaskRelayInfo is initialized to avoid nil dereference when accessing embedded fields
if info.TaskRelayInfo == nil {
info.TaskRelayInfo = &relaycommon.TaskRelayInfo{}
}
platform := constant.TaskPlatform(c.GetString("platform")) platform := constant.TaskPlatform(c.GetString("platform"))
if platform == "" { if platform == "" {
platform = GetTaskPlatform(c) platform = GetTaskPlatform(c)
} }
relayInfo, err := relaycommon.GenTaskRelayInfo(c) info.InitChannelMeta(c)
if err != nil {
return service.TaskErrorWrapper(err, "gen_relay_info_failed", http.StatusInternalServerError)
}
relayInfo.InitChannelMeta(c)
adaptor := GetTaskAdaptor(platform) adaptor := GetTaskAdaptor(platform)
if adaptor == nil { if adaptor == nil {
return service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest) return service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest)
} }
adaptor.Init(relayInfo) adaptor.Init(info)
// get & validate taskRequest 获取并验证文本请求 // get & validate taskRequest 获取并验证文本请求
taskErr = adaptor.ValidateRequestAndSetAction(c, relayInfo) taskErr = adaptor.ValidateRequestAndSetAction(c, info)
if taskErr != nil { if taskErr != nil {
return return
} }
modelName := relayInfo.OriginModelName modelName := info.OriginModelName
if modelName == "" { if modelName == "" {
modelName = service.CoverTaskActionToModelName(platform, relayInfo.Action) modelName = service.CoverTaskActionToModelName(platform, info.Action)
} }
modelPrice, success := ratio_setting.GetModelPrice(modelName, true) modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
if !success { if !success {
@ -62,15 +62,15 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
} }
// 预扣 // 预扣
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) groupRatio := ratio_setting.GetGroupRatio(info.UsingGroup)
var ratio float64 var ratio float64
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(info.UserGroup, info.UsingGroup)
if hasUserGroupRatio { if hasUserGroupRatio {
ratio = modelPrice * userGroupRatio ratio = modelPrice * userGroupRatio
} else { } else {
ratio = modelPrice * groupRatio ratio = modelPrice * groupRatio
} }
userQuota, err := model.GetUserQuota(relayInfo.UserId, false) userQuota, err := model.GetUserQuota(info.UserId, false)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
return return
@ -81,8 +81,8 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
return return
} }
if relayInfo.OriginTaskID != "" { if info.OriginTaskID != "" {
originTask, exist, err := model.GetByTaskId(relayInfo.UserId, relayInfo.OriginTaskID) originTask, exist, err := model.GetByTaskId(info.UserId, info.OriginTaskID)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError)
return return
@ -91,7 +91,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
taskErr = service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest) taskErr = service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest)
return return
} }
if originTask.ChannelId != relayInfo.ChannelId { if originTask.ChannelId != info.ChannelId {
channel, err := model.GetChannelById(originTask.ChannelId, true) channel, err := model.GetChannelById(originTask.ChannelId, true)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest) taskErr = service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest)
@ -104,19 +104,19 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
c.Set("channel_id", originTask.ChannelId) c.Set("channel_id", originTask.ChannelId)
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
relayInfo.ChannelBaseUrl = channel.GetBaseURL() info.ChannelBaseUrl = channel.GetBaseURL()
relayInfo.ChannelId = originTask.ChannelId info.ChannelId = originTask.ChannelId
} }
} }
// build body // build body
requestBody, err := adaptor.BuildRequestBody(c, relayInfo) requestBody, err := adaptor.BuildRequestBody(c, info)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError)
return return
} }
// do request // do request
resp, err := adaptor.DoRequest(c, relayInfo, requestBody) resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
return return
@ -130,9 +130,9 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
defer func() { defer func() {
// release quota // release quota
if relayInfo.ConsumeQuota && taskErr == nil { if info.ConsumeQuota && taskErr == nil {
err := service.PostConsumeQuota(relayInfo.RelayInfo, quota, 0, true) err := service.PostConsumeQuota(info, quota, 0, true)
if err != nil { if err != nil {
common.SysLog("error consuming token remain quota: " + err.Error()) common.SysLog("error consuming token remain quota: " + err.Error())
} }
@ -142,40 +142,40 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
if hasUserGroupRatio { if hasUserGroupRatio {
gRatio = userGroupRatio gRatio = userGroupRatio
} }
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, relayInfo.Action) logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, info.Action)
other := make(map[string]interface{}) other := make(map[string]interface{})
other["model_price"] = modelPrice other["model_price"] = modelPrice
other["group_ratio"] = groupRatio other["group_ratio"] = groupRatio
if hasUserGroupRatio { if hasUserGroupRatio {
other["user_group_ratio"] = userGroupRatio other["user_group_ratio"] = userGroupRatio
} }
model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId, ChannelId: info.ChannelId,
ModelName: modelName, ModelName: modelName,
TokenName: tokenName, TokenName: tokenName,
Quota: quota, Quota: quota,
Content: logContent, Content: logContent,
TokenId: relayInfo.TokenId, TokenId: info.TokenId,
Group: relayInfo.UsingGroup, Group: info.UsingGroup,
Other: other, Other: other,
}) })
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) model.UpdateUserUsedQuotaAndRequestCount(info.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) model.UpdateChannelUsedQuota(info.ChannelId, quota)
} }
} }
}() }()
taskID, taskData, taskErr := adaptor.DoResponse(c, resp, relayInfo) taskID, taskData, taskErr := adaptor.DoResponse(c, resp, info)
if taskErr != nil { if taskErr != nil {
return return
} }
relayInfo.ConsumeQuota = true info.ConsumeQuota = true
// insert task // insert task
task := model.InitTask(platform, relayInfo) task := model.InitTask(platform, info)
task.TaskID = taskID task.TaskID = taskID
task.Quota = quota task.Quota = quota
task.Data = taskData task.Data = taskData
task.Action = relayInfo.Action task.Action = info.Action
err = task.Insert() err = task.Insert()
if err != nil { if err != nil {
taskErr = service.TaskErrorWrapper(err, "insert_task_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(err, "insert_task_failed", http.StatusInternalServerError)

View File

@ -81,7 +81,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if resp != nil { if resp != nil {
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError

View File

@ -82,7 +82,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false) newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码 // reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError

View File

@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/:id", controller.GetChannel) channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
@ -223,6 +224,8 @@ func SetApiRouter(router *gin.Engine) {
modelsRoute := apiRouter.Group("/models") modelsRoute := apiRouter.Group("/models")
modelsRoute.Use(middleware.AdminAuth()) modelsRoute.Use(middleware.AdminAuth())
{ {
modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
modelsRoute.GET("/missing", controller.GetMissingModels) modelsRoute.GET("/missing", controller.GetMissingModels)
modelsRoute.GET("/", controller.GetAllModelsMeta) modelsRoute.GET("/", controller.GetAllModelsMeta)
modelsRoute.GET("/search", controller.SearchModelsMeta) modelsRoute.GET("/search", controller.SearchModelsMeta)

View File

@ -248,9 +248,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}, },
}) })
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "content_block_delta", Index: &info.ClaudeConvertInfo.Index,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{ Delta: &dto.ClaudeMediaMessage{
Type: "text", Type: "text_delta",
Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()), Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()),
}, },
}) })

View File

@ -1,12 +1,14 @@
package service package service
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/dto" "one-api/dto"
"one-api/logger"
"one-api/types" "one-api/types"
"strconv" "strconv"
"strings" "strings"
@ -78,7 +80,7 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude
return claudeErr return claudeErr
} }
func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) {
newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
responseBody, err := io.ReadAll(resp.Body) responseBody, err := io.ReadAll(resp.Body)
@ -94,7 +96,7 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *t
newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
} else { } else {
if common.DebugEnabled { if common.DebugEnabled {
println(fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) logger.LogInfo(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
} }
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
} }

View File

@ -5,6 +5,9 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"image" "image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"

View File

@ -21,6 +21,10 @@ func DecodeBase64ImageData(base64String string) (image.Config, string, string, e
base64String = base64String[idx+1:] base64String = base64String[idx+1:]
} }
if len(base64String) == 0 {
return image.Config{}, "", "", errors.New("base64 string is empty")
}
// 将base64字符串解码为字节切片 // 将base64字符串解码为字节切片
decodedData, err := base64.StdEncoding.DecodeString(base64String) decodedData, err := base64.StdEncoding.DecodeString(base64String)
if err != nil { if err != nil {

View File

@ -1,7 +1,6 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"one-api/common" "one-api/common"
@ -14,13 +13,13 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, preConsumedQuota int) { func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
if preConsumedQuota != 0 { if relayInfo.FinalPreConsumedQuota != 0 {
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota))) logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
gopool.Go(func() { gopool.Go(func() {
relayInfoCopy := *relayInfo relayInfoCopy := *relayInfo
err := PostConsumeQuota(&relayInfoCopy, -preConsumedQuota, 0, false) err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
if err != nil { if err != nil {
common.SysLog("error return pre-consumed quota: " + err.Error()) common.SysLog("error return pre-consumed quota: " + err.Error())
} }
@ -30,16 +29,16 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, pr
// PreConsumeQuota checks if the user has enough quota to pre-consume. // PreConsumeQuota checks if the user has enough quota to pre-consume.
// It returns the pre-consumed quota if successful, or an error if not. // It returns the pre-consumed quota if successful, or an error if not.
func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) (int, *types.NewAPIError) { func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
userQuota, err := model.GetUserQuota(relayInfo.UserId, false) userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
if err != nil { if err != nil {
return 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
} }
if userQuota <= 0 { if userQuota <= 0 {
return 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
} }
if userQuota-preConsumedQuota < 0 { if userQuota-preConsumedQuota < 0 {
return 0, types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
} }
trustQuota := common.GetTrustQuota() trustQuota := common.GetTrustQuota()
@ -66,14 +65,14 @@ func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
if preConsumedQuota > 0 { if preConsumedQuota > 0 {
err := PreConsumeTokenQuota(relayInfo, preConsumedQuota) err := PreConsumeTokenQuota(relayInfo, preConsumedQuota)
if err != nil { if err != nil {
return 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
} }
err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota)
if err != nil { if err != nil {
return 0, types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
} }
logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota))) logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota)))
} }
relayInfo.FinalPreConsumedQuota = preConsumedQuota relayInfo.FinalPreConsumedQuota = preConsumedQuota
return preConsumedQuota, nil return nil
} }

View File

@ -535,8 +535,27 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
if quotaTooLow { if quotaTooLow {
prompt := "您的额度即将用尽" prompt := "您的额度即将用尽"
topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress) topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress)
content := "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink})) // 根据通知方式生成不同的内容格式
var content string
var values []interface{}
notifyType := userSetting.NotifyType
if notifyType == "" {
notifyType = dto.NotifyTypeEmail
}
if notifyType == dto.NotifyTypeBark {
// Bark推送使用简短文本不支持HTML
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else {
// 默认内容格式适用于Email和Webhook
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values))
if err != nil { if err != nil {
common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error())) common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error()))
} }

View File

@ -5,6 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"image" "image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"log" "log"
"math" "math"
"one-api/common" "one-api/common"
@ -250,13 +253,18 @@ func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, er
} }
func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) { func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
if meta == nil { if !constant.GetMediaToken {
return 0, errors.New("token count meta is nil") return 0, nil
}
if !constant.GetMediaTokenNotStream && !info.IsStream {
return 0, nil
} }
if info.RelayFormat == types.RelayFormatOpenAIRealtime { if info.RelayFormat == types.RelayFormatOpenAIRealtime {
return 0, nil return 0, nil
} }
if meta == nil {
return 0, errors.New("token count meta is nil")
}
model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
tkm := 0 tkm := 0
@ -276,7 +284,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
shouldFetchFiles := true shouldFetchFiles := true
if info.RelayFormat == types.RelayFormatOpenAIRealtime || info.RelayFormat == types.RelayFormatGemini { if info.RelayFormat == types.RelayFormatGemini {
shouldFetchFiles = false shouldFetchFiles = false
} }
@ -297,19 +305,43 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
file.FileType = types.FileTypeFile file.FileType = types.FileTypeFile
} }
file.MimeType = mineType file.MimeType = mineType
} else if strings.HasPrefix(file.OriginData, "data:") {
// get mime type from base64 header
parts := strings.SplitN(file.OriginData, ",", 2)
if len(parts) >= 1 {
header := parts[0]
// Extract mime type from "data:mime/type;base64" format
if strings.Contains(header, ":") && strings.Contains(header, ";") {
mimeStart := strings.Index(header, ":") + 1
mimeEnd := strings.Index(header, ";")
if mimeStart < mimeEnd {
mineType := header[mimeStart:mimeEnd]
if strings.HasPrefix(mineType, "image/") {
file.FileType = types.FileTypeImage
} else if strings.HasPrefix(mineType, "video/") {
file.FileType = types.FileTypeVideo
} else if strings.HasPrefix(mineType, "audio/") {
file.FileType = types.FileTypeAudio
} else {
file.FileType = types.FileTypeFile
}
file.MimeType = mineType
}
}
}
} }
} }
} }
for _, file := range meta.Files { for i, file := range meta.Files {
switch file.FileType { switch file.FileType {
case types.FileTypeImage: case types.FileTypeImage:
if info.RelayFormat == types.RelayFormatGemini { if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") {
tkm += 256 tkm += 256
} else { } else {
token, err := getImageToken(file, model, info.IsStream) token, err := getImageToken(file, model, info.IsStream)
if err != nil { if err != nil {
return 0, fmt.Errorf("error counting image token: %v", err) return 0, fmt.Errorf("error counting image token, media index[%d], original data[%s], err: %v", i, file.OriginData, err)
} }
tkm += token tkm += token
} }
@ -328,33 +360,6 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
return tkm, nil return tkm, nil
} }
//func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenAIRequest) (int, error) {
// tkm := 0
// msgTokens, err := CountTokenMessages(info, request.Messages, request.Model, request.Stream)
// if err != nil {
// return 0, err
// }
// tkm += msgTokens
// if request.Tools != nil {
// openaiTools := request.Tools
// countStr := ""
// for _, tool := range openaiTools {
// countStr = tool.Function.Name
// if tool.Function.Description != "" {
// countStr += tool.Function.Description
// }
// if tool.Function.Parameters != nil {
// countStr += fmt.Sprintf("%v", tool.Function.Parameters)
// }
// }
// toolTokens := CountTokenInput(countStr, request.Model)
// tkm += 8
// tkm += toolTokens
// }
//
// return tkm, nil
//}
func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, error) { func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, error) {
tkm := 0 tkm := 0
@ -514,56 +519,6 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent,
return textToken, audioToken, nil return textToken, audioToken, nil
} }
//func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, model string, stream bool) (int, error) {
// //recover when panic
// tokenEncoder := getTokenEncoder(model)
// // Reference:
// // https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
// // https://github.com/pkoukk/tiktoken-go/issues/6
// //
// // Every message follows <|start|>{role/name}\n{content}<|end|>\n
// var tokensPerMessage int
// var tokensPerName int
//
// tokensPerMessage = 3
// tokensPerName = 1
//
// tokenNum := 0
// for _, message := range messages {
// tokenNum += tokensPerMessage
// tokenNum += getTokenNum(tokenEncoder, message.Role)
// if message.Content != nil {
// if message.Name != nil {
// tokenNum += tokensPerName
// tokenNum += getTokenNum(tokenEncoder, *message.Name)
// }
// arrayContent := message.ParseContent()
// for _, m := range arrayContent {
// if m.Type == dto.ContentTypeImageURL {
// imageUrl := m.GetImageMedia()
// imageTokenNum, err := getImageToken(info, imageUrl, model, stream)
// if err != nil {
// return 0, err
// }
// tokenNum += imageTokenNum
// log.Printf("image token num: %d", imageTokenNum)
// } else if m.Type == dto.ContentTypeInputAudio {
// // TODO: 音频token数量计算
// tokenNum += 100
// } else if m.Type == dto.ContentTypeFile {
// tokenNum += 5000
// } else if m.Type == dto.ContentTypeVideoUrl {
// tokenNum += 5000
// } else {
// tokenNum += getTokenNum(tokenEncoder, m.Text)
// }
// }
// }
// }
// tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|>
// return tokenNum, nil
//}
func CountTokenInput(input any, model string) int { func CountTokenInput(input any, model string) int {
switch v := input.(type) { switch v := input.(type) {
case string: case string:

View File

@ -2,9 +2,12 @@ package service
import ( import (
"fmt" "fmt"
"net/http"
"net/url"
"one-api/common" "one-api/common"
"one-api/dto" "one-api/dto"
"one-api/model" "one-api/model"
"one-api/setting"
"strings" "strings"
) )
@ -51,6 +54,13 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
// 获取 webhook secret // 获取 webhook secret
webhookSecret := userSetting.WebhookSecret webhookSecret := userSetting.WebhookSecret
return SendWebhookNotify(webhookURLStr, webhookSecret, data) return SendWebhookNotify(webhookURLStr, webhookSecret, data)
case dto.NotifyTypeBark:
barkURL := userSetting.BarkUrl
if barkURL == "" {
common.SysLog(fmt.Sprintf("user %d has no bark url, skip sending bark", userId))
return nil
}
return sendBarkNotify(barkURL, data)
} }
return nil return nil
} }
@ -64,3 +74,67 @@ func sendEmailNotify(userEmail string, data dto.Notify) error {
} }
return common.SendEmail(data.Title, userEmail, content) return common.SendEmail(data.Title, userEmail, content)
} }
func sendBarkNotify(barkURL string, data dto.Notify) error {
// 处理占位符
content := data.Content
for _, value := range data.Values {
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
}
// 替换模板变量
finalURL := strings.ReplaceAll(barkURL, "{{title}}", url.QueryEscape(data.Title))
finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content))
// 发送GET请求到Bark
var req *http.Request
var resp *http.Response
var err error
if setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
Key: setting.WorkerValidKey,
Method: http.MethodGet,
Headers: map[string]string{
"User-Agent": "OneAPI-Bark-Notify/1.0",
},
}
resp, err = DoWorkerRequest(workerReq)
if err != nil {
return fmt.Errorf("failed to send bark request through worker: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
}
} else {
// 直接发送请求
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
if err != nil {
return fmt.Errorf("failed to create bark request: %v", err)
}
// 设置User-Agent
req.Header.Set("User-Agent", "OneAPI-Bark-Notify/1.0")
// 发送请求
client := GetHttpClient()
resp, err = client.Do(req)
if err != nil {
return fmt.Errorf("failed to send bark request: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
}
}
return nil
}

View File

@ -3,37 +3,37 @@ package console_setting
import "one-api/setting/config" import "one-api/setting/config"
type ConsoleSetting struct { type ConsoleSetting struct {
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
} }
// 默认配置 // 默认配置
var defaultConsoleSetting = ConsoleSetting{ var defaultConsoleSetting = ConsoleSetting{
ApiInfo: "", ApiInfo: "",
UptimeKumaGroups: "", UptimeKumaGroups: "",
Announcements: "", Announcements: "",
FAQ: "", FAQ: "",
ApiInfoEnabled: true, ApiInfoEnabled: true,
UptimeKumaEnabled: true, UptimeKumaEnabled: true,
AnnouncementsEnabled: true, AnnouncementsEnabled: true,
FAQEnabled: true, FAQEnabled: true,
} }
// 全局实例 // 全局实例
var consoleSetting = defaultConsoleSetting var consoleSetting = defaultConsoleSetting
func init() { func init() {
// 注册到全局配置管理器,键名为 console_setting // 注册到全局配置管理器,键名为 console_setting
config.GlobalConfig.Register("console_setting", &consoleSetting) config.GlobalConfig.Register("console_setting", &consoleSetting)
} }
// GetConsoleSetting 获取 ConsoleSetting 配置实例 // GetConsoleSetting 获取 ConsoleSetting 配置实例
func GetConsoleSetting() *ConsoleSetting { func GetConsoleSetting() *ConsoleSetting {
return &consoleSetting return &consoleSetting
} }

View File

@ -1,304 +1,304 @@
package console_setting package console_setting
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"regexp" "regexp"
"strings" "sort"
"time" "strings"
"sort" "time"
) )
var ( var (
urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`) urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`)
dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="} dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
validColors = map[string]bool{ validColors = map[string]bool{
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true, "blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true, "red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
"light-green": true, "teal": true, "light-blue": true, "indigo": true, "light-green": true, "teal": true, "light-blue": true, "indigo": true,
"violet": true, "grey": true, "violet": true, "grey": true,
} }
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
) )
func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) { func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
var list []map[string]interface{} var list []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil { if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
return nil, fmt.Errorf("%s格式错误%s", typeName, err.Error()) return nil, fmt.Errorf("%s格式错误%s", typeName, err.Error())
} }
return list, nil return list, nil
} }
func validateURL(urlStr string, index int, itemType string) error { func validateURL(urlStr string, index int, itemType string) error {
if !urlRegex.MatchString(urlStr) { if !urlRegex.MatchString(urlStr) {
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType) return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
} }
if _, err := url.Parse(urlStr); err != nil { if _, err := url.Parse(urlStr); err != nil {
return fmt.Errorf("第%d个%s的URL无法解析%s", index, itemType, err.Error()) return fmt.Errorf("第%d个%s的URL无法解析%s", index, itemType, err.Error())
} }
return nil return nil
} }
func checkDangerousContent(content string, index int, itemType string) error { func checkDangerousContent(content string, index int, itemType string) error {
lower := strings.ToLower(content) lower := strings.ToLower(content)
for _, d := range dangerousChars { for _, d := range dangerousChars {
if strings.Contains(lower, d) { if strings.Contains(lower, d) {
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType) return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
} }
} }
return nil return nil
} }
func getJSONList(jsonStr string) []map[string]interface{} { func getJSONList(jsonStr string) []map[string]interface{} {
if jsonStr == "" { if jsonStr == "" {
return []map[string]interface{}{} return []map[string]interface{}{}
} }
var list []map[string]interface{} var list []map[string]interface{}
json.Unmarshal([]byte(jsonStr), &list) json.Unmarshal([]byte(jsonStr), &list)
return list return list
} }
func ValidateConsoleSettings(settingsStr string, settingType string) error { func ValidateConsoleSettings(settingsStr string, settingType string) error {
if settingsStr == "" { if settingsStr == "" {
return nil return nil
} }
switch settingType { switch settingType {
case "ApiInfo": case "ApiInfo":
return validateApiInfo(settingsStr) return validateApiInfo(settingsStr)
case "Announcements": case "Announcements":
return validateAnnouncements(settingsStr) return validateAnnouncements(settingsStr)
case "FAQ": case "FAQ":
return validateFAQ(settingsStr) return validateFAQ(settingsStr)
case "UptimeKumaGroups": case "UptimeKumaGroups":
return validateUptimeKumaGroups(settingsStr) return validateUptimeKumaGroups(settingsStr)
default: default:
return fmt.Errorf("未知的设置类型:%s", settingType) return fmt.Errorf("未知的设置类型:%s", settingType)
} }
} }
func validateApiInfo(apiInfoStr string) error { func validateApiInfo(apiInfoStr string) error {
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息") apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
if err != nil { if err != nil {
return err return err
} }
if len(apiInfoList) > 50 { if len(apiInfoList) > 50 {
return fmt.Errorf("API信息数量不能超过50个") return fmt.Errorf("API信息数量不能超过50个")
} }
for i, apiInfo := range apiInfoList { for i, apiInfo := range apiInfoList {
urlStr, ok := apiInfo["url"].(string) urlStr, ok := apiInfo["url"].(string)
if !ok || urlStr == "" { if !ok || urlStr == "" {
return fmt.Errorf("第%d个API信息缺少URL字段", i+1) return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
} }
route, ok := apiInfo["route"].(string) route, ok := apiInfo["route"].(string)
if !ok || route == "" { if !ok || route == "" {
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
} }
description, ok := apiInfo["description"].(string) description, ok := apiInfo["description"].(string)
if !ok || description == "" { if !ok || description == "" {
return fmt.Errorf("第%d个API信息缺少说明字段", i+1) return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
} }
color, ok := apiInfo["color"].(string) color, ok := apiInfo["color"].(string)
if !ok || color == "" { if !ok || color == "" {
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
} }
if err := validateURL(urlStr, i+1, "API信息"); err != nil { if err := validateURL(urlStr, i+1, "API信息"); err != nil {
return err return err
} }
if len(urlStr) > 500 { if len(urlStr) > 500 {
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
} }
if len(route) > 100 { if len(route) > 100 {
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
} }
if len(description) > 200 { if len(description) > 200 {
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
} }
if !validColors[color] { if !validColors[color] {
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
} }
if err := checkDangerousContent(description, i+1, "API信息"); err != nil { if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
return err return err
} }
if err := checkDangerousContent(route, i+1, "API信息"); err != nil { if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
return err return err
} }
} }
return nil return nil
} }
func GetApiInfo() []map[string]interface{} { func GetApiInfo() []map[string]interface{} {
return getJSONList(GetConsoleSetting().ApiInfo) return getJSONList(GetConsoleSetting().ApiInfo)
} }
func validateAnnouncements(announcementsStr string) error { func validateAnnouncements(announcementsStr string) error {
list, err := parseJSONArray(announcementsStr, "系统公告") list, err := parseJSONArray(announcementsStr, "系统公告")
if err != nil { if err != nil {
return err return err
} }
if len(list) > 100 { if len(list) > 100 {
return fmt.Errorf("系统公告数量不能超过100个") return fmt.Errorf("系统公告数量不能超过100个")
} }
validTypes := map[string]bool{ validTypes := map[string]bool{
"default": true, "ongoing": true, "success": true, "warning": true, "error": true, "default": true, "ongoing": true, "success": true, "warning": true, "error": true,
} }
for i, ann := range list { for i, ann := range list {
content, ok := ann["content"].(string) content, ok := ann["content"].(string)
if !ok || content == "" { if !ok || content == "" {
return fmt.Errorf("第%d个公告缺少内容字段", i+1) return fmt.Errorf("第%d个公告缺少内容字段", i+1)
} }
publishDateAny, exists := ann["publishDate"] publishDateAny, exists := ann["publishDate"]
if !exists { if !exists {
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1) return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
} }
publishDateStr, ok := publishDateAny.(string) publishDateStr, ok := publishDateAny.(string)
if !ok || publishDateStr == "" { if !ok || publishDateStr == "" {
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1) return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
} }
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil { if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1) return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
} }
if t, exists := ann["type"]; exists { if t, exists := ann["type"]; exists {
if typeStr, ok := t.(string); ok { if typeStr, ok := t.(string); ok {
if !validTypes[typeStr] { if !validTypes[typeStr] {
return fmt.Errorf("第%d个公告的类型值不合法", i+1) return fmt.Errorf("第%d个公告的类型值不合法", i+1)
} }
} }
} }
if len(content) > 500 { if len(content) > 500 {
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
} }
if extra, exists := ann["extra"]; exists { if extra, exists := ann["extra"]; exists {
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
} }
} }
} }
return nil return nil
} }
func validateFAQ(faqStr string) error { func validateFAQ(faqStr string) error {
list, err := parseJSONArray(faqStr, "FAQ信息") list, err := parseJSONArray(faqStr, "FAQ信息")
if err != nil { if err != nil {
return err return err
} }
if len(list) > 100 { if len(list) > 100 {
return fmt.Errorf("FAQ数量不能超过100个") return fmt.Errorf("FAQ数量不能超过100个")
} }
for i, faq := range list { for i, faq := range list {
question, ok := faq["question"].(string) question, ok := faq["question"].(string)
if !ok || question == "" { if !ok || question == "" {
return fmt.Errorf("第%d个FAQ缺少问题字段", i+1) return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
} }
answer, ok := faq["answer"].(string) answer, ok := faq["answer"].(string)
if !ok || answer == "" { if !ok || answer == "" {
return fmt.Errorf("第%d个FAQ缺少答案字段", i+1) return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
} }
if len(question) > 200 { if len(question) > 200 {
return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1) return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
} }
if len(answer) > 1000 { if len(answer) > 1000 {
return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1) return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
} }
} }
return nil return nil
} }
func getPublishTime(item map[string]interface{}) time.Time { func getPublishTime(item map[string]interface{}) time.Time {
if v, ok := item["publishDate"]; ok { if v, ok := item["publishDate"]; ok {
if s, ok2 := v.(string); ok2 { if s, ok2 := v.(string); ok2 {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t, err := time.Parse(time.RFC3339, s); err == nil {
return t return t
} }
} }
} }
return time.Time{} return time.Time{}
} }
func GetAnnouncements() []map[string]interface{} { func GetAnnouncements() []map[string]interface{} {
list := getJSONList(GetConsoleSetting().Announcements) list := getJSONList(GetConsoleSetting().Announcements)
sort.SliceStable(list, func(i, j int) bool { sort.SliceStable(list, func(i, j int) bool {
return getPublishTime(list[i]).After(getPublishTime(list[j])) return getPublishTime(list[i]).After(getPublishTime(list[j]))
}) })
return list return list
} }
func GetFAQ() []map[string]interface{} { func GetFAQ() []map[string]interface{} {
return getJSONList(GetConsoleSetting().FAQ) return getJSONList(GetConsoleSetting().FAQ)
} }
func validateUptimeKumaGroups(groupsStr string) error { func validateUptimeKumaGroups(groupsStr string) error {
groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置") groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
if err != nil { if err != nil {
return err return err
} }
if len(groups) > 20 { if len(groups) > 20 {
return fmt.Errorf("Uptime Kuma分组数量不能超过20个") return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
} }
nameSet := make(map[string]bool) nameSet := make(map[string]bool)
for i, group := range groups { for i, group := range groups {
categoryName, ok := group["categoryName"].(string) categoryName, ok := group["categoryName"].(string)
if !ok || categoryName == "" { if !ok || categoryName == "" {
return fmt.Errorf("第%d个分组缺少分类名称字段", i+1) return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
} }
if nameSet[categoryName] { if nameSet[categoryName] {
return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1) return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
} }
nameSet[categoryName] = true nameSet[categoryName] = true
urlStr, ok := group["url"].(string) urlStr, ok := group["url"].(string)
if !ok || urlStr == "" { if !ok || urlStr == "" {
return fmt.Errorf("第%d个分组缺少URL字段", i+1) return fmt.Errorf("第%d个分组缺少URL字段", i+1)
} }
slug, ok := group["slug"].(string) slug, ok := group["slug"].(string)
if !ok || slug == "" { if !ok || slug == "" {
return fmt.Errorf("第%d个分组缺少Slug字段", i+1) return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
} }
description, ok := group["description"].(string) description, ok := group["description"].(string)
if !ok { if !ok {
description = "" description = ""
} }
if err := validateURL(urlStr, i+1, "分组"); err != nil { if err := validateURL(urlStr, i+1, "分组"); err != nil {
return err return err
} }
if len(categoryName) > 50 { if len(categoryName) > 50 {
return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
} }
if len(urlStr) > 500 { if len(urlStr) > 500 {
return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
} }
if len(slug) > 100 { if len(slug) > 100 {
return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
} }
if len(description) > 200 { if len(description) > 200 {
return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
} }
if !slugRegex.MatchString(slug) { if !slugRegex.MatchString(slug) {
return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1) return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
} }
if err := checkDangerousContent(description, i+1, "分组"); err != nil { if err := checkDangerousContent(description, i+1, "分组"); err != nil {
return err return err
} }
if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil { if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
return err return err
} }
} }
return nil return nil
} }
func GetUptimeKumaGroups() []map[string]interface{} { func GetUptimeKumaGroups() []map[string]interface{} {
return getJSONList(GetConsoleSetting().UptimeKumaGroups) return getJSONList(GetConsoleSetting().UptimeKumaGroups)
} }

View File

@ -26,6 +26,7 @@ var defaultGeminiSettings = GeminiSettings{
SupportedImagineModels: []string{ SupportedImagineModels: []string{
"gemini-2.0-flash-exp-image-generation", "gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-exp", "gemini-2.0-flash-exp",
"gemini-2.5-flash-image-preview",
}, },
ThinkingAdapterEnabled: false, ThinkingAdapterEnabled: false,
ThinkingAdapterBudgetTokensPercentage: 0.6, ThinkingAdapterBudgetTokensPercentage: 0.6,

View File

@ -0,0 +1,34 @@
package operation_setting
import (
"one-api/setting/config"
"os"
"strconv"
)
type MonitorSetting struct {
AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"`
AutoTestChannelMinutes int `json:"auto_test_channel_minutes"`
}
// 默认配置
var monitorSetting = MonitorSetting{
AutoTestChannelEnabled: false,
AutoTestChannelMinutes: 10,
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("monitor_setting", &monitorSetting)
}
func GetMonitorSetting() *MonitorSetting {
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
if err == nil && frequency > 0 {
monitorSetting.AutoTestChannelEnabled = true
monitorSetting.AutoTestChannelMinutes = frequency
}
}
return &monitorSetting
}

View File

@ -24,6 +24,10 @@ const (
ClaudeWebSearchPrice = 10.00 ClaudeWebSearchPrice = 10.00
) )
const (
Gemini25FlashImagePreviewImageOutputPrice = 30.00
)
func GetClaudeWebSearchPricePerThousand() float64 { func GetClaudeWebSearchPricePerThousand() float64 {
return ClaudeWebSearchPrice return ClaudeWebSearchPrice
} }
@ -65,3 +69,10 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
} }
return 0 return 0
} }
func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 {
if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
return Gemini25FlashImagePreviewImageOutputPrice
}
return 0
}

View File

@ -5,13 +5,13 @@ import "sync/atomic"
var exposeRatioEnabled atomic.Bool var exposeRatioEnabled atomic.Bool
func init() { func init() {
exposeRatioEnabled.Store(false) exposeRatioEnabled.Store(false)
} }
func SetExposeRatioEnabled(enabled bool) { func SetExposeRatioEnabled(enabled bool) {
exposeRatioEnabled.Store(enabled) exposeRatioEnabled.Store(enabled)
} }
func IsExposeRatioEnabled() bool { func IsExposeRatioEnabled() bool {
return exposeRatioEnabled.Load() return exposeRatioEnabled.Load()
} }

View File

@ -1,55 +1,55 @@
package ratio_setting package ratio_setting
import ( import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const exposedDataTTL = 30 * time.Second const exposedDataTTL = 30 * time.Second
type exposedCache struct { type exposedCache struct {
data gin.H data gin.H
expiresAt time.Time expiresAt time.Time
} }
var ( var (
exposedData atomic.Value exposedData atomic.Value
rebuildMu sync.Mutex rebuildMu sync.Mutex
) )
func InvalidateExposedDataCache() { func InvalidateExposedDataCache() {
exposedData.Store((*exposedCache)(nil)) exposedData.Store((*exposedCache)(nil))
} }
func cloneGinH(src gin.H) gin.H { func cloneGinH(src gin.H) gin.H {
dst := make(gin.H, len(src)) dst := make(gin.H, len(src))
for k, v := range src { for k, v := range src {
dst[k] = v dst[k] = v
} }
return dst return dst
} }
func GetExposedData() gin.H { func GetExposedData() gin.H {
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
return cloneGinH(c.data) return cloneGinH(c.data)
} }
rebuildMu.Lock() rebuildMu.Lock()
defer rebuildMu.Unlock() defer rebuildMu.Unlock()
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
return cloneGinH(c.data) return cloneGinH(c.data)
} }
newData := gin.H{ newData := gin.H{
"model_ratio": GetModelRatioCopy(), "model_ratio": GetModelRatioCopy(),
"completion_ratio": GetCompletionRatioCopy(), "completion_ratio": GetCompletionRatioCopy(),
"cache_ratio": GetCacheRatioCopy(), "cache_ratio": GetCacheRatioCopy(),
"model_price": GetModelPriceCopy(), "model_price": GetModelPriceCopy(),
} }
exposedData.Store(&exposedCache{ exposedData.Store(&exposedCache{
data: newData, data: newData,
expiresAt: time.Now().Add(exposedDataTTL), expiresAt: time.Now().Add(exposedDataTTL),
}) })
return cloneGinH(newData) return cloneGinH(newData)
} }

View File

@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05, "gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05, "gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15, "gemini-2.5-flash": 0.15,
"gemini-2.5-flash-image-preview": 0.15, // $0.30text/image) / 1M tokens
"text-embedding-004": 0.001, "text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@ -293,10 +294,11 @@ var (
) )
var defaultCompletionRatio = map[string]float64{ var defaultCompletionRatio = map[string]float64{
"gpt-4-gizmo-*": 2, "gpt-4-gizmo-*": 2,
"gpt-4o-gizmo-*": 3, "gpt-4o-gizmo-*": 3,
"gpt-4-all": 2, "gpt-4-all": 2,
"gpt-image-1": 8, "gpt-image-1": 8,
"gemini-2.5-flash-image-preview": 8.3333333333,
} }
// InitRatioSettings initializes all model related settings maps // InitRatioSettings initializes all model related settings maps
@ -541,7 +543,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
if strings.HasPrefix(name, "gemini-2.5-flash-lite") { if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
return 4, false return 4, false
} }
return 2.5 / 0.3, true return 2.5 / 0.3, false
} }
return 4, false return 4, false
} }

View File

@ -145,13 +145,15 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
Code: e.errorCode, Code: e.errorCode,
} }
} }
default:
result = OpenAIError{
Message: e.Error(),
Type: string(e.errorType),
Param: "",
Code: e.errorCode,
}
} }
result = OpenAIError{
Message: e.Error(),
Type: string(e.errorType),
Param: "",
Code: e.errorCode,
}
result.Message = common.MaskSensitiveInfo(result.Message) result.Message = common.MaskSensitiveInfo(result.Message)
return result return result
} }
@ -160,13 +162,16 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
var result ClaudeError var result ClaudeError
switch e.errorType { switch e.errorType {
case ErrorTypeOpenAIError: case ErrorTypeOpenAIError:
openAIError := e.RelayError.(OpenAIError) if openAIError, ok := e.RelayError.(OpenAIError); ok {
result = ClaudeError{ result = ClaudeError{
Message: e.Error(), Message: e.Error(),
Type: fmt.Sprintf("%v", openAIError.Code), Type: fmt.Sprintf("%v", openAIError.Code),
}
} }
case ErrorTypeClaudeError: case ErrorTypeClaudeError:
result = e.RelayError.(ClaudeError) if claudeError, ok := e.RelayError.(ClaudeError); ok {
result = claudeError
}
default: default:
result = ClaudeError{ result = ClaudeError{
Message: e.Error(), Message: e.Error(),
@ -180,6 +185,14 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
type NewAPIErrorOptions func(*NewAPIError) type NewAPIErrorOptions func(*NewAPIError)
func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError { func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError {
var newErr *NewAPIError
// 保留深层传递的 new err
if errors.As(err, &newErr) {
for _, op := range ops {
op(newErr)
}
return newErr
}
e := &NewAPIError{ e := &NewAPIError{
Err: err, Err: err,
RelayError: nil, RelayError: nil,
@ -194,8 +207,21 @@ func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPI
} }
func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
if errorCode == ErrorCodeDoRequestFailed { var newErr *NewAPIError
err = errors.New("upstream error: do request failed") // 保留深层传递的 new err
if errors.As(err, &newErr) {
if newErr.RelayError == nil {
openaiError := OpenAIError{
Message: newErr.Error(),
Type: string(errorCode),
Code: errorCode,
}
newErr.RelayError = openaiError
}
for _, op := range ops {
op(newErr)
}
return newErr
} }
openaiError := OpenAIError{ openaiError := OpenAIError{
Message: err.Error(), Message: err.Error(),
@ -300,6 +326,15 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions {
} }
} }
func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions {
return func(e *NewAPIError) {
if common.DebugEnabled {
fmt.Printf("ErrOptionWithHideErrMsg: %s, origin error: %s", replaceStr, e.Err)
}
e.Err = errors.New(replaceStr)
}
}
func IsRecordErrorLog(e *NewAPIError) bool { func IsRecordErrorLog(e *NewAPIError) bool {
if e == nil { if e == nil {
return false return false

View File

@ -1,34 +1,42 @@
module.exports = { module.exports = {
root: true, root: true,
env: { browser: true, es2021: true, node: true }, env: { browser: true, es2021: true, node: true },
parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } }, parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
plugins: ['header', 'react-hooks'], plugins: ['header', 'react-hooks'],
overrides: [ overrides: [
{ {
files: ['**/*.{js,jsx}'], files: ['**/*.{js,jsx}'],
rules: { rules: {
'header/header': [2, 'block', [ 'header/header': [
'', 2,
'Copyright (C) 2025 QuantumNous', 'block',
'', [
'This program is free software: you can redistribute it and/or modify', '',
'it under the terms of the GNU Affero General Public License as', 'Copyright (C) 2025 QuantumNous',
'published by the Free Software Foundation, either version 3 of the', '',
'License, or (at your option) any later version.', 'This program is free software: you can redistribute it and/or modify',
'', 'it under the terms of the GNU Affero General Public License as',
'This program is distributed in the hope that it will be useful,', 'published by the Free Software Foundation, either version 3 of the',
'but WITHOUT ANY WARRANTY; without even the implied warranty of', 'License, or (at your option) any later version.',
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the', '',
'GNU Affero General Public License for more details.', 'This program is distributed in the hope that it will be useful,',
'', 'but WITHOUT ANY WARRANTY; without even the implied warranty of',
'You should have received a copy of the GNU Affero General Public License', 'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
'along with this program. If not, see <https://www.gnu.org/licenses/>.', 'GNU Affero General Public License for more details.',
'', '',
'For commercial licensing, please contact support@quantumnous.com', '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/>.',
]], '',
'no-multiple-empty-lines': ['error', { max: 1 }] 'For commercial licensing, please contact support@quantumnous.com',
} '',
} ],
] ],
}; 'no-multiple-empty-lines': ['error', { max: 1 }],
},
},
],
};

View File

@ -1,19 +1,20 @@
<!doctype html> <!doctype html>
<html lang="zh"> <html lang="zh">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/>
<title>New API</title>
</head>
<head> <body>
<meta charset="utf-8" /> <noscript>You need to enable JavaScript to run this app.</noscript>
<link rel="icon" href="/logo.png" /> <div id="root"></div>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <script type="module" src="/src/index.jsx"></script>
<meta name="theme-color" content="#ffffff" /> </body>
<meta name="description" content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用" /> </html>
<title>New API</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

View File

@ -22,4 +22,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

View File

@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { lazy, Suspense } from 'react'; import React, { lazy, Suspense, useContext, useMemo } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom'; import { Route, Routes, useLocation } from 'react-router-dom';
import Loading from './components/common/ui/Loading'; import Loading from './components/common/ui/Loading';
import User from './pages/User'; import User from './pages/User';
@ -27,6 +27,7 @@ import LoginForm from './components/auth/LoginForm';
import NotFound from './pages/NotFound'; import NotFound from './pages/NotFound';
import Forbidden from './pages/Forbidden'; import Forbidden from './pages/Forbidden';
import Setting from './pages/Setting'; import Setting from './pages/Setting';
import { StatusContext } from './context/Status';
import PasswordResetForm from './components/auth/PasswordResetForm'; import PasswordResetForm from './components/auth/PasswordResetForm';
import PasswordResetConfirm from './components/auth/PasswordResetConfirm'; import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
@ -53,6 +54,29 @@ const About = lazy(() => import('./pages/About'));
function App() { function App() {
const location = useLocation(); const location = useLocation();
const [statusState] = useContext(StatusContext);
// 广
const pricingRequireAuth = useMemo(() => {
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
if (headerNavModulesConfig) {
try {
const modules = JSON.parse(headerNavModulesConfig);
// pricingboolean
if (typeof modules.pricing === 'boolean') {
return false; //
}
// 使requireAuth
return modules.pricing?.requireAuth === true;
} catch (error) {
console.error('解析顶栏模块配置失败:', error);
return false; //
}
}
return false; //
}, [statusState?.status?.HeaderNavModules]);
return ( return (
<SetupCheck> <SetupCheck>
@ -73,10 +97,7 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route <Route path='/forbidden' element={<Forbidden />} />
path='/forbidden'
element={<Forbidden />}
/>
<Route <Route
path='/console/models' path='/console/models'
element={ element={
@ -256,9 +277,20 @@ function App() {
<Route <Route
path='/pricing' path='/pricing'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> pricingRequireAuth ? (
<Pricing /> <PrivateRoute>
</Suspense> <Suspense
fallback={<Loading></Loading>}
key={location.pathname}
>
<Pricing />
</Suspense>
</PrivateRoute>
) : (
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Pricing />
</Suspense>
)
} }
/> />
<Route <Route

View File

@ -31,17 +31,10 @@ import {
setUserData, setUserData,
onGitHubOAuthClicked, onGitHubOAuthClicked,
onOIDCClicked, onOIDCClicked,
onLinuxDOOAuthClicked onLinuxDOOAuthClicked,
} from '../../helpers'; } from '../../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login'; import TelegramLoginButton from 'react-telegram-login';
@ -77,7 +70,8 @@ const LoginForm = () => {
const [emailLoginLoading, setEmailLoginLoading] = useState(false); const [emailLoginLoading, setEmailLoginLoading] = useState(false);
const [loginLoading, setLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [showTwoFA, setShowTwoFA] = useState(false); const [showTwoFA, setShowTwoFA] = useState(false);
@ -247,10 +241,7 @@ const LoginForm = () => {
const handleOIDCClick = () => { const handleOIDCClick = () => {
setOidcLoading(true); setOidcLoading(true);
try { try {
onOIDCClicked( onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
status.oidc_authorization_endpoint,
status.oidc_client_id
);
} finally { } finally {
// //
setTimeout(() => setOidcLoading(false), 3000); setTimeout(() => setOidcLoading(false), 3000);
@ -306,73 +297,87 @@ const LoginForm = () => {
const renderOAuthOptions = () => { const renderOAuthOptions = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<div className="space-y-3"> <div className='space-y-3'>
{status.wechat_login && ( {status.wechat_login && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />} icon={
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
}
onClick={onWeChatLoginClicked} onClick={onWeChatLoginClicked}
loading={wechatLoading} loading={wechatLoading}
> >
<span className="ml-3">{t('使用 微信 继续')}</span> <span className='ml-3'>{t('使用 微信 继续')}</span>
</Button> </Button>
)} )}
{status.github_oauth && ( {status.github_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<IconGithubLogo size="large" />} icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick} onClick={handleGitHubClick}
loading={githubLoading} loading={githubLoading}
> >
<span className="ml-3">{t('使用 GitHub 继续')}</span> <span className='ml-3'>{t('使用 GitHub 继续')}</span>
</Button> </Button>
)} )}
{status.oidc_enabled && ( {status.oidc_enabled && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<OIDCIcon style={{ color: '#1877F2' }} />} icon={<OIDCIcon style={{ color: '#1877F2' }} />}
onClick={handleOIDCClick} onClick={handleOIDCClick}
loading={oidcLoading} loading={oidcLoading}
> >
<span className="ml-3">{t('使用 OIDC 继续')}</span> <span className='ml-3'>{t('使用 OIDC 继续')}</span>
</Button> </Button>
)} )}
{status.linuxdo_oauth && ( {status.linuxdo_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />} icon={
<LinuxDoIcon
style={{
color: '#E95420',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleLinuxDOClick} onClick={handleLinuxDOClick}
loading={linuxdoLoading} loading={linuxdoLoading}
> >
<span className="ml-3">{t('使用 LinuxDO 继续')}</span> <span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button> </Button>
)} )}
{status.telegram_oauth && ( {status.telegram_oauth && (
<div className="flex justify-center my-2"> <div className='flex justify-center my-2'>
<TelegramLoginButton <TelegramLoginButton
dataOnauth={onTelegramLoginClicked} dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name} botName={status.telegram_bot_name}
@ -385,24 +390,24 @@ const LoginForm = () => {
</Divider> </Divider>
<Button <Button
theme="solid" theme='solid'
type="primary" type='primary'
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors" className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
icon={<IconMail size="large" />} icon={<IconMail size='large' />}
onClick={handleEmailLoginClick} onClick={handleEmailLoginClick}
loading={emailLoginLoading} loading={emailLoginLoading}
> >
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span> <span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
</Button> </Button>
</div> </div>
{!status.self_use_mode_enabled && ( {!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text> <Text>
{t('没有账户?')}{' '} {t('没有账户?')}{' '}
<Link <Link
to="/register" to='/register'
className="text-blue-600 hover:text-blue-800 font-medium" className='text-blue-600 hover:text-blue-800 font-medium'
> >
{t('注册')} {t('注册')}
</Link> </Link>
@ -418,44 +423,46 @@ const LoginForm = () => {
const renderEmailLoginForm = () => { const renderEmailLoginForm = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3}>{systemName}</Title> <Title heading={3}>{systemName}</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<Form className="space-y-3"> <Form className='space-y-3'>
<Form.Input <Form.Input
field="username" field='username'
label={t('用户名或邮箱')} label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')} placeholder={t('请输入您的用户名或邮箱地址')}
name="username" name='username'
onChange={(value) => handleChange('username', value)} onChange={(value) => handleChange('username', value)}
prefix={<IconMail />} prefix={<IconMail />}
/> />
<Form.Input <Form.Input
field="password" field='password'
label={t('密码')} label={t('密码')}
placeholder={t('请输入您的密码')} placeholder={t('请输入您的密码')}
name="password" name='password'
mode="password" mode='password'
onChange={(value) => handleChange('password', value)} onChange={(value) => handleChange('password', value)}
prefix={<IconLock />} prefix={<IconLock />}
/> />
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={loginLoading} loading={loginLoading}
> >
@ -463,9 +470,9 @@ const LoginForm = () => {
</Button> </Button>
<Button <Button
theme="borderless" theme='borderless'
type='tertiary' type='tertiary'
className="w-full !rounded-full" className='w-full !rounded-full'
onClick={handleResetPasswordClick} onClick={handleResetPasswordClick}
loading={resetPasswordLoading} loading={resetPasswordLoading}
> >
@ -474,17 +481,21 @@ const LoginForm = () => {
</div> </div>
</Form> </Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( {(status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth) && (
<> <>
<Divider margin='12px' align='center'> <Divider margin='12px' align='center'>
{t('或')} {t('或')}
</Divider> </Divider>
<div className="mt-4 text-center"> <div className='mt-4 text-center'>
<Button <Button
theme="outline" theme='outline'
type="tertiary" type='tertiary'
className="w-full !rounded-full" className='w-full !rounded-full'
onClick={handleOtherLoginOptionsClick} onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading} loading={otherLoginOptionsLoading}
> >
@ -495,12 +506,12 @@ const LoginForm = () => {
)} )}
{!status.self_use_mode_enabled && ( {!status.self_use_mode_enabled && (
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text> <Text>
{t('没有账户?')}{' '} {t('没有账户?')}{' '}
<Link <Link
to="/register" to='/register'
className="text-blue-600 hover:text-blue-800 font-medium" className='text-blue-600 hover:text-blue-800 font-medium'
> >
{t('注册')} {t('注册')}
</Link> </Link>
@ -529,21 +540,25 @@ const LoginForm = () => {
loading: wechatCodeSubmitLoading, loading: wechatCodeSubmitLoading,
}} }}
> >
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" /> <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
</div> </div>
<div className="text-center mb-4"> <div className='text-center mb-4'>
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p> <p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div> </div>
<Form> <Form>
<Form.Input <Form.Input
field="wechat_verification_code" field='wechat_verification_code'
placeholder={t('验证码')} placeholder={t('验证码')}
label={t('验证码')} label={t('验证码')}
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)} onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/> />
</Form> </Form>
</Modal> </Modal>
@ -555,10 +570,18 @@ const LoginForm = () => {
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center"> <div className='flex items-center'>
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3"> <div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z" clipRule="evenodd" /> className='w-4 h-4 text-green-600 dark:text-green-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
clipRule='evenodd'
/>
</svg> </svg>
</div> </div>
两步验证 两步验证
@ -580,19 +603,32 @@ const LoginForm = () => {
}; };
return ( return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */} {/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} /> <div
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) />
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
{showEmailLogin ||
!(
status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth
)
? renderEmailLoginForm() ? renderEmailLoginForm()
: renderOAuthOptions()} : renderOAuthOptions()}
{renderWeChatLoginModal()} {renderWeChatLoginModal()}
{render2FAModal()} {render2FAModal()}
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center mt-6"> <div className='flex justify-center mt-6'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {

View File

@ -20,7 +20,13 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers'; import {
API,
showError,
showSuccess,
updateAPI,
setUserData,
} from '../../helpers';
import { UserContext } from '../../context/User'; import { UserContext } from '../../context/User';
import Loading from '../common/ui/Loading'; import Loading from '../common/ui/Loading';

View File

@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers'; import {
API,
copy,
showError,
showNotice,
getLogo,
getSystemName,
} from '../../helpers';
import { useSearchParams, Link } from 'react-router-dom'; import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
@ -55,7 +62,7 @@ const PasswordResetConfirm = () => {
if (formApi) { if (formApi) {
formApi.setValues({ formApi.setValues({
email: email || '', email: email || '',
newPassword: newPassword || '' newPassword: newPassword || '',
}); });
} }
}, [searchParams, newPassword, formApi]); }, [searchParams, newPassword, formApi]);
@ -97,40 +104,53 @@ const PasswordResetConfirm = () => {
} }
return ( return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */} {/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} /> <div
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
<div className="flex flex-col items-center"> />
<div className="w-full max-w-md"> <div
<div className="flex items-center justify-center mb-6 gap-2"> className='blur-ball blur-ball-teal'
<img src={logo} alt="Logo" className="h-10 rounded-full" /> style={{ top: '50%', left: '-120px' }}
<Title heading={3} className='!text-gray-800'>{systemName}</Title> />
<div className='w-full max-w-sm mt-[60px]'>
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('密码重置确认')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
{!isValidResetLink && ( {!isValidResetLink && (
<Banner <Banner
type="danger" type='danger'
description={t('无效的重置链接,请重新发起密码重置请求')} description={t('无效的重置链接,请重新发起密码重置请求')}
className="mb-4 !rounded-lg" className='mb-4 !rounded-lg'
closeIcon={null} closeIcon={null}
/> />
)} )}
<Form <Form
getFormApi={(api) => setFormApi(api)} getFormApi={(api) => setFormApi(api)}
initValues={{ email: email || '', newPassword: newPassword || '' }} initValues={{
className="space-y-4" email: email || '',
newPassword: newPassword || '',
}}
className='space-y-4'
> >
<Form.Input <Form.Input
field="email" field='email'
label={t('邮箱')} label={t('邮箱')}
name="email" name='email'
disabled={true} disabled={true}
prefix={<IconMail />} prefix={<IconMail />}
placeholder={email ? '' : t('等待获取邮箱信息...')} placeholder={email ? '' : t('等待获取邮箱信息...')}
@ -138,19 +158,21 @@ const PasswordResetConfirm = () => {
{newPassword && ( {newPassword && (
<Form.Input <Form.Input
field="newPassword" field='newPassword'
label={t('新密码')} label={t('新密码')}
name="newPassword" name='newPassword'
disabled={true} disabled={true}
prefix={<IconLock />} prefix={<IconLock />}
suffix={ suffix={
<Button <Button
icon={<IconCopy />} icon={<IconCopy />}
type="tertiary" type='tertiary'
theme="borderless" theme='borderless'
onClick={async () => { onClick={async () => {
await copy(newPassword); await copy(newPassword);
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`); showNotice(
`${t('密码已复制到剪贴板:')} ${newPassword}`,
);
}} }}
> >
{t('复制')} {t('复制')}
@ -159,23 +181,32 @@ const PasswordResetConfirm = () => {
/> />
)} )}
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={disableButton || newPassword || !isValidResetLink} disabled={
disableButton || newPassword || !isValidResetLink
}
> >
{newPassword ? t('密码重置完成') : t('确认重置密码')} {newPassword ? t('密码重置完成') : t('确认重置密码')}
</Button> </Button>
</div> </div>
</Form> </Form>
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text><Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('返回登录')}</Link></Text> <Text>
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('返回登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>

View File

@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers'; import {
API,
getLogo,
showError,
showInfo,
showSuccess,
getSystemName,
} from '../../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons'; import { IconMail } from '@douyinfe/semi-icons';
@ -97,57 +104,77 @@ const PasswordResetForm = () => {
} }
return ( return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */} {/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} /> <div
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
<div className="flex flex-col items-center"> />
<div className="w-full max-w-md"> <div
<div className="flex items-center justify-center mb-6 gap-2"> className='blur-ball blur-ball-teal'
<img src={logo} alt="Logo" className="h-10 rounded-full" /> style={{ top: '50%', left: '-120px' }}
<Title heading={3} className='!text-gray-800'>{systemName}</Title> />
<div className='w-full max-w-sm mt-[60px]'>
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('密码重置')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<Form className="space-y-3"> <Form className='space-y-3'>
<Form.Input <Form.Input
field="email" field='email'
label={t('邮箱')} label={t('邮箱')}
placeholder={t('请输入您的邮箱地址')} placeholder={t('请输入您的邮箱地址')}
name="email" name='email'
value={email} value={email}
onChange={handleChange} onChange={handleChange}
prefix={<IconMail />} prefix={<IconMail />}
/> />
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={disableButton} disabled={disableButton}
> >
{disableButton ? `${t('重试')} (${countdown})` : t('提交')} {disableButton
? `${t('重试')} (${countdown})`
: t('提交')}
</Button> </Button>
</div> </div>
</Form> </Form>
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text> <Text>
{t('想起来了?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center mt-6"> <div className='flex justify-center mt-6'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {

View File

@ -27,20 +27,19 @@ import {
showSuccess, showSuccess,
updateAPI, updateAPI,
getSystemName, getSystemName,
setUserData setUserData,
} from '../../helpers'; } from '../../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons'; import {
IconGithubLogo,
IconMail,
IconUser,
IconLock,
IconKey,
} from '@douyinfe/semi-icons';
import { import {
onGitHubOAuthClicked, onGitHubOAuthClicked,
onLinuxDOOAuthClicked, onLinuxDOOAuthClicked,
@ -78,7 +77,8 @@ const RegisterForm = () => {
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
const [registerLoading, setRegisterLoading] = useState(false); const [registerLoading, setRegisterLoading] = useState(false);
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false); const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false); const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30); const [countdown, setCountdown] = useState(30);
@ -236,10 +236,7 @@ const RegisterForm = () => {
const handleOIDCClick = () => { const handleOIDCClick = () => {
setOidcLoading(true); setOidcLoading(true);
try { try {
onOIDCClicked( onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
status.oidc_authorization_endpoint,
status.oidc_client_id
);
} finally { } finally {
setTimeout(() => setOidcLoading(false), 3000); setTimeout(() => setOidcLoading(false), 3000);
} }
@ -303,73 +300,87 @@ const RegisterForm = () => {
const renderOAuthOptions = () => { const renderOAuthOptions = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('注 册')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<div className="space-y-3"> <div className='space-y-3'>
{status.wechat_login && ( {status.wechat_login && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />} icon={
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
}
onClick={onWeChatLoginClicked} onClick={onWeChatLoginClicked}
loading={wechatLoading} loading={wechatLoading}
> >
<span className="ml-3">{t('使用 微信 继续')}</span> <span className='ml-3'>{t('使用 微信 继续')}</span>
</Button> </Button>
)} )}
{status.github_oauth && ( {status.github_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<IconGithubLogo size="large" />} icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick} onClick={handleGitHubClick}
loading={githubLoading} loading={githubLoading}
> >
<span className="ml-3">{t('使用 GitHub 继续')}</span> <span className='ml-3'>{t('使用 GitHub 继续')}</span>
</Button> </Button>
)} )}
{status.oidc_enabled && ( {status.oidc_enabled && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<OIDCIcon style={{ color: '#1877F2' }} />} icon={<OIDCIcon style={{ color: '#1877F2' }} />}
onClick={handleOIDCClick} onClick={handleOIDCClick}
loading={oidcLoading} loading={oidcLoading}
> >
<span className="ml-3">{t('使用 OIDC 继续')}</span> <span className='ml-3'>{t('使用 OIDC 继续')}</span>
</Button> </Button>
)} )}
{status.linuxdo_oauth && ( {status.linuxdo_oauth && (
<Button <Button
theme='outline' theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors" className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type="tertiary" type='tertiary'
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />} icon={
<LinuxDoIcon
style={{
color: '#E95420',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleLinuxDOClick} onClick={handleLinuxDOClick}
loading={linuxdoLoading} loading={linuxdoLoading}
> >
<span className="ml-3">{t('使用 LinuxDO 继续')}</span> <span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button> </Button>
)} )}
{status.telegram_oauth && ( {status.telegram_oauth && (
<div className="flex justify-center my-2"> <div className='flex justify-center my-2'>
<TelegramLoginButton <TelegramLoginButton
dataOnauth={onTelegramLoginClicked} dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name} botName={status.telegram_bot_name}
@ -382,19 +393,27 @@ const RegisterForm = () => {
</Divider> </Divider>
<Button <Button
theme="solid" theme='solid'
type="primary" type='primary'
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors" className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
icon={<IconMail size="large" />} icon={<IconMail size='large' />}
onClick={handleEmailRegisterClick} onClick={handleEmailRegisterClick}
loading={emailRegisterLoading} loading={emailRegisterLoading}
> >
<span className="ml-3">{t('使用 用户名 注册')}</span> <span className='ml-3'>{t('使用 用户名 注册')}</span>
</Button> </Button>
</div> </div>
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text> <Text>
{t('已有账户?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>
@ -405,44 +424,48 @@ const RegisterForm = () => {
const renderEmailRegisterForm = () => { const renderEmailRegisterForm = () => {
return ( return (
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<div className="w-full max-w-md"> <div className='w-full max-w-md'>
<div className="flex items-center justify-center mb-6 gap-2"> <div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div> </div>
<Card className="border-0 !rounded-2xl overflow-hidden"> <Card className='border-0 !rounded-2xl overflow-hidden'>
<div className="flex justify-center pt-6 pb-2"> <div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title> <Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('注 册')}
</Title>
</div> </div>
<div className="px-2 py-8"> <div className='px-2 py-8'>
<Form className="space-y-3"> <Form className='space-y-3'>
<Form.Input <Form.Input
field="username" field='username'
label={t('用户名')} label={t('用户名')}
placeholder={t('请输入用户名')} placeholder={t('请输入用户名')}
name="username" name='username'
onChange={(value) => handleChange('username', value)} onChange={(value) => handleChange('username', value)}
prefix={<IconUser />} prefix={<IconUser />}
/> />
<Form.Input <Form.Input
field="password" field='password'
label={t('密码')} label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')} placeholder={t('输入密码,最短 8 位,最长 20 位')}
name="password" name='password'
mode="password" mode='password'
onChange={(value) => handleChange('password', value)} onChange={(value) => handleChange('password', value)}
prefix={<IconLock />} prefix={<IconLock />}
/> />
<Form.Input <Form.Input
field="password2" field='password2'
label={t('确认密码')} label={t('确认密码')}
placeholder={t('确认密码')} placeholder={t('确认密码')}
name="password2" name='password2'
mode="password" mode='password'
onChange={(value) => handleChange('password2', value)} onChange={(value) => handleChange('password2', value)}
prefix={<IconLock />} prefix={<IconLock />}
/> />
@ -450,11 +473,11 @@ const RegisterForm = () => {
{showEmailVerification && ( {showEmailVerification && (
<> <>
<Form.Input <Form.Input
field="email" field='email'
label={t('邮箱')} label={t('邮箱')}
placeholder={t('输入邮箱地址')} placeholder={t('输入邮箱地址')}
name="email" name='email'
type="email" type='email'
onChange={(value) => handleChange('email', value)} onChange={(value) => handleChange('email', value)}
prefix={<IconMail />} prefix={<IconMail />}
suffix={ suffix={
@ -463,27 +486,31 @@ const RegisterForm = () => {
loading={verificationCodeLoading} loading={verificationCodeLoading}
disabled={disableButton || verificationCodeLoading} disabled={disableButton || verificationCodeLoading}
> >
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')} {disableButton
? `${t('重新发送')} (${countdown})`
: t('获取验证码')}
</Button> </Button>
} }
/> />
<Form.Input <Form.Input
field="verification_code" field='verification_code'
label={t('验证码')} label={t('验证码')}
placeholder={t('输入验证码')} placeholder={t('输入验证码')}
name="verification_code" name='verification_code'
onChange={(value) => handleChange('verification_code', value)} onChange={(value) =>
handleChange('verification_code', value)
}
prefix={<IconKey />} prefix={<IconKey />}
/> />
</> </>
)} )}
<div className="space-y-2 pt-2"> <div className='space-y-2 pt-2'>
<Button <Button
theme="solid" theme='solid'
className="w-full !rounded-full" className='w-full !rounded-full'
type="primary" type='primary'
htmlType="submit" htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={registerLoading} loading={registerLoading}
> >
@ -492,17 +519,21 @@ const RegisterForm = () => {
</div> </div>
</Form> </Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( {(status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth) && (
<> <>
<Divider margin='12px' align='center'> <Divider margin='12px' align='center'>
{t('或')} {t('或')}
</Divider> </Divider>
<div className="mt-4 text-center"> <div className='mt-4 text-center'>
<Button <Button
theme="outline" theme='outline'
type="tertiary" type='tertiary'
className="w-full !rounded-full" className='w-full !rounded-full'
onClick={handleOtherRegisterOptionsClick} onClick={handleOtherRegisterOptionsClick}
loading={otherRegisterOptionsLoading} loading={otherRegisterOptionsLoading}
> >
@ -512,8 +543,16 @@ const RegisterForm = () => {
</> </>
)} )}
<div className="mt-6 text-center text-sm"> <div className='mt-6 text-center text-sm'>
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text> <Text>
{t('已有账户?')}{' '}
<Link
to='/login'
className='text-blue-600 hover:text-blue-800 font-medium'
>
{t('登录')}
</Link>
</Text>
</div> </div>
</div> </div>
</Card> </Card>
@ -536,21 +575,25 @@ const RegisterForm = () => {
loading: wechatCodeSubmitLoading, loading: wechatCodeSubmitLoading,
}} }}
> >
<div className="flex flex-col items-center"> <div className='flex flex-col items-center'>
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" /> <img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
</div> </div>
<div className="text-center mb-4"> <div className='text-center mb-4'>
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p> <p>
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
</p>
</div> </div>
<Form> <Form>
<Form.Input <Form.Input
field="wechat_verification_code" field='wechat_verification_code'
placeholder={t('验证码')} placeholder={t('验证码')}
label={t('验证码')} label={t('验证码')}
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)} onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/> />
</Form> </Form>
</Modal> </Modal>
@ -558,18 +601,31 @@ const RegisterForm = () => {
}; };
return ( return (
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */} {/* 背景模糊晕染球 */}
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} /> <div
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} /> className='blur-ball blur-ball-indigo'
<div className="w-full max-w-sm mt-[60px]"> style={{ top: '-80px', right: '-80px', transform: 'none' }}
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) />
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
{showEmailRegister ||
!(
status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth
)
? renderEmailRegisterForm() ? renderEmailRegisterForm()
: renderOAuthOptions()} : renderOAuthOptions()}
{renderWeChatLoginModal()} {renderWeChatLoginModal()}
{turnstileEnabled && ( {turnstileEnabled && (
<div className="flex justify-center mt-6"> <div className='flex justify-center mt-6'>
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {

View File

@ -17,7 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { Button, Card, Divider, Form, Input, Typography } from '@douyinfe/semi-ui'; import {
Button,
Card,
Divider,
Form,
Input,
Typography,
} from '@douyinfe/semi-ui';
import React, { useState } from 'react'; import React, { useState } from 'react';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
@ -44,7 +51,7 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
setLoading(true); setLoading(true);
try { try {
const res = await API.post('/api/user/login/2fa', { const res = await API.post('/api/user/login/2fa', {
code: verificationCode code: verificationCode,
}); });
if (res.data.success) { if (res.data.success) {
@ -72,30 +79,30 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
if (isModal) { if (isModal) {
return ( return (
<div className="space-y-4"> <div className='space-y-4'>
<Paragraph className="text-gray-600 dark:text-gray-300"> <Paragraph className='text-gray-600 dark:text-gray-300'>
请输入认证器应用显示的验证码完成登录 请输入认证器应用显示的验证码完成登录
</Paragraph> </Paragraph>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.Input <Form.Input
field="code" field='code'
label={useBackupCode ? "备用码" : "验证码"} label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"} placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode} value={verificationCode}
onChange={setVerificationCode} onChange={setVerificationCode}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
autoFocus autoFocus
/> />
<Button <Button
htmlType="submit" htmlType='submit'
type="primary" type='primary'
loading={loading} loading={loading}
block block
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
验证并登录 验证并登录
@ -106,8 +113,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={() => { onClick={() => {
setUseBackupCode(!useBackupCode); setUseBackupCode(!useBackupCode);
setVerificationCode(''); setVerificationCode('');
@ -119,8 +126,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
{onBack && ( {onBack && (
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={onBack} onClick={onBack}
style={{ color: '#1890ff', padding: 0 }} style={{ color: '#1890ff', padding: 0 }}
> >
@ -129,15 +136,14 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
)} )}
</div> </div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
<Text size="small" type="secondary"> <Text size='small' type='secondary'>
<strong>提示</strong> <strong>提示</strong>
<br /> <br />
验证码每30秒更新一次 验证码每30秒更新一次
<br /> <br />
如果无法获取验证码请使用备用码 如果无法获取验证码请使用备用码
<br /> <br /> 每个备用码只能使用一次
每个备用码只能使用一次
</Text> </Text>
</div> </div>
</div> </div>
@ -145,39 +151,41 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
} }
return ( return (
<div style={{ <div
display: 'flex', style={{
justifyContent: 'center', display: 'flex',
alignItems: 'center', justifyContent: 'center',
minHeight: '60vh' alignItems: 'center',
}}> minHeight: '60vh',
}}
>
<Card style={{ width: 400, padding: 24 }}> <Card style={{ width: 400, padding: 24 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}> <div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title heading={3}>两步验证</Title> <Title heading={3}>两步验证</Title>
<Paragraph type="secondary"> <Paragraph type='secondary'>
请输入认证器应用显示的验证码完成登录 请输入认证器应用显示的验证码完成登录
</Paragraph> </Paragraph>
</div> </div>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.Input <Form.Input
field="code" field='code'
label={useBackupCode ? "备用码" : "验证码"} label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"} placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode} value={verificationCode}
onChange={setVerificationCode} onChange={setVerificationCode}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
autoFocus autoFocus
/> />
<Button <Button
htmlType="submit" htmlType='submit'
type="primary" type='primary'
loading={loading} loading={loading}
block block
size="large" size='large'
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
验证并登录 验证并登录
@ -188,8 +196,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={() => { onClick={() => {
setUseBackupCode(!useBackupCode); setUseBackupCode(!useBackupCode);
setVerificationCode(''); setVerificationCode('');
@ -201,8 +209,8 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
{onBack && ( {onBack && (
<Button <Button
theme="borderless" theme='borderless'
type="tertiary" type='tertiary'
onClick={onBack} onClick={onBack}
style={{ color: '#1890ff', padding: 0 }} style={{ color: '#1890ff', padding: 0 }}
> >
@ -211,15 +219,21 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
)} )}
</div> </div>
<div style={{ marginTop: 24, padding: 16, background: '#f6f8fa', borderRadius: 6 }}> <div
<Text size="small" type="secondary"> style={{
marginTop: 24,
padding: 16,
background: '#f6f8fa',
borderRadius: 6,
}}
>
<Text size='small' type='secondary'>
<strong>提示</strong> <strong>提示</strong>
<br /> <br />
验证码每30秒更新一次 验证码每30秒更新一次
<br /> <br />
如果无法获取验证码请使用备用码 如果无法获取验证码请使用备用码
<br /> <br /> 每个备用码只能使用一次
每个备用码只能使用一次
</Text> </Text>
</div> </div>
</Card> </Card>
@ -227,4 +241,4 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
); );
}; };
export default TwoFAVerification; export default TwoFAVerification;

View File

@ -160,7 +160,7 @@ export function PreCode(props) {
}} }}
> >
<div <div
className="copy-code-button" className='copy-code-button'
style={{ style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
@ -174,14 +174,15 @@ export function PreCode(props) {
> >
<Tooltip content={t('复制代码')}> <Tooltip content={t('复制代码')}>
<Button <Button
size="small" size='small'
theme="borderless" theme='borderless'
icon={<IconCopy />} icon={<IconCopy />}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (ref.current) { if (ref.current) {
const code = ref.current.querySelector('code')?.innerText ?? ''; const code =
ref.current.querySelector('code')?.innerText ?? '';
copy(code).then((success) => { copy(code).then((success) => {
if (success) { if (success) {
Toast.success(t('代码已复制到剪贴板')); Toast.success(t('代码已复制到剪贴板'));
@ -217,7 +218,13 @@ export function PreCode(props) {
backgroundColor: 'var(--semi-color-bg-1)', backgroundColor: 'var(--semi-color-bg-1)',
}} }}
> >
<div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}> <div
style={{
marginBottom: '8px',
fontSize: '12px',
color: 'var(--semi-color-text-2)',
}}
>
HTML预览: HTML预览:
</div> </div>
<div dangerouslySetInnerHTML={{ __html: htmlCode }} /> <div dangerouslySetInnerHTML={{ __html: htmlCode }} />
@ -258,7 +265,7 @@ function CustomCode(props) {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<Button size="small" onClick={toggleCollapsed} theme="solid"> <Button size='small' onClick={toggleCollapsed} theme='solid'>
{t('显示更多')} {t('显示更多')}
</Button> </Button>
</div> </div>
@ -367,7 +374,16 @@ function _MarkdownContent(props) {
components={{ components={{
pre: PreCode, pre: PreCode,
code: CustomCode, code: CustomCode,
p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />, p: (pProps) => (
<p
{...pProps}
dir='auto'
style={{
lineHeight: '1.6',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
a: (aProps) => { a: (aProps) => {
const href = aProps.href || ''; const href = aProps.href || '';
if (/\.(aac|mp3|opus|wav)$/.test(href)) { if (/\.(aac|mp3|opus|wav)$/.test(href)) {
@ -379,13 +395,16 @@ function _MarkdownContent(props) {
} }
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) { if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
return ( return (
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}> <video
controls
style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}
>
<source src={href} /> <source src={href} />
</video> </video>
); );
} }
const isInternal = /^\/#/i.test(href); const isInternal = /^\/#/i.test(href);
const target = isInternal ? '_self' : aProps.target ?? '_blank'; const target = isInternal ? '_self' : (aProps.target ?? '_blank');
return ( return (
<a <a
{...aProps} {...aProps}
@ -403,20 +422,84 @@ function _MarkdownContent(props) {
/> />
); );
}, },
h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, h1: (props) => (
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, <h1
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, {...props}
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, style={{
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, fontSize: '24px',
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />, fontWeight: 'bold',
margin: '20px 0 12px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h2: (props) => (
<h2
{...props}
style={{
fontSize: '20px',
fontWeight: 'bold',
margin: '18px 0 10px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h3: (props) => (
<h3
{...props}
style={{
fontSize: '18px',
fontWeight: 'bold',
margin: '16px 0 8px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h4: (props) => (
<h4
{...props}
style={{
fontSize: '16px',
fontWeight: 'bold',
margin: '14px 0 6px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h5: (props) => (
<h5
{...props}
style={{
fontSize: '14px',
fontWeight: 'bold',
margin: '12px 0 4px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
h6: (props) => (
<h6
{...props}
style={{
fontSize: '13px',
fontWeight: 'bold',
margin: '10px 0 4px 0',
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
}}
/>
),
blockquote: (props) => ( blockquote: (props) => (
<blockquote <blockquote
{...props} {...props}
style={{ style={{
borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)', borderLeft: isUserMessage
? '4px solid rgba(255, 255, 255, 0.5)'
: '4px solid var(--semi-color-primary)',
paddingLeft: '16px', paddingLeft: '16px',
margin: '12px 0', margin: '12px 0',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)', backgroundColor: isUserMessage
? 'rgba(255, 255, 255, 0.1)'
: 'var(--semi-color-fill-0)',
padding: '8px 16px', padding: '8px 16px',
borderRadius: '0 4px 4px 0', borderRadius: '0 4px 4px 0',
fontStyle: 'italic', fontStyle: 'italic',
@ -424,9 +507,36 @@ function _MarkdownContent(props) {
}} }}
/> />
), ),
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />, ul: (props) => (
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />, <ul
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />, {...props}
style={{
margin: '8px 0',
paddingLeft: '20px',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
ol: (props) => (
<ol
{...props}
style={{
margin: '8px 0',
paddingLeft: '20px',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
li: (props) => (
<li
{...props}
style={{
margin: '4px 0',
lineHeight: '1.6',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
table: (props) => ( table: (props) => (
<div style={{ overflow: 'auto', margin: '12px 0' }}> <div style={{ overflow: 'auto', margin: '12px 0' }}>
<table <table
@ -434,7 +544,9 @@ function _MarkdownContent(props) {
style={{ style={{
width: '100%', width: '100%',
borderCollapse: 'collapse', borderCollapse: 'collapse',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)', border: isUserMessage
? '1px solid rgba(255, 255, 255, 0.3)'
: '1px solid var(--semi-color-border)',
borderRadius: '6px', borderRadius: '6px',
overflow: 'hidden', overflow: 'hidden',
}} }}
@ -446,8 +558,12 @@ function _MarkdownContent(props) {
{...props} {...props}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)', backgroundColor: isUserMessage
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)', ? 'rgba(255, 255, 255, 0.2)'
: 'var(--semi-color-fill-1)',
border: isUserMessage
? '1px solid rgba(255, 255, 255, 0.3)'
: '1px solid var(--semi-color-border)',
fontWeight: 'bold', fontWeight: 'bold',
textAlign: 'left', textAlign: 'left',
color: isUserMessage ? 'white' : 'inherit', color: isUserMessage ? 'white' : 'inherit',
@ -459,7 +575,9 @@ function _MarkdownContent(props) {
{...props} {...props}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)', border: isUserMessage
? '1px solid rgba(255, 255, 255, 0.3)'
: '1px solid var(--semi-color-border)',
color: isUserMessage ? 'white' : 'inherit', color: isUserMessage ? 'white' : 'inherit',
}} }}
/> />
@ -496,25 +614,29 @@ export function MarkdownRenderer(props) {
color: 'var(--semi-color-text-0)', color: 'var(--semi-color-text-0)',
...style, ...style,
}} }}
dir="auto" dir='auto'
{...otherProps} {...otherProps}
> >
{loading ? ( {loading ? (
<div style={{ <div
display: 'flex', style={{
alignItems: 'center', display: 'flex',
gap: '8px', alignItems: 'center',
padding: '16px', gap: '8px',
color: 'var(--semi-color-text-2)', padding: '16px',
}}> color: 'var(--semi-color-text-2)',
<div style={{ }}
width: '16px', >
height: '16px', <div
border: '2px solid var(--semi-color-border)', style={{
borderTop: '2px solid var(--semi-color-primary)', width: '16px',
borderRadius: '50%', height: '16px',
animation: 'spin 1s linear infinite', border: '2px solid var(--semi-color-border)',
}} /> borderTop: '2px solid var(--semi-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
正在渲染... 正在渲染...
</div> </div>
) : ( ) : (
@ -529,4 +651,4 @@ export function MarkdownRenderer(props) {
); );
} }
export default MarkdownRenderer; export default MarkdownRenderer;

View File

@ -59,12 +59,12 @@
} }
.user-message a { .user-message a {
color: #87CEEB !important; color: #87ceeb !important;
/* 浅蓝色链接 */ /* 浅蓝色链接 */
} }
.user-message a:hover { .user-message a:hover {
color: #B0E0E6 !important; color: #b0e0e6 !important;
/* hover时更浅的蓝色 */ /* hover时更浅的蓝色 */
} }
@ -298,7 +298,12 @@ pre:hover .copy-code-button {
.markdown-body hr { .markdown-body hr {
border: none; border: none;
height: 1px; height: 1px;
background: linear-gradient(to right, transparent, var(--semi-color-border), transparent); background: linear-gradient(
to right,
transparent,
var(--semi-color-border),
transparent
);
margin: 24px 0; margin: 24px 0;
} }
@ -332,7 +337,7 @@ pre:hover .copy-code-button {
} }
/* 任务列表样式 */ /* 任务列表样式 */
.markdown-body input[type="checkbox"] { .markdown-body input[type='checkbox'] {
margin-right: 8px; margin-right: 8px;
transform: scale(1.1); transform: scale(1.1);
} }
@ -441,4 +446,4 @@ pre:hover .copy-code-button {
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: opacity, transform; will-change: opacity, transform;
} }

View File

@ -0,0 +1,146 @@
/*
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 from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
/**
* 可复用的两步验证模态框组件
* @param {Object} props
* @param {boolean} props.visible - 是否显示模态框
* @param {string} props.code - 验证码值
* @param {boolean} props.loading - 是否正在验证
* @param {Function} props.onCodeChange - 验证码变化回调
* @param {Function} props.onVerify - 验证回调
* @param {Function} props.onCancel - 取消回调
* @param {string} props.title - 模态框标题
* @param {string} props.description - 验证描述文本
* @param {string} props.placeholder - 输入框占位文本
*/
const TwoFactorAuthModal = ({
visible,
code,
loading,
onCodeChange,
onVerify,
onCancel,
title,
description,
placeholder,
}) => {
const { t } = useTranslation();
const handleKeyDown = (e) => {
if (e.key === 'Enter' && code && !loading) {
onVerify();
}
};
return (
<Modal
title={
<div className='flex items-center'>
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
<svg
className='w-4 h-4 text-blue-600 dark:text-blue-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
clipRule='evenodd'
/>
</svg>
</div>
{title || t('安全验证')}
</div>
}
visible={visible}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button
type='primary'
loading={loading}
disabled={!code || loading}
onClick={onVerify}
>
{t('验证')}
</Button>
</>
}
width={500}
style={{ maxWidth: '90vw' }}
>
<div className='space-y-6'>
{/* 安全提示 */}
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
<div className='flex items-start'>
<svg
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
<div>
<Typography.Text
strong
className='text-blue-800 dark:text-blue-200'
>
{t('安全验证')}
</Typography.Text>
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
{description || t('为了保护账户安全,请验证您的两步验证码。')}
</Typography.Text>
</div>
</div>
</div>
{/* 验证码输入 */}
<div>
<Typography.Text strong className='block mb-2'>
{t('验证身份')}
</Typography.Text>
<Input
placeholder={placeholder || t('请输入认证器验证码或备用码')}
value={code}
onChange={onCodeChange}
size='large'
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus
/>
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
{t('支持6位TOTP验证码或8位备用码')}
</Typography.Text>
</div>
</div>
</Modal>
);
};
export default TwoFactorAuthModal;

View File

@ -27,15 +27,15 @@ const { Text } = Typography;
/** /**
* CardPro 高级卡片组件 * CardPro 高级卡片组件
* *
* 布局分为6个区域 * 布局分为6个区域
* 1. 统计信息区域 (statsArea) * 1. 统计信息区域 (statsArea)
* 2. 描述信息区域 (descriptionArea) * 2. 描述信息区域 (descriptionArea)
* 3. 类型切换/标签区域 (tabsArea) * 3. 类型切换/标签区域 (tabsArea)
* 4. 操作按钮区域 (actionsArea) * 4. 操作按钮区域 (actionsArea)
* 5. 搜索表单区域 (searchArea) * 5. 搜索表单区域 (searchArea)
* 6. 分页区域 (paginationArea) - 固定在卡片底部 * 6. 分页区域 (paginationArea) - 固定在卡片底部
* *
* 支持三种布局类型 * 支持三种布局类型
* - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单 * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
* - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单 * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
@ -71,47 +71,38 @@ const CardPro = ({
const hasMobileHideableContent = actionsArea || searchArea; const hasMobileHideableContent = actionsArea || searchArea;
const renderHeader = () => { const renderHeader = () => {
const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea; const hasContent =
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
if (!hasContent) return null; if (!hasContent) return null;
return ( return (
<div className="flex flex-col w-full"> <div className='flex flex-col w-full'>
{/* 统计信息区域 - 用于type2 */} {/* 统计信息区域 - 用于type2 */}
{type === 'type2' && statsArea && ( {type === 'type2' && statsArea && <>{statsArea}</>}
<>
{statsArea}
</>
)}
{/* 描述信息区域 - 用于type1和type3 */} {/* 描述信息区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && descriptionArea && ( {(type === 'type1' || type === 'type3') && descriptionArea && (
<> <>{descriptionArea}</>
{descriptionArea}
</>
)} )}
{/* 第一个分隔线 - 在描述信息或统计信息后面 */} {/* 第一个分隔线 - 在描述信息或统计信息后面 */}
{((type === 'type1' || type === 'type3') && descriptionArea) || {((type === 'type1' || type === 'type3') && descriptionArea) ||
(type === 'type2' && statsArea) ? ( (type === 'type2' && statsArea) ? (
<Divider margin="12px" /> <Divider margin='12px' />
) : null} ) : null}
{/* 类型切换/标签区域 - 主要用于type3 */} {/* 类型切换/标签区域 - 主要用于type3 */}
{type === 'type3' && tabsArea && ( {type === 'type3' && tabsArea && <>{tabsArea}</>}
<>
{tabsArea}
</>
)}
{/* 移动端操作切换按钮 */} {/* 移动端操作切换按钮 */}
{isMobile && hasMobileHideableContent && ( {isMobile && hasMobileHideableContent && (
<> <>
<div className="w-full mb-2"> <div className='w-full mb-2'>
<Button <Button
onClick={toggleMobileActions} onClick={toggleMobileActions}
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />} icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
type="tertiary" type='tertiary'
size="small" size='small'
theme='outline' theme='outline'
block block
> >
@ -126,32 +117,24 @@ const CardPro = ({
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`} className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
> >
{/* 操作按钮区域 - 用于type1和type3 */} {/* 操作按钮区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && actionsArea && ( {(type === 'type1' || type === 'type3') &&
Array.isArray(actionsArea) ? ( actionsArea &&
(Array.isArray(actionsArea) ? (
actionsArea.map((area, idx) => ( actionsArea.map((area, idx) => (
<React.Fragment key={idx}> <React.Fragment key={idx}>
{idx !== 0 && <Divider />} {idx !== 0 && <Divider />}
<div className="w-full"> <div className='w-full'>{area}</div>
{area}
</div>
</React.Fragment> </React.Fragment>
)) ))
) : ( ) : (
<div className="w-full"> <div className='w-full'>{actionsArea}</div>
{actionsArea} ))}
</div>
)
)}
{/* 当同时存在操作区和搜索区时,插入分隔线 */} {/* 当同时存在操作区和搜索区时,插入分隔线 */}
{(actionsArea && searchArea) && <Divider />} {actionsArea && searchArea && <Divider />}
{/* 搜索表单区域 - 所有类型都可能有 */} {/* 搜索表单区域 - 所有类型都可能有 */}
{searchArea && ( {searchArea && <div className='w-full'>{searchArea}</div>}
<div className="w-full">
{searchArea}
</div>
)}
</div> </div>
</div> </div>
); );
@ -214,4 +197,4 @@ CardPro.propTypes = {
t: PropTypes.func, t: PropTypes.func,
}; };
export default CardPro; export default CardPro;

View File

@ -19,7 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui'; import {
Table,
Card,
Skeleton,
Pagination,
Empty,
Button,
Collapsible,
} from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../hooks/common/useIsMobile';
@ -27,7 +35,7 @@ import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTi
/** /**
* CardTable 响应式表格组件 * CardTable 响应式表格组件
* *
* 在桌面端渲染 Semi-UI Table 组件在移动端则将每一行数据渲染成 Card 形式 * 在桌面端渲染 Semi-UI Table 组件在移动端则将每一行数据渲染成 Card 形式
* 该组件与 Table 组件的大部分 API 保持一致只需将原 Table 换成 CardTable 即可 * 该组件与 Table 组件的大部分 API 保持一致只需将原 Table 换成 CardTable 即可
*/ */
@ -75,18 +83,22 @@ const CardTable = ({
const renderSkeletonCard = (key) => { const renderSkeletonCard = (key) => {
const placeholder = ( const placeholder = (
<div className="p-2"> <div className='p-2'>
{visibleCols.map((col, idx) => { {visibleCols.map((col, idx) => {
if (!col.title) { if (!col.title) {
return ( return (
<div key={idx} className="mt-2 flex justify-end"> <div key={idx} className='mt-2 flex justify-end'>
<Skeleton.Title active style={{ width: 100, height: 24 }} /> <Skeleton.Title active style={{ width: 100, height: 24 }} />
</div> </div>
); );
} }
return ( return (
<div key={idx} className="flex justify-between items-center py-1 border-b last:border-b-0 border-dashed" style={{ borderColor: 'var(--semi-color-border)' }}> <div
key={idx}
className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
style={{ borderColor: 'var(--semi-color-border)' }}
>
<Skeleton.Title active style={{ width: 80, height: 14 }} /> <Skeleton.Title active style={{ width: 80, height: 14 }} />
<Skeleton.Title <Skeleton.Title
active active
@ -103,14 +115,14 @@ const CardTable = ({
); );
return ( return (
<Card key={key} className="!rounded-2xl shadow-sm"> <Card key={key} className='!rounded-2xl shadow-sm'>
<Skeleton loading={true} active placeholder={placeholder}></Skeleton> <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
</Card> </Card>
); );
}; };
return ( return (
<div className="flex flex-col gap-2"> <div className='flex flex-col gap-2'>
{[1, 2, 3].map((i) => renderSkeletonCard(i))} {[1, 2, 3].map((i) => renderSkeletonCard(i))}
</div> </div>
); );
@ -127,9 +139,12 @@ const CardTable = ({
(!tableProps.rowExpandable || tableProps.rowExpandable(record)); (!tableProps.rowExpandable || tableProps.rowExpandable(record));
return ( return (
<Card key={rowKeyVal} className="!rounded-2xl shadow-sm"> <Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
{columns.map((col, colIdx) => { {columns.map((col, colIdx) => {
if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) { if (
tableProps?.visibleColumns &&
!tableProps.visibleColumns[col.key]
) {
return null; return null;
} }
@ -140,7 +155,7 @@ const CardTable = ({
if (!title) { if (!title) {
return ( return (
<div key={col.key || colIdx} className="mt-2 flex justify-end"> <div key={col.key || colIdx} className='mt-2 flex justify-end'>
{cellContent} {cellContent}
</div> </div>
); );
@ -149,14 +164,16 @@ const CardTable = ({
return ( return (
<div <div
key={col.key || colIdx} key={col.key || colIdx}
className="flex justify-between items-start py-1 border-b last:border-b-0 border-dashed" className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
style={{ borderColor: 'var(--semi-color-border)' }} style={{ borderColor: 'var(--semi-color-border)' }}
> >
<span className="font-medium text-gray-600 mr-2 whitespace-nowrap select-none"> <span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
{title} {title}
</span> </span>
<div className="flex-1 break-all flex justify-end items-center gap-1"> <div className='flex-1 break-all flex justify-end items-center gap-1'>
{cellContent !== undefined && cellContent !== null ? cellContent : '-'} {cellContent !== undefined && cellContent !== null
? cellContent
: '-'}
</div> </div>
</div> </div>
); );
@ -177,7 +194,7 @@ const CardTable = ({
{showDetails ? t('收起') : t('详情')} {showDetails ? t('收起') : t('详情')}
</Button> </Button>
<Collapsible isOpen={showDetails} keepDOM> <Collapsible isOpen={showDetails} keepDOM>
<div className="pt-2"> <div className='pt-2'>
{tableProps.expandedRowRender(record, index)} {tableProps.expandedRowRender(record, index)}
</div> </div>
</Collapsible> </Collapsible>
@ -190,19 +207,23 @@ const CardTable = ({
if (isEmpty) { if (isEmpty) {
if (tableProps.empty) return tableProps.empty; if (tableProps.empty) return tableProps.empty;
return ( return (
<div className="flex justify-center p-4"> <div className='flex justify-center p-4'>
<Empty description="No Data" /> <Empty description='No Data' />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col gap-2"> <div className='flex flex-col gap-2'>
{dataSource.map((record, index) => ( {dataSource.map((record, index) => (
<MobileRowCard key={getRowKey(record, index)} record={record} index={index} /> <MobileRowCard
key={getRowKey(record, index)}
record={record}
index={index}
/>
))} ))}
{!hidePagination && tableProps.pagination && dataSource.length > 0 && ( {!hidePagination && tableProps.pagination && dataSource.length > 0 && (
<div className="mt-2 flex justify-center"> <div className='mt-2 flex justify-center'>
<Pagination {...tableProps.pagination} /> <Pagination {...tableProps.pagination} />
</div> </div>
)} )}
@ -218,4 +239,4 @@ CardTable.propTypes = {
hidePagination: PropTypes.bool, hidePagination: PropTypes.bool,
}; };
export default CardTable; export default CardTable;

View File

@ -0,0 +1,280 @@
/*
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 from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui';
import { copy, showSuccess } from '../../../helpers';
/**
* 解析密钥数据支持多种格式
* @param {string} keyData - 密钥数据
* @param {Function} t - 翻译函数
* @returns {Array} 解析后的密钥数组
*/
const parseChannelKeys = (keyData, t) => {
if (!keyData) return [];
const trimmed = keyData.trim();
// JSONVertex AI
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((item, index) => ({
id: index,
content:
typeof item === 'string' ? item : JSON.stringify(item, null, 2),
type: typeof item === 'string' ? 'text' : 'json',
label: `${t('密钥')} ${index + 1}`,
}));
}
} catch (e) {
//
console.warn('Failed to parse JSON keys:', e);
}
}
//
const lines = trimmed.split('\n').filter((line) => line.trim());
if (lines.length > 1) {
return lines.map((line, index) => ({
id: index,
content: line.trim(),
type: 'text',
label: `${t('密钥')} ${index + 1}`,
}));
}
//
return [
{
id: 0,
content: trimmed,
type: trimmed.startsWith('{') ? 'json' : 'text',
label: t('密钥'),
},
];
};
/**
* 可复用的密钥显示组件
* @param {Object} props
* @param {string} props.keyData - 密钥数据
* @param {boolean} props.showSuccessIcon - 是否显示成功图标
* @param {string} props.successText - 成功文本
* @param {boolean} props.showWarning - 是否显示安全警告
* @param {string} props.warningText - 警告文本
*/
const ChannelKeyDisplay = ({
keyData,
showSuccessIcon = true,
successText,
showWarning = true,
warningText,
}) => {
const { t } = useTranslation();
const parsedKeys = parseChannelKeys(keyData, t);
const isMultipleKeys = parsedKeys.length > 1;
const handleCopyAll = () => {
copy(keyData);
showSuccess(t('所有密钥已复制到剪贴板'));
};
const handleCopyKey = (content) => {
copy(content);
showSuccess(t('密钥已复制到剪贴板'));
};
return (
<div className='space-y-4'>
{/* 成功状态 */}
{showSuccessIcon && (
<div className='flex items-center gap-2'>
<svg
className='w-5 h-5 text-green-600'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
clipRule='evenodd'
/>
</svg>
<Typography.Text strong className='text-green-700'>
{successText || t('验证成功')}
</Typography.Text>
</div>
)}
{/* 密钥内容 */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Typography.Text strong>
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
</Typography.Text>
{isMultipleKeys && (
<div className='flex items-center gap-2'>
<Typography.Text type='tertiary' size='small'>
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
</Typography.Text>
<Button
size='small'
type='primary'
theme='outline'
onClick={handleCopyAll}
>
{t('复制全部')}
</Button>
</div>
)}
</div>
<div className='space-y-3 max-h-80 overflow-auto'>
{parsedKeys.map((keyItem) => (
<Card
key={keyItem.id}
className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Typography.Text
strong
size='small'
className='text-gray-700 dark:text-gray-300'
>
{keyItem.label}
</Typography.Text>
<div className='flex items-center gap-2'>
{keyItem.type === 'json' && (
<Tag size='small' color='blue'>
{t('JSON')}
</Tag>
)}
<Button
size='small'
type='primary'
theme='outline'
icon={
<svg
className='w-3 h-3'
fill='currentColor'
viewBox='0 0 20 20'
>
<path d='M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z' />
<path d='M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z' />
</svg>
}
onClick={() => handleCopyKey(keyItem.content)}
>
{t('复制')}
</Button>
</div>
</div>
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto'>
<Typography.Text
code
className='text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200'
>
{keyItem.content}
</Typography.Text>
</div>
{keyItem.type === 'json' && (
<Typography.Text
type='tertiary'
size='small'
className='block'
>
{t('JSON格式密钥请确保格式正确')}
</Typography.Text>
)}
</div>
</Card>
))}
</div>
{isMultipleKeys && (
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
<Typography.Text
type='tertiary'
size='small'
className='text-blue-700 dark:text-blue-300'
>
<svg
className='w-4 h-4 inline mr-1'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
{t(
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
)}
</Typography.Text>
</div>
)}
</div>
{/* 安全警告 */}
{showWarning && (
<div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
<div className='flex items-start'>
<svg
className='w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
<div>
<Typography.Text
strong
className='text-yellow-800 dark:text-yellow-200'
>
{t('安全提醒')}
</Typography.Text>
<Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
{warningText ||
t(
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
)}
</Typography.Text>
</div>
</div>
</div>
)}
</div>
);
};
export default ChannelKeyDisplay;

View File

@ -65,4 +65,4 @@ CompactModeToggle.propTypes = {
className: PropTypes.string, className: PropTypes.string,
}; };
export default CompactModeToggle; export default CompactModeToggle;

View File

@ -36,11 +36,7 @@ import {
Divider, Divider,
Tooltip, Tooltip,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
IconPlus,
IconDelete,
IconAlertTriangle,
} from '@douyinfe/semi-icons';
const { Text } = Typography; const { Text } = Typography;
@ -88,7 +84,7 @@ const JSONEditor = ({
// //
const keyValueArrayToObject = useCallback((arr) => { const keyValueArrayToObject = useCallback((arr) => {
const result = {}; const result = {};
arr.forEach(item => { arr.forEach((item) => {
if (item.key) { if (item.key) {
result[item.key] = item.value; result[item.key] = item.value;
} }
@ -115,7 +111,8 @@ const JSONEditor = ({
// //
const [manualText, setManualText] = useState(() => { const [manualText, setManualText] = useState(() => {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value, null, 2); if (value && typeof value === 'object')
return JSON.stringify(value, null, 2);
return ''; return '';
}); });
@ -140,7 +137,7 @@ const JSONEditor = ({
const keyCount = {}; const keyCount = {};
const duplicates = new Set(); const duplicates = new Set();
keyValuePairs.forEach(pair => { keyValuePairs.forEach((pair) => {
if (pair.key) { if (pair.key) {
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1; keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
if (keyCount[pair.key] > 1) { if (keyCount[pair.key] > 1) {
@ -178,51 +175,65 @@ const JSONEditor = ({
useEffect(() => { useEffect(() => {
if (editMode !== 'manual') { if (editMode !== 'manual') {
if (typeof value === 'string') setManualText(value); if (typeof value === 'string') setManualText(value);
else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2)); else if (value && typeof value === 'object')
setManualText(JSON.stringify(value, null, 2));
else setManualText(''); else setManualText('');
} }
}, [value, editMode]); }, [value, editMode]);
// //
const handleVisualChange = useCallback((newPairs) => { const handleVisualChange = useCallback(
setKeyValuePairs(newPairs); (newPairs) => {
const jsonObject = keyValueArrayToObject(newPairs); setKeyValuePairs(newPairs);
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2); const jsonObject = keyValueArrayToObject(newPairs);
const jsonString =
Object.keys(jsonObject).length === 0
? ''
: JSON.stringify(jsonObject, null, 2);
setJsonError(''); setJsonError('');
// formApi // formApi
if (formApi && field) { if (formApi && field) {
formApi.setValue(field, jsonString); formApi.setValue(field, jsonString);
} }
onChange?.(jsonString); onChange?.(jsonString);
}, [onChange, formApi, field, keyValueArrayToObject]); },
[onChange, formApi, field, keyValueArrayToObject],
);
// //
const handleManualChange = useCallback((newValue) => { const handleManualChange = useCallback(
setManualText(newValue); (newValue) => {
if (newValue && newValue.trim()) { setManualText(newValue);
try { if (newValue && newValue.trim()) {
const parsed = JSON.parse(newValue); try {
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); const parsed = JSON.parse(newValue);
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
setJsonError('');
onChange?.(newValue);
} catch (error) {
setJsonError(error.message);
}
} else {
setKeyValuePairs([]);
setJsonError(''); setJsonError('');
onChange?.(newValue); onChange?.('');
} catch (error) {
setJsonError(error.message);
} }
} else { },
setKeyValuePairs([]); [onChange, objectToKeyValueArray, keyValuePairs],
setJsonError(''); );
onChange?.('');
}
}, [onChange, objectToKeyValueArray, keyValuePairs]);
// //
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
if (editMode === 'visual') { if (editMode === 'visual') {
const jsonObject = keyValueArrayToObject(keyValuePairs); const jsonObject = keyValueArrayToObject(keyValuePairs);
setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2)); setManualText(
Object.keys(jsonObject).length === 0
? ''
: JSON.stringify(jsonObject, null, 2),
);
setEditMode('manual'); setEditMode('manual');
} else { } else {
try { try {
@ -242,12 +253,19 @@ const JSONEditor = ({
return; return;
} }
} }
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]); }, [
editMode,
value,
manualText,
keyValuePairs,
keyValueArrayToObject,
objectToKeyValueArray,
]);
// //
const addKeyValue = useCallback(() => { const addKeyValue = useCallback(() => {
const newPairs = [...keyValuePairs]; const newPairs = [...keyValuePairs];
const existingKeys = newPairs.map(p => p.key); const existingKeys = newPairs.map((p) => p.key);
let counter = 1; let counter = 1;
let newKey = `field_${counter}`; let newKey = `field_${counter}`;
while (existingKeys.includes(newKey)) { while (existingKeys.includes(newKey)) {
@ -257,32 +275,41 @@ const JSONEditor = ({
newPairs.push({ newPairs.push({
id: generateUniqueId(), id: generateUniqueId(),
key: newKey, key: newKey,
value: '' value: '',
}); });
handleVisualChange(newPairs); handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]); }, [keyValuePairs, handleVisualChange]);
// //
const removeKeyValue = useCallback((id) => { const removeKeyValue = useCallback(
const newPairs = keyValuePairs.filter(pair => pair.id !== id); (id) => {
handleVisualChange(newPairs); const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
}, [keyValuePairs, handleVisualChange]); handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// //
const updateKey = useCallback((id, newKey) => { const updateKey = useCallback(
const newPairs = keyValuePairs.map(pair => (id, newKey) => {
pair.id === id ? { ...pair, key: newKey } : pair const newPairs = keyValuePairs.map((pair) =>
); pair.id === id ? { ...pair, key: newKey } : pair,
handleVisualChange(newPairs); );
}, [keyValuePairs, handleVisualChange]); handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// //
const updateValue = useCallback((id, newValue) => { const updateValue = useCallback(
const newPairs = keyValuePairs.map(pair => (id, newValue) => {
pair.id === id ? { ...pair, value: newValue } : pair const newPairs = keyValuePairs.map((pair) =>
); pair.id === id ? { ...pair, value: newValue } : pair,
handleVisualChange(newPairs); );
}, [keyValuePairs, handleVisualChange]); handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// //
const fillTemplate = useCallback(() => { const fillTemplate = useCallback(() => {
@ -298,7 +325,14 @@ const JSONEditor = ({
onChange?.(templateString); onChange?.(templateString);
setJsonError(''); setJsonError('');
} }
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]); }, [
template,
onChange,
formApi,
field,
objectToKeyValueArray,
keyValuePairs,
]);
// //
const renderValueInput = (pairId, value) => { const renderValueInput = (pairId, value) => {
@ -306,12 +340,12 @@ const JSONEditor = ({
if (valueType === 'boolean') { if (valueType === 'boolean') {
return ( return (
<div className="flex items-center"> <div className='flex items-center'>
<Switch <Switch
checked={value} checked={value}
onChange={(newValue) => updateValue(pairId, newValue)} onChange={(newValue) => updateValue(pairId, newValue)}
/> />
<Text type="tertiary" className="ml-2"> <Text type='tertiary' className='ml-2'>
{value ? t('true') : t('false')} {value ? t('true') : t('false')}
</Text> </Text>
</div> </div>
@ -373,29 +407,29 @@ const JSONEditor = ({
// //
const renderKeyValueEditor = () => { const renderKeyValueEditor = () => {
return ( return (
<div className="space-y-1"> <div className='space-y-1'>
{/* 重复键警告 */} {/* 重复键警告 */}
{duplicateKeys.size > 0 && ( {duplicateKeys.size > 0 && (
<Banner <Banner
type="warning" type='warning'
icon={<IconAlertTriangle />} icon={<IconAlertTriangle />}
description={ description={
<div> <div>
<Text strong>{t('存在重复的键名:')}</Text> <Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text> <Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br /> <br />
<Text type="tertiary" size="small"> <Text type='tertiary' size='small'>
{t('注意JSON中重复的键只会保留最后一个同名键的值')} {t('注意JSON中重复的键只会保留最后一个同名键的值')}
</Text> </Text>
</div> </div>
} }
className="mb-3" className='mb-3'
/> />
)} )}
{keyValuePairs.length === 0 && ( {keyValuePairs.length === 0 && (
<div className="text-center py-6 px-4"> <div className='text-center py-6 px-4'>
<Text type="tertiary" className="text-gray-500 text-sm"> <Text type='tertiary' className='text-gray-500 text-sm'>
{t('暂无数据,点击下方按钮添加键值对')} {t('暂无数据,点击下方按钮添加键值对')}
</Text> </Text>
</div> </div>
@ -403,13 +437,14 @@ const JSONEditor = ({
{keyValuePairs.map((pair, index) => { {keyValuePairs.map((pair, index) => {
const isDuplicate = duplicateKeys.has(pair.key); const isDuplicate = duplicateKeys.has(pair.key);
const isLastDuplicate = isDuplicate && const isLastDuplicate =
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key); isDuplicate &&
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
return ( return (
<Row key={pair.id} gutter={8} align="middle"> <Row key={pair.id} gutter={8} align='middle'>
<Col span={6}> <Col span={10}>
<div className="relative"> <div className='relative'>
<Input <Input
placeholder={t('键名')} placeholder={t('键名')}
value={pair.key} value={pair.key}
@ -425,24 +460,22 @@ const JSONEditor = ({
} }
> >
<IconAlertTriangle <IconAlertTriangle
className="absolute right-2 top-1/2 transform -translate-y-1/2" className='absolute right-2 top-1/2 transform -translate-y-1/2'
style={{ style={{
color: isLastDuplicate ? '#ff7d00' : '#faad14', color: isLastDuplicate ? '#ff7d00' : '#faad14',
fontSize: '14px' fontSize: '14px',
}} }}
/> />
</Tooltip> </Tooltip>
)} )}
</div> </div>
</Col> </Col>
<Col span={16}> <Col span={12}>{renderValueInput(pair.id, pair.value)}</Col>
{renderValueInput(pair.id, pair.value)}
</Col>
<Col span={2}> <Col span={2}>
<Button <Button
icon={<IconDelete />} icon={<IconDelete />}
type="danger" type='danger'
theme="borderless" theme='borderless'
onClick={() => removeKeyValue(pair.id)} onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
@ -451,11 +484,11 @@ const JSONEditor = ({
); );
})} })}
<div className="mt-2 flex justify-center"> <div className='mt-2 flex justify-center'>
<Button <Button
icon={<IconPlus />} icon={<IconPlus />}
type="primary" type='primary'
theme="outline" theme='outline'
onClick={addKeyValue} onClick={addKeyValue}
> >
{t('添加键值对')} {t('添加键值对')}
@ -467,27 +500,27 @@ const JSONEditor = ({
// - // -
const renderRegionEditor = () => { const renderRegionEditor = () => {
const defaultPair = keyValuePairs.find(pair => pair.key === 'default'); const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default'); const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
return ( return (
<div className="space-y-2"> <div className='space-y-2'>
{/* 重复键警告 */} {/* 重复键警告 */}
{duplicateKeys.size > 0 && ( {duplicateKeys.size > 0 && (
<Banner <Banner
type="warning" type='warning'
icon={<IconAlertTriangle />} icon={<IconAlertTriangle />}
description={ description={
<div> <div>
<Text strong>{t('存在重复的键名:')}</Text> <Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text> <Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br /> <br />
<Text type="tertiary" size="small"> <Text type='tertiary' size='small'>
{t('注意JSON中重复的键只会保留最后一个同名键的值')} {t('注意JSON中重复的键只会保留最后一个同名键的值')}
</Text> </Text>
</div> </div>
} }
className="mb-3" className='mb-3'
/> />
)} )}
@ -500,11 +533,14 @@ const JSONEditor = ({
if (defaultPair) { if (defaultPair) {
updateValue(defaultPair.id, value); updateValue(defaultPair.id, value);
} else { } else {
const newPairs = [...keyValuePairs, { const newPairs = [
id: generateUniqueId(), ...keyValuePairs,
key: 'default', {
value: value id: generateUniqueId(),
}]; key: 'default',
value: value,
},
];
handleVisualChange(newPairs); handleVisualChange(newPairs);
} }
}} }}
@ -517,9 +553,9 @@ const JSONEditor = ({
{modelPairs.map((pair) => { {modelPairs.map((pair) => {
const isDuplicate = duplicateKeys.has(pair.key); const isDuplicate = duplicateKeys.has(pair.key);
return ( return (
<Row key={pair.id} gutter={8} align="middle" className="mb-2"> <Row key={pair.id} gutter={8} align='middle' className='mb-2'>
<Col span={10}> <Col span={10}>
<div className="relative"> <div className='relative'>
<Input <Input
placeholder={t('模型名称')} placeholder={t('模型名称')}
value={pair.key} value={pair.key}
@ -529,7 +565,7 @@ const JSONEditor = ({
{isDuplicate && ( {isDuplicate && (
<Tooltip content={t('重复的键名')}> <Tooltip content={t('重复的键名')}>
<IconAlertTriangle <IconAlertTriangle
className="absolute right-2 top-1/2 transform -translate-y-1/2" className='absolute right-2 top-1/2 transform -translate-y-1/2'
style={{ color: '#faad14', fontSize: '14px' }} style={{ color: '#faad14', fontSize: '14px' }}
/> />
</Tooltip> </Tooltip>
@ -546,8 +582,8 @@ const JSONEditor = ({
<Col span={2}> <Col span={2}>
<Button <Button
icon={<IconDelete />} icon={<IconDelete />}
type="danger" type='danger'
theme="borderless" theme='borderless'
onClick={() => removeKeyValue(pair.id)} onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
@ -556,12 +592,12 @@ const JSONEditor = ({
); );
})} })}
<div className="mt-2 flex justify-center"> <div className='mt-2 flex justify-center'>
<Button <Button
icon={<IconPlus />} icon={<IconPlus />}
onClick={addKeyValue} onClick={addKeyValue}
type="primary" type='primary'
theme="outline" theme='outline'
> >
{t('添加模型区域')} {t('添加模型区域')}
</Button> </Button>
@ -590,9 +626,9 @@ const JSONEditor = ({
<Form.Slot label={label}> <Form.Slot label={label}>
<Card <Card
header={ header={
<div className="flex justify-between items-center"> <div className='flex justify-between items-center'>
<Tabs <Tabs
type="slash" type='slash'
activeKey={editMode} activeKey={editMode}
onChange={(key) => { onChange={(key) => {
if (key === 'manual' && editMode === 'visual') { if (key === 'manual' && editMode === 'visual') {
@ -602,16 +638,12 @@ const JSONEditor = ({
} }
}} }}
> >
<TabPane tab={t('可视化')} itemKey="visual" /> <TabPane tab={t('可视化')} itemKey='visual' />
<TabPane tab={t('手动编辑')} itemKey="manual" /> <TabPane tab={t('手动编辑')} itemKey='manual' />
</Tabs> </Tabs>
{template && templateLabel && ( {template && templateLabel && (
<Button <Button type='tertiary' onClick={fillTemplate} size='small'>
type="tertiary"
onClick={fillTemplate}
size="small"
>
{templateLabel} {templateLabel}
</Button> </Button>
)} )}
@ -619,14 +651,14 @@ const JSONEditor = ({
} }
headerStyle={{ padding: '12px 16px' }} headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }} bodyStyle={{ padding: '16px' }}
className="!rounded-2xl" className='!rounded-2xl'
> >
{/* JSON错误提示 */} {/* JSON错误提示 */}
{hasJsonError && ( {hasJsonError && (
<Banner <Banner
type="danger" type='danger'
description={`JSON 格式错误: ${jsonError}`} description={`JSON 格式错误: ${jsonError}`}
className="mb-3" className='mb-3'
/> />
)} )}
@ -668,17 +700,15 @@ const JSONEditor = ({
{/* 额外文本显示在卡片底部 */} {/* 额外文本显示在卡片底部 */}
{extraText && ( {extraText && (
<Divider margin='12px' align='center'> <Divider margin='12px' align='center'>
<Text type="tertiary" size="small">{extraText}</Text> <Text type='tertiary' size='small'>
{extraText}
</Text>
</Divider> </Divider>
)} )}
{extraFooter && ( {extraFooter && <div className='mt-1'>{extraFooter}</div>}
<div className="mt-1">
{extraFooter}
</div>
)}
</Card> </Card>
</Form.Slot> </Form.Slot>
); );
}; };
export default JSONEditor; export default JSONEditor;

Some files were not shown because too many files have changed in this diff Show More