diff --git a/.github/workflows/docker-image-amd64.yml b/.github/workflows/docker-image-alpha.yml similarity index 72% rename from .github/workflows/docker-image-amd64.yml rename to .github/workflows/docker-image-alpha.yml index a823151c..c02bd409 100644 --- a/.github/workflows/docker-image-amd64.yml +++ b/.github/workflows/docker-image-alpha.yml @@ -1,14 +1,15 @@ -name: Publish Docker image (amd64) +name: Publish Docker image (alpha) on: push: - tags: - - '*' + branches: + - alpha workflow_dispatch: inputs: name: - description: 'reason' + description: "reason" required: false + jobs: push_to_registries: name: Push Docker image to multiple registries @@ -22,7 +23,7 @@ jobs: - name: Save version info run: | - git describe --tags > VERSION + echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION - name: Log in to Docker Hub uses: docker/login-action@v3 @@ -37,6 +38,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 @@ -44,11 +48,15 @@ jobs: images: | calciumion/new-api ghcr.io/${{ github.repository }} + tags: | + type=raw,value=alpha + type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}} - name: Build and push Docker images uses: docker/build-push-action@v5 with: context: . + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index d7468c8e..8e4656aa 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -1,14 +1,9 @@ -name: Publish Docker image (arm64) +name: Publish Docker image (Multi Registries) on: push: tags: - '*' - workflow_dispatch: - inputs: - name: - description: 'reason' - required: false jobs: push_to_registries: name: Push Docker image to multiple registries diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 3ddabc6d..c87fcfce 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -3,6 +3,11 @@ permissions: contents: write on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false push: tags: - '*' @@ -15,16 +20,16 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: oven-sh/setup-bun@v2 with: - node-version: 18 + bun-version: latest - name: Build Frontend env: CI: "" run: | cd web - npm install - REACT_APP_VERSION=$(git describe --tags) npm run build + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index ccc480bf..3210065b 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -3,6 +3,11 @@ permissions: contents: write on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false push: tags: - '*' @@ -15,16 +20,16 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: oven-sh/setup-bun@v2 with: - node-version: 18 + bun-version: latest - name: Build Frontend env: CI: "" run: | cd web - npm install - REACT_APP_VERSION=$(git describe --tags) npm run build + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index f9500718..de3d83d5 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -3,6 +3,11 @@ permissions: contents: write on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false push: tags: - '*' @@ -18,16 +23,16 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: oven-sh/setup-bun@v2 with: - node-version: 18 + bun-version: latest - name: Build Frontend env: CI: "" run: | cd web - npm install - REACT_APP_VERSION=$(git describe --tags) npm run build + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 diff --git a/Dockerfile b/Dockerfile index 214ceaa3..3b42089b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,7 @@ RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one- FROM alpine -RUN apk update \ - && apk upgrade \ +RUN apk upgrade --no-cache \ && apk add --no-cache ca-certificates tzdata ffmpeg \ && update-ca-certificates diff --git a/README.en.md b/README.en.md index 4709bc5b..10a3cdb0 100644 --- a/README.en.md +++ b/README.en.md @@ -44,6 +44,9 @@ For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/) +You can also access the AI-generated DeepWiki: +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + ## ✨ Key Features New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details: @@ -110,6 +113,7 @@ For detailed configuration instructions, please refer to [Installation Guide-Env - `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview` - `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes - `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2` +- `ERROR_LOG_ENABLED=true`: Whether to record and display error logs, default is `false` ## Deployment diff --git a/README.md b/README.md index a807b07d..6ba3574c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ GoReportCard + + CodeRabbit Pull Request Reviews +

@@ -44,6 +47,9 @@ 详细文档请访问我们的官方Wiki:[https://docs.newapi.pro/](https://docs.newapi.pro/) +也可访问AI生成的DeepWiki: +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + ## ✨ 主要特性 New API提供了丰富的功能,详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction): @@ -110,6 +116,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do - `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview` - `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟 - `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2` +- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false` ## 部署 @@ -176,7 +183,6 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 其他基于New API的项目: - [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能优化版 -- [VoAPI](https://github.com/VoAPI/VoAPI):基于New API的前端美化版本 ## 帮助支持 diff --git a/common/constants.go b/common/constants.go index bee00506..ac803148 100644 --- a/common/constants.go +++ b/common/constants.go @@ -241,6 +241,7 @@ const ( ChannelTypeXinference = 47 ChannelTypeXai = 48 ChannelTypeCoze = 49 + ChannelTypeKling = 50 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -296,4 +297,5 @@ var ChannelBaseURLs = []string{ "", //47 "https://api.x.ai", //48 "https://api.coze.cn", //49 + "https://api.klingai.com", //50 } diff --git a/common/database.go b/common/database.go index 3c0a944b..9cbaf46a 100644 --- a/common/database.go +++ b/common/database.go @@ -1,7 +1,14 @@ package common +const ( + DatabaseTypeMySQL = "mysql" + DatabaseTypeSQLite = "sqlite" + DatabaseTypePostgreSQL = "postgres" +) + var UsingSQLite = false var UsingPostgreSQL = false +var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries var UsingMySQL = false var UsingClickHouse = false diff --git a/common/redis.go b/common/redis.go index 49d3ec78..1efc217f 100644 --- a/common/redis.go +++ b/common/redis.go @@ -92,12 +92,12 @@ func RedisDel(key string) error { return RDB.Del(ctx, key).Err() } -func RedisHDelObj(key string) error { +func RedisDelKey(key string) error { if DebugEnabled { - SysLog(fmt.Sprintf("Redis HDEL: key=%s", key)) + SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key)) } ctx := context.Background() - return RDB.HDel(ctx, key).Err() + return RDB.Del(ctx, key).Err() } func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { @@ -141,7 +141,11 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { txn := RDB.TxPipeline() txn.HSet(ctx, key, data) - txn.Expire(ctx, key, expiration) + + // 只有在 expiration 大于 0 时才设置过期时间 + if expiration > 0 { + txn.Expire(ctx, key, expiration) + } _, err := txn.Exec(ctx) if err != nil { diff --git a/common/utils.go b/common/utils.go index 587de537..17aecd95 100644 --- a/common/utils.go +++ b/common/utils.go @@ -13,6 +13,7 @@ import ( "math/big" "math/rand" "net" + "net/url" "os" "os/exec" "runtime" @@ -249,13 +250,55 @@ func SaveTmpFile(filename string, data io.Reader) (string, error) { } // GetAudioDuration returns the duration of an audio file in seconds. -func GetAudioDuration(ctx context.Context, filename string) (float64, error) { +func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) { // ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}} c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename) output, err := c.Output() if err != nil { return 0, errors.Wrap(err, "failed to get audio duration") } + durationStr := string(bytes.TrimSpace(output)) + if durationStr == "N/A" { + // Create a temporary output file name + tmpFp, err := os.CreateTemp("", "audio-*"+ext) + if err != nil { + return 0, errors.Wrap(err, "failed to create temporary file") + } + tmpName := tmpFp.Name() + // Close immediately so ffmpeg can open the file on Windows. + _ = tmpFp.Close() + defer os.Remove(tmpName) - return strconv.ParseFloat(string(bytes.TrimSpace(output)), 64) + // ffmpeg -y -i filename -vcodec copy -acodec copy + ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName) + if err := ffmpegCmd.Run(); err != nil { + return 0, errors.Wrap(err, "failed to run ffmpeg") + } + + // 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) + output, err := c.Output() + if err != nil { + return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg") + } + durationStr = string(bytes.TrimSpace(output)) + } + return strconv.ParseFloat(durationStr, 64) +} + +// BuildURL concatenates base and endpoint, returns the complete url string +func BuildURL(base string, endpoint string) string { + u, err := url.Parse(base) + if err != nil { + return base + endpoint + } + end := endpoint + if end == "" { + end = "/" + } + ref, err := url.Parse(end) + if err != nil { + return base + endpoint + } + return u.ResolveReference(ref).String() } diff --git a/constant/cache_key.go b/constant/cache_key.go index 27cb3b75..daedfd40 100644 --- a/constant/cache_key.go +++ b/constant/cache_key.go @@ -2,12 +2,10 @@ package constant import "one-api/common" -var ( - TokenCacheSeconds = common.SyncFrequency - UserId2GroupCacheSeconds = common.SyncFrequency - UserId2QuotaCacheSeconds = common.SyncFrequency - UserId2StatusCacheSeconds = common.SyncFrequency -) +// 使用函数来避免初始化顺序带来的赋值问题 +func RedisKeyCacheSeconds() int { + return common.SyncFrequency +} // Cache keys const ( diff --git a/constant/task.go b/constant/task.go index 1a68b812..d466fc8a 100644 --- a/constant/task.go +++ b/constant/task.go @@ -5,6 +5,7 @@ type TaskPlatform string const ( TaskPlatformSuno TaskPlatform = "suno" TaskPlatformMidjourney = "mj" + TaskPlatformKling TaskPlatform = "kling" ) const ( diff --git a/constant/user_setting.go b/constant/user_setting.go index 055884f7..7e79035e 100644 --- a/constant/user_setting.go +++ b/constant/user_setting.go @@ -7,6 +7,7 @@ var ( UserSettingWebhookSecret = "webhook_secret" // WebhookSecret webhook密钥 UserSettingNotificationEmail = "notification_email" // NotificationEmail 通知邮箱地址 UserAcceptUnsetRatioModel = "accept_unset_model_ratio_model" // AcceptUnsetRatioModel 是否接受未设置价格的模型 + UserSettingRecordIpLog = "record_ip_log" // 是否记录请求和错误日志IP ) var ( diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 2bda0fd2..9bf5d1fe 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -4,11 +4,13 @@ import ( "encoding/json" "errors" "fmt" + "github.com/shopspring/decimal" "io" "net/http" "one-api/common" "one-api/model" "one-api/service" + "one-api/setting" "strconv" "time" @@ -304,6 +306,40 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) { return balance, nil } +func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { + url := "https://api.moonshot.cn/v1/users/me/balance" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + + type MoonshotBalanceData struct { + AvailableBalance float64 `json:"available_balance"` + VoucherBalance float64 `json:"voucher_balance"` + CashBalance float64 `json:"cash_balance"` + } + + type MoonshotBalanceResponse struct { + Code int `json:"code"` + Data MoonshotBalanceData `json:"data"` + Scode string `json:"scode"` + Status bool `json:"status"` + } + + response := MoonshotBalanceResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Status || response.Code != 0 { + return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode) + } + availableBalanceCny := response.Data.AvailableBalance + availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64() + channel.UpdateBalance(availableBalanceUsd) + return availableBalanceUsd, nil +} + func updateChannelBalance(channel *model.Channel) (float64, error) { baseURL := common.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() == "" { @@ -332,6 +368,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { return updateChannelDeepSeekBalance(channel) case common.ChannelTypeOpenRouter: return updateChannelOpenRouterBalance(channel) + case common.ChannelTypeMoonshot: + return updateChannelMoonshotBalance(channel) default: return 0, errors.New("尚未实现") } diff --git a/controller/channel-test.go b/controller/channel-test.go index d1cb4093..d54ccf0d 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -40,6 +40,9 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr if channel.Type == common.ChannelTypeSunoAPI { return errors.New("suno channel test is not supported"), nil } + if channel.Type == common.ChannelTypeKling { + return errors.New("kling channel test is not supported"), nil + } w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -90,7 +93,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr info := relaycommon.GenRelayInfo(c) - err = helper.ModelMappedHelper(c, info) + err = helper.ModelMappedHelper(c, info, nil) if err != nil { return err, nil } @@ -165,8 +168,8 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr tok := time.Now() milliseconds := tok.Sub(tik).Milliseconds() consumedTime := float64(milliseconds) / 1000.0 - other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatio, priceData.CompletionRatio, - usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice) + other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, + usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试", quota, "模型测试", 0, quota, int(consumedTime), false, info.Group, other) common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) @@ -200,10 +203,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest { } else { testRequest.MaxTokens = 10 } - content, _ := json.Marshal("hi") + testMessage := dto.Message{ Role: "user", - Content: content, + Content: "hi", } testRequest.Model = model testRequest.Messages = append(testRequest.Messages, testMessage) @@ -271,6 +274,13 @@ func testAllChannels(notify bool) error { disableThreshold = 10000000 // a impossible value } gopool.Go(func() { + // 使用 defer 确保无论如何都会重置运行状态,防止死锁 + defer func() { + testAllChannelsLock.Lock() + testAllChannelsRunning = false + testAllChannelsLock.Unlock() + }() + for _, channel := range channels { isChannelEnabled := channel.Status == common.ChannelStatusEnabled tik := time.Now() @@ -305,9 +315,7 @@ func testAllChannels(notify bool) error { channel.UpdateResponseTime(milliseconds) time.Sleep(common.RequestInterval) } - testAllChannelsLock.Lock() - testAllChannelsRunning = false - testAllChannelsLock.Unlock() + if notify { service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成") } diff --git a/controller/channel.go b/controller/channel.go index ad85fe24..13ed72b3 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -43,22 +43,31 @@ type OpenAIModelsResponse struct { func GetAllChannels(c *gin.Context) { p, _ := strconv.Atoi(c.Query("p")) pageSize, _ := strconv.Atoi(c.Query("page_size")) - if p < 0 { - p = 0 + if p < 1 { + p = 1 } - if pageSize < 0 { + if pageSize < 1 { pageSize = common.ItemsPerPage } channelData := make([]*model.Channel, 0) idSort, _ := strconv.ParseBool(c.Query("id_sort")) enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) + // type filter + typeStr := c.Query("type") + typeFilter := -1 + if typeStr != "" { + if t, err := strconv.Atoi(typeStr); err == nil { + typeFilter = t + } + } + + var total int64 + if enableTagMode { - tags, err := model.GetPaginatedTags(p*pageSize, pageSize) + // tag 分页:先分页 tag,再取各 tag 下 channels + tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } for _, tag := range tags { @@ -69,21 +78,39 @@ func GetAllChannels(c *gin.Context) { } } } - } else { - channels, err := model.GetAllChannels(p*pageSize, pageSize, false, idSort) + // 计算 tag 总数用于分页 + total, _ = model.CountAllTags() + } else if typeFilter >= 0 { + channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } channelData = channels + total, _ = model.CountChannelsByType(typeFilter) + } else { + channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + channelData = channels + total, _ = model.CountAllChannels() } + + // calculate type counts + typeCounts, _ := model.CountChannelsGroupByType() + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": channelData, + "data": gin.H{ + "items": channelData, + "total": total, + "page": p, + "page_size": pageSize, + "type_counts": typeCounts, + }, }) return } @@ -119,8 +146,11 @@ func FetchUpstreamModels(c *gin.Context) { baseURL = channel.GetBaseURL() } url := fmt.Sprintf("%s/v1/models", baseURL) - if channel.Type == common.ChannelTypeGemini { + switch channel.Type { + case common.ChannelTypeGemini: url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) + case common.ChannelTypeAli: + url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) } body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) if err != nil { @@ -207,10 +237,20 @@ func SearchChannels(c *gin.Context) { } channelData = channels } + + // calculate type counts for search results + typeCounts := make(map[int64]int64) + for _, channel := range channelData { + typeCounts[int64(channel.Type)]++ + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": channelData, + "data": gin.H{ + "items": channelData, + "type_counts": typeCounts, + }, }) return } @@ -620,3 +660,44 @@ func BatchSetChannelTag(c *gin.Context) { }) return } + +func GetTagModels(c *gin.Context) { + tag := c.Query("tag") + if tag == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "tag不能为空", + }) + return + } + + channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var longestModels string + maxLength := 0 + + // Find the longest models string among all channels with the given tag + for _, channel := range channels { + if channel.Models != "" { + currentModels := strings.Split(channel.Models, ",") + if len(currentModels) > maxLength { + maxLength = len(currentModels) + longestModels = channel.Models + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": longestModels, + }) + return +} diff --git a/controller/console_migrate.go b/controller/console_migrate.go new file mode 100644 index 00000000..d25f199b --- /dev/null +++ b/controller/console_migrate.go @@ -0,0 +1,103 @@ +// 用于迁移检测的旧键,该文件下个版本会删除 + +package controller + +import ( + "encoding/json" + "net/http" + "one-api/common" + "one-api/model" + "github.com/gin-gonic/gin" +) + +// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.* +func MigrateConsoleSetting(c *gin.Context) { + // 读取全部 option + opts, err := model.AllOption() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + // 建立 map + valMap := map[string]string{} + for _, o := range opts { + valMap[o.Key] = o.Value + } + + // 处理 APIInfo + if v := valMap["ApiInfo"]; v != "" { + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(v), &arr); err == nil { + if len(arr) > 50 { + arr = arr[:50] + } + bytes, _ := json.Marshal(arr) + model.UpdateOption("console_setting.api_info", string(bytes)) + } + model.UpdateOption("ApiInfo", "") + } + // Announcements 直接搬 + if v := valMap["Announcements"]; v != "" { + model.UpdateOption("console_setting.announcements", v) + model.UpdateOption("Announcements", "") + } + // FAQ 转换 + if v := valMap["FAQ"]; v != "" { + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(v), &arr); err == nil { + out := []map[string]interface{}{} + for _, item := range arr { + q, _ := item["question"].(string) + if q == "" { + q, _ = item["title"].(string) + } + a, _ := item["answer"].(string) + if a == "" { + a, _ = item["content"].(string) + } + if q != "" && a != "" { + out = append(out, map[string]interface{}{"question": q, "answer": a}) + } + } + if len(out) > 50 { + out = out[:50] + } + bytes, _ := json.Marshal(out) + model.UpdateOption("console_setting.faq", string(bytes)) + } + model.UpdateOption("FAQ", "") + } + // Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups) + url := valMap["UptimeKumaUrl"] + slug := valMap["UptimeKumaSlug"] + if url != "" && slug != "" { + // 仅当同时存在 URL 与 Slug 时才进行迁移 + groups := []map[string]interface{}{ + { + "id": 1, + "categoryName": "old", + "url": url, + "slug": slug, + "description": "", + }, + } + bytes, _ := json.Marshal(groups) + model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes)) + } + // 清空旧键内容 + if url != "" { + model.UpdateOption("UptimeKumaUrl", "") + } + if slug != "" { + model.UpdateOption("UptimeKumaSlug", "") + } + + // 删除旧键记录 + oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} + model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{}) + + // 重新加载 OptionMap + model.InitOptionMap() + common.SysLog("console setting migrated") + c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"}) +} \ No newline at end of file diff --git a/controller/group.go b/controller/group.go index 2c725a4d..2565b6ea 100644 --- a/controller/group.go +++ b/controller/group.go @@ -1,15 +1,17 @@ package controller import ( - "github.com/gin-gonic/gin" "net/http" "one-api/model" "one-api/setting" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" ) func GetGroups(c *gin.Context) { groupNames := make([]string, 0) - for groupName, _ := range setting.GetGroupRatioCopy() { + for groupName := range ratio_setting.GetGroupRatioCopy() { groupNames = append(groupNames, groupName) } c.JSON(http.StatusOK, gin.H{ @@ -24,7 +26,7 @@ func GetUserGroups(c *gin.Context) { userGroup := "" userId := c.GetInt("id") userGroup, _ = model.GetUserGroup(userId, false) - for groupName, ratio := range setting.GetGroupRatioCopy() { + for groupName, ratio := range ratio_setting.GetGroupRatioCopy() { // UserUsableGroups contains the groups that the user can use userUsableGroups := setting.GetUserUsableGroups(userGroup) if desc, ok := userUsableGroups[groupName]; ok { @@ -34,6 +36,12 @@ func GetUserGroups(c *gin.Context) { } } } + if setting.GroupInUserUsableGroups("auto") { + usableGroups["auto"] = map[string]interface{}{ + "ratio": "自动", + "desc": setting.GetUsableGroupDescription("auto"), + } + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/controller/midjourney.go b/controller/midjourney.go index 21027d8f..56bdcb80 100644 --- a/controller/midjourney.go +++ b/controller/midjourney.go @@ -7,7 +7,6 @@ import ( "fmt" "github.com/gin-gonic/gin" "io" - "log" "net/http" "one-api/common" "one-api/dto" @@ -215,8 +214,12 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) func GetAllMidjourney(c *gin.Context) { p, _ := strconv.Atoi(c.Query("p")) - if p < 0 { - p = 0 + if p < 1 { + p = 1 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage } // 解析其他查询参数 @@ -227,31 +230,38 @@ func GetAllMidjourney(c *gin.Context) { EndTimestamp: c.Query("end_timestamp"), } - logs := model.GetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams) - if logs == nil { - logs = make([]*model.Midjourney, 0) - } + items := model.GetAllTasks((p-1)*pageSize, pageSize, queryParams) + total := model.CountAllTasks(queryParams) + if setting.MjForwardUrlEnabled { - for i, midjourney := range logs { + for i, midjourney := range items { midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId - logs[i] = midjourney + items[i] = midjourney } } c.JSON(200, gin.H{ "success": true, "message": "", - "data": logs, + "data": gin.H{ + "items": items, + "total": total, + "page": p, + "page_size": pageSize, + }, }) } func GetUserMidjourney(c *gin.Context) { p, _ := strconv.Atoi(c.Query("p")) - if p < 0 { - p = 0 + if p < 1 { + p = 1 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage } userId := c.GetInt("id") - log.Printf("userId = %d \n", userId) queryParams := model.TaskQueryParams{ MjID: c.Query("mj_id"), @@ -259,19 +269,23 @@ func GetUserMidjourney(c *gin.Context) { EndTimestamp: c.Query("end_timestamp"), } - logs := model.GetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams) - if logs == nil { - logs = make([]*model.Midjourney, 0) - } + items := model.GetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams) + total := model.CountAllUserTask(userId, queryParams) + if setting.MjForwardUrlEnabled { - for i, midjourney := range logs { + for i, midjourney := range items { midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId - logs[i] = midjourney + items[i] = midjourney } } c.JSON(200, gin.H{ "success": true, "message": "", - "data": logs, + "data": gin.H{ + "items": items, + "total": total, + "page": p, + "page_size": pageSize, + }, }) } diff --git a/controller/misc.go b/controller/misc.go index 4d265c3f..4ffe86f4 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -6,8 +6,10 @@ import ( "net/http" "one-api/common" "one-api/constant" + "one-api/middleware" "one-api/model" "one-api/setting" + "one-api/setting/console_setting" "one-api/setting/operation_setting" "one-api/setting/system_setting" "strings" @@ -24,57 +26,85 @@ func TestStatus(c *gin.Context) { }) return } + // 获取HTTP统计信息 + httpStats := middleware.GetStats() c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Server is running", + "success": true, + "message": "Server is running", + "http_stats": httpStats, }) return } func GetStatus(c *gin.Context) { + + cs := console_setting.GetConsoleSetting() + + data := gin.H{ + "version": common.Version, + "start_time": common.StartTime, + "email_verification": common.EmailVerificationEnabled, + "github_oauth": common.GitHubOAuthEnabled, + "github_client_id": common.GitHubClientId, + "linuxdo_oauth": common.LinuxDOOAuthEnabled, + "linuxdo_client_id": common.LinuxDOClientId, + "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_bot_name": common.TelegramBotName, + "system_name": common.SystemName, + "logo": common.Logo, + "footer_html": common.Footer, + "wechat_qrcode": common.WeChatAccountQRCodeImageURL, + "wechat_login": common.WeChatAuthEnabled, + "server_address": setting.ServerAddress, + "price": setting.Price, + "min_topup": setting.MinTopUp, + "turnstile_check": common.TurnstileCheckEnabled, + "turnstile_site_key": common.TurnstileSiteKey, + "top_up_link": common.TopUpLink, + "docs_link": operation_setting.GetGeneralSetting().DocsLink, + "quota_per_unit": common.QuotaPerUnit, + "display_in_currency": common.DisplayInCurrencyEnabled, + "enable_batch_update": common.BatchUpdateEnabled, + "enable_drawing": common.DrawingEnabled, + "enable_task": common.TaskEnabled, + "enable_data_export": common.DataExportEnabled, + "data_export_default_time": common.DataExportDefaultTime, + "default_collapse_sidebar": common.DefaultCollapseSidebar, + "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", + "mj_notify_enabled": setting.MjNotifyEnabled, + "chats": setting.Chats, + "demo_site_enabled": operation_setting.DemoSiteEnabled, + "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "default_use_auto_group": setting.DefaultUseAutoGroup, + "pay_methods": setting.PayMethods, + + // 面板启用开关 + "api_info_enabled": cs.ApiInfoEnabled, + "uptime_kuma_enabled": cs.UptimeKumaEnabled, + "announcements_enabled": cs.AnnouncementsEnabled, + "faq_enabled": cs.FAQEnabled, + + "oidc_enabled": system_setting.GetOIDCSettings().Enabled, + "oidc_client_id": system_setting.GetOIDCSettings().ClientId, + "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, + "setup": constant.Setup, + } + + // 根据启用状态注入可选内容 + if cs.ApiInfoEnabled { + data["api_info"] = console_setting.GetApiInfo() + } + if cs.AnnouncementsEnabled { + data["announcements"] = console_setting.GetAnnouncements() + } + if cs.FAQEnabled { + data["faq"] = console_setting.GetFAQ() + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": gin.H{ - "version": common.Version, - "start_time": common.StartTime, - "email_verification": common.EmailVerificationEnabled, - "github_oauth": common.GitHubOAuthEnabled, - "github_client_id": common.GitHubClientId, - "linuxdo_oauth": common.LinuxDOOAuthEnabled, - "linuxdo_client_id": common.LinuxDOClientId, - "telegram_oauth": common.TelegramOAuthEnabled, - "telegram_bot_name": common.TelegramBotName, - "system_name": common.SystemName, - "logo": common.Logo, - "footer_html": common.Footer, - "wechat_qrcode": common.WeChatAccountQRCodeImageURL, - "wechat_login": common.WeChatAuthEnabled, - "server_address": setting.ServerAddress, - "price": setting.Price, - "min_topup": setting.MinTopUp, - "turnstile_check": common.TurnstileCheckEnabled, - "turnstile_site_key": common.TurnstileSiteKey, - "top_up_link": common.TopUpLink, - "docs_link": operation_setting.GetGeneralSetting().DocsLink, - "quota_per_unit": common.QuotaPerUnit, - "display_in_currency": common.DisplayInCurrencyEnabled, - "enable_batch_update": common.BatchUpdateEnabled, - "enable_drawing": common.DrawingEnabled, - "enable_task": common.TaskEnabled, - "enable_data_export": common.DataExportEnabled, - "data_export_default_time": common.DataExportDefaultTime, - "default_collapse_sidebar": common.DefaultCollapseSidebar, - "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", - "mj_notify_enabled": setting.MjNotifyEnabled, - "chats": setting.Chats, - "demo_site_enabled": operation_setting.DemoSiteEnabled, - "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, - "oidc_enabled": system_setting.GetOIDCSettings().Enabled, - "oidc_client_id": system_setting.GetOIDCSettings().ClientId, - "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, - "setup": constant.Setup, - }, + "data": data, }) return } diff --git a/controller/model.go b/controller/model.go index df7e59a6..78bd32d6 100644 --- a/controller/model.go +++ b/controller/model.go @@ -2,7 +2,7 @@ package controller import ( "fmt" - "github.com/gin-gonic/gin" + "github.com/samber/lo" "net/http" "one-api/common" "one-api/constant" @@ -15,6 +15,9 @@ import ( "one-api/relay/channel/moonshot" relaycommon "one-api/relay/common" relayconstant "one-api/relay/constant" + "one-api/setting" + + "github.com/gin-gonic/gin" ) // https://platform.openai.com/docs/api-reference/models/list @@ -134,6 +137,9 @@ func init() { adaptor.Init(meta) channelId2Models[i] = adaptor.GetModelList() } + openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string { + return m.Id + }) } func ListModels(c *gin.Context) { @@ -179,7 +185,19 @@ func ListModels(c *gin.Context) { if tokenGroup != "" { group = tokenGroup } - models := model.GetGroupModels(group) + var models []string + if tokenGroup == "auto" { + for _, autoGroup := range setting.AutoGroups { + groupModels := model.GetGroupModels(autoGroup) + for _, g := range groupModels { + if !common.StringsContains(models, g) { + models = append(models, g) + } + } + } + } else { + models = model.GetGroupModels(group) + } for _, s := range models { if _, ok := openAIModelsMap[s]; ok { userOpenAiModels = append(userOpenAiModels, openAIModelsMap[s]) diff --git a/controller/option.go b/controller/option.go index 250f16bb..97bb6a5a 100644 --- a/controller/option.go +++ b/controller/option.go @@ -6,6 +6,8 @@ import ( "one-api/common" "one-api/model" "one-api/setting" + "one-api/setting/console_setting" + "one-api/setting/ratio_setting" "one-api/setting/system_setting" "strings" @@ -102,7 +104,7 @@ func UpdateOption(c *gin.Context) { return } case "GroupRatio": - err = setting.CheckGroupRatio(option.Value) + err = ratio_setting.CheckGroupRatio(option.Value) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -119,7 +121,42 @@ func UpdateOption(c *gin.Context) { }) return } - + case "console_setting.api_info": + err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.announcements": + err = console_setting.ValidateConsoleSettings(option.Value, "Announcements") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.faq": + err = console_setting.ValidateConsoleSettings(option.Value, "FAQ") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.uptime_kuma_groups": + err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } } err = model.UpdateOption(option.Key, option.Value) if err != nil { diff --git a/controller/playground.go b/controller/playground.go index a2b54790..10393250 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -3,7 +3,6 @@ package controller import ( "errors" "fmt" - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/constant" @@ -13,6 +12,8 @@ import ( "one-api/service" "one-api/setting" "time" + + "github.com/gin-gonic/gin" ) func Playground(c *gin.Context) { @@ -57,13 +58,22 @@ func Playground(c *gin.Context) { c.Set("group", group) } c.Set("token_name", "playground-"+group) - channel, err := model.CacheGetRandomSatisfiedChannel(group, playgroundRequest.Model, 0) + channel, finalGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, playgroundRequest.Model, 0) if err != nil { - message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", group, playgroundRequest.Model) + message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", finalGroup, playgroundRequest.Model) openaiErr = service.OpenAIErrorWrapperLocal(errors.New(message), "get_playground_channel_failed", http.StatusInternalServerError) return } middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model) c.Set(constant.ContextKeyRequestStartTime, time.Now()) + + // Write user context to ensure acceptUnsetRatio is available + userId := c.GetInt("id") + userCache, err := model.GetUserCache(userId) + if err != nil { + openaiErr = service.OpenAIErrorWrapperLocal(err, "get_user_cache_failed", http.StatusInternalServerError) + return + } + userCache.WriteContext(c) Relay(c) } diff --git a/controller/pricing.go b/controller/pricing.go index 1cbfe731..f27336b7 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -1,10 +1,11 @@ package controller import ( - "github.com/gin-gonic/gin" "one-api/model" "one-api/setting" - "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" ) func GetPricing(c *gin.Context) { @@ -12,7 +13,7 @@ func GetPricing(c *gin.Context) { userId, exists := c.Get("id") usableGroup := map[string]string{} groupRatio := map[string]float64{} - for s, f := range setting.GetGroupRatioCopy() { + for s, f := range ratio_setting.GetGroupRatioCopy() { groupRatio[s] = f } var group string @@ -20,12 +21,18 @@ func GetPricing(c *gin.Context) { user, err := model.GetUserCache(userId.(int)) if err == nil { group = user.Group + for g := range groupRatio { + ratio, ok := ratio_setting.GetGroupGroupRatio(group, g) + if ok { + groupRatio[g] = ratio + } + } } } usableGroup = setting.GetUserUsableGroups(group) // check groupRatio contains usableGroup - for group := range setting.GetGroupRatioCopy() { + for group := range ratio_setting.GetGroupRatioCopy() { if _, ok := usableGroup[group]; !ok { delete(groupRatio, group) } @@ -40,7 +47,7 @@ func GetPricing(c *gin.Context) { } func ResetModelRatio(c *gin.Context) { - defaultStr := operation_setting.DefaultModelRatio2JSONString() + defaultStr := ratio_setting.DefaultModelRatio2JSONString() err := model.UpdateOption("ModelRatio", defaultStr) if err != nil { c.JSON(200, gin.H{ @@ -49,7 +56,7 @@ func ResetModelRatio(c *gin.Context) { }) return } - err = operation_setting.UpdateModelRatioByJSONString(defaultStr) + err = ratio_setting.UpdateModelRatioByJSONString(defaultStr) if err != nil { c.JSON(200, gin.H{ "success": false, diff --git a/controller/ratio_config.go b/controller/ratio_config.go new file mode 100644 index 00000000..6ddc3d9e --- /dev/null +++ b/controller/ratio_config.go @@ -0,0 +1,24 @@ +package controller + +import ( + "net/http" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetRatioConfig(c *gin.Context) { + if !ratio_setting.IsExposeRatioEnabled() { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "倍率配置接口未启用", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ratio_setting.GetExposedData(), + }) +} \ No newline at end of file diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go new file mode 100644 index 00000000..f749f384 --- /dev/null +++ b/controller/ratio_sync.go @@ -0,0 +1,322 @@ +package controller + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "one-api/common" + "one-api/dto" + "one-api/model" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +const ( + defaultTimeoutSeconds = 10 + defaultEndpoint = "/api/ratio_config" + maxConcurrentFetches = 8 +) + +var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} + +type upstreamResult struct { + Name string `json:"name"` + Data map[string]any `json:"data,omitempty"` + Err string `json:"err,omitempty"` +} + +func FetchUpstreamRatios(c *gin.Context) { + var req dto.UpstreamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + + if req.Timeout <= 0 { + req.Timeout = defaultTimeoutSeconds + } + + var upstreams []dto.UpstreamDTO + + if len(req.ChannelIDs) > 0 { + intIds := make([]int, 0, len(req.ChannelIDs)) + for _, id64 := range req.ChannelIDs { + intIds = append(intIds, int(id64)) + } + dbChannels, err := model.GetChannelsByIds(intIds) + if err != nil { + common.LogError(c.Request.Context(), "failed to query channels: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"}) + return + } + for _, ch := range dbChannels { + if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") { + upstreams = append(upstreams, dto.UpstreamDTO{ + Name: ch.Name, + BaseURL: strings.TrimRight(base, "/"), + Endpoint: "", + }) + } + } + } + + if len(upstreams) == 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"}) + return + } + + var wg sync.WaitGroup + ch := make(chan upstreamResult, len(upstreams)) + + 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}} + + for _, chn := range upstreams { + wg.Add(1) + go func(chItem dto.UpstreamDTO) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + endpoint := chItem.Endpoint + if endpoint == "" { + endpoint = defaultEndpoint + } else if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + fullURL := chItem.BaseURL + endpoint + + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + common.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + + resp, err := client.Do(httpReq) + if err != nil { + common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status) + ch <- upstreamResult{Name: chItem.Name, Err: resp.Status} + return + } + var body struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + Message string `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + if !body.Success { + ch <- upstreamResult{Name: chItem.Name, Err: body.Message} + return + } + ch <- upstreamResult{Name: chItem.Name, Data: body.Data} + }(chn) + } + + wg.Wait() + close(ch) + + localData := ratio_setting.GetExposedData() + + var testResults []dto.TestResult + var successfulChannels []struct { + name string + data map[string]any + } + + for r := range ch { + if r.Err != "" { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "error", + Error: r.Err, + }) + } else { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "success", + }) + successfulChannels = append(successfulChannels, struct { + name string + data map[string]any + }{name: r.Name, data: r.Data}) + } + } + + differences := buildDifferences(localData, successfulChannels) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "differences": differences, + "test_results": testResults, + }, + }) +} + +func buildDifferences(localData map[string]any, successfulChannels []struct { + name string + data map[string]any +}) map[string]map[string]dto.DifferenceItem { + differences := make(map[string]map[string]dto.DifferenceItem) + + allModels := make(map[string]struct{}) + + for _, ratioType := range ratioTypes { + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + for modelName := range localRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + for _, channel := range successfulChannels { + for _, ratioType := range ratioTypes { + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + for modelName := range upstreamRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + for modelName := range allModels { + for _, ratioType := range ratioTypes { + var localValue interface{} = nil + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + if val, exists := localRatio[modelName]; exists { + localValue = val + } + } + } + + upstreamValues := make(map[string]interface{}) + hasUpstreamValue := false + hasDifference := false + + for _, channel := range successfulChannels { + var upstreamValue interface{} = nil + + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + if val, exists := upstreamRatio[modelName]; exists { + upstreamValue = val + hasUpstreamValue = true + + if localValue != nil && localValue != val { + hasDifference = true + } else if localValue == val { + upstreamValue = "same" + } + } + } + if upstreamValue == nil && localValue == nil { + upstreamValue = "same" + } + + if localValue == nil && upstreamValue != nil && upstreamValue != "same" { + hasDifference = true + } + + upstreamValues[channel.name] = upstreamValue + } + + shouldInclude := false + + if localValue != nil { + if hasDifference { + shouldInclude = true + } + } else { + if hasUpstreamValue { + shouldInclude = true + } + } + + if shouldInclude { + if differences[modelName] == nil { + differences[modelName] = make(map[string]dto.DifferenceItem) + } + differences[modelName][ratioType] = dto.DifferenceItem{ + Current: localValue, + Upstreams: upstreamValues, + } + } + } + } + + channelHasDiff := make(map[string]bool) + for _, ratioMap := range differences { + for _, item := range ratioMap { + for chName, val := range item.Upstreams { + if val != nil && val != "same" { + channelHasDiff[chName] = true + } + } + } + } + + for modelName, ratioMap := range differences { + for ratioType, item := range ratioMap { + for chName := range item.Upstreams { + if !channelHasDiff[chName] { + delete(item.Upstreams, chName) + } + } + differences[modelName][ratioType] = item + } + } + + return differences +} + +func GetSyncableChannels(c *gin.Context) { + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var syncableChannels []dto.SyncableChannel + for _, channel := range channels { + if channel.GetBaseURL() != "" { + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: channel.Id, + Name: channel.Name, + BaseURL: channel.GetBaseURL(), + Status: channel.Status, + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": syncableChannels, + }) +} \ No newline at end of file diff --git a/controller/redemption.go b/controller/redemption.go index a7e09a8a..50620597 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -5,6 +5,7 @@ import ( "one-api/common" "one-api/model" "strconv" + "errors" "github.com/gin-gonic/gin" ) @@ -126,6 +127,10 @@ func AddRedemption(c *gin.Context) { }) return } + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } var keys []string for i := 0; i < redemption.Count; i++ { key := common.GetUUID() @@ -135,6 +140,7 @@ func AddRedemption(c *gin.Context) { Key: key, CreatedTime: common.GetTimestamp(), Quota: redemption.Quota, + ExpiredTime: redemption.ExpiredTime, } err = cleanRedemption.Insert() if err != nil { @@ -191,12 +197,18 @@ func UpdateRedemption(c *gin.Context) { }) return } - if statusOnly != "" { - cleanRedemption.Status = redemption.Status - } else { + if statusOnly == "" { + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } // If you add more fields, please also update redemption.Update() cleanRedemption.Name = redemption.Name cleanRedemption.Quota = redemption.Quota + cleanRedemption.ExpiredTime = redemption.ExpiredTime + } + if statusOnly != "" { + cleanRedemption.Status = redemption.Status } err = cleanRedemption.Update() if err != nil { @@ -213,3 +225,27 @@ func UpdateRedemption(c *gin.Context) { }) return } + +func DeleteInvalidRedemption(c *gin.Context) { + rows, err := model.DeleteInvalidRedemptions() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + +func validateExpiredTime(expired int64) error { + if expired != 0 && expired < common.GetTimestamp() { + return errors.New("过期时间不能早于当前时间") + } + return nil +} diff --git a/controller/relay.go b/controller/relay.go index 41cb22a5..4da4262b 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -40,6 +40,8 @@ func relayHandler(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode err = relay.EmbeddingHelper(c) case relayconstant.RelayModeResponses: err = relay.ResponsesHelper(c) + case relayconstant.RelayModeGemini: + err = relay.GeminiHelper(c) default: err = relay.TextHelper(c) } @@ -257,7 +259,7 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m AutoBan: &autoBanInt, }, nil } - channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, retryCount) + channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) if err != nil { return nil, errors.New(fmt.Sprintf("获取重试渠道失败: %s", err.Error())) } @@ -386,7 +388,7 @@ func RelayTask(c *gin.Context) { retryTimes = 0 } for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ { - channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, i) + channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, i) if err != nil { common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", err.Error())) break @@ -418,7 +420,7 @@ func RelayTask(c *gin.Context) { func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError { var err *dto.TaskError switch relayMode { - case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID: + case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeKlingFetchByID: err = relay.RelayTaskFetch(c, relayMode) default: err = relay.RelayTaskSubmit(c, relayMode) diff --git a/controller/setup.go b/controller/setup.go index 0a13bcf9..8943a1a0 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -75,6 +75,14 @@ func PostSetup(c *gin.Context) { // If root doesn't exist, validate and create admin account if !rootExists { + // Validate username length: max 12 characters to align with model.User validation + if len(req.Username) > 12 { + c.JSON(400, gin.H{ + "success": false, + "message": "用户名长度不能超过12个字符", + }) + return + } // Validate password if req.Password != req.ConfirmPassword { c.JSON(400, gin.H{ diff --git a/controller/task.go b/controller/task.go index 65f79ead..f7523e87 100644 --- a/controller/task.go +++ b/controller/task.go @@ -74,6 +74,8 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][ //_ = UpdateMidjourneyTaskAll(context.Background(), tasks) case constant.TaskPlatformSuno: _ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM) + case constant.TaskPlatformKling: + _ = UpdateVideoTaskAll(context.Background(), taskChannelM, taskM) default: common.SysLog("未知平台") } @@ -224,9 +226,14 @@ func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool func GetAllTask(c *gin.Context) { p, _ := strconv.Atoi(c.Query("p")) - if p < 0 { - p = 0 + if p < 1 { + p = 1 } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage + } + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) // 解析其他查询参数 @@ -237,24 +244,32 @@ func GetAllTask(c *gin.Context) { Action: c.Query("action"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp, + ChannelID: c.Query("channel_id"), } - logs := model.TaskGetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams) - if logs == nil { - logs = make([]*model.Task, 0) - } + items := model.TaskGetAllTasks((p-1)*pageSize, pageSize, queryParams) + total := model.TaskCountAllTasks(queryParams) c.JSON(200, gin.H{ "success": true, "message": "", - "data": logs, + "data": gin.H{ + "items": items, + "total": total, + "page": p, + "page_size": pageSize, + }, }) } func GetUserTask(c *gin.Context) { p, _ := strconv.Atoi(c.Query("p")) - if p < 0 { - p = 0 + if p < 1 { + p = 1 + } + pageSize, _ := strconv.Atoi(c.Query("page_size")) + if pageSize <= 0 { + pageSize = common.ItemsPerPage } userId := c.GetInt("id") @@ -271,14 +286,17 @@ func GetUserTask(c *gin.Context) { EndTimestamp: endTimestamp, } - logs := model.TaskGetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams) - if logs == nil { - logs = make([]*model.Task, 0) - } + items := model.TaskGetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams) + total := model.TaskCountAllUserTask(userId, queryParams) c.JSON(200, gin.H{ "success": true, "message": "", - "data": logs, + "data": gin.H{ + "items": items, + "total": total, + "page": p, + "page_size": pageSize, + }, }) } diff --git a/controller/task_video.go b/controller/task_video.go new file mode 100644 index 00000000..a2c2431d --- /dev/null +++ b/controller/task_video.go @@ -0,0 +1,140 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/model" + "one-api/relay" + "one-api/relay/channel" +) + +func UpdateVideoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { + for channelId, taskIds := range taskChannelM { + if err := updateVideoTaskAll(ctx, channelId, taskIds, taskM); err != nil { + common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error())) + } + } + return nil +} + +func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error { + common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + return nil + } + cacheGetChannel, err := model.CacheGetChannel(channelId) + if err != nil { + errUpdate := model.TaskBulkUpdate(taskIds, map[string]any{ + "fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId), + "status": "FAILURE", + "progress": "100%", + }) + if errUpdate != nil { + common.SysError(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate)) + } + return fmt.Errorf("CacheGetChannel failed: %w", err) + } + adaptor := relay.GetTaskAdaptor(constant.TaskPlatformKling) + if adaptor == nil { + return fmt.Errorf("video adaptor not found") + } + for _, taskId := range taskIds { + if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil { + common.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error())) + } + } + return nil +} + +func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, channel *model.Channel, taskId string, taskM map[string]*model.Task) error { + baseURL := common.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{ + "task_id": taskId, + }) + if err != nil { + return fmt.Errorf("FetchTask failed for task %s: %w", taskId, err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Get Video Task status code: %d", resp.StatusCode) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("ReadAll failed for task %s: %w", taskId, err) + } + + var responseItem map[string]interface{} + err = json.Unmarshal(responseBody, &responseItem) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Failed to parse video task response body: %v, body: %s", err, string(responseBody))) + return fmt.Errorf("Unmarshal failed for task %s: %w", taskId, err) + } + + code, _ := responseItem["code"].(float64) + if code != 0 { + return fmt.Errorf("video task fetch failed for task %s", taskId) + } + + data, ok := responseItem["data"].(map[string]interface{}) + if !ok { + common.LogError(ctx, fmt.Sprintf("Video task data format error: %s", string(responseBody))) + return fmt.Errorf("video task data format error for task %s", taskId) + } + + task := taskM[taskId] + if task == nil { + common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) + return fmt.Errorf("task %s not found", taskId) + } + + if status, ok := data["task_status"].(string); ok { + switch status { + case "submitted", "queued": + task.Status = model.TaskStatusSubmitted + case "processing": + task.Status = model.TaskStatusInProgress + case "succeed": + task.Status = model.TaskStatusSuccess + task.Progress = "100%" + if url, err := adaptor.ParseResultUrl(responseItem); err == nil { + task.FailReason = url + } else { + common.LogWarn(ctx, fmt.Sprintf("Failed to get url from body for task %s: %s", task.TaskID, err.Error())) + } + case "failed": + task.Status = model.TaskStatusFailure + task.Progress = "100%" + if reason, ok := data["fail_reason"].(string); ok { + task.FailReason = reason + } + } + } + + // If task failed, refund quota + if task.Status == model.TaskStatusFailure { + common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) + quota := task.Quota + if quota != 0 { + if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil { + common.LogError(ctx, "Failed to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } + + task.Data = responseBody + if err := task.Update(); err != nil { + common.SysError("UpdateVideoTask task error: " + err.Error()) + } + + return nil +} diff --git a/controller/token.go b/controller/token.go index a8803279..c57552c0 100644 --- a/controller/token.go +++ b/controller/token.go @@ -12,15 +12,15 @@ func GetAllTokens(c *gin.Context) { userId := c.GetInt("id") p, _ := strconv.Atoi(c.Query("p")) size, _ := strconv.Atoi(c.Query("size")) - if p < 0 { - p = 0 + if p < 1 { + p = 1 } if size <= 0 { size = common.ItemsPerPage } else if size > 100 { size = 100 } - tokens, err := model.GetAllUserTokens(userId, p*size, size) + tokens, err := model.GetAllUserTokens(userId, (p-1)*size, size) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -28,10 +28,18 @@ func GetAllTokens(c *gin.Context) { }) return } + // Get total count for pagination + total, _ := model.CountUserTokens(userId) + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": tokens, + "data": gin.H{ + "items": tokens, + "total": total, + "page": p, + "page_size": size, + }, }) return } diff --git a/controller/topup.go b/controller/topup.go index 4654b6ea..827dda39 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -97,16 +97,14 @@ func RequestEpay(c *gin.Context) { c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) return } - payType := "wxpay" - if req.PaymentMethod == "zfb" { - payType = "alipay" - } - if req.PaymentMethod == "wx" { - req.PaymentMethod = "wxpay" - payType = "wxpay" + + if !setting.ContainsPayMethod(req.PaymentMethod) { + c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) + return } + callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(setting.ServerAddress + "/log") + returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log") notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) @@ -116,7 +114,7 @@ func RequestEpay(c *gin.Context) { return } uri, params, err := client.Purchase(&epay.PurchaseArgs{ - Type: payType, + Type: req.PaymentMethod, ServiceTradeNo: tradeNo, Name: fmt.Sprintf("TUC%d", req.Amount), Money: strconv.FormatFloat(payMoney, 'f', 2, 64), diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go new file mode 100644 index 00000000..05d6297e --- /dev/null +++ b/controller/uptime_kuma.go @@ -0,0 +1,154 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "one-api/setting/console_setting" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +const ( + requestTimeout = 30 * time.Second + httpTimeout = 10 * time.Second + uptimeKeySuffix = "_24" + apiStatusPath = "/api/status-page/" + apiHeartbeatPath = "/api/status-page/heartbeat/" +) + +type Monitor struct { + Name string `json:"name"` + Uptime float64 `json:"uptime"` + Status int `json:"status"` + Group string `json:"group,omitempty"` +} + +type UptimeGroupResult struct { + CategoryName string `json:"categoryName"` + Monitors []Monitor `json:"monitors"` +} + +func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("non-200 status") + } + + return json.NewDecoder(resp.Body).Decode(dest) +} + +func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult { + url, _ := groupConfig["url"].(string) + slug, _ := groupConfig["slug"].(string) + categoryName, _ := groupConfig["categoryName"].(string) + + result := UptimeGroupResult{ + CategoryName: categoryName, + Monitors: []Monitor{}, + } + + if url == "" || slug == "" { + return result + } + + baseURL := strings.TrimSuffix(url, "/") + + var statusData struct { + PublicGroupList []struct { + ID int `json:"id"` + Name string `json:"name"` + MonitorList []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"monitorList"` + } `json:"publicGroupList"` + } + + var heartbeatData struct { + HeartbeatList map[string][]struct { + Status int `json:"status"` + } `json:"heartbeatList"` + UptimeList map[string]float64 `json:"uptimeList"` + } + + g, gCtx := errgroup.WithContext(ctx) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) + }) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) + }) + + if g.Wait() != nil { + return result + } + + for _, pg := range statusData.PublicGroupList { + if len(pg.MonitorList) == 0 { + continue + } + + for _, m := range pg.MonitorList { + monitor := Monitor{ + Name: m.Name, + Group: pg.Name, + } + + monitorID := strconv.Itoa(m.ID) + + if uptime, exists := heartbeatData.UptimeList[monitorID+uptimeKeySuffix]; exists { + monitor.Uptime = uptime + } + + if heartbeats, exists := heartbeatData.HeartbeatList[monitorID]; exists && len(heartbeats) > 0 { + monitor.Status = heartbeats[0].Status + } + + result.Monitors = append(result.Monitors, monitor) + } + } + + return result +} + +func GetUptimeKumaStatus(c *gin.Context) { + groups := console_setting.GetUptimeKumaGroups() + if len(groups) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + client := &http.Client{Timeout: httpTimeout} + results := make([]UptimeGroupResult, len(groups)) + + g, gCtx := errgroup.WithContext(ctx) + for i, group := range groups { + i, group := i, group + g.Go(func() error { + results[i] = fetchGroupData(gCtx, client, group) + return nil + }) + } + + g.Wait() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results}) +} \ No newline at end of file diff --git a/controller/user.go b/controller/user.go index fd53e743..e8ce3c3d 100644 --- a/controller/user.go +++ b/controller/user.go @@ -226,6 +226,9 @@ func Register(c *gin.Context) { UnlimitedQuota: true, ModelLimitsEnabled: false, } + if setting.DefaultUseAutoGroup { + token.Group = "auto" + } if err := token.Insert(); err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -459,6 +462,9 @@ func GetSelf(c *gin.Context) { }) return } + // 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 = "" + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -943,6 +949,7 @@ type UpdateUserSettingRequest struct { WebhookSecret string `json:"webhook_secret,omitempty"` NotificationEmail string `json:"notification_email,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` + RecordIpLog bool `json:"record_ip_log"` } func UpdateUserSetting(c *gin.Context) { @@ -1019,6 +1026,7 @@ func UpdateUserSetting(c *gin.Context) { constant.UserSettingNotifyType: req.QuotaWarningType, constant.UserSettingQuotaWarningThreshold: req.QuotaWarningThreshold, "accept_unset_model_ratio_model": req.AcceptUnsetModelRatioModel, + constant.UserSettingRecordIpLog: req.RecordIpLog, } // 如果是webhook类型,添加webhook相关设置 diff --git a/dto/claude.go b/dto/claude.go index 8068feb8..98e09c78 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -1,29 +1,33 @@ package dto -import "encoding/json" +import ( + "encoding/json" + "one-api/common" +) type ClaudeMetadata struct { UserId string `json:"user_id"` } type ClaudeMediaMessage struct { - Type string `json:"type,omitempty"` - Text *string `json:"text,omitempty"` - Model string `json:"model,omitempty"` - Source *ClaudeMessageSource `json:"source,omitempty"` - Usage *ClaudeUsage `json:"usage,omitempty"` - StopReason *string `json:"stop_reason,omitempty"` - PartialJson *string `json:"partial_json,omitempty"` - Role string `json:"role,omitempty"` - Thinking string `json:"thinking,omitempty"` - Signature string `json:"signature,omitempty"` - Delta string `json:"delta,omitempty"` + Type string `json:"type,omitempty"` + Text *string `json:"text,omitempty"` + Model string `json:"model,omitempty"` + Source *ClaudeMessageSource `json:"source,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` + PartialJson *string `json:"partial_json,omitempty"` + Role string `json:"role,omitempty"` + Thinking string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` + Delta string `json:"delta,omitempty"` + CacheControl json.RawMessage `json:"cache_control,omitempty"` // tool_calls - Id string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input any `json:"input,omitempty"` - Content json.RawMessage `json:"content,omitempty"` - ToolUseId string `json:"tool_use_id,omitempty"` + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Content any `json:"content,omitempty"` + ToolUseId string `json:"tool_use_id,omitempty"` } func (c *ClaudeMediaMessage) SetText(s string) { @@ -38,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string { } func (c *ClaudeMediaMessage) IsStringContent() bool { - var content string - return json.Unmarshal(c.Content, &content) == nil + if c.Content == nil { + return false + } + _, ok := c.Content.(string) + if ok { + return true + } + return false } func (c *ClaudeMediaMessage) GetStringContent() string { - var content string - if err := json.Unmarshal(c.Content, &content); err == nil { - return content + if c.Content == nil { + return "" } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + return "" } @@ -56,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string { } func (c *ClaudeMediaMessage) SetContent(content any) { - jsonContent, _ := json.Marshal(content) - c.Content = jsonContent + c.Content = content } func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { - var mediaContent []ClaudeMediaMessage - if err := json.Unmarshal(c.Content, &mediaContent); err == nil { - return mediaContent - } - return make([]ClaudeMediaMessage, 0) + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content) + return mediaContent } type ClaudeMessageSource struct { @@ -81,14 +105,36 @@ type ClaudeMessage struct { } func (c *ClaudeMessage) IsStringContent() bool { + if c.Content == nil { + return false + } _, ok := c.Content.(string) return ok } func (c *ClaudeMessage) GetStringContent() string { - if c.IsStringContent() { - return c.Content.(string) + if c.Content == nil { + return "" } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + return "" } @@ -97,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) { } func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) { - // map content to []ClaudeMediaMessage - // parse to json - jsonContent, _ := json.Marshal(c.Content) - var contentList []ClaudeMediaMessage - err := json.Unmarshal(jsonContent, &contentList) - if err != nil { - return make([]ClaudeMediaMessage, 0), err - } - return contentList, nil + return common.Any2Type[[]ClaudeMediaMessage](c.Content) } type Tool struct { @@ -140,7 +178,14 @@ type ClaudeRequest struct { type Thinking struct { Type string `json:"type"` - BudgetTokens int `json:"budget_tokens"` + BudgetTokens *int `json:"budget_tokens,omitempty"` +} + +func (c *Thinking) GetBudgetTokens() int { + if c.BudgetTokens == nil { + return 0 + } + return *c.BudgetTokens } func (c *ClaudeRequest) IsStringSystem() bool { @@ -160,14 +205,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) { } func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { - // map content to []ClaudeMediaMessage - // parse to json - jsonContent, _ := json.Marshal(c.System) - var contentList []ClaudeMediaMessage - if err := json.Unmarshal(jsonContent, &contentList); err == nil { - return contentList - } - return make([]ClaudeMediaMessage, 0) + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System) + return mediaContent } type ClaudeError struct { diff --git a/dto/dalle.go b/dto/dalle.go index 44104d33..ce2f6361 100644 --- a/dto/dalle.go +++ b/dto/dalle.go @@ -14,6 +14,8 @@ type ImageRequest struct { ExtraFields json.RawMessage `json:"extra_fields,omitempty"` Background string `json:"background,omitempty"` Moderation string `json:"moderation,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + Watermark *bool `json:"watermark,omitempty"` } type ImageResponse struct { diff --git a/dto/openai_request.go b/dto/openai_request.go index e8833b3d..42c290ca 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -2,6 +2,7 @@ package dto import ( "encoding/json" + "one-api/common" "strings" ) @@ -18,41 +19,54 @@ type FormatJsonSchema struct { } type GeneralOpenAIRequest struct { - Model string `json:"model,omitempty"` - Messages []Message `json:"messages,omitempty"` - Prompt any `json:"prompt,omitempty"` - Prefix any `json:"prefix,omitempty"` - Suffix any `json:"suffix,omitempty"` - Stream bool `json:"stream,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - MaxTokens uint `json:"max_tokens,omitempty"` - MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - //Reasoning json.RawMessage `json:"reasoning,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP float64 `json:"top_p,omitempty"` - TopK int `json:"top_k,omitempty"` - Stop any `json:"stop,omitempty"` - N int `json:"n,omitempty"` - Input any `json:"input,omitempty"` - Instruction string `json:"instruction,omitempty"` - Size string `json:"size,omitempty"` - Functions any `json:"functions,omitempty"` - FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` - PresencePenalty float64 `json:"presence_penalty,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - EncodingFormat any `json:"encoding_format,omitempty"` - Seed float64 `json:"seed,omitempty"` - Tools []ToolCallRequest `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - User string `json:"user,omitempty"` - LogProbs bool `json:"logprobs,omitempty"` - TopLogProbs int `json:"top_logprobs,omitempty"` - Dimensions int `json:"dimensions,omitempty"` - Modalities any `json:"modalities,omitempty"` - Audio any `json:"audio,omitempty"` - EnableThinking any `json:"enable_thinking,omitempty"` // ali - ExtraBody any `json:"extra_body,omitempty"` + Model string `json:"model,omitempty"` + Messages []Message `json:"messages,omitempty"` + Prompt any `json:"prompt,omitempty"` + Prefix any `json:"prefix,omitempty"` + Suffix any `json:"suffix,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Stop any `json:"stop,omitempty"` + N int `json:"n,omitempty"` + Input any `json:"input,omitempty"` + Instruction string `json:"instruction,omitempty"` + Size string `json:"size,omitempty"` + Functions json.RawMessage `json:"functions,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + EncodingFormat json.RawMessage `json:"encoding_format,omitempty"` + Seed float64 `json:"seed,omitempty"` + ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` + Tools []ToolCallRequest `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + User string `json:"user,omitempty"` + LogProbs bool `json:"logprobs,omitempty"` + TopLogProbs int `json:"top_logprobs,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + Modalities json.RawMessage `json:"modalities,omitempty"` + Audio json.RawMessage `json:"audio,omitempty"` + EnableThinking any `json:"enable_thinking,omitempty"` // ali + THINKING json.RawMessage `json:"thinking,omitempty"` // doubao + ExtraBody json.RawMessage `json:"extra_body,omitempty"` + WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` + // OpenRouter Params + Reasoning json.RawMessage `json:"reasoning,omitempty"` + // Ali Qwen Params + VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` +} + +func (r *GeneralOpenAIRequest) ToMap() map[string]any { + result := make(map[string]any) + data, _ := common.EncodeJson(r) + _ = common.DecodeJson(data, &result) + return result } type ToolCallRequest struct { @@ -72,11 +86,11 @@ type StreamOptions struct { IncludeUsage bool `json:"include_usage,omitempty"` } -func (r GeneralOpenAIRequest) GetMaxTokens() int { +func (r *GeneralOpenAIRequest) GetMaxTokens() int { return int(r.MaxTokens) } -func (r GeneralOpenAIRequest) ParseInput() []string { +func (r *GeneralOpenAIRequest) ParseInput() []string { if r.Input == nil { return nil } @@ -96,16 +110,16 @@ func (r GeneralOpenAIRequest) ParseInput() []string { } type Message struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - Name *string `json:"name,omitempty"` - Prefix *bool `json:"prefix,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - ToolCalls json.RawMessage `json:"tool_calls,omitempty"` - ToolCallId string `json:"tool_call_id,omitempty"` - parsedContent []MediaContent - parsedStringContent *string + Role string `json:"role"` + Content any `json:"content"` + Name *string `json:"name,omitempty"` + Prefix *bool `json:"prefix,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` + parsedContent []MediaContent + //parsedStringContent *string } type MediaContent struct { @@ -115,25 +129,56 @@ type MediaContent struct { InputAudio any `json:"input_audio,omitempty"` File any `json:"file,omitempty"` VideoUrl any `json:"video_url,omitempty"` + // OpenRouter Params + CacheControl json.RawMessage `json:"cache_control,omitempty"` } func (m *MediaContent) GetImageMedia() *MessageImageUrl { if m.ImageUrl != nil { - return m.ImageUrl.(*MessageImageUrl) + if _, ok := m.ImageUrl.(*MessageImageUrl); ok { + return m.ImageUrl.(*MessageImageUrl) + } + if itemMap, ok := m.ImageUrl.(map[string]any); ok { + out := &MessageImageUrl{ + Url: common.Interface2String(itemMap["url"]), + Detail: common.Interface2String(itemMap["detail"]), + MimeType: common.Interface2String(itemMap["mime_type"]), + } + return out + } } return nil } func (m *MediaContent) GetInputAudio() *MessageInputAudio { if m.InputAudio != nil { - return m.InputAudio.(*MessageInputAudio) + if _, ok := m.InputAudio.(*MessageInputAudio); ok { + return m.InputAudio.(*MessageInputAudio) + } + if itemMap, ok := m.InputAudio.(map[string]any); ok { + out := &MessageInputAudio{ + Data: common.Interface2String(itemMap["data"]), + Format: common.Interface2String(itemMap["format"]), + } + return out + } } return nil } func (m *MediaContent) GetFile() *MessageFile { if m.File != nil { - return m.File.(*MessageFile) + if _, ok := m.File.(*MessageFile); ok { + return m.File.(*MessageFile) + } + if itemMap, ok := m.File.(map[string]any); ok { + out := &MessageFile{ + FileName: common.Interface2String(itemMap["file_name"]), + FileData: common.Interface2String(itemMap["file_data"]), + FileId: common.Interface2String(itemMap["file_id"]), + } + return out + } } return nil } @@ -199,6 +244,186 @@ func (m *Message) SetToolCalls(toolCalls any) { } func (m *Message) StringContent() string { + switch m.Content.(type) { + case string: + return m.Content.(string) + case []any: + var contentStr string + for _, contentItem := range m.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (m *Message) SetNullContent() { + m.Content = nil + m.parsedContent = nil +} + +func (m *Message) SetStringContent(content string) { + m.Content = content + m.parsedContent = nil +} + +func (m *Message) SetMediaContent(content []MediaContent) { + m.Content = content + m.parsedContent = content +} + +func (m *Message) IsStringContent() bool { + _, ok := m.Content.(string) + if ok { + return true + } + return false +} + +func (m *Message) ParseContent() []MediaContent { + if m.Content == nil { + return nil + } + if len(m.parsedContent) > 0 { + return m.parsedContent + } + + var contentList []MediaContent + // 先尝试解析为字符串 + content, ok := m.Content.(string) + if ok { + contentList = []MediaContent{{ + Type: ContentTypeText, + Text: content, + }} + m.parsedContent = contentList + return contentList + } + + // 尝试解析为数组 + //var arrayContent []map[string]interface{} + + arrayContent, ok := m.Content.([]any) + if !ok { + return contentList + } + + for _, contentItemAny := range arrayContent { + mediaItem, ok := contentItemAny.(MediaContent) + if ok { + contentList = append(contentList, mediaItem) + continue + } + + contentItem, ok := contentItemAny.(map[string]any) + if !ok { + continue + } + contentType, ok := contentItem["type"].(string) + if !ok { + continue + } + + switch contentType { + case ContentTypeText: + if text, ok := contentItem["text"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeText, + Text: text, + }) + } + + case ContentTypeImageURL: + imageUrl := contentItem["image_url"] + temp := &MessageImageUrl{ + Detail: "high", + } + switch v := imageUrl.(type) { + case string: + temp.Url = v + case map[string]interface{}: + url, ok1 := v["url"].(string) + detail, ok2 := v["detail"].(string) + if ok2 { + temp.Detail = detail + } + if ok1 { + temp.Url = url + } + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeImageURL, + ImageUrl: temp, + }) + + case ContentTypeInputAudio: + if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok { + data, ok1 := audioData["data"].(string) + format, ok2 := audioData["format"].(string) + if ok1 && ok2 { + temp := &MessageInputAudio{ + Data: data, + Format: format, + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeInputAudio, + InputAudio: temp, + }) + } + } + case ContentTypeFile: + if fileData, ok := contentItem["file"].(map[string]interface{}); ok { + fileId, ok3 := fileData["file_id"].(string) + if ok3 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileId: fileId, + }, + }) + } else { + fileName, ok1 := fileData["filename"].(string) + fileDataStr, ok2 := fileData["file_data"].(string) + if ok1 && ok2 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileName: fileName, + FileData: fileDataStr, + }, + }) + } + } + } + case ContentTypeVideoUrl: + if videoUrl, ok := contentItem["video_url"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeVideoUrl, + VideoUrl: &MessageVideoUrl{ + Url: videoUrl, + }, + }) + } + } + } + + if len(contentList) > 0 { + m.parsedContent = contentList + } + return contentList +} + +// old code +/*func (m *Message) StringContent() string { if m.parsedStringContent != nil { return *m.parsedStringContent } @@ -369,6 +594,11 @@ func (m *Message) ParseContent() []MediaContent { m.parsedContent = contentList } return contentList +}*/ + +type WebSearchOptions struct { + SearchContextSize string `json:"search_context_size,omitempty"` + UserLocation json.RawMessage `json:"user_location,omitempty"` } type OpenAIResponsesRequest struct { diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go new file mode 100644 index 00000000..55a89025 --- /dev/null +++ b/dto/ratio_sync.go @@ -0,0 +1,49 @@ +package dto + +// UpstreamDTO 提交到后端同步倍率的上游渠道信息 +// Endpoint 可以为空,后端会默认使用 /api/ratio_config +// BaseURL 必须以 http/https 开头,不要以 / 结尾 +// 例如: https://api.example.com +// Endpoint: /api/ratio_config +// 提交示例: +// { +// "name": "openai", +// "base_url": "https://api.openai.com", +// "endpoint": "/ratio_config" +// } + +type UpstreamDTO struct { + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + Endpoint string `json:"endpoint"` +} + +type UpstreamRequest struct { + ChannelIDs []int64 `json:"channel_ids"` + Timeout int `json:"timeout"` +} + +// TestResult 上游测试连通性结果 +type TestResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// DifferenceItem 差异项 +// Current 为本地值,可能为 nil +// Upstreams 为各渠道的上游值,具体数值 / "same" / nil + +type DifferenceItem struct { + Current interface{} `json:"current"` + Upstreams map[string]interface{} `json:"upstreams"` +} + +// SyncableChannel 可同步的渠道信息(base_url 不为空) + +type SyncableChannel struct { + ID int `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Status int `json:"status"` +} \ No newline at end of file diff --git a/dto/video.go b/dto/video.go new file mode 100644 index 00000000..5b48146a --- /dev/null +++ b/dto/video.go @@ -0,0 +1,47 @@ +package dto + +type VideoRequest struct { + Model string `json:"model,omitempty" example:"kling-v1"` // Model/style ID + Prompt string `json:"prompt,omitempty" example:"宇航员站起身走了"` // Text prompt + Image string `json:"image,omitempty" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"` // Image input (URL/Base64) + Duration float64 `json:"duration" example:"5.0"` // Video duration (seconds) + Width int `json:"width" example:"512"` // Video width + Height int `json:"height" example:"512"` // Video height + Fps int `json:"fps,omitempty" example:"30"` // Video frame rate + Seed int `json:"seed,omitempty" example:"20231234"` // Random seed + N int `json:"n,omitempty" example:"1"` // Number of videos to generate + ResponseFormat string `json:"response_format,omitempty" example:"url"` // Response format + User string `json:"user,omitempty" example:"user-1234"` // User identifier + Metadata map[string]any `json:"metadata,omitempty"` // Vendor-specific/custom params (e.g. negative_prompt, style, quality_level, etc.) +} + +// VideoResponse 视频生成提交任务后的响应 +type VideoResponse struct { + TaskId string `json:"task_id"` + Status string `json:"status"` +} + +// VideoTaskResponse 查询视频生成任务状态的响应 +type VideoTaskResponse struct { + TaskId string `json:"task_id" example:"abcd1234efgh"` // 任务ID + Status string `json:"status" example:"succeeded"` // 任务状态 + Url string `json:"url,omitempty"` // 视频资源URL(成功时) + Format string `json:"format,omitempty" example:"mp4"` // 视频格式 + Metadata *VideoTaskMetadata `json:"metadata,omitempty"` // 结果元数据 + Error *VideoTaskError `json:"error,omitempty"` // 错误信息(失败时) +} + +// VideoTaskMetadata 视频任务元数据 +type VideoTaskMetadata struct { + Duration float64 `json:"duration" example:"5.0"` // 实际生成的视频时长 + Fps int `json:"fps" example:"30"` // 实际帧率 + Width int `json:"width" example:"512"` // 实际宽度 + Height int `json:"height" example:"512"` // 实际高度 + Seed int `json:"seed" example:"20231234"` // 使用的随机种子 +} + +// VideoTaskError 视频任务错误信息 +type VideoTaskError struct { + Code int `json:"code"` + Message string `json:"message"` +} diff --git a/go.mod b/go.mod index ce768bf3..9479ba55 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b - github.com/bytedance/sonic v1.11.6 github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/sessions v0.0.5 @@ -25,10 +24,10 @@ require ( github.com/gorilla/websocket v1.5.0 github.com/joho/godotenv v1.5.1 github.com/pkg/errors v0.9.1 - github.com/pkoukk/tiktoken-go v0.1.7 github.com/samber/lo v1.39.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 + github.com/tiktoken-go/tokenizer v0.6.2 golang.org/x/crypto v0.35.0 golang.org/x/image v0.23.0 golang.org/x/net v0.35.0 @@ -43,12 +42,13 @@ require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect github.com/aws/smithy-go v1.20.2 // indirect + github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect diff --git a/go.sum b/go.sum index 2bd81fa3..71dd83c2 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= @@ -167,8 +167,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= -github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -197,6 +195,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g= +github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= diff --git a/main.go b/main.go index 95c6820d..cf593b57 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,7 @@ import ( "one-api/model" "one-api/router" "one-api/service" - "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" "os" "strconv" @@ -74,7 +74,7 @@ func main() { } // Initialize model settings - operation_setting.InitRatioSettings() + ratio_setting.InitRatioSettings() // Initialize constants constant.InitEnv() // Initialize options @@ -89,13 +89,28 @@ func main() { if common.MemoryCacheEnabled { common.SysLog("memory cache enabled") common.SysError(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency)) - model.InitChannelCache() - } - if common.MemoryCacheEnabled { - go model.SyncOptions(common.SyncFrequency) + + // Add panic recovery and retry for InitChannelCache + func() { + defer func() { + if r := recover(); r != nil { + common.SysError(fmt.Sprintf("InitChannelCache panic: %v, retrying once", r)) + // Retry once + _, fixErr := model.FixAbility() + if fixErr != nil { + common.SysError(fmt.Sprintf("InitChannelCache failed: %s", fixErr.Error())) + } + } + }() + model.InitChannelCache() + }() + go model.SyncChannelCache(common.SyncFrequency) } + // 热更新配置 + go model.SyncOptions(common.SyncFrequency) + // 数据看板 go model.UpdateQuotaData() diff --git a/makefile b/makefile index 5042723c..cbc4ea6a 100644 --- a/makefile +++ b/makefile @@ -7,7 +7,7 @@ all: build-frontend start-backend build-frontend: @echo "Building frontend..." - @cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) npm run build + @cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build start-backend: @echo "Starting backend dev server..." diff --git a/middleware/auth.go b/middleware/auth.go index fece4553..f387029f 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -1,13 +1,14 @@ package middleware import ( - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/model" "strconv" "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" ) func validUserInfo(username string, role int) bool { @@ -182,6 +183,18 @@ func TokenAuth() func(c *gin.Context) { c.Request.Header.Set("Authorization", "Bearer "+key) } } + // gemini api 从query中获取key + if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") { + skKey := c.Query("key") + if skKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+skKey) + } + // 从x-goog-api-key header中获取key + xGoogKey := c.Request.Header.Get("x-goog-api-key") + if xGoogKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+xGoogKey) + } + } key := c.Request.Header.Get("Authorization") parts := make([]string, 0) key = strings.TrimPrefix(key, "Bearer ") diff --git a/middleware/distributor.go b/middleware/distributor.go index e7db6d77..9d074ce8 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -11,6 +11,7 @@ import ( relayconstant "one-api/relay/constant" "one-api/service" "one-api/setting" + "one-api/setting/ratio_setting" "strconv" "strings" "time" @@ -48,9 +49,11 @@ func Distribute() func(c *gin.Context) { return } // check group in common.GroupRatio - if !setting.ContainsGroupRatio(tokenGroup) { - abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) - return + if !ratio_setting.ContainsGroupRatio(tokenGroup) { + if tokenGroup != "auto" { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) + return + } } userGroup = tokenGroup } @@ -95,9 +98,14 @@ func Distribute() func(c *gin.Context) { } if shouldSelectChannel { - channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model, 0) + var selectGroup string + channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0) if err != nil { - message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", userGroup, modelRequest.Model) + showGroup := userGroup + if userGroup == "auto" { + showGroup = fmt.Sprintf("auto(%s)", selectGroup) + } + message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", showGroup, modelRequest.Model) // 如果错误,但是渠道不为空,说明是数据库一致性问题 if channel != nil { common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) @@ -162,6 +170,23 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { } c.Set("platform", string(constant.TaskPlatformSuno)) c.Set("relay_mode", relayMode) + } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") { + relayMode := relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path) + if relayMode == relayconstant.RelayModeKlingFetchByID { + shouldSelectChannel = false + } else { + err = common.UnmarshalBodyReusable(c, &modelRequest) + } + c.Set("platform", string(constant.TaskPlatformKling)) + c.Set("relay_mode", relayMode) + } else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") { + // Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent + relayMode := relayconstant.RelayModeGemini + modelName := extractModelNameFromGeminiPath(c.Request.URL.Path) + if modelName != "" { + modelRequest.Model = modelName + } + c.Set("relay_mode", relayMode) } else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { err = common.UnmarshalBodyReusable(c, &modelRequest) } @@ -244,3 +269,31 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode c.Set("bot_id", channel.Other) } } + +// extractModelNameFromGeminiPath 从 Gemini API URL 路径中提取模型名 +// 输入格式: /v1beta/models/gemini-2.0-flash:generateContent +// 输出: gemini-2.0-flash +func extractModelNameFromGeminiPath(path string) string { + // 查找 "/models/" 的位置 + modelsPrefix := "/models/" + modelsIndex := strings.Index(path, modelsPrefix) + if modelsIndex == -1 { + return "" + } + + // 从 "/models/" 之后开始提取 + startIndex := modelsIndex + len(modelsPrefix) + if startIndex >= len(path) { + return "" + } + + // 查找 ":" 的位置,模型名在 ":" 之前 + colonIndex := strings.Index(path[startIndex:], ":") + if colonIndex == -1 { + // 如果没有找到 ":",返回从 "/models/" 到路径结尾的部分 + return path[startIndex:] + } + + // 返回模型名部分 + return path[startIndex : startIndex+colonIndex] +} diff --git a/middleware/stats.go b/middleware/stats.go new file mode 100644 index 00000000..1c97983f --- /dev/null +++ b/middleware/stats.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "sync/atomic" + + "github.com/gin-gonic/gin" +) + +// HTTPStats 存储HTTP统计信息 +type HTTPStats struct { + activeConnections int64 +} + +var globalStats = &HTTPStats{} + +// StatsMiddleware 统计中间件 +func StatsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 增加活跃连接数 + atomic.AddInt64(&globalStats.activeConnections, 1) + + // 确保在请求结束时减少连接数 + defer func() { + atomic.AddInt64(&globalStats.activeConnections, -1) + }() + + c.Next() + } +} + +// StatsInfo 统计信息结构 +type StatsInfo struct { + ActiveConnections int64 `json:"active_connections"` +} + +// GetStats 获取统计信息 +func GetStats() StatsInfo { + return StatsInfo{ + ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections), + } +} \ No newline at end of file diff --git a/model/ability.go b/model/ability.go index 52720307..96a9ef6a 100644 --- a/model/ability.go +++ b/model/ability.go @@ -8,6 +8,7 @@ import ( "github.com/samber/lo" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type Ability struct { @@ -23,7 +24,7 @@ type Ability struct { func GetGroupModels(group string) []string { var models []string // Find distinct models - DB.Table("abilities").Where(groupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models) + DB.Table("abilities").Where(commonGroupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models) return models } @@ -41,15 +42,11 @@ func GetAllEnableAbilities() []Ability { } func getPriority(group string, model string, retry int) (int, error) { - trueVal := "1" - if common.UsingPostgreSQL { - trueVal = "true" - } var priorities []int err := DB.Model(&Ability{}). Select("DISTINCT(priority)"). - Where(groupCol+" = ? and model = ? and enabled = "+trueVal, group, model). + Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, commonTrueVal). Order("priority DESC"). // 按优先级降序排序 Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中 @@ -75,18 +72,14 @@ func getPriority(group string, model string, retry int) (int, error) { } func getChannelQuery(group string, model string, retry int) *gorm.DB { - trueVal := "1" - if common.UsingPostgreSQL { - trueVal = "true" - } - maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(groupCol+" = ? and model = ? and enabled = "+trueVal, group, model) - channelQuery := DB.Where(groupCol+" = ? and model = ? and enabled = "+trueVal+" and priority = (?)", group, model, maxPrioritySubQuery) + maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, commonTrueVal) + channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, commonTrueVal, maxPrioritySubQuery) if retry != 0 { priority, err := getPriority(group, model, retry) if err != nil { common.SysError(fmt.Sprintf("Get priority failed: %s", err.Error())) } else { - channelQuery = DB.Where(groupCol+" = ? and model = ? and enabled = "+trueVal+" and priority = ?", group, model, priority) + channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, commonTrueVal, priority) } } @@ -133,9 +126,15 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, func (channel *Channel) AddAbilities() error { models_ := strings.Split(channel.Models, ",") groups_ := strings.Split(channel.Group, ",") + abilitySet := make(map[string]struct{}) abilities := make([]Ability, 0, len(models_)) for _, model := range models_ { for _, group := range groups_ { + key := group + "|" + model + if _, exists := abilitySet[key]; exists { + continue + } + abilitySet[key] = struct{}{} ability := Ability{ Group: group, Model: model, @@ -152,7 +151,7 @@ func (channel *Channel) AddAbilities() error { return nil } for _, chunk := range lo.Chunk(abilities, 50) { - err := DB.Create(&chunk).Error + err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error if err != nil { return err } @@ -194,9 +193,15 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error { // Then add new abilities models_ := strings.Split(channel.Models, ",") groups_ := strings.Split(channel.Group, ",") + abilitySet := make(map[string]struct{}) abilities := make([]Ability, 0, len(models_)) for _, model := range models_ { for _, group := range groups_ { + key := group + "|" + model + if _, exists := abilitySet[key]; exists { + continue + } + abilitySet[key] = struct{}{} ability := Ability{ Group: group, Model: model, @@ -212,7 +217,7 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error { if len(abilities) > 0 { for _, chunk := range lo.Chunk(abilities, 50) { - err = tx.Create(&chunk).Error + err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error if err != nil { if isNewTx { tx.Rollback() @@ -261,12 +266,28 @@ func FixAbility() (int, error) { common.SysError(fmt.Sprintf("Get channel ids from channel table failed: %s", err.Error())) return 0, err } - // Delete abilities of channels that are not in channel table - err = DB.Where("channel_id NOT IN (?)", channelIds).Delete(&Ability{}).Error - if err != nil { - common.SysError(fmt.Sprintf("Delete abilities of channels that are not in channel table failed: %s", err.Error())) - return 0, err + + // Delete abilities of channels that are not in channel table - in batches to avoid too many placeholders + if len(channelIds) > 0 { + // Process deletion in chunks to avoid "too many placeholders" error + for _, chunk := range lo.Chunk(channelIds, 100) { + err = DB.Where("channel_id NOT IN (?)", chunk).Delete(&Ability{}).Error + if err != nil { + common.SysError(fmt.Sprintf("Delete abilities of channels (batch) that are not in channel table failed: %s", err.Error())) + return 0, err + } + } + } else { + // If no channels exist, delete all abilities + err = DB.Delete(&Ability{}).Error + if err != nil { + common.SysError(fmt.Sprintf("Delete all abilities failed: %s", err.Error())) + return 0, err + } + common.SysLog("Delete all abilities successfully") + return 0, nil } + common.SysLog(fmt.Sprintf("Delete abilities of channels that are not in channel table successfully, ids: %v", channelIds)) count += len(channelIds) @@ -275,17 +296,26 @@ func FixAbility() (int, error) { err = DB.Table("abilities").Distinct("channel_id").Pluck("channel_id", &abilityChannelIds).Error if err != nil { common.SysError(fmt.Sprintf("Get channel ids from abilities table failed: %s", err.Error())) - return 0, err + return count, err } + var channels []Channel if len(abilityChannelIds) == 0 { err = DB.Find(&channels).Error } else { - err = DB.Where("id NOT IN (?)", abilityChannelIds).Find(&channels).Error - } - if err != nil { - return 0, err + // Process query in chunks to avoid "too many placeholders" error + err = nil + for _, chunk := range lo.Chunk(abilityChannelIds, 100) { + var channelsChunk []Channel + err = DB.Where("id NOT IN (?)", chunk).Find(&channelsChunk).Error + if err != nil { + common.SysError(fmt.Sprintf("Find channels not in abilities table failed: %s", err.Error())) + return count, err + } + channels = append(channels, channelsChunk...) + } } + for _, channel := range channels { err := channel.UpdateAbilities(nil) if err != nil { diff --git a/model/cache.go b/model/cache.go index 2d1c36bf..3e5eb4c4 100644 --- a/model/cache.go +++ b/model/cache.go @@ -5,10 +5,13 @@ import ( "fmt" "math/rand" "one-api/common" + "one-api/setting" "sort" "strings" "sync" "time" + + "github.com/gin-gonic/gin" ) var group2model2channels map[string]map[string][]*Channel @@ -16,6 +19,9 @@ var channelsIDM map[int]*Channel var channelSyncLock sync.RWMutex func InitChannelCache() { + if !common.MemoryCacheEnabled { + return + } newChannelId2channel := make(map[int]*Channel) var channels []*Channel DB.Where("status = ?", common.ChannelStatusEnabled).Find(&channels) @@ -72,7 +78,43 @@ func SyncChannelCache(frequency int) { } } -func CacheGetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { +func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, retry int) (*Channel, string, error) { + var channel *Channel + var err error + selectGroup := group + if group == "auto" { + if len(setting.AutoGroups) == 0 { + return nil, selectGroup, errors.New("auto groups is not enabled") + } + for _, autoGroup := range setting.AutoGroups { + if common.DebugEnabled { + println("autoGroup:", autoGroup) + } + channel, _ = getRandomSatisfiedChannel(autoGroup, model, retry) + if channel == nil { + continue + } else { + c.Set("auto_group", autoGroup) + selectGroup = autoGroup + if common.DebugEnabled { + println("selectGroup:", selectGroup) + } + break + } + } + } else { + channel, err = getRandomSatisfiedChannel(group, model, retry) + if err != nil { + return nil, group, err + } + } + if channel == nil { + return nil, group, errors.New("channel not found") + } + return channel, selectGroup, nil +} + +func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { if strings.HasPrefix(model, "gpt-4-gizmo") { model = "gpt-4-gizmo-*" } @@ -84,11 +126,11 @@ func CacheGetRandomSatisfiedChannel(group string, model string, retry int) (*Cha if !common.MemoryCacheEnabled { return GetRandomSatisfiedChannel(group, model, retry) } - + channelSyncLock.RLock() channels := group2model2channels[group][model] channelSyncLock.RUnlock() - + if len(channels) == 0 { return nil, errors.New("channel not found") } diff --git a/model/channel.go b/model/channel.go index 41e5e371..6cbd8adc 100644 --- a/model/channel.go +++ b/model/channel.go @@ -46,6 +46,17 @@ func (channel *Channel) GetModels() []string { return strings.Split(strings.Trim(channel.Models, ","), ",") } +func (channel *Channel) GetGroups() []string { + if channel.Group == "" { + return []string{} + } + groups := strings.Split(strings.Trim(channel.Group, ","), ",") + for i, group := range groups { + groups[i] = strings.TrimSpace(group) + } + return groups +} + func (channel *Channel) GetOtherInfo() map[string]interface{} { otherInfo := make(map[string]interface{}) if channel.OtherInfo != "" { @@ -134,7 +145,7 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([] } // 构造基础查询 - baseQuery := DB.Model(&Channel{}).Omit(keyCol) + baseQuery := DB.Model(&Channel{}).Omit("key") // 构造WHERE子句 var whereClause string @@ -142,15 +153,15 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([] if group != "" && group != "null" { var groupCondition string if common.UsingMySQL { - groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?` + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` } else { // sqlite, PostgreSQL - groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?` + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` } - whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") } else { - whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") } @@ -467,7 +478,7 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str } // 构造基础查询 - baseQuery := DB.Model(&Channel{}).Omit(keyCol) + baseQuery := DB.Model(&Channel{}).Omit("key") // 构造WHERE子句 var whereClause string @@ -475,15 +486,15 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str if group != "" && group != "null" { var groupCondition string if common.UsingMySQL { - groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?` + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` } else { // sqlite, PostgreSQL - groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?` + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` } - whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") } else { - whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") } @@ -572,3 +583,53 @@ func BatchSetChannelTag(ids []int, tag *string) error { // 提交事务 return tx.Commit().Error } + +// CountAllChannels returns total channels in DB +func CountAllChannels() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Count(&total).Error + return total, err +} + +// CountAllTags returns number of non-empty distinct tags +func CountAllTags() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error + return total, err +} + +// Get channels of specified type with pagination +func GetChannelsByType(startIdx int, num int, idSort bool, channelType int) ([]*Channel, error) { + var channels []*Channel + order := "priority desc" + if idSort { + order = "id desc" + } + err := DB.Where("type = ?", channelType).Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error + return channels, err +} + +// Count channels of specific type +func CountChannelsByType(channelType int) (int64, error) { + var count int64 + err := DB.Model(&Channel{}).Where("type = ?", channelType).Count(&count).Error + return count, err +} + +// Return map[type]count for all channels +func CountChannelsGroupByType() (map[int64]int64, error) { + type result struct { + Type int64 `gorm:"column:type"` + Count int64 `gorm:"column:count"` + } + var results []result + err := DB.Model(&Channel{}).Select("type, count(*) as count").Group("type").Find(&results).Error + if err != nil { + return nil, err + } + counts := make(map[int64]int64) + for _, r := range results { + counts[r.Type] = r.Count + } + return counts, nil +} diff --git a/model/log.go b/model/log.go index 0a891fcd..b3fd1ad2 100644 --- a/model/log.go +++ b/model/log.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "one-api/common" + "one-api/constant" "os" "strings" "time" @@ -32,6 +33,7 @@ type Log struct { ChannelName string `json:"channel_name" gorm:"->"` TokenId int `json:"token_id" gorm:"default:0;index"` Group string `json:"group" gorm:"index"` + Ip string `json:"ip" gorm:"index;default:''"` Other string `json:"other"` } @@ -61,7 +63,7 @@ func formatUserLogs(logs []*Log) { func GetLogByKey(key string) (logs []*Log, err error) { if os.Getenv("LOG_SQL_DSN") != "" { var tk Token - if err = DB.Model(&Token{}).Where(keyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil { + if err = DB.Model(&Token{}).Where(logKeyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil { return nil, err } err = LOG_DB.Model(&Log{}).Where("token_id=?", tk.Id).Find(&logs).Error @@ -95,6 +97,15 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, common.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content)) username := c.GetString("username") otherStr := common.MapToJsonStr(other) + // 判断是否需要记录 IP + needRecordIp := false + if settingMap, err := GetUserSetting(userId, false); err == nil { + if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok { + if vb, ok := v.(bool); ok && vb { + needRecordIp = true + } + } + } log := &Log{ UserId: userId, Username: username, @@ -111,7 +122,13 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, UseTime: useTimeSeconds, IsStream: isStream, Group: group, - Other: otherStr, + Ip: func() string { + if needRecordIp { + return c.ClientIP() + } + return "" + }(), + Other: otherStr, } err := LOG_DB.Create(log).Error if err != nil { @@ -128,6 +145,15 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in } username := c.GetString("username") otherStr := common.MapToJsonStr(other) + // 判断是否需要记录 IP + needRecordIp := false + if settingMap, err := GetUserSetting(userId, false); err == nil { + if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok { + if vb, ok := v.(bool); ok && vb { + needRecordIp = true + } + } + } log := &Log{ UserId: userId, Username: username, @@ -144,7 +170,13 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in UseTime: useTimeSeconds, IsStream: isStream, Group: group, - Other: otherStr, + Ip: func() string { + if needRecordIp { + return c.ClientIP() + } + return "" + }(), + Other: otherStr, } err := LOG_DB.Create(log).Error if err != nil { @@ -184,7 +216,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName tx = tx.Where("logs.channel_id = ?", channel) } if group != "" { - tx = tx.Where("logs."+groupCol+" = ?", group) + tx = tx.Where("logs."+logGroupCol+" = ?", group) } err = tx.Model(&Log{}).Count(&total).Error if err != nil { @@ -195,13 +227,18 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName return nil, 0, err } - channelIds := make([]int, 0) + channelIdsMap := make(map[int]struct{}) channelMap := make(map[int]string) for _, log := range logs { if log.ChannelId != 0 { - channelIds = append(channelIds, log.ChannelId) + channelIdsMap[log.ChannelId] = struct{}{} } } + + channelIds := make([]int, 0, len(channelIdsMap)) + for channelId := range channelIdsMap { + channelIds = append(channelIds, channelId) + } if len(channelIds) > 0 { var channels []struct { Id int `gorm:"column:id"` @@ -242,7 +279,7 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int tx = tx.Where("logs.created_at <= ?", endTimestamp) } if group != "" { - tx = tx.Where("logs."+groupCol+" = ?", group) + tx = tx.Where("logs."+logGroupCol+" = ?", group) } err = tx.Model(&Log{}).Count(&total).Error if err != nil { @@ -303,8 +340,8 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel) } if group != "" { - tx = tx.Where(groupCol+" = ?", group) - rpmTpmQuery = rpmTpmQuery.Where(groupCol+" = ?", group) + tx = tx.Where(logGroupCol+" = ?", group) + rpmTpmQuery = rpmTpmQuery.Where(logGroupCol+" = ?", group) } tx = tx.Where("type = ?", LogTypeConsume) diff --git a/model/main.go b/model/main.go index 61d6bb10..d46a21cf 100644 --- a/model/main.go +++ b/model/main.go @@ -1,6 +1,7 @@ package model import ( + "fmt" "log" "one-api/common" "one-api/constant" @@ -15,18 +16,48 @@ import ( "gorm.io/gorm" ) -var groupCol string -var keyCol string +var commonGroupCol string +var commonKeyCol string +var commonTrueVal string +var commonFalseVal string + +var logKeyCol string +var logGroupCol string func initCol() { + // init common column names if common.UsingPostgreSQL { - groupCol = `"group"` - keyCol = `"key"` - + commonGroupCol = `"group"` + commonKeyCol = `"key"` + commonTrueVal = "true" + commonFalseVal = "false" } else { - groupCol = "`group`" - keyCol = "`key`" + commonGroupCol = "`group`" + commonKeyCol = "`key`" + commonTrueVal = "1" + commonFalseVal = "0" } + if os.Getenv("LOG_SQL_DSN") != "" { + switch common.LogSqlType { + case common.DatabaseTypePostgreSQL: + logGroupCol = `"group"` + logKeyCol = `"key"` + default: + logGroupCol = commonGroupCol + logKeyCol = commonKeyCol + } + } else { + // LOG_SQL_DSN 为空时,日志数据库与主数据库相同 + if common.UsingPostgreSQL { + logGroupCol = `"group"` + logKeyCol = `"key"` + } else { + logGroupCol = commonGroupCol + logKeyCol = commonKeyCol + } + } + // log sql type and database type + common.SysLog("Using Log SQL Type: " + common.LogSqlType) } var DB *gorm.DB @@ -83,7 +114,7 @@ func CheckSetup() { } } -func chooseDB(envName string) (*gorm.DB, error) { +func chooseDB(envName string, isLog bool) (*gorm.DB, error) { defer func() { initCol() }() @@ -92,7 +123,11 @@ func chooseDB(envName string) (*gorm.DB, error) { if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { // Use PostgreSQL common.SysLog("using PostgreSQL as database") - common.UsingPostgreSQL = true + if !isLog { + common.UsingPostgreSQL = true + } else { + common.LogSqlType = common.DatabaseTypePostgreSQL + } return gorm.Open(postgres.New(postgres.Config{ DSN: dsn, PreferSimpleProtocol: true, // disables implicit prepared statement usage @@ -102,7 +137,11 @@ func chooseDB(envName string) (*gorm.DB, error) { } if strings.HasPrefix(dsn, "local") { common.SysLog("SQL_DSN not set, using SQLite as database") - common.UsingSQLite = true + if !isLog { + common.UsingSQLite = true + } else { + common.LogSqlType = common.DatabaseTypeSQLite + } return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ PrepareStmt: true, // precompile SQL }) @@ -117,7 +156,11 @@ func chooseDB(envName string) (*gorm.DB, error) { dsn += "?parseTime=true" } } - common.UsingMySQL = true + if !isLog { + common.UsingMySQL = true + } else { + common.LogSqlType = common.DatabaseTypeMySQL + } return gorm.Open(mysql.Open(dsn), &gorm.Config{ PrepareStmt: true, // precompile SQL }) @@ -131,7 +174,7 @@ func chooseDB(envName string) (*gorm.DB, error) { } func InitDB() (err error) { - db, err := chooseDB("SQL_DSN") + db, err := chooseDB("SQL_DSN", false) if err == nil { if common.DebugEnabled { db = db.Debug() @@ -149,7 +192,7 @@ func InitDB() (err error) { return nil } if common.UsingMySQL { - _, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded + //_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded } common.SysLog("database migration started") err = migrateDB() @@ -165,7 +208,7 @@ func InitLogDB() (err error) { LOG_DB = DB return } - db, err := chooseDB("LOG_SQL_DSN") + db, err := chooseDB("LOG_SQL_DSN", true) if err == nil { if common.DebugEnabled { db = db.Debug() @@ -198,54 +241,73 @@ func InitLogDB() (err error) { } func migrateDB() error { - err := DB.AutoMigrate(&Channel{}) + if !common.UsingPostgreSQL { + return migrateDBFast() + } + err := DB.AutoMigrate( + &Channel{}, + &Token{}, + &User{}, + &Option{}, + &Redemption{}, + &Ability{}, + &Log{}, + &Midjourney{}, + &TopUp{}, + &QuotaData{}, + &Task{}, + &Setup{}, + ) if err != nil { return err } - err = DB.AutoMigrate(&Token{}) - if err != nil { - return err + return nil +} + +func migrateDBFast() error { + var wg sync.WaitGroup + errChan := make(chan error, 12) // Buffer size matches number of migrations + + migrations := []struct { + model interface{} + name string + }{ + {&Channel{}, "Channel"}, + {&Token{}, "Token"}, + {&User{}, "User"}, + {&Option{}, "Option"}, + {&Redemption{}, "Redemption"}, + {&Ability{}, "Ability"}, + {&Log{}, "Log"}, + {&Midjourney{}, "Midjourney"}, + {&TopUp{}, "TopUp"}, + {&QuotaData{}, "QuotaData"}, + {&Task{}, "Task"}, + {&Setup{}, "Setup"}, } - err = DB.AutoMigrate(&User{}) - if err != nil { - return err + + for _, m := range migrations { + wg.Add(1) + go func(model interface{}, name string) { + defer wg.Done() + if err := DB.AutoMigrate(model); err != nil { + errChan <- fmt.Errorf("failed to migrate %s: %v", name, err) + } + }(m.model, m.name) } - err = DB.AutoMigrate(&Option{}) - if err != nil { - return err + + // Wait for all migrations to complete + wg.Wait() + close(errChan) + + // Check for any errors + for err := range errChan { + if err != nil { + return err + } } - err = DB.AutoMigrate(&Redemption{}) - if err != nil { - return err - } - err = DB.AutoMigrate(&Ability{}) - if err != nil { - return err - } - err = DB.AutoMigrate(&Log{}) - if err != nil { - return err - } - err = DB.AutoMigrate(&Midjourney{}) - if err != nil { - return err - } - err = DB.AutoMigrate(&TopUp{}) - if err != nil { - return err - } - err = DB.AutoMigrate(&QuotaData{}) - if err != nil { - return err - } - err = DB.AutoMigrate(&Task{}) - if err != nil { - return err - } - err = DB.AutoMigrate(&Setup{}) common.SysLog("database migrated") - //err = createRootAccountIfNeed() - return err + return nil } func migrateLOGDB() error { diff --git a/model/midjourney.go b/model/midjourney.go index 5f85abfd..e8140447 100644 --- a/model/midjourney.go +++ b/model/midjourney.go @@ -166,3 +166,40 @@ func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error { Where("id in (?)", taskIDs). Updates(params).Error } + +// CountAllTasks returns total midjourney tasks for admin query +func CountAllTasks(queryParams TaskQueryParams) int64 { + var total int64 + query := DB.Model(&Midjourney{}) + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} + +// CountAllUserTask returns total midjourney tasks for user +func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 { + var total int64 + query := DB.Model(&Midjourney{}).Where("user_id = ?", userId) + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} diff --git a/model/option.go b/model/option.go index d892b120..ea72e5ee 100644 --- a/model/option.go +++ b/model/option.go @@ -5,6 +5,7 @@ import ( "one-api/setting" "one-api/setting/config" "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" "strconv" "strings" "time" @@ -76,6 +77,9 @@ func InitOptionMap() { common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() + common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) + common.OptionMap["PayMethods"] = setting.PayMethods2JsonString() common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientSecret"] = "" common.OptionMap["TelegramBotToken"] = "" @@ -94,12 +98,13 @@ func InitOptionMap() { common.OptionMap["ModelRequestRateLimitDurationMinutes"] = strconv.Itoa(setting.ModelRequestRateLimitDurationMinutes) common.OptionMap["ModelRequestRateLimitSuccessCount"] = strconv.Itoa(setting.ModelRequestRateLimitSuccessCount) common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString() - common.OptionMap["ModelRatio"] = operation_setting.ModelRatio2JSONString() - common.OptionMap["ModelPrice"] = operation_setting.ModelPrice2JSONString() - common.OptionMap["CacheRatio"] = operation_setting.CacheRatio2JSONString() - common.OptionMap["GroupRatio"] = setting.GroupRatio2JSONString() + common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() + common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() + common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() + common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() + common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString() common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString() - common.OptionMap["CompletionRatio"] = operation_setting.CompletionRatio2JSONString() + common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString() common.OptionMap["TopUpLink"] = common.TopUpLink //common.OptionMap["ChatLink"] = common.ChatLink //common.OptionMap["ChatLink2"] = common.ChatLink2 @@ -122,6 +127,7 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) // 自动添加所有注册的模型配置 modelConfigs := config.GlobalConfig.ExportAllConfigs() @@ -191,7 +197,7 @@ func updateOptionMap(key string, value string) (err error) { common.ImageDownloadPermission = intValue } } - if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" { + if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" { boolValue := value == "true" switch key { case "PasswordRegisterEnabled": @@ -260,6 +266,10 @@ func updateOptionMap(key string, value string) (err error) { common.SMTPSSLEnabled = boolValue case "WorkerAllowHttpImageRequestEnabled": setting.WorkerAllowHttpImageRequestEnabled = boolValue + case "DefaultUseAutoGroup": + setting.DefaultUseAutoGroup = boolValue + case "ExposeRatioEnabled": + ratio_setting.SetExposeRatioEnabled(boolValue) } } switch key { @@ -286,6 +296,8 @@ func updateOptionMap(key string, value string) (err error) { setting.PayAddress = value case "Chats": err = setting.UpdateChatsByJsonString(value) + case "AutoGroups": + err = setting.UpdateAutoGroupsByJsonString(value) case "CustomCallbackAddress": setting.CustomCallbackAddress = value case "EpayId": @@ -351,17 +363,19 @@ func updateOptionMap(key string, value string) (err error) { case "DataExportDefaultTime": common.DataExportDefaultTime = value case "ModelRatio": - err = operation_setting.UpdateModelRatioByJSONString(value) + err = ratio_setting.UpdateModelRatioByJSONString(value) case "GroupRatio": - err = setting.UpdateGroupRatioByJSONString(value) + err = ratio_setting.UpdateGroupRatioByJSONString(value) + case "GroupGroupRatio": + err = ratio_setting.UpdateGroupGroupRatioByJSONString(value) case "UserUsableGroups": err = setting.UpdateUserUsableGroupsByJSONString(value) case "CompletionRatio": - err = operation_setting.UpdateCompletionRatioByJSONString(value) + err = ratio_setting.UpdateCompletionRatioByJSONString(value) case "ModelPrice": - err = operation_setting.UpdateModelPriceByJSONString(value) + err = ratio_setting.UpdateModelPriceByJSONString(value) case "CacheRatio": - err = operation_setting.UpdateCacheRatioByJSONString(value) + err = ratio_setting.UpdateCacheRatioByJSONString(value) case "TopUpLink": common.TopUpLink = value //case "ChatLink": @@ -378,6 +392,8 @@ func updateOptionMap(key string, value string) (err error) { operation_setting.AutomaticDisableKeywordsFromString(value) case "StreamCacheQueueLength": setting.StreamCacheQueueLength, _ = strconv.Atoi(value) + case "PayMethods": + err = setting.UpdatePayMethodsByJsonString(value) } return err } diff --git a/model/pricing.go b/model/pricing.go index ba1815e2..74a25f2d 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -2,7 +2,7 @@ package model import ( "one-api/common" - "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" "sync" "time" ) @@ -65,14 +65,14 @@ func updatePricing() { ModelName: model, EnableGroup: groups, } - modelPrice, findPrice := operation_setting.GetModelPrice(model, false) + modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) if findPrice { pricing.ModelPrice = modelPrice pricing.QuotaType = 1 } else { - modelRatio, _ := operation_setting.GetModelRatio(model) + modelRatio, _ := ratio_setting.GetModelRatio(model) pricing.ModelRatio = modelRatio - pricing.CompletionRatio = operation_setting.GetCompletionRatio(model) + pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model) pricing.QuotaType = 0 } pricingMap = append(pricingMap, pricing) diff --git a/model/redemption.go b/model/redemption.go index 89c4ac8c..bf237668 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -21,6 +21,7 @@ type Redemption struct { Count int `json:"count" gorm:"-:all"` // only for api request UsedUserId int `json:"used_user_id"` DeletedAt gorm.DeletedAt `gorm:"index"` + ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期 } func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) { @@ -131,6 +132,9 @@ func Redeem(key string, userId int) (quota int, err error) { if redemption.Status != common.RedemptionCodeStatusEnabled { return errors.New("该兑换码已被使用") } + if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { + return errors.New("该兑换码已过期") + } err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error if err != nil { return err @@ -162,7 +166,7 @@ func (redemption *Redemption) SelectUpdate() error { // Update Make sure your token's fields is completed, because this will update non-zero values func (redemption *Redemption) Update() error { var err error - err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time").Updates(redemption).Error + err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error return err } @@ -183,3 +187,9 @@ func DeleteRedemptionById(id int) (err error) { } return redemption.Delete() } + +func DeleteInvalidRedemptions() (int64, error) { + now := common.GetTimestamp() + result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{}) + return result.RowsAffected, result.Error +} diff --git a/model/task.go b/model/task.go index df221edf..9e4177ba 100644 --- a/model/task.go +++ b/model/task.go @@ -302,3 +302,64 @@ func SumUsedTaskQuota(queryParams SyncTaskQueryParams) (stat []TaskQuotaUsage, e err = query.Select("mode, sum(quota) as count").Group("mode").Find(&stat).Error return stat, err } + +// TaskCountAllTasks returns total tasks that match the given query params (admin usage) +func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 { + var total int64 + query := DB.Model(&Task{}) + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.UserID != "" { + query = query.Where("user_id = ?", queryParams.UserID) + } + if len(queryParams.UserIDs) != 0 { + query = query.Where("user_id in (?)", queryParams.UserIDs) + } + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} + +// TaskCountAllUserTask returns total tasks for given user +func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 { + var total int64 + query := DB.Model(&Task{}).Where("user_id = ?", userId) + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} diff --git a/model/token.go b/model/token.go index 8587ea62..2ed2c09a 100644 --- a/model/token.go +++ b/model/token.go @@ -66,7 +66,7 @@ func SearchUserTokens(userId int, keyword string, token string) (tokens []*Token if token != "" { token = strings.Trim(token, "sk-") } - err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(keyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error + err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(commonKeyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error return tokens, err } @@ -161,7 +161,7 @@ func GetTokenByKey(key string, fromDB bool) (token *Token, err error) { // Don't return error - fall through to DB } fromDB = true - err = DB.Where(keyCol+" = ?", key).First(&token).Error + err = DB.Where(commonKeyCol+" = ?", key).First(&token).Error return token, err } @@ -320,3 +320,10 @@ func decreaseTokenQuota(id int, quota int) (err error) { ).Error return err } + +// CountUserTokens returns total number of tokens for the given user, used for pagination +func CountUserTokens(userId int) (int64, error) { + var total int64 + err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error + return total, err +} diff --git a/model/token_cache.go b/model/token_cache.go index 0fe02fea..a4b0beae 100644 --- a/model/token_cache.go +++ b/model/token_cache.go @@ -10,7 +10,7 @@ import ( func cacheSetToken(token Token) error { key := common.GenerateHMAC(token.Key) token.Clean() - err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(constant.TokenCacheSeconds)*time.Second) + err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(constant.RedisKeyCacheSeconds())*time.Second) if err != nil { return err } @@ -19,7 +19,7 @@ func cacheSetToken(token Token) error { func cacheDeleteToken(key string) error { key = common.GenerateHMAC(key) - err := common.RedisHDelObj(fmt.Sprintf("token:%s", key)) + err := common.RedisDelKey(fmt.Sprintf("token:%s", key)) if err != nil { return err } diff --git a/model/user.go b/model/user.go index 1a3372aa..6a695457 100644 --- a/model/user.go +++ b/model/user.go @@ -41,6 +41,7 @@ type User struct { DeletedAt gorm.DeletedAt `gorm:"index"` LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` Setting string `json:"setting" gorm:"type:text;column:setting"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` } func (user *User) ToBaseUser() *UserBase { @@ -175,7 +176,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, // 如果是数字,同时搜索ID和其他字段 likeCondition = "id = ? OR " + likeCondition if group != "" { - query = query.Where("("+likeCondition+") AND "+groupCol+" = ?", + query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) } else { query = query.Where(likeCondition, @@ -184,7 +185,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, } else { // 非数字关键字,只搜索字符串字段 if group != "" { - query = query.Where("("+likeCondition+") AND "+groupCol+" = ?", + query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) } else { query = query.Where(likeCondition, @@ -366,6 +367,7 @@ func (user *User) Edit(updatePassword bool) error { "display_name": newUser.DisplayName, "group": newUser.Group, "quota": newUser.Quota, + "remark": newUser.Remark, } if updatePassword { updates["password"] = newUser.Password @@ -615,7 +617,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) { // Don't return error - fall through to DB } fromDB = true - err = DB.Model(&User{}).Where("id = ?", id).Select(groupCol).Find(&group).Error + err = DB.Model(&User{}).Where("id = ?", id).Select(commonGroupCol).Find(&group).Error if err != nil { return "", err } diff --git a/model/user_cache.go b/model/user_cache.go index bc412e77..e673defc 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -3,11 +3,12 @@ package model import ( "encoding/json" "fmt" - "github.com/gin-gonic/gin" "one-api/common" "one-api/constant" "time" + "github.com/gin-gonic/gin" + "github.com/bytedance/gopkg/util/gopool" ) @@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error { if !common.RedisEnabled { return nil } - return common.RedisHDelObj(getUserCacheKey(userId)) + return common.RedisDelKey(getUserCacheKey(userId)) } // updateUserCache updates all user cache fields using hash @@ -69,7 +70,7 @@ func updateUserCache(user User) error { return common.RedisHSetObj( getUserCacheKey(user.Id), user.ToBaseUser(), - time.Duration(constant.UserId2QuotaCacheSeconds)*time.Second, + time.Duration(constant.RedisKeyCacheSeconds())*time.Second, ) } diff --git a/model/utils.go b/model/utils.go index e6b09aa5..1f8a0963 100644 --- a/model/utils.go +++ b/model/utils.go @@ -2,11 +2,12 @@ package model import ( "errors" - "github.com/bytedance/gopkg/util/gopool" - "gorm.io/gorm" "one-api/common" "sync" "time" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" ) const ( @@ -48,6 +49,22 @@ func addNewRecord(type_ int, id int, value int) { } func batchUpdate() { + // check if there's any data to update + hasData := false + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateLocks[i].Lock() + if len(batchUpdateStores[i]) > 0 { + hasData = true + batchUpdateLocks[i].Unlock() + break + } + batchUpdateLocks[i].Unlock() + } + + if !hasData { + return + } + common.SysLog("batch update started") for i := 0; i < BatchUpdateTypeCount; i++ { batchUpdateLocks[i].Lock() diff --git a/relay/relay-audio.go b/relay/audio_handler.go similarity index 91% rename from relay/relay-audio.go rename to relay/audio_handler.go index deb45c58..c1ce1a02 100644 --- a/relay/relay-audio.go +++ b/relay/audio_handler.go @@ -55,7 +55,7 @@ func getAndValidAudioRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto. } func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { - relayInfo := relaycommon.GenRelayInfo(c) + relayInfo := relaycommon.GenRelayInfoOpenAIAudio(c) audioRequest, err := getAndValidAudioRequest(c, relayInfo) if err != nil { @@ -66,10 +66,7 @@ func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { promptTokens := 0 preConsumedTokens := common.PreConsumedQuota if relayInfo.RelayMode == relayconstant.RelayModeAudioSpeech { - promptTokens, err = service.CountTTSToken(audioRequest.Input, audioRequest.Model) - if err != nil { - return service.OpenAIErrorWrapper(err, "count_audio_token_failed", http.StatusInternalServerError) - } + promptTokens = service.CountTTSToken(audioRequest.Input, audioRequest.Model) preConsumedTokens = promptTokens relayInfo.PromptTokens = promptTokens } @@ -89,13 +86,11 @@ func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { } }() - err = helper.ModelMappedHelper(c, relayInfo) + err = helper.ModelMappedHelper(c, relayInfo, audioRequest) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError) } - audioRequest.Model = relayInfo.UpstreamModelName - adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { return service.OpenAIErrorWrapperLocal(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), "invalid_api_type", http.StatusBadRequest) diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index 50255d0a..873997f6 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -44,4 +44,6 @@ type TaskAdaptor interface { // FetchTask FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) + + ParseResultUrl(resp map[string]any) (string, error) } diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index ab632d22..f30d4dc4 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { switch info.RelayMode { case constant.RelayModeEmbeddings: fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/text-embedding/text-embedding", info.BaseUrl) + case constant.RelayModeRerank: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl) case constant.RelayModeImagesGenerations: fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl) case constant.RelayModeCompletions: @@ -57,6 +59,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } + + // fix: ali parameter.enable_thinking must be set to false for non-streaming calls + if !info.IsStream { + request.EnableThinking = false + } + switch info.RelayMode { default: aliReq := requestOpenAI2Ali(*request) @@ -70,7 +78,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { - return nil, errors.New("not implemented") + return ConvertRerankRequest(request), nil } func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { @@ -97,6 +105,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom err, usage = aliImageHandler(c, resp, info) case constant.RelayModeEmbeddings: err, usage = aliEmbeddingHandler(c, resp) + case constant.RelayModeRerank: + err, usage = RerankHandler(c, resp, info) default: if info.IsStream { err, usage = openai.OaiStreamHandler(c, resp, info) diff --git a/relay/channel/ali/constants.go b/relay/channel/ali/constants.go index 46de5e40..df64439b 100644 --- a/relay/channel/ali/constants.go +++ b/relay/channel/ali/constants.go @@ -8,6 +8,7 @@ var ModelList = []string{ "qwq-32b", "qwen3-235b-a22b", "text-embedding-v1", + "gte-rerank-v2", } var ChannelName = "ali" diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go index f51286ad..dbd18968 100644 --- a/relay/channel/ali/dto.go +++ b/relay/channel/ali/dto.go @@ -1,5 +1,7 @@ package ali +import "one-api/dto" + type AliMessage struct { Content string `json:"content"` Role string `json:"role"` @@ -97,3 +99,28 @@ type AliImageRequest struct { } `json:"parameters,omitempty"` ResponseFormat string `json:"response_format,omitempty"` } + +type AliRerankParameters struct { + TopN *int `json:"top_n,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` +} + +type AliRerankInput struct { + Query string `json:"query"` + Documents []any `json:"documents"` +} + +type AliRerankRequest struct { + Model string `json:"model"` + Input AliRerankInput `json:"input"` + Parameters AliRerankParameters `json:"parameters,omitempty"` +} + +type AliRerankResponse struct { + Output struct { + Results []dto.RerankResponseResult `json:"results"` + } `json:"output"` + Usage AliUsage `json:"usage"` + RequestId string `json:"request_id"` + AliError +} diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go new file mode 100644 index 00000000..c9ae066a --- /dev/null +++ b/relay/channel/ali/rerank.go @@ -0,0 +1,83 @@ +package ali + +import ( + "encoding/json" + "io" + "net/http" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + + "github.com/gin-gonic/gin" +) + +func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest { + returnDocuments := request.ReturnDocuments + if returnDocuments == nil { + t := true + returnDocuments = &t + } + return &AliRerankRequest{ + Model: request.Model, + Input: AliRerankInput{ + Query: request.Query, + Documents: request.Documents, + }, + Parameters: AliRerankParameters{ + TopN: &request.TopN, + ReturnDocuments: returnDocuments, + }, + } +} + +func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + + var aliResponse AliRerankResponse + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + if aliResponse.Code != "" { + return &dto.OpenAIErrorWithStatusCode{ + Error: dto.OpenAIError{ + Message: aliResponse.Message, + Type: aliResponse.Code, + Param: aliResponse.RequestId, + Code: aliResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + + usage := dto.Usage{ + PromptTokens: aliResponse.Usage.TotalTokens, + CompletionTokens: 0, + TotalTokens: aliResponse.Usage.TotalTokens, + } + rerankResponse := dto.RerankResponse{ + Results: aliResponse.Output.Results, + Usage: usage, + } + + jsonResponse, err := json.Marshal(rerankResponse) + if err != nil { + return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + if err != nil { + return service.OpenAIErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil + } + + return nil, &usage +} diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go index 3fe893b3..2f1387c5 100644 --- a/relay/channel/ali/text.go +++ b/relay/channel/ali/text.go @@ -3,7 +3,6 @@ package ali import ( "bufio" "encoding/json" - "github.com/gin-gonic/gin" "io" "net/http" "one-api/common" @@ -11,6 +10,8 @@ import ( "one-api/relay/helper" "one-api/service" "strings" + + "github.com/gin-gonic/gin" ) // https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r @@ -27,9 +28,6 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque } func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest { - if request.Model == "" { - request.Model = "text-embedding-v1" - } return &AliEmbeddingRequest{ Model: request.Model, Input: struct { @@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW }, nil } - fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse) + model := c.GetString("model") + if model == "" { + model = "text-embedding-v4" + } + fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse, model) jsonResponse, err := json.Marshal(fullTextResponse) if err != nil { return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil @@ -75,11 +77,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW return nil, &fullTextResponse.Usage } -func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbeddingResponse { +func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse { openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ Object: "list", Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)), - Model: "text-embedding-v1", + Model: model, Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens}, } @@ -94,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbe } func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse { - content, _ := json.Marshal(response.Output.Text) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Output.Text, }, FinishReason: response.Output.FinishReason, } diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 03eff9cf..c3da5134 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -104,6 +104,105 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return targetConn, nil } +func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.CancelFunc { + pingerCtx, stopPinger := context.WithCancel(context.Background()) + + gopool.Go(func() { + defer func() { + // 增加panic恢复处理 + if r := recover(); r != nil { + if common2.DebugEnabled { + println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r)) + } + } + if common2.DebugEnabled { + println("SSE ping goroutine stopped.") + } + }() + + if pingInterval <= 0 { + pingInterval = helper.DefaultPingInterval + } + + ticker := time.NewTicker(pingInterval) + // 确保在任何情况下都清理ticker + defer func() { + ticker.Stop() + if common2.DebugEnabled { + println("SSE ping ticker stopped") + } + }() + + var pingMutex sync.Mutex + if common2.DebugEnabled { + println("SSE ping goroutine started") + } + + // 增加超时控制,防止goroutine长时间运行 + maxPingDuration := 120 * time.Minute // 最大ping持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + + for { + select { + // 发送 ping 数据 + case <-ticker.C: + if err := sendPingData(c, &pingMutex); err != nil { + if common2.DebugEnabled { + println("SSE ping error, stopping goroutine:", err.Error()) + } + return + } + // 收到退出信号 + case <-pingerCtx.Done(): + return + // request 结束 + case <-c.Request.Context().Done(): + return + // 超时保护,防止goroutine无限运行 + case <-pingTimeout.C: + if common2.DebugEnabled { + println("SSE ping goroutine timeout, stopping") + } + return + } + } + }) + + return stopPinger +} + +func sendPingData(c *gin.Context, mutex *sync.Mutex) error { + // 增加超时控制,防止锁死等待 + done := make(chan error, 1) + go func() { + mutex.Lock() + defer mutex.Unlock() + + err := helper.PingData(c) + if err != nil { + common2.LogError(c, "SSE ping error: "+err.Error()) + done <- err + return + } + + if common2.DebugEnabled { + println("SSE ping data sent.") + } + done <- nil + }() + + // 设置发送ping数据的超时时间 + select { + case err := <-done: + return err + case <-time.After(10 * time.Second): + return errors.New("SSE ping data send timeout") + case <-c.Request.Context().Done(): + return errors.New("request context cancelled during ping") + } +} + func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { var client *http.Client var err error @@ -115,68 +214,36 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http } else { client = service.GetHttpClient() } - // 流式请求 ping 保活 - var stopPinger func() - generalSettings := operation_setting.GetGeneralSetting() - pingEnabled := generalSettings.PingIntervalEnabled - var pingerWg sync.WaitGroup + + var stopPinger context.CancelFunc if info.IsStream { helper.SetEventStreamHeaders(c) - pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second - var pingerCtx context.Context - pingerCtx, stopPinger = context.WithCancel(c.Request.Context()) - - if pingEnabled { - pingerWg.Add(1) - gopool.Go(func() { - defer pingerWg.Done() - if pingInterval <= 0 { - pingInterval = helper.DefaultPingInterval - } - - ticker := time.NewTicker(pingInterval) - defer ticker.Stop() - var pingMutex sync.Mutex - if common2.DebugEnabled { - println("SSE ping goroutine started") - } - - for { - select { - case <-ticker.C: - pingMutex.Lock() - err2 := helper.PingData(c) - pingMutex.Unlock() - if err2 != nil { - common2.LogError(c, "SSE ping error: "+err.Error()) - return - } - if common2.DebugEnabled { - println("SSE ping data sent.") - } - case <-pingerCtx.Done(): - if common2.DebugEnabled { - println("SSE ping goroutine stopped.") - } - return + // 处理流式请求的 ping 保活 + generalSettings := operation_setting.GetGeneralSetting() + if generalSettings.PingIntervalEnabled { + pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second + stopPinger = startPingKeepAlive(c, pingInterval) + // 使用defer确保在任何情况下都能停止ping goroutine + defer func() { + if stopPinger != nil { + stopPinger() + if common2.DebugEnabled { + println("SSE ping goroutine stopped by defer") } } - }) + }() } } resp, err := client.Do(req) - // request结束后停止ping - if info.IsStream && pingEnabled { - stopPinger() - pingerWg.Wait() - } + if err != nil { return nil, err } if resp == nil { return nil, errors.New("resp is nil") } + _ = req.Body.Close() _ = c.Request.Body.Close() return resp, nil diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 37196fd8..64c7b747 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -11,6 +11,8 @@ var awsModelIDMap = map[string]string{ "claude-3-5-sonnet-20241022": "anthropic.claude-3-5-sonnet-20241022-v2:0", "claude-3-5-haiku-20241022": "anthropic.claude-3-5-haiku-20241022-v1:0", "claude-3-7-sonnet-20250219": "anthropic.claude-3-7-sonnet-20250219-v1:0", + "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0", + "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", } var awsModelCanCrossRegionMap = map[string]map[string]bool{ @@ -41,6 +43,16 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ }, "anthropic.claude-3-7-sonnet-20250219-v1:0": { "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-sonnet-4-20250514-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-opus-4-20250514-v1:0": { + "us": true, }, } diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go index 62b06413..55b6c137 100644 --- a/relay/channel/baidu/relay-baidu.go +++ b/relay/channel/baidu/relay-baidu.go @@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest { } func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse { - content, _ := json.Marshal(response.Result) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Result, }, FinishReason: "stop", } diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go index 77afe2dd..2b8a52a2 100644 --- a/relay/channel/baidu_v2/adaptor.go +++ b/relay/channel/baidu_v2/adaptor.go @@ -9,6 +9,7 @@ import ( "one-api/relay/channel" "one-api/relay/channel/openai" relaycommon "one-api/relay/common" + "strings" "github.com/gin-gonic/gin" ) @@ -49,6 +50,18 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } + if strings.HasSuffix(info.UpstreamModelName, "-search") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search") + request.Model = info.UpstreamModelName + toMap := request.ToMap() + toMap["web_search"] = map[string]any{ + "enable": true, + "enable_citation": true, + "enable_trace": true, + "enable_status": false, + } + return toMap, nil + } return request, nil } diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 4b071712..8389b9f1 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -38,10 +38,10 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { - if strings.HasPrefix(info.UpstreamModelName, "claude-3") { - a.RequestMode = RequestModeMessage - } else { + if strings.HasPrefix(info.UpstreamModelName, "claude-2") || strings.HasPrefix(info.UpstreamModelName, "claude-instant") { a.RequestMode = RequestModeCompletion + } else { + a.RequestMode = RequestModeMessage } } diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index d7e0c8e3..e0e3c421 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -13,6 +13,10 @@ var ModelList = []string{ "claude-3-5-sonnet-20241022", "claude-3-7-sonnet-20250219", "claude-3-7-sonnet-20250219-thinking", + "claude-sonnet-4-20250514", + "claude-sonnet-4-20250514-thinking", + "claude-opus-4-20250514", + "claude-opus-4-20250514-thinking", } var ChannelName = "claude" diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 95e7c4be..406ebc8a 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla prompt := "" for _, message := range textRequest.Messages { if message.Role == "user" { - prompt += fmt.Sprintf("\n\nHuman: %s", message.Content) + prompt += fmt.Sprintf("\n\nHuman: %s", message.StringContent()) } else if message.Role == "assistant" { - prompt += fmt.Sprintf("\n\nAssistant: %s", message.Content) + prompt += fmt.Sprintf("\n\nAssistant: %s", message.StringContent()) } else if message.Role == "system" { if prompt == "" { prompt = message.StringContent() @@ -113,7 +113,7 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla // BudgetTokens 为 max_tokens 的 80% claudeRequest.Thinking = &dto.Thinking{ Type: "enabled", - BudgetTokens: int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage), + BudgetTokens: common.GetPointer[int](int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), } // TODO: 临时处理 // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking @@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla } if lastMessage.Role == message.Role && lastMessage.Role != "tool" { if lastMessage.IsStringContent() && message.IsStringContent() { - content, _ := json.Marshal(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) - fmtMessage.Content = content + fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) // delete last message formatMessages = formatMessages[:len(formatMessages)-1] } } if fmtMessage.Content == nil { - content, _ := json.Marshal("...") - fmtMessage.Content = content + fmtMessage.SetStringContent("...") } formatMessages = append(formatMessages, fmtMessage) lastMessage = fmtMessage @@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto thinkingContent := "" if reqMode == RequestModeCompletion { - content, _ := json.Marshal(strings.TrimPrefix(claudeResponse.Completion, " ")) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: strings.TrimPrefix(claudeResponse.Completion, " "), Name: nil, }, FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), @@ -457,6 +454,7 @@ type ClaudeResponseInfo struct { Model string ResponseText strings.Builder Usage *dto.Usage + Done bool } func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool { @@ -464,20 +462,32 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons claudeInfo.ResponseText.WriteString(claudeResponse.Completion) } else { if claudeResponse.Type == "message_start" { - // message_start, 获取usage claudeInfo.ResponseId = claudeResponse.Message.Id claudeInfo.Model = claudeResponse.Message.Model + + // message_start, 获取usage claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens + claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens + claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens + claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens } else if claudeResponse.Type == "content_block_delta" { if claudeResponse.Delta.Text != nil { claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text) } + if claudeResponse.Delta.Thinking != "" { + claudeInfo.ResponseText.WriteString(claudeResponse.Delta.Thinking) + } } else if claudeResponse.Type == "message_delta" { - claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens + // 最终的usage获取 if claudeResponse.Usage.InputTokens > 0 { + // 不叠加,只取最新的 claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens } - claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeResponse.Usage.OutputTokens + claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens + claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens + + // 判断是否完整 + claudeInfo.Done = true } else if claudeResponse.Type == "content_block_start" { } else { return false @@ -509,25 +519,15 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud } } if info.RelayFormat == relaycommon.RelayFormatClaude { + FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo) + if requestMode == RequestModeCompletion { - claudeInfo.ResponseText.WriteString(claudeResponse.Completion) } else { if claudeResponse.Type == "message_start" { // message_start, 获取usage info.UpstreamModelName = claudeResponse.Message.Model - claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens - claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens - claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens - claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens } else if claudeResponse.Type == "content_block_delta" { - claudeInfo.ResponseText.WriteString(claudeResponse.Delta.GetText()) } else if claudeResponse.Type == "message_delta" { - if claudeResponse.Usage.InputTokens > 0 { - // 不叠加,只取最新的 - claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens - } - claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens - claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens } } helper.ClaudeChunkData(c, claudeResponse, data) @@ -547,29 +547,25 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud } func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) { + + if requestMode == RequestModeCompletion { + claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + if claudeInfo.Usage.PromptTokens == 0 { + //上游出错 + } + if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done { + if common.DebugEnabled { + common.SysError("claude response usage is not complete, maybe upstream error") + } + claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) + } + } + if info.RelayFormat == relaycommon.RelayFormatClaude { - if requestMode == RequestModeCompletion { - claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens) - } else { - // 说明流模式建立失败,可能为官方出错 - if claudeInfo.Usage.PromptTokens == 0 { - //usage.PromptTokens = info.PromptTokens - } - if claudeInfo.Usage.CompletionTokens == 0 { - claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) - } - } + // } else if info.RelayFormat == relaycommon.RelayFormatOpenAI { - if requestMode == RequestModeCompletion { - claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens) - } else { - if claudeInfo.Usage.PromptTokens == 0 { - //上游出错 - } - if claudeInfo.Usage.CompletionTokens == 0 { - claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) - } - } + if info.ShouldIncludeUsage { response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage) err := helper.ObjectData(c, response) @@ -622,10 +618,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud } } if requestMode == RequestModeCompletion { - completionTokens, err := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) - if err != nil { - return service.OpenAIErrorWrapper(err, "count_token_text_failed", http.StatusInternalServerError) - } + completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) claudeInfo.Usage.PromptTokens = info.PromptTokens claudeInfo.Usage.CompletionTokens = completionTokens claudeInfo.Usage.TotalTokens = info.PromptTokens + completionTokens diff --git a/relay/channel/cloudflare/relay_cloudflare.go b/relay/channel/cloudflare/relay_cloudflare.go index a487429c..50d4928a 100644 --- a/relay/channel/cloudflare/relay_cloudflare.go +++ b/relay/channel/cloudflare/relay_cloudflare.go @@ -71,7 +71,7 @@ func cfStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela if err := scanner.Err(); err != nil { common.LogError(c, "error_scanning_stream_response: "+err.Error()) } - usage, _ := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) if info.ShouldIncludeUsage { response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage) err := helper.ObjectData(c, response) @@ -108,7 +108,7 @@ func cfHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) for _, choice := range response.Choices { responseText += choice.Message.StringContent() } - usage, _ := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) response.Usage = *usage response.Id = helper.GetResponseID(c) jsonResponse, err := json.Marshal(response) @@ -150,7 +150,7 @@ func cfSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayIn usage := &dto.Usage{} usage.PromptTokens = info.PromptTokens - usage.CompletionTokens, _ = service.CountTextToken(cfResp.Result.Text, info.UpstreamModelName) + usage.CompletionTokens = service.CountTextToken(cfResp.Result.Text, info.UpstreamModelName) usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens return nil, usage diff --git a/relay/channel/cohere/relay-cohere.go b/relay/channel/cohere/relay-cohere.go index 17b58dbc..29064242 100644 --- a/relay/channel/cohere/relay-cohere.go +++ b/relay/channel/cohere/relay-cohere.go @@ -3,7 +3,6 @@ package cohere import ( "bufio" "encoding/json" - "fmt" "github.com/gin-gonic/gin" "io" "net/http" @@ -78,7 +77,7 @@ func stopReasonCohere2OpenAI(reason string) string { } func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) { - responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + responseId := helper.GetResponseID(c) createdTime := common.GetTimestamp() usage := &dto.Usage{} responseText := "" @@ -163,7 +162,7 @@ func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon. } }) if usage.PromptTokens == 0 { - usage, _ = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) } return nil, usage } @@ -195,11 +194,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt openaiResp.Model = modelName openaiResp.Usage = usage - content, _ := json.Marshal(cohereResp.Text) openaiResp.Choices = []dto.OpenAITextResponseChoice{ { Index: 0, - Message: dto.Message{Content: content, Role: "assistant"}, + Message: dto.Message{Content: cohereResp.Text, Role: "assistant"}, FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason), }, } diff --git a/relay/channel/coze/dto.go b/relay/channel/coze/dto.go index 4e9afa23..d5dc9a81 100644 --- a/relay/channel/coze/dto.go +++ b/relay/channel/coze/dto.go @@ -10,7 +10,7 @@ type CozeError struct { type CozeEnterMessage struct { Role string `json:"role"` Type string `json:"type,omitempty"` - Content json.RawMessage `json:"content,omitempty"` + Content any `json:"content,omitempty"` MetaData json.RawMessage `json:"meta_data,omitempty"` ContentType string `json:"content_type,omitempty"` } diff --git a/relay/channel/coze/relay-coze.go b/relay/channel/coze/relay-coze.go index 6db40213..ac76476f 100644 --- a/relay/channel/coze/relay-coze.go +++ b/relay/channel/coze/relay-coze.go @@ -106,7 +106,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo var currentEvent string var currentData string - var usage dto.Usage + var usage = &dto.Usage{} for scanner.Scan() { line := scanner.Text() @@ -114,7 +114,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo if line == "" { if currentEvent != "" && currentData != "" { // handle last event - handleCozeEvent(c, currentEvent, currentData, &responseText, &usage, id, info) + handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info) currentEvent = "" currentData = "" } @@ -134,7 +134,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo // Last event if currentEvent != "" && currentData != "" { - handleCozeEvent(c, currentEvent, currentData, &responseText, &usage, id, info) + handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info) } if err := scanner.Err(); err != nil { @@ -143,12 +143,10 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo helper.Done(c) if usage.TotalTokens == 0 { - usage.PromptTokens = info.PromptTokens - usage.CompletionTokens, _ = service.CountTextToken("gpt-3.5-turbo", responseText) - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, c.GetInt("coze_input_count")) } - return nil, &usage + return nil, usage } func handleCozeEvent(c *gin.Context, event string, data string, responseText *string, usage *dto.Usage, id string, info *relaycommon.RelayInfo) { diff --git a/relay/channel/dify/relay-dify.go b/relay/channel/dify/relay-dify.go index b58fbe53..115aed1b 100644 --- a/relay/channel/dify/relay-dify.go +++ b/relay/channel/dify/relay-dify.go @@ -243,15 +243,8 @@ func difyStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re return true }) helper.Done(c) - err := resp.Body.Close() - if err != nil { - // return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - common.SysError("close_response_body_failed: " + err.Error()) - } if usage.TotalTokens == 0 { - usage.PromptTokens = info.PromptTokens - usage.CompletionTokens, _ = service.CountTextToken("gpt-3.5-turbo", responseText) - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) } usage.CompletionTokens += nodeToken return nil, usage @@ -278,12 +271,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf Created: common.GetTimestamp(), Usage: difyResponse.MetaData.Usage, } - content, _ := json.Marshal(difyResponse.Answer) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: difyResponse.Answer, }, FinishReason: "stop", } diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index c3c7b49d..968d9c9b 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -10,6 +10,7 @@ import ( "one-api/dto" "one-api/relay/channel" relaycommon "one-api/relay/common" + "one-api/relay/constant" "one-api/service" "one-api/setting/model_setting" "strings" @@ -71,10 +72,13 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { - // suffix -thinking and -nothinking - if strings.HasSuffix(info.OriginModelName, "-thinking") { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.UpstreamModelName, "-thinking-") { + parts := strings.Split(info.UpstreamModelName, "-thinking-") + info.UpstreamModelName = parts[0] + } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配 info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") - } else if strings.HasSuffix(info.OriginModelName, "-nothinking") { + } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") } } @@ -165,6 +169,14 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) { + if info.RelayMode == constant.RelayModeGemini { + if info.IsStream { + return GeminiTextGenerationStreamHandler(c, resp, info) + } else { + return GeminiTextGenerationHandler(c, resp, info) + } + } + if strings.HasPrefix(info.UpstreamModelName, "imagen") { return GeminiImageHandler(c, resp, info) } diff --git a/relay/channel/gemini/dto.go b/relay/channel/gemini/dto.go index 5d5c1287..b22e092a 100644 --- a/relay/channel/gemini/dto.go +++ b/relay/channel/gemini/dto.go @@ -1,11 +1,13 @@ package gemini +import "encoding/json" + type GeminiChatRequest struct { Contents []GeminiChatContent `json:"contents"` - SafetySettings []GeminiChatSafetySettings `json:"safety_settings,omitempty"` - GenerationConfig GeminiChatGenerationConfig `json:"generation_config,omitempty"` + SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"` + GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"` Tools []GeminiChatTool `json:"tools,omitempty"` - SystemInstructions *GeminiChatContent `json:"system_instruction,omitempty"` + SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"` } type GeminiThinkingConfig struct { @@ -22,19 +24,38 @@ type GeminiInlineData struct { Data string `json:"data"` } +// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType +func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { + type Alias GeminiInlineData // Use type alias to avoid recursion + var aux struct { + Alias + MimeTypeSnake string `json:"mime_type"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + *g = GeminiInlineData(aux.Alias) // Copy other fields if any in future + + // Prioritize snake_case if present + if aux.MimeTypeSnake != "" { + g.MimeType = aux.MimeTypeSnake + } else if aux.MimeType != "" { // Fallback to camelCase from Alias + g.MimeType = aux.MimeType + } + // g.Data would be populated by aux.Alias.Data + return nil +} + type FunctionCall struct { FunctionName string `json:"name"` Arguments any `json:"args"` } -type GeminiFunctionResponseContent struct { - Name string `json:"name"` - Content any `json:"content"` -} - type FunctionResponse struct { - Name string `json:"name"` - Response GeminiFunctionResponseContent `json:"response"` + Name string `json:"name"` + Response map[string]interface{} `json:"response"` } type GeminiPartExecutableCode struct { @@ -54,6 +75,7 @@ type GeminiFileData struct { type GeminiPart struct { Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` InlineData *GeminiInlineData `json:"inlineData,omitempty"` FunctionCall *FunctionCall `json:"functionCall,omitempty"` FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"` @@ -62,6 +84,33 @@ type GeminiPart struct { CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"` } +// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData +func (p *GeminiPart) UnmarshalJSON(data []byte) error { + // Alias to avoid recursion during unmarshalling + type Alias GeminiPart + var aux struct { + Alias + InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Assign fields from alias + *p = GeminiPart(aux.Alias) + + // Prioritize snake_case for InlineData if present + if aux.InlineDataSnake != nil { + p.InlineData = aux.InlineDataSnake + } else if aux.InlineData != nil { // Fallback to camelCase from Alias + p.InlineData = aux.InlineData + } + // Other fields like Text, FunctionCall etc. are already populated via aux.Alias + + return nil +} + type GeminiChatContent struct { Role string `json:"role,omitempty"` Parts []GeminiPart `json:"parts"` @@ -91,6 +140,7 @@ type GeminiChatGenerationConfig struct { Seed int64 `json:"seed,omitempty"` ResponseModalities []string `json:"responseModalities,omitempty"` ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` + SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config } type GeminiChatCandidate struct { @@ -116,10 +166,16 @@ type GeminiChatResponse struct { } type GeminiUsageMetadata struct { - PromptTokenCount int `json:"promptTokenCount"` - CandidatesTokenCount int `json:"candidatesTokenCount"` - TotalTokenCount int `json:"totalTokenCount"` - ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"` +} + +type GeminiPromptTokensDetails struct { + Modality string `json:"modality"` + TokenCount int `json:"tokenCount"` } // Imagen related structs diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go new file mode 100644 index 00000000..39757cef --- /dev/null +++ b/relay/channel/gemini/relay-gemini-native.go @@ -0,0 +1,146 @@ +package gemini + +import ( + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "strings" + + "github.com/gin-gonic/gin" +) + +func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *dto.OpenAIErrorWithStatusCode) { + // 读取响应体 + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + err = resp.Body.Close() + if err != nil { + return nil, service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError) + } + + if common.DebugEnabled { + println(string(responseBody)) + } + + // 解析为 Gemini 原生响应格式 + var geminiResponse GeminiChatResponse + err = common.DecodeJson(responseBody, &geminiResponse) + if err != nil { + return nil, service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + } + + // 计算使用量(基于 UsageMetadata) + usage := dto.Usage{ + PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, + CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount, + TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, + } + + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + + // 直接返回 Gemini 原生格式的 JSON 响应 + jsonResponse, err := json.Marshal(geminiResponse) + if err != nil { + return nil, service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError) + } + + // 设置响应头并写入响应 + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + if err != nil { + return nil, service.OpenAIErrorWrapper(err, "write_response_failed", http.StatusInternalServerError) + } + + return &usage, nil +} + +func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *dto.OpenAIErrorWithStatusCode) { + var usage = &dto.Usage{} + var imageCount int + + helper.SetEventStreamHeaders(c) + + responseText := strings.Builder{} + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var geminiResponse GeminiChatResponse + err := common.DecodeJsonStr(data, &geminiResponse) + if err != nil { + common.LogError(c, "error unmarshalling stream response: "+err.Error()) + return false + } + + // 统计图片数量 + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && part.InlineData.MimeType != "" { + imageCount++ + } + if part.Text != "" { + responseText.WriteString(part.Text) + } + } + } + + // 更新使用量统计 + if geminiResponse.UsageMetadata.TotalTokenCount != 0 { + usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount + usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount + usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + } + + // 直接发送 GeminiChatResponse 响应 + err = helper.StringData(c, data) + if err != nil { + common.LogError(c, err.Error()) + } + + return true + }) + + if imageCount != 0 { + if usage.CompletionTokens == 0 { + usage.CompletionTokens = imageCount * 258 + } + } + + // 如果usage.CompletionTokens为0,则使用本地统计的completion tokens + if usage.CompletionTokens == 0 { + str := responseText.String() + if len(str) > 0 { + usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + // 空补全,不需要使用量 + usage = &dto.Usage{} + } + } + + // 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为 + //helper.Done(c) + + return usage, nil +} diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index ae9a3b7b..18edfd04 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -12,12 +12,72 @@ import ( "one-api/relay/helper" "one-api/service" "one-api/setting/model_setting" + "strconv" "strings" "unicode/utf8" "github.com/gin-gonic/gin" ) +var geminiSupportedMimeTypes = map[string]bool{ + "application/pdf": true, + "audio/mpeg": true, + "audio/mp3": true, + "audio/wav": true, + "image/png": true, + "image/jpeg": true, + "text/plain": true, + "video/mov": true, + "video/mpeg": true, + "video/mp4": true, + "video/mpg": true, + "video/avi": true, + "video/wmv": true, + "video/mpegps": true, + "video/flv": true, +} + +// Gemini 允许的思考预算范围 +const ( + pro25MinBudget = 128 + pro25MaxBudget = 32768 + flash25MaxBudget = 24576 + flash25LiteMinBudget = 512 + flash25LiteMaxBudget = 24576 +) + +// clampThinkingBudget 根据模型名称将预算限制在允许的范围内 +func clampThinkingBudget(modelName string, budget int) int { + isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") + is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite") + + if is25FlashLite { + if budget < flash25LiteMinBudget { + return flash25LiteMinBudget + } + if budget > flash25LiteMaxBudget { + return flash25LiteMaxBudget + } + } else if isNew25Pro { + if budget < pro25MinBudget { + return pro25MinBudget + } + if budget > pro25MaxBudget { + return pro25MaxBudget + } + } else { // 其他模型 + if budget < 0 { + return 0 + } + if budget > flash25MaxBudget { + return flash25MaxBudget + } + } + return budget +} + // Setting safety to the lowest possible values since Gemini is already powerless enough func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { @@ -39,18 +99,54 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { - if strings.HasSuffix(info.OriginModelName, "-thinking") { - budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) - if budgetTokens == 0 || budgetTokens > 24576 { - budgetTokens = 24576 + modelName := info.UpstreamModelName + isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") + + if strings.Contains(modelName, "-thinking-") { + parts := strings.SplitN(modelName, "-thinking-", 2) + if len(parts) == 2 && parts[1] != "" { + if budgetTokens, err := strconv.Atoi(parts[1]); err == nil { + clampedBudget := clampThinkingBudget(modelName, budgetTokens) + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(clampedBudget), + IncludeThoughts: true, + } + } } - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ - ThinkingBudget: common.GetPointer(int(budgetTokens)), - IncludeThoughts: true, + } else if strings.HasSuffix(modelName, "-thinking") { + unsupportedModels := []string{ + "gemini-2.5-pro-preview-05-06", + "gemini-2.5-pro-preview-03-25", } - } else if strings.HasSuffix(info.OriginModelName, "-nothinking") { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ - ThinkingBudget: common.GetPointer(0), + isUnsupported := false + for _, unsupportedModel := range unsupportedModels { + if strings.HasPrefix(modelName, unsupportedModel) { + isUnsupported = true + break + } + } + + if isUnsupported { + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + IncludeThoughts: true, + } + } else { + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + IncludeThoughts: true, + } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) + clampedBudget := clampThinkingBudget(modelName, int(budgetTokens)) + geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget) + } + } + } else if strings.HasSuffix(modelName, "-nothinking") { + if !isNew25Pro { + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(0), + } } } } @@ -112,12 +208,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon // common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools)) // json_data, _ := json.Marshal(geminiRequest.Tools) // common.SysLog("tools_json: " + string(json_data)) - } else if textRequest.Functions != nil { - //geminiRequest.Tools = []GeminiChatTool{ - // { - // FunctionDeclarations: textRequest.Functions, - // }, - //} } if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { @@ -148,17 +238,27 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } else if val, exists := tool_call_ids[message.ToolCallId]; exists { name = val } - content := common.StrToMap(message.StringContent()) + var contentMap map[string]interface{} + contentStr := message.StringContent() + + // 1. 尝试解析为 JSON 对象 + if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil { + // 2. 如果失败,尝试解析为 JSON 数组 + var contentSlice []interface{} + if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil { + // 如果是数组,包装成对象 + contentMap = map[string]interface{}{"result": contentSlice} + } else { + // 3. 如果再次失败,作为纯文本处理 + contentMap = map[string]interface{}{"content": contentStr} + } + } + functionResp := &FunctionResponse{ - Name: name, - Response: GeminiFunctionResponseContent{ - Name: name, - Content: content, - }, - } - if content == nil { - functionResp.Response.Content = message.StringContent() + Name: name, + Response: contentMap, } + *parts = append(*parts, GeminiPart{ FunctionResponse: functionResp, }) @@ -208,14 +308,21 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } // 判断是否是url if strings.HasPrefix(part.GetImageMedia().Url, "http") { - // 是url,获取图片的类型和base64编码的数据 + // 是url,获取文件的类型和base64编码的数据 fileData, err := service.GetFileBase64FromUrl(part.GetImageMedia().Url) if err != nil { - return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error()) + return nil, fmt.Errorf("get file base64 from url '%s' failed: %w", part.GetImageMedia().Url, err) } + + // 校验 MimeType 是否在 Gemini 支持的白名单中 + if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok { + url := part.GetImageMedia().Url + return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList()) + } + parts = append(parts, GeminiPart{ InlineData: &GeminiInlineData{ - MimeType: fileData.MimeType, + MimeType: fileData.MimeType, // 使用原始的 MimeType,因为大小写可能对API有意义 Data: fileData.Base64Data, }, }) @@ -249,13 +356,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if part.GetInputAudio().Data == "" { return nil, fmt.Errorf("only base64 audio is supported in gemini") } - format, base64String, err := service.DecodeBase64FileData(part.GetInputAudio().Data) + base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data) if err != nil { return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) } parts = append(parts, GeminiPart{ InlineData: &GeminiInlineData{ - MimeType: format, + MimeType: "audio/" + part.GetInputAudio().Format, Data: base64String, }, }) @@ -268,7 +375,9 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if content.Role == "assistant" { content.Role = "model" } - geminiRequest.Contents = append(geminiRequest.Contents, content) + if len(content.Parts) > 0 { + geminiRequest.Contents = append(geminiRequest.Contents, content) + } } if len(system_content) > 0 { @@ -284,100 +393,126 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon return &geminiRequest, nil } +// Helper function to get a list of supported MIME types for error messages +func getSupportedMimeTypesList() []string { + keys := make([]string, 0, len(geminiSupportedMimeTypes)) + for k := range geminiSupportedMimeTypes { + keys = append(keys, k) + } + return keys +} + // cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters. func cleanFunctionParameters(params interface{}) interface{} { if params == nil { return nil } - paramMap, ok := params.(map[string]interface{}) - if !ok { - // Not a map, return as is (e.g., could be an array or primitive) - return params - } + switch v := params.(type) { + case map[string]interface{}: + // Create a copy to avoid modifying the original + cleanedMap := make(map[string]interface{}) + for k, val := range v { + cleanedMap[k] = val + } - // Create a copy to avoid modifying the original - cleanedMap := make(map[string]interface{}) - for k, v := range paramMap { - cleanedMap[k] = v - } + // Remove unsupported root-level fields + delete(cleanedMap, "default") + delete(cleanedMap, "exclusiveMaximum") + delete(cleanedMap, "exclusiveMinimum") + delete(cleanedMap, "$schema") + delete(cleanedMap, "additionalProperties") - // Remove unsupported root-level fields - delete(cleanedMap, "default") - delete(cleanedMap, "exclusiveMaximum") - delete(cleanedMap, "exclusiveMinimum") - delete(cleanedMap, "$schema") - delete(cleanedMap, "additionalProperties") - - // Clean properties - if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil { - cleanedProps := make(map[string]interface{}) - for propName, propValue := range props { - propMap, ok := propValue.(map[string]interface{}) - if !ok { - cleanedProps[propName] = propValue // Keep non-map properties - continue - } - - // Create a copy of the property map - cleanedPropMap := make(map[string]interface{}) - for k, v := range propMap { - cleanedPropMap[k] = v - } - - // Remove unsupported fields - delete(cleanedPropMap, "default") - delete(cleanedPropMap, "exclusiveMaximum") - delete(cleanedPropMap, "exclusiveMinimum") - delete(cleanedPropMap, "$schema") - delete(cleanedPropMap, "additionalProperties") - - // Check and clean 'format' for string types - if propType, typeExists := cleanedPropMap["type"].(string); typeExists && propType == "string" { - if formatValue, formatExists := cleanedPropMap["format"].(string); formatExists { - if formatValue != "enum" && formatValue != "date-time" { - delete(cleanedPropMap, "format") - } + // Check and clean 'format' for string types + if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" { + if formatValue, formatExists := cleanedMap["format"].(string); formatExists { + if formatValue != "enum" && formatValue != "date-time" { + delete(cleanedMap, "format") } } + } - // Recursively clean nested properties within this property if it's an object/array - // Check the type before recursing - if propType, typeExists := cleanedPropMap["type"].(string); typeExists && (propType == "object" || propType == "array") { - cleanedProps[propName] = cleanFunctionParameters(cleanedPropMap) - } else { - cleanedProps[propName] = cleanedPropMap // Assign the cleaned map back if not recursing + // Clean properties + if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil { + cleanedProps := make(map[string]interface{}) + for propName, propValue := range props { + cleanedProps[propName] = cleanFunctionParameters(propValue) } - + cleanedMap["properties"] = cleanedProps } - cleanedMap["properties"] = cleanedProps - } - // Recursively clean items in arrays if needed (e.g., type: array, items: { ... }) - if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil { - cleanedMap["items"] = cleanFunctionParameters(items) - } - // Also handle items if it's an array of schemas - if itemsArray, ok := cleanedMap["items"].([]interface{}); ok { - cleanedItemsArray := make([]interface{}, len(itemsArray)) - for i, item := range itemsArray { - cleanedItemsArray[i] = cleanFunctionParameters(item) + // Recursively clean items in arrays + if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil { + cleanedMap["items"] = cleanFunctionParameters(items) } - cleanedMap["items"] = cleanedItemsArray - } - - // Recursively clean other schema composition keywords if necessary - for _, field := range []string{"allOf", "anyOf", "oneOf"} { - if nested, ok := cleanedMap[field].([]interface{}); ok { - cleanedNested := make([]interface{}, len(nested)) - for i, item := range nested { - cleanedNested[i] = cleanFunctionParameters(item) + // Also handle items if it's an array of schemas + if itemsArray, ok := cleanedMap["items"].([]interface{}); ok { + cleanedItemsArray := make([]interface{}, len(itemsArray)) + for i, item := range itemsArray { + cleanedItemsArray[i] = cleanFunctionParameters(item) } - cleanedMap[field] = cleanedNested + cleanedMap["items"] = cleanedItemsArray } - } - return cleanedMap + // Recursively clean other schema composition keywords + for _, field := range []string{"allOf", "anyOf", "oneOf"} { + if nested, ok := cleanedMap[field].([]interface{}); ok { + cleanedNested := make([]interface{}, len(nested)) + for i, item := range nested { + cleanedNested[i] = cleanFunctionParameters(item) + } + cleanedMap[field] = cleanedNested + } + } + + // Recursively clean patternProperties + if patternProps, ok := cleanedMap["patternProperties"].(map[string]interface{}); ok { + cleanedPatternProps := make(map[string]interface{}) + for pattern, schema := range patternProps { + cleanedPatternProps[pattern] = cleanFunctionParameters(schema) + } + cleanedMap["patternProperties"] = cleanedPatternProps + } + + // Recursively clean definitions + if definitions, ok := cleanedMap["definitions"].(map[string]interface{}); ok { + cleanedDefinitions := make(map[string]interface{}) + for defName, defSchema := range definitions { + cleanedDefinitions[defName] = cleanFunctionParameters(defSchema) + } + cleanedMap["definitions"] = cleanedDefinitions + } + + // Recursively clean $defs (newer JSON Schema draft) + if defs, ok := cleanedMap["$defs"].(map[string]interface{}); ok { + cleanedDefs := make(map[string]interface{}) + for defName, defSchema := range defs { + cleanedDefs[defName] = cleanFunctionParameters(defSchema) + } + cleanedMap["$defs"] = cleanedDefs + } + + // Clean conditional keywords + for _, field := range []string{"if", "then", "else", "not"} { + if nested, ok := cleanedMap[field]; ok { + cleanedMap[field] = cleanFunctionParameters(nested) + } + } + + return cleanedMap + + case []interface{}: + // Handle arrays of schemas + cleanedArray := make([]interface{}, len(v)) + for i, item := range v { + cleanedArray[i] = cleanFunctionParameters(item) + } + return cleanedArray + + default: + // Not a map or array, return as is (e.g., could be a primitive) + return params + } } func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} { @@ -512,21 +647,20 @@ func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse { } } -func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResponse { +func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse { fullTextResponse := dto.OpenAITextResponse{ - Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()), + Id: helper.GetResponseID(c), Object: "chat.completion", Created: common.GetTimestamp(), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), } - content, _ := json.Marshal("") isToolCall := false for _, candidate := range response.Candidates { choice := dto.OpenAITextResponseChoice{ Index: int(candidate.Index), Message: dto.Message{ Role: "assistant", - Content: content, + Content: "", }, FinishReason: constant.FinishReasonStop, } @@ -539,6 +673,8 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp if call := getResponseToolCall(&part); call != nil { toolCalls = append(toolCalls, *call) } + } else if part.Thought { + choice.Message.ReasoningContent = part.Text } else { if part.ExecutableCode != nil { texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```") @@ -556,7 +692,6 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp choice.Message.SetToolCalls(toolCalls) isToolCall = true } - choice.Message.SetStringContent(strings.Join(texts, "\n")) } @@ -596,6 +731,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C } var texts []string isTools := false + isThought := false if candidate.FinishReason != nil { // p := GeminiConvertFinishReason(*candidate.FinishReason) switch *candidate.FinishReason { @@ -620,6 +756,9 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C call.SetIndex(len(choice.Delta.ToolCalls)) choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call) } + } else if part.Thought { + isThought = true + texts = append(texts, part.Text) } else { if part.ExecutableCode != nil { texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```\n") @@ -632,7 +771,11 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C } } } - choice.Delta.SetContentString(strings.Join(texts, "\n")) + if isThought { + choice.Delta.SetReasoningContent(strings.Join(texts, "\n")) + } else { + choice.Delta.SetContentString(strings.Join(texts, "\n")) + } if isTools { choice.FinishReason = &constant.FinishReasonToolCalls } @@ -647,7 +790,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) { // responseText := "" - id := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + id := helper.GetResponseID(c) createAt := common.GetTimestamp() var usage = &dto.Usage{} var imageCount int @@ -672,6 +815,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } } err = helper.ObjectData(c, response) if err != nil { @@ -716,8 +866,11 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re if err != nil { return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil } + if common.DebugEnabled { + println(string(responseBody)) + } var geminiResponse GeminiChatResponse - err = json.Unmarshal(responseBody, &geminiResponse) + err = common.DecodeJson(responseBody, &geminiResponse) if err != nil { return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil } @@ -732,7 +885,7 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re StatusCode: resp.StatusCode, }, nil } - fullTextResponse := responseGeminiChat2OpenAI(&geminiResponse) + fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) fullTextResponse.Model = info.UpstreamModelName usage := dto.Usage{ PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, @@ -743,6 +896,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + fullTextResponse.Usage = usage jsonResponse, err := json.Marshal(fullTextResponse) if err != nil { diff --git a/relay/channel/mistral/text.go b/relay/channel/mistral/text.go index 75272e34..e26c6101 100644 --- a/relay/channel/mistral/text.go +++ b/relay/channel/mistral/text.go @@ -1,13 +1,55 @@ package mistral import ( + "one-api/common" "one-api/dto" + "regexp" ) +var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$") + func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { messages := make([]dto.Message, 0, len(request.Messages)) + idMap := make(map[string]string) for _, message := range request.Messages { + // 1. tool_calls.id + toolCalls := message.ParseToolCalls() + if toolCalls != nil { + for i := range toolCalls { + if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) { + if newId, ok := idMap[toolCalls[i].ID]; ok { + toolCalls[i].ID = newId + } else { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[toolCalls[i].ID] = newId + toolCalls[i].ID = newId + } + } + } + } + message.SetToolCalls(toolCalls) + } + + // 2. tool_call_id + if message.ToolCallId != "" { + if newId, ok := idMap[message.ToolCallId]; ok { + message.ToolCallId = newId + } else { + if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[message.ToolCallId] = newId + message.ToolCallId = newId + } + } + } + } + mediaMessages := message.ParseContent() + if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" { + mediaMessages = []dto.MediaContent{} + } for j, mediaMessage := range mediaMessages { if mediaMessage.Type == dto.ContentTypeImageURL { imageUrl := mediaMessage.GetImageMedia() diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f0cf073f..8358f3e2 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -88,6 +88,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { requestURL := strings.Split(info.RequestURLPath, "?")[0] requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion) task := strings.TrimPrefix(requestURL, "/v1/") + + // 特殊处理 responses API + if info.RelayMode == constant.RelayModeResponses { + requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview") + return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil + } + model_ := info.UpstreamModelName // 2025年5月10日后创建的渠道不移除. if info.ChannelCreateTime < constant2.AzureNoRemoveDotTime { diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 86c47a15..71590cd6 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -15,6 +15,7 @@ import ( "one-api/relay/helper" "one-api/service" "os" + "path/filepath" "strings" "github.com/bytedance/gopkg/util/gopool" @@ -180,7 +181,7 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel } if !containStreamUsage { - usage, _ = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) + usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) usage.CompletionTokens += toolCount * 7 } else { if info.ChannelType == common.ChannelTypeDeepSeek { @@ -215,7 +216,7 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI StatusCode: resp.StatusCode, }, nil } - + forceFormat := false if forceFmt, ok := info.ChannelSetting[constant.ForceFormat].(bool); ok { forceFormat = forceFmt @@ -224,7 +225,7 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI if simpleResponse.Usage.TotalTokens == 0 || (simpleResponse.Usage.PromptTokens == 0 && simpleResponse.Usage.CompletionTokens == 0) { completionTokens := 0 for _, choice := range simpleResponse.Choices { - ctkm, _ := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName) + ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName) completionTokens += ctkm } simpleResponse.Usage = dto.Usage{ @@ -273,36 +274,25 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI } func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) { - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil - } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } - // Reset response body - resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) - // We shouldn't set the header before we parse the response body, because the parse part may fail. - // And then we will have to send an error response, but in this case, the header has already been set. - // So the httpClient will be confused by the response. - // For example, Postman will report error, and we cannot check the response at all. + // the status code has been judged before, if there is a body reading failure, + // it should be regarded as a non-recoverable error, so it should not return err for external retry. + // Analogous to nginx's load balancing, it will only retry if it can't be requested or + // if the upstream returns a specific status code, once the upstream has already written the header, + // the subsequent failure of the response body should be regarded as a non-recoverable error, + // and can be terminated directly. + defer resp.Body.Close() + usage := &dto.Usage{} + usage.PromptTokens = info.PromptTokens + usage.TotalTokens = info.PromptTokens for k, v := range resp.Header { c.Writer.Header().Set(k, v[0]) } c.Writer.WriteHeader(resp.StatusCode) - _, err = io.Copy(c.Writer, resp.Body) + c.Writer.WriteHeaderNow() + _, err := io.Copy(c.Writer, resp.Body) if err != nil { - return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil + common.LogError(c, err.Error()) } - err = resp.Body.Close() - if err != nil { - return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil - } - - usage := &dto.Usage{} - usage.PromptTokens = info.PromptTokens - usage.TotalTokens = info.PromptTokens return nil, usage } @@ -356,13 +346,14 @@ func countAudioTokens(c *gin.Context) (int, error) { if err = c.ShouldBind(&reqBody); err != nil { return 0, errors.WithStack(err) } - + ext := filepath.Ext(reqBody.File.Filename) // 获取文件扩展名 reqFp, err := reqBody.File.Open() if err != nil { return 0, errors.WithStack(err) } + defer reqFp.Close() - tmpFp, err := os.CreateTemp("", "audio-*") + tmpFp, err := os.CreateTemp("", "audio-*"+ext) if err != nil { return 0, errors.WithStack(err) } @@ -376,7 +367,7 @@ func countAudioTokens(c *gin.Context) (int, error) { return 0, errors.WithStack(err) } - duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name()) + duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name(), ext) if err != nil { return 0, errors.WithStack(err) } diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 1d1e060e..da9382c3 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -110,7 +110,7 @@ func OaiResponsesStreamHandler(c *gin.Context, resp *http.Response, info *relayc tempStr := responseTextBuilder.String() if len(tempStr) > 0 { // 非正常结束,使用输出文本的 token 数量 - completionTokens, _ := service.CountTextToken(tempStr, info.UpstreamModelName) + completionTokens := service.CountTextToken(tempStr, info.UpstreamModelName) usage.CompletionTokens = completionTokens } } diff --git a/relay/channel/openrouter/dto.go b/relay/channel/openrouter/dto.go new file mode 100644 index 00000000..607f495b --- /dev/null +++ b/relay/channel/openrouter/dto.go @@ -0,0 +1,9 @@ +package openrouter + +type RequestReasoning struct { + // One of the following (not both): + Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style) + MaxTokens int `json:"max_tokens,omitempty"` // Specific token limit (Anthropic-style) + // Optional: Default is false. All models support this. + Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response +} diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go index 3a06e7ee..aee4a307 100644 --- a/relay/channel/palm/adaptor.go +++ b/relay/channel/palm/adaptor.go @@ -74,7 +74,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom if info.IsStream { var responseText string err, responseText = palmStreamHandler(c, resp) - usage, _ = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) } else { err, usage = palmHandler(c, resp, info.PromptTokens, info.UpstreamModelName) } diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index c8e337de..9d3dbd67 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -2,7 +2,6 @@ package palm import ( "encoding/json" - "fmt" "github.com/gin-gonic/gin" "io" "net/http" @@ -45,12 +44,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse { Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), } for i, candidate := range response.Candidates { - content, _ := json.Marshal(candidate.Content) choice := dto.OpenAITextResponseChoice{ Index: i, Message: dto.Message{ Role: "assistant", - Content: content, + Content: candidate.Content, }, FinishReason: "stop", } @@ -74,7 +72,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompleti func palmStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, string) { responseText := "" - responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID()) + responseId := helper.GetResponseID(c) createdTime := common.GetTimestamp() dataChan := make(chan string) stopChan := make(chan bool) @@ -157,7 +155,7 @@ func palmHandler(c *gin.Context, resp *http.Response, promptTokens int, model st }, nil } fullTextResponse := responsePaLM2OpenAI(&palmResponse) - completionTokens, _ := service.CountTextToken(palmResponse.Candidates[0].Content, model) + completionTokens := service.CountTextToken(palmResponse.Candidates[0].Content, model) usage := dto.Usage{ PromptTokens: promptTokens, CompletionTokens: completionTokens, diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go new file mode 100644 index 00000000..9ea58728 --- /dev/null +++ b/relay/channel/task/kling/adaptor.go @@ -0,0 +1,312 @@ +package kling + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" + "github.com/pkg/errors" + + "one-api/common" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type SubmitReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type requestPayload struct { + Prompt string `json:"prompt,omitempty"` + Image string `json:"image,omitempty"` + Mode string `json:"mode,omitempty"` + Duration string `json:"duration,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Model string `json:"model,omitempty"` + ModelName string `json:"model_name,omitempty"` + CfgScale float64 `json:"cfg_scale,omitempty"` +} + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"task_id"` + } `json:"data"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + accessKey string + secretKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.BaseUrl + + // apiKey format: "access_key,secret_key" + keyParts := strings.Split(info.ApiKey, ",") + if len(keyParts) == 2 { + a.accessKey = strings.TrimSpace(keyParts[0]) + a.secretKey = strings.TrimSpace(keyParts[1]) + } +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + action := "generate" + info.Action = action + + var req SubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Prompt) == "" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest) + return + } + + // Store into context for later usage + c.Set("kling_request", req) + return nil +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { + return fmt.Sprintf("%s/v1/videos/image2video", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { + token, err := a.createJWTToken() + if err != nil { + return fmt.Errorf("failed to create JWT token: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "kling-sdk/1.0") + return nil +} + +// BuildRequestBody converts request into Kling specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { + v, exists := c.Get("kling_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(SubmitReq) + + body := a.convertToRequestPayload(&req) + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// 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) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + + // Attempt Kling response parse first. + var kResp responsePayload + if err := json.Unmarshal(responseBody, &kResp); err == nil && kResp.Code == 0 { + c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskID}) + return kResp.Data.TaskID, responseBody, nil + } + + // Fallback generic task response. + var generic dto.TaskResponse[string] + if err := json.Unmarshal(responseBody, &generic); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if !generic.IsSuccess() { + taskErr = service.TaskErrorWrapper(fmt.Errorf(generic.Message), generic.Code, http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": generic.Data}) + return generic.Data, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + url := fmt.Sprintf("%s/v1/videos/image2video/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + token, err := a.createJWTTokenWithKey(key) + if err != nil { + token = key + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + req = req.WithContext(ctx) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "kling-sdk/1.0") + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"kling-v1", "kling-v1-6", "kling-v2-master"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "kling" +} + +// ============================ +// helpers +// ============================ + +func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) *requestPayload { + r := &requestPayload{ + Prompt: req.Prompt, + Image: req.Image, + Mode: defaultString(req.Mode, "std"), + Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)), + AspectRatio: a.getAspectRatio(req.Size), + Model: req.Model, + ModelName: req.Model, + CfgScale: 0.5, + } + if r.Model == "" { + r.Model = "kling-v1" + r.ModelName = "kling-v1" + } + return r +} + +func (a *TaskAdaptor) getAspectRatio(size string) string { + switch size { + case "1024x1024", "512x512": + return "1:1" + case "1280x720", "1920x1080": + return "16:9" + case "720x1280", "1080x1920": + return "9:16" + default: + return "1:1" + } +} + +func defaultString(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} + +func defaultInt(v int, def int) int { + if v == 0 { + return def + } + return v +} + +// ============================ +// JWT helpers +// ============================ + +func (a *TaskAdaptor) createJWTToken() (string, error) { + return a.createJWTTokenWithKeys(a.accessKey, a.secretKey) +} + +func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) { + parts := strings.Split(apiKey, ",") + if len(parts) != 2 { + return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'") + } + return a.createJWTTokenWithKeys(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) +} + +func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (string, error) { + if accessKey == "" || secretKey == "" { + return "", fmt.Errorf("access key and secret key are required") + } + now := time.Now().Unix() + claims := jwt.MapClaims{ + "iss": accessKey, + "exp": now + 1800, // 30 minutes + "nbf": now - 5, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + token.Header["typ"] = "JWT" + return token.SignedString([]byte(secretKey)) +} + +// ParseResultUrl 提取视频任务结果的 url +func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) { + data, ok := resp["data"].(map[string]any) + if !ok { + return "", fmt.Errorf("data field not found or invalid") + } + taskResult, ok := data["task_result"].(map[string]any) + if !ok { + return "", fmt.Errorf("task_result field not found or invalid") + } + videos, ok := taskResult["videos"].([]interface{}) + if !ok || len(videos) == 0 { + return "", fmt.Errorf("videos field not found or empty") + } + video, ok := videos[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("video item invalid") + } + url, ok := video["url"].(string) + if !ok || url == "" { + return "", fmt.Errorf("url field not found or invalid") + } + return url, nil +} diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go index 03d60516..f7042348 100644 --- a/relay/channel/task/suno/adaptor.go +++ b/relay/channel/task/suno/adaptor.go @@ -22,6 +22,10 @@ type TaskAdaptor struct { ChannelType int } +func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) { + return "", nil // todo implement this method if needed +} + func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { a.ChannelType = info.ChannelType } diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go index 44718a25..7ea3aae7 100644 --- a/relay/channel/tencent/adaptor.go +++ b/relay/channel/tencent/adaptor.go @@ -98,7 +98,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom if info.IsStream { var responseText string err, responseText = tencentStreamHandler(c, resp) - usage, _ = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) } else { err, usage = tencentHandler(c, resp) } diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go index 5630650f..1446e06e 100644 --- a/relay/channel/tencent/relay-tencent.go +++ b/relay/channel/tencent/relay-tencent.go @@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon }, } if len(response.Choices) > 0 { - content, _ := json.Marshal(response.Choices[0].Messages.Content) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Choices[0].Messages.Content, }, FinishReason: response.Choices[0].FinishReason, } diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 7daf9a61..e568f651 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -12,6 +12,7 @@ import ( "one-api/relay/channel/gemini" "one-api/relay/channel/openai" relaycommon "one-api/relay/common" + "one-api/relay/constant" "one-api/setting/model_setting" "strings" @@ -31,6 +32,8 @@ var claudeModelMap = map[string]string{ "claude-3-5-sonnet-20240620": "claude-3-5-sonnet@20240620", "claude-3-5-sonnet-20241022": "claude-3-5-sonnet-v2@20241022", "claude-3-7-sonnet-20250219": "claude-3-7-sonnet@20250219", + "claude-sonnet-4-20250514": "claude-sonnet-4@20250514", + "claude-opus-4-20250514": "claude-opus-4@20250514", } const anthropicVersion = "vertex-2023-10-16" @@ -80,10 +83,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { suffix := "" if a.RequestMode == RequestModeGemini { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { - // suffix -thinking and -nothinking - if strings.HasSuffix(info.OriginModelName, "-thinking") { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.UpstreamModelName, "-thinking-") { + parts := strings.Split(info.UpstreamModelName, "-thinking-") + info.UpstreamModelName = parts[0] + } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配 info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") - } else if strings.HasSuffix(info.OriginModelName, "-nothinking") { + } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") } } @@ -93,14 +99,23 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } else { suffix = "generateContent" } - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", - region, - adc.ProjectID, - region, - info.UpstreamModelName, - suffix, - ), nil + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + info.UpstreamModelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + info.UpstreamModelName, + suffix, + ), nil + } } else if a.RequestMode == RequestModeClaude { if info.IsStream { suffix = "streamRawPredict?alt=sse" @@ -111,14 +126,23 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if v, ok := claudeModelMap[info.UpstreamModelName]; ok { model = v } - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", - region, - adc.ProjectID, - region, - model, - suffix, - ), nil + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", + adc.ProjectID, + model, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", + region, + adc.ProjectID, + region, + model, + suffix, + ), nil + } } else if a.RequestMode == RequestModeLlama { return fmt.Sprintf( "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", @@ -190,7 +214,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom case RequestModeClaude: err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) case RequestModeGemini: - err, usage = gemini.GeminiChatStreamHandler(c, resp, info) + if info.RelayMode == constant.RelayModeGemini { + usage, err = gemini.GeminiTextGenerationStreamHandler(c, resp, info) + } else { + err, usage = gemini.GeminiChatStreamHandler(c, resp, info) + } case RequestModeLlama: err, usage = openai.OaiStreamHandler(c, resp, info) } @@ -199,7 +227,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom case RequestModeClaude: err, usage = claude.ClaudeHandler(c, resp, claude.RequestModeMessage, info) case RequestModeGemini: - err, usage = gemini.GeminiChatHandler(c, resp, info) + if info.RelayMode == constant.RelayModeGemini { + usage, err = gemini.GeminiTextGenerationHandler(c, resp, info) + } else { + err, usage = gemini.GeminiChatHandler(c, resp, info) + } case RequestModeLlama: err, usage = openai.OpenaiHandler(c, resp, info) } diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index a4a48ee9..78233934 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -1,15 +1,19 @@ package volcengine import ( + "bytes" "errors" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "one-api/dto" "one-api/relay/channel" "one-api/relay/channel/openai" relaycommon "one-api/relay/common" "one-api/relay/constant" + "path/filepath" "strings" "github.com/gin-gonic/gin" @@ -30,8 +34,146 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf } func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { - //TODO implement me - return nil, errors.New("not implemented") + switch info.RelayMode { + case constant.RelayModeImagesEdits: + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writer.WriteField("model", request.Model) + // 获取所有表单字段 + formData := c.Request.PostForm + // 遍历表单字段并打印输出 + for key, values := range formData { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } + } + + // Parse the multipart form to handle both single image and multiple images + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory + return nil, errors.New("failed to parse multipart form") + } + + if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil { + // Check if "image" field exists in any form, including array notation + var imageFiles []*multipart.FileHeader + var exists bool + + // First check for standard "image" field + if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 { + // If still not found, iterate through all fields to find any that start with "image[" + foundArrayImages := false + for fieldName, files := range c.Request.MultipartForm.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + for _, file := range files { + imageFiles = append(imageFiles, file) + } + } + } + + // If no image fields found at all + if !foundArrayImages && (len(imageFiles) == 0) { + return nil, errors.New("image is required") + } + } + } + + // Process all image files + for i, fileHeader := range imageFiles { + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + } + defer file.Close() + + // If multiple images, use image[] as the field name + fieldName := "image" + if len(imageFiles) > 1 { + fieldName = "image[]" + } + + // Determine MIME type based on file extension + mimeType := detectImageMimeType(fileHeader.Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) + h.Set("Content-Type", mimeType) + + part, err := writer.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) + } + + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) + } + } + + // Handle mask file if present + if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 { + maskFile, err := maskFiles[0].Open() + if err != nil { + return nil, errors.New("failed to open mask file") + } + defer maskFile.Close() + + // Determine MIME type for mask file + mimeType := detectImageMimeType(maskFiles[0].Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename)) + h.Set("Content-Type", mimeType) + + maskPart, err := writer.CreatePart(h) + if err != nil { + return nil, errors.New("create form file failed for mask") + } + + if _, err := io.Copy(maskPart, maskFile); err != nil { + return nil, errors.New("copy mask file failed") + } + } + } else { + return nil, errors.New("no multipart form data found") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + return bytes.NewReader(requestBody.Bytes()), nil + + default: + return request, nil + } +} + +// detectImageMimeType determines the MIME type based on the file extension +func detectImageMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + default: + // Try to detect from extension if possible + if strings.HasPrefix(ext, ".jp") { + return "image/jpeg" + } + // Default to png as a fallback + return "image/png" + } } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { @@ -46,6 +188,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return fmt.Sprintf("%s/api/v3/chat/completions", info.BaseUrl), nil case constant.RelayModeEmbeddings: return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil default: } return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) @@ -91,6 +235,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom } case constant.RelayModeEmbeddings: err, usage = openai.OpenaiHandler(c, resp, info) + case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: + err, usage = openai.OpenaiHandlerWithUsage(c, resp, info) } return } diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go index e019c2dc..408160fb 100644 --- a/relay/channel/xai/text.go +++ b/relay/channel/xai/text.go @@ -68,7 +68,7 @@ func xAIStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel }) if !containStreamUsage { - usage, _ = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) + usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) usage.CompletionTokens += toolCount * 7 } diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go index 15d33510..c6ef722c 100644 --- a/relay/channel/xunfei/relay-xunfei.go +++ b/relay/channel/xunfei/relay-xunfei.go @@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse }, } } - content, _ := json.Marshal(response.Payload.Choices.Text[0].Content) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Payload.Choices.Text[0].Content, }, FinishReason: constant.FinishReasonStop, } diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go index b0cac858..744538e3 100644 --- a/relay/channel/zhipu/relay-zhipu.go +++ b/relay/channel/zhipu/relay-zhipu.go @@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse { Usage: response.Data.Usage, } for i, choice := range response.Data.Choices { - content, _ := json.Marshal(strings.Trim(choice.Content, "\"")) openaiChoice := dto.OpenAITextResponseChoice{ Index: i, Message: dto.Message{ Role: choice.Role, - Content: content, + Content: strings.Trim(choice.Content, "\""), }, FinishReason: "", } diff --git a/relay/claude_handler.go b/relay/claude_handler.go index fb68a88a..42139ddf 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -46,13 +46,11 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) { relayInfo.IsStream = true } - err = helper.ModelMappedHelper(c, relayInfo) + err = helper.ModelMappedHelper(c, relayInfo, textRequest) if err != nil { return service.ClaudeErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError) } - textRequest.Model = relayInfo.UpstreamModelName - promptTokens, err := getClaudePromptTokens(textRequest, relayInfo) // count messages token error 计算promptTokens错误 if err != nil { @@ -98,7 +96,7 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) { // BudgetTokens 为 max_tokens 的 80% textRequest.Thinking = &dto.Thinking{ Type: "enabled", - BudgetTokens: int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage), + BudgetTokens: common.GetPointer[int](int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), } // TODO: 临时处理 // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking @@ -126,7 +124,7 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) { var httpResp *http.Response resp, err := adaptor.DoRequest(c, relayInfo, requestBody) if err != nil { - return service.ClaudeErrorWrapperLocal(err, "do_request_failed", http.StatusInternalServerError) + return service.ClaudeErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) } if resp != nil { diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f4fc3c1e..3759c363 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -34,9 +34,14 @@ type ClaudeConvertInfo struct { } const ( - RelayFormatOpenAI = "openai" - RelayFormatClaude = "claude" - RelayFormatGemini = "gemini" + RelayFormatOpenAI = "openai" + RelayFormatClaude = "claude" + RelayFormatGemini = "gemini" + RelayFormatOpenAIResponses = "openai_responses" + RelayFormatOpenAIAudio = "openai_audio" + RelayFormatOpenAIImage = "openai_image" + RelayFormatRerank = "rerank" + RelayFormatEmbedding = "embedding" ) type RerankerInfo struct { @@ -61,6 +66,7 @@ type RelayInfo struct { TokenKey string UserId int Group string + UserGroup string TokenUnlimited bool StartTime time.Time FirstResponseTime time.Time @@ -142,6 +148,7 @@ func GenRelayInfoClaude(c *gin.Context) *RelayInfo { func GenRelayInfoRerank(c *gin.Context, req *dto.RerankRequest) *RelayInfo { info := GenRelayInfo(c) info.RelayMode = relayconstant.RelayModeRerank + info.RelayFormat = RelayFormatRerank info.RerankerInfo = &RerankerInfo{ Documents: req.Documents, ReturnDocuments: req.GetReturnDocuments(), @@ -149,9 +156,25 @@ func GenRelayInfoRerank(c *gin.Context, req *dto.RerankRequest) *RelayInfo { return info } +func GenRelayInfoOpenAIAudio(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatOpenAIAudio + return info +} + +func GenRelayInfoEmbedding(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatEmbedding + return info +} + func GenRelayInfoResponses(c *gin.Context, req *dto.OpenAIResponsesRequest) *RelayInfo { info := GenRelayInfo(c) info.RelayMode = relayconstant.RelayModeResponses + info.RelayFormat = RelayFormatOpenAIResponses + + info.SupportStreamOptions = false + info.ResponsesUsageInfo = &ResponsesUsageInfo{ BuiltInTools: make(map[string]*BuildInToolInfo), } @@ -174,6 +197,19 @@ func GenRelayInfoResponses(c *gin.Context, req *dto.OpenAIResponsesRequest) *Rel return info } +func GenRelayInfoGemini(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatGemini + info.ShouldIncludeUsage = false + return info +} + +func GenRelayInfoImage(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatOpenAIImage + return info +} + func GenRelayInfo(c *gin.Context) *RelayInfo { channelType := c.GetInt("channel_type") channelId := c.GetInt("channel_id") @@ -204,6 +240,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo { TokenKey: tokenKey, UserId: userId, Group: group, + UserGroup: c.GetString(constant.ContextKeyUserGroup), TokenUnlimited: tokenUnlimited, StartTime: startTime, FirstResponseTime: startTime.Add(-time.Second), @@ -241,10 +278,6 @@ func GenRelayInfo(c *gin.Context) *RelayInfo { if streamSupportedChannels[info.ChannelType] { info.SupportStreamOptions = true } - // responses 模式不支持 StreamOptions - if relayconstant.RelayModeResponses == info.RelayMode { - info.SupportStreamOptions = false - } return info } diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 4454e815..02a286e2 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -38,11 +38,16 @@ const ( RelayModeSunoFetchByID RelayModeSunoSubmit + RelayModeKlingFetchByID + RelayModeKlingSubmit + RelayModeRerank RelayModeResponses RelayModeRealtime + + RelayModeGemini ) func Path2RelayMode(path string) int { @@ -75,6 +80,8 @@ func Path2RelayMode(path string) int { relayMode = RelayModeRerank } else if strings.HasPrefix(path, "/v1/realtime") { relayMode = RelayModeRealtime + } else if strings.HasPrefix(path, "/v1beta/models") { + relayMode = RelayModeGemini } return relayMode } @@ -129,3 +136,13 @@ func Path2RelaySuno(method, path string) int { } return relayMode } + +func Path2RelayKling(method, path string) int { + relayMode := RelayModeUnknown + if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") { + relayMode = RelayModeKlingSubmit + } else if method == http.MethodGet && strings.Contains(path, "/video/generations/") { + relayMode = RelayModeKlingFetchByID + } + return relayMode +} diff --git a/relay/relay_embedding.go b/relay/embedding_handler.go similarity index 94% rename from relay/relay_embedding.go rename to relay/embedding_handler.go index b4909849..849c70da 100644 --- a/relay/relay_embedding.go +++ b/relay/embedding_handler.go @@ -15,7 +15,7 @@ import ( ) func getEmbeddingPromptToken(embeddingRequest dto.EmbeddingRequest) int { - token, _ := service.CountTokenInput(embeddingRequest.Input, embeddingRequest.Model) + token := service.CountTokenInput(embeddingRequest.Input, embeddingRequest.Model) return token } @@ -33,7 +33,7 @@ func validateEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, embed } func EmbeddingHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { - relayInfo := relaycommon.GenRelayInfo(c) + relayInfo := relaycommon.GenRelayInfoEmbedding(c) var embeddingRequest *dto.EmbeddingRequest err := common.UnmarshalBodyReusable(c, &embeddingRequest) @@ -47,13 +47,11 @@ func EmbeddingHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) return service.OpenAIErrorWrapperLocal(err, "invalid_embedding_request", http.StatusBadRequest) } - err = helper.ModelMappedHelper(c, relayInfo) + err = helper.ModelMappedHelper(c, relayInfo, embeddingRequest) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError) } - embeddingRequest.Model = relayInfo.UpstreamModelName - promptToken := getEmbeddingPromptToken(*embeddingRequest) relayInfo.PromptTokens = promptToken diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go new file mode 100644 index 00000000..14d58cc5 --- /dev/null +++ b/relay/gemini_handler.go @@ -0,0 +1,190 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/channel/gemini" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/setting" + "strings" + + "github.com/gin-gonic/gin" +) + +func getAndValidateGeminiRequest(c *gin.Context) (*gemini.GeminiChatRequest, error) { + request := &gemini.GeminiChatRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + if len(request.Contents) == 0 { + return nil, errors.New("contents is required") + } + return request, nil +} + +// 流模式 +// /v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=xxx +func checkGeminiStreamMode(c *gin.Context, relayInfo *relaycommon.RelayInfo) { + if c.Query("alt") == "sse" { + relayInfo.IsStream = true + } + + // if strings.Contains(c.Request.URL.Path, "streamGenerateContent") { + // relayInfo.IsStream = true + // } +} + +func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, error) { + var inputTexts []string + for _, content := range textRequest.Contents { + for _, part := range content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + if len(inputTexts) == 0 { + return nil, nil + } + + sensitiveWords, err := service.CheckSensitiveInput(inputTexts) + return sensitiveWords, err +} + +func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) int { + // 计算输入 token 数量 + var inputTexts []string + for _, content := range req.Contents { + for _, part := range content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + + inputText := strings.Join(inputTexts, "\n") + inputTokens := service.CountTokenInput(inputText, info.UpstreamModelName) + info.PromptTokens = inputTokens + return inputTokens +} + +func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { + req, err := getAndValidateGeminiRequest(c) + if err != nil { + common.LogError(c, fmt.Sprintf("getAndValidateGeminiRequest error: %s", err.Error())) + return service.OpenAIErrorWrapperLocal(err, "invalid_gemini_request", http.StatusBadRequest) + } + + relayInfo := relaycommon.GenRelayInfoGemini(c) + + // 检查 Gemini 流式模式 + checkGeminiStreamMode(c, relayInfo) + + if setting.ShouldCheckPromptSensitive() { + sensitiveWords, err := checkGeminiInputSensitive(req) + if err != nil { + common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", "))) + return service.OpenAIErrorWrapperLocal(err, "check_request_sensitive_error", http.StatusBadRequest) + } + } + + // model mapped 模型映射 + err = helper.ModelMappedHelper(c, relayInfo, req) + if err != nil { + return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusBadRequest) + } + + if value, exists := c.Get("prompt_tokens"); exists { + promptTokens := value.(int) + relayInfo.SetPromptTokens(promptTokens) + } else { + promptTokens := getGeminiInputTokens(req, relayInfo) + if err != nil { + return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest) + } + c.Set("prompt_tokens", promptTokens) + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens)) + if err != nil { + return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError) + } + + // pre consume quota + preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if openaiErr != nil { + return openaiErr + } + defer func() { + if openaiErr != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return service.OpenAIErrorWrapperLocal(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), "invalid_api_type", http.StatusBadRequest) + } + + adaptor.Init(relayInfo) + + // Clean up empty system instruction + if req.SystemInstructions != nil { + hasContent := false + for _, part := range req.SystemInstructions.Parts { + if part.Text != "" { + hasContent = true + break + } + } + if !hasContent { + req.SystemInstructions = nil + } + } + + requestBody, err := json.Marshal(req) + if err != nil { + return service.OpenAIErrorWrapperLocal(err, "marshal_text_request_failed", http.StatusInternalServerError) + } + + if common.DebugEnabled { + println("Gemini request body: %s", string(requestBody)) + } + + resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody)) + if err != nil { + common.LogError(c, "Do gemini request failed: "+err.Error()) + return service.OpenAIErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + relayInfo.IsStream = relayInfo.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + openaiErr = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(openaiErr, statusCodeMappingStr) + return openaiErr + } + } + + usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), relayInfo) + if openaiErr != nil { + service.ResetStatusCode(openaiErr, statusCodeMappingStr) + return openaiErr + } + + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + return nil +} diff --git a/relay/helper/model_mapped.go b/relay/helper/model_mapped.go index 9bf67c03..c1735149 100644 --- a/relay/helper/model_mapped.go +++ b/relay/helper/model_mapped.go @@ -4,12 +4,14 @@ import ( "encoding/json" "errors" "fmt" + common2 "one-api/common" + "one-api/dto" "one-api/relay/common" "github.com/gin-gonic/gin" ) -func ModelMappedHelper(c *gin.Context, info *common.RelayInfo) error { +func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request any) error { // map model name modelMapping := c.GetString("model_mapping") if modelMapping != "" && modelMapping != "{}" { @@ -50,5 +52,41 @@ func ModelMappedHelper(c *gin.Context, info *common.RelayInfo) error { info.UpstreamModelName = currentModel } } + if request != nil { + switch info.RelayFormat { + case common.RelayFormatGemini: + // Gemini 模型映射 + case common.RelayFormatClaude: + if claudeRequest, ok := request.(*dto.ClaudeRequest); ok { + claudeRequest.Model = info.UpstreamModelName + } + case common.RelayFormatOpenAIResponses: + if openAIResponsesRequest, ok := request.(*dto.OpenAIResponsesRequest); ok { + openAIResponsesRequest.Model = info.UpstreamModelName + } + case common.RelayFormatOpenAIAudio: + if openAIAudioRequest, ok := request.(*dto.AudioRequest); ok { + openAIAudioRequest.Model = info.UpstreamModelName + } + case common.RelayFormatOpenAIImage: + if imageRequest, ok := request.(*dto.ImageRequest); ok { + imageRequest.Model = info.UpstreamModelName + } + case common.RelayFormatRerank: + if rerankRequest, ok := request.(*dto.RerankRequest); ok { + rerankRequest.Model = info.UpstreamModelName + } + case common.RelayFormatEmbedding: + if embeddingRequest, ok := request.(*dto.EmbeddingRequest); ok { + embeddingRequest.Model = info.UpstreamModelName + } + default: + if openAIRequest, ok := request.(*dto.GeneralOpenAIRequest); ok { + openAIRequest.Model = info.UpstreamModelName + } else { + common2.LogWarn(c, fmt.Sprintf("model mapped but request type %T not supported", request)) + } + } + } return nil } diff --git a/relay/helper/price.go b/relay/helper/price.go index 89efa1da..1ee2767e 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -2,14 +2,19 @@ package helper import ( "fmt" - "github.com/gin-gonic/gin" "one-api/common" constant2 "one-api/constant" relaycommon "one-api/relay/common" - "one-api/setting" - "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" ) +type GroupRatioInfo struct { + GroupRatio float64 + GroupSpecialRatio float64 +} + type PriceData struct { ModelPrice float64 ModelRatio float64 @@ -17,18 +22,50 @@ type PriceData struct { CacheRatio float64 CacheCreationRatio float64 ImageRatio float64 - GroupRatio float64 UsePrice bool ShouldPreConsumedQuota int + GroupRatioInfo GroupRatioInfo } func (p PriceData) ToSetting() string { - return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio) + return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio) +} + +// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.Group if present +func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupRatioInfo { + groupRatioInfo := GroupRatioInfo{ + GroupRatio: 1.0, // default ratio + GroupSpecialRatio: -1, + } + + // check auto group + autoGroup, exists := ctx.Get("auto_group") + if exists { + if common.DebugEnabled { + println(fmt.Sprintf("final group: %s", autoGroup)) + } + relayInfo.Group = autoGroup.(string) + } + + // check user group special ratio + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group) + if ok { + // user group special ratio + groupRatioInfo.GroupSpecialRatio = userGroupRatio + groupRatioInfo.GroupRatio = userGroupRatio + } else { + // normal group ratio + groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.Group) + } + + return groupRatioInfo } func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, maxTokens int) (PriceData, error) { - modelPrice, usePrice := operation_setting.GetModelPrice(info.OriginModelName, false) - groupRatio := setting.GetGroupRatio(info.Group) + modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false) + + groupRatioInfo := HandleGroupRatio(c, info) + var preConsumedQuota int var modelRatio float64 var completionRatio float64 @@ -41,7 +78,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens preConsumedTokens = promptTokens + maxTokens } var success bool - modelRatio, success = operation_setting.GetModelRatio(info.OriginModelName) + modelRatio, success = ratio_setting.GetModelRatio(info.OriginModelName) if !success { acceptUnsetRatio := false if accept, ok := info.UserSetting[constant2.UserAcceptUnsetRatioModel]; ok { @@ -54,21 +91,21 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens return PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", info.OriginModelName, info.OriginModelName) } } - completionRatio = operation_setting.GetCompletionRatio(info.OriginModelName) - cacheRatio, _ = operation_setting.GetCacheRatio(info.OriginModelName) - cacheCreationRatio, _ = operation_setting.GetCreateCacheRatio(info.OriginModelName) - imageRatio, _ = operation_setting.GetImageRatio(info.OriginModelName) - ratio := modelRatio * groupRatio + completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) + cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) + cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) + imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) + ratio := modelRatio * groupRatioInfo.GroupRatio preConsumedQuota = int(float64(preConsumedTokens) * ratio) } else { - preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatio) + preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) } priceData := PriceData{ ModelPrice: modelPrice, ModelRatio: modelRatio, CompletionRatio: completionRatio, - GroupRatio: groupRatio, + GroupRatioInfo: groupRatioInfo, UsePrice: usePrice, CacheRatio: cacheRatio, ImageRatio: imageRatio, @@ -84,11 +121,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } func ContainPriceOrRatio(modelName string) bool { - _, ok := operation_setting.GetModelPrice(modelName, false) + _, ok := ratio_setting.GetModelPrice(modelName, false) if ok { return true } - _, ok = operation_setting.GetModelRatio(modelName) + _, ok = ratio_setting.GetModelRatio(modelName) if ok { return true } diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index c1bc0d6e..a69877e2 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -3,6 +3,7 @@ package helper import ( "bufio" "context" + "fmt" "io" "net/http" "one-api/common" @@ -19,8 +20,8 @@ import ( ) const ( - InitialScannerBufferSize = 1 << 20 // 1MB (1*1024*1024) - MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024) + InitialScannerBufferSize = 64 << 10 // 64KB (64*1024) + MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024) DefaultPingInterval = 10 * time.Second ) @@ -30,7 +31,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon return } - defer resp.Body.Close() + // 确保响应体总是被关闭 + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second if strings.HasPrefix(info.UpstreamModelName, "o") { @@ -39,11 +45,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon } var ( - stopChan = make(chan bool, 2) + stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞 scanner = bufio.NewScanner(resp.Body) ticker = time.NewTicker(streamingTimeout) pingTicker *time.Ticker writeMutex sync.Mutex // Mutex to protect concurrent writes + wg sync.WaitGroup // 用于等待所有 goroutine 退出 ) generalSettings := operation_setting.GetGeneralSetting() @@ -57,13 +64,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon pingTicker = time.NewTicker(pingInterval) } + // 改进资源清理,确保所有 goroutine 正确退出 defer func() { + // 通知所有 goroutine 停止 + common.SafeSendBool(stopChan, true) + ticker.Stop() if pingTicker != nil { pingTicker.Stop() } + + // 等待所有 goroutine 退出,最多等待5秒 + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + common.LogError(c, "timeout waiting for goroutines to exit") + } + close(stopChan) }() + scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize) scanner.Split(bufio.ScanLines) SetEventStreamHeaders(c) @@ -73,35 +99,95 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ctx = context.WithValue(ctx, "stop_chan", stopChan) - // Handle ping data sending + // Handle ping data sending with improved error handling if pingEnabled && pingTicker != nil { + wg.Add(1) gopool.Go(func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + common.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r)) + common.SafeSendBool(stopChan, true) + } + if common.DebugEnabled { + println("ping goroutine exited") + } + }() + + // 添加超时保护,防止 goroutine 无限运行 + maxPingDuration := 30 * time.Minute // 最大 ping 持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + for { select { case <-pingTicker.C: - writeMutex.Lock() // Lock before writing - err := PingData(c) - writeMutex.Unlock() // Unlock after writing - if err != nil { - common.LogError(c, "ping data error: "+err.Error()) - common.SafeSendBool(stopChan, true) + // 使用超时机制防止写操作阻塞 + done := make(chan error, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- PingData(c) + }() + + select { + case err := <-done: + if err != nil { + common.LogError(c, "ping data error: "+err.Error()) + return + } + if common.DebugEnabled { + println("ping data sent") + } + case <-time.After(10 * time.Second): + common.LogError(c, "ping data send timeout") + return + case <-ctx.Done(): + return + case <-stopChan: return } - if common.DebugEnabled { - println("ping data sent") - } case <-ctx.Done(): - if common.DebugEnabled { - println("ping data goroutine stopped") - } + return + case <-stopChan: + return + case <-c.Request.Context().Done(): + // 监听客户端断开连接 + return + case <-pingTimeout.C: + common.LogError(c, "ping goroutine max duration reached") return } } }) } + // Scanner goroutine with improved error handling + wg.Add(1) common.RelayCtxGo(ctx, func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + common.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r)) + } + common.SafeSendBool(stopChan, true) + if common.DebugEnabled { + println("scanner goroutine exited") + } + }() + for scanner.Scan() { + // 检查是否需要停止 + select { + case <-stopChan: + return + case <-ctx.Done(): + return + case <-c.Request.Context().Done(): + return + default: + } + ticker.Reset(streamingTimeout) data := scanner.Text() if common.DebugEnabled { @@ -119,11 +205,27 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon data = strings.TrimSuffix(data, "\r") if !strings.HasPrefix(data, "[DONE]") { info.SetFirstResponseTime() - writeMutex.Lock() // Lock before writing - success := dataHandler(data) - writeMutex.Unlock() // Unlock after writing - if !success { - break + + // 使用超时机制防止写操作阻塞 + done := make(chan bool, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- dataHandler(data) + }() + + select { + case success := <-done: + if !success { + return + } + case <-time.After(10 * time.Second): + common.LogError(c, "data handler timeout") + return + case <-ctx.Done(): + return + case <-stopChan: + return } } } @@ -133,17 +235,18 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon common.LogError(c, "scanner error: "+err.Error()) } } - - common.SafeSendBool(stopChan, true) }) + // 主循环等待完成或超时 select { case <-ticker.C: // 超时处理逻辑 common.LogError(c, "streaming timeout") - common.SafeSendBool(stopChan, true) case <-stopChan: // 正常结束 common.LogInfo(c, "streaming finished") + case <-c.Request.Context().Done(): + // 客户端断开连接 + common.LogInfo(c, "client disconnected") } } diff --git a/relay/relay-image.go b/relay/image_handler.go similarity index 71% rename from relay/relay-image.go rename to relay/image_handler.go index daed3d80..15a42e79 100644 --- a/relay/relay-image.go +++ b/relay/image_handler.go @@ -17,6 +17,8 @@ import ( "one-api/setting" "strings" + "one-api/relay/constant" + "github.com/gin-gonic/gin" ) @@ -41,16 +43,36 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto. imageRequest.Quality = "standard" } } + if imageRequest.N == 0 { + imageRequest.N = 1 + } + + if info.ApiType == constant.APITypeVolcEngine { + watermark := formData.Has("watermark") + imageRequest.Watermark = &watermark + } default: err := common.UnmarshalBodyReusable(c, imageRequest) if err != nil { return nil, err } + + if imageRequest.Model == "" { + imageRequest.Model = "dall-e-3" + } + + if strings.Contains(imageRequest.Size, "×") { + return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") + } + // Not "256x256", "512x512", or "1024x1024" if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" { if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" { return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e") } + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } } else if imageRequest.Model == "dall-e-3" { if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" { return nil, errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3") @@ -58,74 +80,24 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto. if imageRequest.Quality == "" { imageRequest.Quality = "standard" } - // N should between 1 and 10 - //if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) { - // return service.OpenAIErrorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", http.StatusBadRequest) - //} + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } + } else if imageRequest.Model == "gpt-image-1" { + if imageRequest.Quality == "" { + imageRequest.Quality = "auto" + } + } + + if imageRequest.Prompt == "" { + return nil, errors.New("prompt is required") + } + + if imageRequest.N == 0 { + imageRequest.N = 1 } } - if imageRequest.Prompt == "" { - return nil, errors.New("prompt is required") - } - - if imageRequest.Model == "" { - imageRequest.Model = "dall-e-2" - } - if strings.Contains(imageRequest.Size, "×") { - return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") - } - if imageRequest.N == 0 { - imageRequest.N = 1 - } - if imageRequest.Size == "" { - imageRequest.Size = "1024x1024" - } - - err := common.UnmarshalBodyReusable(c, imageRequest) - if err != nil { - return nil, err - } - if imageRequest.Prompt == "" { - return nil, errors.New("prompt is required") - } - if strings.Contains(imageRequest.Size, "×") { - return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") - } - if imageRequest.N == 0 { - imageRequest.N = 1 - } - if imageRequest.Size == "" { - imageRequest.Size = "1024x1024" - } - if imageRequest.Model == "" { - imageRequest.Model = "dall-e-2" - } - // x.ai grok-2-image not support size, quality or style - if imageRequest.Size == "empty" { - imageRequest.Size = "" - } - - // Not "256x256", "512x512", or "1024x1024" - if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" { - if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" { - return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024, dall-e-3 1024x1792 or 1792x1024") - } - } else if imageRequest.Model == "dall-e-3" { - if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" { - return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024, dall-e-3 1024x1792 or 1792x1024") - } - if imageRequest.Quality == "" { - imageRequest.Quality = "standard" - } - //if imageRequest.N != 1 { - // return nil, errors.New("n must be 1") - //} - } - // N should between 1 and 10 - //if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) { - // return service.OpenAIErrorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", http.StatusBadRequest) - //} if setting.ShouldCheckPromptSensitive() { words, err := service.CheckSensitiveInput(imageRequest.Prompt) if err != nil { @@ -137,7 +109,7 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto. } func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode { - relayInfo := relaycommon.GenRelayInfo(c) + relayInfo := relaycommon.GenRelayInfoImage(c) imageRequest, err := getAndValidImageRequest(c, relayInfo) if err != nil { @@ -145,13 +117,11 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode { return service.OpenAIErrorWrapper(err, "invalid_image_request", http.StatusBadRequest) } - err = helper.ModelMappedHelper(c, relayInfo) + err = helper.ModelMappedHelper(c, relayInfo, imageRequest) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError) } - imageRequest.Model = relayInfo.UpstreamModelName - priceData, err := helper.ModelPriceHelper(c, relayInfo, len(imageRequest.Prompt), 0) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError) @@ -197,7 +167,7 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode { // reset model price priceData.ModelPrice *= sizeRatio * qualityRatio * float64(imageRequest.N) - quota = int(priceData.ModelPrice * priceData.GroupRatio * common.QuotaPerUnit) + quota = int(priceData.ModelPrice * priceData.GroupRatioInfo.GroupRatio * common.QuotaPerUnit) userQuota, err = model.GetUserQuota(relayInfo.UserId, false) if err != nil { return service.OpenAIErrorWrapperLocal(err, "get_user_quota_failed", http.StatusInternalServerError) @@ -229,6 +199,10 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode { requestBody = bytes.NewBuffer(jsonData) } + if common.DebugEnabled { + println(fmt.Sprintf("image request body: %s", requestBody)) + } + statusCodeMappingStr := c.GetString("status_code_mapping") resp, err := adaptor.DoRequest(c, relayInfo, requestBody) diff --git a/relay/relay-mj.go b/relay/relay-mj.go index 9d0a2077..ce4346b6 100644 --- a/relay/relay-mj.go +++ b/relay/relay-mj.go @@ -15,7 +15,7 @@ import ( relayconstant "one-api/relay/constant" "one-api/service" "one-api/setting" - "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" "strconv" "strings" "time" @@ -174,17 +174,17 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse { return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required") } modelName := service.CoverActionToModelName(constant.MjActionSwapFace) - modelPrice, success := operation_setting.GetModelPrice(modelName, true) + modelPrice, success := ratio_setting.GetModelPrice(modelName, true) // 如果没有配置价格,则使用默认价格 if !success { - defaultPrice, ok := operation_setting.GetDefaultModelRatioMap()[modelName] + defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName] if !ok { modelPrice = 0.1 } else { modelPrice = defaultPrice } } - groupRatio := setting.GetGroupRatio(group) + groupRatio := ratio_setting.GetGroupRatio(group) ratio := modelPrice * groupRatio userQuota, err := model.GetUserQuota(userId, false) if err != nil { @@ -480,17 +480,17 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) modelName := service.CoverActionToModelName(midjRequest.Action) - modelPrice, success := operation_setting.GetModelPrice(modelName, true) + modelPrice, success := ratio_setting.GetModelPrice(modelName, true) // 如果没有配置价格,则使用默认价格 if !success { - defaultPrice, ok := operation_setting.GetDefaultModelRatioMap()[modelName] + defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName] if !ok { modelPrice = 0.1 } else { modelPrice = defaultPrice } } - groupRatio := setting.GetGroupRatio(group) + groupRatio := ratio_setting.GetGroupRatio(group) ratio := modelPrice * groupRatio userQuota, err := model.GetUserQuota(userId, false) if err != nil { diff --git a/relay/relay-text.go b/relay/relay-text.go index 8d5cd384..db8d0d3b 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -47,6 +47,20 @@ func getAndValidateTextRequest(c *gin.Context, relayInfo *relaycommon.RelayInfo) if textRequest.Model == "" { return nil, errors.New("model is required") } + if textRequest.WebSearchOptions != nil { + if textRequest.WebSearchOptions.SearchContextSize != "" { + validSizes := map[string]bool{ + "high": true, + "medium": true, + "low": true, + } + if !validSizes[textRequest.WebSearchOptions.SearchContextSize] { + return nil, errors.New("invalid search_context_size, must be one of: high, medium, low") + } + } else { + textRequest.WebSearchOptions.SearchContextSize = "medium" + } + } switch relayInfo.RelayMode { case relayconstant.RelayModeCompletions: if textRequest.Prompt == "" { @@ -76,11 +90,16 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { // get & validate textRequest 获取并验证文本请求 textRequest, err := getAndValidateTextRequest(c, relayInfo) + if err != nil { common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) return service.OpenAIErrorWrapperLocal(err, "invalid_text_request", http.StatusBadRequest) } + if textRequest.WebSearchOptions != nil { + c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize) + } + if setting.ShouldCheckPromptSensitive() { words, err := checkRequestSensitive(textRequest, relayInfo) if err != nil { @@ -89,13 +108,11 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { } } - err = helper.ModelMappedHelper(c, relayInfo) + err = helper.ModelMappedHelper(c, relayInfo, textRequest) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError) } - textRequest.Model = relayInfo.UpstreamModelName - // 获取 promptTokens,如果上下文中已经存在,则直接使用 var promptTokens int if value, exists := c.Get("prompt_tokens"); exists { @@ -234,11 +251,11 @@ func getPromptTokens(textRequest *dto.GeneralOpenAIRequest, info *relaycommon.Re case relayconstant.RelayModeChatCompletions: promptTokens, err = service.CountTokenChatRequest(info, *textRequest) case relayconstant.RelayModeCompletions: - promptTokens, err = service.CountTokenInput(textRequest.Prompt, textRequest.Model) + promptTokens = service.CountTokenInput(textRequest.Prompt, textRequest.Model) case relayconstant.RelayModeModerations: - promptTokens, err = service.CountTokenInput(textRequest.Input, textRequest.Model) + promptTokens = service.CountTokenInput(textRequest.Input, textRequest.Model) case relayconstant.RelayModeEmbeddings: - promptTokens, err = service.CountTokenInput(textRequest.Input, textRequest.Model) + promptTokens = service.CountTokenInput(textRequest.Input, textRequest.Model) default: err = errors.New("unknown relay mode") promptTokens = 0 @@ -334,6 +351,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, promptTokens := usage.PromptTokens cacheTokens := usage.PromptTokensDetails.CachedTokens imageTokens := usage.PromptTokensDetails.ImageTokens + audioTokens := usage.PromptTokensDetails.AudioTokens completionTokens := usage.CompletionTokens modelName := relayInfo.OriginModelName @@ -342,13 +360,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, cacheRatio := priceData.CacheRatio imageRatio := priceData.ImageRatio modelRatio := priceData.ModelRatio - groupRatio := priceData.GroupRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio modelPrice := priceData.ModelPrice // Convert values to decimal for precise calculation dPromptTokens := decimal.NewFromInt(int64(promptTokens)) dCacheTokens := decimal.NewFromInt(int64(cacheTokens)) dImageTokens := decimal.NewFromInt(int64(imageTokens)) + dAudioTokens := decimal.NewFromInt(int64(audioTokens)) dCompletionTokens := decimal.NewFromInt(int64(completionTokens)) dCompletionRatio := decimal.NewFromFloat(completionRatio) dCacheRatio := decimal.NewFromFloat(cacheRatio) @@ -370,9 +389,20 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))). Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 $%s", + extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 %s", webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String()) } + } else if strings.HasSuffix(modelName, "search-preview") { + // search-preview 模型不支持 response api + searchContextSize := ctx.GetString("chat_completion_web_search_context_size") + if searchContextSize == "" { + searchContextSize = "medium" + } + webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, searchContextSize) + dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s,调用花费 %s", + searchContextSize, dWebSearchQuota.String()) } // file search tool 计费 var dFileSearchQuota decimal.Decimal @@ -383,23 +413,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice). Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))). Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s", + extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", fileSearchTool.CallCount, dFileSearchQuota.String()) } } var quotaCalculateDecimal decimal.Decimal - if !priceData.UsePrice { - nonCachedTokens := dPromptTokens.Sub(dCacheTokens) - cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio) - promptQuota := nonCachedTokens.Add(cachedTokensWithRatio) - if imageTokens > 0 { - nonImageTokens := dPromptTokens.Sub(dImageTokens) - imageTokensWithRatio := dImageTokens.Mul(dImageRatio) - promptQuota = nonImageTokens.Add(imageTokensWithRatio) + var audioInputQuota decimal.Decimal + var audioInputPrice float64 + if !priceData.UsePrice { + baseTokens := dPromptTokens + // 减去 cached tokens + var cachedTokensWithRatio decimal.Decimal + if !dCacheTokens.IsZero() { + baseTokens = baseTokens.Sub(dCacheTokens) + cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) } + // 减去 image tokens + var imageTokensWithRatio decimal.Decimal + if !dImageTokens.IsZero() { + baseTokens = baseTokens.Sub(dImageTokens) + imageTokensWithRatio = dImageTokens.Mul(dImageRatio) + } + + // 减去 Gemini audio tokens + if !dAudioTokens.IsZero() { + audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName) + if audioInputPrice > 0 { + // 重新计算 base tokens + baseTokens = baseTokens.Sub(dAudioTokens) + audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()) + } + } + promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio) + completionQuota := dCompletionTokens.Mul(dCompletionRatio) quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio) @@ -413,6 +463,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, // 添加 responses tools call 调用的配额 quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) + // 添加 audio input 独立计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quota := int(quotaCalculateDecimal.Round(0).IntPart()) totalTokens := promptTokens + completionTokens @@ -457,16 +509,22 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, if extraContent != "" { logContent += ", " + extraContent } - other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice) + other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) if imageTokens != 0 { other["image"] = true other["image_ratio"] = imageRatio other["image_output"] = imageTokens } - if !dWebSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil { - if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists { + if !dWebSearchQuota.IsZero() { + if relayInfo.ResponsesUsageInfo != nil { + if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists { + other["web_search"] = true + other["web_search_call_count"] = webSearchTool.CallCount + other["web_search_price"] = webSearchPrice + } + } else if strings.HasSuffix(modelName, "search-preview") { other["web_search"] = true - other["web_search_call_count"] = webSearchTool.CallCount + other["web_search_call_count"] = 1 other["web_search_price"] = webSearchPrice } } @@ -477,6 +535,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other["file_search_price"] = fileSearchPrice } } + if !audioInputQuota.IsZero() { + other["audio_input_seperate_price"] = true + other["audio_input_token_count"] = audioTokens + other["audio_input_price"] = audioInputPrice + } model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) } diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 7bf0da9f..626bb7e4 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -22,6 +22,7 @@ import ( "one-api/relay/channel/palm" "one-api/relay/channel/perplexity" "one-api/relay/channel/siliconflow" + "one-api/relay/channel/task/kling" "one-api/relay/channel/task/suno" "one-api/relay/channel/tencent" "one-api/relay/channel/vertex" @@ -101,6 +102,8 @@ func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor { // return &aiproxy.Adaptor{} case commonconstant.TaskPlatformSuno: return &suno.TaskAdaptor{} + case commonconstant.TaskPlatformKling: + return &kling.TaskAdaptor{} } return nil } diff --git a/relay/relay_task.go b/relay/relay_task.go index 26874ba6..245fd681 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -15,8 +15,7 @@ import ( relaycommon "one-api/relay/common" relayconstant "one-api/relay/constant" "one-api/service" - "one-api/setting" - "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" ) /* @@ -38,9 +37,12 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { } modelName := service.CoverTaskActionToModelName(platform, relayInfo.Action) - modelPrice, success := operation_setting.GetModelPrice(modelName, true) + if platform == constant.TaskPlatformKling { + modelName = relayInfo.OriginModelName + } + modelPrice, success := ratio_setting.GetModelPrice(modelName, true) if !success { - defaultPrice, ok := operation_setting.GetDefaultModelRatioMap()[modelName] + defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName] if !ok { modelPrice = 0.1 } else { @@ -49,7 +51,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { } // 预扣 - groupRatio := setting.GetGroupRatio(relayInfo.Group) + groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group) ratio := modelPrice * groupRatio userQuota, err := model.GetUserQuota(relayInfo.UserId, false) if err != nil { @@ -137,10 +139,11 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { } relayInfo.ConsumeQuota = true // insert task - task := model.InitTask(constant.TaskPlatformSuno, relayInfo) + task := model.InitTask(platform, relayInfo) task.TaskID = taskID task.Quota = quota task.Data = taskData + task.Action = relayInfo.Action err = task.Insert() if err != nil { taskErr = service.TaskErrorWrapper(err, "insert_task_failed", http.StatusInternalServerError) @@ -150,8 +153,9 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { } var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ - relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, - relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, + relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, + relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, + relayconstant.RelayModeKlingFetchByID: videoFetchByIDRespBodyBuilder, } func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) { @@ -226,6 +230,27 @@ func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dt return } +func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { + taskId := c.Param("id") + userId := c.GetInt("id") + + originTask, exist, err := model.GetByTaskId(userId, taskId) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + + respBody, err = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: TaskModel2Dto(originTask), + }) + return +} + func TaskModel2Dto(task *model.Task) *dto.TaskDto { return &dto.TaskDto{ TaskID: task.TaskID, diff --git a/relay/relay_rerank.go b/relay/rerank_handler.go similarity index 92% rename from relay/relay_rerank.go rename to relay/rerank_handler.go index 6ca98de7..319811b8 100644 --- a/relay/relay_rerank.go +++ b/relay/rerank_handler.go @@ -14,12 +14,10 @@ import ( ) func getRerankPromptToken(rerankRequest dto.RerankRequest) int { - token, _ := service.CountTokenInput(rerankRequest.Query, rerankRequest.Model) + token := service.CountTokenInput(rerankRequest.Query, rerankRequest.Model) for _, document := range rerankRequest.Documents { - tkm, err := service.CountTokenInput(document, rerankRequest.Model) - if err == nil { - token += tkm - } + tkm := service.CountTokenInput(document, rerankRequest.Model) + token += tkm } return token } @@ -42,13 +40,11 @@ func RerankHelper(c *gin.Context, relayMode int) (openaiErr *dto.OpenAIErrorWith return service.OpenAIErrorWrapperLocal(fmt.Errorf("documents is empty"), "invalid_documents", http.StatusBadRequest) } - err = helper.ModelMappedHelper(c, relayInfo) + err = helper.ModelMappedHelper(c, relayInfo, rerankRequest) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError) } - rerankRequest.Model = relayInfo.UpstreamModelName - promptToken := getRerankPromptToken(*rerankRequest) relayInfo.PromptTokens = promptToken diff --git a/relay/relay-responses.go b/relay/responses_handler.go similarity index 93% rename from relay/relay-responses.go rename to relay/responses_handler.go index fd3ddb5a..e744e354 100644 --- a/relay/relay-responses.go +++ b/relay/responses_handler.go @@ -40,10 +40,10 @@ func checkInputSensitive(textRequest *dto.OpenAIResponsesRequest, info *relaycom return sensitiveWords, err } -func getInputTokens(req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) (int, error) { - inputTokens, err := service.CountTokenInput(req.Input, req.Model) +func getInputTokens(req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) int { + inputTokens := service.CountTokenInput(req.Input, req.Model) info.PromptTokens = inputTokens - return inputTokens, err + return inputTokens } func ResponsesHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) { @@ -63,19 +63,16 @@ func ResponsesHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) } } - err = helper.ModelMappedHelper(c, relayInfo) + err = helper.ModelMappedHelper(c, relayInfo, req) if err != nil { return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusBadRequest) } - req.Model = relayInfo.UpstreamModelName + if value, exists := c.Get("prompt_tokens"); exists { promptTokens := value.(int) relayInfo.SetPromptTokens(promptTokens) } else { - promptTokens, err := getInputTokens(req, relayInfo) - if err != nil { - return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest) - } + promptTokens := getInputTokens(req, relayInfo) c.Set("prompt_tokens", promptTokens) } diff --git a/relay/websocket.go b/relay/websocket.go index c815eb71..571f3a82 100644 --- a/relay/websocket.go +++ b/relay/websocket.go @@ -6,12 +6,10 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "net/http" - "one-api/common" "one-api/dto" relaycommon "one-api/relay/common" + "one-api/relay/helper" "one-api/service" - "one-api/setting" - "one-api/setting/operation_setting" ) func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWithStatusCode) { @@ -39,43 +37,14 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWi //isModelMapped = true } } - //relayInfo.UpstreamModelName = textRequest.Model - modelPrice, getModelPriceSuccess := operation_setting.GetModelPrice(relayInfo.UpstreamModelName, false) - groupRatio := setting.GetGroupRatio(relayInfo.Group) - var preConsumedQuota int - var ratio float64 - var modelRatio float64 - //err := service.SensitiveWordsCheck(textRequest) - - //if constant.ShouldCheckPromptSensitive() { - // err = checkRequestSensitive(textRequest, relayInfo) - // if err != nil { - // return service.OpenAIErrorWrapperLocal(err, "sensitive_words_detected", http.StatusBadRequest) - // } - //} - - //promptTokens, err := getWssPromptTokens(realtimeEvent, relayInfo) - //// count messages token error 计算promptTokens错误 - //if err != nil { - // return service.OpenAIErrorWrapper(err, "count_token_messages_failed", http.StatusInternalServerError) - //} - // - if !getModelPriceSuccess { - preConsumedTokens := common.PreConsumedQuota - //if realtimeEvent.Session.MaxResponseOutputTokens != 0 { - // preConsumedTokens = promptTokens + int(realtimeEvent.Session.MaxResponseOutputTokens) - //} - modelRatio, _ = operation_setting.GetModelRatio(relayInfo.UpstreamModelName) - ratio = modelRatio * groupRatio - preConsumedQuota = int(float64(preConsumedTokens) * ratio) - } else { - preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatio) - relayInfo.UsePrice = true + priceData, err := helper.ModelPriceHelper(c, relayInfo, 0, 0) + if err != nil { + return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError) } // pre-consume quota 预消耗配额 - preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, preConsumedQuota, relayInfo) + preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) if openaiErr != nil { return openaiErr } @@ -113,6 +82,6 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWi return openaiErr } service.PostWssConsumeQuota(c, relayInfo, relayInfo.UpstreamModelName, usage.(*dto.RealtimeUsage), preConsumedQuota, - userQuota, modelRatio, groupRatio, modelPrice, getModelPriceSuccess, "") + userQuota, priceData, "") return nil } diff --git a/router/api-router.go b/router/api-router.go index 1720ff57..badfa7bf 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/setup", controller.GetSetup) apiRouter.POST("/setup", controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) + apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) @@ -35,6 +36,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind) apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) + apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) userRoute := apiRouter.Group("/user") { @@ -80,6 +82,13 @@ func SetApiRouter(router *gin.Engine) { optionRoute.GET("/", controller.GetOptions) optionRoute.PUT("/", controller.UpdateOption) optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) + optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 + } + ratioSyncRoute := apiRouter.Group("/ratio_sync") + ratioSyncRoute.Use(middleware.RootAuth()) + { + ratioSyncRoute.GET("/channels", controller.GetSyncableChannels) + ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios) } channelRoute := apiRouter.Group("/channel") channelRoute.Use(middleware.AdminAuth()) @@ -105,6 +114,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.POST("/fetch_models", controller.FetchModels) channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) + channelRoute.GET("/tag/models", controller.GetTagModels) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) @@ -124,6 +134,7 @@ func SetApiRouter(router *gin.Engine) { redemptionRoute.GET("/:id", controller.GetRedemption) redemptionRoute.POST("/", controller.AddRedemption) redemptionRoute.PUT("/", controller.UpdateRedemption) + redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption) redemptionRoute.DELETE("/:id", controller.DeleteRedemption) } logRoute := apiRouter.Group("/log") diff --git a/router/main.go b/router/main.go index b8ac4055..0d2bfdce 100644 --- a/router/main.go +++ b/router/main.go @@ -14,6 +14,7 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { SetApiRouter(router) SetDashboardRouter(router) SetRelayRouter(router) + SetVideoRouter(router) frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") if common.IsMasterNode && frontendBaseUrl != "" { frontendBaseUrl = "" diff --git a/router/relay-router.go b/router/relay-router.go index 4cd84b41..aa7f27a8 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -11,6 +11,7 @@ import ( func SetRelayRouter(router *gin.Engine) { router.Use(middleware.CORS()) router.Use(middleware.DecompressRequestMiddleware()) + router.Use(middleware.StatsMiddleware()) // https://platform.openai.com/docs/api-reference/introduction modelsRouter := router.Group("/v1/models") modelsRouter.Use(middleware.TokenAuth()) @@ -79,6 +80,14 @@ func SetRelayRouter(router *gin.Engine) { relaySunoRouter.GET("/fetch/:id", controller.RelayTask) } + relayGeminiRouter := router.Group("/v1beta") + relayGeminiRouter.Use(middleware.TokenAuth()) + relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) + relayGeminiRouter.Use(middleware.Distribute()) + { + // Gemini API 路径格式: /v1beta/models/{model_name}:{action} + relayGeminiRouter.POST("/models/*path", controller.Relay) + } } func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) { diff --git a/router/video-router.go b/router/video-router.go new file mode 100644 index 00000000..7201c34a --- /dev/null +++ b/router/video-router.go @@ -0,0 +1,17 @@ +package router + +import ( + "one-api/controller" + "one-api/middleware" + + "github.com/gin-gonic/gin" +) + +func SetVideoRouter(router *gin.Engine) { + videoV1Router := router.Group("/v1") + videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute()) + { + videoV1Router.POST("/video/generations", controller.RelayTask) + videoV1Router.GET("/video/generations/:task_id", controller.RelayTask) + } +} diff --git a/service/audio.go b/service/audio.go index d558e96f..c4b6f01b 100644 --- a/service/audio.go +++ b/service/audio.go @@ -3,6 +3,7 @@ package service import ( "encoding/base64" "fmt" + "strings" ) func parseAudio(audioBase64 string, format string) (duration float64, err error) { @@ -29,3 +30,19 @@ func parseAudio(audioBase64 string, format string) (duration float64, err error) duration = float64(samplesCount) / float64(sampleRate) return duration, nil } + +func DecodeBase64AudioData(audioBase64 string) (string, error) { + // 检查并移除 data:audio/xxx;base64, 前缀 + idx := strings.Index(audioBase64, ",") + if idx != -1 { + audioBase64 = audioBase64[idx+1:] + } + + // 解码 Base64 数据 + _, err := base64.StdEncoding.DecodeString(audioBase64) + if err != nil { + return "", fmt.Errorf("base64 decode error: %v", err) + } + + return audioBase64, nil +} diff --git a/service/channel.go b/service/channel.go index e3a76af4..746e9a34 100644 --- a/service/channel.go +++ b/service/channel.go @@ -59,6 +59,8 @@ func ShouldDisableChannel(channelType int, err *dto.OpenAIErrorWithStatusCode) b return true case "billing_not_active": return true + case "pre_consume_token_quota_failed": + return true } switch err.Error.Type { case "insufficient_quota": diff --git a/service/convert.go b/service/convert.go index cc462b40..7a9e8403 100644 --- a/service/convert.go +++ b/service/convert.go @@ -5,6 +5,7 @@ import ( "fmt" "one-api/common" "one-api/dto" + "one-api/relay/channel/openrouter" relaycommon "one-api/relay/common" "strings" ) @@ -18,10 +19,24 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re Stream: claudeRequest.Stream, } - if claudeRequest.Thinking != nil { - if strings.HasSuffix(info.OriginModelName, "-thinking") && - !strings.HasSuffix(claudeRequest.Model, "-thinking") { - openAIRequest.Model = openAIRequest.Model + "-thinking" + isOpenRouter := info.ChannelType == common.ChannelTypeOpenRouter + + if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" { + if isOpenRouter { + reasoning := openrouter.RequestReasoning{ + MaxTokens: claudeRequest.Thinking.GetBudgetTokens(), + } + reasoningJSON, err := json.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("failed to marshal reasoning: %w", err) + } + openAIRequest.Reasoning = reasoningJSON + } else { + thinkingSuffix := "-thinking" + if strings.HasSuffix(info.OriginModelName, thinkingSuffix) && + !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) { + openAIRequest.Model = openAIRequest.Model + thinkingSuffix + } } } @@ -62,16 +77,30 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re } else { systems := claudeRequest.ParseSystem() if len(systems) > 0 { - systemStr := "" openAIMessage := dto.Message{ Role: "system", } - for _, system := range systems { - if system.Text != nil { - systemStr += *system.Text + isOpenRouterClaude := isOpenRouter && strings.HasPrefix(info.UpstreamModelName, "anthropic/claude") + if isOpenRouterClaude { + systemMediaMessages := make([]dto.MediaContent, 0, len(systems)) + for _, system := range systems { + message := dto.MediaContent{ + Type: "text", + Text: system.GetText(), + CacheControl: system.CacheControl, + } + systemMediaMessages = append(systemMediaMessages, message) } + openAIMessage.SetMediaContent(systemMediaMessages) + } else { + systemStr := "" + for _, system := range systems { + if system.Text != nil { + systemStr += *system.Text + } + } + openAIMessage.SetStringContent(systemStr) } - openAIMessage.SetStringContent(systemStr) openAIMessages = append(openAIMessages, openAIMessage) } } @@ -97,8 +126,9 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re switch mediaMsg.Type { case "text": message := dto.MediaContent{ - Type: "text", - Text: mediaMsg.GetText(), + Type: "text", + Text: mediaMsg.GetText(), + CacheControl: mediaMsg.CacheControl, } mediaMessages = append(mediaMessages, message) case "image": diff --git a/service/error.go b/service/error.go index 1bf5992b..f3d8a17d 100644 --- a/service/error.go +++ b/service/error.go @@ -29,9 +29,11 @@ func MidjourneyErrorWithStatusCodeWrapper(code int, desc string, statusCode int) func OpenAIErrorWrapper(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode { text := err.Error() lowerText := strings.ToLower(text) - if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { - common.SysLog(fmt.Sprintf("error: %s", text)) - text = "请求上游地址失败" + if !strings.HasPrefix(lowerText, "get file base64 from url") && !strings.HasPrefix(lowerText, "mime type is not supported") { + if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { + common.SysLog(fmt.Sprintf("error: %s", text)) + text = "请求上游地址失败" + } } openAIError := dto.OpenAIError{ Message: text, @@ -53,9 +55,11 @@ func OpenAIErrorWrapperLocal(err error, code string, statusCode int) *dto.OpenAI func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode { text := err.Error() lowerText := strings.ToLower(text) - if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { - common.SysLog(fmt.Sprintf("error: %s", text)) - text = "请求上游地址失败" + if !strings.HasPrefix(lowerText, "get file base64 from url") { + if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { + common.SysLog(fmt.Sprintf("error: %s", text)) + text = "请求上游地址失败" + } } claudeError := dto.ClaudeError{ Message: text, diff --git a/service/file_decoder.go b/service/file_decoder.go index bbb188f8..c1d4fb0c 100644 --- a/service/file_decoder.go +++ b/service/file_decoder.go @@ -4,8 +4,10 @@ import ( "encoding/base64" "fmt" "io" + "one-api/common" "one-api/constant" "one-api/dto" + "strings" ) func GetFileBase64FromUrl(url string) (*dto.LocalFileData, error) { @@ -30,9 +32,104 @@ func GetFileBase64FromUrl(url string) (*dto.LocalFileData, error) { // Convert to base64 base64Data := base64.StdEncoding.EncodeToString(fileBytes) + mimeType := resp.Header.Get("Content-Type") + if len(strings.Split(mimeType, ";")) > 1 { + // If Content-Type has parameters, take the first part + mimeType = strings.Split(mimeType, ";")[0] + } + if mimeType == "application/octet-stream" { + if common.DebugEnabled { + println("MIME type is application/octet-stream, trying to guess from URL or filename") + } + // try to guess the MIME type from the url last segment + urlParts := strings.Split(url, "/") + if len(urlParts) > 0 { + lastSegment := urlParts[len(urlParts)-1] + if strings.Contains(lastSegment, ".") { + // Extract the file extension + filename := strings.Split(lastSegment, ".") + if len(filename) > 1 { + ext := strings.ToLower(filename[len(filename)-1]) + // Guess MIME type based on file extension + mimeType = GetMimeTypeByExtension(ext) + } + } + } else { + // try to guess the MIME type from the file extension + fileName := resp.Header.Get("Content-Disposition") + if fileName != "" { + // Extract the filename from the Content-Disposition header + parts := strings.Split(fileName, ";") + for _, part := range parts { + if strings.HasPrefix(strings.TrimSpace(part), "filename=") { + fileName = strings.TrimSpace(strings.TrimPrefix(part, "filename=")) + // Remove quotes if present + if len(fileName) > 2 && fileName[0] == '"' && fileName[len(fileName)-1] == '"' { + fileName = fileName[1 : len(fileName)-1] + } + // Guess MIME type based on file extension + if ext := strings.ToLower(strings.TrimPrefix(fileName, ".")); ext != "" { + mimeType = GetMimeTypeByExtension(ext) + } + break + } + } + } + } + } + return &dto.LocalFileData{ Base64Data: base64Data, - MimeType: resp.Header.Get("Content-Type"), + MimeType: mimeType, Size: int64(len(fileBytes)), }, nil } + +func GetMimeTypeByExtension(ext string) string { + // Convert to lowercase for case-insensitive comparison + ext = strings.ToLower(ext) + switch ext { + // Text files + case "txt", "md", "markdown", "csv", "json", "xml", "html", "htm": + return "text/plain" + + // Image files + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + + // Audio files + case "mp3": + return "audio/mp3" + case "wav": + return "audio/wav" + case "mpeg": + return "audio/mpeg" + + // Video files + case "mp4": + return "video/mp4" + case "wmv": + return "video/wmv" + case "flv": + return "video/flv" + case "mov": + return "video/mov" + case "mpg": + return "video/mpg" + case "avi": + return "video/avi" + case "mpegps": + return "video/mpegps" + + // Document files + case "pdf": + return "application/pdf" + + default: + return "application/octet-stream" // Default for unknown types + } +} diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 75457b97..1edc9073 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -8,7 +8,7 @@ import ( ) func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64, - cacheTokens int, cacheRatio float64, modelPrice float64) map[string]interface{} { + cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} { other := make(map[string]interface{}) other["model_ratio"] = modelRatio other["group_ratio"] = groupRatio @@ -16,6 +16,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other["cache_tokens"] = cacheTokens other["cache_ratio"] = cacheRatio other["model_price"] = modelPrice + other["user_group_ratio"] = userGroupRatio other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) if relayInfo.ReasoningEffort != "" { other["reasoning_effort"] = relayInfo.ReasoningEffort @@ -30,8 +31,8 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m return other } -func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} { - info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice) +func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) info["ws"] = true info["audio_input"] = usage.InputTokenDetails.AudioTokens info["audio_output"] = usage.OutputTokenDetails.AudioTokens @@ -42,8 +43,8 @@ func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us return info } -func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} { - info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice) +func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) info["audio"] = true info["audio_input"] = usage.PromptTokensDetails.AudioTokens info["audio_output"] = usage.CompletionTokenDetails.AudioTokens @@ -55,8 +56,8 @@ func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, } func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64, - cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64) map[string]interface{} { - info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice) + cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio) info["claude"] = true info["cache_creation_tokens"] = cacheCreationTokens info["cache_creation_ratio"] = cacheCreationRatio diff --git a/service/quota.go b/service/quota.go index 0d11b4a0..973deba7 100644 --- a/service/quota.go +++ b/service/quota.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "log" "one-api/common" constant2 "one-api/constant" "one-api/dto" @@ -10,7 +11,7 @@ import ( relaycommon "one-api/relay/common" "one-api/relay/helper" "one-api/setting" - "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" "strings" "time" @@ -45,9 +46,9 @@ func calculateAudioQuota(info QuotaInfo) int { return int(quota.IntPart()) } - completionRatio := decimal.NewFromFloat(operation_setting.GetCompletionRatio(info.ModelName)) - audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(info.ModelName)) - audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(info.ModelName)) + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(info.ModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(info.ModelName)) groupRatio := decimal.NewFromFloat(info.GroupRatio) modelRatio := decimal.NewFromFloat(info.ModelRatio) @@ -93,8 +94,21 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag textOutTokens := usage.OutputTokenDetails.TextTokens audioInputTokens := usage.InputTokenDetails.AudioTokens audioOutTokens := usage.OutputTokenDetails.AudioTokens - groupRatio := setting.GetGroupRatio(relayInfo.Group) - modelRatio, _ := operation_setting.GetModelRatio(modelName) + groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group) + modelRatio, _ := ratio_setting.GetModelRatio(modelName) + + autoGroup, exists := ctx.Get("auto_group") + if exists { + groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string)) + log.Printf("final group ratio: %f", groupRatio) + relayInfo.Group = autoGroup.(string) + } + + actualGroupRatio := groupRatio + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group) + if ok { + actualGroupRatio = userGroupRatio + } quotaInfo := QuotaInfo{ InputDetails: TokenDetails{ @@ -108,7 +122,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag ModelName: modelName, UsePrice: relayInfo.UsePrice, ModelRatio: modelRatio, - GroupRatio: groupRatio, + GroupRatio: actualGroupRatio, } quota := calculateAudioQuota(quotaInfo) @@ -130,8 +144,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag } func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string, - usage *dto.RealtimeUsage, preConsumedQuota int, userQuota int, modelRatio float64, groupRatio float64, - modelPrice float64, usePrice bool, extraContent string) { + usage *dto.RealtimeUsage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) { useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() textInputTokens := usage.InputTokenDetails.TextTokens @@ -141,9 +154,14 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod audioOutTokens := usage.OutputTokenDetails.AudioTokens tokenName := ctx.GetString("token_name") - completionRatio := decimal.NewFromFloat(operation_setting.GetCompletionRatio(modelName)) - audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName)) - audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(modelName)) + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(modelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(modelName)) + + modelRatio := priceData.ModelRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio + modelPrice := priceData.ModelPrice + usePrice := priceData.UsePrice quotaInfo := QuotaInfo{ InputDetails: TokenDetails{ @@ -189,7 +207,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod logContent += ", " + extraContent } other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, - completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice) + completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.InputTokens, usage.OutputTokens, logModel, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) } @@ -205,9 +223,8 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, tokenName := ctx.GetString("token_name") completionRatio := priceData.CompletionRatio modelRatio := priceData.ModelRatio - groupRatio := priceData.GroupRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio modelPrice := priceData.ModelPrice - cacheRatio := priceData.CacheRatio cacheTokens := usage.PromptTokensDetails.CachedTokens @@ -256,7 +273,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, } other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, - cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice) + cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) } @@ -272,12 +289,12 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, audioOutTokens := usage.CompletionTokenDetails.AudioTokens tokenName := ctx.GetString("token_name") - completionRatio := decimal.NewFromFloat(operation_setting.GetCompletionRatio(relayInfo.OriginModelName)) - audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName)) - audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(relayInfo.OriginModelName)) + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(relayInfo.OriginModelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(relayInfo.OriginModelName)) modelRatio := priceData.ModelRatio - groupRatio := priceData.GroupRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio modelPrice := priceData.ModelPrice usePrice := priceData.UsePrice @@ -333,7 +350,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, logContent += ", " + extraContent } other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, - completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice) + completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.PromptTokens, usage.CompletionTokens, logModel, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) } diff --git a/service/token_counter.go b/service/token_counter.go index d63b54ad..53c6c2fa 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "github.com/tiktoken-go/tokenizer" + "github.com/tiktoken-go/tokenizer/codec" "image" "log" "math" @@ -11,78 +13,63 @@ import ( "one-api/constant" "one-api/dto" relaycommon "one-api/relay/common" - "one-api/setting/operation_setting" "strings" + "sync" "unicode/utf8" - - "github.com/pkoukk/tiktoken-go" ) // tokenEncoderMap won't grow after initialization -var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} -var defaultTokenEncoder *tiktoken.Tiktoken -var o200kTokenEncoder *tiktoken.Tiktoken +var defaultTokenEncoder tokenizer.Codec + +// tokenEncoderMap is used to store token encoders for different models +var tokenEncoderMap = make(map[string]tokenizer.Codec) + +// tokenEncoderMutex protects tokenEncoderMap for concurrent access +var tokenEncoderMutex sync.RWMutex func InitTokenEncoders() { common.SysLog("initializing token encoders") - cl100TokenEncoder, err := tiktoken.GetEncoding(tiktoken.MODEL_CL100K_BASE) - if err != nil { - common.FatalLog(fmt.Sprintf("failed to get gpt-3.5-turbo token encoder: %s", err.Error())) - } - defaultTokenEncoder = cl100TokenEncoder - o200kTokenEncoder, err = tiktoken.GetEncoding(tiktoken.MODEL_O200K_BASE) - if err != nil { - common.FatalLog(fmt.Sprintf("failed to get gpt-4o token encoder: %s", err.Error())) - } - for model, _ := range operation_setting.GetDefaultModelRatioMap() { - if strings.HasPrefix(model, "gpt-3.5") { - tokenEncoderMap[model] = cl100TokenEncoder - } else if strings.HasPrefix(model, "gpt-4") { - if strings.HasPrefix(model, "gpt-4o") { - tokenEncoderMap[model] = o200kTokenEncoder - } else { - tokenEncoderMap[model] = defaultTokenEncoder - } - } else if strings.HasPrefix(model, "o") { - tokenEncoderMap[model] = o200kTokenEncoder - } else { - tokenEncoderMap[model] = defaultTokenEncoder - } - } + defaultTokenEncoder = codec.NewCl100kBase() common.SysLog("token encoders initialized") } -func getModelDefaultTokenEncoder(model string) *tiktoken.Tiktoken { - if strings.HasPrefix(model, "gpt-4o") || strings.HasPrefix(model, "chatgpt-4o") || strings.HasPrefix(model, "o1") { - return o200kTokenEncoder +func getTokenEncoder(model string) tokenizer.Codec { + // First, try to get the encoder from cache with read lock + tokenEncoderMutex.RLock() + if encoder, exists := tokenEncoderMap[model]; exists { + tokenEncoderMutex.RUnlock() + return encoder } - return defaultTokenEncoder + tokenEncoderMutex.RUnlock() + + // If not in cache, create new encoder with write lock + tokenEncoderMutex.Lock() + defer tokenEncoderMutex.Unlock() + + // Double-check if another goroutine already created the encoder + if encoder, exists := tokenEncoderMap[model]; exists { + return encoder + } + + // Create new encoder + modelCodec, err := tokenizer.ForModel(tokenizer.Model(model)) + if err != nil { + // Cache the default encoder for this model to avoid repeated failures + tokenEncoderMap[model] = defaultTokenEncoder + return defaultTokenEncoder + } + + // Cache the new encoder + tokenEncoderMap[model] = modelCodec + return modelCodec } -func getTokenEncoder(model string) *tiktoken.Tiktoken { - tokenEncoder, ok := tokenEncoderMap[model] - if ok && tokenEncoder != nil { - return tokenEncoder - } - // 如果ok(即model在tokenEncoderMap中),但是tokenEncoder为nil,说明可能是自定义模型 - if ok { - tokenEncoder, err := tiktoken.EncodingForModel(model) - if err != nil { - common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error())) - tokenEncoder = getModelDefaultTokenEncoder(model) - } - tokenEncoderMap[model] = tokenEncoder - return tokenEncoder - } - // 如果model不在tokenEncoderMap中,直接返回默认的tokenEncoder - return getModelDefaultTokenEncoder(model) -} - -func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int { +func getTokenNum(tokenEncoder tokenizer.Codec, text string) int { if text == "" { return 0 } - return len(tokenEncoder.Encode(text, nil, nil)) + tkm, _ := tokenEncoder.Count(text) + return tkm } func getImageToken(info *relaycommon.RelayInfo, imageUrl *dto.MessageImageUrl, model string, stream bool) (int, error) { @@ -184,7 +171,7 @@ func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenA countStr += fmt.Sprintf("%v", tool.Function.Parameters) } } - toolTokens, err := CountTokenInput(countStr, request.Model) + toolTokens := CountTokenInput(countStr, request.Model) if err != nil { return 0, err } @@ -207,7 +194,7 @@ func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, erro // Count tokens in system message if request.System != "" { - systemTokens, err := CountTokenInput(request.System, model) + systemTokens := CountTokenInput(request.System, model) if err != nil { return 0, err } @@ -261,12 +248,16 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream //} tokenNum += 1000 case "tool_use": - tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) - inputJSON, _ := json.Marshal(mediaMessage.Input) - tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + if mediaMessage.Input != nil { + tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) + inputJSON, _ := json.Marshal(mediaMessage.Input) + tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + } case "tool_result": - contentJSON, _ := json.Marshal(mediaMessage.Content) - tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + if mediaMessage.Content != nil { + contentJSON, _ := json.Marshal(mediaMessage.Content) + tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + } } } } @@ -305,10 +296,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, switch request.Type { case dto.RealtimeEventTypeSessionUpdate: if request.Session != nil { - msgTokens, err := CountTextToken(request.Session.Instructions, model) - if err != nil { - return 0, 0, err - } + msgTokens := CountTextToken(request.Session.Instructions, model) textToken += msgTokens } case dto.RealtimeEventResponseAudioDelta: @@ -320,10 +308,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, audioToken += atk case dto.RealtimeEventResponseAudioTranscriptionDelta, dto.RealtimeEventResponseFunctionCallArgumentsDelta: // count text token - tkm, err := CountTextToken(request.Delta, model) - if err != nil { - return 0, 0, fmt.Errorf("error counting text token: %v", err) - } + tkm := CountTextToken(request.Delta, model) textToken += tkm case dto.RealtimeEventInputAudioBufferAppend: // count audio token @@ -338,10 +323,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, case "message": for _, content := range request.Item.Content { if content.Type == "input_text" { - tokens, err := CountTextToken(content.Text, model) - if err != nil { - return 0, 0, err - } + tokens := CountTextToken(content.Text, model) textToken += tokens } } @@ -352,10 +334,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, if !info.IsFirstRequest { if info.RealtimeTools != nil && len(info.RealtimeTools) > 0 { for _, tool := range info.RealtimeTools { - toolTokens, err := CountTokenInput(tool, model) - if err != nil { - return 0, 0, err - } + toolTokens := CountTokenInput(tool, model) textToken += 8 textToken += toolTokens } @@ -386,7 +365,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod for _, message := range messages { tokenNum += tokensPerMessage tokenNum += getTokenNum(tokenEncoder, message.Role) - if len(message.Content) > 0 { + if message.Content != nil { if message.Name != nil { tokenNum += tokensPerName tokenNum += getTokenNum(tokenEncoder, *message.Name) @@ -418,7 +397,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod return tokenNum, nil } -func CountTokenInput(input any, model string) (int, error) { +func CountTokenInput(input any, model string) int { switch v := input.(type) { case string: return CountTextToken(v, model) @@ -441,13 +420,13 @@ func CountTokenInput(input any, model string) (int, error) { func CountTokenStreamChoices(messages []dto.ChatCompletionsStreamResponseChoice, model string) int { tokens := 0 for _, message := range messages { - tkm, _ := CountTokenInput(message.Delta.GetContentString(), model) + tkm := CountTokenInput(message.Delta.GetContentString(), model) tokens += tkm if message.Delta.ToolCalls != nil { for _, tool := range message.Delta.ToolCalls { - tkm, _ := CountTokenInput(tool.Function.Name, model) + tkm := CountTokenInput(tool.Function.Name, model) tokens += tkm - tkm, _ = CountTokenInput(tool.Function.Arguments, model) + tkm = CountTokenInput(tool.Function.Arguments, model) tokens += tkm } } @@ -455,9 +434,9 @@ func CountTokenStreamChoices(messages []dto.ChatCompletionsStreamResponseChoice, return tokens } -func CountTTSToken(text string, model string) (int, error) { +func CountTTSToken(text string, model string) int { if strings.HasPrefix(model, "tts") { - return utf8.RuneCountInString(text), nil + return utf8.RuneCountInString(text) } else { return CountTextToken(text, model) } @@ -492,8 +471,10 @@ func CountAudioTokenOutput(audioBase64 string, audioFormat string) (int, error) //} // CountTextToken 统计文本的token数量,仅当文本包含敏感词,返回错误,同时返回token数量 -func CountTextToken(text string, model string) (int, error) { - var err error +func CountTextToken(text string, model string) int { + if text == "" { + return 0 + } tokenEncoder := getTokenEncoder(model) - return getTokenNum(tokenEncoder, text), err + return getTokenNum(tokenEncoder, text) } diff --git a/service/usage_helpr.go b/service/usage_helpr.go index c52e1e15..ca9c0830 100644 --- a/service/usage_helpr.go +++ b/service/usage_helpr.go @@ -16,13 +16,13 @@ import ( // return 0, errors.New("unknown relay mode") //} -func ResponseText2Usage(responseText string, modeName string, promptTokens int) (*dto.Usage, error) { +func ResponseText2Usage(responseText string, modeName string, promptTokens int) *dto.Usage { usage := &dto.Usage{} usage.PromptTokens = promptTokens - ctkm, err := CountTextToken(responseText, modeName) + ctkm := CountTextToken(responseText, modeName) usage.CompletionTokens = ctkm usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - return usage, err + return usage } func ValidUsage(usage *dto.Usage) bool { diff --git a/setting/auto_group.go b/setting/auto_group.go new file mode 100644 index 00000000..5a87ae56 --- /dev/null +++ b/setting/auto_group.go @@ -0,0 +1,31 @@ +package setting + +import "encoding/json" + +var AutoGroups = []string{ + "default", +} + +var DefaultUseAutoGroup = false + +func ContainsAutoGroup(group string) bool { + for _, autoGroup := range AutoGroups { + if autoGroup == group { + return true + } + } + return false +} + +func UpdateAutoGroupsByJsonString(jsonString string) error { + AutoGroups = make([]string, 0) + return json.Unmarshal([]byte(jsonString), &AutoGroups) +} + +func AutoGroups2JsonString() string { + jsonBytes, err := json.Marshal(AutoGroups) + if err != nil { + return "[]" + } + return string(jsonBytes) +} diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go new file mode 100644 index 00000000..6327e558 --- /dev/null +++ b/setting/console_setting/config.go @@ -0,0 +1,39 @@ +package console_setting + +import "one-api/setting/config" + +type ConsoleSetting struct { + ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) + UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) + Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) + FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) + ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 + UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 + AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 + FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 +} + +// 默认配置 +var defaultConsoleSetting = ConsoleSetting{ + ApiInfo: "", + UptimeKumaGroups: "", + Announcements: "", + FAQ: "", + ApiInfoEnabled: true, + UptimeKumaEnabled: true, + AnnouncementsEnabled: true, + FAQEnabled: true, +} + +// 全局实例 +var consoleSetting = defaultConsoleSetting + +func init() { + // 注册到全局配置管理器,键名为 console_setting + config.GlobalConfig.Register("console_setting", &consoleSetting) +} + +// GetConsoleSetting 获取 ConsoleSetting 配置实例 +func GetConsoleSetting() *ConsoleSetting { + return &consoleSetting +} \ No newline at end of file diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go new file mode 100644 index 00000000..fda6453d --- /dev/null +++ b/setting/console_setting/validation.go @@ -0,0 +1,304 @@ +package console_setting + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strings" + "time" + "sort" +) + +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})?(?:/.*)?$`) + dangerousChars = []string{" 50 { + return fmt.Errorf("API信息数量不能超过50个") + } + + for i, apiInfo := range apiInfoList { + urlStr, ok := apiInfo["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个API信息缺少URL字段", i+1) + } + route, ok := apiInfo["route"].(string) + if !ok || route == "" { + return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) + } + description, ok := apiInfo["description"].(string) + if !ok || description == "" { + return fmt.Errorf("第%d个API信息缺少说明字段", i+1) + } + color, ok := apiInfo["color"].(string) + if !ok || color == "" { + return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) + } + + if err := validateURL(urlStr, i+1, "API信息"); err != nil { + return err + } + + if len(urlStr) > 500 { + return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) + } + if len(route) > 100 { + return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) + } + + if !validColors[color] { + return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) + } + + if err := checkDangerousContent(description, i+1, "API信息"); err != nil { + return err + } + if err := checkDangerousContent(route, i+1, "API信息"); err != nil { + return err + } + } + return nil +} + +func GetApiInfo() []map[string]interface{} { + return getJSONList(GetConsoleSetting().ApiInfo) +} + +func validateAnnouncements(announcementsStr string) error { + list, err := parseJSONArray(announcementsStr, "系统公告") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("系统公告数量不能超过100个") + } + validTypes := map[string]bool{ + "default": true, "ongoing": true, "success": true, "warning": true, "error": true, + } + for i, ann := range list { + content, ok := ann["content"].(string) + if !ok || content == "" { + return fmt.Errorf("第%d个公告缺少内容字段", i+1) + } + publishDateAny, exists := ann["publishDate"] + if !exists { + return fmt.Errorf("第%d个公告缺少发布日期字段", i+1) + } + publishDateStr, ok := publishDateAny.(string) + if !ok || publishDateStr == "" { + return fmt.Errorf("第%d个公告的发布日期不能为空", i+1) + } + if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil { + return fmt.Errorf("第%d个公告的发布日期格式错误", i+1) + } + if t, exists := ann["type"]; exists { + if typeStr, ok := t.(string); ok { + if !validTypes[typeStr] { + return fmt.Errorf("第%d个公告的类型值不合法", i+1) + } + } + } + if len(content) > 500 { + return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) + } + if extra, exists := ann["extra"]; exists { + if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { + return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) + } + } + } + return nil +} + +func validateFAQ(faqStr string) error { + list, err := parseJSONArray(faqStr, "FAQ信息") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("FAQ数量不能超过100个") + } + for i, faq := range list { + question, ok := faq["question"].(string) + if !ok || question == "" { + return fmt.Errorf("第%d个FAQ缺少问题字段", i+1) + } + answer, ok := faq["answer"].(string) + if !ok || answer == "" { + return fmt.Errorf("第%d个FAQ缺少答案字段", i+1) + } + if len(question) > 200 { + return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1) + } + if len(answer) > 1000 { + return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1) + } + } + return nil +} + +func getPublishTime(item map[string]interface{}) time.Time { + if v, ok := item["publishDate"]; ok { + if s, ok2 := v.(string); ok2 { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + } + return time.Time{} +} + +func GetAnnouncements() []map[string]interface{} { + list := getJSONList(GetConsoleSetting().Announcements) + sort.SliceStable(list, func(i, j int) bool { + return getPublishTime(list[i]).After(getPublishTime(list[j])) + }) + return list +} + +func GetFAQ() []map[string]interface{} { + return getJSONList(GetConsoleSetting().FAQ) +} + +func validateUptimeKumaGroups(groupsStr string) error { + groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置") + if err != nil { + return err + } + + if len(groups) > 20 { + return fmt.Errorf("Uptime Kuma分组数量不能超过20个") + } + + nameSet := make(map[string]bool) + + for i, group := range groups { + categoryName, ok := group["categoryName"].(string) + if !ok || categoryName == "" { + return fmt.Errorf("第%d个分组缺少分类名称字段", i+1) + } + if nameSet[categoryName] { + return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1) + } + nameSet[categoryName] = true + urlStr, ok := group["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个分组缺少URL字段", i+1) + } + slug, ok := group["slug"].(string) + if !ok || slug == "" { + return fmt.Errorf("第%d个分组缺少Slug字段", i+1) + } + description, ok := group["description"].(string) + if !ok { + description = "" + } + + if err := validateURL(urlStr, i+1, "分组"); err != nil { + return err + } + + if len(categoryName) > 50 { + return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) + } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) + } + if len(slug) > 100 { + return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) + } + + if !slugRegex.MatchString(slug) { + return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1) + } + + if err := checkDangerousContent(description, i+1, "分组"); err != nil { + return err + } + if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil { + return err + } + } + return nil +} + +func GetUptimeKumaGroups() []map[string]interface{} { + return getJSONList(GetConsoleSetting().UptimeKumaGroups) +} \ No newline at end of file diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 974c4ed2..a401b923 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -14,6 +14,15 @@ const ( FileSearchPrice = 2.5 ) +const ( + // Gemini Audio Input Price + Gemini25FlashPreviewInputAudioPrice = 1.00 + Gemini25FlashProductionInputAudioPrice = 1.00 // for `gemini-2.5-flash` + Gemini25FlashLitePreviewInputAudioPrice = 0.50 + Gemini25FlashNativeAudioInputAudioPrice = 3.00 + Gemini20FlashInputAudioPrice = 0.70 +) + func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { // 确定模型类型 // https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费 @@ -55,3 +64,18 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 func GetFileSearchPricePerThousand() float64 { return FileSearchPrice } + +func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { + if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") { + return Gemini25FlashNativeAudioInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") { + return Gemini25FlashLitePreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") { + return Gemini25FlashPreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash") { + return Gemini25FlashProductionInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.0-flash") { + return Gemini20FlashInputAudioPrice + } + return 0 +} diff --git a/setting/payment.go b/setting/payment.go index f50723c3..3fc0f14a 100644 --- a/setting/payment.go +++ b/setting/payment.go @@ -1,8 +1,45 @@ package setting +import "encoding/json" + var PayAddress = "" var CustomCallbackAddress = "" var EpayId = "" var EpayKey = "" var Price = 7.3 var MinTopUp = 1 + +var PayMethods = []map[string]string{ + { + "name": "支付宝", + "color": "rgba(var(--semi-blue-5), 1)", + "type": "alipay", + }, + { + "name": "微信", + "color": "rgba(var(--semi-green-5), 1)", + "type": "wxpay", + }, +} + +func UpdatePayMethodsByJsonString(jsonString string) error { + PayMethods = make([]map[string]string, 0) + return json.Unmarshal([]byte(jsonString), &PayMethods) +} + +func PayMethods2JsonString() string { + jsonBytes, err := json.Marshal(PayMethods) + if err != nil { + return "[]" + } + return string(jsonBytes) +} + +func ContainsPayMethod(method string) bool { + for _, payMethod := range PayMethods { + if payMethod["type"] == method { + return true + } + } + return false +} diff --git a/setting/operation_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go similarity index 81% rename from setting/operation_setting/cache_ratio.go rename to setting/ratio_setting/cache_ratio.go index dd29eac2..51d473a8 100644 --- a/setting/operation_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -1,4 +1,4 @@ -package operation_setting +package ratio_setting import ( "encoding/json" @@ -36,6 +36,10 @@ var defaultCacheRatio = map[string]float64{ "claude-3-5-sonnet-20241022": 0.1, "claude-3-7-sonnet-20250219": 0.1, "claude-3-7-sonnet-20250219-thinking": 0.1, + "claude-sonnet-4-20250514": 0.1, + "claude-sonnet-4-20250514-thinking": 0.1, + "claude-opus-4-20250514": 0.1, + "claude-opus-4-20250514-thinking": 0.1, } var defaultCreateCacheRatio = map[string]float64{ @@ -47,6 +51,10 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-3-5-sonnet-20241022": 1.25, "claude-3-7-sonnet-20250219": 1.25, "claude-3-7-sonnet-20250219-thinking": 1.25, + "claude-sonnet-4-20250514": 1.25, + "claude-sonnet-4-20250514-thinking": 1.25, + "claude-opus-4-20250514": 1.25, + "claude-opus-4-20250514-thinking": 1.25, } //var defaultCreateCacheRatio = map[string]float64{} @@ -77,7 +85,11 @@ func UpdateCacheRatioByJSONString(jsonStr string) error { cacheRatioMapMutex.Lock() defer cacheRatioMapMutex.Unlock() cacheRatioMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + err := json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // GetCacheRatio returns the cache ratio for a model @@ -98,3 +110,13 @@ func GetCreateCacheRatio(name string) (float64, bool) { } return ratio, true } + +func GetCacheRatioCopy() map[string]float64 { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(cacheRatioMap)) + for k, v := range cacheRatioMap { + copyMap[k] = v + } + return copyMap +} diff --git a/setting/ratio_setting/expose_ratio.go b/setting/ratio_setting/expose_ratio.go new file mode 100644 index 00000000..8fca0bcb --- /dev/null +++ b/setting/ratio_setting/expose_ratio.go @@ -0,0 +1,17 @@ +package ratio_setting + +import "sync/atomic" + +var exposeRatioEnabled atomic.Bool + +func init() { + exposeRatioEnabled.Store(false) +} + +func SetExposeRatioEnabled(enabled bool) { + exposeRatioEnabled.Store(enabled) +} + +func IsExposeRatioEnabled() bool { + return exposeRatioEnabled.Load() +} \ No newline at end of file diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go new file mode 100644 index 00000000..9e5b6c30 --- /dev/null +++ b/setting/ratio_setting/exposed_cache.go @@ -0,0 +1,55 @@ +package ratio_setting + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +const exposedDataTTL = 30 * time.Second + +type exposedCache struct { + data gin.H + expiresAt time.Time +} + +var ( + exposedData atomic.Value + rebuildMu sync.Mutex +) + +func InvalidateExposedDataCache() { + exposedData.Store((*exposedCache)(nil)) +} + +func cloneGinH(src gin.H) gin.H { + dst := make(gin.H, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func GetExposedData() gin.H { + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + rebuildMu.Lock() + defer rebuildMu.Unlock() + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + newData := gin.H{ + "model_ratio": GetModelRatioCopy(), + "completion_ratio": GetCompletionRatioCopy(), + "cache_ratio": GetCacheRatioCopy(), + "model_price": GetModelPriceCopy(), + } + exposedData.Store(&exposedCache{ + data: newData, + expiresAt: time.Now().Add(exposedDataTTL), + }) + return cloneGinH(newData) +} \ No newline at end of file diff --git a/setting/group_ratio.go b/setting/ratio_setting/group_ratio.go similarity index 62% rename from setting/group_ratio.go rename to setting/ratio_setting/group_ratio.go index 8b163625..f600a7b5 100644 --- a/setting/group_ratio.go +++ b/setting/ratio_setting/group_ratio.go @@ -1,4 +1,4 @@ -package setting +package ratio_setting import ( "encoding/json" @@ -14,10 +14,19 @@ var groupRatio = map[string]float64{ } var groupRatioMutex sync.RWMutex +var ( + GroupGroupRatio = map[string]map[string]float64{ + "vip": { + "edit_this": 0.9, + }, + } + groupGroupRatioMutex sync.RWMutex +) + func GetGroupRatioCopy() map[string]float64 { groupRatioMutex.RLock() defer groupRatioMutex.RUnlock() - + groupRatioCopy := make(map[string]float64) for k, v := range groupRatio { groupRatioCopy[k] = v @@ -28,7 +37,7 @@ func GetGroupRatioCopy() map[string]float64 { func ContainsGroupRatio(name string) bool { groupRatioMutex.RLock() defer groupRatioMutex.RUnlock() - + _, ok := groupRatio[name] return ok } @@ -36,7 +45,7 @@ func ContainsGroupRatio(name string) bool { func GroupRatio2JSONString() string { groupRatioMutex.RLock() defer groupRatioMutex.RUnlock() - + jsonBytes, err := json.Marshal(groupRatio) if err != nil { common.SysError("error marshalling model ratio: " + err.Error()) @@ -47,7 +56,7 @@ func GroupRatio2JSONString() string { func UpdateGroupRatioByJSONString(jsonStr string) error { groupRatioMutex.Lock() defer groupRatioMutex.Unlock() - + groupRatio = make(map[string]float64) return json.Unmarshal([]byte(jsonStr), &groupRatio) } @@ -55,7 +64,7 @@ func UpdateGroupRatioByJSONString(jsonStr string) error { func GetGroupRatio(name string) float64 { groupRatioMutex.RLock() defer groupRatioMutex.RUnlock() - + ratio, ok := groupRatio[name] if !ok { common.SysError("group ratio not found: " + name) @@ -64,6 +73,40 @@ func GetGroupRatio(name string) float64 { return ratio } +func GetGroupGroupRatio(group, name string) (float64, bool) { + groupGroupRatioMutex.RLock() + defer groupGroupRatioMutex.RUnlock() + + gp, ok := GroupGroupRatio[group] + if !ok { + return -1, false + } + ratio, ok := gp[name] + if !ok { + return -1, false + } + return ratio, true +} + +func GroupGroupRatio2JSONString() string { + groupGroupRatioMutex.RLock() + defer groupGroupRatioMutex.RUnlock() + + jsonBytes, err := json.Marshal(GroupGroupRatio) + if err != nil { + common.SysError("error marshalling group-group ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateGroupGroupRatioByJSONString(jsonStr string) error { + groupGroupRatioMutex.Lock() + defer groupGroupRatioMutex.Unlock() + + GroupGroupRatio = make(map[string]map[string]float64) + return json.Unmarshal([]byte(jsonStr), &GroupGroupRatio) +} + func CheckGroupRatio(jsonStr string) error { checkGroupRatio := make(map[string]float64) err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio) diff --git a/setting/operation_setting/model-ratio.go b/setting/ratio_setting/model_ratio.go similarity index 86% rename from setting/operation_setting/model-ratio.go rename to setting/ratio_setting/model_ratio.go index fdc1c950..879423eb 100644 --- a/setting/operation_setting/model-ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -1,8 +1,9 @@ -package operation_setting +package ratio_setting import ( "encoding/json" "one-api/common" + "one-api/setting/operation_setting" "strings" "sync" ) @@ -114,7 +115,9 @@ var defaultModelRatio = map[string]float64{ "claude-3-5-sonnet-20241022": 1.5, "claude-3-7-sonnet-20250219": 1.5, "claude-3-7-sonnet-20250219-thinking": 1.5, + "claude-sonnet-4-20250514": 1.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens + "claude-opus-4-20250514": 7.5, "ERNIE-4.0-8K": 0.120 * RMB, "ERNIE-3.5-8K": 0.012 * RMB, "ERNIE-3.5-8K-0205": 0.024 * RMB, @@ -137,9 +140,17 @@ var defaultModelRatio = map[string]float64{ "gemini-2.0-flash": 0.05, "gemini-2.5-pro-exp-03-25": 0.625, "gemini-2.5-pro-preview-03-25": 0.625, + "gemini-2.5-pro": 0.625, "gemini-2.5-flash-preview-04-17": 0.075, "gemini-2.5-flash-preview-04-17-thinking": 0.075, "gemini-2.5-flash-preview-04-17-nothinking": 0.075, + "gemini-2.5-flash-preview-05-20": 0.075, + "gemini-2.5-flash-preview-05-20-thinking": 0.075, + "gemini-2.5-flash-preview-05-20-nothinking": 0.075, + "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率 + "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率 + "gemini-2.5-flash-lite-preview-06-17": 0.05, + "gemini-2.5-flash": 0.15, "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens @@ -309,7 +320,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error { modelPriceMapMutex.Lock() defer modelPriceMapMutex.Unlock() modelPriceMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &modelPriceMap) + err := json.Unmarshal([]byte(jsonStr), &modelPriceMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false @@ -337,19 +352,33 @@ func UpdateModelRatioByJSONString(jsonStr string) error { modelRatioMapMutex.Lock() defer modelRatioMapMutex.Unlock() modelRatioMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &modelRatioMap) + err := json.Unmarshal([]byte(jsonStr), &modelRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +// 处理带有思考预算的模型名称,方便统一定价 +func handleThinkingBudgetModel(name, prefix, wildcard string) string { + if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") { + return wildcard + } + return name } func GetModelRatio(name string) (float64, bool) { modelRatioMapMutex.RLock() defer modelRatioMapMutex.RUnlock() + name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") if strings.HasPrefix(name, "gpt-4-gizmo") { name = "gpt-4-gizmo-*" } ratio, ok := modelRatioMap[name] if !ok { - return 37.5, SelfUseModeEnabled + return 37.5, operation_setting.SelfUseModeEnabled } return ratio, true } @@ -387,13 +416,22 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error { CompletionRatioMutex.Lock() defer CompletionRatioMutex.Unlock() CompletionRatio = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &CompletionRatio) + err := json.Unmarshal([]byte(jsonStr), &CompletionRatio) + if err == nil { + InvalidateExposedDataCache() + } + return err } func GetCompletionRatio(name string) float64 { CompletionRatioMutex.RLock() defer CompletionRatioMutex.RUnlock() - + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + if strings.HasPrefix(name, "gpt-4o-gizmo") { + name = "gpt-4o-gizmo-*" + } if strings.Contains(name, "/") { if ratio, ok := CompletionRatio[name]; ok { return ratio @@ -411,12 +449,6 @@ func GetCompletionRatio(name string) float64 { func getHardcodedCompletionModelRatio(name string) (float64, bool) { lowercaseName := strings.ToLower(name) - if strings.HasPrefix(name, "gpt-4-gizmo") { - name = "gpt-4-gizmo-*" - } - if strings.HasPrefix(name, "gpt-4o-gizmo") { - name = "gpt-4o-gizmo-*" - } if strings.HasPrefix(name, "gpt-4") && !strings.HasSuffix(name, "-all") && !strings.HasSuffix(name, "-gizmo-*") { if strings.HasPrefix(name, "gpt-4o") { if name == "gpt-4o-2024-05-13" { @@ -440,13 +472,15 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { if name == "chatgpt-4o-latest" { return 3, true } - if strings.Contains(name, "claude-instant-1") { - return 3, true - } else if strings.Contains(name, "claude-2") { - return 3, true - } else if strings.Contains(name, "claude-3") { + + if strings.Contains(name, "claude-3") { return 5, true + } else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") { + return 5, true + } else if strings.Contains(name, "claude-instant-1") || strings.Contains(name, "claude-2") { + return 3, true } + if strings.HasPrefix(name, "gpt-3.5") { if name == "gpt-3.5-turbo" || strings.HasSuffix(name, "0125") { // https://openai.com/blog/new-embedding-models-and-api-updates @@ -466,14 +500,19 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { return 4, true } else if strings.HasPrefix(name, "gemini-2.0") { return 4, true - } else if strings.HasPrefix(name, "gemini-2.5-pro-preview") { + } else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致 return 8, true - } else if strings.HasPrefix(name, "gemini-2.5-flash-preview") { - if strings.HasSuffix(name, "-nothinking") { - return 4, false - } else { - return 3.5 / 0.6, false + } else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率 + if strings.HasPrefix(name, "gemini-2.5-flash-preview") { + if strings.HasSuffix(name, "-nothinking") { + return 4, true + } + return 3.5 / 0.15, true } + if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") { + return 4, true + } + return 2.5 / 0.3, true } return 4, false } @@ -589,3 +628,33 @@ func GetImageRatio(name string) (float64, bool) { } return ratio, true } + +func GetModelRatioCopy() map[string]float64 { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelRatioMap)) + for k, v := range modelRatioMap { + copyMap[k] = v + } + return copyMap +} + +func GetModelPriceCopy() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelPriceMap)) + for k, v := range modelPriceMap { + copyMap[k] = v + } + return copyMap +} + +func GetCompletionRatioCopy() map[string]float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + copyMap := make(map[string]float64, len(CompletionRatio)) + for k, v := range CompletionRatio { + copyMap[k] = v + } + return copyMap +} diff --git a/setting/user_usable_group.go b/setting/user_usable_group.go index 7082b683..fdf2f723 100644 --- a/setting/user_usable_group.go +++ b/setting/user_usable_group.go @@ -50,3 +50,10 @@ func GroupInUserUsableGroups(groupName string) bool { _, ok := userUsableGroups[groupName] return ok } + +func GetUsableGroupDescription(groupName string) string { + if desc, ok := userUsableGroups[groupName]; ok { + return desc + } + return groupName +} diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 07a1fd2a..00000000 --- a/web/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# React Template - -## Basic Usages - -```shell -# Runs the app in the development mode -npm start - -# Builds the app for production to the `build` folder -npm run build -``` - -If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build, -for example: `REACT_APP_SERVER=http://your.domain.com`. - -Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled. - -## Reference - -1. https://github.com/OIerDb-ng/OIerDb -2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example diff --git a/web/bun.lockb b/web/bun.lockb index c71070a6..4d0cdea9 100755 Binary files a/web/bun.lockb and b/web/bun.lockb differ diff --git a/web/package.json b/web/package.json index e6ce588d..a313e0f5 100644 --- a/web/package.json +++ b/web/package.json @@ -6,27 +6,40 @@ "dependencies": { "@douyinfe/semi-icons": "^2.63.1", "@douyinfe/semi-ui": "^2.69.1", + "@lobehub/icons": "^2.0.0", "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", "axios": "^0.27.2", + "clsx": "^2.1.1", + "country-flag-icons": "^1.5.19", "dayjs": "^1.11.11", "history": "^5.3.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^7.2.0", + "katex": "^0.16.22", + "lucide-react": "^0.511.0", "marked": "^4.1.1", + "mermaid": "^11.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-fireworks": "^1.0.4", + "react-i18next": "^13.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.3.0", "react-telegram-login": "^1.1.2", "react-toastify": "^9.0.8", "react-turnstile": "^1.0.5", - "semantic-ui-offline": "^2.5.0", - "semantic-ui-react": "^2.1.3", - "sse": "https://github.com/mpetazzoni/sse.js", - "i18next": "^23.16.8", - "react-i18next": "^13.0.0", - "i18next-browser-languagedetector": "^7.2.0" + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "sse.js": "^2.6.0", + "unist-util-visit": "^5.0.0", + "use-debounce": "^10.0.4" }, "scripts": { "dev": "vite", @@ -54,9 +67,13 @@ ] }, "devDependencies": { + "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", "@so1ve/prettier-config": "^3.1.0", "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", "prettier": "^3.0.0", + "tailwindcss": "^3", "typescript": "4.4.2", "vite": "^5.2.0" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml deleted file mode 100644 index c503b5bb..00000000 --- a/web/pnpm-lock.yaml +++ /dev/null @@ -1,5584 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - .: - dependencies: - '@douyinfe/semi-icons': - specifier: ^2.63.1 - version: 2.77.1(react@18.3.1) - '@douyinfe/semi-ui': - specifier: ^2.69.1 - version: 2.77.1(acorn@8.14.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@visactor/react-vchart': - specifier: ~1.8.8 - version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@visactor/vchart': - specifier: ~1.8.8 - version: 1.8.11 - '@visactor/vchart-semi-theme': - specifier: ~1.8.8 - version: 1.8.8(@visactor/vchart@1.8.11) - axios: - specifier: ^0.27.2 - version: 0.27.2 - dayjs: - specifier: ^1.11.11 - version: 1.11.13 - history: - specifier: ^5.3.0 - version: 5.3.0 - i18next: - specifier: ^23.16.8 - version: 23.16.8 - i18next-browser-languagedetector: - specifier: ^7.2.0 - version: 7.2.2 - marked: - specifier: ^4.1.1 - version: 4.3.0 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - react-dropzone: - specifier: ^14.2.3 - version: 14.3.8(react@18.3.1) - react-fireworks: - specifier: ^1.0.4 - version: 1.0.4 - react-i18next: - specifier: ^13.0.0 - version: 13.5.0(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router-dom: - specifier: ^6.3.0 - version: 6.30.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-telegram-login: - specifier: ^1.1.2 - version: 1.1.2(react@18.3.1) - react-toastify: - specifier: ^9.0.8 - version: 9.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-turnstile: - specifier: ^1.0.5 - version: 1.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - semantic-ui-offline: - specifier: ^2.5.0 - version: 2.5.0 - semantic-ui-react: - specifier: ^2.1.3 - version: 2.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - sse: - specifier: github:mpetazzoni/sse.js - version: sse.js@https://codeload.github.com/mpetazzoni/sse.js/tar.gz/39b9b82aae95fd58d9d08b487845fe230f4b14e6 - devDependencies: - '@so1ve/prettier-config': - specifier: ^3.1.0 - version: 3.1.0(prettier@3.5.3) - '@vitejs/plugin-react': - specifier: ^4.2.1 - version: 4.3.4(vite@5.4.16) - prettier: - specifier: ^3.0.0 - version: 3.5.3 - typescript: - specifier: 4.4.2 - version: 4.4.2 - vite: - specifier: ^5.2.0 - version: 5.4.16 - -packages: - '@ampproject/remapping@2.3.0': - resolution: - { - integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, - } - engines: { node: '>=6.0.0' } - - '@astrojs/compiler@2.11.0': - resolution: - { - integrity: sha512-zZOO7i+JhojO8qmlyR/URui6LyfHJY6m+L9nwyX5GiKD78YoRaZ5tzz6X0fkl+5bD3uwlDHayf6Oe8Fu36RKNg==, - } - - '@babel/code-frame@7.26.2': - resolution: - { - integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==, - } - engines: { node: '>=6.9.0' } - - '@babel/compat-data@7.26.8': - resolution: - { - integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==, - } - engines: { node: '>=6.9.0' } - - '@babel/core@7.26.10': - resolution: - { - integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==, - } - engines: { node: '>=6.9.0' } - - '@babel/generator@7.27.0': - resolution: - { - integrity: sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==, - } - engines: { node: '>=6.9.0' } - - '@babel/helper-compilation-targets@7.27.0': - resolution: - { - integrity: sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==, - } - engines: { node: '>=6.9.0' } - - '@babel/helper-module-imports@7.25.9': - resolution: - { - integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==, - } - engines: { node: '>=6.9.0' } - - '@babel/helper-module-transforms@7.26.0': - resolution: - { - integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==, - } - engines: { node: '>=6.9.0' } - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.26.5': - resolution: - { - integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==, - } - engines: { node: '>=6.9.0' } - - '@babel/helper-string-parser@7.25.9': - resolution: - { - integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==, - } - engines: { node: '>=6.9.0' } - - '@babel/helper-validator-identifier@7.25.9': - resolution: - { - integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==, - } - engines: { node: '>=6.9.0' } - - '@babel/helper-validator-option@7.25.9': - resolution: - { - integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==, - } - engines: { node: '>=6.9.0' } - - '@babel/helpers@7.27.0': - resolution: - { - integrity: sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==, - } - engines: { node: '>=6.9.0' } - - '@babel/parser@7.27.0': - resolution: - { - integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==, - } - engines: { node: '>=6.0.0' } - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.25.9': - resolution: - { - integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==, - } - engines: { node: '>=6.9.0' } - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.25.9': - resolution: - { - integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==, - } - engines: { node: '>=6.9.0' } - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.27.0': - resolution: - { - integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==, - } - engines: { node: '>=6.9.0' } - - '@babel/template@7.27.0': - resolution: - { - integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==, - } - engines: { node: '>=6.9.0' } - - '@babel/traverse@7.27.0': - resolution: - { - integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==, - } - engines: { node: '>=6.9.0' } - - '@babel/types@7.27.0': - resolution: - { - integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==, - } - engines: { node: '>=6.9.0' } - - '@dnd-kit/accessibility@3.1.1': - resolution: - { - integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==, - } - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: - { - integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==, - } - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/sortable@7.0.2': - resolution: - { - integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==, - } - peerDependencies: - '@dnd-kit/core': ^6.0.7 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: - { - integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==, - } - peerDependencies: - react: '>=16.8.0' - - '@douyinfe/semi-animation-react@2.77.1': - resolution: - { - integrity: sha512-imELR02pufgGFkZURfTd9oBUtZPYhHvXv9WsYoRvEoBM9U7yzxrR6Fb/Lc3TH+WHVJ2oZHH2S0APS5t1MceEOw==, - } - - '@douyinfe/semi-animation-styled@2.77.1': - resolution: - { - integrity: sha512-FBRroqVJroel1CXmBgV58ulZHG2xUVInJF7k0FAag54noKKaToEobSxRjiTJ6JHne3ZDU1M6sBqpbzYJElFnPQ==, - } - - '@douyinfe/semi-animation@2.77.1': - resolution: - { - integrity: sha512-Q1D7whvQe0D+mPov8hXeH/e1uR/iBhpGGcW1LCTL2pSVMEZEYGJLf2KeXTTiCIgRVWm0PRH3Sux7auJ64zg7vw==, - } - - '@douyinfe/semi-foundation@2.77.1': - resolution: - { - integrity: sha512-DAXRy8ryLNzbKAiTAv+RrivGCoMU0asv2cO7PNV5aBq0ICB8XXn97FHyZo6Wb5NpqpyMhOaOr8Ro1bfpd0FeaA==, - } - - '@douyinfe/semi-icons@2.77.1': - resolution: - { - integrity: sha512-IbGqYzbjzCoSd+//HlO/Gn1c3XmbulQwGys+JgDfQhYIbPeGyhQfLk56Q7ku3vJGC8BGy7dUmR9MbeTf1UQGtw==, - } - peerDependencies: - react: '>=16.0.0' - - '@douyinfe/semi-illustrations@2.77.1': - resolution: - { - integrity: sha512-FlESLOPaY0SadiSIFcP4gqJUk+CYkd4rHK6YP9bfjmU26v7h1S02H7pGLLV1lS0WnY4j0ad4zqRV9tbXFvba9g==, - } - peerDependencies: - react: '>=16.0.0' - - '@douyinfe/semi-json-viewer-core@2.77.1': - resolution: - { - integrity: sha512-LOW+7ga2OzFIL9pGKftwHfl1kKLTV3x6Cs857iyvq9GIF/GHbAboiHcKUy2OZIHfy66zvP+Focs+yhfZG7IcZw==, - } - - '@douyinfe/semi-theme-default@2.77.1': - resolution: - { - integrity: sha512-Rug75C7jjSqmCP2L2tBI0K4dnXuo4GardzwSzdSjxDkiaIXwOwR5KE0K1FRbKWkQ7xmxbyRu4S6Pff+CDEJ/lA==, - } - - '@douyinfe/semi-ui@2.77.1': - resolution: - { - integrity: sha512-eIy7kr9OleCwlNRby3VICSGScHM23Zt2u7TJpID68qN3WrfQowGaB4wQ/0k5bvpLzv463HQnVWFk5aak+v46yw==, - } - peerDependencies: - react: '>=16.0.0' - react-dom: '>=16.0.0' - - '@esbuild/aix-ppc64@0.21.5': - resolution: - { - integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==, - } - engines: { node: '>=12' } - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: - { - integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==, - } - engines: { node: '>=12' } - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: - { - integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==, - } - engines: { node: '>=12' } - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: - { - integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: - { - integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==, - } - engines: { node: '>=12' } - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: - { - integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: - { - integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==, - } - engines: { node: '>=12' } - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: - { - integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: - { - integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==, - } - engines: { node: '>=12' } - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: - { - integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==, - } - engines: { node: '>=12' } - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: - { - integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==, - } - engines: { node: '>=12' } - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: - { - integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==, - } - engines: { node: '>=12' } - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: - { - integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==, - } - engines: { node: '>=12' } - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: - { - integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==, - } - engines: { node: '>=12' } - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: - { - integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==, - } - engines: { node: '>=12' } - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: - { - integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==, - } - engines: { node: '>=12' } - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: - { - integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-x64@0.21.5': - resolution: - { - integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: - { - integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [openbsd] - - '@esbuild/sunos-x64@0.21.5': - resolution: - { - integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: - { - integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==, - } - engines: { node: '>=12' } - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: - { - integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==, - } - engines: { node: '>=12' } - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: - { - integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==, - } - engines: { node: '>=12' } - cpu: [x64] - os: [win32] - - '@fluentui/react-component-event-listener@0.63.1': - resolution: - { - integrity: sha512-gSMdOh6tI3IJKZFqxfQwbTpskpME0CvxdxGM2tdglmf6ZPVDi0L4+KKIm+2dN8nzb8Ya1A8ZT+Ddq0KmZtwVQg==, - } - peerDependencies: - react: ^16.8.0 || ^17 || ^18 - react-dom: ^16.8.0 || ^17 || ^18 - - '@fluentui/react-component-ref@0.63.1': - resolution: - { - integrity: sha512-8MkXX4+R3i80msdbD4rFpEB4WWq2UDvGwG386g3ckIWbekdvN9z2kWAd9OXhRGqB7QeOsoAGWocp6gAMCivRlw==, - } - peerDependencies: - react: ^16.8.0 || ^17 || ^18 - react-dom: ^16.8.0 || ^17 || ^18 - - '@jridgewell/gen-mapping@0.3.8': - resolution: - { - integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==, - } - engines: { node: '>=6.0.0' } - - '@jridgewell/resolve-uri@3.1.2': - resolution: - { - integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, - } - engines: { node: '>=6.0.0' } - - '@jridgewell/set-array@1.2.1': - resolution: - { - integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==, - } - engines: { node: '>=6.0.0' } - - '@jridgewell/sourcemap-codec@1.5.0': - resolution: - { - integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==, - } - - '@jridgewell/trace-mapping@0.3.25': - resolution: - { - integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==, - } - - '@mdx-js/mdx@3.1.0': - resolution: - { - integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==, - } - - '@popperjs/core@2.11.8': - resolution: - { - integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==, - } - - '@remix-run/router@1.23.0': - resolution: - { - integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==, - } - engines: { node: '>=14.0.0' } - - '@resvg/resvg-js-android-arm-eabi@2.4.1': - resolution: - { - integrity: sha512-AA6f7hS0FAPpvQMhBCf6f1oD1LdlqNXKCxAAPpKh6tR11kqV0YIB9zOlIYgITM14mq2YooLFl6XIbbvmY+jwUw==, - } - engines: { node: '>= 10' } - cpu: [arm] - os: [android] - - '@resvg/resvg-js-android-arm64@2.4.1': - resolution: - { - integrity: sha512-/QleoRdPfsEuH9jUjilYcDtKK/BkmWcK+1LXM8L2nsnf/CI8EnFyv7ZzCj4xAIvZGAy9dTYr/5NZBcTwxG2HQg==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [android] - - '@resvg/resvg-js-darwin-arm64@2.4.1': - resolution: - { - integrity: sha512-U1oMNhea+kAXgiEXgzo7EbFGCD1Edq5aSlQoe6LMly6UjHzgx2W3N5kEXCwU/CgN5FiQhZr7PlSJSlcr7mdhfg==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [darwin] - - '@resvg/resvg-js-darwin-x64@2.4.1': - resolution: - { - integrity: sha512-avyVh6DpebBfHHtTQTZYSr6NG1Ur6TEilk1+H0n7V+g4F7x7WPOo8zL00ZhQCeRQ5H4f8WXNWIEKL8fwqcOkYw==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [darwin] - - '@resvg/resvg-js-linux-arm-gnueabihf@2.4.1': - resolution: - { - integrity: sha512-isY/mdKoBWH4VB5v621co+8l101jxxYjuTkwOLsbW+5RK9EbLciPlCB02M99ThAHzI2MYxIUjXNmNgOW8btXvw==, - } - engines: { node: '>= 10' } - cpu: [arm] - os: [linux] - - '@resvg/resvg-js-linux-arm64-gnu@2.4.1': - resolution: - { - integrity: sha512-uY5voSCrFI8TH95vIYBm5blpkOtltLxLRODyhKJhGfskOI7XkRw5/t1u0sWAGYD8rRSNX+CA+np86otKjubrNg==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [linux] - - '@resvg/resvg-js-linux-arm64-musl@2.4.1': - resolution: - { - integrity: sha512-6mT0+JBCsermKMdi/O2mMk3m7SqOjwi9TKAwSngRZ/nQoL3Z0Z5zV+572ztgbWr0GODB422uD8e9R9zzz38dRQ==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [linux] - - '@resvg/resvg-js-linux-x64-gnu@2.4.1': - resolution: - { - integrity: sha512-60KnrscLj6VGhkYOJEmmzPlqqfcw1keDh6U+vMcNDjPhV3B5vRSkpP/D/a8sfokyeh4VEacPSYkWGezvzS2/mg==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [linux] - - '@resvg/resvg-js-linux-x64-musl@2.4.1': - resolution: - { - integrity: sha512-0AMyZSICC1D7ge115cOZQW8Pcad6PjWuZkBFF3FJuSxC6Dgok0MQnLTs2MfMdKBlAcwO9dXsf3bv9tJZj8pATA==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [linux] - - '@resvg/resvg-js-win32-arm64-msvc@2.4.1': - resolution: - { - integrity: sha512-76XDFOFSa3d0QotmcNyChh2xHwk+JTFiEQBVxMlHpHMeq7hNrQJ1IpE1zcHSQvrckvkdfLboKRrlGB86B10Qjw==, - } - engines: { node: '>= 10' } - cpu: [arm64] - os: [win32] - - '@resvg/resvg-js-win32-ia32-msvc@2.4.1': - resolution: - { - integrity: sha512-odyVFGrEWZIzzJ89KdaFtiYWaIJh9hJRW/frcEcG3agJ464VXkN/2oEVF5ulD+5mpGlug9qJg7htzHcKxDN8sg==, - } - engines: { node: '>= 10' } - cpu: [ia32] - os: [win32] - - '@resvg/resvg-js-win32-x64-msvc@2.4.1': - resolution: - { - integrity: sha512-vY4kTLH2S3bP+puU5x7hlAxHv+ulFgcK6Zn3efKSr0M0KnZ9A3qeAjZteIpkowEFfUeMPNg2dvvoFRJA9zqxSw==, - } - engines: { node: '>= 10' } - cpu: [x64] - os: [win32] - - '@resvg/resvg-js@2.4.1': - resolution: - { - integrity: sha512-wTOf1zerZX8qYcMmLZw3czR4paI4hXqPjShNwJRh5DeHxvgffUS5KM7XwxtbIheUW6LVYT5fhT2AJiP6mU7U4A==, - } - engines: { node: '>= 10' } - - '@rollup/rollup-android-arm-eabi@4.39.0': - resolution: - { - integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==, - } - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.39.0': - resolution: - { - integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==, - } - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.39.0': - resolution: - { - integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==, - } - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.39.0': - resolution: - { - integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==, - } - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.39.0': - resolution: - { - integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==, - } - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.39.0': - resolution: - { - integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==, - } - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.39.0': - resolution: - { - integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==, - } - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.39.0': - resolution: - { - integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==, - } - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.39.0': - resolution: - { - integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==, - } - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.39.0': - resolution: - { - integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==, - } - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loongarch64-gnu@4.39.0': - resolution: - { - integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==, - } - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-powerpc64le-gnu@4.39.0': - resolution: - { - integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==, - } - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.39.0': - resolution: - { - integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==, - } - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.39.0': - resolution: - { - integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==, - } - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.39.0': - resolution: - { - integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==, - } - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.39.0': - resolution: - { - integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==, - } - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.39.0': - resolution: - { - integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==, - } - cpu: [x64] - os: [linux] - - '@rollup/rollup-win32-arm64-msvc@4.39.0': - resolution: - { - integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==, - } - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.39.0': - resolution: - { - integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==, - } - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.39.0': - resolution: - { - integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==, - } - cpu: [x64] - os: [win32] - - '@semantic-ui-react/event-stack@3.1.3': - resolution: - { - integrity: sha512-FdTmJyWvJaYinHrKRsMLDrz4tTMGdFfds299Qory53hBugiDvGC0tEJf+cHsi5igDwWb/CLOgOiChInHwq8URQ==, - } - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 - - '@so1ve/prettier-config@3.1.0': - resolution: - { - integrity: sha512-9GJ1yXKBC4DzqCTTaZoBf8zw7WWkVuXcccZt1Aqk4lj6ab/GiNUnjPGajUVYLjaqAEOKqM7jUSUfTjk2JTjCAg==, - } - peerDependencies: - prettier: ^3.0.0 - - '@so1ve/prettier-plugin-toml@3.1.0': - resolution: - { - integrity: sha512-8WZAGjAVNIJlkfWL6wHKxlUuEBY45fdd5qY5bR/Z6r/txgzKXk/r9qi1DTwc17gi/WcNuRrcRugecRT+mWbIYg==, - } - peerDependencies: - prettier: ^3.0.0 - - '@turf/boolean-clockwise@6.5.0': - resolution: - { - integrity: sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==, - } - - '@turf/clone@6.5.0': - resolution: - { - integrity: sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw==, - } - - '@turf/flatten@6.5.0': - resolution: - { - integrity: sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ==, - } - - '@turf/helpers@6.5.0': - resolution: - { - integrity: sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==, - } - - '@turf/invariant@6.5.0': - resolution: - { - integrity: sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==, - } - - '@turf/meta@3.14.0': - resolution: - { - integrity: sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg==, - } - - '@turf/meta@6.5.0': - resolution: - { - integrity: sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==, - } - - '@turf/rewind@6.5.0': - resolution: - { - integrity: sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ==, - } - - '@types/babel__core@7.20.5': - resolution: - { - integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, - } - - '@types/babel__generator@7.6.8': - resolution: - { - integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==, - } - - '@types/babel__template@7.4.4': - resolution: - { - integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==, - } - - '@types/babel__traverse@7.20.7': - resolution: - { - integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==, - } - - '@types/debug@4.1.12': - resolution: - { - integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==, - } - - '@types/estree-jsx@1.0.5': - resolution: - { - integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==, - } - - '@types/estree@1.0.7': - resolution: - { - integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==, - } - - '@types/hast@3.0.4': - resolution: - { - integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==, - } - - '@types/mdast@4.0.4': - resolution: - { - integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==, - } - - '@types/mdx@2.0.13': - resolution: - { - integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==, - } - - '@types/ms@2.1.0': - resolution: - { - integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==, - } - - '@types/parse-author@2.0.3': - resolution: - { - integrity: sha512-pgRW2K/GVQoogylrGJXDl7PBLW9A6T4OOc9Hy9MLT5f7vgufK2GQ8FcfAbjFHR5HjcN9ByzuCczAORk49REqoA==, - } - - '@types/parse-json@4.0.2': - resolution: - { - integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==, - } - - '@types/unist@2.0.11': - resolution: - { - integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==, - } - - '@types/unist@3.0.3': - resolution: - { - integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, - } - - '@ungap/structured-clone@1.3.0': - resolution: - { - integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==, - } - - '@visactor/react-vchart@1.8.11': - resolution: - { - integrity: sha512-wHnCex9gOpnttTtSu04ozKJhTveUk8Ln2KX/7PZyCJxqlXq+eWvW4zvM6Ja8T8kGXfXtFYVVNh9zBMQ7y2T/Sw==, - } - peerDependencies: - react: '>=16.0.0' - react-dom: '>=16.0.0' - - '@visactor/vchart-semi-theme@1.8.8': - resolution: - { - integrity: sha512-lm57CX3r6Bm7iGBYYyWhDY+1BvkyhNVLEckKx2PnlPKpJHikKSIK2ACyI5SmHuSOOdYzhY2QK6ZfYa2NShJ83w==, - } - peerDependencies: - '@visactor/vchart': ~1.8.8 - - '@visactor/vchart-theme-utils@1.8.8': - resolution: - { - integrity: sha512-RdCey3/t0+82EYyFZvx210rgJJWti9rsgcL3ROZS7o9CtRW1CMj9u9LKLDNIcPLNcLNACFC0aoT03jpdD1BCpA==, - } - peerDependencies: - '@visactor/vchart': ~1.8.8 - - '@visactor/vchart@1.8.11': - resolution: - { - integrity: sha512-RdQ822J02GgAQNXvO1LiT0T3O6FjdgPdcm9hVBFyrpBBmuI8MH02IE7Y1kGe9NiFTH4tDwP0ixRgBmqNSGSLZQ==, - } - - '@visactor/vdataset@0.17.5': - resolution: - { - integrity: sha512-zVBdLWHWrhldGc8JDjSYF9lvpFT4ZEFQDB0b6yvfSiHzHKHiSco+rWmUFvA7r4ObT6j2QWF1vZAV9To8Ml4vHw==, - } - - '@visactor/vgrammar-coordinate@0.10.11': - resolution: - { - integrity: sha512-XSUvEkaf/NQHFafmTwqoIMZicp9fF3o6NB2FDpuWrK4DI1lTuip/0RkqrC+kBAjc5erjt0em0TiITyqXpp4G6w==, - } - - '@visactor/vgrammar-core@0.10.11': - resolution: - { - integrity: sha512-VL9vcLPDg1LrHl7EOx0Ga9ATsoaChKIaCGzxjrPEjWiIS5VPU9Rs0jBKP+ch8BjamAoSuqL5mKd0L/RaUBqlaA==, - } - - '@visactor/vgrammar-hierarchy@0.10.11': - resolution: - { - integrity: sha512-0r3k51pPlJHu63BduG3htsV/ul62aVcKJxFftRfvKkwGjm1KeHoOZEEAwIf78U2puio0BkLqVn2Ek2L4FYZaIg==, - } - - '@visactor/vgrammar-projection@0.10.11': - resolution: - { - integrity: sha512-yEiKsxdfs5+g60wv5xZ1kyS/EDrAsUzAxCMpFFASVUYbQObHvW+elm+UPq2TBX6KZqAM0gsd1inzaLvfsCrLSg==, - } - - '@visactor/vgrammar-sankey@0.10.11': - resolution: - { - integrity: sha512-BbJTPuyydsL/L5XtQv59Q82GgJeePY7Wleac798usx3GnDK0GAOrPsI3bubSsOESJ4pNk3V4HPGEQDG1vCPb4w==, - } - - '@visactor/vgrammar-util@0.10.11': - resolution: - { - integrity: sha512-cJZLmKZvN95Y+yGhX+28+UpZu3bhYYlXDlHJNvXHyonI76ZYgtceyon2b3lI6XIsUsBGcD4Uo777s949X5os3g==, - } - - '@visactor/vgrammar-wordcloud-shape@0.10.11': - resolution: - { - integrity: sha512-NsQOYJp+9WHnIApMvkcUOaajxIg5U/r6rD8LKnoXW/HqAN2TFYXcRR3Daqmk9rrpM5VztQimKOsA1yZWyzozrA==, - } - - '@visactor/vgrammar-wordcloud@0.10.11': - resolution: - { - integrity: sha512-JWDqjGhr9JlYkKVBeEkiOqLQk7C1x1BtnsZ+E8oN541gzUqHwfS9qZyhwI3OyoSLewJlsSSPu1vXLKSQzLzKPA==, - } - - '@visactor/vrender-components@0.17.17': - resolution: - { - integrity: sha512-7gYFQrozvBkyGF7s/JHXdWDZnATzymxzug63CZd4EB7A0OXKatVDImXRePqwzlPD3QamF7QMVWn0CuIx3gQ2gA==, - } - - '@visactor/vrender-core@0.17.17': - resolution: - { - integrity: sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ==, - } - - '@visactor/vrender-kits@0.17.17': - resolution: - { - integrity: sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw==, - } - - '@visactor/vscale@0.17.5': - resolution: - { - integrity: sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA==, - } - - '@visactor/vutils-extension@1.8.11': - resolution: - { - integrity: sha512-Hknzpy3+xh4sdL0iSn5N93BHiMJF4FdwSwhHYEibRpriZmWKG6wBxsJ0Bll4d7oS4f+svxt8Sg2vRYKzQEcIxQ==, - } - - '@visactor/vutils@0.17.5': - resolution: - { - integrity: sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw==, - } - - '@vitejs/plugin-react@4.3.4': - resolution: - { - integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==, - } - engines: { node: ^14.18.0 || >=16.0.0 } - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 - - abs-svg-path@0.1.1: - resolution: - { - integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==, - } - - acorn-jsx@5.3.2: - resolution: - { - integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, - } - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.14.1: - resolution: - { - integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==, - } - engines: { node: '>=0.4.0' } - hasBin: true - - array-source@0.0.4: - resolution: - { - integrity: sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw==, - } - - astring@1.9.0: - resolution: - { - integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==, - } - hasBin: true - - async-validator@3.5.2: - resolution: - { - integrity: sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ==, - } - - asynckit@0.4.0: - resolution: - { - integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, - } - - attr-accept@2.2.5: - resolution: - { - integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==, - } - engines: { node: '>=4' } - - author-regex@1.0.0: - resolution: - { - integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==, - } - engines: { node: '>=0.8' } - - axios@0.27.2: - resolution: - { - integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==, - } - - bail@2.0.2: - resolution: - { - integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==, - } - - balanced-match@1.0.2: - resolution: - { - integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, - } - - bezier-easing@2.1.0: - resolution: - { - integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==, - } - - brace-expansion@1.1.11: - resolution: - { - integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, - } - - browserslist@4.24.4: - resolution: - { - integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==, - } - engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } - hasBin: true - - buffer-from@1.1.2: - resolution: - { - integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, - } - - call-bind-apply-helpers@1.0.2: - resolution: - { - integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==, - } - engines: { node: '>= 0.4' } - - callsites@3.1.0: - resolution: - { - integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, - } - engines: { node: '>=6' } - - caniuse-lite@1.0.30001709: - resolution: - { - integrity: sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==, - } - - ccount@2.0.1: - resolution: - { - integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==, - } - - character-entities-html4@2.1.0: - resolution: - { - integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==, - } - - character-entities-legacy@3.0.0: - resolution: - { - integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==, - } - - character-entities@2.0.2: - resolution: - { - integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==, - } - - character-reference-invalid@2.0.1: - resolution: - { - integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==, - } - - classnames@2.5.1: - resolution: - { - integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==, - } - - clsx@1.2.1: - resolution: - { - integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==, - } - engines: { node: '>=6' } - - collapse-white-space@2.1.0: - resolution: - { - integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==, - } - - color-convert@2.0.1: - resolution: - { - integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, - } - engines: { node: '>=7.0.0' } - - color-name@1.1.4: - resolution: - { - integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, - } - - combined-stream@1.0.8: - resolution: - { - integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, - } - engines: { node: '>= 0.8' } - - comma-separated-tokens@2.0.3: - resolution: - { - integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==, - } - - commander@2.20.3: - resolution: - { - integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==, - } - - commander@4.1.1: - resolution: - { - integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==, - } - engines: { node: '>= 6' } - - compute-scroll-into-view@1.0.20: - resolution: - { - integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==, - } - - concat-map@0.0.1: - resolution: - { - integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, - } - - concat-stream@1.4.11: - resolution: - { - integrity: sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw==, - } - engines: { '0': node >= 0.8 } - - concat-stream@2.0.0: - resolution: - { - integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==, - } - engines: { '0': node >= 6.0 } - - convert-source-map@2.0.0: - resolution: - { - integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, - } - - copy-text-to-clipboard@2.2.0: - resolution: - { - integrity: sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ==, - } - engines: { node: '>=6' } - - core-util-is@1.0.3: - resolution: - { - integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==, - } - - cosmiconfig@7.1.0: - resolution: - { - integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==, - } - engines: { node: '>=10' } - - d3-array@1.2.4: - resolution: - { - integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==, - } - - d3-dsv@2.0.0: - resolution: - { - integrity: sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==, - } - hasBin: true - - d3-geo@1.12.1: - resolution: - { - integrity: sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==, - } - - d3-hexbin@0.2.2: - resolution: - { - integrity: sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==, - } - - d3-hierarchy@3.1.2: - resolution: - { - integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==, - } - engines: { node: '>=12' } - - date-fns-tz@1.3.8: - resolution: - { - integrity: sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ==, - } - peerDependencies: - date-fns: '>=2.0.0' - - date-fns@2.30.0: - resolution: - { - integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==, - } - engines: { node: '>=0.11' } - - dayjs@1.11.13: - resolution: - { - integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==, - } - - debug@4.4.0: - resolution: - { - integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==, - } - engines: { node: '>=6.0' } - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decode-named-character-reference@1.1.0: - resolution: - { - integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==, - } - - delayed-stream@1.0.0: - resolution: - { - integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, - } - engines: { node: '>=0.4.0' } - - dequal@2.0.3: - resolution: - { - integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, - } - engines: { node: '>=6' } - - devlop@1.1.0: - resolution: - { - integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, - } - - dunder-proto@1.0.1: - resolution: - { - integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, - } - engines: { node: '>= 0.4' } - - electron-to-chromium@1.5.130: - resolution: - { - integrity: sha512-Ou2u7L9j2XLZbhqzyX0jWDj6gA8D3jIfVzt4rikLf3cGBa0VdReuFimBKS9tQJA4+XpeCxj1NoWlfBXzbMa9IA==, - } - - error-ex@1.3.2: - resolution: - { - integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==, - } - - es-define-property@1.0.1: - resolution: - { - integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==, - } - engines: { node: '>= 0.4' } - - es-errors@1.3.0: - resolution: - { - integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==, - } - engines: { node: '>= 0.4' } - - es-object-atoms@1.1.1: - resolution: - { - integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==, - } - engines: { node: '>= 0.4' } - - es-set-tostringtag@2.1.0: - resolution: - { - integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==, - } - engines: { node: '>= 0.4' } - - esast-util-from-estree@2.0.0: - resolution: - { - integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==, - } - - esast-util-from-js@2.0.1: - resolution: - { - integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==, - } - - esbuild@0.21.5: - resolution: - { - integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==, - } - engines: { node: '>=12' } - hasBin: true - - escalade@3.2.0: - resolution: - { - integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, - } - engines: { node: '>=6' } - - escape-string-regexp@5.0.0: - resolution: - { - integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==, - } - engines: { node: '>=12' } - - estree-util-attach-comments@3.0.0: - resolution: - { - integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==, - } - - estree-util-build-jsx@3.0.1: - resolution: - { - integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==, - } - - estree-util-is-identifier-name@3.0.0: - resolution: - { - integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==, - } - - estree-util-scope@1.0.0: - resolution: - { - integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==, - } - - estree-util-to-js@2.0.0: - resolution: - { - integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==, - } - - estree-util-visit@2.0.0: - resolution: - { - integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==, - } - - estree-walker@3.0.3: - resolution: - { - integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, - } - - eventemitter3@4.0.7: - resolution: - { - integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, - } - - exenv@1.2.2: - resolution: - { - integrity: sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==, - } - - extend@3.0.2: - resolution: - { - integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, - } - - fast-copy@3.0.2: - resolution: - { - integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==, - } - - file-selector@2.1.2: - resolution: - { - integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==, - } - engines: { node: '>= 12' } - - file-source@0.6.1: - resolution: - { - integrity: sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA==, - } - - follow-redirects@1.15.9: - resolution: - { - integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==, - } - engines: { node: '>=4.0' } - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.2: - resolution: - { - integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==, - } - engines: { node: '>= 6' } - - fs-extra@10.1.0: - resolution: - { - integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==, - } - engines: { node: '>=12' } - - fs-extra@4.0.3: - resolution: - { - integrity: sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==, - } - - fs.realpath@1.0.0: - resolution: - { - integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==, - } - - fsevents@2.3.3: - resolution: - { - integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, - } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } - os: [darwin] - - function-bind@1.1.2: - resolution: - { - integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, - } - - gensync@1.0.0-beta.2: - resolution: - { - integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, - } - engines: { node: '>=6.9.0' } - - geobuf@3.0.2: - resolution: - { - integrity: sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==, - } - hasBin: true - - geojson-dissolve@3.1.0: - resolution: - { - integrity: sha512-JXHfn+A3tU392HA703gJbjmuHaQOAE/C1KzbELCczFRFux+GdY6zt1nKb1VMBHp4LWeE7gUY2ql+g06vJqhiwQ==, - } - - geojson-flatten@0.2.4: - resolution: - { - integrity: sha512-LiX6Jmot8adiIdZ/fthbcKKPOfWjTQchX/ggHnwMZ2e4b0I243N1ANUos0LvnzepTEsj0+D4fIJ5bKhBrWnAHA==, - } - hasBin: true - - geojson-linestring-dissolve@0.0.1: - resolution: - { - integrity: sha512-Y8I2/Ea28R/Xeki7msBcpMvJL2TaPfaPKP8xqueJfQ9/jEhps+iOJxOR2XCBGgVb12Z6XnDb1CMbaPfLepsLaw==, - } - - get-intrinsic@1.3.0: - resolution: - { - integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==, - } - engines: { node: '>= 0.4' } - - get-proto@1.0.1: - resolution: - { - integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==, - } - engines: { node: '>= 0.4' } - - get-stdin@6.0.0: - resolution: - { - integrity: sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==, - } - engines: { node: '>=4' } - - glob@7.2.3: - resolution: - { - integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, - } - deprecated: Glob versions prior to v9 are no longer supported - - globals@11.12.0: - resolution: - { - integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==, - } - engines: { node: '>=4' } - - gopd@1.2.0: - resolution: - { - integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==, - } - engines: { node: '>= 0.4' } - - graceful-fs@4.2.11: - resolution: - { - integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, - } - - has-symbols@1.1.0: - resolution: - { - integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==, - } - engines: { node: '>= 0.4' } - - has-tostringtag@1.0.2: - resolution: - { - integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, - } - engines: { node: '>= 0.4' } - - hasown@2.0.2: - resolution: - { - integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, - } - engines: { node: '>= 0.4' } - - hast-util-to-estree@3.1.3: - resolution: - { - integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==, - } - - hast-util-to-jsx-runtime@2.3.6: - resolution: - { - integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==, - } - - hast-util-whitespace@3.0.0: - resolution: - { - integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, - } - - history@5.3.0: - resolution: - { - integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==, - } - - html-parse-stringify@3.0.1: - resolution: - { - integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==, - } - - i18next-browser-languagedetector@7.2.2: - resolution: - { - integrity: sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ==, - } - - i18next@23.16.8: - resolution: - { - integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==, - } - - iconv-lite@0.4.24: - resolution: - { - integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, - } - engines: { node: '>=0.10.0' } - - ieee754@1.2.1: - resolution: - { - integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, - } - - import-fresh@3.3.1: - resolution: - { - integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, - } - engines: { node: '>=6' } - - inflight@1.0.6: - resolution: - { - integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==, - } - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: - { - integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, - } - - inline-style-parser@0.2.4: - resolution: - { - integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==, - } - - is-alphabetical@2.0.1: - resolution: - { - integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==, - } - - is-alphanumerical@2.0.1: - resolution: - { - integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==, - } - - is-arrayish@0.2.1: - resolution: - { - integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, - } - - is-decimal@2.0.1: - resolution: - { - integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==, - } - - is-hexadecimal@2.0.1: - resolution: - { - integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==, - } - - is-plain-obj@4.1.0: - resolution: - { - integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==, - } - engines: { node: '>=12' } - - isarray@0.0.1: - resolution: - { - integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==, - } - - jquery@3.7.1: - resolution: - { - integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==, - } - - js-tokens@4.0.0: - resolution: - { - integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, - } - - jsesc@3.1.0: - resolution: - { - integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, - } - engines: { node: '>=6' } - hasBin: true - - json-parse-even-better-errors@2.3.1: - resolution: - { - integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, - } - - json5@2.2.3: - resolution: - { - integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, - } - engines: { node: '>=6' } - hasBin: true - - jsonc-parser@3.3.1: - resolution: - { - integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==, - } - - jsonfile@4.0.0: - resolution: - { - integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==, - } - - jsonfile@6.1.0: - resolution: - { - integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==, - } - - keyboard-key@1.1.0: - resolution: - { - integrity: sha512-qkBzPTi3rlAKvX7k0/ub44sqOfXeLc/jcnGGmj5c7BJpU8eDrEVPyhCvNYAaoubbsLm9uGWwQJO1ytQK1a9/dQ==, - } - - lines-and-columns@1.2.4: - resolution: - { - integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, - } - - lodash-es@4.17.21: - resolution: - { - integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==, - } - - lodash@4.17.21: - resolution: - { - integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==, - } - - longest-streak@3.1.0: - resolution: - { - integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==, - } - - loose-envify@1.4.0: - resolution: - { - integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, - } - hasBin: true - - lottie-web@5.12.2: - resolution: - { - integrity: sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg==, - } - - lru-cache@5.1.1: - resolution: - { - integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, - } - - markdown-extensions@2.0.0: - resolution: - { - integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==, - } - engines: { node: '>=16' } - - markdown-table@3.0.4: - resolution: - { - integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==, - } - - marked@4.3.0: - resolution: - { - integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==, - } - engines: { node: '>= 12' } - hasBin: true - - math-intrinsics@1.1.0: - resolution: - { - integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, - } - engines: { node: '>= 0.4' } - - mdast-util-find-and-replace@3.0.2: - resolution: - { - integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==, - } - - mdast-util-from-markdown@2.0.2: - resolution: - { - integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==, - } - - mdast-util-gfm-autolink-literal@2.0.1: - resolution: - { - integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==, - } - - mdast-util-gfm-footnote@2.1.0: - resolution: - { - integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==, - } - - mdast-util-gfm-strikethrough@2.0.0: - resolution: - { - integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==, - } - - mdast-util-gfm-table@2.0.0: - resolution: - { - integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==, - } - - mdast-util-gfm-task-list-item@2.0.0: - resolution: - { - integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==, - } - - mdast-util-gfm@3.1.0: - resolution: - { - integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==, - } - - mdast-util-mdx-expression@2.0.1: - resolution: - { - integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==, - } - - mdast-util-mdx-jsx@3.2.0: - resolution: - { - integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==, - } - - mdast-util-mdx@3.0.0: - resolution: - { - integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==, - } - - mdast-util-mdxjs-esm@2.0.1: - resolution: - { - integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==, - } - - mdast-util-phrasing@4.1.0: - resolution: - { - integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==, - } - - mdast-util-to-hast@13.2.0: - resolution: - { - integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==, - } - - mdast-util-to-markdown@2.1.2: - resolution: - { - integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==, - } - - mdast-util-to-string@4.0.0: - resolution: - { - integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==, - } - - memoize-one@5.2.1: - resolution: - { - integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==, - } - - micromark-core-commonmark@2.0.3: - resolution: - { - integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==, - } - - micromark-extension-gfm-autolink-literal@2.1.0: - resolution: - { - integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==, - } - - micromark-extension-gfm-footnote@2.1.0: - resolution: - { - integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==, - } - - micromark-extension-gfm-strikethrough@2.1.0: - resolution: - { - integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==, - } - - micromark-extension-gfm-table@2.1.1: - resolution: - { - integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==, - } - - micromark-extension-gfm-tagfilter@2.0.0: - resolution: - { - integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==, - } - - micromark-extension-gfm-task-list-item@2.1.0: - resolution: - { - integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==, - } - - micromark-extension-gfm@3.0.0: - resolution: - { - integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==, - } - - micromark-extension-mdx-expression@3.0.1: - resolution: - { - integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==, - } - - micromark-extension-mdx-jsx@3.0.2: - resolution: - { - integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==, - } - - micromark-extension-mdx-md@2.0.0: - resolution: - { - integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==, - } - - micromark-extension-mdxjs-esm@3.0.0: - resolution: - { - integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==, - } - - micromark-extension-mdxjs@3.0.0: - resolution: - { - integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==, - } - - micromark-factory-destination@2.0.1: - resolution: - { - integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==, - } - - micromark-factory-label@2.0.1: - resolution: - { - integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==, - } - - micromark-factory-mdx-expression@2.0.3: - resolution: - { - integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==, - } - - micromark-factory-space@2.0.1: - resolution: - { - integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==, - } - - micromark-factory-title@2.0.1: - resolution: - { - integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==, - } - - micromark-factory-whitespace@2.0.1: - resolution: - { - integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==, - } - - micromark-util-character@2.1.1: - resolution: - { - integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==, - } - - micromark-util-chunked@2.0.1: - resolution: - { - integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==, - } - - micromark-util-classify-character@2.0.1: - resolution: - { - integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==, - } - - micromark-util-combine-extensions@2.0.1: - resolution: - { - integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==, - } - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: - { - integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==, - } - - micromark-util-decode-string@2.0.1: - resolution: - { - integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==, - } - - micromark-util-encode@2.0.1: - resolution: - { - integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==, - } - - micromark-util-events-to-acorn@2.0.3: - resolution: - { - integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==, - } - - micromark-util-html-tag-name@2.0.1: - resolution: - { - integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==, - } - - micromark-util-normalize-identifier@2.0.1: - resolution: - { - integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==, - } - - micromark-util-resolve-all@2.0.1: - resolution: - { - integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==, - } - - micromark-util-sanitize-uri@2.0.1: - resolution: - { - integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==, - } - - micromark-util-subtokenize@2.1.0: - resolution: - { - integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==, - } - - micromark-util-symbol@2.0.1: - resolution: - { - integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==, - } - - micromark-util-types@2.0.2: - resolution: - { - integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==, - } - - micromark@4.0.2: - resolution: - { - integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==, - } - - mime-db@1.52.0: - resolution: - { - integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, - } - engines: { node: '>= 0.6' } - - mime-types@2.1.35: - resolution: - { - integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, - } - engines: { node: '>= 0.6' } - - minimatch@3.1.2: - resolution: - { - integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, - } - - minimist@1.2.0: - resolution: - { - integrity: sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==, - } - - minimist@1.2.6: - resolution: - { - integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==, - } - - ms@2.1.3: - resolution: - { - integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, - } - - nanoid@3.3.11: - resolution: - { - integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, - } - engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } - hasBin: true - - node-releases@2.0.19: - resolution: - { - integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==, - } - - object-assign@4.1.1: - resolution: - { - integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, - } - engines: { node: '>=0.10.0' } - - once@1.4.0: - resolution: - { - integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, - } - - parent-module@1.0.1: - resolution: - { - integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, - } - engines: { node: '>=6' } - - parse-author@2.0.0: - resolution: - { - integrity: sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==, - } - engines: { node: '>=0.10.0' } - - parse-entities@4.0.2: - resolution: - { - integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==, - } - - parse-json@5.2.0: - resolution: - { - integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, - } - engines: { node: '>=8' } - - parse-svg-path@0.1.2: - resolution: - { - integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==, - } - - path-browserify@1.0.1: - resolution: - { - integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==, - } - - path-data-parser@0.1.0: - resolution: - { - integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==, - } - - path-is-absolute@1.0.1: - resolution: - { - integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, - } - engines: { node: '>=0.10.0' } - - path-source@0.1.3: - resolution: - { - integrity: sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw==, - } - - path-type@4.0.0: - resolution: - { - integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, - } - engines: { node: '>=8' } - - pbf@3.3.0: - resolution: - { - integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==, - } - hasBin: true - - picocolors@1.1.1: - resolution: - { - integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, - } - - point-at-length@1.1.0: - resolution: - { - integrity: sha512-nNHDk9rNEh/91o2Y8kHLzBLNpLf80RYd2gCun9ss+V0ytRSf6XhryBTx071fesktjbachRmGuUbId+JQmzhRXw==, - } - - points-on-curve@0.2.0: - resolution: - { - integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==, - } - - points-on-path@0.2.1: - resolution: - { - integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==, - } - - postcss@8.5.3: - resolution: - { - integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==, - } - engines: { node: ^10 || ^12 || >=14 } - - prettier-package-json@2.8.0: - resolution: - { - integrity: sha512-WxtodH/wWavfw3MR7yK/GrS4pASEQ+iSTkdtSxPJWvqzG55ir5nvbLt9rw5AOiEcqqPCRM92WCtR1rk3TG3JSQ==, - } - hasBin: true - - prettier-plugin-astro@0.14.1: - resolution: - { - integrity: sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==, - } - engines: { node: ^14.15.0 || >=16.0.0 } - - prettier-plugin-curly-and-jsdoc@3.1.0: - resolution: - { - integrity: sha512-4QMOHnLlkP2jTRWS0MFH6j+cuOiXLvXOqCLKbtwwVd8PPyq8NenW5AAwfwqiTNHBQG/DmzViPphRrwgN0XkUVQ==, - } - peerDependencies: - prettier: ^3.0.0 - - prettier-plugin-pkgsort@0.2.1: - resolution: - { - integrity: sha512-/k5MIw84EhgoH7dmq4+6ozHjJ0VYbxbw17g4C+WPGHODkLivGwJoA6U1YPR/KObyRDMQJHXAfXKu++9smg7Jyw==, - } - peerDependencies: - prettier: ^3.0.0 - - prettier@3.5.3: - resolution: - { - integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==, - } - engines: { node: '>=14' } - hasBin: true - - prismjs@1.30.0: - resolution: - { - integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==, - } - engines: { node: '>=6' } - - prop-types@15.8.1: - resolution: - { - integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, - } - - property-information@7.0.0: - resolution: - { - integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==, - } - - protocol-buffers-schema@3.6.0: - resolution: - { - integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==, - } - - react-dom@18.3.1: - resolution: - { - integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==, - } - peerDependencies: - react: ^18.3.1 - - react-draggable@4.4.6: - resolution: - { - integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==, - } - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - - react-dropzone@14.3.8: - resolution: - { - integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==, - } - engines: { node: '>= 10.13' } - peerDependencies: - react: '>= 16.8 || 18.0.0' - - react-fast-compare@3.2.2: - resolution: - { - integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==, - } - - react-fireworks@1.0.4: - resolution: - { - integrity: sha512-jj1a+HTicB4pR6g2lqhVyAox0GTE0TOrZK2XaJFRYOwltgQWeYErZxnvU9+zH/blY+Hpmu9IKyb39OD3KcCMJw==, - } - - react-i18next@13.5.0: - resolution: - { - integrity: sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==, - } - peerDependencies: - i18next: '>= 23.2.3' - react: '>= 16.8.0' - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - - react-is@16.13.1: - resolution: - { - integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, - } - - react-is@18.3.1: - resolution: - { - integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, - } - - react-popper@2.3.0: - resolution: - { - integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==, - } - peerDependencies: - '@popperjs/core': ^2.0.0 - react: ^16.8.0 || ^17 || ^18 - react-dom: ^16.8.0 || ^17 || ^18 - - react-refresh@0.14.2: - resolution: - { - integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==, - } - engines: { node: '>=0.10.0' } - - react-resizable@3.0.5: - resolution: - { - integrity: sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==, - } - peerDependencies: - react: '>= 16.3' - - react-router-dom@6.30.0: - resolution: - { - integrity: sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==, - } - engines: { node: '>=14.0.0' } - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.30.0: - resolution: - { - integrity: sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==, - } - engines: { node: '>=14.0.0' } - peerDependencies: - react: '>=16.8' - - react-telegram-login@1.1.2: - resolution: - { - integrity: sha512-pDP+bvfaklWgnK5O6yvZnIwgky0nnYUU6Zhk0EjdMSkPsLQoOzZRsXIoZnbxyBXhi7346bsxMH+EwwJPTxClDw==, - } - peerDependencies: - react: ^16.13.1 - - react-toastify@9.1.3: - resolution: - { - integrity: sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==, - } - peerDependencies: - react: '>=16' - react-dom: '>=16' - - react-turnstile@1.1.4: - resolution: - { - integrity: sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ==, - } - peerDependencies: - react: '>= 16.13.1' - react-dom: '>= 16.13.1' - - react-window@1.8.11: - resolution: - { - integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==, - } - engines: { node: '>8.0.0' } - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react@18.3.1: - resolution: - { - integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==, - } - engines: { node: '>=0.10.0' } - - readable-stream@1.1.14: - resolution: - { - integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==, - } - - readable-stream@3.6.2: - resolution: - { - integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, - } - engines: { node: '>= 6' } - - recma-build-jsx@1.0.0: - resolution: - { - integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==, - } - - recma-jsx@1.0.0: - resolution: - { - integrity: sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q==, - } - - recma-parse@1.0.0: - resolution: - { - integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==, - } - - recma-stringify@1.0.0: - resolution: - { - integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==, - } - - regenerator-runtime@0.14.1: - resolution: - { - integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, - } - - rehype-recma@1.0.0: - resolution: - { - integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==, - } - - remark-gfm@4.0.1: - resolution: - { - integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==, - } - - remark-mdx@3.1.0: - resolution: - { - integrity: sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==, - } - - remark-parse@11.0.0: - resolution: - { - integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==, - } - - remark-rehype@11.1.2: - resolution: - { - integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==, - } - - remark-stringify@11.0.0: - resolution: - { - integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==, - } - - resolve-from@4.0.0: - resolution: - { - integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, - } - engines: { node: '>=4' } - - resolve-protobuf-schema@2.1.0: - resolution: - { - integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==, - } - - rollup@4.39.0: - resolution: - { - integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==, - } - engines: { node: '>=18.0.0', npm: '>=8.0.0' } - hasBin: true - - roughjs@4.5.2: - resolution: - { - integrity: sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg==, - } - - rw@1.3.3: - resolution: - { - integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==, - } - - s.color@0.0.15: - resolution: - { - integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==, - } - - safe-buffer@5.2.1: - resolution: - { - integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, - } - - safer-buffer@2.1.2: - resolution: - { - integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, - } - - sass-formatter@0.7.9: - resolution: - { - integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==, - } - - scheduler@0.23.2: - resolution: - { - integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, - } - - scroll-into-view-if-needed@2.2.31: - resolution: - { - integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==, - } - - semantic-ui-offline@2.5.0: - resolution: - { - integrity: sha512-Fldx3SfaVtWx5EeCb/5EiJwYkzrGbtsAwVs02xLkeV5z5l8GJmplWEVOeJVjbEpmyiwPWp7cA48JwT5RjbWBVA==, - } - - semantic-ui-react@2.1.5: - resolution: - { - integrity: sha512-nIqmmUNpFHfovEb+RI2w3E2/maZQutd8UIWyRjf1SLse+XF51hI559xbz/sLN3O6RpLjr/echLOOXwKCirPy3Q==, - } - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - - semver@6.3.1: - resolution: - { - integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, - } - hasBin: true - - shallowequal@1.1.0: - resolution: - { - integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==, - } - - shapefile@0.6.6: - resolution: - { - integrity: sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw==, - } - hasBin: true - - simple-statistics@7.8.8: - resolution: - { - integrity: sha512-CUtP0+uZbcbsFpqEyvNDYjJCl+612fNgjT8GaVuvMG7tBuJg8gXGpsP5M7X658zy0IcepWOZ6nPBu1Qb9ezA1w==, - } - - simplify-geojson@1.0.5: - resolution: - { - integrity: sha512-02l1W4UipP5ivNVq6kX15mAzCRIV1oI3tz0FUEyOsNiv1ltuFDjbNhO+nbv/xhbDEtKqWLYuzpWhUsJrjR/ypA==, - } - hasBin: true - - simplify-geometry@0.0.2: - resolution: - { - integrity: sha512-ZEyrplkqgCqDlL7V8GbbYgTLlcnNF+MWWUdy8s8ZeJru50bnI71rDew/I+HG36QS2mPOYAq1ZjwNXxHJ8XOVBw==, - } - - slice-source@0.4.1: - resolution: - { - integrity: sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==, - } - - sort-object-keys@1.1.3: - resolution: - { - integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==, - } - - sort-order@1.1.2: - resolution: - { - integrity: sha512-Q8tOrwB1TSv9fNUXym9st3TZJODtmcOIi2JWCkVNQPrRg17KPwlpwweTEb7pMwUIFMTAgx2/JsQQXEPFzYQj3A==, - } - - source-map-js@1.2.1: - resolution: - { - integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, - } - engines: { node: '>=0.10.0' } - - source-map@0.7.4: - resolution: - { - integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==, - } - engines: { node: '>= 8' } - - space-separated-tokens@2.0.2: - resolution: - { - integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==, - } - - sse.js@https://codeload.github.com/mpetazzoni/sse.js/tar.gz/39b9b82aae95fd58d9d08b487845fe230f4b14e6: - resolution: - { - tarball: https://codeload.github.com/mpetazzoni/sse.js/tar.gz/39b9b82aae95fd58d9d08b487845fe230f4b14e6, - } - version: 2.6.0 - - stream-source@0.3.5: - resolution: - { - integrity: sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g==, - } - - string_decoder@0.10.31: - resolution: - { - integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==, - } - - string_decoder@1.3.0: - resolution: - { - integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, - } - - stringify-entities@4.0.4: - resolution: - { - integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==, - } - - style-to-js@1.1.16: - resolution: - { - integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==, - } - - style-to-object@1.0.8: - resolution: - { - integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==, - } - - suf-log@2.5.3: - resolution: - { - integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==, - } - - text-encoding@0.6.4: - resolution: - { - integrity: sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg==, - } - deprecated: no longer maintained - - topojson-client@3.1.0: - resolution: - { - integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==, - } - hasBin: true - - topojson-server@3.0.1: - resolution: - { - integrity: sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==, - } - hasBin: true - - trim-lines@3.0.1: - resolution: - { - integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==, - } - - trough@2.2.0: - resolution: - { - integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==, - } - - tslib@2.8.1: - resolution: - { - integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, - } - - typedarray@0.0.6: - resolution: - { - integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==, - } - - typedarray@0.0.7: - resolution: - { - integrity: sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ==, - } - - typescript@4.4.2: - resolution: - { - integrity: sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ==, - } - engines: { node: '>=4.2.0' } - hasBin: true - - unified@11.0.5: - resolution: - { - integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==, - } - - unist-util-is@6.0.0: - resolution: - { - integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==, - } - - unist-util-position-from-estree@2.0.0: - resolution: - { - integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==, - } - - unist-util-position@5.0.0: - resolution: - { - integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==, - } - - unist-util-stringify-position@4.0.0: - resolution: - { - integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==, - } - - unist-util-visit-parents@6.0.1: - resolution: - { - integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==, - } - - unist-util-visit@5.0.0: - resolution: - { - integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==, - } - - universalify@0.1.2: - resolution: - { - integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==, - } - engines: { node: '>= 4.0.0' } - - universalify@2.0.1: - resolution: - { - integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==, - } - engines: { node: '>= 10.0.0' } - - update-browserslist-db@1.1.3: - resolution: - { - integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==, - } - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - util-deprecate@1.0.2: - resolution: - { - integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, - } - - utility-types@3.11.0: - resolution: - { - integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==, - } - engines: { node: '>= 4' } - - vfile-message@4.0.2: - resolution: - { - integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==, - } - - vfile@6.0.3: - resolution: - { - integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, - } - - vite@5.4.16: - resolution: - { - integrity: sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==, - } - engines: { node: ^18.0.0 || >=20.0.0 } - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - void-elements@3.1.0: - resolution: - { - integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==, - } - engines: { node: '>=0.10.0' } - - warning@4.0.3: - resolution: - { - integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==, - } - - wrappy@1.0.2: - resolution: - { - integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, - } - - yallist@3.1.1: - resolution: - { - integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, - } - - yaml@1.10.2: - resolution: - { - integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==, - } - engines: { node: '>= 6' } - - zwitch@2.0.4: - resolution: - { - integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==, - } - -snapshots: - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - - '@astrojs/compiler@2.11.0': {} - - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.26.8': {} - - '@babel/core@7.26.10': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.27.0 - '@babel/helper-compilation-targets': 7.27.0 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) - '@babel/helpers': 7.27.0 - '@babel/parser': 7.27.0 - '@babel/template': 7.27.0 - '@babel/traverse': 7.27.0 - '@babel/types': 7.27.0 - convert-source-map: 2.0.0 - debug: 4.4.0 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.27.0': - dependencies: - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.27.0': - dependencies: - '@babel/compat-data': 7.26.8 - '@babel/helper-validator-option': 7.25.9 - browserslist: 4.24.4 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-module-imports@7.25.9': - dependencies: - '@babel/traverse': 7.27.0 - '@babel/types': 7.27.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.27.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.26.5': {} - - '@babel/helper-string-parser@7.25.9': {} - - '@babel/helper-validator-identifier@7.25.9': {} - - '@babel/helper-validator-option@7.25.9': {} - - '@babel/helpers@7.27.0': - dependencies: - '@babel/template': 7.27.0 - '@babel/types': 7.27.0 - - '@babel/parser@7.27.0': - dependencies: - '@babel/types': 7.27.0 - - '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-plugin-utils': 7.26.5 - - '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-plugin-utils': 7.26.5 - - '@babel/runtime@7.27.0': - dependencies: - regenerator-runtime: 0.14.1 - - '@babel/template@7.27.0': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 - - '@babel/traverse@7.27.0': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.27.0 - '@babel/parser': 7.27.0 - '@babel/template': 7.27.0 - '@babel/types': 7.27.0 - debug: 4.4.0 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.27.0': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - - '@dnd-kit/accessibility@3.1.1(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - - '@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@douyinfe/semi-animation-react@2.77.1': - dependencies: - '@douyinfe/semi-animation': 2.77.1 - '@douyinfe/semi-animation-styled': 2.77.1 - classnames: 2.5.1 - - '@douyinfe/semi-animation-styled@2.77.1': {} - - '@douyinfe/semi-animation@2.77.1': - dependencies: - bezier-easing: 2.1.0 - - '@douyinfe/semi-foundation@2.77.1(acorn@8.14.1)': - dependencies: - '@douyinfe/semi-animation': 2.77.1 - '@douyinfe/semi-json-viewer-core': 2.77.1 - '@mdx-js/mdx': 3.1.0(acorn@8.14.1) - async-validator: 3.5.2 - classnames: 2.5.1 - date-fns: 2.30.0 - date-fns-tz: 1.3.8(date-fns@2.30.0) - fast-copy: 3.0.2 - lodash: 4.17.21 - lottie-web: 5.12.2 - memoize-one: 5.2.1 - prismjs: 1.30.0 - remark-gfm: 4.0.1 - scroll-into-view-if-needed: 2.2.31 - transitivePeerDependencies: - - acorn - - supports-color - - '@douyinfe/semi-icons@2.77.1(react@18.3.1)': - dependencies: - classnames: 2.5.1 - react: 18.3.1 - - '@douyinfe/semi-illustrations@2.77.1(react@18.3.1)': - dependencies: - react: 18.3.1 - - '@douyinfe/semi-json-viewer-core@2.77.1': - dependencies: - jsonc-parser: 3.3.1 - - '@douyinfe/semi-theme-default@2.77.1': {} - - '@douyinfe/semi-ui@2.77.1(acorn@8.14.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/sortable': 7.0.2(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - '@douyinfe/semi-animation': 2.77.1 - '@douyinfe/semi-animation-react': 2.77.1 - '@douyinfe/semi-foundation': 2.77.1(acorn@8.14.1) - '@douyinfe/semi-icons': 2.77.1(react@18.3.1) - '@douyinfe/semi-illustrations': 2.77.1(react@18.3.1) - '@douyinfe/semi-theme-default': 2.77.1 - async-validator: 3.5.2 - classnames: 2.5.1 - copy-text-to-clipboard: 2.2.0 - date-fns: 2.30.0 - date-fns-tz: 1.3.8(date-fns@2.30.0) - fast-copy: 3.0.2 - jsonc-parser: 3.3.1 - lodash: 4.17.21 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-resizable: 3.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-window: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - scroll-into-view-if-needed: 2.2.31 - utility-types: 3.11.0 - transitivePeerDependencies: - - acorn - - supports-color - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@fluentui/react-component-event-listener@0.63.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.27.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@fluentui/react-component-ref@0.63.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.27.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 16.13.1 - - '@jridgewell/gen-mapping@0.3.8': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/sourcemap-codec@1.5.0': {} - - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - - '@mdx-js/mdx@3.1.0(acorn@8.14.1)': - dependencies: - '@types/estree': 1.0.7 - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdx': 2.0.13 - collapse-white-space: 2.1.0 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - estree-util-scope: 1.0.0 - estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 - markdown-extensions: 2.0.0 - recma-build-jsx: 1.0.0 - recma-jsx: 1.0.0(acorn@8.14.1) - recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.0 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - source-map: 0.7.4 - unified: 11.0.5 - unist-util-position-from-estree: 2.0.0 - unist-util-stringify-position: 4.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - transitivePeerDependencies: - - acorn - - supports-color - - '@popperjs/core@2.11.8': {} - - '@remix-run/router@1.23.0': {} - - '@resvg/resvg-js-android-arm-eabi@2.4.1': - optional: true - - '@resvg/resvg-js-android-arm64@2.4.1': - optional: true - - '@resvg/resvg-js-darwin-arm64@2.4.1': - optional: true - - '@resvg/resvg-js-darwin-x64@2.4.1': - optional: true - - '@resvg/resvg-js-linux-arm-gnueabihf@2.4.1': - optional: true - - '@resvg/resvg-js-linux-arm64-gnu@2.4.1': - optional: true - - '@resvg/resvg-js-linux-arm64-musl@2.4.1': - optional: true - - '@resvg/resvg-js-linux-x64-gnu@2.4.1': - optional: true - - '@resvg/resvg-js-linux-x64-musl@2.4.1': - optional: true - - '@resvg/resvg-js-win32-arm64-msvc@2.4.1': - optional: true - - '@resvg/resvg-js-win32-ia32-msvc@2.4.1': - optional: true - - '@resvg/resvg-js-win32-x64-msvc@2.4.1': - optional: true - - '@resvg/resvg-js@2.4.1': - optionalDependencies: - '@resvg/resvg-js-android-arm-eabi': 2.4.1 - '@resvg/resvg-js-android-arm64': 2.4.1 - '@resvg/resvg-js-darwin-arm64': 2.4.1 - '@resvg/resvg-js-darwin-x64': 2.4.1 - '@resvg/resvg-js-linux-arm-gnueabihf': 2.4.1 - '@resvg/resvg-js-linux-arm64-gnu': 2.4.1 - '@resvg/resvg-js-linux-arm64-musl': 2.4.1 - '@resvg/resvg-js-linux-x64-gnu': 2.4.1 - '@resvg/resvg-js-linux-x64-musl': 2.4.1 - '@resvg/resvg-js-win32-arm64-msvc': 2.4.1 - '@resvg/resvg-js-win32-ia32-msvc': 2.4.1 - '@resvg/resvg-js-win32-x64-msvc': 2.4.1 - - '@rollup/rollup-android-arm-eabi@4.39.0': - optional: true - - '@rollup/rollup-android-arm64@4.39.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.39.0': - optional: true - - '@rollup/rollup-darwin-x64@4.39.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.39.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.39.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.39.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.39.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.39.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.39.0': - optional: true - - '@rollup/rollup-linux-loongarch64-gnu@4.39.0': - optional: true - - '@rollup/rollup-linux-powerpc64le-gnu@4.39.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.39.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.39.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.39.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.39.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.39.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.39.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.39.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.39.0': - optional: true - - '@semantic-ui-react/event-stack@3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - exenv: 1.2.2 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@so1ve/prettier-config@3.1.0(prettier@3.5.3)': - dependencies: - '@so1ve/prettier-plugin-toml': 3.1.0(prettier@3.5.3) - prettier: 3.5.3 - prettier-plugin-astro: 0.14.1 - prettier-plugin-curly-and-jsdoc: 3.1.0(prettier@3.5.3) - prettier-plugin-pkgsort: 0.2.1(prettier@3.5.3) - - '@so1ve/prettier-plugin-toml@3.1.0(prettier@3.5.3)': - dependencies: - prettier: 3.5.3 - - '@turf/boolean-clockwise@6.5.0': - dependencies: - '@turf/helpers': 6.5.0 - '@turf/invariant': 6.5.0 - - '@turf/clone@6.5.0': - dependencies: - '@turf/helpers': 6.5.0 - - '@turf/flatten@6.5.0': - dependencies: - '@turf/helpers': 6.5.0 - '@turf/meta': 6.5.0 - - '@turf/helpers@6.5.0': {} - - '@turf/invariant@6.5.0': - dependencies: - '@turf/helpers': 6.5.0 - - '@turf/meta@3.14.0': {} - - '@turf/meta@6.5.0': - dependencies: - '@turf/helpers': 6.5.0 - - '@turf/rewind@6.5.0': - dependencies: - '@turf/boolean-clockwise': 6.5.0 - '@turf/clone': 6.5.0 - '@turf/helpers': 6.5.0 - '@turf/invariant': 6.5.0 - '@turf/meta': 6.5.0 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 - '@types/babel__generator': 7.6.8 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.7 - - '@types/babel__generator@7.6.8': - dependencies: - '@babel/types': 7.27.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 - - '@types/babel__traverse@7.20.7': - dependencies: - '@babel/types': 7.27.0 - - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.7 - - '@types/estree@1.0.7': {} - - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdx@2.0.13': {} - - '@types/ms@2.1.0': {} - - '@types/parse-author@2.0.3': {} - - '@types/parse-json@4.0.2': {} - - '@types/unist@2.0.11': {} - - '@types/unist@3.0.3': {} - - '@ungap/structured-clone@1.3.0': {} - - '@visactor/react-vchart@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@visactor/vchart': 1.8.11 - '@visactor/vgrammar-core': 0.10.11 - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vutils': 0.17.5 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 18.3.1 - - '@visactor/vchart-semi-theme@1.8.8(@visactor/vchart@1.8.11)': - dependencies: - '@visactor/vchart': 1.8.11 - '@visactor/vchart-theme-utils': 1.8.8(@visactor/vchart@1.8.11) - - '@visactor/vchart-theme-utils@1.8.8(@visactor/vchart@1.8.11)': - dependencies: - '@visactor/vchart': 1.8.11 - - '@visactor/vchart@1.8.11': - dependencies: - '@visactor/vdataset': 0.17.5 - '@visactor/vgrammar-core': 0.10.11 - '@visactor/vgrammar-hierarchy': 0.10.11 - '@visactor/vgrammar-projection': 0.10.11 - '@visactor/vgrammar-sankey': 0.10.11 - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vgrammar-wordcloud': 0.10.11 - '@visactor/vgrammar-wordcloud-shape': 0.10.11 - '@visactor/vrender-components': 0.17.17 - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vscale': 0.17.5 - '@visactor/vutils': 0.17.5 - '@visactor/vutils-extension': 1.8.11 - - '@visactor/vdataset@0.17.5': - dependencies: - '@turf/flatten': 6.5.0 - '@turf/helpers': 6.5.0 - '@turf/rewind': 6.5.0 - '@visactor/vutils': 0.17.5 - d3-dsv: 2.0.0 - d3-geo: 1.12.1 - d3-hexbin: 0.2.2 - d3-hierarchy: 3.1.2 - eventemitter3: 4.0.7 - geobuf: 3.0.2 - geojson-dissolve: 3.1.0 - path-browserify: 1.0.1 - pbf: 3.3.0 - point-at-length: 1.1.0 - simple-statistics: 7.8.8 - simplify-geojson: 1.0.5 - topojson-client: 3.1.0 - - '@visactor/vgrammar-coordinate@0.10.11': - dependencies: - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vutils': 0.17.5 - - '@visactor/vgrammar-core@0.10.11': - dependencies: - '@visactor/vdataset': 0.17.5 - '@visactor/vgrammar-coordinate': 0.10.11 - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vrender-components': 0.17.17 - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vscale': 0.17.5 - '@visactor/vutils': 0.17.5 - - '@visactor/vgrammar-hierarchy@0.10.11': - dependencies: - '@visactor/vgrammar-core': 0.10.11 - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vutils': 0.17.5 - - '@visactor/vgrammar-projection@0.10.11': - dependencies: - '@visactor/vgrammar-core': 0.10.11 - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vutils': 0.17.5 - d3-geo: 1.12.1 - - '@visactor/vgrammar-sankey@0.10.11': - dependencies: - '@visactor/vgrammar-core': 0.10.11 - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vutils': 0.17.5 - - '@visactor/vgrammar-util@0.10.11': - dependencies: - '@visactor/vutils': 0.17.5 - - '@visactor/vgrammar-wordcloud-shape@0.10.11': - dependencies: - '@visactor/vgrammar-core': 0.10.11 - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vscale': 0.17.5 - '@visactor/vutils': 0.17.5 - - '@visactor/vgrammar-wordcloud@0.10.11': - dependencies: - '@visactor/vgrammar-core': 0.10.11 - '@visactor/vgrammar-util': 0.10.11 - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vutils': 0.17.5 - - '@visactor/vrender-components@0.17.17': - dependencies: - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vscale': 0.17.5 - '@visactor/vutils': 0.17.5 - - '@visactor/vrender-core@0.17.17': - dependencies: - '@visactor/vutils': 0.17.5 - color-convert: 2.0.1 - - '@visactor/vrender-kits@0.17.17': - dependencies: - '@resvg/resvg-js': 2.4.1 - '@visactor/vrender-core': 0.17.17 - '@visactor/vutils': 0.17.5 - roughjs: 4.5.2 - - '@visactor/vscale@0.17.5': - dependencies: - '@visactor/vutils': 0.17.5 - - '@visactor/vutils-extension@1.8.11': - dependencies: - '@visactor/vrender-core': 0.17.17 - '@visactor/vrender-kits': 0.17.17 - '@visactor/vscale': 0.17.5 - '@visactor/vutils': 0.17.5 - - '@visactor/vutils@0.17.5': - dependencies: - '@turf/helpers': 6.5.0 - '@turf/invariant': 6.5.0 - eventemitter3: 4.0.7 - - '@vitejs/plugin-react@4.3.4(vite@5.4.16)': - dependencies: - '@babel/core': 7.26.10 - '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10) - '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10) - '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.4.16 - transitivePeerDependencies: - - supports-color - - abs-svg-path@0.1.1: {} - - acorn-jsx@5.3.2(acorn@8.14.1): - dependencies: - acorn: 8.14.1 - - acorn@8.14.1: {} - - array-source@0.0.4: {} - - astring@1.9.0: {} - - async-validator@3.5.2: {} - - asynckit@0.4.0: {} - - attr-accept@2.2.5: {} - - author-regex@1.0.0: {} - - axios@0.27.2: - dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.2 - transitivePeerDependencies: - - debug - - bail@2.0.2: {} - - balanced-match@1.0.2: {} - - bezier-easing@2.1.0: {} - - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - browserslist@4.24.4: - dependencies: - caniuse-lite: 1.0.30001709 - electron-to-chromium: 1.5.130 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.24.4) - - buffer-from@1.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001709: {} - - ccount@2.0.1: {} - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} - - character-reference-invalid@2.0.1: {} - - classnames@2.5.1: {} - - clsx@1.2.1: {} - - collapse-white-space@2.1.0: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - comma-separated-tokens@2.0.3: {} - - commander@2.20.3: {} - - commander@4.1.1: {} - - compute-scroll-into-view@1.0.20: {} - - concat-map@0.0.1: {} - - concat-stream@1.4.11: - dependencies: - inherits: 2.0.4 - readable-stream: 1.1.14 - typedarray: 0.0.7 - - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 - - convert-source-map@2.0.0: {} - - copy-text-to-clipboard@2.2.0: {} - - core-util-is@1.0.3: {} - - cosmiconfig@7.1.0: - dependencies: - '@types/parse-json': 4.0.2 - import-fresh: 3.3.1 - parse-json: 5.2.0 - path-type: 4.0.0 - yaml: 1.10.2 - - d3-array@1.2.4: {} - - d3-dsv@2.0.0: - dependencies: - commander: 2.20.3 - iconv-lite: 0.4.24 - rw: 1.3.3 - - d3-geo@1.12.1: - dependencies: - d3-array: 1.2.4 - - d3-hexbin@0.2.2: {} - - d3-hierarchy@3.1.2: {} - - date-fns-tz@1.3.8(date-fns@2.30.0): - dependencies: - date-fns: 2.30.0 - - date-fns@2.30.0: - dependencies: - '@babel/runtime': 7.27.0 - - dayjs@1.11.13: {} - - debug@4.4.0: - dependencies: - ms: 2.1.3 - - decode-named-character-reference@1.1.0: - dependencies: - character-entities: 2.0.2 - - delayed-stream@1.0.0: {} - - dequal@2.0.3: {} - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - electron-to-chromium@1.5.130: {} - - error-ex@1.3.2: - dependencies: - is-arrayish: 0.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - esast-util-from-estree@2.0.0: - dependencies: - '@types/estree-jsx': 1.0.5 - devlop: 1.1.0 - estree-util-visit: 2.0.0 - unist-util-position-from-estree: 2.0.0 - - esast-util-from-js@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - acorn: 8.14.1 - esast-util-from-estree: 2.0.0 - vfile-message: 4.0.2 - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - escalade@3.2.0: {} - - escape-string-regexp@5.0.0: {} - - estree-util-attach-comments@3.0.0: - dependencies: - '@types/estree': 1.0.7 - - estree-util-build-jsx@3.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - estree-walker: 3.0.3 - - estree-util-is-identifier-name@3.0.0: {} - - estree-util-scope@1.0.0: - dependencies: - '@types/estree': 1.0.7 - devlop: 1.1.0 - - estree-util-to-js@2.0.0: - dependencies: - '@types/estree-jsx': 1.0.5 - astring: 1.9.0 - source-map: 0.7.4 - - estree-util-visit@2.0.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/unist': 3.0.3 - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.7 - - eventemitter3@4.0.7: {} - - exenv@1.2.2: {} - - extend@3.0.2: {} - - fast-copy@3.0.2: {} - - file-selector@2.1.2: - dependencies: - tslib: 2.8.1 - - file-source@0.6.1: - dependencies: - stream-source: 0.3.5 - - follow-redirects@1.15.9: {} - - form-data@4.0.2: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - mime-types: 2.1.35 - - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - - fs-extra@4.0.3: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - geobuf@3.0.2: - dependencies: - concat-stream: 2.0.0 - pbf: 3.3.0 - shapefile: 0.6.6 - - geojson-dissolve@3.1.0: - dependencies: - '@turf/meta': 3.14.0 - geojson-flatten: 0.2.4 - geojson-linestring-dissolve: 0.0.1 - topojson-client: 3.1.0 - topojson-server: 3.0.1 - - geojson-flatten@0.2.4: - dependencies: - get-stdin: 6.0.0 - minimist: 1.2.0 - - geojson-linestring-dissolve@0.0.1: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stdin@6.0.0: {} - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@11.12.0: {} - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hast-util-to-estree@3.1.3: - dependencies: - '@types/estree': 1.0.7 - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-attach-comments: 3.0.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.0.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.16 - unist-util-position: 5.0.0 - zwitch: 2.0.4 - transitivePeerDependencies: - - supports-color - - hast-util-to-jsx-runtime@2.3.6: - dependencies: - '@types/estree': 1.0.7 - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.0.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.16 - unist-util-position: 5.0.0 - vfile-message: 4.0.2 - transitivePeerDependencies: - - supports-color - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.4 - - history@5.3.0: - dependencies: - '@babel/runtime': 7.27.0 - - html-parse-stringify@3.0.1: - dependencies: - void-elements: 3.1.0 - - i18next-browser-languagedetector@7.2.2: - dependencies: - '@babel/runtime': 7.27.0 - - i18next@23.16.8: - dependencies: - '@babel/runtime': 7.27.0 - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - inline-style-parser@0.2.4: {} - - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - - is-arrayish@0.2.1: {} - - is-decimal@2.0.1: {} - - is-hexadecimal@2.0.1: {} - - is-plain-obj@4.1.0: {} - - isarray@0.0.1: {} - - jquery@3.7.1: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json-parse-even-better-errors@2.3.1: {} - - json5@2.2.3: {} - - jsonc-parser@3.3.1: {} - - jsonfile@4.0.0: - optionalDependencies: - graceful-fs: 4.2.11 - - jsonfile@6.1.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - keyboard-key@1.1.0: {} - - lines-and-columns@1.2.4: {} - - lodash-es@4.17.21: {} - - lodash@4.17.21: {} - - longest-streak@3.1.0: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - lottie-web@5.12.2: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - markdown-extensions@2.0.0: {} - - markdown-table@3.0.4: {} - - marked@4.3.0: {} - - math-intrinsics@1.1.0: {} - - mdast-util-find-and-replace@3.0.2: - dependencies: - '@types/mdast': 4.0.4 - escape-string-regexp: 5.0.0 - unist-util-is: 6.0.0 - unist-util-visit-parents: 6.0.1 - - mdast-util-from-markdown@2.0.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.1.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-autolink-literal@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 - micromark-util-character: 2.1.1 - - mdast-util-gfm-footnote@2.1.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-strikethrough@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-table@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm@3.1.0: - dependencies: - mdast-util-from-markdown: 2.0.2 - mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-expression@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@3.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.2 - stringify-entities: 4.0.4 - unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx@3.0.0: - dependencies: - mdast-util-from-markdown: 2.0.2 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: - dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.0 - - mdast-util-to-hast@13.2.0: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.0.0 - zwitch: 2.0.4 - - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - - memoize-one@5.2.1: {} - - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.1.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-autolink-literal@2.1.0: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-footnote@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-strikethrough@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-table@2.1.1: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-tagfilter@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-gfm-task-list-item@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm@3.0.0: - dependencies: - micromark-extension-gfm-autolink-literal: 2.1.0 - micromark-extension-gfm-footnote: 2.1.0 - micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.1 - micromark-extension-gfm-tagfilter: 2.0.0 - micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-mdx-expression@3.0.1: - dependencies: - '@types/estree': 1.0.7 - devlop: 1.1.0 - micromark-factory-mdx-expression: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-mdx-jsx@3.0.2: - dependencies: - '@types/estree': 1.0.7 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - micromark-factory-mdx-expression: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - vfile-message: 4.0.2 - - micromark-extension-mdx-md@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-mdxjs-esm@3.0.0: - dependencies: - '@types/estree': 1.0.7 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-position-from-estree: 2.0.0 - vfile-message: 4.0.2 - - micromark-extension-mdxjs@3.0.0: - dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) - micromark-extension-mdx-expression: 3.0.1 - micromark-extension-mdx-jsx: 3.0.2 - micromark-extension-mdx-md: 2.0.0 - micromark-extension-mdxjs-esm: 3.0.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-mdx-expression@2.0.3: - dependencies: - '@types/estree': 1.0.7 - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-position-from-estree: 2.0.0 - vfile-message: 4.0.2 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-events-to-acorn@2.0.3: - dependencies: - '@types/estree': 1.0.7 - '@types/unist': 3.0.3 - devlop: 1.1.0 - estree-util-visit: 2.0.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - vfile-message: 4.0.2 - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.2 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.12 - debug: 4.4.0 - decode-named-character-reference: 1.1.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.11 - - minimist@1.2.0: {} - - minimist@1.2.6: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - node-releases@2.0.19: {} - - object-assign@4.1.1: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-author@2.0.0: - dependencies: - author-regex: 1.0.0 - - parse-entities@4.0.2: - dependencies: - '@types/unist': 2.0.11 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.1.0 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.26.2 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parse-svg-path@0.1.2: {} - - path-browserify@1.0.1: {} - - path-data-parser@0.1.0: {} - - path-is-absolute@1.0.1: {} - - path-source@0.1.3: - dependencies: - array-source: 0.0.4 - file-source: 0.6.1 - - path-type@4.0.0: {} - - pbf@3.3.0: - dependencies: - ieee754: 1.2.1 - resolve-protobuf-schema: 2.1.0 - - picocolors@1.1.1: {} - - point-at-length@1.1.0: - dependencies: - abs-svg-path: 0.1.1 - isarray: 0.0.1 - parse-svg-path: 0.1.2 - - points-on-curve@0.2.0: {} - - points-on-path@0.2.1: - dependencies: - path-data-parser: 0.1.0 - points-on-curve: 0.2.0 - - postcss@8.5.3: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prettier-package-json@2.8.0: - dependencies: - '@types/parse-author': 2.0.3 - commander: 4.1.1 - cosmiconfig: 7.1.0 - fs-extra: 10.1.0 - glob: 7.2.3 - minimatch: 3.1.2 - parse-author: 2.0.0 - sort-object-keys: 1.1.3 - sort-order: 1.1.2 - - prettier-plugin-astro@0.14.1: - dependencies: - '@astrojs/compiler': 2.11.0 - prettier: 3.5.3 - sass-formatter: 0.7.9 - - prettier-plugin-curly-and-jsdoc@3.1.0(prettier@3.5.3): - dependencies: - prettier: 3.5.3 - - prettier-plugin-pkgsort@0.2.1(prettier@3.5.3): - dependencies: - prettier: 3.5.3 - prettier-package-json: 2.8.0 - - prettier@3.5.3: {} - - prismjs@1.30.0: {} - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - property-information@7.0.0: {} - - protocol-buffers-schema@3.6.0: {} - - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - - react-draggable@4.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - clsx: 1.2.1 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-dropzone@14.3.8(react@18.3.1): - dependencies: - attr-accept: 2.2.5 - file-selector: 2.1.2 - prop-types: 15.8.1 - react: 18.3.1 - - react-fast-compare@3.2.2: {} - - react-fireworks@1.0.4: {} - - react-i18next@13.5.0(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.0 - html-parse-stringify: 3.0.1 - i18next: 23.16.8 - react: 18.3.1 - optionalDependencies: - react-dom: 18.3.1(react@18.3.1) - - react-is@16.13.1: {} - - react-is@18.3.1: {} - - react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@popperjs/core': 2.11.8 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-fast-compare: 3.2.2 - warning: 4.0.3 - - react-refresh@0.14.2: {} - - react-resizable@3.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - prop-types: 15.8.1 - react: 18.3.1 - react-draggable: 4.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - transitivePeerDependencies: - - react-dom - - react-router-dom@6.30.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.0(react@18.3.1) - - react-router@6.30.0(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.0 - react: 18.3.1 - - react-telegram-login@1.1.2(react@18.3.1): - dependencies: - react: 18.3.1 - - react-toastify@9.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - clsx: 1.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-turnstile@1.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.0 - memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - - readable-stream@1.1.14: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 0.0.1 - string_decoder: 0.10.31 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - recma-build-jsx@1.0.0: - dependencies: - '@types/estree': 1.0.7 - estree-util-build-jsx: 3.0.1 - vfile: 6.0.3 - - recma-jsx@1.0.0(acorn@8.14.1): - dependencies: - acorn-jsx: 5.3.2(acorn@8.14.1) - estree-util-to-js: 2.0.0 - recma-parse: 1.0.0 - recma-stringify: 1.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - acorn - - recma-parse@1.0.0: - dependencies: - '@types/estree': 1.0.7 - esast-util-from-js: 2.0.1 - unified: 11.0.5 - vfile: 6.0.3 - - recma-stringify@1.0.0: - dependencies: - '@types/estree': 1.0.7 - estree-util-to-js: 2.0.0 - unified: 11.0.5 - vfile: 6.0.3 - - regenerator-runtime@0.14.1: {} - - rehype-recma@1.0.0: - dependencies: - '@types/estree': 1.0.7 - '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3 - transitivePeerDependencies: - - supports-color - - remark-gfm@4.0.1: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 - micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-mdx@3.1.0: - dependencies: - mdast-util-mdx: 3.0.0 - micromark-extension-mdxjs: 3.0.0 - transitivePeerDependencies: - - supports-color - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - micromark-util-types: 2.0.2 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.2: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 - unified: 11.0.5 - vfile: 6.0.3 - - remark-stringify@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.2 - unified: 11.0.5 - - resolve-from@4.0.0: {} - - resolve-protobuf-schema@2.1.0: - dependencies: - protocol-buffers-schema: 3.6.0 - - rollup@4.39.0: - dependencies: - '@types/estree': 1.0.7 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.39.0 - '@rollup/rollup-android-arm64': 4.39.0 - '@rollup/rollup-darwin-arm64': 4.39.0 - '@rollup/rollup-darwin-x64': 4.39.0 - '@rollup/rollup-freebsd-arm64': 4.39.0 - '@rollup/rollup-freebsd-x64': 4.39.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.39.0 - '@rollup/rollup-linux-arm-musleabihf': 4.39.0 - '@rollup/rollup-linux-arm64-gnu': 4.39.0 - '@rollup/rollup-linux-arm64-musl': 4.39.0 - '@rollup/rollup-linux-loongarch64-gnu': 4.39.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.39.0 - '@rollup/rollup-linux-riscv64-gnu': 4.39.0 - '@rollup/rollup-linux-riscv64-musl': 4.39.0 - '@rollup/rollup-linux-s390x-gnu': 4.39.0 - '@rollup/rollup-linux-x64-gnu': 4.39.0 - '@rollup/rollup-linux-x64-musl': 4.39.0 - '@rollup/rollup-win32-arm64-msvc': 4.39.0 - '@rollup/rollup-win32-ia32-msvc': 4.39.0 - '@rollup/rollup-win32-x64-msvc': 4.39.0 - fsevents: 2.3.3 - - roughjs@4.5.2: - dependencies: - path-data-parser: 0.1.0 - points-on-curve: 0.2.0 - points-on-path: 0.2.1 - - rw@1.3.3: {} - - s.color@0.0.15: {} - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - sass-formatter@0.7.9: - dependencies: - suf-log: 2.5.3 - - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - - scroll-into-view-if-needed@2.2.31: - dependencies: - compute-scroll-into-view: 1.0.20 - - semantic-ui-offline@2.5.0: - dependencies: - fs-extra: 4.0.3 - jquery: 3.7.1 - - semantic-ui-react@2.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.0 - '@fluentui/react-component-event-listener': 0.63.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@fluentui/react-component-ref': 0.63.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@popperjs/core': 2.11.8 - '@semantic-ui-react/event-stack': 3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - clsx: 1.2.1 - keyboard-key: 1.1.0 - lodash: 4.17.21 - lodash-es: 4.17.21 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 18.3.1 - react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - shallowequal: 1.1.0 - - semver@6.3.1: {} - - shallowequal@1.1.0: {} - - shapefile@0.6.6: - dependencies: - array-source: 0.0.4 - commander: 2.20.3 - path-source: 0.1.3 - slice-source: 0.4.1 - stream-source: 0.3.5 - text-encoding: 0.6.4 - - simple-statistics@7.8.8: {} - - simplify-geojson@1.0.5: - dependencies: - concat-stream: 1.4.11 - minimist: 1.2.6 - simplify-geometry: 0.0.2 - - simplify-geometry@0.0.2: {} - - slice-source@0.4.1: {} - - sort-object-keys@1.1.3: {} - - sort-order@1.1.2: {} - - source-map-js@1.2.1: {} - - source-map@0.7.4: {} - - space-separated-tokens@2.0.2: {} - - sse.js@https://codeload.github.com/mpetazzoni/sse.js/tar.gz/39b9b82aae95fd58d9d08b487845fe230f4b14e6: - {} - - stream-source@0.3.5: {} - - string_decoder@0.10.31: {} - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - style-to-js@1.1.16: - dependencies: - style-to-object: 1.0.8 - - style-to-object@1.0.8: - dependencies: - inline-style-parser: 0.2.4 - - suf-log@2.5.3: - dependencies: - s.color: 0.0.15 - - text-encoding@0.6.4: {} - - topojson-client@3.1.0: - dependencies: - commander: 2.20.3 - - topojson-server@3.0.1: - dependencies: - commander: 2.20.3 - - trim-lines@3.0.1: {} - - trough@2.2.0: {} - - tslib@2.8.1: {} - - typedarray@0.0.6: {} - - typedarray@0.0.7: {} - - typescript@4.4.2: {} - - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unist-util-is@6.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position-from-estree@2.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.1: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.0 - - unist-util-visit@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.0 - unist-util-visit-parents: 6.0.1 - - universalify@0.1.2: {} - - universalify@2.0.1: {} - - update-browserslist-db@1.1.3(browserslist@4.24.4): - dependencies: - browserslist: 4.24.4 - escalade: 3.2.0 - picocolors: 1.1.1 - - util-deprecate@1.0.2: {} - - utility-types@3.11.0: {} - - vfile-message@4.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.2 - - vite@5.4.16: - dependencies: - esbuild: 0.21.5 - postcss: 8.5.3 - rollup: 4.39.0 - optionalDependencies: - fsevents: 2.3.3 - - void-elements@3.1.0: {} - - warning@4.0.3: - dependencies: - loose-envify: 1.4.0 - - wrappy@1.0.2: {} - - yallist@3.1.1: {} - - yaml@1.10.2: {} - - zwitch@2.0.4: {} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico index 0c10d1e5..ab5f17bc 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/public/logo.png b/web/public/logo.png index 8aea273d..851556f6 100644 Binary files a/web/public/logo.png and b/web/public/logo.png differ diff --git a/web/src/App.js b/web/src/App.js index ed53f6a0..2d715767 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -1,15 +1,15 @@ -import React, { lazy, Suspense, useContext, useEffect } from 'react'; +import React, { lazy, Suspense } from 'react'; import { Route, Routes, useLocation } from 'react-router-dom'; -import Loading from './components/Loading'; +import Loading from './components/common/Loading.js'; import User from './pages/User'; -import { PrivateRoute } from './components/PrivateRoute'; -import RegisterForm from './components/RegisterForm'; -import LoginForm from './components/LoginForm'; +import { AuthRedirect, PrivateRoute } from './helpers'; +import RegisterForm from './components/auth/RegisterForm.js'; +import LoginForm from './components/auth/LoginForm.js'; import NotFound from './pages/NotFound'; import Setting from './pages/Setting'; import EditUser from './pages/User/EditUser'; -import PasswordResetForm from './components/PasswordResetForm'; -import PasswordResetConfirm from './components/PasswordResetConfirm'; +import PasswordResetForm from './components/auth/PasswordResetForm.js'; +import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js'; import Channel from './pages/Channel'; import Token from './pages/Token'; import EditChannel from './pages/Channel/EditChannel'; @@ -18,15 +18,14 @@ import TopUp from './pages/TopUp'; import Log from './pages/Log'; import Chat from './pages/Chat'; import Chat2Link from './pages/Chat2Link'; -import { Layout } from '@douyinfe/semi-ui'; import Midjourney from './pages/Midjourney'; import Pricing from './pages/Pricing/index.js'; import Task from './pages/Task/index.js'; -import Playground from './pages/Playground/Playground.js'; -import OAuth2Callback from './components/OAuth2Callback.js'; -import PersonalSetting from './components/PersonalSetting.js'; +import Playground from './pages/Playground/index.js'; +import OAuth2Callback from './components/auth/OAuth2Callback.js'; +import PersonalSetting from './components/settings/PersonalSetting.js'; import Setup from './pages/Setup/index.js'; -import SetupCheck from './components/SetupCheck'; +import SetupCheck from './components/layout/SetupCheck.js'; const Home = lazy(() => import('./pages/Home')); const Detail = lazy(() => import('./pages/Detail')); @@ -55,7 +54,7 @@ function App() { } /> @@ -63,7 +62,7 @@ function App() { } /> } key={location.pathname}> @@ -71,7 +70,7 @@ function App() { } /> } key={location.pathname}> @@ -79,7 +78,7 @@ function App() { } /> @@ -87,7 +86,7 @@ function App() { } /> @@ -95,7 +94,7 @@ function App() { } /> @@ -103,7 +102,7 @@ function App() { } /> @@ -111,7 +110,7 @@ function App() { } /> } key={location.pathname}> @@ -119,7 +118,7 @@ function App() { } /> } key={location.pathname}> @@ -138,7 +137,9 @@ function App() { path='/login' element={ } key={location.pathname}> - + + + } /> @@ -146,7 +147,9 @@ function App() { path='/register' element={ } key={location.pathname}> - + + + } /> @@ -183,7 +186,7 @@ function App() { } /> } key={location.pathname}> @@ -193,7 +196,7 @@ function App() { } /> } key={location.pathname}> @@ -203,7 +206,7 @@ function App() { } /> } key={location.pathname}> @@ -213,7 +216,7 @@ function App() { } /> @@ -221,7 +224,7 @@ function App() { } /> } key={location.pathname}> @@ -231,7 +234,7 @@ function App() { } /> } key={location.pathname}> @@ -241,7 +244,7 @@ function App() { } /> } key={location.pathname}> @@ -267,7 +270,7 @@ function App() { } /> } key={location.pathname}> diff --git a/web/src/components/ChannelsTable.js b/web/src/components/ChannelsTable.js deleted file mode 100644 index 3425beea..00000000 --- a/web/src/components/ChannelsTable.js +++ /dev/null @@ -1,1730 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - API, - isMobile, - shouldShowPrompt, - showError, - showInfo, - showSuccess, - showWarning, - timestamp2string, -} from '../helpers'; - -import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; -import { - getQuotaPerUnit, - renderGroup, - renderNumberWithPoint, - renderQuota, - renderQuotaWithPrompt, - stringToColor, -} from '../helpers/render'; -import { - Button, - Divider, - Dropdown, - Form, - Input, - InputNumber, - Modal, - Popconfirm, - Space, - SplitButtonGroup, - Switch, - Table, - Tag, - Tooltip, - Typography, - Checkbox, - Layout, -} from '@douyinfe/semi-ui'; -import EditChannel from '../pages/Channel/EditChannel'; -import { - IconList, - IconTreeTriangleDown, - IconClose, - IconFilter, - IconPlus, - IconRefresh, - IconSetting, -} from '@douyinfe/semi-icons'; -import { loadChannelModels } from './utils.js'; -import EditTagModal from '../pages/Channel/EditTagModal.js'; -import TextNumberInput from './custom/TextNumberInput.js'; -import { useTranslation } from 'react-i18next'; - -function renderTimestamp(timestamp) { - return <>{timestamp2string(timestamp)}; -} - -const ChannelsTable = () => { - const { t } = useTranslation(); - - let type2label = undefined; - - const renderType = (type) => { - if (!type2label) { - type2label = new Map(); - for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { - type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; - } - type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' }; - } - return ( - - {type2label[type]?.label} - - ); - }; - - const renderTagType = () => { - return ( - } - size='large' - shape='circle' - type='light' - > - {t('标签聚合')} - - ); - }; - - const renderStatus = (status) => { - switch (status) { - case 1: - return ( - - {t('已启用')} - - ); - case 2: - return ( - - {t('已禁用')} - - ); - case 3: - return ( - - {t('自动禁用')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const renderResponseTime = (responseTime) => { - let time = responseTime / 1000; - time = time.toFixed(2) + t(' 秒'); - if (responseTime === 0) { - return ( - - {t('未测试')} - - ); - } else if (responseTime <= 1000) { - return ( - - {time} - - ); - } else if (responseTime <= 3000) { - return ( - - {time} - - ); - } else if (responseTime <= 5000) { - return ( - - {time} - - ); - } else { - return ( - - {time} - - ); - } - }; - - // Define column keys for selection - const COLUMN_KEYS = { - ID: 'id', - NAME: 'name', - GROUP: 'group', - TYPE: 'type', - STATUS: 'status', - RESPONSE_TIME: 'response_time', - BALANCE: 'balance', - PRIORITY: 'priority', - WEIGHT: 'weight', - OPERATE: 'operate', - }; - - // State for column visibility - const [visibleColumns, setVisibleColumns] = useState({}); - const [showColumnSelector, setShowColumnSelector] = useState(false); - - // Load saved column preferences from localStorage - useEffect(() => { - const savedColumns = localStorage.getItem('channels-table-columns'); - if (savedColumns) { - try { - const parsed = JSON.parse(savedColumns); - // Make sure all columns are accounted for - const defaults = getDefaultColumnVisibility(); - const merged = { ...defaults, ...parsed }; - setVisibleColumns(merged); - } catch (e) { - console.error('Failed to parse saved column preferences', e); - initDefaultColumns(); - } - } else { - initDefaultColumns(); - } - }, []); - - // Update table when column visibility changes - useEffect(() => { - if (Object.keys(visibleColumns).length > 0) { - // Save to localStorage - localStorage.setItem( - 'channels-table-columns', - JSON.stringify(visibleColumns), - ); - } - }, [visibleColumns]); - - // Get default column visibility - const getDefaultColumnVisibility = () => { - return { - [COLUMN_KEYS.ID]: true, - [COLUMN_KEYS.NAME]: true, - [COLUMN_KEYS.GROUP]: true, - [COLUMN_KEYS.TYPE]: true, - [COLUMN_KEYS.STATUS]: true, - [COLUMN_KEYS.RESPONSE_TIME]: true, - [COLUMN_KEYS.BALANCE]: true, - [COLUMN_KEYS.PRIORITY]: true, - [COLUMN_KEYS.WEIGHT]: true, - [COLUMN_KEYS.OPERATE]: true, - }; - }; - - // Initialize default column visibility - const initDefaultColumns = () => { - const defaults = getDefaultColumnVisibility(); - setVisibleColumns(defaults); - }; - - // Handle column visibility change - const handleColumnVisibilityChange = (columnKey, checked) => { - const updatedColumns = { ...visibleColumns, [columnKey]: checked }; - setVisibleColumns(updatedColumns); - }; - - // Handle "Select All" checkbox - const handleSelectAll = (checked) => { - const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]); - const updatedColumns = {}; - - allKeys.forEach((key) => { - updatedColumns[key] = checked; - }); - - setVisibleColumns(updatedColumns); - }; - - // Define all columns with keys - const allColumns = [ - { - key: COLUMN_KEYS.ID, - title: t('ID'), - dataIndex: 'id', - }, - { - key: COLUMN_KEYS.NAME, - title: t('名称'), - dataIndex: 'name', - }, - { - key: COLUMN_KEYS.GROUP, - title: t('分组'), - dataIndex: 'group', - render: (text, record, index) => { - return ( -
- - {text - ?.split(',') - .sort((a, b) => { - if (a === 'default') return -1; - if (b === 'default') return 1; - return a.localeCompare(b); - }) - .map((item, index) => { - return renderGroup(item); - })} - -
- ); - }, - }, - { - key: COLUMN_KEYS.TYPE, - title: t('类型'), - dataIndex: 'type', - render: (text, record, index) => { - if (record.children === undefined) { - return <>{renderType(text)}; - } else { - return <>{renderTagType()}; - } - }, - }, - { - key: COLUMN_KEYS.STATUS, - title: t('状态'), - dataIndex: 'status', - render: (text, record, index) => { - if (text === 3) { - if (record.other_info === '') { - record.other_info = '{}'; - } - let otherInfo = JSON.parse(record.other_info); - let reason = otherInfo['status_reason']; - let time = otherInfo['status_time']; - return ( -
- - {renderStatus(text)} - -
- ); - } else { - return renderStatus(text); - } - }, - }, - { - key: COLUMN_KEYS.RESPONSE_TIME, - title: t('响应时间'), - dataIndex: 'response_time', - render: (text, record, index) => { - return
{renderResponseTime(text)}
; - }, - }, - { - key: COLUMN_KEYS.BALANCE, - title: t('已用/剩余'), - dataIndex: 'expired_time', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- - - - {renderQuota(record.used_quota)} - - - - { - updateChannelBalance(record); - }} - > - ${renderNumberWithPoint(record.balance)} - - - -
- ); - } else { - return ( - - - {renderQuota(record.used_quota)} - - - ); - } - }, - }, - { - key: COLUMN_KEYS.PRIORITY, - title: t('优先级'), - dataIndex: 'priority', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- { - manageChannel(record.id, 'priority', record, e.target.value); - }} - keepFocus={true} - innerButtons - defaultValue={record.priority} - min={-999} - /> -
- ); - } else { - return ( - <> - { - Modal.warning({ - title: t('修改子渠道优先级'), - content: - t('确定要修改所有子渠道优先级为 ') + - e.target.value + - t(' 吗?'), - onOk: () => { - if (e.target.value === '') { - return; - } - submitTagEdit('priority', { - tag: record.key, - priority: e.target.value, - }); - }, - }); - }} - innerButtons - defaultValue={record.priority} - min={-999} - /> - - ); - } - }, - }, - { - key: COLUMN_KEYS.WEIGHT, - title: t('权重'), - dataIndex: 'weight', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- { - manageChannel(record.id, 'weight', record, e.target.value); - }} - keepFocus={true} - innerButtons - defaultValue={record.weight} - min={0} - /> -
- ); - } else { - return ( - { - Modal.warning({ - title: t('修改子渠道权重'), - content: - t('确定要修改所有子渠道权重为 ') + - e.target.value + - t(' 吗?'), - onOk: () => { - if (e.target.value === '') { - return; - } - submitTagEdit('weight', { - tag: record.key, - weight: e.target.value, - }); - }, - }); - }} - innerButtons - defaultValue={record.weight} - min={-999} - /> - ); - } - }, - }, - { - key: COLUMN_KEYS.OPERATE, - title: '', - dataIndex: 'operate', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- - - - - { - manageChannel(record.id, 'delete', record).then(() => { - removeRecord(record); - }); - }} - > - - - {record.status === 1 ? ( - - ) : ( - - )} - - { - copySelectedChannel(record); - }} - > - - -
- ); - } else { - return ( - <> - - - - - ); - } - }, - }, - ]; - - // Filter columns based on visibility settings - const getVisibleColumns = () => { - return allColumns.filter((column) => visibleColumns[column.key]); - }; - - // Column selector modal - const renderColumnSelector = () => { - return ( - setShowColumnSelector(false)} - footer={ - <> - - - - - } - style={{ width: isMobile() ? '90%' : 500 }} - bodyStyle={{ padding: '24px' }} - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {allColumns.map((column) => { - // Skip columns without title - if (!column.title) { - return null; - } - - return ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [idSort, setIdSort] = useState(false); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searchGroup, setSearchGroup] = useState(''); - const [searchModel, setSearchModel] = useState(''); - const [searching, setSearching] = useState(false); - const [updatingBalance, setUpdatingBalance] = useState(false); - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [showPrompt, setShowPrompt] = useState( - shouldShowPrompt('channel-test'), - ); - const [channelCount, setChannelCount] = useState(pageSize); - const [groupOptions, setGroupOptions] = useState([]); - const [showEdit, setShowEdit] = useState(false); - const [enableBatchDelete, setEnableBatchDelete] = useState(false); - const [editingChannel, setEditingChannel] = useState({ - id: undefined, - }); - const [showEditTag, setShowEditTag] = useState(false); - const [editingTag, setEditingTag] = useState(''); - const [selectedChannels, setSelectedChannels] = useState([]); - const [showEditPriority, setShowEditPriority] = useState(false); - const [enableTagMode, setEnableTagMode] = useState(false); - const [showBatchSetTag, setShowBatchSetTag] = useState(false); - const [batchSetTagValue, setBatchSetTagValue] = useState(''); - const [showModelTestModal, setShowModelTestModal] = useState(false); - const [currentTestChannel, setCurrentTestChannel] = useState(null); - const [modelSearchKeyword, setModelSearchKeyword] = useState(''); - - const removeRecord = (record) => { - let newDataSource = [...channels]; - if (record.id != null) { - let idx = newDataSource.findIndex((data) => { - if (data.children !== undefined) { - for (let i = 0; i < data.children.length; i++) { - if (data.children[i].id === record.id) { - data.children.splice(i, 1); - return false; - } - } - } else { - return data.id === record.id; - } - }); - - if (idx > -1) { - newDataSource.splice(idx, 1); - setChannels(newDataSource); - } - } - }; - - const setChannelFormat = (channels, enableTagMode) => { - let channelDates = []; - let channelTags = {}; - for (let i = 0; i < channels.length; i++) { - channels[i].key = '' + channels[i].id; - if (!enableTagMode) { - channelDates.push(channels[i]); - } else { - let tag = channels[i].tag ? channels[i].tag : ''; - // find from channelTags - let tagIndex = channelTags[tag]; - let tagChannelDates = undefined; - if (tagIndex === undefined) { - // not found, create a new tag - channelTags[tag] = 1; - tagChannelDates = { - key: tag, - id: tag, - tag: tag, - name: '标签:' + tag, - group: '', - used_quota: 0, - response_time: 0, - priority: -1, - weight: -1, - }; - tagChannelDates.children = []; - channelDates.push(tagChannelDates); - } else { - // found, add to the tag - tagChannelDates = channelDates.find((item) => item.key === tag); - } - if (tagChannelDates.priority === -1) { - tagChannelDates.priority = channels[i].priority; - } else { - if (tagChannelDates.priority !== channels[i].priority) { - tagChannelDates.priority = ''; - } - } - if (tagChannelDates.weight === -1) { - tagChannelDates.weight = channels[i].weight; - } else { - if (tagChannelDates.weight !== channels[i].weight) { - tagChannelDates.weight = ''; - } - } - - if (tagChannelDates.group === '') { - tagChannelDates.group = channels[i].group; - } else { - let channelGroupsStr = channels[i].group; - channelGroupsStr.split(',').forEach((item, index) => { - if (tagChannelDates.group.indexOf(item) === -1) { - // join - tagChannelDates.group += ',' + item; - } - }); - } - - tagChannelDates.children.push(channels[i]); - if (channels[i].status === 1) { - tagChannelDates.status = 1; - } - tagChannelDates.used_quota += channels[i].used_quota; - tagChannelDates.response_time += channels[i].response_time; - tagChannelDates.response_time = tagChannelDates.response_time / 2; - } - } - // data.key = '' + data.id - setChannels(channelDates); - if (channelDates.length >= pageSize) { - setChannelCount(channelDates.length + pageSize); - } else { - setChannelCount(channelDates.length); - } - }; - - const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => { - setLoading(true); - const res = await API.get( - `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`, - ); - if (res === undefined) { - return; - } - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setChannelFormat(data, enableTagMode); - } else { - let newChannels = [...channels]; - newChannels.splice(startIdx * pageSize, data.length, ...data); - setChannelFormat(newChannels, enableTagMode); - } - } else { - showError(message); - } - setLoading(false); - }; - - const copySelectedChannel = async (record) => { - const channelToCopy = record; - channelToCopy.name += t('_复制'); - channelToCopy.created_time = null; - channelToCopy.balance = 0; - channelToCopy.used_quota = 0; - if (!channelToCopy) { - showError(t('渠道未找到,请刷新页面后重试。')); - return; - } - try { - const newChannel = { ...channelToCopy, id: undefined }; - const response = await API.post('/api/channel/', newChannel); - if (response.data.success) { - showSuccess(t('渠道复制成功')); - await refresh(); - } else { - showError(response.data.message); - } - } catch (error) { - showError(t('渠道复制失败: ') + error.message); - } - }; - - const refresh = async () => { - await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); - }; - - useEffect(() => { - // console.log('default effect') - const localIdSort = localStorage.getItem('id-sort') === 'true'; - const localPageSize = - parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; - setIdSort(localIdSort); - setPageSize(localPageSize); - loadChannels(0, localPageSize, localIdSort, enableTagMode) - .then() - .catch((reason) => { - showError(reason); - }); - fetchGroups().then(); - loadChannelModels().then(); - }, []); - - const manageChannel = async (id, action, record, value) => { - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/channel/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/channel/', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/channel/', data); - break; - case 'priority': - if (value === '') { - return; - } - data.priority = parseInt(value); - res = await API.put('/api/channel/', data); - break; - case 'weight': - if (value === '') { - return; - } - data.weight = parseInt(value); - if (data.weight < 0) { - data.weight = 0; - } - res = await API.put('/api/channel/', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess(t('操作成功完成!')); - let channel = res.data.data; - let newChannels = [...channels]; - if (action === 'delete') { - } else { - record.status = channel.status; - } - setChannels(newChannels); - } else { - showError(message); - } - }; - - const manageTag = async (tag, action) => { - console.log(tag, action); - let res; - switch (action) { - case 'enable': - res = await API.post('/api/channel/tag/enabled', { - tag: tag, - }); - break; - case 'disable': - res = await API.post('/api/channel/tag/disabled', { - tag: tag, - }); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let newChannels = [...channels]; - for (let i = 0; i < newChannels.length; i++) { - if (newChannels[i].tag === tag) { - let status = action === 'enable' ? 1 : 2; - newChannels[i]?.children?.forEach((channel) => { - channel.status = status; - }); - newChannels[i].status = status; - } - } - setChannels(newChannels); - } else { - showError(message); - } - }; - - const searchChannels = async ( - searchKeyword, - searchGroup, - searchModel, - enableTagMode, - ) => { - if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - await loadChannels(0, pageSize, idSort, enableTagMode); - setActivePage(1); - return; - } - setSearching(true); - const res = await API.get( - `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`, - ); - const { success, message, data } = res.data; - if (success) { - setChannelFormat(data, enableTagMode); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const updateChannelProperty = (channelId, updateFn) => { - // Create a new copy of channels array - const newChannels = [...channels]; - let updated = false; - - // Find and update the correct channel - newChannels.forEach((channel) => { - if (channel.children !== undefined) { - // If this is a tag group, search in its children - channel.children.forEach((child) => { - if (child.id === channelId) { - updateFn(child); - updated = true; - } - }); - } else if (channel.id === channelId) { - // Direct channel match - updateFn(channel); - updated = true; - } - }); - - // Only update state if we actually modified a channel - if (updated) { - setChannels(newChannels); - } - }; - - const testChannel = async (record, model) => { - const res = await API.get(`/api/channel/test/${record.id}?model=${model}`); - const { success, message, time } = res.data; - if (success) { - // Also update the channels state to persist the change - updateChannelProperty(record.id, (channel) => { - channel.response_time = time * 1000; - channel.test_time = Date.now() / 1000; - }); - - showInfo( - t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。') - .replace('${name}', record.name) - .replace('${time.toFixed(2)}', time.toFixed(2)), - ); - } else { - showError(message); - } - }; - - const updateChannelBalance = async (record) => { - const res = await API.get(`/api/channel/update_balance/${record.id}/`); - const { success, message, balance } = res.data; - if (success) { - updateChannelProperty(record.id, (channel) => { - channel.balance = balance; - channel.balance_updated_time = Date.now() / 1000; - }); - showInfo( - t('通道 ${name} 余额更新成功!').replace('${name}', record.name), - ); - } else { - showError(message); - } - }; - - const testAllChannels = async () => { - const res = await API.get(`/api/channel/test`); - const { success, message } = res.data; - if (success) { - showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。')); - } else { - showError(message); - } - }; - - const deleteAllDisabledChannels = async () => { - const res = await API.delete(`/api/channel/disabled`); - const { success, message, data } = res.data; - if (success) { - showSuccess( - t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data), - ); - await refresh(); - } else { - showError(message); - } - }; - - const updateAllChannelsBalance = async () => { - setUpdatingBalance(true); - const res = await API.get(`/api/channel/update_balance`); - const { success, message } = res.data; - if (success) { - showInfo(t('已更新完毕所有已启用通道余额!')); - } else { - showError(message); - } - setUpdatingBalance(false); - }; - - const batchDeleteChannels = async () => { - if (selectedChannels.length === 0) { - showError(t('请先选择要删除的通道!')); - return; - } - setLoading(true); - let ids = []; - selectedChannels.forEach((channel) => { - ids.push(channel.id); - }); - const res = await API.post(`/api/channel/batch`, { ids: ids }); - const { success, message, data } = res.data; - if (success) { - showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data)); - await refresh(); - } else { - showError(message); - } - setLoading(false); - }; - - const fixChannelsAbilities = async () => { - const res = await API.post(`/api/channel/fix`); - const { success, message, data } = res.data; - if (success) { - showSuccess(t('已修复 ${data} 个通道!').replace('${data}', data)); - await refresh(); - } else { - showError(message); - } - }; - - let pageData = channels.slice( - (activePage - 1) * pageSize, - activePage * pageSize, - ); - - const handlePageChange = (page) => { - setActivePage(page); - if (page === Math.ceil(channels.length / pageSize) + 1) { - // In this case we have to load more data and then append them. - loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => {}); - } - }; - - const handlePageSizeChange = async (size) => { - localStorage.setItem('page-size', size + ''); - setPageSize(size); - setActivePage(1); - loadChannels(0, size, idSort, enableTagMode) - .then() - .catch((reason) => { - showError(reason); - }); - }; - - const fetchGroups = async () => { - try { - let res = await API.get(`/api/group/`); - // add 'all' option - // res.data.data.unshift('all'); - if (res === undefined) { - return; - } - setGroupOptions( - res.data.data.map((group) => ({ - label: group, - value: group, - })), - ); - } catch (error) { - showError(error.message); - } - }; - - const submitTagEdit = async (type, data) => { - switch (type) { - case 'priority': - if (data.priority === undefined || data.priority === '') { - showInfo('优先级必须是整数!'); - return; - } - data.priority = parseInt(data.priority); - break; - case 'weight': - if ( - data.weight === undefined || - data.weight < 0 || - data.weight === '' - ) { - showInfo('权重必须是非负整数!'); - return; - } - data.weight = parseInt(data.weight); - break; - } - - try { - const res = await API.put('/api/channel/tag', data); - if (res?.data?.success) { - showSuccess('更新成功!'); - await refresh(); - } - } catch (error) { - showError(error); - } - }; - - const closeEdit = () => { - setShowEdit(false); - }; - - const handleRow = (record, index) => { - if (record.status !== 1) { - return { - style: { - background: 'var(--semi-color-disabled-border)', - }, - }; - } else { - return {}; - } - }; - - const batchSetChannelTag = async () => { - if (selectedChannels.length === 0) { - showError(t('请先选择要设置标签的渠道!')); - return; - } - if (batchSetTagValue === '') { - showError(t('标签不能为空!')); - return; - } - let ids = selectedChannels.map((channel) => channel.id); - const res = await API.post('/api/channel/batch/tag', { - ids: ids, - tag: batchSetTagValue === '' ? null : batchSetTagValue, - }); - if (res.data.success) { - showSuccess( - t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data), - ); - await refresh(); - setShowBatchSetTag(false); - } else { - showError(res.data.message); - } - }; - - return ( - <> - {renderColumnSelector()} - setShowEditTag(false)} - refresh={refresh} - /> - -
{ - searchChannels( - searchKeyword, - searchGroup, - searchModel, - enableTagMode, - ); - }} - labelPosition='left' - > -
- - { - setSearchKeyword(v.trim()); - }} - /> - { - setSearchModel(v.trim()); - }} - /> - { - setSearchGroup(v); - searchChannels(searchKeyword, v, searchModel, enableTagMode); - }} - /> - - -
-
- -
- -
- - {t('使用ID排序')} - - { - localStorage.setItem('id-sort', v + ''); - setIdSort(v); - loadChannels(0, pageSize, v, enableTagMode) - .then() - .catch((reason) => { - showError(reason); - }); - }} - > -
- -
- - - - - - - - - - - - - - - - - - - - - - } - > - - -
-
-
-
-
- - {t('开启批量操作')} - - { - setEnableBatchDelete(v); - }} - /> -
- -
- - - - - - -
-
- -
-
- - {t('标签聚合模式')} - - { - setEnableTagMode(v); - loadChannels(0, pageSize, idSort, v); - }} - /> -
- -
- - - -
-
- - '', - onPageSizeChange: (size) => { - handlePageSizeChange(size).then(); - }, - onPageChange: handlePageChange, - }} - expandAllRows={false} - onRow={handleRow} - rowSelection={ - enableBatchDelete - ? { - onChange: (selectedRowKeys, selectedRows) => { - // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows); - setSelectedChannels(selectedRows); - }, - } - : null - } - /> - setShowBatchSetTag(false)} - maskClosable={false} - centered={true} - style={{ width: isMobile() ? '90%' : 500 }} - > -
- {t('请输入要设置的标签名称')} -
- setBatchSetTagValue(v)} - size='large' - /> -
- - {t('已选择 ${count} 个渠道').replace( - '${count}', - selectedChannels.length, - )} - -
-
- - {/* 模型测试弹窗 */} - { - setShowModelTestModal(false); - setModelSearchKeyword(''); - }} - footer={null} - maskClosable={true} - centered={true} - > -
- {currentTestChannel && ( -
- - {t('渠道')}: {currentTestChannel.name} - - - {/* 搜索框 */} - setModelSearchKeyword(v)} - style={{ marginBottom: '16px' }} - prefix={} - showClear - /> - -
- {currentTestChannel.models - .split(',') - .filter((model) => - model - .toLowerCase() - .includes(modelSearchKeyword.toLowerCase()), - ) - .map((model, index) => { - return ( - - ); - })} -
- - {/* 显示搜索结果数量 */} - {modelSearchKeyword && ( - - {t('找到')}{' '} - { - currentTestChannel.models - .split(',') - .filter((model) => - model - .toLowerCase() - .includes(modelSearchKeyword.toLowerCase()), - ).length - }{' '} - {t('个模型')} - - )} -
- )} -
-
- - ); -}; - -export default ChannelsTable; diff --git a/web/src/components/Footer.js b/web/src/components/Footer.js deleted file mode 100644 index 7092b873..00000000 --- a/web/src/components/Footer.js +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useEffect, useState, useContext } from 'react'; -import { useTranslation } from 'react-i18next'; -import { getFooterHTML, getSystemName } from '../helpers'; -import { Layout, Tooltip } from '@douyinfe/semi-ui'; -import { StyleContext } from '../context/Style/index.js'; - -const FooterBar = () => { - const { t } = useTranslation(); - const systemName = getSystemName(); - const [footer, setFooter] = useState(getFooterHTML()); - const [styleState] = useContext(StyleContext); - let remainCheckTimes = 5; - - const loadFooter = () => { - let footer_html = localStorage.getItem('footer_html'); - if (footer_html) { - setFooter(footer_html); - } - }; - - const defaultFooter = ( -
- - New API {import.meta.env.VITE_REACT_APP_VERSION}{' '} - - {t('由')}{' '} - - Calcium-Ion - {' '} - {t('开发,基于')}{' '} - - One API - -
- ); - - useEffect(() => { - const timer = setInterval(() => { - if (remainCheckTimes <= 0) { - clearInterval(timer); - return; - } - remainCheckTimes--; - loadFooter(); - }, 200); - return () => clearTimeout(timer); - }, []); - - return ( -
- {footer ? ( -
- ) : ( - defaultFooter - )} -
- ); -}; - -export default FooterBar; diff --git a/web/src/components/HeaderBar.js b/web/src/components/HeaderBar.js deleted file mode 100644 index de2401a1..00000000 --- a/web/src/components/HeaderBar.js +++ /dev/null @@ -1,494 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { UserContext } from '../context/User'; -import { useSetTheme, useTheme } from '../context/Theme'; -import { useTranslation } from 'react-i18next'; - -import { API, getLogo, getSystemName, isMobile, showSuccess } from '../helpers'; -import '../index.css'; - -import fireworks from 'react-fireworks'; - -import { - IconClose, - IconHelpCircle, - IconHome, - IconHomeStroked, - IconIndentLeft, - IconComment, - IconKey, - IconMenu, - IconNoteMoneyStroked, - IconPriceTag, - IconUser, - IconLanguage, - IconInfoCircle, - IconCreditCard, - IconTerminal, -} from '@douyinfe/semi-icons'; -import { - Avatar, - Button, - Dropdown, - Layout, - Nav, - Switch, - Tag, -} from '@douyinfe/semi-ui'; -import { stringToColor } from '../helpers/render'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import { StyleContext } from '../context/Style/index.js'; -import { StatusContext } from '../context/Status/index.js'; - -// 自定义顶部栏样式 -const headerStyle = { - boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)', - borderBottom: '1px solid var(--semi-color-border)', - background: 'var(--semi-color-bg-0)', - transition: 'all 0.3s ease', - width: '100%', -}; - -// 自定义顶部栏按钮样式 -const headerItemStyle = { - borderRadius: '4px', - margin: '0 4px', - transition: 'all 0.3s ease', -}; - -// 自定义顶部栏按钮悬停样式 -const headerItemHoverStyle = { - backgroundColor: 'var(--semi-color-primary-light-default)', - color: 'var(--semi-color-primary)', -}; - -// 自定义顶部栏Logo样式 -const logoStyle = { - display: 'flex', - alignItems: 'center', - gap: '10px', - padding: '0 10px', - height: '100%', -}; - -// 自定义顶部栏系统名称样式 -const systemNameStyle = { - fontWeight: 'bold', - fontSize: '18px', - background: - 'linear-gradient(45deg, var(--semi-color-primary), var(--semi-color-secondary))', - WebkitBackgroundClip: 'text', - WebkitTextFillColor: 'transparent', - padding: '0 5px', -}; - -// 自定义顶部栏按钮图标样式 -const headerIconStyle = { - fontSize: '18px', - transition: 'all 0.3s ease', -}; - -// 自定义头像样式 -const avatarStyle = { - margin: '4px', - cursor: 'pointer', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', - transition: 'all 0.3s ease', -}; - -// 自定义下拉菜单样式 -const dropdownStyle = { - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - overflow: 'hidden', -}; - -// 自定义主题切换开关样式 -const switchStyle = { - margin: '0 8px', -}; - -const HeaderBar = () => { - const { t, i18n } = useTranslation(); - const [userState, userDispatch] = useContext(UserContext); - const [styleState, styleDispatch] = useContext(StyleContext); - const [statusState, statusDispatch] = useContext(StatusContext); - let navigate = useNavigate(); - const [currentLang, setCurrentLang] = useState(i18n.language); - - const systemName = getSystemName(); - const logo = getLogo(); - const currentDate = new Date(); - // enable fireworks on new year(1.1 and 2.9-2.24) - const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1; - - // Check if self-use mode is enabled - const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false; - const docsLink = statusState?.status?.docs_link || ''; - const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; - - let buttons = [ - { - text: t('首页'), - itemKey: 'home', - to: '/', - icon: , - }, - { - text: t('控制台'), - itemKey: 'detail', - to: '/', - icon: , - }, - { - text: t('定价'), - itemKey: 'pricing', - to: '/pricing', - icon: , - }, - // Only include the docs button if docsLink exists - ...(docsLink - ? [ - { - text: t('文档'), - itemKey: 'docs', - isExternal: true, - externalLink: docsLink, - icon: , - }, - ] - : []), - { - text: t('关于'), - itemKey: 'about', - to: '/about', - icon: , - }, - ]; - - async function logout() { - await API.get('/api/user/logout'); - showSuccess(t('注销成功!')); - userDispatch({ type: 'logout' }); - localStorage.removeItem('user'); - navigate('/login'); - } - - const handleNewYearClick = () => { - fireworks.init('root', {}); - fireworks.start(); - setTimeout(() => { - fireworks.stop(); - setTimeout(() => { - window.location.reload(); - }, 10000); - }, 3000); - }; - - const theme = useTheme(); - const setTheme = useSetTheme(); - - useEffect(() => { - if (theme === 'dark') { - document.body.setAttribute('theme-mode', 'dark'); - } else { - document.body.removeAttribute('theme-mode'); - } - // 发送当前主题模式给子页面 - const iframe = document.querySelector('iframe'); - if (iframe) { - iframe.contentWindow.postMessage({ themeMode: theme }, '*'); - } - - if (isNewYear) { - console.log('Happy New Year!'); - } - }, [theme]); - - useEffect(() => { - const handleLanguageChanged = (lng) => { - setCurrentLang(lng); - const iframe = document.querySelector('iframe'); - if (iframe) { - iframe.contentWindow.postMessage({ lang: lng }, '*'); - } - }; - - i18n.on('languageChanged', handleLanguageChanged); - - return () => { - i18n.off('languageChanged', handleLanguageChanged); - }; - }, [i18n]); - - const handleLanguageChange = (lang) => { - i18n.changeLanguage(lang); - }; - - return ( - <> - -
- -
-
- - ); -}; - -export default HeaderBar; diff --git a/web/src/components/Loading.js b/web/src/components/Loading.js deleted file mode 100644 index 14242e44..00000000 --- a/web/src/components/Loading.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { Spin } from '@douyinfe/semi-ui'; - -const Loading = ({ prompt: name = 'page' }) => { - return ( - - 加载{name}中... - - ); -}; - -export default Loading; diff --git a/web/src/components/LoginForm.js b/web/src/components/LoginForm.js deleted file mode 100644 index 6721199f..00000000 --- a/web/src/components/LoginForm.js +++ /dev/null @@ -1,385 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { UserContext } from '../context/User'; -import { - API, - getLogo, - showError, - showInfo, - showSuccess, - updateAPI, -} from '../helpers'; -import { - onGitHubOAuthClicked, - onOIDCClicked, - onLinuxDOOAuthClicked, -} from './utils'; -import Turnstile from 'react-turnstile'; -import { - Button, - Card, - Divider, - Form, - Icon, - Layout, - Modal, -} from '@douyinfe/semi-ui'; -import Title from '@douyinfe/semi-ui/lib/es/typography/title'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import TelegramLoginButton from 'react-telegram-login'; - -import { IconGithubLogo, IconAlarm } from '@douyinfe/semi-icons'; -import OIDCIcon from './OIDCIcon.js'; -import WeChatIcon from './WeChatIcon'; -import { setUserData } from '../helpers/data.js'; -import LinuxDoIcon from './LinuxDoIcon.js'; -import { useTranslation } from 'react-i18next'; - -const LoginForm = () => { - const [inputs, setInputs] = useState({ - username: '', - password: '', - wechat_verification_code: '', - }); - const [searchParams, setSearchParams] = useSearchParams(); - const [submitted, setSubmitted] = useState(false); - const { username, password } = inputs; - const [userState, userDispatch] = useContext(UserContext); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - let navigate = useNavigate(); - const [status, setStatus] = useState({}); - const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - const { t } = useTranslation(); - - const logo = getLogo(); - - let affCode = new URLSearchParams(window.location.search).get('aff'); - if (affCode) { - localStorage.setItem('aff', affCode); - } - - useEffect(() => { - if (searchParams.get('expired')) { - showError(t('未登录或登录已过期,请重新登录')); - } - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - }, []); - - const onWeChatLoginClicked = () => { - setShowWeChatLoginModal(true); - }; - - const onSubmitWeChatVerificationCode = async () => { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - const res = await API.get( - `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - setUserData(data); - updateAPI(); - navigate('/'); - showSuccess('登录成功!'); - setShowWeChatLoginModal(false); - } else { - showError(message); - } - }; - - function handleChange(name, value) { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setSubmitted(true); - if (username && password) { - const res = await API.post( - `/api/user/login?turnstile=${turnstileToken}`, - { - username, - password, - }, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - setUserData(data); - updateAPI(); - showSuccess('登录成功!'); - if (username === 'root' && password === '123456') { - Modal.error({ - title: '您正在使用默认密码!', - content: '请立刻修改默认密码!', - centered: true, - }); - } - navigate('/token'); - } else { - showError(message); - } - } else { - showError('请输入用户名和密码!'); - } - } - - // 添加Telegram登录处理函数 - const onTelegramLoginClicked = async (response) => { - const fields = [ - 'id', - 'first_name', - 'last_name', - 'username', - 'photo_url', - 'auth_date', - 'hash', - 'lang', - ]; - const params = {}; - fields.forEach((field) => { - if (response[field]) { - params[field] = response[field]; - } - }); - const res = await API.get(`/api/oauth/telegram/login`, { params }); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); - setUserData(data); - updateAPI(); - navigate('/'); - } else { - showError(message); - } - }; - - return ( -
- - - -
-
- - - {t('用户登录')} - -
- handleChange('username', value)} - /> - handleChange('password', value)} - /> - - - -
- - {t('没有账户?')}{' '} - {t('点击注册')} - - - {t('忘记密码?')} {t('点击重置')} - -
- {status.github_oauth || - status.oidc_enabled || - status.wechat_login || - status.telegram_oauth || - status.linuxdo_oauth ? ( - <> - - {t('第三方登录')} - -
- {status.github_oauth ? ( -
- {status.telegram_oauth ? ( - <> -
- -
- - ) : ( - <> - )} - - ) : ( - <> - )} - setShowWeChatLoginModal(false)} - okText={t('登录')} - size={'small'} - centered={true} - > -
- -
-
-

- {t( - '微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)', - )} -

-
-
- - handleChange('wechat_verification_code', value) - } - /> - -
-
- {turnstileEnabled ? ( -
- { - setTurnstileToken(token); - }} - /> -
- ) : ( - <> - )} -
-
-
-
-
- ); -}; - -export default LoginForm; diff --git a/web/src/components/MjLogsTable.js b/web/src/components/MjLogsTable.js deleted file mode 100644 index 502569cf..00000000 --- a/web/src/components/MjLogsTable.js +++ /dev/null @@ -1,660 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - API, - copy, - isAdmin, - showError, - showSuccess, - timestamp2string, -} from '../helpers'; - -import { - Banner, - Button, - Form, - ImagePreview, - Layout, - Modal, - Progress, - Table, - Tag, - Typography, -} from '@douyinfe/semi-ui'; -import { ITEMS_PER_PAGE } from '../constants'; -import { useTranslation } from 'react-i18next'; - -const colors = [ - 'amber', - 'blue', - 'cyan', - 'green', - 'grey', - 'indigo', - 'light-blue', - 'lime', - 'orange', - 'pink', - 'purple', - 'red', - 'teal', - 'violet', - 'yellow', -]; - -const LogsTable = () => { - const { t } = useTranslation(); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalContent, setModalContent] = useState(''); - function renderType(type) { - switch (type) { - case 'IMAGINE': - return ( - - {t('绘图')} - - ); - case 'UPSCALE': - return ( - - {t('放大')} - - ); - case 'VARIATION': - return ( - - {t('变换')} - - ); - case 'HIGH_VARIATION': - return ( - - {t('强变换')} - - ); - case 'LOW_VARIATION': - return ( - - {t('弱变换')} - - ); - case 'PAN': - return ( - - {t('平移')} - - ); - case 'DESCRIBE': - return ( - - {t('图生文')} - - ); - case 'BLEND': - return ( - - {t('图混合')} - - ); - case 'UPLOAD': - return ( - - 上传文件 - - ); - case 'SHORTEN': - return ( - - {t('缩词')} - - ); - case 'REROLL': - return ( - - {t('重绘')} - - ); - case 'INPAINT': - return ( - - {t('局部重绘-提交')} - - ); - case 'ZOOM': - return ( - - {t('变焦')} - - ); - case 'CUSTOM_ZOOM': - return ( - - {t('自定义变焦-提交')} - - ); - case 'MODAL': - return ( - - {t('窗口处理')} - - ); - case 'SWAP_FACE': - return ( - - {t('换脸')} - - ); - default: - return ( - - {t('未知')} - - ); - } - } - - function renderCode(code) { - switch (code) { - case 1: - return ( - - {t('已提交')} - - ); - case 21: - return ( - - {t('等待中')} - - ); - case 22: - return ( - - {t('重复提交')} - - ); - case 0: - return ( - - {t('未提交')} - - ); - default: - return ( - - {t('未知')} - - ); - } - } - - function renderStatus(type) { - switch (type) { - case 'SUCCESS': - return ( - - {t('成功')} - - ); - case 'NOT_START': - return ( - - {t('未启动')} - - ); - case 'SUBMITTED': - return ( - - {t('队列中')} - - ); - case 'IN_PROGRESS': - return ( - - {t('执行中')} - - ); - case 'FAILURE': - return ( - - {t('失败')} - - ); - case 'MODAL': - return ( - - {t('窗口等待')} - - ); - default: - return ( - - {t('未知')} - - ); - } - } - - const renderTimestamp = (timestampInSeconds) => { - const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒 - - const year = date.getFullYear(); // 获取年份 - const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数 - const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数 - const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数 - const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数 - const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数 - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出 - }; - // 修改renderDuration函数以包含颜色逻辑 - function renderDuration(submit_time, finishTime) { - if (!submit_time || !finishTime) return 'N/A'; - - const start = new Date(submit_time); - const finish = new Date(finishTime); - const durationMs = finish - start; - const durationSec = (durationMs / 1000).toFixed(1); - const color = durationSec > 60 ? 'red' : 'green'; - - return ( - - {durationSec} {t('秒')} - - ); - } - const columns = [ - { - title: t('提交时间'), - dataIndex: 'submit_time', - render: (text, record, index) => { - return
{renderTimestamp(text / 1000)}
; - }, - }, - { - title: t('花费时间'), - dataIndex: 'finish_time', // 以finish_time作为dataIndex - key: 'finish_time', - render: (finish, record) => { - // 假设record.start_time是存在的,并且finish是完成时间的时间戳 - return renderDuration(record.submit_time, finish); - }, - }, - { - title: t('渠道'), - dataIndex: 'channel_id', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return ( -
- { - copyText(text); // 假设copyText是用于文本复制的函数 - }} - > - {' '} - {text}{' '} - -
- ); - }, - }, - { - title: t('类型'), - dataIndex: 'action', - render: (text, record, index) => { - return
{renderType(text)}
; - }, - }, - { - title: t('任务ID'), - dataIndex: 'mj_id', - render: (text, record, index) => { - return
{text}
; - }, - }, - { - title: t('提交结果'), - dataIndex: 'code', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return
{renderCode(text)}
; - }, - }, - { - title: t('任务状态'), - dataIndex: 'status', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - { - title: t('进度'), - dataIndex: 'progress', - render: (text, record, index) => { - return ( -
- { - // 转换例如100%为数字100,如果text未定义,返回0 - - } -
- ); - }, - }, - { - title: t('结果图片'), - dataIndex: 'image_url', - render: (text, record, index) => { - if (!text) { - return t('无'); - } - return ( - - ); - }, - }, - { - title: 'Prompt', - dataIndex: 'prompt', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - { - title: 'PromptEn', - dataIndex: 'prompt_en', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - { - title: t('失败原因'), - dataIndex: 'fail_reason', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - ]; - - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); - const [logType, setLogType] = useState(0); - const isAdminUser = isAdmin(); - const [isModalOpenurl, setIsModalOpenurl] = useState(false); - const [showBanner, setShowBanner] = useState(false); - - // 定义模态框图片URL的状态和更新函数 - const [modalImageUrl, setModalImageUrl] = useState(''); - let now = new Date(); - // 初始化start_timestamp为前一天 - const [inputs, setInputs] = useState({ - channel_id: '', - mj_id: '', - start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000), - end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), - }); - const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs; - - const [stat, setStat] = useState({ - quota: 0, - token: 0, - }); - - const handleInputChange = (value, name) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const setLogsFormat = (logs) => { - for (let i = 0; i < logs.length; i++) { - logs[i].timestamp2string = timestamp2string(logs[i].created_at); - logs[i].key = '' + logs[i].id; - } - // data.key = '' + data.id - setLogs(logs); - setLogCount(logs.length + ITEMS_PER_PAGE); - // console.log(logCount); - }; - - const loadLogs = async (startIdx) => { - setLoading(true); - - let url = ''; - let localStartTimestamp = Date.parse(start_timestamp); - let localEndTimestamp = Date.parse(end_timestamp); - if (isAdminUser) { - url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } else { - url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } - const res = await API.get(url); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setLogsFormat(data); - } else { - let newLogs = [...logs]; - newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); - setLogsFormat(newLogs); - } - } else { - showError(message); - } - setLoading(false); - }; - - const pageData = logs.slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE, - ); - - const handlePageChange = (page) => { - setActivePage(page); - if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - loadLogs(page - 1).then((r) => {}); - } - }; - - const refresh = async () => { - // setLoading(true); - setActivePage(1); - await loadLogs(0); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess('已复制:' + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, [logType]); - - useEffect(() => { - const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled'); - if (mjNotifyEnabled !== 'true') { - setShowBanner(true); - } - }, []); - - return ( - <> - - {isAdminUser && showBanner ? ( - - ) : ( - <> - )} -
- <> - handleInputChange(value, 'channel_id')} - /> - handleInputChange(value, 'mj_id')} - /> - handleInputChange(value, 'start_timestamp')} - /> - handleInputChange(value, 'end_timestamp')} - /> - - - - - - -
- t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: logCount, - }), - }} - loading={loading} - /> - setIsModalOpen(false)} - onCancel={() => setIsModalOpen(false)} - closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式 - width={800} // 设置模态框宽度 - > -

{modalContent}

-
- setIsModalOpenurl(visible)} - /> - - - ); -}; - -export default LogsTable; diff --git a/web/src/components/ModelPricing.js b/web/src/components/ModelPricing.js deleted file mode 100644 index 16eb08f1..00000000 --- a/web/src/components/ModelPricing.js +++ /dev/null @@ -1,433 +0,0 @@ -import React, { useContext, useEffect, useRef, useMemo, useState } from 'react'; -import { API, copy, showError, showInfo, showSuccess } from '../helpers'; -import { useTranslation } from 'react-i18next'; - -import { - Banner, - Input, - Layout, - Modal, - Space, - Table, - Tag, - Tooltip, - Popover, - ImagePreview, - Button, -} from '@douyinfe/semi-ui'; -import { - IconMore, - IconVerify, - IconUploadError, - IconHelpCircle, -} from '@douyinfe/semi-icons'; -import { UserContext } from '../context/User/index.js'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; - -const ModelPricing = () => { - const { t } = useTranslation(); - const [filteredValue, setFilteredValue] = useState([]); - const compositionRef = useRef({ isComposition: false }); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [modalImageUrl, setModalImageUrl] = useState(''); - const [isModalOpenurl, setIsModalOpenurl] = useState(false); - const [selectedGroup, setSelectedGroup] = useState('default'); - - const rowSelection = useMemo( - () => ({ - onChange: (selectedRowKeys, selectedRows) => { - setSelectedRowKeys(selectedRowKeys); - }, - }), - [], - ); - - const handleChange = (value) => { - if (compositionRef.current.isComposition) { - return; - } - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - const handleCompositionStart = () => { - compositionRef.current.isComposition = true; - }; - - const handleCompositionEnd = (event) => { - compositionRef.current.isComposition = false; - const value = event.target.value; - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - - function renderQuotaType(type) { - // Ensure all cases are string literals by adding quotes. - switch (type) { - case 1: - return ( - - {t('按次计费')} - - ); - case 0: - return ( - - {t('按量计费')} - - ); - default: - return t('未知'); - } - } - - function renderAvailable(available) { - return available ? ( - {t('您的分组可以使用该模型')} - } - position='top' - key={available} - style={{ - backgroundColor: 'rgba(var(--semi-blue-4),1)', - borderColor: 'rgba(var(--semi-blue-4),1)', - color: 'var(--semi-color-white)', - borderWidth: 1, - borderStyle: 'solid', - }} - > - - - ) : null; - } - - const columns = [ - { - title: t('可用性'), - dataIndex: 'available', - render: (text, record, index) => { - // if record.enable_groups contains selectedGroup, then available is true - return renderAvailable(record.enable_groups.includes(selectedGroup)); - }, - sorter: (a, b) => { - const aAvailable = a.enable_groups.includes(selectedGroup); - const bAvailable = b.enable_groups.includes(selectedGroup); - return Number(aAvailable) - Number(bAvailable); - }, - defaultSortOrder: 'descend', - }, - { - title: t('模型名称'), - dataIndex: 'model_name', - render: (text, record, index) => { - return ( - <> - { - copyText(text); - }} - > - {text} - - - ); - }, - onFilter: (value, record) => - record.model_name.toLowerCase().includes(value.toLowerCase()), - filteredValue, - }, - { - title: t('计费类型'), - dataIndex: 'quota_type', - render: (text, record, index) => { - return renderQuotaType(parseInt(text)); - }, - sorter: (a, b) => a.quota_type - b.quota_type, - }, - { - title: t('可用分组'), - dataIndex: 'enable_groups', - render: (text, record, index) => { - // enable_groups is a string array - return ( - - {text.map((group) => { - if (usableGroup[group]) { - if (group === selectedGroup) { - return ( - }> - {group} - - ); - } else { - return ( - { - setSelectedGroup(group); - showInfo( - t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { - group: group, - ratio: groupRatio[group], - }), - ); - }} - > - {group} - - ); - } - } - })} - - ); - }, - }, - { - title: () => ( - - {t('倍率')} - - {t('倍率是为了方便换算不同价格的模型')} -
- {t('点击查看倍率说明')} - - } - position='top' - style={{ - backgroundColor: 'rgba(var(--semi-blue-4),1)', - borderColor: 'rgba(var(--semi-blue-4),1)', - color: 'var(--semi-color-white)', - borderWidth: 1, - borderStyle: 'solid', - }} - > - { - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> -
-
- ), - dataIndex: 'model_ratio', - render: (text, record, index) => { - let content = text; - let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); - content = ( - <> - - {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} - -
- - {t('补全倍率')}: - {record.quota_type === 0 ? completionRatio : t('无')} - -
- - {t('分组倍率')}:{groupRatio[selectedGroup]} - - - ); - return
{content}
; - }, - }, - { - title: t('模型价格'), - dataIndex: 'model_price', - render: (text, record, index) => { - let content = text; - if (record.quota_type === 0) { - // 这里的 *2 是因为 1倍率=0.002刀,请勿删除 - let inputRatioPrice = - record.model_ratio * 2 * groupRatio[selectedGroup]; - let completionRatioPrice = - record.model_ratio * - record.completion_ratio * - 2 * - groupRatio[selectedGroup]; - content = ( - <> - - {t('提示')} ${inputRatioPrice} / 1M tokens - -
- - {t('补全')} ${completionRatioPrice} / 1M tokens - - - ); - } else { - let price = parseFloat(text) * groupRatio[selectedGroup]; - content = ( - <> - ${t('模型价格')}:${price} - - ); - } - return
{content}
; - }, - }, - ]; - - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(true); - const [userState, userDispatch] = useContext(UserContext); - const [groupRatio, setGroupRatio] = useState({}); - const [usableGroup, setUsableGroup] = useState({}); - - const setModelsFormat = (models, groupRatio) => { - for (let i = 0; i < models.length; i++) { - models[i].key = models[i].model_name; - models[i].group_ratio = groupRatio[models[i].model_name]; - } - // sort by quota_type - models.sort((a, b) => { - return a.quota_type - b.quota_type; - }); - - // sort by model_name, start with gpt is max, other use localeCompare - models.sort((a, b) => { - if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) { - return -1; - } else if ( - !a.model_name.startsWith('gpt') && - b.model_name.startsWith('gpt') - ) { - return 1; - } else { - return a.model_name.localeCompare(b.model_name); - } - }); - - setModels(models); - }; - - const loadPricing = async () => { - setLoading(true); - - let url = ''; - url = `/api/pricing`; - const res = await API.get(url); - const { success, message, data, group_ratio, usable_group } = res.data; - if (success) { - setGroupRatio(group_ratio); - setUsableGroup(usable_group); - setSelectedGroup(userState.user ? userState.user.group : 'default'); - setModelsFormat(data, group_ratio); - } else { - showError(message); - } - setLoading(false); - }; - - const refresh = async () => { - await loadPricing(); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess('已复制:' + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, []); - - return ( - <> - - {userState.user ? ( - - ) : ( - - )} -
- - {t( - '按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)', - )} - - } - closeIcon='null' - /> -
- - - - -
- t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: models.length, - }), - pageSize: models.length, - showSizeChanger: false, - }} - rowSelection={rowSelection} - /> - setIsModalOpenurl(visible)} - /> - - - ); -}; - -export default ModelPricing; diff --git a/web/src/components/OperationSetting.js b/web/src/components/OperationSetting.js deleted file mode 100644 index 28fb3a8f..00000000 --- a/web/src/components/OperationSetting.js +++ /dev/null @@ -1,173 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Card, Spin, Tabs } from '@douyinfe/semi-ui'; -import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js'; -import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js'; -import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js'; -import SettingsLog from '../pages/Setting/Operation/SettingsLog.js'; -import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js'; -import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js'; -import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js'; -import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js'; -import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js'; -import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js'; - -import { API, showError, showSuccess } from '../helpers'; -import SettingsChats from '../pages/Setting/Operation/SettingsChats.js'; -import { useTranslation } from 'react-i18next'; -import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js'; - -const OperationSetting = () => { - const { t } = useTranslation(); - let [inputs, setInputs] = useState({ - QuotaForNewUser: 0, - QuotaForInviter: 0, - QuotaForInvitee: 0, - QuotaRemindThreshold: 0, - PreConsumedQuota: 0, - StreamCacheQueueLength: 0, - ModelRatio: '', - CacheRatio: '', - CompletionRatio: '', - ModelPrice: '', - GroupRatio: '', - UserUsableGroups: '', - TopUpLink: '', - 'general_setting.docs_link': '', - // ChatLink2: '', // 添加的新状态变量 - QuotaPerUnit: 0, - AutomaticDisableChannelEnabled: false, - AutomaticEnableChannelEnabled: false, - ChannelDisableThreshold: 0, - LogConsumeEnabled: false, - DisplayInCurrencyEnabled: false, - DisplayTokenStatEnabled: false, - CheckSensitiveEnabled: false, - CheckSensitiveOnPromptEnabled: false, - CheckSensitiveOnCompletionEnabled: '', - StopOnSensitiveEnabled: '', - SensitiveWords: '', - MjNotifyEnabled: false, - MjAccountFilterEnabled: false, - MjModeClearEnabled: false, - MjForwardUrlEnabled: false, - MjActionCheckSuccessEnabled: false, - DrawingEnabled: false, - DataExportEnabled: false, - DataExportDefaultTime: 'hour', - DataExportInterval: 5, - DefaultCollapseSidebar: false, // 默认折叠侧边栏 - RetryTimes: 0, - Chats: '[]', - DemoSiteEnabled: false, - SelfUseModeEnabled: false, - AutomaticDisableKeywords: '', - }); - - let [loading, setLoading] = useState(false); - - const getOptions = async () => { - const res = await API.get('/api/option/'); - const { success, message, data } = res.data; - if (success) { - let newInputs = {}; - data.forEach((item) => { - if ( - item.key === 'ModelRatio' || - item.key === 'GroupRatio' || - item.key === 'UserUsableGroups' || - item.key === 'CompletionRatio' || - item.key === 'ModelPrice' || - item.key === 'CacheRatio' - ) { - item.value = JSON.stringify(JSON.parse(item.value), null, 2); - } - if ( - item.key.endsWith('Enabled') || - ['DefaultCollapseSidebar'].includes(item.key) - ) { - newInputs[item.key] = item.value === 'true' ? true : false; - } else { - newInputs[item.key] = item.value; - } - }); - - setInputs(newInputs); - } else { - showError(message); - } - }; - async function onRefresh() { - try { - setLoading(true); - await getOptions(); - // showSuccess('刷新成功'); - } catch (error) { - showError('刷新失败'); - } finally { - setLoading(false); - } - } - - useEffect(() => { - onRefresh(); - }, []); - - return ( - <> - - {/* 通用设置 */} - - - - {/* 绘图设置 */} - - - - {/* 屏蔽词过滤设置 */} - - - - {/* 日志设置 */} - - - - {/* 数据看板 */} - - - - {/* 监控设置 */} - - - - {/* 额度设置 */} - - - - {/* 聊天设置 */} - - - - {/* 分组倍率设置 */} - - - - {/* 合并模型倍率设置和可视化倍率设置 */} - - - - - - - - - - - - - - - - ); -}; - -export default OperationSetting; diff --git a/web/src/components/PasswordResetConfirm.js b/web/src/components/PasswordResetConfirm.js deleted file mode 100644 index 222c8add..00000000 --- a/web/src/components/PasswordResetConfirm.js +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; -import { API, copy, showError, showNotice } from '../helpers'; -import { useSearchParams } from 'react-router-dom'; - -const PasswordResetConfirm = () => { - const [inputs, setInputs] = useState({ - email: '', - token: '', - }); - const { email, token } = inputs; - - const [loading, setLoading] = useState(false); - - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - - const [newPassword, setNewPassword] = useState(''); - - const [searchParams, setSearchParams] = useSearchParams(); - useEffect(() => { - let token = searchParams.get('token'); - let email = searchParams.get('email'); - setInputs({ - token, - email, - }); - }, []); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); - }, [disableButton, countdown]); - - async function handleSubmit(e) { - setDisableButton(true); - if (!email) return; - setLoading(true); - const res = await API.post(`/api/user/reset`, { - email, - token, - }); - const { success, message } = res.data; - if (success) { - let password = res.data.data; - setNewPassword(password); - await copy(password); - showNotice(`新密码已复制到剪贴板:${password}`); - } else { - showError(message); - } - setLoading(false); - } - - return ( - - -
- 密码重置确认 -
-
- - - {newPassword && ( - { - e.target.select(); - navigator.clipboard.writeText(newPassword); - showNotice(`密码已复制到剪贴板:${newPassword}`); - }} - /> - )} - - - -
-
- ); -}; - -export default PasswordResetConfirm; diff --git a/web/src/components/PasswordResetForm.js b/web/src/components/PasswordResetForm.js deleted file mode 100644 index 631d83be..00000000 --- a/web/src/components/PasswordResetForm.js +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; -import { API, showError, showInfo, showSuccess } from '../helpers'; -import Turnstile from 'react-turnstile'; - -const PasswordResetForm = () => { - const [inputs, setInputs] = useState({ - email: '', - }); - const { email } = inputs; - - const [loading, setLoading] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); - }, [disableButton, countdown]); - - function handleChange(e) { - const { name, value } = e.target; - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - setDisableButton(true); - if (!email) return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess('重置邮件发送成功,请检查邮箱!'); - setInputs({ ...inputs, email: '' }); - } else { - showError(message); - } - setLoading(false); - } - - return ( - - -
- 密码重置 -
-
- - - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} - - - -
-
- ); -}; - -export default PasswordResetForm; diff --git a/web/src/components/PersonalSetting.js b/web/src/components/PersonalSetting.js deleted file mode 100644 index 0f52c319..00000000 --- a/web/src/components/PersonalSetting.js +++ /dev/null @@ -1,1193 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - API, - copy, - isRoot, - showError, - showInfo, - showSuccess, -} from '../helpers'; -import Turnstile from 'react-turnstile'; -import { UserContext } from '../context/User'; -import { - onGitHubOAuthClicked, - onOIDCClicked, - onLinuxDOOAuthClicked, -} from './utils'; -import { - Avatar, - Banner, - Button, - Card, - Descriptions, - Image, - Input, - InputNumber, - Layout, - Modal, - Space, - Tag, - Typography, - Collapsible, - Select, - Radio, - RadioGroup, - AutoComplete, - Checkbox, - Tabs, - TabPane, -} from '@douyinfe/semi-ui'; -import { - getQuotaPerUnit, - renderQuota, - renderQuotaWithPrompt, - stringToColor, -} from '../helpers/render'; -import TelegramLoginButton from 'react-telegram-login'; -import { useTranslation } from 'react-i18next'; - -const PersonalSetting = () => { - const [userState, userDispatch] = useContext(UserContext); - let navigate = useNavigate(); - const { t } = useTranslation(); - - const [inputs, setInputs] = useState({ - wechat_verification_code: '', - email_verification_code: '', - email: '', - self_account_deletion_confirmation: '', - original_password: '', - set_new_password: '', - set_new_password_confirmation: '', - }); - const [status, setStatus] = useState({}); - const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); - const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); - const [showEmailBindModal, setShowEmailBindModal] = useState(false); - const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - const [affLink, setAffLink] = useState(''); - const [systemToken, setSystemToken] = useState(''); - const [models, setModels] = useState([]); - const [openTransfer, setOpenTransfer] = useState(false); - const [transferAmount, setTransferAmount] = useState(0); - const [isModelsExpanded, setIsModelsExpanded] = useState(() => { - // Initialize from localStorage if available - const savedState = localStorage.getItem('modelsExpanded'); - return savedState ? JSON.parse(savedState) : false; - }); - const MODELS_DISPLAY_COUNT = 10; // 默认显示的模型数量 - const [notificationSettings, setNotificationSettings] = useState({ - warningType: 'email', - warningThreshold: 100000, - webhookUrl: '', - webhookSecret: '', - notificationEmail: '', - acceptUnsetModelRatioModel: false, - }); - const [showWebhookDocs, setShowWebhookDocs] = useState(false); - - useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - getUserData().then((res) => { - console.log(userState); - }); - loadModels().then(); - getAffLink().then(); - setTransferAmount(getQuotaPerUnit()); - }, []); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); // Clean up on unmount - }, [disableButton, countdown]); - - useEffect(() => { - if (userState?.user?.setting) { - const settings = JSON.parse(userState.user.setting); - setNotificationSettings({ - warningType: settings.notify_type || 'email', - warningThreshold: settings.quota_warning_threshold || 500000, - webhookUrl: settings.webhook_url || '', - webhookSecret: settings.webhook_secret || '', - notificationEmail: settings.notification_email || '', - acceptUnsetModelRatioModel: - settings.accept_unset_model_ratio_model || false, - }); - } - }, [userState?.user?.setting]); - - // Save models expanded state to localStorage whenever it changes - useEffect(() => { - localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded)); - }, [isModelsExpanded]); - - const handleInputChange = (name, value) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const generateAccessToken = async () => { - const res = await API.get('/api/user/token'); - const { success, message, data } = res.data; - if (success) { - setSystemToken(data); - await copy(data); - showSuccess(t('令牌已重置并已复制到剪贴板')); - } else { - showError(message); - } - }; - - const getAffLink = async () => { - const res = await API.get('/api/user/aff'); - const { success, message, data } = res.data; - if (success) { - let link = `${window.location.origin}/register?aff=${data}`; - setAffLink(link); - } else { - showError(message); - } - }; - - const getUserData = async () => { - let res = await API.get(`/api/user/self`); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - } else { - showError(message); - } - }; - - const loadModels = async () => { - let res = await API.get(`/api/user/models`); - const { success, message, data } = res.data; - if (success) { - if (data != null) { - setModels(data); - } - } else { - showError(message); - } - }; - - const handleAffLinkClick = async (e) => { - e.target.select(); - await copy(e.target.value); - showSuccess(t('邀请链接已复制到剪切板')); - }; - - const handleSystemTokenClick = async (e) => { - e.target.select(); - await copy(e.target.value); - showSuccess(t('系统令牌已复制到剪切板')); - }; - - const deleteAccount = async () => { - if (inputs.self_account_deletion_confirmation !== userState.user.username) { - showError(t('请输入你的账户名以确认删除!')); - return; - } - - const res = await API.delete('/api/user/self'); - const { success, message } = res.data; - - if (success) { - showSuccess(t('账户已删除!')); - await API.get('/api/user/logout'); - userDispatch({ type: 'logout' }); - localStorage.removeItem('user'); - navigate('/login'); - } else { - showError(message); - } - }; - - const bindWeChat = async () => { - if (inputs.wechat_verification_code === '') return; - const res = await API.get( - `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess(t('微信账户绑定成功!')); - setShowWeChatBindModal(false); - } else { - showError(message); - } - }; - - const changePassword = async () => { - if (inputs.original_password === '') { - showError(t('请输入原密码!')); - return; - } - if (inputs.set_new_password === '') { - showError(t('请输入新密码!')); - return; - } - if (inputs.original_password === inputs.set_new_password) { - showError(t('新密码需要和原密码不一致!')); - return; - } - if (inputs.set_new_password !== inputs.set_new_password_confirmation) { - showError(t('两次输入的密码不一致!')); - return; - } - const res = await API.put(`/api/user/self`, { - original_password: inputs.original_password, - password: inputs.set_new_password, - }); - const { success, message } = res.data; - if (success) { - showSuccess(t('密码修改成功!')); - setShowWeChatBindModal(false); - } else { - showError(message); - } - setShowChangePasswordModal(false); - }; - - const transfer = async () => { - if (transferAmount < getQuotaPerUnit()) { - showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit())); - return; - } - const res = await API.post(`/api/user/aff_transfer`, { - quota: transferAmount, - }); - const { success, message } = res.data; - if (success) { - showSuccess(message); - setOpenTransfer(false); - getUserData().then(); - } else { - showError(message); - } - }; - - const sendVerificationCode = async () => { - if (inputs.email === '') { - showError(t('请输入邮箱!')); - return; - } - setDisableButton(true); - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess(t('验证码发送成功,请检查邮箱!')); - } else { - showError(message); - } - setLoading(false); - }; - - const bindEmail = async () => { - if (inputs.email_verification_code === '') { - showError(t('请输入邮箱验证码!')); - return; - } - setLoading(true); - const res = await API.get( - `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess(t('邮箱账户绑定成功!')); - setShowEmailBindModal(false); - userState.user.email = inputs.email; - } else { - showError(message); - } - setLoading(false); - }; - - const getUsername = () => { - if (userState.user) { - return userState.user.username; - } else { - return 'null'; - } - }; - - const handleCancel = () => { - setOpenTransfer(false); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制:') + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; - - const handleNotificationSettingChange = (type, value) => { - setNotificationSettings((prev) => ({ - ...prev, - [type]: value.target ? value.target.value : value, // 处理 Radio 事件对象 - })); - }; - - const saveNotificationSettings = async () => { - try { - const res = await API.put('/api/user/setting', { - notify_type: notificationSettings.warningType, - quota_warning_threshold: parseFloat( - notificationSettings.warningThreshold, - ), - webhook_url: notificationSettings.webhookUrl, - webhook_secret: notificationSettings.webhookSecret, - notification_email: notificationSettings.notificationEmail, - accept_unset_model_ratio_model: - notificationSettings.acceptUnsetModelRatioModel, - }); - - if (res.data.success) { - showSuccess(t('通知设置已更新')); - await getUserData(); - } else { - showError(res.data.message); - } - } catch (error) { - showError(t('更新通知设置失败')); - } - }; - - return ( -
- - - -
- - {t('可用额度')} - {renderQuotaWithPrompt(userState?.user?.aff_quota)} - - -
-
- - {t('划转额度')} - {renderQuotaWithPrompt(transferAmount)}{' '} - {t('最低') + renderQuota(getQuotaPerUnit())} - -
- setTransferAmount(value)} - disabled={false} - > -
-
-
-
- - {typeof getUsername() === 'string' && - getUsername().slice(0, 1)} - - } - title={{getUsername()}} - description={ - isRoot() ? ( - {t('管理员')} - ) : ( - {t('普通用户')} - ) - } - > - } - headerExtraContent={ - <> - - {'ID: ' + userState?.user?.id} - {userState?.user?.group} - - - } - footer={ - <> -
- - {t('可用模型')} - -
-
- {models.length <= MODELS_DISPLAY_COUNT ? ( - - {models.map((model) => ( - { - copyText(model); - }} - > - {model} - - ))} - - ) : ( - <> - - - {models.map((model) => ( - { - copyText(model); - }} - > - {model} - - ))} - setIsModelsExpanded(false)} - > - {t('收起')} - - - - {!isModelsExpanded && ( - - {models - .slice(0, MODELS_DISPLAY_COUNT) - .map((model) => ( - { - copyText(model); - }} - > - {model} - - ))} - setIsModelsExpanded(true)} - > - {t('更多')} {models.length - MODELS_DISPLAY_COUNT}{' '} - {t('个模型')} - - - )} - - )} -
- - } - > - - - {renderQuota(userState?.user?.quota)} - - - {renderQuota(userState?.user?.used_quota)} - - - {userState.user?.request_count} - - -
- - {t('邀请链接')} - -
- } - > - {t('邀请信息')} -
- - - - {renderQuota(userState?.user?.aff_quota)} - - - - - {renderQuota(userState?.user?.aff_history_quota)} - - - {userState?.user?.aff_count} - - -
- - - {t('个人信息')} -
- {t('邮箱')} -
-
- -
-
- -
-
-
-
- {t('微信')} -
-
- -
-
- -
-
-
-
- {t('GitHub')} -
-
- -
-
- -
-
-
-
- {t('OIDC')} -
-
- -
-
- -
-
-
-
- {t('Telegram')} -
-
- -
-
- {status.telegram_oauth ? ( - userState.user.telegram_id !== '' ? ( - - ) : ( - - ) - ) : ( - - )} -
-
-
-
- {t('LinuxDO')} -
-
- -
-
- -
-
-
-
- - - - - - - {systemToken && ( - - )} - setShowWeChatBindModal(false)} - visible={showWeChatBindModal} - size={'small'} - > - -
-

- 微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) -

-
- - handleInputChange('wechat_verification_code', v) - } - /> - -
-
-
- - - -
- {t('通知方式')} -
- - handleNotificationSettingChange('warningType', value) - } - > - {t('邮件通知')} - {t('Webhook通知')} - -
-
- {notificationSettings.warningType === 'webhook' && ( - <> -
- - {t('Webhook地址')} - -
- - handleNotificationSettingChange('webhookUrl', val) - } - placeholder={t( - '请输入Webhook地址,例如: https://example.com/webhook', - )} - /> - - {t( - '只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求', - )} - - -
- setShowWebhookDocs(!showWebhookDocs) - } - > - {t('Webhook请求结构')}{' '} - {showWebhookDocs ? '▼' : '▶'} -
- -
-                                {`{
-    "type": "quota_exceed",      // 通知类型
-    "title": "标题",             // 通知标题
-    "content": "通知内容",       // 通知内容,支持 {{value}} 变量占位符
-    "values": ["值1", "值2"],    // 按顺序替换content中的 {{value}} 占位符
-    "timestamp": 1739950503      // 时间戳
-}
-
-示例:
-{
-    "type": "quota_exceed",
-    "title": "额度预警通知",
-    "content": "您的额度即将用尽,当前剩余额度为 {{value}}",
-    "values": ["$0.99"],
-    "timestamp": 1739950503
-}`}
-                              
-
-
-
-
-
- - {t('接口凭证(可选)')} - -
- - handleNotificationSettingChange( - 'webhookSecret', - val, - ) - } - placeholder={t('请输入密钥')} - /> - - {t( - '密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性', - )} - - - {t('Authorization: Bearer your-secret-key')} - -
-
- - )} - {notificationSettings.warningType === 'email' && ( -
- {t('通知邮箱')} -
- - handleNotificationSettingChange( - 'notificationEmail', - val, - ) - } - placeholder={t('留空则使用账号绑定的邮箱')} - /> - - {t( - '设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱', - )} - -
-
- )} -
- - {t('额度预警阈值')}{' '} - {renderQuotaWithPrompt( - notificationSettings.warningThreshold, - )} - -
- - handleNotificationSettingChange( - 'warningThreshold', - val, - ) - } - style={{ width: 200 }} - placeholder={t('请输入预警额度')} - data={[ - { value: 100000, label: '0.2$' }, - { value: 500000, label: '1$' }, - { value: 1000000, label: '5$' }, - { value: 5000000, label: '10$' }, - ]} - /> -
- - {t( - '当剩余额度低于此数值时,系统将通过选择的方式发送通知', - )} - -
-
- -
- - {t('接受未设置价格模型')} - -
- - handleNotificationSettingChange( - 'acceptUnsetModelRatioModel', - e.target.checked, - ) - } - > - {t('接受未设置价格模型')} - - - {t( - '当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用', - )} - -
-
-
-
-
- -
-
- setShowEmailBindModal(false)} - onOk={bindEmail} - visible={showEmailBindModal} - size={'small'} - centered={true} - maskClosable={false} - > - - {t('绑定邮箱地址')} - -
- handleInputChange('email', value)} - name='email' - type='email' - /> - -
-
- - handleInputChange('email_verification_code', value) - } - /> -
- {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
- setShowAccountDeleteModal(false)} - visible={showAccountDeleteModal} - size={'small'} - centered={true} - onOk={deleteAccount} - > -
- -
-
- - handleInputChange( - 'self_account_deletion_confirmation', - value, - ) - } - /> - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
-
- setShowChangePasswordModal(false)} - visible={showChangePasswordModal} - size={'small'} - centered={true} - onOk={changePassword} - > -
- - handleInputChange('original_password', value) - } - /> - - handleInputChange('set_new_password', value) - } - /> - - handleInputChange('set_new_password_confirmation', value) - } - /> - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
-
-
- - - - ); -}; - -export default PersonalSetting; diff --git a/web/src/components/PrivateRoute.js b/web/src/components/PrivateRoute.js deleted file mode 100644 index ca938c41..00000000 --- a/web/src/components/PrivateRoute.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Navigate } from 'react-router-dom'; - -import { history } from '../helpers'; - -function PrivateRoute({ children }) { - if (!localStorage.getItem('user')) { - return ; - } - return children; -} - -export { PrivateRoute }; diff --git a/web/src/components/RedemptionsTable.js b/web/src/components/RedemptionsTable.js deleted file mode 100644 index f4efca06..00000000 --- a/web/src/components/RedemptionsTable.js +++ /dev/null @@ -1,449 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - API, - copy, - showError, - showSuccess, - timestamp2string, -} from '../helpers'; - -import { ITEMS_PER_PAGE } from '../constants'; -import { renderQuota } from '../helpers/render'; -import { - Button, - Divider, - Form, - Modal, - Popconfirm, - Popover, - Table, - Tag, -} from '@douyinfe/semi-ui'; -import EditRedemption from '../pages/Redemption/EditRedemption'; -import { useTranslation } from 'react-i18next'; - -function renderTimestamp(timestamp) { - return <>{timestamp2string(timestamp)}; -} - -const RedemptionsTable = () => { - const { t } = useTranslation(); - - const renderStatus = (status) => { - switch (status) { - case 1: - return ( - - {t('未使用')} - - ); - case 2: - return ( - - {t('已禁用')} - - ); - case 3: - return ( - - {t('已使用')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const columns = [ - { - title: t('ID'), - dataIndex: 'id', - }, - { - title: t('名称'), - dataIndex: 'name', - }, - { - title: t('状态'), - dataIndex: 'status', - key: 'status', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - { - title: t('额度'), - dataIndex: 'quota', - render: (text, record, index) => { - return
{renderQuota(parseInt(text))}
; - }, - }, - { - title: t('创建时间'), - dataIndex: 'created_time', - render: (text, record, index) => { - return
{renderTimestamp(text)}
; - }, - }, - { - title: t('兑换人ID'), - dataIndex: 'used_user_id', - render: (text, record, index) => { - return
{text === 0 ? t('无') : text}
; - }, - }, - { - title: '', - dataIndex: 'operate', - render: (text, record, index) => ( -
- - - - - { - manageRedemption(record.id, 'delete', record).then(() => { - removeRecord(record.key); - }); - }} - > - - - {record.status === 1 ? ( - - ) : ( - - )} - -
- ), - }, - ]; - - const [redemptions, setRedemptions] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); - const [selectedKeys, setSelectedKeys] = useState([]); - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [editingRedemption, setEditingRedemption] = useState({ - id: undefined, - }); - const [showEdit, setShowEdit] = useState(false); - - const closeEdit = () => { - setShowEdit(false); - }; - - const setRedemptionFormat = (redeptions) => { - setRedemptions(redeptions); - }; - - const loadRedemptions = async (startIdx, pageSize) => { - const res = await API.get( - `/api/redemption/?p=${startIdx}&page_size=${pageSize}`, - ); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setTokenCount(data.total); - setRedemptionFormat(newPageData); - } else { - showError(message); - } - setLoading(false); - }; - - const removeRecord = (key) => { - let newDataSource = [...redemptions]; - if (key != null) { - let idx = newDataSource.findIndex((data) => data.key === key); - - if (idx > -1) { - newDataSource.splice(idx, 1); - setRedemptions(newDataSource); - } - } - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制到剪贴板!')); - } else { - // setSearchKeyword(text); - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; - - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(redemptions.length / pageSize) + 1) { - await loadRedemptions(activePage - 1, pageSize); - } - setActivePage(activePage); - })(); - }; - - useEffect(() => { - loadRedemptions(0, pageSize) - .then() - .catch((reason) => { - showError(reason); - }); - }, []); - - const refresh = async () => { - await loadRedemptions(activePage - 1, pageSize); - }; - - const manageRedemption = async (id, action, record) => { - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/redemption/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/redemption/?status_only=true', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/redemption/?status_only=true', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess(t('操作成功完成!')); - let redemption = res.data.data; - let newRedemptions = [...redemptions]; - // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - if (action === 'delete') { - } else { - record.status = redemption.status; - } - setRedemptions(newRedemptions); - } else { - showError(message); - } - }; - - const searchRedemptions = async (keyword, page, pageSize) => { - if (searchKeyword === '') { - await loadRedemptions(page, pageSize); - return; - } - setSearching(true); - const res = await API.get( - `/api/redemption/search?keyword=${keyword}&p=${page}&page_size=${pageSize}`, - ); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setTokenCount(data.total); - setRedemptionFormat(newPageData); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - - const sortRedemption = (key) => { - if (redemptions.length === 0) return; - setLoading(true); - let sortedRedemptions = [...redemptions]; - sortedRedemptions.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); - }); - if (sortedRedemptions[0].id === redemptions[0].id) { - sortedRedemptions.reverse(); - } - setRedemptions(sortedRedemptions); - setLoading(false); - }; - - const handlePageChange = (page) => { - setActivePage(page); - if (searchKeyword === '') { - loadRedemptions(page, pageSize).then(); - } else { - searchRedemptions(searchKeyword, page, pageSize).then(); - } - }; - - let pageData = redemptions; - const rowSelection = { - onSelect: (record, selected) => {}, - onSelectAll: (selected, selectedRows) => {}, - onChange: (selectedRowKeys, selectedRows) => { - setSelectedKeys(selectedRows); - }, - }; - - const handleRow = (record, index) => { - if (record.status !== 1) { - return { - style: { - background: 'var(--semi-color-disabled-border)', - }, - }; - } else { - return {}; - } - }; - - return ( - <> - -
{ - searchRedemptions(searchKeyword, activePage, pageSize).then(); - }} - > - - - -
- - -
- -
- t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: tokenCount, - }), - onPageSizeChange: (size) => { - setPageSize(size); - setActivePage(1); - if (searchKeyword === '') { - loadRedemptions(1, size).then(); - } else { - searchRedemptions(searchKeyword, 1, size).then(); - } - }, - onPageChange: handlePageChange, - }} - loading={loading} - rowSelection={rowSelection} - onRow={handleRow} - >
- - ); -}; - -export default RedemptionsTable; diff --git a/web/src/components/RegisterForm.js b/web/src/components/RegisterForm.js deleted file mode 100644 index 50fe4def..00000000 --- a/web/src/components/RegisterForm.js +++ /dev/null @@ -1,434 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { - API, - getLogo, - showError, - showInfo, - showSuccess, - updateAPI, -} from '../helpers'; -import Turnstile from 'react-turnstile'; -import { - Button, - Card, - Divider, - Form, - Icon, - Layout, - Modal, -} from '@douyinfe/semi-ui'; -import Title from '@douyinfe/semi-ui/lib/es/typography/title'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import { IconGithubLogo } from '@douyinfe/semi-icons'; -import { - onGitHubOAuthClicked, - onLinuxDOOAuthClicked, - onOIDCClicked, -} from './utils.js'; -import OIDCIcon from './OIDCIcon.js'; -import LinuxDoIcon from './LinuxDoIcon.js'; -import WeChatIcon from './WeChatIcon.js'; -import TelegramLoginButton from 'react-telegram-login/src'; -import { setUserData } from '../helpers/data.js'; -import { UserContext } from '../context/User/index.js'; -import { useTranslation } from 'react-i18next'; - -const RegisterForm = () => { - const { t } = useTranslation(); - const [inputs, setInputs] = useState({ - username: '', - password: '', - password2: '', - email: '', - verification_code: '', - }); - const { username, password, password2 } = inputs; - const [showEmailVerification, setShowEmailVerification] = useState(false); - const [userState, userDispatch] = useContext(UserContext); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); - const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - const [status, setStatus] = useState({}); - let navigate = useNavigate(); - const logo = getLogo(); - - let affCode = new URLSearchParams(window.location.search).get('aff'); - if (affCode) { - localStorage.setItem('aff', affCode); - } - - useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - setShowEmailVerification(status.email_verification); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - }); - - const onWeChatLoginClicked = () => { - setShowWeChatLoginModal(true); - }; - - const onSubmitWeChatVerificationCode = async () => { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - const res = await API.get( - `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - setUserData(data); - updateAPI(); - navigate('/'); - showSuccess('登录成功!'); - setShowWeChatLoginModal(false); - } else { - showError(message); - } - }; - - function handleChange(name, value) { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - if (password.length < 8) { - showInfo('密码长度不得小于 8 位!'); - return; - } - if (password !== password2) { - showInfo('两次输入的密码不一致'); - return; - } - if (username && password) { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - if (!affCode) { - affCode = localStorage.getItem('aff'); - } - inputs.aff_code = affCode; - const res = await API.post( - `/api/user/register?turnstile=${turnstileToken}`, - inputs, - ); - const { success, message } = res.data; - if (success) { - navigate('/login'); - showSuccess('注册成功!'); - } else { - showError(message); - } - setLoading(false); - } - } - - const sendVerificationCode = async () => { - if (inputs.email === '') return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess('验证码发送成功,请检查你的邮箱!'); - } else { - showError(message); - } - setLoading(false); - }; - - const onTelegramLoginClicked = async (response) => { - const fields = [ - 'id', - 'first_name', - 'last_name', - 'username', - 'photo_url', - 'auth_date', - 'hash', - 'lang', - ]; - const params = {}; - fields.forEach((field) => { - if (response[field]) { - params[field] = response[field]; - } - }); - const res = await API.get(`/api/oauth/telegram/login`, { params }); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); - setUserData(data); - updateAPI(); - navigate('/'); - } else { - showError(message); - } - }; - - return ( -
- - - -
-
- - - {t('新用户注册')} - -
- handleChange('username', value)} - /> - handleChange('password', value)} - /> - handleChange('password2', value)} - /> - {showEmailVerification ? ( - <> - handleChange('email', value)} - name='email' - type='email' - suffix={ - - } - /> - - handleChange('verification_code', value) - } - name='verification_code' - /> - - ) : ( - <> - )} - - -
- - {t('已有账户?')} - {t('点击登录')} - -
- {status.github_oauth || - status.oidc_enabled || - status.wechat_login || - status.telegram_oauth || - status.linuxdo_oauth ? ( - <> - - {t('第三方登录')} - -
- {status.github_oauth ? ( -
- {status.telegram_oauth ? ( - <> -
- -
- - ) : ( - <> - )} - - ) : ( - <> - )} -
- setShowWeChatLoginModal(false)} - okText={t('登录')} - size={'small'} - centered={true} - > -
- -
-
-

- {t( - '微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)', - )} -

-
-
- - handleChange('wechat_verification_code', value) - } - /> - -
- {turnstileEnabled ? ( -
- { - setTurnstileToken(token); - }} - /> -
- ) : ( - <> - )} -
-
-
-
-
- ); -}; - -export default RegisterForm; diff --git a/web/src/components/SiderBar.js b/web/src/components/SiderBar.js deleted file mode 100644 index 25b350d1..00000000 --- a/web/src/components/SiderBar.js +++ /dev/null @@ -1,535 +0,0 @@ -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useLocation } from 'react-router-dom'; -import { UserContext } from '../context/User'; -import { StatusContext } from '../context/Status'; -import { useTranslation } from 'react-i18next'; - -import { - API, - getLogo, - getSystemName, - isAdmin, - isMobile, - showError, -} from '../helpers'; -import '../index.css'; - -import { - IconCalendarClock, - IconChecklistStroked, - IconComment, - IconCommentStroked, - IconCreditCard, - IconGift, - IconHelpCircle, - IconHistogram, - IconHome, - IconImage, - IconKey, - IconLayers, - IconPriceTag, - IconSetting, - IconUser, -} from '@douyinfe/semi-icons'; -import { - Avatar, - Dropdown, - Layout, - Nav, - Switch, - Divider, -} from '@douyinfe/semi-ui'; -import { setStatusData } from '../helpers/data.js'; -import { stringToColor } from '../helpers/render.js'; -import { useSetTheme, useTheme } from '../context/Theme/index.js'; -import { StyleContext } from '../context/Style/index.js'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; - -// 自定义侧边栏按钮样式 -const navItemStyle = { - borderRadius: '6px', - margin: '4px 8px', -}; - -// 自定义侧边栏按钮悬停样式 -const navItemHoverStyle = { - backgroundColor: 'var(--semi-color-primary-light-default)', - color: 'var(--semi-color-primary)', -}; - -// 自定义侧边栏按钮选中样式 -const navItemSelectedStyle = { - backgroundColor: 'var(--semi-color-primary-light-default)', - color: 'var(--semi-color-primary)', - fontWeight: '600', -}; - -// 自定义图标样式 -const iconStyle = (itemKey, selectedKeys) => { - return { - fontSize: '18px', - color: selectedKeys.includes(itemKey) - ? 'var(--semi-color-primary)' - : 'var(--semi-color-text-2)', - }; -}; - -// Define routerMap as a constant outside the component -const routerMap = { - home: '/', - channel: '/channel', - token: '/token', - redemption: '/redemption', - topup: '/topup', - user: '/user', - log: '/log', - midjourney: '/midjourney', - setting: '/setting', - about: '/about', - detail: '/detail', - pricing: '/pricing', - task: '/task', - playground: '/playground', - personal: '/personal', -}; - -const SiderBar = () => { - const { t } = useTranslation(); - const [styleState, styleDispatch] = useContext(StyleContext); - const [statusState, statusDispatch] = useContext(StatusContext); - const defaultIsCollapsed = - localStorage.getItem('default_collapse_sidebar') === 'true'; - - const [selectedKeys, setSelectedKeys] = useState(['home']); - const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed); - const [chatItems, setChatItems] = useState([]); - const [openedKeys, setOpenedKeys] = useState([]); - const theme = useTheme(); - const setTheme = useSetTheme(); - const location = useLocation(); - const [routerMapState, setRouterMapState] = useState(routerMap); - - // 预先计算所有可能的图标样式 - const allItemKeys = useMemo(() => { - const keys = [ - 'home', - 'channel', - 'token', - 'redemption', - 'topup', - 'user', - 'log', - 'midjourney', - 'setting', - 'about', - 'chat', - 'detail', - 'pricing', - 'task', - 'playground', - 'personal', - ]; - // 添加聊天项的keys - for (let i = 0; i < chatItems.length; i++) { - keys.push('chat' + i); - } - return keys; - }, [chatItems]); - - // 使用useMemo一次性计算所有图标样式 - const iconStyles = useMemo(() => { - const styles = {}; - allItemKeys.forEach((key) => { - styles[key] = iconStyle(key, selectedKeys); - }); - return styles; - }, [allItemKeys, selectedKeys]); - - const workspaceItems = useMemo( - () => [ - { - text: t('数据看板'), - itemKey: 'detail', - to: '/detail', - icon: , - className: - localStorage.getItem('enable_data_export') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('API令牌'), - itemKey: 'token', - to: '/token', - icon: , - }, - { - text: t('使用日志'), - itemKey: 'log', - to: '/log', - icon: , - }, - { - text: t('绘图日志'), - itemKey: 'midjourney', - to: '/midjourney', - icon: , - className: - localStorage.getItem('enable_drawing') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('任务日志'), - itemKey: 'task', - to: '/task', - icon: , - className: - localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', - }, - ], - [ - localStorage.getItem('enable_data_export'), - localStorage.getItem('enable_drawing'), - localStorage.getItem('enable_task'), - t, - ], - ); - - const financeItems = useMemo( - () => [ - { - text: t('钱包'), - itemKey: 'topup', - to: '/topup', - icon: , - }, - { - text: t('个人设置'), - itemKey: 'personal', - to: '/personal', - icon: , - }, - ], - [t], - ); - - const adminItems = useMemo( - () => [ - { - text: t('渠道'), - itemKey: 'channel', - to: '/channel', - icon: , - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('兑换码'), - itemKey: 'redemption', - to: '/redemption', - icon: , - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('用户管理'), - itemKey: 'user', - to: '/user', - icon: , - }, - { - text: t('系统设置'), - itemKey: 'setting', - to: '/setting', - icon: , - }, - ], - [isAdmin(), t], - ); - - const chatMenuItems = useMemo( - () => [ - { - text: 'Playground', - itemKey: 'playground', - to: '/playground', - icon: , - }, - { - text: t('聊天'), - itemKey: 'chat', - items: chatItems, - icon: , - }, - ], - [chatItems, t], - ); - - // Function to update router map with chat routes - const updateRouterMapWithChats = (chats) => { - const newRouterMap = { ...routerMap }; - - if (Array.isArray(chats) && chats.length > 0) { - for (let i = 0; i < chats.length; i++) { - newRouterMap['chat' + i] = '/chat/' + i; - } - } - - setRouterMapState(newRouterMap); - return newRouterMap; - }; - - // Update the useEffect for chat items - useEffect(() => { - let chats = localStorage.getItem('chats'); - if (chats) { - try { - chats = JSON.parse(chats); - if (Array.isArray(chats)) { - let chatItems = []; - for (let i = 0; i < chats.length; i++) { - let chat = {}; - for (let key in chats[i]) { - chat.text = key; - chat.itemKey = 'chat' + i; - chat.to = '/chat/' + i; - } - chatItems.push(chat); - } - setChatItems(chatItems); - - // Update router map with chat routes - updateRouterMapWithChats(chats); - } - } catch (e) { - console.error(e); - showError('聊天数据解析失败'); - } - } - }, []); - - // Update the useEffect for route selection - useEffect(() => { - const currentPath = location.pathname; - let matchingKey = Object.keys(routerMapState).find( - (key) => routerMapState[key] === currentPath, - ); - - // Handle chat routes - if (!matchingKey && currentPath.startsWith('/chat/')) { - const chatIndex = currentPath.split('/').pop(); - if (!isNaN(chatIndex)) { - matchingKey = 'chat' + chatIndex; - } else { - matchingKey = 'chat'; - } - } - - // If we found a matching key, update the selected keys - if (matchingKey) { - setSelectedKeys([matchingKey]); - } - }, [location.pathname, routerMapState]); - - useEffect(() => { - setIsCollapsed(styleState.siderCollapsed); - }, [styleState.siderCollapsed]); - - // Custom divider style - const dividerStyle = { - margin: '8px 0', - opacity: 0.6, - }; - - // Custom group label style - const groupLabelStyle = { - padding: '8px 16px', - color: 'var(--semi-color-text-2)', - fontSize: '12px', - fontWeight: 'bold', - textTransform: 'uppercase', - letterSpacing: '0.5px', - }; - - return ( - <> - - - ); -}; - -export default SiderBar; diff --git a/web/src/components/TaskLogsTable.js b/web/src/components/TaskLogsTable.js deleted file mode 100644 index 4d243133..00000000 --- a/web/src/components/TaskLogsTable.js +++ /dev/null @@ -1,512 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Label } from 'semantic-ui-react'; -import { - API, - copy, - isAdmin, - showError, - showSuccess, - timestamp2string, -} from '../helpers'; - -import { - Table, - Tag, - Form, - Button, - Layout, - Modal, - Typography, - Progress, - Card, -} from '@douyinfe/semi-ui'; -import { ITEMS_PER_PAGE } from '../constants'; - -const colors = [ - 'amber', - 'blue', - 'cyan', - 'green', - 'grey', - 'indigo', - 'light-blue', - 'lime', - 'orange', - 'pink', - 'purple', - 'red', - 'teal', - 'violet', - 'yellow', -]; - -const renderTimestamp = (timestampInSeconds) => { - const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒 - - const year = date.getFullYear(); // 获取年份 - const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数 - const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数 - const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数 - const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数 - const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数 - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出 -}; - -function renderDuration(submit_time, finishTime) { - // 确保startTime和finishTime都是有效的时间戳 - if (!submit_time || !finishTime) return 'N/A'; - - // 将时间戳转换为Date对象 - const start = new Date(submit_time); - const finish = new Date(finishTime); - - // 计算时间差(毫秒) - const durationMs = finish - start; - - // 将时间差转换为秒,并保留一位小数 - const durationSec = (durationMs / 1000).toFixed(1); - - // 设置颜色:大于60秒则为红色,小于等于60秒则为绿色 - const color = durationSec > 60 ? 'red' : 'green'; - - // 返回带有样式的颜色标签 - return ( - - {durationSec} 秒 - - ); -} - -const LogsTable = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalContent, setModalContent] = useState(''); - const isAdminUser = isAdmin(); - const columns = [ - { - title: '提交时间', - dataIndex: 'submit_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - title: '结束时间', - dataIndex: 'finish_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - title: '进度', - dataIndex: 'progress', - width: 50, - render: (text, record, index) => { - return ( -
- { - // 转换例如100%为数字100,如果text未定义,返回0 - isNaN(text.replace('%', '')) ? ( - text - ) : ( - - ) - } -
- ); - }, - }, - { - title: '花费时间', - dataIndex: 'finish_time', // 以finish_time作为dataIndex - key: 'finish_time', - render: (finish, record) => { - // 假设record.start_time是存在的,并且finish是完成时间的时间戳 - return <>{finish ? renderDuration(record.submit_time, finish) : '-'}; - }, - }, - { - title: '渠道', - dataIndex: 'channel_id', - className: isAdminUser ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return ( -
- { - copyText(text); // 假设copyText是用于文本复制的函数 - }} - > - {' '} - {text}{' '} - -
- ); - }, - }, - { - title: '平台', - dataIndex: 'platform', - render: (text, record, index) => { - return
{renderPlatform(text)}
; - }, - }, - { - title: '类型', - dataIndex: 'action', - render: (text, record, index) => { - return
{renderType(text)}
; - }, - }, - { - title: '任务ID(点击查看详情)', - dataIndex: 'task_id', - render: (text, record, index) => { - return ( - { - setModalContent(JSON.stringify(record, null, 2)); - setIsModalOpen(true); - }} - > -
{text}
-
- ); - }, - }, - { - title: '任务状态', - dataIndex: 'status', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - - { - title: '失败原因', - dataIndex: 'fail_reason', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return '无'; - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - ]; - - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); - const [logType] = useState(0); - - let now = new Date(); - // 初始化start_timestamp为前一天 - let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const [inputs, setInputs] = useState({ - channel_id: '', - task_id: '', - start_timestamp: timestamp2string(zeroNow.getTime() / 1000), - end_timestamp: '', - }); - const { channel_id, task_id, start_timestamp, end_timestamp } = inputs; - - const handleInputChange = (value, name) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const setLogsFormat = (logs) => { - for (let i = 0; i < logs.length; i++) { - logs[i].timestamp2string = timestamp2string(logs[i].created_at); - logs[i].key = '' + logs[i].id; - } - // data.key = '' + data.id - setLogs(logs); - setLogCount(logs.length + ITEMS_PER_PAGE); - // console.log(logCount); - }; - - const loadLogs = async (startIdx) => { - setLoading(true); - - let url = ''; - let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000); - let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000); - if (isAdminUser) { - url = `/api/task/?p=${startIdx}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } else { - url = `/api/task/self?p=${startIdx}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } - const res = await API.get(url); - let { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setLogsFormat(data); - } else { - let newLogs = [...logs]; - newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); - setLogsFormat(newLogs); - } - } else { - showError(message); - } - setLoading(false); - }; - - const pageData = logs.slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE, - ); - - const handlePageChange = (page) => { - setActivePage(page); - if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - loadLogs(page - 1).then((r) => {}); - } - }; - - const refresh = async () => { - // setLoading(true); - setActivePage(1); - await loadLogs(0); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess('已复制:' + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, [logType]); - - const renderType = (type) => { - switch (type) { - case 'MUSIC': - return ( - - ); - case 'LYRICS': - return ( - - ); - - default: - return ( - - ); - } - }; - - const renderPlatform = (type) => { - switch (type) { - case 'suno': - return ( - - ); - default: - return ( - - ); - } - }; - - const renderStatus = (type) => { - switch (type) { - case 'SUCCESS': - return ( - - ); - case 'NOT_START': - return ( - - ); - case 'SUBMITTED': - return ( - - ); - case 'IN_PROGRESS': - return ( - - ); - case 'FAILURE': - return ( - - ); - case 'QUEUED': - return ( - - ); - case 'UNKNOWN': - return ( - - ); - case '': - return ( - - ); - default: - return ( - - ); - } - }; - - return ( - <> - -
- <> - {isAdminUser && ( - handleInputChange(value, 'channel_id')} - /> - )} - handleInputChange(value, 'task_id')} - /> - - handleInputChange(value, 'start_timestamp')} - /> - handleInputChange(value, 'end_timestamp')} - /> - - - - - - - setIsModalOpen(false)} - onCancel={() => setIsModalOpen(false)} - closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式 - width={800} // 设置模态框宽度 - > -

{modalContent}

-
- - - ); -}; - -export default LogsTable; diff --git a/web/src/components/TokensTable.js b/web/src/components/TokensTable.js deleted file mode 100644 index 599b3459..00000000 --- a/web/src/components/TokensTable.js +++ /dev/null @@ -1,613 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - API, - copy, - showError, - showSuccess, - timestamp2string, -} from '../helpers'; - -import { ITEMS_PER_PAGE } from '../constants'; -import { renderGroup, renderQuota } from '../helpers/render'; -import { - Button, - Divider, - Dropdown, - Form, - Modal, - Popconfirm, - Popover, - Space, - SplitButtonGroup, - Table, - Tag, -} from '@douyinfe/semi-ui'; - -import { IconTreeTriangleDown } from '@douyinfe/semi-icons'; -import EditToken from '../pages/Token/EditToken'; -import { useTranslation } from 'react-i18next'; - -function renderTimestamp(timestamp) { - return <>{timestamp2string(timestamp)}; -} - -const TokensTable = () => { - const { t } = useTranslation(); - - const renderStatus = (status, model_limits_enabled = false) => { - switch (status) { - case 1: - if (model_limits_enabled) { - return ( - - {t('已启用:限制模型')} - - ); - } else { - return ( - - {t('已启用')} - - ); - } - case 2: - return ( - - {t('已禁用')} - - ); - case 3: - return ( - - {t('已过期')} - - ); - case 4: - return ( - - {t('已耗尽')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const columns = [ - { - title: t('名称'), - dataIndex: 'name', - }, - { - title: t('状态'), - dataIndex: 'status', - key: 'status', - render: (text, record, index) => { - return ( -
- - {renderStatus(text, record.model_limits_enabled)} - {renderGroup(record.group)} - -
- ); - }, - }, - { - title: t('已用额度'), - dataIndex: 'used_quota', - render: (text, record, index) => { - return
{renderQuota(parseInt(text))}
; - }, - }, - { - title: t('剩余额度'), - dataIndex: 'remain_quota', - render: (text, record, index) => { - return ( -
- {record.unlimited_quota ? ( - - {t('无限制')} - - ) : ( - - {renderQuota(parseInt(text))} - - )} -
- ); - }, - }, - { - title: t('创建时间'), - dataIndex: 'created_time', - render: (text, record, index) => { - return
{renderTimestamp(text)}
; - }, - }, - { - title: t('过期时间'), - dataIndex: 'expired_time', - render: (text, record, index) => { - return ( -
- {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)} -
- ); - }, - }, - { - title: '', - dataIndex: 'operate', - render: (text, record, index) => { - let chats = localStorage.getItem('chats'); - let chatsArray = []; - let shouldUseCustom = true; - - if (shouldUseCustom) { - try { - // console.log(chats); - chats = JSON.parse(chats); - // check chats is array - if (Array.isArray(chats)) { - for (let i = 0; i < chats.length; i++) { - let chat = {}; - chat.node = 'item'; - // c is a map - // chat.key = chats[i].name; - // console.log(chats[i]) - for (let key in chats[i]) { - if (chats[i].hasOwnProperty(key)) { - chat.key = i; - chat.name = key; - chat.onClick = () => { - onOpenLink(key, chats[i][key], record); - }; - } - } - chatsArray.push(chat); - } - } - } catch (e) { - console.log(e); - showError(t('聊天链接配置错误,请联系管理员')); - } - } - return ( -
- - - - - - - - - - - { - manageToken(record.id, 'delete', record).then(() => { - removeRecord(record.key); - }); - }} - > - - - {record.status === 1 ? ( - - ) : ( - - )} - -
- ); - }, - }, - ]; - - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [showEdit, setShowEdit] = useState(false); - const [tokens, setTokens] = useState([]); - const [selectedKeys, setSelectedKeys] = useState([]); - const [tokenCount, setTokenCount] = useState(pageSize); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searchToken, setSearchToken] = useState(''); - const [searching, setSearching] = useState(false); - const [chats, setChats] = useState([]); - const [editingToken, setEditingToken] = useState({ - id: undefined, - }); - - const closeEdit = () => { - setShowEdit(false); - setTimeout(() => { - setEditingToken({ - id: undefined, - }); - }, 500); - }; - - const setTokensFormat = (tokens) => { - setTokens(tokens); - if (tokens.length >= pageSize) { - setTokenCount(tokens.length + pageSize); - } else { - setTokenCount(tokens.length); - } - }; - - let pageData = tokens.slice( - (activePage - 1) * pageSize, - activePage * pageSize, - ); - const loadTokens = async (startIdx) => { - setLoading(true); - const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setTokensFormat(data); - } else { - let newTokens = [...tokens]; - newTokens.splice(startIdx * pageSize, data.length, ...data); - setTokensFormat(newTokens); - } - } else { - showError(message); - } - setLoading(false); - }; - - const refresh = async () => { - await loadTokens(activePage - 1); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制到剪贴板!')); - } else { - Modal.error({ - title: t('无法复制到剪贴板,请手动复制'), - content: text, - size: 'large', - }); - } - }; - - const onOpenLink = async (type, url, record) => { - // console.log(type, url, key); - let status = localStorage.getItem('status'); - let serverAddress = ''; - if (status) { - status = JSON.parse(status); - serverAddress = status.server_address; - } - if (serverAddress === '') { - serverAddress = window.location.origin; - } - let encodedServerAddress = encodeURIComponent(serverAddress); - url = url.replaceAll('{address}', encodedServerAddress); - url = url.replaceAll('{key}', 'sk-' + record.key); - - window.open(url, '_blank'); - }; - - useEffect(() => { - loadTokens(0) - .then() - .catch((reason) => { - showError(reason); - }); - }, [pageSize]); - - const removeRecord = (key) => { - let newDataSource = [...tokens]; - if (key != null) { - let idx = newDataSource.findIndex((data) => data.key === key); - - if (idx > -1) { - newDataSource.splice(idx, 1); - setTokensFormat(newDataSource); - } - } - }; - - const manageToken = async (id, action, record) => { - setLoading(true); - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/token/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/token/?status_only=true', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/token/?status_only=true', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let token = res.data.data; - let newTokens = [...tokens]; - // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - if (action === 'delete') { - } else { - record.status = token.status; - // newTokens[realIdx].status = token.status; - } - setTokensFormat(newTokens); - } else { - showError(message); - } - setLoading(false); - }; - - const searchTokens = async () => { - if (searchKeyword === '' && searchToken === '') { - // if keyword is blank, load files instead. - await loadTokens(0); - setActivePage(1); - return; - } - setSearching(true); - const res = await API.get( - `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`, - ); - const { success, message, data } = res.data; - if (success) { - setTokensFormat(data); - setActivePage(1); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - - const handleSearchTokenChange = async (value) => { - setSearchToken(value.trim()); - }; - - const sortToken = (key) => { - if (tokens.length === 0) return; - setLoading(true); - let sortedTokens = [...tokens]; - sortedTokens.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); - }); - if (sortedTokens[0].id === tokens[0].id) { - sortedTokens.reverse(); - } - setTokens(sortedTokens); - setLoading(false); - }; - - const handlePageChange = (page) => { - setActivePage(page); - if (page === Math.ceil(tokens.length / pageSize) + 1) { - // In this case we have to load more data and then append them. - loadTokens(page - 1).then((r) => {}); - } - }; - - const rowSelection = { - onSelect: (record, selected) => {}, - onSelectAll: (selected, selectedRows) => {}, - onChange: (selectedRowKeys, selectedRows) => { - setSelectedKeys(selectedRows); - }, - }; - - const handleRow = (record, index) => { - if (record.status !== 1) { - return { - style: { - background: 'var(--semi-color-disabled-border)', - }, - }; - } else { - return {}; - } - }; - - return ( - <> - - - - - - - -
- - -
- -
- t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: tokens.length, - }), - onPageSizeChange: (size) => { - setPageSize(size); - setActivePage(1); - }, - onPageChange: handlePageChange, - }} - loading={loading} - rowSelection={rowSelection} - onRow={handleRow} - >
- - ); -}; - -export default TokensTable; diff --git a/web/src/components/UsersTable.js b/web/src/components/UsersTable.js deleted file mode 100644 index b77f7396..00000000 --- a/web/src/components/UsersTable.js +++ /dev/null @@ -1,515 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { API, showError, showSuccess } from '../helpers'; -import { - Button, - Form, - Popconfirm, - Space, - Table, - Tag, - Tooltip, -} from '@douyinfe/semi-ui'; -import { ITEMS_PER_PAGE } from '../constants'; -import { renderGroup, renderNumber, renderQuota } from '../helpers/render'; -import AddUser from '../pages/User/AddUser'; -import EditUser from '../pages/User/EditUser'; -import { useTranslation } from 'react-i18next'; - -const UsersTable = () => { - const { t } = useTranslation(); - - function renderRole(role) { - switch (role) { - case 1: - return {t('普通用户')}; - case 10: - return ( - - {t('管理员')} - - ); - case 100: - return ( - - {t('超级管理员')} - - ); - default: - return ( - - {t('未知身份')} - - ); - } - } - const columns = [ - { - title: 'ID', - dataIndex: 'id', - }, - { - title: t('用户名'), - dataIndex: 'username', - }, - { - title: t('分组'), - dataIndex: 'group', - render: (text, record, index) => { - return
{renderGroup(text)}
; - }, - }, - { - title: t('统计信息'), - dataIndex: 'info', - render: (text, record, index) => { - return ( -
- - - - {renderQuota(record.quota)} - - - - - {renderQuota(record.used_quota)} - - - - - {renderNumber(record.request_count)} - - - -
- ); - }, - }, - { - title: t('邀请信息'), - dataIndex: 'invite', - render: (text, record, index) => { - return ( -
- - - - {renderNumber(record.aff_count)} - - - - - {renderQuota(record.aff_history_quota)} - - - - {record.inviter_id === 0 ? ( - - {t('无')} - - ) : ( - - {record.inviter_id} - - )} - - -
- ); - }, - }, - { - title: t('角色'), - dataIndex: 'role', - render: (text, record, index) => { - return
{renderRole(text)}
; - }, - }, - { - title: t('状态'), - dataIndex: 'status', - render: (text, record, index) => { - return ( -
- {record.DeletedAt !== null ? ( - {t('已注销')} - ) : ( - renderStatus(text) - )} -
- ); - }, - }, - { - title: '', - dataIndex: 'operate', - render: (text, record, index) => ( -
- {record.DeletedAt !== null ? ( - <> - ) : ( - <> - { - manageUser(record.id, 'promote', record); - }} - > - - - { - manageUser(record.id, 'demote', record); - }} - > - - - {record.status === 1 ? ( - - ) : ( - - )} - - { - manageUser(record.id, 'delete', record).then(() => { - removeRecord(record.id); - }); - }} - > - - - - )} -
- ), - }, - ]; - - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - const [searchGroup, setSearchGroup] = useState(''); - const [groupOptions, setGroupOptions] = useState([]); - const [userCount, setUserCount] = useState(ITEMS_PER_PAGE); - const [showAddUser, setShowAddUser] = useState(false); - const [showEditUser, setShowEditUser] = useState(false); - const [editingUser, setEditingUser] = useState({ - id: undefined, - }); - - const removeRecord = (key) => { - let newDataSource = [...users]; - if (key != null) { - let idx = newDataSource.findIndex((data) => data.id === key); - - if (idx > -1) { - // update deletedAt - newDataSource[idx].DeletedAt = new Date(); - setUsers(newDataSource); - } - } - }; - - const setUserFormat = (users) => { - for (let i = 0; i < users.length; i++) { - users[i].key = users[i].id; - } - setUsers(users); - }; - - const loadUsers = async (startIdx, pageSize) => { - const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setUserCount(data.total); - setUserFormat(newPageData); - } else { - showError(message); - } - setLoading(false); - }; - - useEffect(() => { - loadUsers(0, pageSize) - .then() - .catch((reason) => { - showError(reason); - }); - fetchGroups().then(); - }, []); - - const manageUser = async (userId, action, record) => { - const res = await API.post('/api/user/manage', { - id: userId, - action, - }); - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let user = res.data.data; - let newUsers = [...users]; - if (action === 'delete') { - } else { - record.status = user.status; - record.role = user.role; - } - setUsers(newUsers); - } else { - showError(message); - } - }; - - const renderStatus = (status) => { - switch (status) { - case 1: - return {t('已激活')}; - case 2: - return ( - - {t('已封禁')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const searchUsers = async ( - startIdx, - pageSize, - searchKeyword, - searchGroup, - ) => { - if (searchKeyword === '' && searchGroup === '') { - // if keyword is blank, load files instead. - await loadUsers(startIdx, pageSize); - return; - } - setSearching(true); - const res = await API.get( - `/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`, - ); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setUserCount(data.total); - setUserFormat(newPageData); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - - const handlePageChange = (page) => { - setActivePage(page); - if (searchKeyword === '' && searchGroup === '') { - loadUsers(page, pageSize).then(); - } else { - searchUsers(page, pageSize, searchKeyword, searchGroup).then(); - } - }; - - const closeAddUser = () => { - setShowAddUser(false); - }; - - const closeEditUser = () => { - setShowEditUser(false); - setEditingUser({ - id: undefined, - }); - }; - - const refresh = async () => { - setActivePage(1); - if (searchKeyword === '') { - await loadUsers(activePage, pageSize); - } else { - await searchUsers(activePage, pageSize, searchKeyword, searchGroup); - } - }; - - const fetchGroups = async () => { - try { - let res = await API.get(`/api/group/`); - // add 'all' option - // res.data.data.unshift('all'); - if (res === undefined) { - return; - } - setGroupOptions( - res.data.data.map((group) => ({ - label: group, - value: group, - })), - ); - } catch (error) { - showError(error.message); - } - }; - - const handlePageSizeChange = async (size) => { - localStorage.setItem('page-size', size + ''); - setPageSize(size); - setActivePage(1); - loadUsers(activePage, size) - .then() - .catch((reason) => { - showError(reason); - }); - }; - - return ( - <> - - -
{ - searchUsers(activePage, pageSize, searchKeyword, searchGroup); - }} - labelPosition='left' - > -
- - - handleKeywordChange(value)} - /> - - - { - setSearchGroup(value); - searchUsers(activePage, pageSize, searchKeyword, value); - }} - /> - - - -
-
- - - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: users.length, - }), - currentPage: activePage, - pageSize: pageSize, - total: userCount, - pageSizeOpts: [10, 20, 50, 100], - showSizeChanger: true, - onPageSizeChange: (size) => { - handlePageSizeChange(size); - }, - onPageChange: handlePageChange, - }} - loading={loading} - /> - - ); -}; - -export default UsersTable; diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js new file mode 100644 index 00000000..c8847a33 --- /dev/null +++ b/web/src/components/auth/LoginForm.js @@ -0,0 +1,524 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { UserContext } from '../../context/User/index.js'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData, + onGitHubOAuthClicked, + onOIDCClicked, + onLinuxDOOAuthClicked +} from '../../helpers/index.js'; +import Turnstile from 'react-turnstile'; +import { + Button, + Card, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import TelegramLoginButton from 'react-telegram-login'; + +import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons'; +import OIDCIcon from '../common/logo/OIDCIcon.js'; +import WeChatIcon from '../common/logo/WeChatIcon.js'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import { useTranslation } from 'react-i18next'; + +const LoginForm = () => { + const [inputs, setInputs] = useState({ + username: '', + password: '', + wechat_verification_code: '', + }); + const [searchParams, setSearchParams] = useSearchParams(); + const [submitted, setSubmitted] = useState(false); + const { username, password } = inputs; + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + let navigate = useNavigate(); + const [status, setStatus] = useState({}); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailLogin, setShowEmailLogin] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailLoginLoading, setEmailLoginLoading] = useState(false); + const [loginLoading, setLoginLoading] = useState(false); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + const { t } = useTranslation(); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + useEffect(() => { + if (searchParams.get('expired')) { + showError(t('未登录或登录已过期,请重新登录')); + } + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + setStatus(status); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + const onWeChatLoginClicked = () => { + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setSubmitted(true); + setLoginLoading(true); + try { + if (username && password) { + const res = await API.post( + `/api/user/login?turnstile=${turnstileToken}`, + { + username, + password, + }, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + showSuccess('登录成功!'); + if (username === 'root' && password === '123456') { + Modal.error({ + title: '您正在使用默认密码!', + content: '请立刻修改默认密码!', + centered: true, + }); + } + navigate('/console'); + } else { + showError(message); + } + } else { + showError('请输入用户名和密码!'); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setLoginLoading(false); + } + } + + // 添加Telegram登录处理函数 + const onTelegramLoginClicked = async (response) => { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + // 包装的GitHub登录点击处理 + const handleGitHubClick = () => { + setGithubLoading(true); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + // 包装的OIDC登录点击处理 + const handleOIDCClick = () => { + setOidcLoading(true); + try { + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id + ); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + // 包装的LinuxDO登录点击处理 + const handleLinuxDOClick = () => { + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + // 包装的邮箱登录选项点击处理 + const handleEmailLoginClick = () => { + setEmailLoginLoading(true); + setShowEmailLogin(true); + setEmailLoginLoading(false); + }; + + // 包装的重置密码点击处理 + const handleResetPasswordClick = () => { + setResetPasswordLoading(true); + navigate('/reset'); + setResetPasswordLoading(false); + }; + + // 包装的其他登录选项点击处理 + const handleOtherLoginOptionsClick = () => { + setOtherLoginOptionsLoading(true); + setShowEmailLogin(false); + setOtherLoginOptionsLoading(false); + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('登 录')} +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + + {t('或')} + + + +
+ +
+ {t('没有账户?')} {t('注册')} +
+
+
+
+
+ ); + }; + + const renderEmailLoginForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('登 录')} +
+
+
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + +
+ + + +
+ + + {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + +
+ {t('没有账户?')} {t('注册')} +
+
+
+
+
+ ); + }; + + // 微信登录模态框 + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + size="small" + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}

+
+ +
+ handleChange('wechat_verification_code', value)} + /> + +
+ ); + }; + + return ( +
+
+ {showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) + ? renderEmailLoginForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default LoginForm; diff --git a/web/src/components/OAuth2Callback.js b/web/src/components/auth/OAuth2Callback.js similarity index 58% rename from web/src/components/OAuth2Callback.js rename to web/src/components/auth/OAuth2Callback.js index 616ec313..6d0bbe70 100644 --- a/web/src/components/OAuth2Callback.js +++ b/web/src/components/auth/OAuth2Callback.js @@ -1,16 +1,16 @@ import React, { useContext, useEffect, useState } from 'react'; -import { Dimmer, Loader, Segment } from 'semantic-ui-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { API, showError, showSuccess, updateAPI } from '../helpers'; -import { UserContext } from '../context/User'; -import { setUserData } from '../helpers/data.js'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers'; +import { UserContext } from '../../context/User'; +import Loading from '../common/Loading'; const OAuth2Callback = (props) => { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const [userState, userDispatch] = useContext(UserContext); - const [prompt, setPrompt] = useState('处理中...'); - const [processing, setProcessing] = useState(true); + const [prompt, setPrompt] = useState(t('处理中...')); let navigate = useNavigate(); @@ -21,25 +21,25 @@ const OAuth2Callback = (props) => { const { success, message, data } = res.data; if (success) { if (message === 'bind') { - showSuccess('绑定成功!'); - navigate('/setting'); + showSuccess(t('绑定成功!')); + navigate('/console/setting'); } else { userDispatch({ type: 'login', payload: data }); localStorage.setItem('user', JSON.stringify(data)); setUserData(data); updateAPI(); - showSuccess('登录成功!'); - navigate('/token'); + showSuccess(t('登录成功!')); + navigate('/console/token'); } } else { showError(message); if (count === 0) { - setPrompt(`操作失败,重定向至登录界面中...`); - navigate('/setting'); // in case this is failed to bind GitHub + setPrompt(t('操作失败,重定向至登录界面中...')); + navigate('/console/setting'); // in case this is failed to bind GitHub return; } count++; - setPrompt(`出现错误,第 ${count} 次重试中...`); + setPrompt(t('出现错误,第 ${count} 次重试中...', { count })); await new Promise((resolve) => setTimeout(resolve, count * 2000)); await sendCode(code, state, count); } @@ -51,13 +51,7 @@ const OAuth2Callback = (props) => { sendCode(code, state, 0).then(); }, []); - return ( - - - {prompt} - - - ); + return ; }; export default OAuth2Callback; diff --git a/web/src/components/auth/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js new file mode 100644 index 00000000..e2d9a9ad --- /dev/null +++ b/web/src/components/auth/PasswordResetConfirm.js @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react'; +import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers'; +import { useSearchParams, Link } from 'react-router-dom'; +import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; +import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetConfirm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + token: '', + }); + const { email, token } = inputs; + const isValidResetLink = email && token; + + const [loading, setLoading] = useState(false); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + const [newPassword, setNewPassword] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + const [formApi, setFormApi] = useState(null); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let token = searchParams.get('token'); + let email = searchParams.get('email'); + setInputs({ + token: token || '', + email: email || '', + }); + if (formApi) { + formApi.setValues({ + email: email || '', + newPassword: newPassword || '' + }); + } + }, [searchParams, newPassword, formApi]); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + async function handleSubmit(e) { + if (!email || !token) { + showError(t('无效的重置链接,请重新发起密码重置请求')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.post(`/api/user/reset`, { + email, + token, + }); + const { success, message } = res.data; + if (success) { + let password = res.data.data; + setNewPassword(password); + await copy(password); + showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+
+
+
+
+ Logo + {systemName} +
+ + +
+ {t('密码重置确认')} +
+
+ {!isValidResetLink && ( + + )} +
setFormApi(api)} + initValues={{ email: email || '', newPassword: newPassword || '' }} + className="space-y-4" + > + } + placeholder={email ? '' : t('等待获取邮箱信息...')} + /> + + {newPassword && ( + } + suffix={ + + } + /> + )} + +
+ +
+ + +
+ {t('返回登录')} +
+
+
+
+
+
+
+ ); +}; + +export default PasswordResetConfirm; diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js new file mode 100644 index 00000000..29c3d477 --- /dev/null +++ b/web/src/components/auth/PasswordResetForm.js @@ -0,0 +1,147 @@ +import React, { useEffect, useState } from 'react'; +import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers'; +import Turnstile from 'react-turnstile'; +import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; +import { IconMail } from '@douyinfe/semi-icons'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetForm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + }); + const { email } = inputs; + + const [loading, setLoading] = useState(false); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + function handleChange(value) { + setInputs((inputs) => ({ ...inputs, email: value })); + } + + async function handleSubmit(e) { + if (!email) { + showError(t('请输入邮箱地址')); + return; + } + if (turnstileEnabled && turnstileToken === '') { + showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.get( + `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess(t('重置邮件发送成功,请检查邮箱!')); + setInputs({ ...inputs, email: '' }); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+
+
+
+
+ Logo + {systemName} +
+ + +
+ {t('密码重置')} +
+
+
+ } + /> + +
+ +
+ + +
+ {t('想起来了?')} {t('登录')} +
+
+
+ + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+
+
+ ); +}; + +export default PasswordResetForm; diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js new file mode 100644 index 00000000..0d9c8982 --- /dev/null +++ b/web/src/components/auth/RegisterForm.js @@ -0,0 +1,566 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData +} from '../../helpers/index.js'; +import Turnstile from 'react-turnstile'; +import { + Button, + Card, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons'; +import { + onGitHubOAuthClicked, + onLinuxDOOAuthClicked, + onOIDCClicked, +} from '../../helpers/index.js'; +import OIDCIcon from '../common/logo/OIDCIcon.js'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import WeChatIcon from '../common/logo/WeChatIcon.js'; +import TelegramLoginButton from 'react-telegram-login/src'; +import { UserContext } from '../../context/User/index.js'; +import { useTranslation } from 'react-i18next'; + +const RegisterForm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + username: '', + password: '', + password2: '', + email: '', + verification_code: '', + wechat_verification_code: '', + }); + const { username, password, password2 } = inputs; + const [showEmailVerification, setShowEmailVerification] = useState(false); + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [loading, setLoading] = useState(false); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailRegister, setShowEmailRegister] = useState(false); + const [status, setStatus] = useState({}); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); + const [registerLoading, setRegisterLoading] = useState(false); + const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); + const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + let navigate = useNavigate(); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + useEffect(() => { + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + setStatus(status); + setShowEmailVerification(status.email_verification); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + const onWeChatLoginClicked = () => { + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if (password.length < 8) { + showInfo('密码长度不得小于 8 位!'); + return; + } + if (password !== password2) { + showInfo('两次输入的密码不一致'); + return; + } + if (username && password) { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setRegisterLoading(true); + try { + if (!affCode) { + affCode = localStorage.getItem('aff'); + } + inputs.aff_code = affCode; + const res = await API.post( + `/api/user/register?turnstile=${turnstileToken}`, + inputs, + ); + const { success, message } = res.data; + if (success) { + navigate('/login'); + showSuccess('注册成功!'); + } else { + showError(message); + } + } catch (error) { + showError('注册失败,请重试'); + } finally { + setRegisterLoading(false); + } + } + } + + const sendVerificationCode = async () => { + if (inputs.email === '') return; + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setVerificationCodeLoading(true); + try { + const res = await API.get( + `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess('验证码发送成功,请检查你的邮箱!'); + } else { + showError(message); + } + } catch (error) { + showError('发送验证码失败,请重试'); + } finally { + setVerificationCodeLoading(false); + } + }; + + const handleGitHubClick = () => { + setGithubLoading(true); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + const handleOIDCClick = () => { + setOidcLoading(true); + try { + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id + ); + } finally { + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + const handleLinuxDOClick = () => { + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + const handleEmailRegisterClick = () => { + setEmailRegisterLoading(true); + setShowEmailRegister(true); + setEmailRegisterLoading(false); + }; + + const handleOtherRegisterOptionsClick = () => { + setOtherRegisterOptionsLoading(true); + setShowEmailRegister(false); + setOtherRegisterOptionsLoading(false); + }; + + const onTelegramLoginClicked = async (response) => { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('注 册')} +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + + {t('或')} + + + +
+ +
+ {t('已有账户?')} {t('登录')} +
+
+
+
+
+ ); + }; + + const renderEmailRegisterForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('注 册')} +
+
+
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + + handleChange('password2', value)} + prefix={} + /> + + {showEmailVerification && ( + <> + handleChange('email', value)} + prefix={} + suffix={ + + } + /> + handleChange('verification_code', value)} + prefix={} + /> + + )} + +
+ +
+ + + {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + +
+ {t('已有账户?')} {t('登录')} +
+
+
+
+
+ ); + }; + + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + size="small" + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}

+
+ +
+ handleChange('wechat_verification_code', value)} + /> + +
+ ); + }; + + return ( +
+
+ {showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) + ? renderEmailRegisterForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default RegisterForm; diff --git a/web/src/components/common/Loading.js b/web/src/components/common/Loading.js new file mode 100644 index 00000000..a12be053 --- /dev/null +++ b/web/src/components/common/Loading.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { Spin } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; + +const Loading = ({ prompt: name = '', size = 'large' }) => { + const { t } = useTranslation(); + + return ( +
+
+ + + {name ? t('{{name}}', { name }) : t('加载中...')} + +
+
+ ); +}; + +export default Loading; diff --git a/web/src/components/LinuxDoIcon.js b/web/src/components/common/logo/LinuxDoIcon.js similarity index 100% rename from web/src/components/LinuxDoIcon.js rename to web/src/components/common/logo/LinuxDoIcon.js diff --git a/web/src/components/OIDCIcon.js b/web/src/components/common/logo/OIDCIcon.js similarity index 97% rename from web/src/components/OIDCIcon.js rename to web/src/components/common/logo/OIDCIcon.js index eec3e655..bd98c8fb 100644 --- a/web/src/components/OIDCIcon.js +++ b/web/src/components/common/logo/OIDCIcon.js @@ -11,8 +11,8 @@ const OIDCIcon = (props) => { version='1.1' xmlns='http://www.w3.org/2000/svg' p-id='10969' - width='1em' - height='1em' + width='20' + height='20' > { version='1.1' xmlns='http://www.w3.org/2000/svg' p-id='5091' - width='16' - height='16' + width='20' + height='20' > { + if (props.code && ref.current) { + mermaid + .run({ + nodes: [ref.current], + suppressErrors: true, + }) + .catch((e) => { + setHasError(true); + console.error('[Mermaid] ', e.message); + }); + } + }, [props.code]); + + function viewSvgInNewWindow() { + const svg = ref.current?.querySelector('svg'); + if (!svg) return; + const text = new XMLSerializer().serializeToString(svg); + const blob = new Blob([text], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + } + + if (hasError) { + return null; + } + + return ( +
viewSvgInNewWindow()} + > + {props.code} +
+ ); +} + +export function PreCode(props) { + const ref = useRef(null); + const [mermaidCode, setMermaidCode] = useState(''); + const [htmlCode, setHtmlCode] = useState(''); + const { t } = useTranslation(); + + const renderArtifacts = useDebouncedCallback(() => { + if (!ref.current) return; + const mermaidDom = ref.current.querySelector('code.language-mermaid'); + if (mermaidDom) { + setMermaidCode(mermaidDom.innerText); + } + const htmlDom = ref.current.querySelector('code.language-html'); + const refText = ref.current.querySelector('code')?.innerText; + if (htmlDom) { + setHtmlCode(htmlDom.innerText); + } else if ( + refText?.startsWith(' { + if (ref.current) { + const codeElements = ref.current.querySelectorAll('code'); + const wrapLanguages = [ + '', + 'md', + 'markdown', + 'text', + 'txt', + 'plaintext', + 'tex', + 'latex', + ]; + codeElements.forEach((codeElement) => { + let languageClass = codeElement.className.match(/language-(\w+)/); + let name = languageClass ? languageClass[1] : ''; + if (wrapLanguages.includes(name)) { + codeElement.style.whiteSpace = 'pre-wrap'; + } + }); + setTimeout(renderArtifacts, 1); + } + }, []); + + return ( + <> +
+        
+ +
+ {props.children} +
+ {mermaidCode.length > 0 && ( + + )} + {htmlCode.length > 0 && ( +
+
+ HTML预览: +
+
+
+ )} + + ); +} + +function CustomCode(props) { + const ref = useRef(null); + const [collapsed, setCollapsed] = useState(true); + const [showToggle, setShowToggle] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + if (ref.current) { + const codeHeight = ref.current.scrollHeight; + setShowToggle(codeHeight > 400); + ref.current.scrollTop = ref.current.scrollHeight; + } + }, [props.children]); + + const toggleCollapsed = () => { + setCollapsed((collapsed) => !collapsed); + }; + + const renderShowMoreButton = () => { + if (showToggle && collapsed) { + return ( +
+ +
+ ); + } + return null; + }; + + return ( +
+ + {props.children} + + {renderShowMoreButton()} +
+ ); +} + +function escapeBrackets(text) { + const pattern = + /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g; + return text.replace( + pattern, + (match, codeBlock, squareBracket, roundBracket) => { + if (codeBlock) { + return codeBlock; + } else if (squareBracket) { + return `$$${squareBracket}$$`; + } else if (roundBracket) { + return `$${roundBracket}$`; + } + return match; + }, + ); +} + +function tryWrapHtmlCode(text) { + // 尝试包装HTML代码 + if (text.includes('```')) { + return text; + } + return text + .replace( + /([`]*?)(\w*?)([\n\r]*?)()/g, + (match, quoteStart, lang, newLine, doctype) => { + return !quoteStart ? '\n```html\n' + doctype : match; + }, + ) + .replace( + /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g, + (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => { + return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match; + }, + ); +} + +function _MarkdownContent(props) { + const { + content, + className, + animated = false, + previousContentLength = 0, + } = props; + + const escapedContent = useMemo(() => { + return tryWrapHtmlCode(escapeBrackets(content)); + }, [content]); + + // 判断是否为用户消息 + const isUserMessage = className && className.includes('user-message'); + + const rehypePluginsBase = useMemo(() => { + const base = [ + RehypeKatex, + [ + RehypeHighlight, + { + detect: false, + ignoreMissing: true, + }, + ], + ]; + if (animated) { + base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]); + } + return base; + }, [animated, previousContentLength]); + + return ( +

, + a: (aProps) => { + const href = aProps.href || ''; + if (/\.(aac|mp3|opus|wav)$/.test(href)) { + return ( +

+ +
+ ); + } + if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) { + return ( + + ); + } + const isInternal = /^\/#/i.test(href); + const target = isInternal ? '_self' : aProps.target ?? '_blank'; + return ( + { + e.target.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.target.style.textDecoration = 'none'; + }} + /> + ); + }, + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + h4: (props) =>

, + h5: (props) =>

, + h6: (props) =>
, + blockquote: (props) => ( +
+ ), + ul: (props) =>
    , + ol: (props) =>
      , + li: (props) =>
    1. , + table: (props) => ( +
      +
+ + ), + th: (props) => ( +
+ ), + td: (props) => ( + + ), + }} + > + {escapedContent} + + ); +} + +export const MarkdownContent = React.memo(_MarkdownContent); + +export function MarkdownRenderer(props) { + const { + content, + loading, + fontSize = 14, + fontFamily = 'inherit', + className, + style, + animated = false, + previousContentLength = 0, + ...otherProps + } = props; + + return ( +
+ {loading ? ( +
+
+ 正在渲染... +
+ ) : ( + + )} +
+ ); +} + +export default MarkdownRenderer; \ No newline at end of file diff --git a/web/src/components/common/markdown/markdown.css b/web/src/components/common/markdown/markdown.css new file mode 100644 index 00000000..3b5c1067 --- /dev/null +++ b/web/src/components/common/markdown/markdown.css @@ -0,0 +1,444 @@ +/* 基础markdown样式 */ +.markdown-body { + font-family: inherit; + line-height: 1.6; + color: var(--semi-color-text-0); + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + +/* 用户消息样式 - 白色字体适配蓝色背景 */ +.user-message { + color: white !important; +} + +.user-message .markdown-body { + color: white !important; +} + +.user-message h1, +.user-message h2, +.user-message h3, +.user-message h4, +.user-message h5, +.user-message h6 { + color: white !important; +} + +.user-message p { + color: white !important; +} + +.user-message span { + color: white !important; +} + +.user-message div { + color: white !important; +} + +.user-message li { + color: white !important; +} + +.user-message td, +.user-message th { + color: white !important; +} + +.user-message blockquote { + color: white !important; + border-left-color: rgba(255, 255, 255, 0.5) !important; + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.user-message code:not(pre code) { + color: #000 !important; + background-color: rgba(255, 255, 255, 0.9) !important; +} + +.user-message a { + color: #87CEEB !important; + /* 浅蓝色链接 */ +} + +.user-message a:hover { + color: #B0E0E6 !important; + /* hover时更浅的蓝色 */ +} + +/* 表格在用户消息中的样式 */ +.user-message table { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message th { + background-color: rgba(255, 255, 255, 0.2) !important; + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message td { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +/* 加载动画 */ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* 代码高亮主题 - 适配Semi Design */ +.hljs { + display: block; + overflow-x: auto; + padding: 0; + background: transparent; + color: var(--semi-color-text-0); +} + +.hljs-comment, +.hljs-quote { + color: var(--semi-color-text-2); + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: var(--semi-color-warning); +} + +.hljs-string, +.hljs-doctag { + color: var(--semi-color-success); +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: var(--semi-color-info); + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: var(--semi-color-primary); + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: var(--semi-color-tertiary); +} + +.hljs-symbol, +.hljs-bullet { + color: var(--semi-color-warning); +} + +.hljs-built_in, +.hljs-builtin-name { + color: var(--semi-color-info); +} + +.hljs-meta { + color: var(--semi-color-text-2); +} + +.hljs-deletion { + background: var(--semi-color-danger-light-default); +} + +.hljs-addition { + background: var(--semi-color-success-light-default); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +/* Mermaid容器样式 */ +.mermaid-container { + transition: all 0.2s ease; +} + +.mermaid-container:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +/* 代码块样式增强 */ +pre { + position: relative; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + transition: all 0.2s ease; +} + +pre:hover { + border-color: var(--semi-color-primary) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +pre:hover .copy-code-button { + opacity: 1 !important; +} + +.copy-code-button { + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; + pointer-events: auto; +} + +.copy-code-button:hover { + opacity: 1 !important; +} + +.copy-code-button button { + pointer-events: auto !important; + cursor: pointer !important; +} + +/* 确保按钮可点击 */ +.copy-code-button .semi-button { + pointer-events: auto !important; + cursor: pointer !important; + transition: all 0.2s ease; +} + +.copy-code-button .semi-button:hover { + background-color: var(--semi-color-fill-1) !important; + border-color: var(--semi-color-primary) !important; + transform: scale(1.05); +} + +/* 表格响应式 */ +@media (max-width: 768px) { + .markdown-body table { + font-size: 12px; + } + + .markdown-body th, + .markdown-body td { + padding: 6px 8px; + } +} + +/* 数学公式样式 */ +.katex { + font-size: 1em; +} + +.katex-display { + margin: 1em 0; + text-align: center; +} + +/* 链接hover效果 */ +.markdown-body a { + transition: all 0.2s ease; +} + +/* 引用块样式增强 */ +.markdown-body blockquote { + position: relative; +} + +.markdown-body blockquote::before { + content: '"'; + position: absolute; + left: -8px; + top: -8px; + font-size: 24px; + color: var(--semi-color-primary); + opacity: 0.3; +} + +/* 列表样式增强 */ +.markdown-body ul li::marker { + color: var(--semi-color-primary); +} + +.markdown-body ol li::marker { + color: var(--semi-color-primary); + font-weight: bold; +} + +/* 分隔线样式 */ +.markdown-body hr { + border: none; + height: 1px; + background: linear-gradient(to right, transparent, var(--semi-color-border), transparent); + margin: 24px 0; +} + +/* 图片样式 */ +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin: 12px 0; +} + +/* 内联代码样式 */ +.markdown-body code:not(pre code) { + background-color: var(--semi-color-fill-1); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + color: var(--semi-color-primary); + border: 1px solid var(--semi-color-border); +} + +/* 标题锚点样式 */ +.markdown-body h1:hover, +.markdown-body h2:hover, +.markdown-body h3:hover, +.markdown-body h4:hover, +.markdown-body h5:hover, +.markdown-body h6:hover { + position: relative; +} + +/* 任务列表样式 */ +.markdown-body input[type="checkbox"] { + margin-right: 8px; + transform: scale(1.1); +} + +.markdown-body li.task-list-item { + list-style: none; + margin-left: -20px; +} + +/* 键盘按键样式 */ +.markdown-body kbd { + background-color: var(--semi-color-fill-0); + border: 1px solid var(--semi-color-border); + border-radius: 3px; + box-shadow: 0 1px 0 var(--semi-color-border); + color: var(--semi-color-text-0); + display: inline-block; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.85em; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + white-space: nowrap; +} + +/* 详情折叠样式 */ +.markdown-body details { + border: 1px solid var(--semi-color-border); + border-radius: 6px; + padding: 12px; + margin: 12px 0; +} + +.markdown-body summary { + cursor: pointer; + font-weight: bold; + color: var(--semi-color-primary); + margin-bottom: 8px; +} + +.markdown-body summary:hover { + color: var(--semi-color-primary-hover); +} + +/* 脚注样式 */ +.markdown-body .footnote-ref { + color: var(--semi-color-primary); + text-decoration: none; + font-weight: bold; +} + +.markdown-body .footnote-ref:hover { + text-decoration: underline; +} + +/* 警告块样式 */ +.markdown-body .warning { + background-color: var(--semi-color-warning-light-default); + border-left: 4px solid var(--semi-color-warning); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .info { + background-color: var(--semi-color-info-light-default); + border-left: 4px solid var(--semi-color-info); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .success { + background-color: var(--semi-color-success-light-default); + border-left: 4px solid var(--semi-color-success); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .danger { + background-color: var(--semi-color-danger-light-default); + border-left: 4px solid var(--semi-color-danger); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +@keyframes fade-in { + 0% { + opacity: 0; + transform: translateY(6px) scale(0.98); + filter: blur(3px); + } + 60% { + opacity: 0.85; + filter: blur(0.5px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.animate-fade-in { + animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: opacity, transform; +} \ No newline at end of file diff --git a/web/src/components/custom/TextInput.js b/web/src/components/custom/TextInput.js deleted file mode 100644 index 8c137fe1..00000000 --- a/web/src/components/custom/TextInput.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Input, Typography } from '@douyinfe/semi-ui'; -import React from 'react'; - -const TextInput = ({ - label, - name, - value, - onChange, - placeholder, - type = 'text', -}) => { - return ( - <> -
- {label} -
- onChange(value)} - value={value} - autoComplete='new-password' - /> - - ); -}; - -export default TextInput; diff --git a/web/src/components/custom/TextNumberInput.js b/web/src/components/custom/TextNumberInput.js deleted file mode 100644 index 36e0cac0..00000000 --- a/web/src/components/custom/TextNumberInput.js +++ /dev/null @@ -1,21 +0,0 @@ -import { Input, InputNumber, Typography } from '@douyinfe/semi-ui'; -import React from 'react'; - -const TextNumberInput = ({ label, name, value, onChange, placeholder }) => { - return ( - <> -
- {label} -
- onChange(value)} - value={value} - autoComplete='new-password' - /> - - ); -}; - -export default TextNumberInput; diff --git a/web/src/components/fetchTokenKeys.js b/web/src/components/fetchTokenKeys.js deleted file mode 100644 index e9cec001..00000000 --- a/web/src/components/fetchTokenKeys.js +++ /dev/null @@ -1,68 +0,0 @@ -// src/hooks/useTokenKeys.js -import { useEffect, useState } from 'react'; -import { API, showError } from '../helpers'; - -async function fetchTokenKeys() { - try { - const response = await API.get('/api/token/?p=0&size=100'); - const { success, data } = response.data; - if (success) { - const activeTokens = data.filter((token) => token.status === 1); - return activeTokens.map((token) => token.key); - } else { - throw new Error('Failed to fetch token keys'); - } - } catch (error) { - console.error('Error fetching token keys:', error); - return []; - } -} - -function getServerAddress() { - let status = localStorage.getItem('status'); - let serverAddress = ''; - - if (status) { - try { - status = JSON.parse(status); - serverAddress = status.server_address || ''; - } catch (error) { - console.error('Failed to parse status from localStorage:', error); - } - } - - if (!serverAddress) { - serverAddress = window.location.origin; - } - - return serverAddress; -} - -export function useTokenKeys(id) { - const [keys, setKeys] = useState([]); - // const [chatLink, setChatLink] = useState(''); - const [serverAddress, setServerAddress] = useState(''); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const loadAllData = async () => { - const fetchedKeys = await fetchTokenKeys(); - if (fetchedKeys.length === 0) { - showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!'); - setTimeout(() => { - window.location.href = '/token'; - }, 1500); // 延迟 1.5 秒后跳转 - } - setKeys(fetchedKeys); - setIsLoading(false); - // setChatLink(link); - - const address = getServerAddress(); - setServerAddress(address); - }; - - loadAllData(); - }, []); - - return { keys, serverAddress, isLoading }; -} diff --git a/web/src/components/layout/Footer.js b/web/src/components/layout/Footer.js new file mode 100644 index 00000000..4f44c1dc --- /dev/null +++ b/web/src/components/layout/Footer.js @@ -0,0 +1,112 @@ +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Typography } from '@douyinfe/semi-ui'; +import { getFooterHTML, getLogo, getSystemName } from '../../helpers'; +import { StatusContext } from '../../context/Status'; + +const FooterBar = () => { + const { t } = useTranslation(); + const [footer, setFooter] = useState(getFooterHTML()); + const systemName = getSystemName(); + const logo = getLogo(); + const [statusState] = useContext(StatusContext); + const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + + const loadFooter = () => { + let footer_html = localStorage.getItem('footer_html'); + if (footer_html) { + setFooter(footer_html); + } + }; + + const currentYear = new Date().getFullYear(); + + const customFooter = useMemo(() => ( + + ), [logo, systemName, t, currentYear, isDemoSiteMode]); + + useEffect(() => { + loadFooter(); + }, []); + + return ( +
+ {footer ? ( +
+ ) : ( + customFooter + )} +
+ ); +}; + +export default FooterBar; diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js new file mode 100644 index 00000000..b7425645 --- /dev/null +++ b/web/src/components/layout/HeaderBar.js @@ -0,0 +1,604 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { UserContext } from '../../context/User/index.js'; +import { useSetTheme, useTheme } from '../../context/Theme/index.js'; +import { useTranslation } from 'react-i18next'; +import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js'; +import fireworks from 'react-fireworks'; +import { CN, GB } from 'country-flag-icons/react/3x2'; +import NoticeModal from './NoticeModal.js'; + +import { + IconClose, + IconMenu, + IconLanguage, + IconChevronDown, + IconSun, + IconMoon, + IconExit, + IconUserSetting, + IconCreditCard, + IconKey, + IconBell, +} from '@douyinfe/semi-icons'; +import { + Avatar, + Button, + Dropdown, + Tag, + Typography, + Skeleton, + Badge, +} from '@douyinfe/semi-ui'; +import { StatusContext } from '../../context/Status/index.js'; +import { useStyle, styleActions } from '../../context/Style/index.js'; + +const HeaderBar = () => { + const { t, i18n } = useTranslation(); + const [userState, userDispatch] = useContext(UserContext); + const [statusState, statusDispatch] = useContext(StatusContext); + const { state: styleState, dispatch: styleDispatch } = useStyle(); + const [isLoading, setIsLoading] = useState(true); + let navigate = useNavigate(); + const [currentLang, setCurrentLang] = useState(i18n.language); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const location = useLocation(); + const [noticeVisible, setNoticeVisible] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + + const systemName = getSystemName(); + const logo = getLogo(); + const currentDate = new Date(); + const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1; + + const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false; + const docsLink = statusState?.status?.docs_link || ''; + const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + + const isConsoleRoute = location.pathname.startsWith('/console'); + + const theme = useTheme(); + const setTheme = useSetTheme(); + + const announcements = statusState?.status?.announcements || []; + + const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`; + + const calculateUnreadCount = () => { + if (!announcements.length) return 0; + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const readSet = new Set(readKeys); + return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length; + }; + + const getUnreadKeys = () => { + if (!announcements.length) return []; + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const readSet = new Set(readKeys); + return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey); + }; + + useEffect(() => { + setUnreadCount(calculateUnreadCount()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [announcements]); + + const mainNavLinks = [ + { + text: t('首页'), + itemKey: 'home', + to: '/', + }, + { + text: t('控制台'), + itemKey: 'console', + to: '/console', + }, + { + text: t('定价'), + itemKey: 'pricing', + to: '/pricing', + }, + ...(docsLink + ? [ + { + text: t('文档'), + itemKey: 'docs', + isExternal: true, + externalLink: docsLink, + }, + ] + : []), + { + text: t('关于'), + itemKey: 'about', + to: '/about', + }, + ]; + + async function logout() { + await API.get('/api/user/logout'); + showSuccess(t('注销成功!')); + userDispatch({ type: 'logout' }); + localStorage.removeItem('user'); + navigate('/login'); + setMobileMenuOpen(false); + } + + const handleNewYearClick = () => { + fireworks.init('root', {}); + fireworks.start(); + setTimeout(() => { + fireworks.stop(); + }, 3000); + }; + + const handleNoticeOpen = () => { + setNoticeVisible(true); + }; + + const handleNoticeClose = () => { + setNoticeVisible(false); + if (announcements.length) { + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)])); + localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys)); + } + setUnreadCount(0); + }; + + useEffect(() => { + if (theme === 'dark') { + document.body.setAttribute('theme-mode', 'dark'); + document.documentElement.classList.add('dark'); + } else { + document.body.removeAttribute('theme-mode'); + document.documentElement.classList.remove('dark'); + } + + const iframe = document.querySelector('iframe'); + if (iframe) { + iframe.contentWindow.postMessage({ themeMode: theme }, '*'); + } + + }, [theme, isNewYear]); + + useEffect(() => { + const handleLanguageChanged = (lng) => { + setCurrentLang(lng); + const iframe = document.querySelector('iframe'); + if (iframe) { + iframe.contentWindow.postMessage({ lang: lng }, '*'); + } + }; + + i18n.on('languageChanged', handleLanguageChanged); + return () => { + i18n.off('languageChanged', handleLanguageChanged); + }; + }, [i18n]); + + useEffect(() => { + const timer = setTimeout(() => { + setIsLoading(false); + }, 500); + return () => clearTimeout(timer); + }, []); + + const handleLanguageChange = (lang) => { + i18n.changeLanguage(lang); + setMobileMenuOpen(false); + }; + + const handleNavLinkClick = (itemKey) => { + if (itemKey === 'home') { + styleDispatch(styleActions.setSider(false)); + } + setMobileMenuOpen(false); + }; + + const renderNavLinks = (isMobileView = false, isLoading = false) => { + if (isLoading) { + const skeletonLinkClasses = isMobileView + ? 'flex items-center gap-1 p-3 w-full rounded-md' + : 'flex items-center gap-1 p-2 rounded-md'; + return Array(4) + .fill(null) + .map((_, index) => ( +
+ +
+ )); + } + + return mainNavLinks.map((link) => { + const commonLinkClasses = isMobileView + ? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold' + : 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold'; + + const linkContent = ( + {link.text} + ); + + if (link.isExternal) { + return ( + handleNavLinkClick(link.itemKey)} + > + {linkContent} + + ); + } + + let targetPath = link.to; + if (link.itemKey === 'console' && !userState.user) { + targetPath = '/login'; + } + + return ( + handleNavLinkClick(link.itemKey)} + > + {linkContent} + + ); + }); + }; + + const renderUserArea = () => { + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (userState.user) { + return ( + + { + navigate('/console/personal'); + setMobileMenuOpen(false); + }} + className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white" + > +
+ + {t('个人设置')} +
+
+ { + navigate('/console/token'); + setMobileMenuOpen(false); + }} + className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white" + > +
+ + {t('API令牌')} +
+
+ { + navigate('/console/topup'); + setMobileMenuOpen(false); + }} + className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white" + > +
+ + {t('钱包')} +
+
+ +
+ + {t('退出')} +
+
+ + } + > + +
+ ); + } else { + const showRegisterButton = !isSelfUseMode; + + const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5"; + + const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors"; + let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`; + + let registerButtonClasses = `${commonSizingAndLayoutClass}`; + + const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5"; + const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5"; + + if (showRegisterButton) { + if (styleState.isMobile) { + loginButtonClasses += " !rounded-full"; + } else { + loginButtonClasses += " !rounded-l-full !rounded-r-none"; + } + registerButtonClasses += " !rounded-r-full !rounded-l-none"; + } else { + loginButtonClasses += " !rounded-full"; + } + + return ( +
+ handleNavLinkClick('login')} className="flex"> + + + {showRegisterButton && ( +
+ handleNavLinkClick('register')} className="flex -ml-px"> + + +
+ )} +
+ ); + } + }; + + return ( +
+ 0 ? 'system' : 'inApp'} + unreadKeys={getUnreadKeys()} + /> +
+
+
+
+
+ handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2"> + {isLoading ? ( + + ) : ( + logo + )} +
+
+ {isLoading ? ( + + ) : ( + + {systemName} + + )} + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( + + {isSelfUseMode ? t('自用模式') : t('演示站点')} + + )} +
+
+ + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( +
+ + {isSelfUseMode ? t('自用模式') : t('演示站点')} + +
+ )} + + +
+ +
+ {isNewYear && ( + + + Happy New Year!!! 🎉 + + + } + > +
+
+
+ +
+
+ +
+
+
+ ); +}; + +export default HeaderBar; diff --git a/web/src/components/layout/NoticeModal.js b/web/src/components/layout/NoticeModal.js new file mode 100644 index 00000000..456c012f --- /dev/null +++ b/web/src/components/layout/NoticeModal.js @@ -0,0 +1,178 @@ +import React, { useEffect, useState, useContext, useMemo } from 'react'; +import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; +import { API, showError, getRelativeTime } from '../../helpers'; +import { marked } from 'marked'; +import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations'; +import { StatusContext } from '../../context/Status/index.js'; +import { Bell, Megaphone } from 'lucide-react'; + +const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => { + const { t } = useTranslation(); + const [noticeContent, setNoticeContent] = useState(''); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState(defaultTab); + + const [statusState] = useContext(StatusContext); + + const announcements = statusState?.status?.announcements || []; + + const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]); + + const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`; + + const processedAnnouncements = useMemo(() => { + return (announcements || []).slice(0, 20).map(item => ({ + key: getKeyForItem(item), + type: item.type || 'default', + time: getRelativeTime(item.publishDate), + content: item.content, + extra: item.extra, + isUnread: unreadSet.has(getKeyForItem(item)) + })); + }, [announcements, unreadSet]); + + const handleCloseTodayNotice = () => { + const today = new Date().toDateString(); + localStorage.setItem('notice_close_date', today); + onClose(); + }; + + const displayNotice = async () => { + setLoading(true); + try { + const res = await API.get('/api/notice'); + const { success, message, data } = res.data; + if (success) { + if (data !== '') { + const htmlNotice = marked.parse(data); + setNoticeContent(htmlNotice); + } else { + setNoticeContent(''); + } + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (visible) { + displayNotice(); + } + }, [visible]); + + useEffect(() => { + if (visible) { + setActiveTab(defaultTab); + } + }, [defaultTab, visible]); + + const renderMarkdownNotice = () => { + if (loading) { + return
; + } + + if (!noticeContent) { + return ( +
+ } + darkModeImage={} + description={t('暂无公告')} + /> +
+ ); + } + + return ( +
+ ); + }; + + const renderAnnouncementTimeline = () => { + if (processedAnnouncements.length === 0) { + return ( +
+ } + darkModeImage={} + description={t('暂无系统公告')} + /> +
+ ); + } + + return ( +
+ + {processedAnnouncements.map((item, idx) => ( + +
+ {item.isUnread ? ( + + {item.content} + + ) : ( + item.content + )} + {item.extra &&
{item.extra}
} +
+
+ ))} +
+
+ ); + }; + + const renderBody = () => { + if (activeTab === 'inApp') { + return renderMarkdownNotice(); + } + return renderAnnouncementTimeline(); + }; + + return ( + + {t('系统公告')} + + {t('通知')}} itemKey='inApp' /> + {t('系统公告')}} itemKey='system' /> + +
+ } + visible={visible} + onCancel={onClose} + footer={( +
+ + +
+ )} + size={isMobile ? 'full-width' : 'large'} + > + {renderBody()} + + ); +}; + +export default NoticeModal; \ No newline at end of file diff --git a/web/src/components/PageLayout.js b/web/src/components/layout/PageLayout.js similarity index 69% rename from web/src/components/PageLayout.js rename to web/src/components/layout/PageLayout.js index d52bc0d4..e25901ef 100644 --- a/web/src/components/PageLayout.js +++ b/web/src/components/layout/PageLayout.js @@ -1,23 +1,30 @@ import HeaderBar from './HeaderBar.js'; import { Layout } from '@douyinfe/semi-ui'; import SiderBar from './SiderBar.js'; -import App from '../App.js'; +import App from '../../App.js'; import FooterBar from './Footer.js'; import { ToastContainer } from 'react-toastify'; import React, { useContext, useEffect } from 'react'; -import { StyleContext } from '../context/Style/index.js'; +import { useStyle } from '../../context/Style/index.js'; import { useTranslation } from 'react-i18next'; -import { API, getLogo, getSystemName, showError } from '../helpers/index.js'; -import { setStatusData } from '../helpers/data.js'; -import { UserContext } from '../context/User/index.js'; -import { StatusContext } from '../context/Status/index.js'; +import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js'; +import { UserContext } from '../../context/User/index.js'; +import { StatusContext } from '../../context/Status/index.js'; +import { useLocation } from 'react-router-dom'; const { Sider, Content, Header, Footer } = Layout; const PageLayout = () => { const [userState, userDispatch] = useContext(UserContext); const [statusState, statusDispatch] = useContext(StatusContext); - const [styleState, styleDispatch] = useContext(StyleContext); + const { state: styleState } = useStyle(); const { i18n } = useTranslation(); + const location = useLocation(); + + const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat'); + + const shouldInnerPadding = location.pathname.includes('/console') && + !location.pathname.startsWith('/console/chat') && + location.pathname !== '/console/playground'; const loadUser = () => { let user = localStorage.getItem('user'); @@ -61,15 +68,8 @@ const PageLayout = () => { if (savedLang) { i18n.changeLanguage(savedLang); } - - // 默认显示侧边栏 - styleDispatch({ type: 'SET_SIDER', payload: true }); }, [i18n]); - // 获取侧边栏折叠状态 - const isSidebarCollapsed = - localStorage.getItem('default_collapse_sidebar') === 'true'; - return ( { padding: 0, height: 'auto', lineHeight: 'normal', - position: styleState.isMobile ? 'sticky' : 'fixed', + position: 'fixed', width: '100%', top: 0, zIndex: 100, - boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)', }} > { style={{ position: 'fixed', left: 0, - top: '56px', + top: '64px', zIndex: 99, - background: 'var(--semi-color-bg-1)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', border: 'none', paddingRight: '0', - height: 'calc(100vh - 56px)', + height: 'calc(100vh - 64px)', }} > @@ -126,7 +123,7 @@ const PageLayout = () => { : styleState.showSider ? styleState.siderCollapsed ? '60px' - : '200px' + : '180px' : '0', transition: 'margin-left 0.3s ease', flex: '1 1 auto', @@ -137,23 +134,24 @@ const PageLayout = () => { - - - + {!shouldHideFooter && ( + + + + )} diff --git a/web/src/components/SetupCheck.js b/web/src/components/layout/SetupCheck.js similarity index 90% rename from web/src/components/SetupCheck.js rename to web/src/components/layout/SetupCheck.js index 99364b00..3fbd9012 100644 --- a/web/src/components/SetupCheck.js +++ b/web/src/components/layout/SetupCheck.js @@ -1,6 +1,6 @@ import React, { useContext, useEffect } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; -import { StatusContext } from '../context/Status'; +import { StatusContext } from '../../context/Status'; const SetupCheck = ({ children }) => { const [statusState] = useContext(StatusContext); diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js new file mode 100644 index 00000000..8ede4bc8 --- /dev/null +++ b/web/src/components/layout/SiderBar.js @@ -0,0 +1,448 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js'; +import { ChevronLeft } from 'lucide-react'; +import { useStyle, styleActions } from '../../context/Style/index.js'; +import { + isAdmin, + isRoot, + showError +} from '../../helpers/index.js'; + +import { + Nav, + Divider, + Tooltip, +} from '@douyinfe/semi-ui'; + +const routerMap = { + home: '/', + channel: '/console/channel', + token: '/console/token', + redemption: '/console/redemption', + topup: '/console/topup', + user: '/console/user', + log: '/console/log', + midjourney: '/console/midjourney', + setting: '/console/setting', + about: '/about', + detail: '/console', + pricing: '/pricing', + task: '/console/task', + playground: '/console/playground', + personal: '/console/personal', +}; + +const SiderBar = () => { + const { t } = useTranslation(); + const { state: styleState, dispatch: styleDispatch } = useStyle(); + + const [selectedKeys, setSelectedKeys] = useState(['home']); + const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed); + const [chatItems, setChatItems] = useState([]); + const [openedKeys, setOpenedKeys] = useState([]); + const location = useLocation(); + const [routerMapState, setRouterMapState] = useState(routerMap); + + const workspaceItems = useMemo( + () => [ + { + text: t('数据看板'), + itemKey: 'detail', + to: '/detail', + className: + localStorage.getItem('enable_data_export') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('API令牌'), + itemKey: 'token', + to: '/token', + }, + { + text: t('使用日志'), + itemKey: 'log', + to: '/log', + }, + { + text: t('绘图日志'), + itemKey: 'midjourney', + to: '/midjourney', + className: + localStorage.getItem('enable_drawing') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('任务日志'), + itemKey: 'task', + to: '/task', + className: + localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', + }, + ], + [ + localStorage.getItem('enable_data_export'), + localStorage.getItem('enable_drawing'), + localStorage.getItem('enable_task'), + t, + ], + ); + + const financeItems = useMemo( + () => [ + { + text: t('钱包'), + itemKey: 'topup', + to: '/topup', + }, + { + text: t('个人设置'), + itemKey: 'personal', + to: '/personal', + }, + ], + [t], + ); + + const adminItems = useMemo( + () => [ + { + text: t('渠道'), + itemKey: 'channel', + to: '/channel', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('兑换码'), + itemKey: 'redemption', + to: '/redemption', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('用户管理'), + itemKey: 'user', + to: '/user', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('系统设置'), + itemKey: 'setting', + to: '/setting', + className: isRoot() ? '' : 'tableHiddle', + }, + ], + [isAdmin(), isRoot(), t], + ); + + const chatMenuItems = useMemo( + () => [ + { + text: t('操练场'), + itemKey: 'playground', + to: '/playground', + }, + { + text: t('聊天'), + itemKey: 'chat', + items: chatItems, + }, + ], + [chatItems, t], + ); + + // 更新路由映射,添加聊天路由 + const updateRouterMapWithChats = (chats) => { + const newRouterMap = { ...routerMap }; + + if (Array.isArray(chats) && chats.length > 0) { + for (let i = 0; i < chats.length; i++) { + newRouterMap['chat' + i] = '/console/chat/' + i; + } + } + + setRouterMapState(newRouterMap); + return newRouterMap; + }; + + // 加载聊天项 + useEffect(() => { + let chats = localStorage.getItem('chats'); + if (chats) { + try { + chats = JSON.parse(chats); + if (Array.isArray(chats)) { + let chatItems = []; + for (let i = 0; i < chats.length; i++) { + let chat = {}; + for (let key in chats[i]) { + chat.text = key; + chat.itemKey = 'chat' + i; + chat.to = '/console/chat/' + i; + } + chatItems.push(chat); + } + setChatItems(chatItems); + updateRouterMapWithChats(chats); + } + } catch (e) { + console.error(e); + showError('聊天数据解析失败'); + } + } + }, []); + + // 根据当前路径设置选中的菜单项 + useEffect(() => { + const currentPath = location.pathname; + let matchingKey = Object.keys(routerMapState).find( + (key) => routerMapState[key] === currentPath, + ); + + // 处理聊天路由 + if (!matchingKey && currentPath.startsWith('/console/chat/')) { + const chatIndex = currentPath.split('/').pop(); + if (!isNaN(chatIndex)) { + matchingKey = 'chat' + chatIndex; + } else { + matchingKey = 'chat'; + } + } + + // 如果找到匹配的键,更新选中的键 + if (matchingKey) { + setSelectedKeys([matchingKey]); + } + }, [location.pathname, routerMapState]); + + // 同步折叠状态 + useEffect(() => { + setIsCollapsed(styleState.siderCollapsed); + }, [styleState.siderCollapsed]); + + // 获取菜单项对应的颜色 + const getItemColor = (itemKey) => { + switch (itemKey) { + case 'detail': return sidebarIconColors.dashboard; + case 'playground': return sidebarIconColors.terminal; + case 'chat': return sidebarIconColors.message; + case 'token': return sidebarIconColors.key; + case 'log': return sidebarIconColors.chart; + case 'midjourney': return sidebarIconColors.image; + case 'task': return sidebarIconColors.check; + case 'topup': return sidebarIconColors.credit; + case 'channel': return sidebarIconColors.layers; + case 'redemption': return sidebarIconColors.gift; + case 'user': + case 'personal': return sidebarIconColors.user; + case 'setting': return sidebarIconColors.settings; + default: + // 处理聊天项 + if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message; + return 'currentColor'; + } + }; + + // 渲染自定义菜单项 + const renderNavItem = (item) => { + // 跳过隐藏的项目 + if (item.className === 'tableHiddle') return null; + + const isSelected = selectedKeys.includes(item.itemKey); + const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + + return ( + + + {item.text} + +
+ } + icon={ +
+ {getLucideIcon(item.itemKey, isSelected)} +
+ } + className={item.className} + /> + ); + }; + + // 渲染子菜单项 + const renderSubItem = (item) => { + if (item.items && item.items.length > 0) { + const isSelected = selectedKeys.includes(item.itemKey); + const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + + return ( + + + {item.text} + + + } + icon={ +
+ {getLucideIcon(item.itemKey, isSelected)} +
+ } + > + {item.items.map((subItem) => { + const isSubSelected = selectedKeys.includes(subItem.itemKey); + const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit'; + + return ( + + {subItem.text} + + } + /> + ); + })} +
+ ); + } else { + return renderNavItem(item); + } + }; + + return ( +
+ + + {/* 底部折叠按钮 */} +
{ + const newCollapsed = !isCollapsed; + setIsCollapsed(newCollapsed); + styleDispatch(styleActions.setSiderCollapsed(newCollapsed)); + }} + > + +
+ + + +
+
+
+
+ ); +}; + +export default SiderBar; diff --git a/web/src/components/playground/ChatArea.js b/web/src/components/playground/ChatArea.js new file mode 100644 index 00000000..81e2df90 --- /dev/null +++ b/web/src/components/playground/ChatArea.js @@ -0,0 +1,113 @@ +import React from 'react'; +import { + Card, + Chat, + Typography, + Button, +} from '@douyinfe/semi-ui'; +import { + MessageSquare, + Eye, + EyeOff, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import CustomInputRender from './CustomInputRender'; + +const ChatArea = ({ + chatRef, + message, + inputs, + styleState, + showDebugPanel, + roleInfo, + onMessageSend, + onMessageCopy, + onMessageReset, + onMessageDelete, + onStopGenerator, + onClearMessages, + onToggleDebugPanel, + renderCustomChatContent, + renderChatBoxAction, +}) => { + const { t } = useTranslation(); + + const renderInputArea = React.useCallback((props) => { + return ; + }, []); + + return ( + + {/* 聊天头部 */} + {styleState.isMobile ? ( +
+ ) : ( +
+
+
+
+ +
+
+ + {t('AI 对话')} + + + {inputs.model || t('选择模型开始对话')} + +
+
+
+ +
+
+
+ )} + + {/* 聊天内容区域 */} +
+ null, + }} + renderInputArea={renderInputArea} + roleConfig={roleInfo} + style={{ + height: '100%', + maxWidth: '100%', + overflow: 'hidden' + }} + chats={message} + onMessageSend={onMessageSend} + onMessageCopy={onMessageCopy} + onMessageReset={onMessageReset} + onMessageDelete={onMessageDelete} + showClearContext + showStopGenerate + onStopGenerator={onStopGenerator} + onClear={onClearMessages} + className="h-full" + placeholder={t('请输入您的问题...')} + /> +
+
+ ); +}; + +export default ChatArea; \ No newline at end of file diff --git a/web/src/components/playground/CodeViewer.js b/web/src/components/playground/CodeViewer.js new file mode 100644 index 00000000..1ce723ce --- /dev/null +++ b/web/src/components/playground/CodeViewer.js @@ -0,0 +1,313 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { Button, Tooltip, Toast } from '@douyinfe/semi-ui'; +import { Copy, ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { copy } from '../../helpers'; + +const PERFORMANCE_CONFIG = { + MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数 + PREVIEW_LENGTH: 5000, // 预览长度 + VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数 +}; + +const codeThemeStyles = { + container: { + backgroundColor: '#1e1e1e', + color: '#d4d4d4', + fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace', + fontSize: '13px', + lineHeight: '1.4', + borderRadius: '8px', + border: '1px solid #3c3c3c', + position: 'relative', + overflow: 'hidden', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', + }, + content: { + height: '100%', + overflowY: 'auto', + overflowX: 'auto', + padding: '16px', + margin: 0, + whiteSpace: 'pre', + wordBreak: 'normal', + background: '#1e1e1e', + }, + actionButton: { + position: 'absolute', + zIndex: 10, + backgroundColor: 'rgba(45, 45, 45, 0.9)', + border: '1px solid rgba(255, 255, 255, 0.1)', + color: '#d4d4d4', + borderRadius: '6px', + transition: 'all 0.2s ease', + }, + actionButtonHover: { + backgroundColor: 'rgba(60, 60, 60, 0.95)', + borderColor: 'rgba(255, 255, 255, 0.2)', + transform: 'scale(1.05)', + }, + noContent: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: '#666', + fontSize: '14px', + fontStyle: 'italic', + backgroundColor: 'var(--semi-color-fill-0)', + borderRadius: '8px', + }, + performanceWarning: { + padding: '8px 12px', + backgroundColor: 'rgba(255, 193, 7, 0.1)', + border: '1px solid rgba(255, 193, 7, 0.3)', + borderRadius: '6px', + color: '#ffc107', + fontSize: '12px', + marginBottom: '8px', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, +}; + +const highlightJson = (str) => { + return str.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, + (match) => { + let color = '#b5cea8'; + if (/^"/.test(match)) { + color = /:$/.test(match) ? '#9cdcfe' : '#ce9178'; + } else if (/true|false|null/.test(match)) { + color = '#569cd6'; + } + return `${match}`; + } + ); +}; + +const isJsonLike = (content, language) => { + if (language === 'json') return true; + const trimmed = content.trim(); + return (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')); +}; + +const formatContent = (content) => { + if (!content) return ''; + + if (typeof content === 'object') { + try { + return JSON.stringify(content, null, 2); + } catch (e) { + return String(content); + } + } + + if (typeof content === 'string') { + try { + const parsed = JSON.parse(content); + return JSON.stringify(parsed, null, 2); + } catch (e) { + return content; + } + } + + return String(content); +}; + +const CodeViewer = ({ content, title, language = 'json' }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const [isHoveringCopy, setIsHoveringCopy] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + + const formattedContent = useMemo(() => formatContent(content), [content]); + + const contentMetrics = useMemo(() => { + const length = formattedContent.length; + const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH; + const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER; + return { length, isLarge, isVeryLarge }; + }, [formattedContent.length]); + + const displayContent = useMemo(() => { + if (!contentMetrics.isLarge || isExpanded) { + return formattedContent; + } + return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) + + '\n\n// ... 内容被截断以提升性能 ...'; + }, [formattedContent, contentMetrics.isLarge, isExpanded]); + + const highlightedContent = useMemo(() => { + if (contentMetrics.isVeryLarge && !isExpanded) { + return displayContent; + } + + if (isJsonLike(displayContent, language)) { + return highlightJson(displayContent); + } + + return displayContent; + }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]); + + const handleCopy = useCallback(async () => { + try { + const textToCopy = typeof content === 'object' && content !== null + ? JSON.stringify(content, null, 2) + : content; + + const success = await copy(textToCopy); + setCopied(true); + Toast.success(t('已复制到剪贴板')); + setTimeout(() => setCopied(false), 2000); + + if (!success) { + throw new Error('Copy operation failed'); + } + } catch (err) { + Toast.error(t('复制失败')); + console.error('Copy failed:', err); + } + }, [content, t]); + + const handleToggleExpand = useCallback(() => { + if (contentMetrics.isVeryLarge && !isExpanded) { + setIsProcessing(true); + setTimeout(() => { + setIsExpanded(true); + setIsProcessing(false); + }, 100); + } else { + setIsExpanded(!isExpanded); + } + }, [isExpanded, contentMetrics.isVeryLarge]); + + if (!content) { + const placeholderText = { + preview: t('正在构造请求体预览...'), + request: t('暂无请求数据'), + response: t('暂无响应数据') + }[title] || t('暂无数据'); + + return ( +
+ {placeholderText} +
+ ); + } + + const warningTop = contentMetrics.isLarge ? '52px' : '12px'; + const contentPadding = contentMetrics.isLarge ? '52px' : '16px'; + + return ( +
+ {/* 性能警告 */} + {contentMetrics.isLarge && ( +
+ + + {contentMetrics.isVeryLarge + ? t('内容较大,已启用性能优化模式') + : t('内容较大,部分功能可能受限')} + +
+ )} + + {/* 复制按钮 */} +
setIsHoveringCopy(true)} + onMouseLeave={() => setIsHoveringCopy(false)} + > + +
+ + {/* 代码内容 */} +
+ {isProcessing ? ( +
+
+ {t('正在处理大内容...')} +
+ ) : ( +
+ )} +
+ + {/* 展开/收起按钮 */} + {contentMetrics.isLarge && !isProcessing && ( +
+ + + +
+ )} +
+ ); +}; + +export default CodeViewer; \ No newline at end of file diff --git a/web/src/components/playground/ConfigManager.js b/web/src/components/playground/ConfigManager.js new file mode 100644 index 00000000..ddff8785 --- /dev/null +++ b/web/src/components/playground/ConfigManager.js @@ -0,0 +1,260 @@ +import React, { useRef } from 'react'; +import { + Button, + Typography, + Toast, + Modal, + Dropdown, +} from '@douyinfe/semi-ui'; +import { + Download, + Upload, + RotateCcw, + Settings2, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage'; + +const ConfigManager = ({ + currentConfig, + onConfigImport, + onConfigReset, + styleState, + messages, +}) => { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + + const handleExport = () => { + try { + // 在导出前先保存当前配置,确保导出的是最新内容 + const configWithTimestamp = { + ...currentConfig, + timestamp: new Date().toISOString(), + }; + localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp)); + + exportConfig(currentConfig, messages); + Toast.success({ + content: t('配置已导出到下载文件夹'), + duration: 3, + }); + } catch (error) { + Toast.error({ + content: t('导出配置失败: ') + error.message, + duration: 3, + }); + } + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event) => { + const file = event.target.files[0]; + if (!file) return; + + try { + const importedConfig = await importConfig(file); + + Modal.confirm({ + title: t('确认导入配置'), + content: t('导入的配置将覆盖当前设置,是否继续?'), + okText: t('确定导入'), + cancelText: t('取消'), + onOk: () => { + onConfigImport(importedConfig); + Toast.success({ + content: t('配置导入成功'), + duration: 3, + }); + }, + }); + } catch (error) { + Toast.error({ + content: t('导入配置失败: ') + error.message, + duration: 3, + }); + } finally { + // 重置文件输入,允许重复选择同一文件 + event.target.value = ''; + } + }; + + const handleReset = () => { + Modal.confirm({ + title: t('重置配置'), + content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'), + okText: t('确定重置'), + cancelText: t('取消'), + okButtonProps: { + type: 'danger', + }, + onOk: () => { + // 询问是否同时重置消息 + Modal.confirm({ + title: t('重置选项'), + content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'), + okText: t('同时重置消息'), + cancelText: t('仅重置配置'), + okButtonProps: { + type: 'danger', + }, + onOk: () => { + clearConfig(); + onConfigReset({ resetMessages: true }); + Toast.success({ + content: t('配置和消息已全部重置'), + duration: 3, + }); + }, + onCancel: () => { + clearConfig(); + onConfigReset({ resetMessages: false }); + Toast.success({ + content: t('配置已重置,对话消息已保留'), + duration: 3, + }); + }, + }); + }, + }); + }; + + const getConfigStatus = () => { + if (hasStoredConfig()) { + const timestamp = getConfigTimestamp(); + if (timestamp) { + const date = new Date(timestamp); + return t('上次保存: ') + date.toLocaleString(); + } + return t('已有保存的配置'); + } + return t('暂无保存的配置'); + }; + + const dropdownItems = [ + { + node: 'item', + name: 'export', + onClick: handleExport, + children: ( +
+ + {t('导出配置')} +
+ ), + }, + { + node: 'item', + name: 'import', + onClick: handleImportClick, + children: ( +
+ + {t('导入配置')} +
+ ), + }, + { + node: 'divider', + }, + { + node: 'item', + name: 'reset', + onClick: handleReset, + children: ( +
+ + {t('重置配置')} +
+ ), + }, + ]; + + if (styleState.isMobile) { + // 移动端显示简化的下拉菜单 + return ( + <> + +
+ + {/* 导出和导入按钮 */} +
+ + + +
+ + +
+ ); +}; + +export default ConfigManager; \ No newline at end of file diff --git a/web/src/components/playground/CustomInputRender.js b/web/src/components/playground/CustomInputRender.js new file mode 100644 index 00000000..ff62c104 --- /dev/null +++ b/web/src/components/playground/CustomInputRender.js @@ -0,0 +1,58 @@ +import React from 'react'; + +const CustomInputRender = (props) => { + const { detailProps } = props; + const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps; + + // 清空按钮 + const styledClearNode = clearContextNode + ? React.cloneElement(clearContextNode, { + className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`, + style: { + ...clearContextNode.props.style, + width: '32px', + height: '32px', + minWidth: '32px', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } + }) + : null; + + // 发送按钮 + const styledSendNode = React.cloneElement(sendNode, { + className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`, + style: { + ...sendNode.props.style, + width: '32px', + height: '32px', + minWidth: '32px', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } + }); + + return ( +
+
+ {/* 清空对话按钮 - 左边 */} + {styledClearNode} +
+ {inputNode} +
+ {/* 发送按钮 - 右边 */} + {styledSendNode} +
+
+ ); +}; + +export default CustomInputRender; \ No newline at end of file diff --git a/web/src/components/playground/CustomRequestEditor.js b/web/src/components/playground/CustomRequestEditor.js new file mode 100644 index 00000000..9b11b4f4 --- /dev/null +++ b/web/src/components/playground/CustomRequestEditor.js @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import { + TextArea, + Typography, + Button, + Switch, + Banner, +} from '@douyinfe/semi-ui'; +import { + Code, + Edit, + Check, + X, + AlertTriangle, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +const CustomRequestEditor = ({ + customRequestMode, + customRequestBody, + onCustomRequestModeChange, + onCustomRequestBodyChange, + defaultPayload, +}) => { + const { t } = useTranslation(); + const [isValid, setIsValid] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [localValue, setLocalValue] = useState(customRequestBody || ''); + + // 当切换到自定义模式时,用默认payload初始化 + useEffect(() => { + if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) { + const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : ''; + setLocalValue(defaultJson); + onCustomRequestBodyChange(defaultJson); + } + }, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]); + + // 同步外部传入的customRequestBody到本地状态 + useEffect(() => { + if (customRequestBody !== localValue) { + setLocalValue(customRequestBody || ''); + validateJson(customRequestBody || ''); + } + }, [customRequestBody]); + + // 验证JSON格式 + const validateJson = (value) => { + if (!value.trim()) { + setIsValid(true); + setErrorMessage(''); + return true; + } + + try { + JSON.parse(value); + setIsValid(true); + setErrorMessage(''); + return true; + } catch (error) { + setIsValid(false); + setErrorMessage(`JSON格式错误: ${error.message}`); + return false; + } + }; + + const handleValueChange = (value) => { + setLocalValue(value); + validateJson(value); + // 始终保存用户输入,让预览逻辑处理JSON解析错误 + onCustomRequestBodyChange(value); + }; + + const handleModeToggle = (enabled) => { + onCustomRequestModeChange(enabled); + if (enabled && defaultPayload) { + const defaultJson = JSON.stringify(defaultPayload, null, 2); + setLocalValue(defaultJson); + onCustomRequestBodyChange(defaultJson); + } + }; + + const formatJson = () => { + try { + const parsed = JSON.parse(localValue); + const formatted = JSON.stringify(parsed, null, 2); + setLocalValue(formatted); + onCustomRequestBodyChange(formatted); + setIsValid(true); + setErrorMessage(''); + } catch (error) { + // 如果格式化失败,保持原样 + } + }; + + return ( +
+ {/* 自定义模式开关 */} +
+
+ + + 自定义请求体模式 + +
+ +
+ + {customRequestMode && ( + <> + {/* 提示信息 */} + } + className="!rounded-lg" + closable={false} + /> + + {/* JSON编辑器 */} +
+
+ + 请求体 JSON + +
+ {isValid ? ( +
+ + + 格式正确 + +
+ ) : ( +
+ + + 格式错误 + +
+ )} + +
+
+ +