diff --git a/common/billing_model.go b/common/billing_model.go new file mode 100644 index 000000000000..73c84a3b7b16 --- /dev/null +++ b/common/billing_model.go @@ -0,0 +1,50 @@ +package common + +import "strings" + +func normalizeBillingModelName(modelName string) string { + return strings.ToLower(strings.TrimSpace(modelName)) +} + +func IsDurationOnlyBillingModel(modelName string) bool { + name := normalizeBillingModelName(modelName) + if name == "" { + return false + } + if strings.HasPrefix(name, "veo") { + return true + } + return strings.Contains(name, "grok-imagine") && strings.Contains(name, "video") +} + +func IsResolutionOnlyBillingModel(modelName string) bool { + name := normalizeBillingModelName(modelName) + if name == "" { + return false + } + return strings.Contains(name, "banana") +} + +func FilterOtherRatiosForBillingModel(modelName string, ratios map[string]float64) map[string]float64 { + if len(ratios) == 0 { + return map[string]float64{} + } + + filtered := make(map[string]float64, len(ratios)) + switch { + case IsDurationOnlyBillingModel(modelName): + if ratio, ok := ratios["seconds"]; ok && ratio > 0 { + filtered["seconds"] = ratio + } + case IsResolutionOnlyBillingModel(modelName): + return filtered + default: + for key, ratio := range ratios { + if ratio > 0 { + filtered[key] = ratio + } + } + } + + return filtered +} diff --git a/common/billing_model_test.go b/common/billing_model_test.go new file mode 100644 index 000000000000..9c14938845fb --- /dev/null +++ b/common/billing_model_test.go @@ -0,0 +1,30 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilterOtherRatiosForDurationOnlyModel(t *testing.T) { + filtered := FilterOtherRatiosForBillingModel("grok-imagine-1.0-video", map[string]float64{ + "seconds": 6, + "size": 1.666667, + "resolution": 1.5, + }) + + assert.Equal(t, map[string]float64{ + "seconds": 6, + }, filtered) +} + +func TestFilterOtherRatiosForResolutionOnlyModel(t *testing.T) { + filtered := FilterOtherRatiosForBillingModel("nano-banana-pro", map[string]float64{ + "resolution": 2, + "quality": 1.5, + "output_resolution": 4, + "n": 3, + }) + + assert.Empty(t, filtered) +} diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index 11ec79217530..5c85fbb220f4 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -24,7 +24,9 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"}, constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, + constant.EndpointTypeImageEdit: {Path: "/v1/images/edits", Method: "POST"}, constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"}, + constant.EndpointTypeOpenAIVideo: {Path: "/v1/video/generations", Method: "POST"}, } // GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 diff --git a/common/endpoint_type.go b/common/endpoint_type.go index a5e2ff8412e8..fbd5bccbaf58 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -37,7 +37,11 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} } } - if IsImageGenerationModel(modelName) { + if IsOpenAIVideoModel(modelName) { + endpointTypes = append([]constant.EndpointType{constant.EndpointTypeOpenAIVideo}, endpointTypes...) + } else if IsImageEditModel(modelName) { + endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageEdit}, endpointTypes...) + } else if IsImageGenerationModel(modelName) { // add to first endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...) } diff --git a/common/endpoint_type_test.go b/common/endpoint_type_test.go new file mode 100644 index 000000000000..7f2c980c876b --- /dev/null +++ b/common/endpoint_type_test.go @@ -0,0 +1,59 @@ +package common + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" +) + +func TestGetEndpointTypesByChannelTypeRecognizesGrokImagineImageModels(t *testing.T) { + tests := []struct { + name string + channel int + model string + expected constant.EndpointType + }{ + { + name: "grok imagine 1.0 generation", + channel: constant.ChannelTypeXai, + model: "grok-imagine-1.0", + expected: constant.EndpointTypeImageGeneration, + }, + { + name: "grok imagine 1.0 fast generation", + channel: constant.ChannelTypeXai, + model: "grok-imagine-1.0-fast", + expected: constant.EndpointTypeImageGeneration, + }, + { + name: "grok imagine 1.0 edit", + channel: constant.ChannelTypeXai, + model: "grok-imagine-1.0-edit", + expected: constant.EndpointTypeImageEdit, + }, + { + name: "grok imagine 1.0 video on xai", + channel: constant.ChannelTypeXai, + model: "grok-imagine-1.0-video", + expected: constant.EndpointTypeOpenAIVideo, + }, + { + name: "grok imagine video stays video", + channel: constant.ChannelTypeSora, + model: "grok-imagine-1.0-video", + expected: constant.EndpointTypeOpenAIVideo, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetEndpointTypesByChannelType(tt.channel, tt.model) + if len(got) == 0 { + t.Fatalf("expected endpoint types for %s", tt.model) + } + if got[0] != tt.expected { + t.Fatalf("expected first endpoint %s, got %s", tt.expected, got[0]) + } + }) + } +} diff --git a/common/init.go b/common/init.go index e9cfc98bbf64..be6c3b597eb7 100644 --- a/common/init.go +++ b/common/init.go @@ -150,7 +150,9 @@ func initConstantEnv() { // 任务轮询时查询的最大数量 constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000) // 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。 - constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440) + constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 20) + // 视频轮询遇到泛化 404 Not Found 时的宽限时间(分钟)。0 表示立即失败。 + constant.TaskNotFoundGraceMinutes = GetEnvOrDefault("TASK_NOT_FOUND_GRACE_MINUTES", 10) soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "") if soraPatchStr != "" { diff --git a/common/model.go b/common/model.go index 4ebc7b532d74..ec399b1fe9f1 100644 --- a/common/model.go +++ b/common/model.go @@ -13,10 +13,20 @@ var ( "dall-e-3", "dall-e-2", "gpt-image-1", + "gpt-image2", + "exact:grok-imagine-1.0", + "exact:grok-imagine-1.0-fast", "prefix:imagen-", "flux-", "flux.1-", } + ImageEditModels = []string{ + "exact:grok-imagine-1.0-edit", + } + OpenAIVideoModels = []string{ + "exact:grok-imagine-1.0-video", + "exact:grok-imagine-video", + } OpenAITextModels = []string{ "gpt-", "o1", @@ -38,6 +48,41 @@ func IsOpenAIResponseOnlyModel(modelName string) bool { func IsImageGenerationModel(modelName string) bool { modelName = strings.ToLower(modelName) for _, m := range ImageGenerationModels { + if strings.HasPrefix(m, "exact:") && modelName == strings.TrimPrefix(m, "exact:") { + return true + } + if strings.Contains(modelName, m) { + return true + } + if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) { + return true + } + } + return false +} + +func IsImageEditModel(modelName string) bool { + modelName = strings.ToLower(modelName) + for _, m := range ImageEditModels { + if strings.HasPrefix(m, "exact:") && modelName == strings.TrimPrefix(m, "exact:") { + return true + } + if strings.Contains(modelName, m) { + return true + } + if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) { + return true + } + } + return false +} + +func IsOpenAIVideoModel(modelName string) bool { + modelName = strings.ToLower(modelName) + for _, m := range OpenAIVideoModels { + if strings.HasPrefix(m, "exact:") && modelName == strings.TrimPrefix(m, "exact:") { + return true + } if strings.Contains(modelName, m) { return true } diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go index 8681bf06e319..c5d1e3be3a97 100644 --- a/constant/endpoint_type.go +++ b/constant/endpoint_type.go @@ -10,6 +10,7 @@ const ( EndpointTypeGemini EndpointType = "gemini" EndpointTypeJinaRerank EndpointType = "jina-rerank" EndpointTypeImageGeneration EndpointType = "image-generation" + EndpointTypeImageEdit EndpointType = "image-edit" EndpointTypeEmbeddings EndpointType = "embeddings" EndpointTypeOpenAIVideo EndpointType = "openai-video" //EndpointTypeMidjourney EndpointType = "midjourney-proxy" diff --git a/constant/env.go b/constant/env.go index d5aff1b0b173..8872351ab807 100644 --- a/constant/env.go +++ b/constant/env.go @@ -17,6 +17,7 @@ var GenerateDefaultToken bool var ErrorLogEnabled bool var TaskQueryLimit int var TaskTimeoutMinutes int +var TaskNotFoundGraceMinutes int // temporary variable for sora patch, will be removed in future var TaskPricePatches []string diff --git a/constant/task.go b/constant/task.go index ecccf4dfe119..9d6f5b1c80ef 100644 --- a/constant/task.go +++ b/constant/task.go @@ -11,6 +11,8 @@ const ( SunoActionMusic = "MUSIC" SunoActionLyrics = "LYRICS" + TaskActionImageGenerate = "imageGenerate" + TaskActionImageEdit = "imageEdit" TaskActionGenerate = "generate" TaskActionTextGenerate = "textGenerate" TaskActionFirstTailGenerate = "firstTailGenerate" diff --git a/controller/asset.go b/controller/asset.go new file mode 100644 index 000000000000..3cf8d29f51cc --- /dev/null +++ b/controller/asset.go @@ -0,0 +1,182 @@ +package controller + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +func GetAllCreativeCenterAssets(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + queryParams := buildCreativeCenterAssetQueryParams(c) + + assets, err := model.GetAllCreativeCenterAssets(queryParams) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(len(assets)) + pageInfo.SetItems(sliceCreativeCenterAssets(assets, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), true)) + common.ApiSuccess(c, pageInfo) +} + +func GetUserCreativeCenterAssets(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + userID := c.GetInt("id") + queryParams := buildCreativeCenterAssetQueryParams(c) + + assets, err := model.GetUserCreativeCenterAssets(userID, queryParams) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(len(assets)) + pageInfo.SetItems(sliceCreativeCenterAssets(assets, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), false)) + common.ApiSuccess(c, pageInfo) +} + +func DownloadAllCreativeCenterAssets(c *gin.Context) { + queryParams := buildCreativeCenterAssetQueryParams(c) + assets, err := model.GetAllCreativeCenterAssets(queryParams) + if err != nil { + common.ApiError(c, err) + return + } + downloadCreativeCenterAssets(c, assets) +} + +func DownloadUserCreativeCenterAssets(c *gin.Context) { + userID := c.GetInt("id") + queryParams := buildCreativeCenterAssetQueryParams(c) + assets, err := model.GetUserCreativeCenterAssets(userID, queryParams) + if err != nil { + common.ApiError(c, err) + return + } + downloadCreativeCenterAssets(c, assets) +} + +func buildCreativeCenterAssetQueryParams(c *gin.Context) model.CreativeCenterAssetQueryParams { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + return model.CreativeCenterAssetQueryParams{ + Type: c.Query("type"), + Keyword: c.Query("keyword"), + ModelName: c.Query("model_name"), + Status: c.Query("status"), + Username: c.Query("username"), + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + } +} + +func downloadCreativeCenterAssets(c *gin.Context, availableAssets []*dto.CreativeCenterAsset) { + request := &dto.CreativeCenterAssetDownloadRequest{} + if err := common.DecodeJson(c.Request.Body, request); err != nil { + common.ApiError(c, err) + return + } + if len(request.AssetIDs) == 0 { + common.ApiErrorMsg(c, "asset_ids is required") + return + } + + selectedAssets := filterCreativeCenterAssetsByIDs(availableAssets, request.AssetIDs) + if len(selectedAssets) == 0 { + common.ApiErrorMsg(c, "no matching assets found") + return + } + + archive, err := service.CreateCreativeCenterAssetArchive(selectedAssets, buildRequestBaseURL(c)) + if err != nil { + common.ApiError(c, err) + return + } + defer service.CleanupCreativeCenterArchiveFile(archive.FilePath) + + payload, err := service.ReadCreativeCenterArchiveFile(archive.FilePath) + if err != nil { + common.ApiError(c, err) + return + } + + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", archive.DownloadName)) + c.Header("Content-Length", strconv.Itoa(len(payload))) + c.Data(http.StatusOK, "application/zip", payload) +} + +func filterCreativeCenterAssetsByIDs(assets []*dto.CreativeCenterAsset, assetIDs []string) []*dto.CreativeCenterAsset { + if len(assetIDs) == 0 || len(assets) == 0 { + return nil + } + + selected := make(map[string]struct{}, len(assetIDs)) + for _, assetID := range assetIDs { + trimmed := strings.TrimSpace(assetID) + if trimmed == "" { + continue + } + selected[trimmed] = struct{}{} + } + + result := make([]*dto.CreativeCenterAsset, 0, len(selected)) + for _, asset := range assets { + if asset == nil { + continue + } + if _, ok := selected[asset.AssetID]; ok { + result = append(result, asset) + } + } + + return result +} + +func sliceCreativeCenterAssets(assets []*dto.CreativeCenterAsset, startIdx int, pageSize int, includeUsername bool) []*dto.CreativeCenterAsset { + if startIdx < 0 { + startIdx = 0 + } + if startIdx >= len(assets) { + return []*dto.CreativeCenterAsset{} + } + + endIdx := startIdx + pageSize + if endIdx > len(assets) { + endIdx = len(assets) + } + + items := make([]*dto.CreativeCenterAsset, 0, endIdx-startIdx) + for _, asset := range assets[startIdx:endIdx] { + if asset == nil { + continue + } + copyAsset := *asset + if !includeUsername { + copyAsset.Username = "" + } + items = append(items, ©Asset) + } + + return items +} + +func buildRequestBaseURL(c *gin.Context) string { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } else if forwardedProto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" { + scheme = forwardedProto + } + return fmt.Sprintf("%s://%s", scheme, c.Request.Host) +} diff --git a/controller/async_image.go b/controller/async_image.go new file mode 100644 index 000000000000..45063c76fa25 --- /dev/null +++ b/controller/async_image.go @@ -0,0 +1,366 @@ +package controller + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +const ( + asyncImageObject = "image.task" + asyncImagePlatform = constant.TaskPlatform("image") + asyncImageGeneration = "/v1/images/generations" + asyncImageEdits = "/v1/images/edits" + asyncImageTaskQueued = dto.VideoStatusQueued +) + +type asyncImageJob struct { + TaskID string + Action string + RelayPath string + Method string + RawQuery string + Header http.Header + Body []byte + Keys map[string]any +} + +func RelayAsyncImageGenerations(c *gin.Context) { + relayAsyncImage(c, constant.TaskActionImageGenerate, asyncImageGeneration) +} + +func RelayAsyncImageEdits(c *gin.Context) { + relayAsyncImage(c, constant.TaskActionImageEdit, asyncImageEdits) +} + +func RelayAsyncImageFetch(c *gin.Context) { + taskID := strings.TrimSpace(c.Param("task_id")) + if taskID == "" { + respondAsyncImageOpenAIError(c, http.StatusBadRequest, "task_id is required", types.ErrorCodeInvalidRequest) + return + } + + task, exist, err := model.GetByTaskId(c.GetInt("id"), taskID) + if err != nil { + respondAsyncImageOpenAIError(c, http.StatusInternalServerError, err.Error(), types.ErrorCodeQueryDataError) + return + } + if !exist || task == nil { + respondAsyncImageOpenAIError(c, http.StatusNotFound, "task_not_exist", types.ErrorCodeInvalidRequest) + return + } + + c.JSON(http.StatusOK, buildAsyncImageTaskResponse(task)) +} + +func relayAsyncImage(c *gin.Context, action string, relayPath string) { + storage, err := common.GetBodyStorage(c) + if err != nil { + statusCode := http.StatusBadRequest + if common.IsRequestBodyTooLargeError(err) { + statusCode = http.StatusRequestEntityTooLarge + } + respondAsyncImageOpenAIError(c, statusCode, err.Error(), types.ErrorCodeReadRequestBodyFailed) + return + } + bodyBytes, err := storage.Bytes() + if err != nil { + respondAsyncImageOpenAIError(c, http.StatusBadRequest, err.Error(), types.ErrorCodeReadRequestBodyFailed) + return + } + if _, err := storage.Seek(0, io.SeekStart); err != nil { + respondAsyncImageOpenAIError(c, http.StatusBadRequest, err.Error(), types.ErrorCodeReadRequestBodyFailed) + return + } + c.Request.Body = io.NopCloser(storage) + + request, err := helper.GetAndValidateRequest(c, types.RelayFormatOpenAIImage) + if err != nil { + respondAsyncImageOpenAIError(c, http.StatusBadRequest, err.Error(), types.ErrorCodeInvalidRequest) + return + } + + imageReq, ok := request.(*dto.ImageRequest) + if !ok { + respondAsyncImageOpenAIError(c, http.StatusBadRequest, fmt.Sprintf("invalid image request type: %T", request), types.ErrorCodeInvalidRequest) + return + } + relayBodyBytes := bodyBytes + if !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + normalizedBodyBytes, err := common.Marshal(imageReq) + if err != nil { + respondAsyncImageOpenAIError(c, http.StatusBadRequest, err.Error(), types.ErrorCodeInvalidRequest) + return + } + relayBodyBytes = normalizedBodyBytes + } + + task := initAsyncImageTask(c, action, imageReq) + if err := task.Insert(); err != nil { + respondAsyncImageOpenAIError(c, http.StatusInternalServerError, err.Error(), types.ErrorCodeQueryDataError) + return + } + + job := asyncImageJob{ + TaskID: task.TaskID, + Action: action, + RelayPath: relayPath, + Method: c.Request.Method, + Header: c.Request.Header.Clone(), + Body: append([]byte(nil), relayBodyBytes...), + Keys: cloneAsyncImageContextKeys(c), + } + if c.Request != nil && c.Request.URL != nil { + job.RawQuery = c.Request.URL.RawQuery + } + + gopool.Go(func() { + runAsyncImageJob(job) + }) + + c.JSON(http.StatusOK, buildAsyncImageTaskResponse(task)) +} + +func initAsyncImageTask(c *gin.Context, action string, imageReq *dto.ImageRequest) *model.Task { + now := time.Now().Unix() + if requestStartTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime); !requestStartTime.IsZero() { + now = requestStartTime.Unix() + } + modelName := strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyOriginalModel)) + if modelName == "" && imageReq != nil { + modelName = strings.TrimSpace(imageReq.Model) + } + + task := &model.Task{ + TaskID: model.GenerateTaskID(), + Platform: asyncImagePlatform, + UserId: c.GetInt("id"), + Group: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + ChannelId: common.GetContextKeyInt(c, constant.ContextKeyChannelId), + Action: action, + Status: model.TaskStatusSubmitted, + SubmitTime: now, + Progress: taskcommon.ProgressSubmitted, + Properties: model.Properties{ + OriginModelName: modelName, + UpstreamModelName: modelName, + }, + } + if imageReq != nil { + task.Properties.Input = strings.TrimSpace(imageReq.Prompt) + } + task.PrivateData.RequestId = strings.TrimSpace(c.GetString(common.RequestIdKey)) + task.PrivateData.TokenId = c.GetInt("token_id") + return task +} + +func cloneAsyncImageContextKeys(c *gin.Context) map[string]any { + keys := make(map[string]any, len(c.Keys)) + for key, value := range c.Keys { + if key == common.KeyBodyStorage || key == common.KeyRequestBody { + continue + } + keys[key] = value + } + return keys +} + +func runAsyncImageJob(job asyncImageJob) { + task, exist, err := model.GetByOnlyTaskId(job.TaskID) + if err != nil || !exist || task == nil { + if err != nil { + common.SysError("get async image task error: " + err.Error()) + } + return + } + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + targetURL := job.RelayPath + if strings.TrimSpace(job.RawQuery) != "" { + targetURL += "?" + job.RawQuery + } + req := httptest.NewRequest(job.Method, targetURL, bytes.NewReader(job.Body)) + req.Header = job.Header.Clone() + req.ContentLength = int64(len(job.Body)) + if parsedURL, parseErr := url.Parse(targetURL); parseErr == nil { + req.URL = parsedURL + } + ctx.Request = req + for key, value := range job.Keys { + ctx.Set(key, value) + } + ctx.Set(common.RequestIdKey, task.TaskID) + common.SetContextKey(ctx, constant.ContextKeyRequestStartTime, time.Now()) + + bodyStorage, err := common.CreateBodyStorage(job.Body) + if err != nil { + updateAsyncImageTaskFailure(task, nil, err.Error()) + return + } + ctx.Set(common.KeyBodyStorage, bodyStorage) + defer common.CleanupBodyStorage(ctx) + + updateAsyncImageTaskRunning(task) + Relay(ctx, types.RelayFormatOpenAIImage) + + responseBody := recorder.Body.Bytes() + statusCode := recorder.Code + if statusCode == 0 { + statusCode = http.StatusOK + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + updateAsyncImageTaskFailure(task, responseBody, extractPlaygroundTaskErrorMessage(responseBody, "async image request failed")) + return + } + updateAsyncImageTaskSuccess(task, ctx, responseBody) +} + +func updateAsyncImageTaskRunning(task *model.Task) { + if task == nil || task.Status != model.TaskStatusSubmitted { + return + } + task.Status = model.TaskStatusInProgress + task.Progress = taskcommon.ProgressInProgress + task.StartTime = time.Now().Unix() + if err := task.Update(); err != nil { + common.SysError("update async image task running error: " + err.Error()) + } +} + +func updateAsyncImageTaskSuccess(task *model.Task, c *gin.Context, responseBody []byte) { + var imageResponse dto.ImageResponse + if err := common.Unmarshal(responseBody, &imageResponse); err != nil { + updateAsyncImageTaskFailure(task, responseBody, "image result parse failed") + return + } + if len(imageResponse.Data) == 0 { + updateAsyncImageTaskFailure(task, responseBody, "image result is empty") + return + } + + resultURL := "" + for _, item := range imageResponse.Data { + if resultURL = buildPlaygroundImageResultURL(item); resultURL != "" { + break + } + } + if resultURL == "" { + updateAsyncImageTaskFailure(task, responseBody, "image result url is empty") + return + } + + task.Status = model.TaskStatusSuccess + task.Progress = taskcommon.ProgressComplete + task.FinishTime = time.Now().Unix() + task.FailReason = "" + task.PrivateData.ResultURL = resultURL + task.Data = append([]byte(nil), responseBody...) + if c != nil { + if channelID := common.GetContextKeyInt(c, constant.ContextKeyChannelId); channelID > 0 { + task.ChannelId = channelID + } + } + if err := task.Update(); err != nil { + common.SysError("update async image task success error: " + err.Error()) + } +} + +func updateAsyncImageTaskFailure(task *model.Task, responseBody []byte, failReason string) { + if task == nil { + return + } + task.Status = model.TaskStatusFailure + task.Progress = taskcommon.ProgressComplete + task.FinishTime = time.Now().Unix() + task.FailReason = strings.TrimSpace(failReason) + task.PrivateData.ResultURL = "" + if len(responseBody) > 0 { + task.Data = append([]byte(nil), responseBody...) + } + if err := task.Update(); err != nil { + common.SysError("update async image task failure error: " + err.Error()) + } +} + +func buildAsyncImageTaskResponse(task *model.Task) *dto.AsyncImageTaskResponse { + resp := &dto.AsyncImageTaskResponse{ + ID: task.TaskID, + TaskID: task.TaskID, + Object: asyncImageObject, + Model: task.Properties.OriginModelName, + Status: mapAsyncImageStatus(task.Status), + Progress: parseAsyncImageProgress(task.Progress), + CreatedAt: task.SubmitTime, + CompletedAt: task.FinishTime, + ResultURL: task.PrivateData.ResultURL, + } + if task.Status == model.TaskStatusFailure { + resp.Error = &dto.AsyncImageTaskError{ + Message: strings.TrimSpace(task.FailReason), + Code: string(types.ErrorCodeBadResponse), + } + } + if task.Status == model.TaskStatusSuccess && len(task.Data) > 0 { + var imageResponse dto.ImageResponse + if err := common.Unmarshal(task.Data, &imageResponse); err == nil { + resp.Data = imageResponse.Data + } + } + return resp +} + +func mapAsyncImageStatus(status model.TaskStatus) string { + switch status { + case model.TaskStatusSuccess: + return dto.VideoStatusCompleted + case model.TaskStatusFailure: + return dto.VideoStatusFailed + case model.TaskStatusInProgress: + return dto.VideoStatusInProgress + case model.TaskStatusQueued, model.TaskStatusSubmitted, model.TaskStatusNotStart: + return asyncImageTaskQueued + default: + return dto.VideoStatusUnknown + } +} + +func parseAsyncImageProgress(progress string) int { + trimmed := strings.TrimSpace(strings.TrimSuffix(progress, "%")) + if trimmed == "" { + return 0 + } + parsed, err := strconv.Atoi(trimmed) + if err != nil { + return 0 + } + return parsed +} + +func respondAsyncImageOpenAIError(c *gin.Context, statusCode int, message string, code any) { + c.JSON(statusCode, gin.H{ + "error": types.OpenAIError{ + Message: strings.TrimSpace(message), + Type: string(types.ErrorTypeNewAPIError), + Param: "", + Code: code, + }, + }) +} diff --git a/controller/async_video.go b/controller/async_video.go new file mode 100644 index 000000000000..e741deeb3fdd --- /dev/null +++ b/controller/async_video.go @@ -0,0 +1,330 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +const ( + asyncVideoObject = "video" + asyncVideoGeneration = "/v1/video/generations" + relayTaskPublicTaskIDContextKey = "relay_task_public_task_id" +) + +type asyncVideoJob struct { + TaskID string + Path string + Method string + RawQuery string + Header http.Header + Body []byte + Keys map[string]any +} + +func RelayAsyncVideoGenerations(c *gin.Context) { + storage, err := common.GetBodyStorage(c) + if err != nil { + statusCode := http.StatusBadRequest + if common.IsRequestBodyTooLargeError(err) { + statusCode = http.StatusRequestEntityTooLarge + } + respondAsyncVideoOpenAIError(c, statusCode, err.Error(), types.ErrorCodeReadRequestBodyFailed) + return + } + bodyBytes, err := storage.Bytes() + if err != nil { + respondAsyncVideoOpenAIError(c, http.StatusBadRequest, err.Error(), types.ErrorCodeReadRequestBodyFailed) + return + } + + req := readAsyncVideoTaskRequest(c, bodyBytes) + task := initAsyncVideoTask(c, req) + if err := task.Insert(); err != nil { + respondAsyncVideoOpenAIError(c, http.StatusInternalServerError, err.Error(), types.ErrorCodeQueryDataError) + return + } + + job := asyncVideoJob{ + TaskID: task.TaskID, + Path: asyncVideoGeneration, + Method: c.Request.Method, + Header: c.Request.Header.Clone(), + Body: append([]byte(nil), bodyBytes...), + Keys: cloneAsyncImageContextKeys(c), + } + if c.Request != nil && c.Request.URL != nil { + job.RawQuery = c.Request.URL.RawQuery + } + + gopool.Go(func() { + runAsyncVideoJob(job) + }) + + c.JSON(http.StatusOK, buildAsyncVideoTaskResponse(task)) +} + +func RelayAsyncVideoFetch(c *gin.Context) { + taskID := strings.TrimSpace(c.Param("task_id")) + if taskID == "" { + respondAsyncVideoOpenAIError(c, http.StatusBadRequest, "task_id is required", types.ErrorCodeInvalidRequest) + return + } + task, exist, err := model.GetByTaskId(c.GetInt("id"), taskID) + if err != nil { + respondAsyncVideoOpenAIError(c, http.StatusInternalServerError, err.Error(), types.ErrorCodeQueryDataError) + return + } + if !exist || task == nil { + respondAsyncVideoOpenAIError(c, http.StatusNotFound, "task_not_exist", types.ErrorCodeInvalidRequest) + return + } + if shouldRefreshAsyncVideoTask(task) { + if err := service.RefreshVideoTask(c.Request.Context(), task); err != nil { + common.SysLog("refresh async video task failed: " + err.Error()) + } + task, exist, err = model.GetByTaskId(c.GetInt("id"), taskID) + if err != nil { + respondAsyncVideoOpenAIError(c, http.StatusInternalServerError, err.Error(), types.ErrorCodeQueryDataError) + return + } + if !exist || task == nil { + respondAsyncVideoOpenAIError(c, http.StatusNotFound, "task_not_exist", types.ErrorCodeInvalidRequest) + return + } + } + c.JSON(http.StatusOK, buildAsyncVideoTaskResponse(task)) +} + +func shouldRefreshAsyncVideoTask(task *model.Task) bool { + if task == nil { + return false + } + if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure { + return false + } + if task.ChannelId <= 0 { + return false + } + return strings.TrimSpace(task.PrivateData.UpstreamTaskID) != "" +} + +func readAsyncVideoTaskRequest(c *gin.Context, bodyBytes []byte) relaycommon.TaskSubmitReq { + var req relaycommon.TaskSubmitReq + if strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") && len(bodyBytes) > 0 { + _ = common.Unmarshal(bodyBytes, &req) + return req + } + if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { + if form, err := common.ParseMultipartFormReusable(c); err == nil && form != nil { + req.Prompt = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "prompt")) + req.Model = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "model")) + req.Image = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "image")) + req.ImageURL = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "image_url")) + req.Size = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "size")) + req.Seconds = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "seconds")) + if duration, err := strconv.Atoi(strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "duration"))); err == nil { + req.Duration = duration + } + if images := form.Value["images"]; len(images) > 0 { + req.Images = images + } + } + } + return req +} + +func firstAsyncVideoFormValue(values map[string][]string, key string) string { + if len(values[key]) == 0 { + return "" + } + return values[key][0] +} + +func initAsyncVideoTask(c *gin.Context, req relaycommon.TaskSubmitReq) *model.Task { + now := time.Now().Unix() + if requestStartTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime); !requestStartTime.IsZero() { + now = requestStartTime.Unix() + } + modelName := strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyOriginalModel)) + if modelName == "" { + modelName = strings.TrimSpace(req.Model) + } + + action := constant.TaskActionTextGenerate + if req.HasImage() { + action = constant.TaskActionGenerate + } + + platform := constant.TaskPlatform("") + if channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType); channelType > 0 { + platform = constant.TaskPlatform(strconv.Itoa(channelType)) + } + + task := &model.Task{ + TaskID: model.GenerateTaskID(), + Platform: platform, + UserId: c.GetInt("id"), + Group: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + ChannelId: common.GetContextKeyInt(c, constant.ContextKeyChannelId), + Action: action, + Status: model.TaskStatusSubmitted, + SubmitTime: now, + Progress: taskcommon.ProgressSubmitted, + Properties: model.Properties{ + Input: strings.TrimSpace(req.Prompt), + OriginModelName: modelName, + UpstreamModelName: modelName, + }, + } + task.PrivateData.RequestId = strings.TrimSpace(c.GetString(common.RequestIdKey)) + if clientRequestID := strings.TrimSpace(req.RequestId); clientRequestID != "" { + task.PrivateData.ClientRequestId = clientRequestID + } else if clientRequestID := strings.TrimSpace(c.GetHeader("X-Request-Id")); clientRequestID != "" { + task.PrivateData.ClientRequestId = clientRequestID + } + task.PrivateData.TokenId = c.GetInt("token_id") + return task +} + +func runAsyncVideoJob(job asyncVideoJob) { + task, exist, err := model.GetByOnlyTaskId(job.TaskID) + if err != nil || !exist || task == nil { + if err != nil { + common.SysError("get async video task error: " + err.Error()) + } + return + } + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + targetURL := job.Path + if strings.TrimSpace(job.RawQuery) != "" { + targetURL += "?" + job.RawQuery + } + req := httptest.NewRequest(job.Method, targetURL, bytes.NewReader(job.Body)) + req.Header = job.Header.Clone() + req.ContentLength = int64(len(job.Body)) + if parsedURL, parseErr := url.Parse(targetURL); parseErr == nil { + req.URL = parsedURL + } + ctx.Request = req + for key, value := range job.Keys { + ctx.Set(key, value) + } + ctx.Set(common.RequestIdKey, task.TaskID) + ctx.Set(relayTaskPublicTaskIDContextKey, task.TaskID) + common.SetContextKey(ctx, constant.ContextKeyRequestStartTime, time.Now()) + + bodyStorage, err := common.CreateBodyStorage(job.Body) + if err != nil { + updateAsyncVideoTaskFailure(task, nil, err.Error()) + return + } + ctx.Set(common.KeyBodyStorage, bodyStorage) + defer common.CleanupBodyStorage(ctx) + + updateAsyncVideoTaskRunning(task) + RelayTask(ctx) + + responseBody := recorder.Body.Bytes() + statusCode := recorder.Code + if statusCode == 0 { + statusCode = http.StatusOK + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + updateAsyncVideoTaskFailure(task, responseBody, extractPlaygroundTaskErrorMessage(responseBody, "async video request failed")) + } +} + +func updateAsyncVideoTaskFailure(task *model.Task, responseBody []byte, failReason string) { + if task == nil { + return + } + task.Status = model.TaskStatusFailure + task.Progress = taskcommon.ProgressComplete + task.FinishTime = time.Now().Unix() + task.FailReason = strings.TrimSpace(failReason) + task.PrivateData.ResultURL = "" + if len(responseBody) > 0 { + task.Data = append([]byte(nil), responseBody...) + } + if err := task.Update(); err != nil { + common.SysError("update async video task failure error: " + err.Error()) + } +} + +func updateAsyncVideoTaskRunning(task *model.Task) { + if task == nil || task.Status != model.TaskStatusSubmitted { + return + } + task.Status = model.TaskStatusInProgress + task.Progress = taskcommon.ProgressInProgress + task.StartTime = time.Now().Unix() + if err := task.Update(); err != nil { + common.SysError("update async video task running error: " + err.Error()) + } +} + +func buildAsyncVideoTaskResponse(task *model.Task) *dto.AsyncVideoTaskResponse { + resp := &dto.AsyncVideoTaskResponse{ + ID: task.TaskID, + TaskID: task.TaskID, + Object: asyncVideoObject, + Model: task.Properties.OriginModelName, + Status: task.Status.ToVideoStatus(), + URL: strings.TrimSpace(task.PrivateData.ResultURL), + Progress: parseAsyncImageProgress(task.Progress), + CreatedAt: task.SubmitTime, + CompletedAt: task.FinishTime, + } + + if len(task.Data) > 0 { + if resp.URL == "" { + for _, path := range []string{"url", "video_url", "data.0.url", "data.0.video_url"} { + if url := strings.TrimSpace(gjson.GetBytes(task.Data, path).String()); url != "" { + resp.URL = url + break + } + } + } + resp.Seconds = strings.TrimSpace(gjson.GetBytes(task.Data, "seconds").String()) + resp.Size = strings.TrimSpace(gjson.GetBytes(task.Data, "size").String()) + } + + if task.Status == model.TaskStatusFailure { + resp.Error = &dto.AsyncVideoTaskError{ + Message: strings.TrimSpace(task.FailReason), + Code: string(types.ErrorCodeBadResponse), + } + } + return resp +} + +func respondAsyncVideoOpenAIError(c *gin.Context, statusCode int, message string, code any) { + c.JSON(statusCode, gin.H{ + "error": types.OpenAIError{ + Message: strings.TrimSpace(message), + Type: string(types.ErrorTypeNewAPIError), + Param: "", + Code: code, + }, + }) +} diff --git a/controller/async_video_test.go b/controller/async_video_test.go new file mode 100644 index 000000000000..017635e35b4f --- /dev/null +++ b/controller/async_video_test.go @@ -0,0 +1,127 @@ +package controller + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + + "github.com/gin-gonic/gin" +) + +func TestShouldRefreshAsyncVideoTask(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + task *model.Task + want bool + }{ + { + name: "nil task", + task: nil, + want: false, + }, + { + name: "terminal success task does not refresh", + task: &model.Task{ + Status: model.TaskStatusSuccess, + ChannelId: 1, + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: "upstream-task", + }, + }, + want: false, + }, + { + name: "missing channel does not refresh", + task: &model.Task{ + Status: model.TaskStatusInProgress, + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: "upstream-task", + }, + }, + want: false, + }, + { + name: "missing upstream id does not refresh", + task: &model.Task{ + Status: model.TaskStatusInProgress, + ChannelId: 1, + }, + want: false, + }, + { + name: "in progress task with upstream id refreshes", + task: &model.Task{ + Status: model.TaskStatusInProgress, + ChannelId: 1, + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: "upstream-task", + }, + }, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := shouldRefreshAsyncVideoTask(tt.task); got != tt.want { + t.Fatalf("shouldRefreshAsyncVideoTask() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestInitAsyncVideoTaskStoresClientRequestID(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + bodyRequestID string + headerID string + want string + }{ + { + name: "body request id wins", + bodyRequestID: "creative-request-body", + headerID: "creative-request-header", + want: "creative-request-body", + }, + { + name: "header fallback", + headerID: "creative-request-header", + want: "creative-request-header", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/video/async-generations", nil) + c.Set(common.RequestIdKey, "internal-request-id") + if tt.headerID != "" { + c.Request.Header.Set("X-Request-Id", tt.headerID) + } + + task := initAsyncVideoTask(c, relaycommon.TaskSubmitReq{ + Model: "sora2", + Prompt: "make a short video", + RequestId: tt.bodyRequestID, + }) + + if got := task.PrivateData.ClientRequestId; got != tt.want { + t.Fatalf("ClientRequestId = %q, want %q", got, tt.want) + } + if got := task.PrivateData.RequestId; got != "internal-request-id" { + t.Fatalf("RequestId = %q, want internal request id", got) + } + }) + } +} diff --git a/controller/creative_center_history.go b/controller/creative_center_history.go new file mode 100644 index 000000000000..98ffdb9e298c --- /dev/null +++ b/controller/creative_center_history.go @@ -0,0 +1,93 @@ +package controller + +import ( + "encoding/json" + "fmt" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +type CreativeCenterHistorySaveRequest struct { + Tab string `json:"tab"` + ModelName string `json:"model_name"` + Group string `json:"group"` + Prompt string `json:"prompt"` + Payload json.RawMessage `json:"payload"` +} + +func GetCreativeCenterHistory(c *gin.Context) { + userId := c.GetInt("id") + histories, err := model.ListCreativeCenterHistoriesByUser(userId) + if err != nil { + common.ApiError(c, err) + return + } + + result := make(map[string]any) + for _, history := range histories { + var payload any + if history.Payload != "" { + if err := common.UnmarshalJsonStr(string(history.Payload), &payload); err != nil { + payload = nil + } + } + result[history.Tab] = gin.H{ + "id": history.ID, + "tab": history.Tab, + "model_name": history.ModelName, + "group": history.Group, + "prompt": history.Prompt, + "payload": payload, + "created_at": history.CreatedAt, + "updated_at": history.UpdatedAt, + } + } + + common.ApiSuccess(c, result) +} + +func SaveCreativeCenterHistory(c *gin.Context) { + userId := c.GetInt("id") + var req CreativeCenterHistorySaveRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + if err := model.ValidateCreativeCenterTab(req.Tab); err != nil { + common.ApiError(c, err) + return + } + if len(req.Payload) == 0 { + common.ApiError(c, fmt.Errorf("payload is required")) + return + } + + history, err := model.UpsertCreativeCenterHistory( + userId, + req.Tab, + req.ModelName, + req.Group, + req.Prompt, + req.Payload, + ) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, gin.H{ + "id": history.ID, + "updated_at": history.UpdatedAt, + }) +} + +func DeleteCreativeCenterHistory(c *gin.Context) { + userId := c.GetInt("id") + tab := c.Param("tab") + if err := model.DeleteCreativeCenterHistory(userId, tab); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/creative_center_upload.go b/controller/creative_center_upload.go new file mode 100644 index 000000000000..5ee8c2a4efd5 --- /dev/null +++ b/controller/creative_center_upload.go @@ -0,0 +1,489 @@ +package controller + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + neturl "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" +) + +const creativeCenterImageUploadMaxBytes int64 = 10 << 20 + +var creativeCenterImageExtByMime = map[string]string{ + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + +type creativeCenterExternalUploadItem struct { + Src string `json:"src"` +} + +type creativeCenterExternalWrappedUploadResp struct { + Data []creativeCenterExternalUploadItem `json:"data"` +} + +func GetCreativeCenterImageUploadConfig(c *gin.Context) { + if !system_setting.EnableCreativeCenterImageBed() { + common.ApiSuccess(c, gin.H{ + "mode": "backend", + }) + return + } + + common.ApiSuccess(c, gin.H{ + "mode": "direct", + "upload_url": strings.TrimRight(strings.TrimSpace(system_setting.CreativeCenterImageBedURL), "/"), + "api_key": strings.TrimSpace(system_setting.CreativeCenterImageBedApiKey), + "auto_retry": true, + "return_type": "full", + }) +} + +func UploadCreativeCenterImage(c *gin.Context) { + if system_setting.EnableCreativeCenterImageBed() { + uploaded, err := uploadCreativeCenterImageToExternalBed(c) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, uploaded) + return + } + + fileHeader, err := c.FormFile("file") + if err != nil { + common.ApiErrorMsg(c, "请选择要上传的图片") + return + } + if fileHeader.Size <= 0 { + common.ApiErrorMsg(c, "图片文件不能为空") + return + } + if fileHeader.Size > creativeCenterImageUploadMaxBytes { + common.ApiErrorMsg(c, "图片大小不能超过 10MB") + return + } + + src, err := fileHeader.Open() + if err != nil { + common.ApiError(c, err) + return + } + defer src.Close() + + head := make([]byte, 512) + headSize, err := io.ReadFull(src, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + common.ApiError(c, err) + return + } + + contentType := http.DetectContentType(head[:headSize]) + ext, ok := creativeCenterImageExtByMime[contentType] + if !ok { + common.ApiErrorMsg(c, "仅支持 PNG、JPG、WEBP、GIF 图片") + return + } + + uploadDir := creativeCenterImageUploadDir() + if err = os.MkdirAll(uploadDir, 0o755); err != nil { + common.ApiError(c, err) + return + } + + tempFile, err := os.CreateTemp(uploadDir, "creative-center-image-*") + if err != nil { + common.ApiError(c, err) + return + } + + tempFilePath := tempFile.Name() + tempFileClosed := false + defer func() { + if !tempFileClosed { + _ = tempFile.Close() + } + if tempFilePath != "" { + _ = os.Remove(tempFilePath) + } + }() + + hasher := sha256.New() + writer := io.MultiWriter(tempFile, hasher) + reader := io.MultiReader(bytes.NewReader(head[:headSize]), src) + + if _, err = io.Copy(writer, reader); err != nil { + common.ApiError(c, err) + return + } + if err = tempFile.Close(); err != nil { + common.ApiError(c, err) + return + } + tempFileClosed = true + + fileHash := hex.EncodeToString(hasher.Sum(nil)) + fileName := fileHash + ext + finalPath := filepath.Join(uploadDir, fileName) + + if _, statErr := os.Stat(finalPath); statErr == nil { + _ = os.Remove(tempFilePath) + tempFilePath = "" + } else if errors.Is(statErr, os.ErrNotExist) { + if err = os.Rename(tempFilePath, finalPath); err != nil { + common.ApiError(c, err) + return + } + tempFilePath = "" + } else { + common.ApiError(c, statErr) + return + } + + publicPath := fmt.Sprintf("/api/public/creative-center/image/%s", fileName) + common.ApiSuccess(c, gin.H{ + "url": buildCreativeCenterImageAbsoluteURL(c, publicPath), + "name": fileHeader.Filename, + "filename": fileName, + "content_type": contentType, + "size": fileHeader.Size, + }) +} + +func GetCreativeCenterUploadedImage(c *gin.Context) { + fileName := filepath.Base(strings.TrimSpace(c.Param("filename"))) + if fileName == "" || fileName == "." || fileName != strings.TrimSpace(c.Param("filename")) { + c.Status(http.StatusNotFound) + return + } + + filePath := filepath.Join(creativeCenterImageUploadDir(), fileName) + if _, err := os.Stat(filePath); err != nil { + c.Status(http.StatusNotFound) + return + } + + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.File(filePath) +} + +func ProxyCreativeCenterRemoteImage(c *gin.Context) { + targetURL := strings.TrimSpace(c.Query("url")) + if targetURL == "" { + c.Status(http.StatusBadRequest) + return + } + if !strings.HasPrefix(targetURL, "https://") && !strings.HasPrefix(targetURL, "http://") { + c.Status(http.StatusBadRequest) + return + } + + resp, err := service.DoDownloadRequest(targetURL, "creative center image proxy") + if err != nil { + c.Status(http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + c.Status(http.StatusBadGateway) + return + } + + head := make([]byte, 512) + headSize, err := io.ReadFull(resp.Body, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + c.Status(http.StatusBadGateway) + return + } + + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" || !strings.HasPrefix(strings.ToLower(contentType), "image/") { + contentType = http.DetectContentType(head[:headSize]) + } + if !strings.HasPrefix(strings.ToLower(contentType), "image/") { + c.Status(http.StatusUnsupportedMediaType) + return + } + + c.Header("Cache-Control", "private, max-age=300") + c.Header("Content-Disposition", "inline") + c.DataFromReader( + http.StatusOK, + -1, + contentType, + io.MultiReader(bytes.NewReader(head[:headSize]), resp.Body), + nil, + ) +} + +func DownloadCreativeCenterRemoteMedia(c *gin.Context) { + targetURL := strings.TrimSpace(c.Query("url")) + if targetURL == "" { + c.Status(http.StatusBadRequest) + return + } + if !strings.HasPrefix(targetURL, "https://") && !strings.HasPrefix(targetURL, "http://") { + c.Status(http.StatusBadRequest) + return + } + + resp, err := service.DoDownloadRequest(targetURL, "creative center media download") + if err != nil { + c.Status(http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + c.Status(http.StatusBadGateway) + return + } + + fileName := resolveCreativeCenterDownloadFilename(targetURL, strings.TrimSpace(c.Query("filename"))) + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/octet-stream" + } + + c.Header("Cache-Control", "private, no-store") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", neturl.QueryEscape(fileName))) + c.DataFromReader( + http.StatusOK, + resp.ContentLength, + contentType, + resp.Body, + nil, + ) +} + +func resolveCreativeCenterDownloadFilename(targetURL string, candidate string) string { + fileName := sanitizeCreativeCenterDownloadFilename(candidate) + if fileName != "" { + return fileName + } + + parsedURL, err := neturl.Parse(targetURL) + if err == nil { + fileName = sanitizeCreativeCenterDownloadFilename(path.Base(parsedURL.Path)) + if fileName != "" && fileName != "." { + return fileName + } + } + + return "creative-center-asset.bin" +} + +func sanitizeCreativeCenterDownloadFilename(candidate string) string { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + return "" + } + + trimmed = strings.ReplaceAll(trimmed, "\\", "/") + trimmed = path.Base(trimmed) + trimmed = strings.Map(func(r rune) rune { + switch r { + case '\r', '\n', 0: + return -1 + default: + return r + } + }, trimmed) + trimmed = strings.TrimSpace(trimmed) + if trimmed == "" || trimmed == "." { + return "" + } + + ext := strings.TrimSpace(path.Ext(trimmed)) + name := strings.TrimSpace(strings.TrimSuffix(trimmed, ext)) + if ext != "" { + normalizedExt := mime.TypeByExtension(ext) + if normalizedExt == "" { + ext = "" + } + } + + if name == "" { + name = "creative-center-asset" + } + if ext == "" { + return name + } + return name + ext +} + +func creativeCenterImageUploadDir() string { + return filepath.Join("data", "uploads", "creative-center") +} + +func buildCreativeCenterImageAbsoluteURL(c *gin.Context, publicPath string) string { + baseURL := strings.TrimSuffix(strings.TrimSpace(system_setting.ServerAddress), "/") + if baseURL != "" { + return baseURL + publicPath + } + + scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")) + if scheme == "" { + if c.Request.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + + host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")) + if host == "" { + host = c.Request.Host + } + + return fmt.Sprintf("%s://%s%s", scheme, host, publicPath) +} + +func uploadCreativeCenterImageToExternalBed(c *gin.Context) (gin.H, error) { + fileHeader, err := c.FormFile("file") + if err != nil { + return nil, fmt.Errorf("请选择要上传的图片") + } + if fileHeader.Size <= 0 { + return nil, fmt.Errorf("图片文件不能为空") + } + if fileHeader.Size > creativeCenterImageUploadMaxBytes { + return nil, fmt.Errorf("图片大小不能超过 10MB") + } + + src, err := fileHeader.Open() + if err != nil { + return nil, err + } + defer src.Close() + + uploadURL := strings.TrimRight(strings.TrimSpace(system_setting.CreativeCenterImageBedURL), "/") + "/upload" + uploadToken := strings.TrimSpace(system_setting.CreativeCenterImageBedApiKey) + + req, contentType, err := buildCreativeCenterExternalUploadRequest(uploadURL, uploadToken, fileHeader.Filename, src) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + + httpClient := service.GetHttpClient() + if httpClient == nil { + httpClient = http.DefaultClient + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("图床上传失败,状态码 %d:%s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + imageURL, err := parseCreativeCenterExternalUploadURL(uploadURL, respBody) + if err != nil { + return nil, err + } + + return gin.H{ + "url": imageURL, + "name": fileHeader.Filename, + "filename": filepath.Base(imageURL), + "size": fileHeader.Size, + }, nil +} + +func buildCreativeCenterExternalUploadRequest(uploadURL string, uploadToken string, fileName string, file io.Reader) (*http.Request, string, error) { + pipeReader, pipeWriter := io.Pipe() + writer := multipart.NewWriter(pipeWriter) + + go func() { + defer pipeWriter.Close() + defer writer.Close() + + part, err := writer.CreateFormFile("file", fileName) + if err != nil { + _ = pipeWriter.CloseWithError(err) + return + } + if _, err = io.Copy(part, file); err != nil { + _ = pipeWriter.CloseWithError(err) + return + } + }() + + queryPrefix := "?" + if strings.Contains(uploadURL, "?") { + queryPrefix = "&" + } + requestURL := uploadURL + queryPrefix + "returnFormat=full&autoRetry=true" + + req, err := http.NewRequest(http.MethodPost, requestURL, pipeReader) + if err != nil { + return nil, "", err + } + req.Header.Set("Authorization", "Bearer "+uploadToken) + req.Header.Set("User-Agent", "new-api creative-center uploader") + return req, writer.FormDataContentType(), nil +} + +func parseCreativeCenterExternalUploadURL(uploadURL string, respBody []byte) (string, error) { + var directItems []creativeCenterExternalUploadItem + if err := common.Unmarshal(respBody, &directItems); err == nil { + if imageURL := normalizeCreativeCenterExternalImageURL(uploadURL, directItems); imageURL != "" { + return imageURL, nil + } + } + + var wrapped creativeCenterExternalWrappedUploadResp + if err := common.Unmarshal(respBody, &wrapped); err == nil { + if imageURL := normalizeCreativeCenterExternalImageURL(uploadURL, wrapped.Data); imageURL != "" { + return imageURL, nil + } + } + + return "", fmt.Errorf("图床上传成功但未返回可用图片链接") +} + +func normalizeCreativeCenterExternalImageURL(uploadURL string, items []creativeCenterExternalUploadItem) string { + if len(items) == 0 { + return "" + } + + src := strings.TrimSpace(items[0].Src) + if src == "" { + return "" + } + if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") { + return src + } + + baseURL := strings.TrimSuffix(uploadURL, "/upload") + baseURL = strings.TrimRight(baseURL, "/") + if strings.HasPrefix(src, "/") { + return baseURL + src + } + return baseURL + "/" + src +} diff --git a/controller/playground.go b/controller/playground.go index 501c4e156573..808198f76031 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -1,17 +1,649 @@ package controller import ( + "bytes" + "encoding/json" "errors" "fmt" + "regexp" + "strings" + "time" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) +type playgroundChatRequestMeta struct { + ModelName string + HasVisualInput bool + IsStream bool + Prompt string +} + +var ( + playgroundChatImageModels = map[string]struct{}{ + "nano-banana": {}, + "nano-banana2": {}, + "nano-banana-pro": {}, + } + playgroundChatVideoModels = map[string]struct{}{ + "sora2": {}, + "sora2-pro": {}, + "veo31": {}, + "veo31-ref": {}, + "veo31-fast": {}, + } + playgroundHTMLVideoURLPattern = regexp.MustCompile(`]+src=['"]([^'"]+)['"]`) + playgroundMarkdownURLPattern = regexp.MustCompile(`\((https?://[^)\s]+)\)`) + playgroundPlainURLPattern = regexp.MustCompile(`https?://[^\s'"]+`) + playgroundImageURLPattern = regexp.MustCompile(`https?://[^\s'"]+\.(?:png|jpe?g|webp|gif)(?:\?[^\s'"]*)?`) + playgroundVideoURLPattern = regexp.MustCompile(`https?://[^\s'"]+\.(?:mp4|mov|webm|m3u8)(?:\?[^\s'"]*)?`) +) + +type playgroundBodyCaptureWriter struct { + gin.ResponseWriter + body bytes.Buffer +} + +func (w *playgroundBodyCaptureWriter) Write(data []byte) (int, error) { + if len(data) > 0 { + _, _ = w.body.Write(data) + } + return w.ResponseWriter.Write(data) +} + +func (w *playgroundBodyCaptureWriter) WriteString(value string) (int, error) { + if value != "" { + _, _ = w.body.WriteString(value) + } + return w.ResponseWriter.WriteString(value) +} + +func buildPlaygroundImageResultURL(item dto.ImageData) string { + if strings.TrimSpace(item.Url) != "" { + return strings.TrimSpace(item.Url) + } + if strings.TrimSpace(item.PresignedURL) != "" { + return strings.TrimSpace(item.PresignedURL) + } + if strings.TrimSpace(item.PresignedURLAlt) != "" { + return strings.TrimSpace(item.PresignedURLAlt) + } + if strings.TrimSpace(item.B64Json) != "" { + return "data:image/png;base64," + strings.TrimSpace(item.B64Json) + } + return "" +} + +func extractPlaygroundPromptFromMessageContent(content any) string { + switch typedContent := content.(type) { + case string: + return strings.TrimSpace(typedContent) + case []any: + promptParts := make([]string, 0, len(typedContent)) + for _, item := range typedContent { + itemPayload, ok := item.(map[string]any) + if !ok { + continue + } + textValue := strings.TrimSpace(common.Interface2String(itemPayload["text"])) + if textValue == "" { + textValue = strings.TrimSpace(common.Interface2String(itemPayload["content"])) + } + if textValue != "" { + promptParts = append(promptParts, textValue) + } + } + return strings.TrimSpace(strings.Join(promptParts, "\n")) + default: + return "" + } +} + +func readPlaygroundRequestPrompt(c *gin.Context) string { + storage, err := common.GetBodyStorage(c) + if err != nil { + return "" + } + bodyBytes, err := storage.Bytes() + if err != nil || len(bodyBytes) == 0 { + return "" + } + + var payload map[string]any + if err := common.Unmarshal(bodyBytes, &payload); err != nil { + return "" + } + + if prompt := strings.TrimSpace(common.Interface2String(payload["prompt"])); prompt != "" { + return prompt + } + if inputPrompt := strings.TrimSpace(common.Interface2String(payload["input"])); inputPrompt != "" { + return inputPrompt + } + if messages, ok := payload["messages"].([]any); ok { + promptParts := make([]string, 0, len(messages)) + for _, message := range messages { + messagePayload, ok := message.(map[string]any) + if !ok { + continue + } + messagePrompt := extractPlaygroundPromptFromMessageContent(messagePayload["content"]) + if messagePrompt != "" { + promptParts = append(promptParts, messagePrompt) + } + } + return strings.TrimSpace(strings.Join(promptParts, "\n")) + } + + return "" +} + +func buildPlaygroundMediaTaskModelName(c *gin.Context, modelName string) string { + resolvedModelName := strings.TrimSpace(modelName) + if resolvedModelName == "" { + resolvedModelName = common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + } + return resolvedModelName +} + +func getPlaygroundMediaTaskStartTime(c *gin.Context) int64 { + startTime := time.Now().Unix() + if requestStartTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime); !requestStartTime.IsZero() { + startTime = requestStartTime.Unix() + } + return startTime +} + +func createPendingPlaygroundMediaTask(c *gin.Context, action string, modelName string) string { + if action == "" { + return "" + } + + resolvedModelName := buildPlaygroundMediaTaskModelName(c, modelName) + startTime := getPlaygroundMediaTaskStartTime(c) + task := &model.Task{ + TaskID: model.GenerateTaskID(), + UserId: c.GetInt(string(constant.ContextKeyUserId)), + Group: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + ChannelId: common.GetContextKeyInt(c, constant.ContextKeyChannelId), + Action: action, + Status: model.TaskStatusSubmitted, + SubmitTime: startTime, + Progress: taskcommon.ProgressSubmitted, + Properties: model.Properties{ + OriginModelName: resolvedModelName, + UpstreamModelName: resolvedModelName, + Input: readPlaygroundRequestPrompt(c), + }, + } + if err := task.Insert(); err != nil { + common.SysError("insert playground media task error: " + err.Error()) + return "" + } + return task.TaskID +} + +func updatePlaygroundMediaTask(c *gin.Context, taskID string, action string, modelName string, responseBody []byte, resultURL string, failReason string) { + if strings.TrimSpace(taskID) == "" || action == "" { + return + } + + task, exist, err := model.GetByOnlyTaskId(strings.TrimSpace(taskID)) + if err != nil { + common.SysError("get playground media task error: " + err.Error()) + return + } + if !exist || task == nil { + return + } + + task.Action = action + task.Properties.OriginModelName = buildPlaygroundMediaTaskModelName(c, modelName) + task.Properties.UpstreamModelName = task.Properties.OriginModelName + if task.Properties.Input == "" { + task.Properties.Input = readPlaygroundRequestPrompt(c) + } + if len(responseBody) > 0 { + task.Data = json.RawMessage(responseBody) + } + + now := time.Now().Unix() + startTime := getPlaygroundMediaTaskStartTime(c) + if task.SubmitTime == 0 { + task.SubmitTime = startTime + } + if task.StartTime == 0 { + task.StartTime = startTime + } + + if strings.TrimSpace(failReason) != "" { + task.Status = model.TaskStatusFailure + task.Progress = taskcommon.ProgressComplete + task.FinishTime = now + task.FailReason = strings.TrimSpace(failReason) + task.PrivateData.ResultURL = "" + } else if strings.TrimSpace(resultURL) != "" { + task.Status = model.TaskStatusSuccess + task.Progress = taskcommon.ProgressComplete + task.FinishTime = now + task.FailReason = "" + task.PrivateData.ResultURL = strings.TrimSpace(resultURL) + } else { + task.Status = model.TaskStatusSubmitted + task.Progress = taskcommon.ProgressSubmitted + } + + if updateErr := task.Update(); updateErr != nil { + common.SysError("update playground media task error: " + updateErr.Error()) + } +} + +func extractPlaygroundTaskErrorMessage(responseBody []byte, fallback string) string { + message := strings.TrimSpace(fallback) + if len(responseBody) == 0 { + return message + } + + var errorResponse dto.GeneralErrorResponse + if err := common.Unmarshal(responseBody, &errorResponse); err == nil { + if parsed := strings.TrimSpace(errorResponse.ToMessage()); parsed != "" { + return parsed + } + } + + bodyMessage := strings.TrimSpace(string(responseBody)) + if bodyMessage != "" { + return bodyMessage + } + return message +} + +func recordPlaygroundImageTask(c *gin.Context, taskID string, action string, responseBody []byte) { + if len(responseBody) == 0 { + updatePlaygroundMediaTask(c, taskID, action, common.GetContextKeyString(c, constant.ContextKeyOriginalModel), nil, "", "未获取到图片结果") + return + } + + var imageResponse dto.ImageResponse + if err := common.Unmarshal(responseBody, &imageResponse); err != nil { + updatePlaygroundMediaTask(c, taskID, action, common.GetContextKeyString(c, constant.ContextKeyOriginalModel), responseBody, "", "图片结果解析失败") + return + } + if len(imageResponse.Data) == 0 { + updatePlaygroundMediaTask(c, taskID, action, common.GetContextKeyString(c, constant.ContextKeyOriginalModel), responseBody, "", "未获取到图片结果") + return + } + + resultURL := "" + for _, item := range imageResponse.Data { + resultURL = buildPlaygroundImageResultURL(item) + if resultURL != "" { + break + } + } + if resultURL == "" { + updatePlaygroundMediaTask(c, taskID, action, common.GetContextKeyString(c, constant.ContextKeyOriginalModel), responseBody, "", "未获取到图片结果") + return + } + + updatePlaygroundMediaTask( + c, + taskID, + action, + common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + responseBody, + resultURL, + "", + ) +} + +func readPlaygroundChatRequestMeta(c *gin.Context) playgroundChatRequestMeta { + meta := playgroundChatRequestMeta{ + ModelName: strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyOriginalModel)), + } + + storage, err := common.GetBodyStorage(c) + if err != nil { + return meta + } + bodyBytes, err := storage.Bytes() + if err != nil || len(bodyBytes) == 0 { + return meta + } + + var payload map[string]any + if err := common.Unmarshal(bodyBytes, &payload); err != nil { + return meta + } + + if modelName := strings.TrimSpace(common.Interface2String(payload["model"])); modelName != "" { + meta.ModelName = modelName + } + meta.Prompt = readPlaygroundRequestPrompt(c) + if stream, ok := payload["stream"].(bool); ok { + meta.IsStream = stream + } + if messages, ok := payload["messages"].([]any); ok { + for _, message := range messages { + messagePayload, ok := message.(map[string]any) + if !ok { + continue + } + if playgroundMessageHasVisualInput(messagePayload["content"]) { + meta.HasVisualInput = true + break + } + } + } + + return meta +} + +func playgroundMessageHasVisualInput(content any) bool { + items, ok := content.([]any) + if !ok { + return false + } + for _, item := range items { + itemPayload, ok := item.(map[string]any) + if !ok { + continue + } + itemType := strings.ToLower(strings.TrimSpace(common.Interface2String(itemPayload["type"]))) + if itemType == dto.ContentTypeImageURL || itemType == dto.ContentTypeVideoUrl { + return true + } + } + return false +} + +func extractPlaygroundImageURLsFromText(content string) []string { + if strings.TrimSpace(content) == "" { + return nil + } + matches := playgroundImageURLPattern.FindAllString(content, -1) + if len(matches) == 0 { + return nil + } + result := make([]string, 0, len(matches)) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + trimmed := strings.TrimSpace(match) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} + +func extractPlaygroundVideoURLFromText(content string) string { + trimmedContent := strings.TrimSpace(content) + if trimmedContent == "" { + return "" + } + if matches := playgroundHTMLVideoURLPattern.FindStringSubmatch(trimmedContent); len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + if matches := playgroundMarkdownURLPattern.FindStringSubmatch(trimmedContent); len(matches) > 1 { + candidate := strings.TrimSpace(matches[1]) + if playgroundVideoURLPattern.MatchString(candidate) { + return candidate + } + } + if matches := playgroundPlainURLPattern.FindStringSubmatch(trimmedContent); len(matches) > 0 { + candidate := strings.TrimSpace(matches[0]) + if playgroundVideoURLPattern.MatchString(candidate) { + return candidate + } + } + return "" +} + +func extractPlaygroundMediaFieldURL(value any) string { + switch typedValue := value.(type) { + case string: + return strings.TrimSpace(typedValue) + case map[string]any: + candidates := []string{ + common.Interface2String(typedValue["url"]), + common.Interface2String(typedValue["presignedUrl"]), + common.Interface2String(typedValue["presigned_url"]), + common.Interface2String(typedValue["resultUrl"]), + common.Interface2String(typedValue["result_url"]), + } + for _, candidate := range candidates { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + return trimmed + } + } + return "" + default: + return "" + } +} + +func extractPlaygroundChatMediaURLs(responseBody []byte) ([]string, []string) { + if len(responseBody) == 0 { + return nil, nil + } + + var payload map[string]any + if err := common.Unmarshal(responseBody, &payload); err != nil { + return nil, nil + } + + imageURLs := make([]string, 0) + videoURLs := make([]string, 0) + appendUnique := func(target *[]string, candidate string) { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + return + } + for _, existing := range *target { + if existing == trimmed { + return + } + } + *target = append(*target, trimmed) + } + + if items, ok := payload["data"].([]any); ok { + for _, item := range items { + itemPayload, ok := item.(map[string]any) + if !ok { + continue + } + appendUnique(&imageURLs, extractPlaygroundMediaFieldURL(itemPayload["url"])) + appendUnique(&imageURLs, extractPlaygroundMediaFieldURL(itemPayload["presignedUrl"])) + appendUnique(&imageURLs, extractPlaygroundMediaFieldURL(itemPayload["presigned_url"])) + appendUnique(&imageURLs, extractPlaygroundMediaFieldURL(itemPayload["resultUrl"])) + appendUnique(&imageURLs, extractPlaygroundMediaFieldURL(itemPayload["result_url"])) + if b64 := strings.TrimSpace(common.Interface2String(itemPayload["b64_json"])); b64 != "" { + appendUnique(&imageURLs, "data:image/png;base64,"+b64) + } + if b64 := strings.TrimSpace(common.Interface2String(itemPayload["b64Json"])); b64 != "" { + appendUnique(&imageURLs, "data:image/png;base64,"+b64) + } + } + } + + choices, ok := payload["choices"].([]any) + if !ok || len(choices) == 0 { + return imageURLs, videoURLs + } + firstChoice, ok := choices[0].(map[string]any) + if !ok { + return imageURLs, videoURLs + } + message, ok := firstChoice["message"].(map[string]any) + if !ok { + return imageURLs, videoURLs + } + + switch content := message["content"].(type) { + case string: + for _, imageURL := range extractPlaygroundImageURLsFromText(content) { + appendUnique(&imageURLs, imageURL) + } + appendUnique(&videoURLs, extractPlaygroundVideoURLFromText(content)) + case []any: + for _, item := range content { + itemPayload, ok := item.(map[string]any) + if !ok { + continue + } + itemType := strings.ToLower(strings.TrimSpace(common.Interface2String(itemPayload["type"]))) + switch itemType { + case dto.ContentTypeImageURL: + appendUnique(&imageURLs, extractPlaygroundMediaFieldURL(itemPayload["image_url"])) + case dto.ContentTypeVideoUrl: + appendUnique(&videoURLs, extractPlaygroundMediaFieldURL(itemPayload["video_url"])) + default: + textContent := strings.TrimSpace(common.Interface2String(itemPayload["text"])) + if textContent == "" { + textContent = strings.TrimSpace(common.Interface2String(itemPayload["content"])) + } + for _, imageURL := range extractPlaygroundImageURLsFromText(textContent) { + appendUnique(&imageURLs, imageURL) + } + appendUnique(&videoURLs, extractPlaygroundVideoURLFromText(textContent)) + } + } + } + + return imageURLs, videoURLs +} + +func inferPlaygroundChatTaskAction(meta playgroundChatRequestMeta, imageURLs []string, videoURLs []string) string { + modelName := strings.ToLower(strings.TrimSpace(meta.ModelName)) + + if _, ok := playgroundChatImageModels[modelName]; ok && len(imageURLs) > 0 { + if meta.HasVisualInput { + return constant.TaskActionImageEdit + } + return constant.TaskActionImageGenerate + } + if _, ok := playgroundChatVideoModels[modelName]; ok && len(videoURLs) > 0 { + if meta.HasVisualInput { + return constant.TaskActionGenerate + } + return constant.TaskActionTextGenerate + } + + if len(videoURLs) > 0 { + if meta.HasVisualInput { + return constant.TaskActionGenerate + } + return constant.TaskActionTextGenerate + } + + if len(imageURLs) > 0 { + if meta.HasVisualInput { + return constant.TaskActionImageEdit + } + return constant.TaskActionImageGenerate + } + + if _, ok := playgroundChatVideoModels[modelName]; ok { + if meta.HasVisualInput { + return constant.TaskActionGenerate + } + return constant.TaskActionTextGenerate + } + if _, ok := playgroundChatImageModels[modelName]; ok { + if meta.HasVisualInput { + return constant.TaskActionImageEdit + } + return constant.TaskActionImageGenerate + } + + return "" +} + +func inferPlaygroundChatRequestAction(meta playgroundChatRequestMeta) string { + return inferPlaygroundChatTaskAction(meta, nil, nil) +} + +func recordPlaygroundChatMediaTask(c *gin.Context, taskID string, meta playgroundChatRequestMeta, responseBody []byte) { + if meta.IsStream || len(responseBody) == 0 { + return + } + + imageURLs, videoURLs := extractPlaygroundChatMediaURLs(responseBody) + action := inferPlaygroundChatTaskAction(meta, imageURLs, videoURLs) + if action == "" { + action = inferPlaygroundChatRequestAction(meta) + } + if action == "" { + return + } + + resultURL := "" + switch action { + case constant.TaskActionImageGenerate, constant.TaskActionImageEdit: + if len(imageURLs) > 0 { + resultURL = imageURLs[0] + } + default: + if len(videoURLs) > 0 { + resultURL = videoURLs[0] + } else if len(imageURLs) > 0 { + resultURL = imageURLs[0] + } + } + if resultURL == "" { + updatePlaygroundMediaTask(c, taskID, action, meta.ModelName, responseBody, "", "未获取到媒体结果") + return + } + + updatePlaygroundMediaTask(c, taskID, action, meta.ModelName, responseBody, resultURL, "") +} + +func relayPlaygroundImage(c *gin.Context, tokenName string, action string) *types.NewAPIError { + if newAPIError := setupPlaygroundTokenContext(c, tokenName, c.GetString("group")); newAPIError != nil { + return newAPIError + } + + bodyCaptureWriter := &playgroundBodyCaptureWriter{ResponseWriter: c.Writer} + c.Writer = bodyCaptureWriter + pendingTaskID := createPendingPlaygroundMediaTask( + c, + action, + common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + ) + Relay(c, types.RelayFormatOpenAIImage) + + if bodyCaptureWriter.Status() >= 200 && bodyCaptureWriter.Status() < 300 { + recordPlaygroundImageTask(c, pendingTaskID, action, bodyCaptureWriter.body.Bytes()) + } else if pendingTaskID != "" { + updatePlaygroundMediaTask( + c, + pendingTaskID, + action, + common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + bodyCaptureWriter.body.Bytes(), + "", + extractPlaygroundTaskErrorMessage(bodyCaptureWriter.body.Bytes(), "playground image request failed"), + ) + } + + return nil +} + func Playground(c *gin.Context) { var newAPIError *types.NewAPIError @@ -35,22 +667,185 @@ func Playground(c *gin.Context) { return } - userId := c.GetInt("id") + if newAPIError = setupPlaygroundTokenContext(c, fmt.Sprintf("playground-%s", relayInfo.UsingGroup), relayInfo.UsingGroup); newAPIError != nil { + return + } + + requestMeta := readPlaygroundChatRequestMeta(c) + if requestMeta.IsStream { + Relay(c, types.RelayFormatOpenAI) + return + } + + bodyCaptureWriter := &playgroundBodyCaptureWriter{ResponseWriter: c.Writer} + c.Writer = bodyCaptureWriter + pendingAction := inferPlaygroundChatRequestAction(requestMeta) + pendingTaskID := createPendingPlaygroundMediaTask(c, pendingAction, requestMeta.ModelName) + Relay(c, types.RelayFormatOpenAI) + if bodyCaptureWriter.Status() >= 200 && bodyCaptureWriter.Status() < 300 { + recordPlaygroundChatMediaTask(c, pendingTaskID, requestMeta, bodyCaptureWriter.body.Bytes()) + } else if pendingTaskID != "" { + updatePlaygroundMediaTask( + c, + pendingTaskID, + pendingAction, + requestMeta.ModelName, + bodyCaptureWriter.body.Bytes(), + "", + extractPlaygroundTaskErrorMessage(bodyCaptureWriter.body.Bytes(), "playground media request failed"), + ) + } +} + +func PlaygroundVideoSubmit(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-video", c.GetString("group")); newAPIError != nil { + return + } + RelayTask(c) +} - // Write user context to ensure acceptUnsetRatio is available +func PlaygroundAsyncVideoSubmit(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-video", c.GetString("group")); newAPIError != nil { + return + } + RelayAsyncVideoGenerations(c) +} + +func PlaygroundImageGenerations(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + newAPIError = relayPlaygroundImage(c, "playground-image", constant.TaskActionImageGenerate) +} + +func PlaygroundAsyncImageGenerations(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-image", c.GetString("group")); newAPIError != nil { + return + } + RelayAsyncImageGenerations(c) +} + +func PlaygroundImageEdits(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + newAPIError = relayPlaygroundImage(c, "playground-image-edit", constant.TaskActionImageEdit) +} + +func PlaygroundAsyncImageEdits(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-image-edit", c.GetString("group")); newAPIError != nil { + return + } + RelayAsyncImageEdits(c) +} + +func PlaygroundVideoFetch(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-video-fetch", c.GetString("group")); newAPIError != nil { + return + } + RelayTaskFetch(c) +} + +func PlaygroundAsyncVideoFetch(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-video-fetch", c.GetString("group")); newAPIError != nil { + return + } + RelayAsyncVideoFetch(c) +} + +func PlaygroundAsyncImageFetch(c *gin.Context) { + var newAPIError *types.NewAPIError + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + if newAPIError = setupPlaygroundTokenContext(c, "playground-image-fetch", c.GetString("group")); newAPIError != nil { + return + } + RelayAsyncImageFetch(c) +} + +func setupPlaygroundTokenContext(c *gin.Context, tokenName string, tokenGroup string) *types.NewAPIError { + userId := c.GetInt("id") userCache, err := model.GetUserCache(userId) if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) - return + return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) } userCache.WriteContext(c) - + if tokenGroup == "" { + tokenGroup = c.GetString("group") + } + if tokenGroup == "" { + tokenGroup = userCache.Group + } tempToken := &model.Token{ - UserId: userId, - Name: fmt.Sprintf("playground-%s", relayInfo.UsingGroup), - Group: relayInfo.UsingGroup, + UserId: userId, + Name: tokenName, + Key: fmt.Sprintf("playground_%d_%s", userId, tokenName), + Group: tokenGroup, + UnlimitedQuota: true, } _ = middleware.SetupContextForToken(c, tempToken) - - Relay(c, types.RelayFormatOpenAI) + return nil } diff --git a/controller/relay.go b/controller/relay.go index 10dfd502fbd0..ab62d170ab7f 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/relay" + taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" @@ -486,6 +487,9 @@ func RelayTask(c *gin.Context) { }) return } + if relayInfo.TaskRelayInfo != nil { + relayInfo.PublicTaskID = strings.TrimSpace(c.GetString(relayTaskPublicTaskIDContextKey)) + } if taskErr := relay.ResolveOriginTask(c, relayInfo); taskErr != nil { respondTaskError(c, taskErr) @@ -564,31 +568,13 @@ func RelayTask(c *gin.Context) { } // ── 成功:结算 + 日志 + 插入任务 ── + upsertRelayTaskRecord(c, relayInfo, result, taskErr) + if taskErr == nil { if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil { common.SysError("settle task billing error: " + settleErr.Error()) } service.LogTaskConsumption(c, relayInfo) - - task := model.InitTask(result.Platform, relayInfo) - task.PrivateData.UpstreamTaskID = result.UpstreamTaskID - task.PrivateData.BillingSource = relayInfo.BillingSource - task.PrivateData.SubscriptionId = relayInfo.SubscriptionId - task.PrivateData.TokenId = relayInfo.TokenId - task.PrivateData.BillingContext = &model.TaskBillingContext{ - ModelPrice: relayInfo.PriceData.ModelPrice, - GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, - ModelRatio: relayInfo.PriceData.ModelRatio, - OtherRatios: relayInfo.PriceData.OtherRatios, - OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName), - } - task.Quota = result.Quota - task.Data = result.TaskData - task.Action = relayInfo.Action - if insertErr := task.Insert(); insertErr != nil { - common.SysError("insert task error: " + insertErr.Error()) - } } if taskErr != nil { @@ -604,6 +590,138 @@ func respondTaskError(c *gin.Context, taskErr *dto.TaskError) { c.JSON(taskErr.StatusCode, taskErr) } +func applyTaskInfoToRelayTask(task *model.Task, taskInfo *relaycommon.TaskInfo, now int64) { + if task == nil || taskInfo == nil || taskInfo.Status == "" { + return + } + + task.Status = model.TaskStatus(taskInfo.Status) + switch task.Status { + case model.TaskStatusSubmitted: + task.Progress = taskcommon.ProgressSubmitted + case model.TaskStatusQueued: + task.Progress = taskcommon.ProgressQueued + case model.TaskStatusInProgress: + task.Progress = taskcommon.ProgressInProgress + if task.StartTime == 0 { + task.StartTime = now + } + case model.TaskStatusSuccess: + task.Progress = taskcommon.ProgressComplete + if task.StartTime == 0 { + task.StartTime = now + } + if task.FinishTime == 0 { + task.FinishTime = now + } + task.PrivateData.ResultURL = taskInfo.Url + case model.TaskStatusFailure: + task.Progress = taskcommon.ProgressComplete + if task.FinishTime == 0 { + task.FinishTime = now + } + task.FailReason = taskInfo.Reason + task.PrivateData.ResultURL = taskInfo.Url + } + + if taskInfo.Progress != "" { + task.Progress = taskInfo.Progress + } +} + +func upsertRelayTaskRecord(c *gin.Context, relayInfo *relaycommon.RelayInfo, result *relay.TaskSubmitResult, taskErr *dto.TaskError) { + if relayInfo == nil || relayInfo.PublicTaskID == "" { + return + } + + platform := relay.GetTaskPlatform(c) + if result != nil && result.Platform != "" { + platform = result.Platform + } + if platform == "" { + return + } + + task, exist, err := model.GetByOnlyTaskId(relayInfo.PublicTaskID) + if err != nil { + common.SysError("get task for upsert error: " + err.Error()) + return + } + if !exist || task == nil { + task = model.InitTask(platform, relayInfo) + } else { + task.Platform = platform + } + + task.Action = relayInfo.Action + task.PrivateData.RequestId = relayInfo.RequestId + task.PrivateData.BillingSource = relayInfo.BillingSource + task.PrivateData.SubscriptionId = relayInfo.SubscriptionId + task.PrivateData.TokenId = relayInfo.TokenId + if req, reqErr := relaycommon.GetTaskRequest(c); reqErr == nil { + if task.Properties.Input == "" { + task.Properties.Input = strings.TrimSpace(req.GetPrompt()) + } + if clientRequestID := strings.TrimSpace(req.RequestId); clientRequestID != "" { + task.PrivateData.ClientRequestId = clientRequestID + } + } + task.PrivateData.BillingContext = &model.TaskBillingContext{ + ModelPrice: relayInfo.PriceData.ModelPrice, + GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, + ModelRatio: relayInfo.PriceData.ModelRatio, + OtherRatios: relayInfo.PriceData.OtherRatios, + OriginModelName: relayInfo.OriginModelName, + PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName), + } + + now := common.GetTimestamp() + if result != nil { + if result.UpstreamTaskID != "" { + task.PrivateData.UpstreamTaskID = result.UpstreamTaskID + } + task.Quota = result.Quota + if len(result.TaskData) > 0 { + task.Data = result.TaskData + } + if adaptor := relay.GetTaskAdaptor(platform); adaptor != nil && len(result.TaskData) > 0 { + if taskInfo, parseErr := adaptor.ParseTaskResult(result.TaskData); parseErr == nil { + applyTaskInfoToRelayTask(task, taskInfo, now) + } + } + } + + if taskErr != nil { + task.Status = model.TaskStatusFailure + task.Progress = taskcommon.ProgressComplete + if task.FinishTime == 0 { + task.FinishTime = now + } + if task.FailReason == "" { + task.FailReason = strings.TrimSpace(taskErr.Message) + } + if task.FailReason == "" && taskErr.Error != nil { + task.FailReason = taskErr.Error.Error() + } + } + + if result != nil && taskErr == nil && task.Status == model.TaskStatusNotStart { + task.Status = model.TaskStatusSubmitted + task.Progress = taskcommon.ProgressSubmitted + } + + if exist { + if updateErr := task.Update(); updateErr != nil { + common.SysError("update task error: " + updateErr.Error()) + } + return + } + + if insertErr := task.Insert(); insertErr != nil { + common.SysError("insert task error: " + insertErr.Error()) + } +} + func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, retryTimes int) bool { if taskErr == nil { return false diff --git a/controller/task.go b/controller/task.go index eac7db153b48..341681391d1c 100644 --- a/controller/task.go +++ b/controller/task.go @@ -30,6 +30,7 @@ func GetAllTask(c *gin.Context) { TaskID: c.Query("task_id"), Status: c.Query("status"), Action: c.Query("action"), + MediaType: c.Query("media_type"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp, ChannelID: c.Query("channel_id"), @@ -55,6 +56,7 @@ func GetUserTask(c *gin.Context) { TaskID: c.Query("task_id"), Status: c.Query("status"), Action: c.Query("action"), + MediaType: c.Query("media_type"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp, } @@ -66,6 +68,65 @@ func GetUserTask(c *gin.Context) { common.ApiSuccess(c, pageInfo) } +func ResolveUserTask(c *gin.Context) { + userId := c.GetInt("id") + var req dto.ResolveTaskRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, err) + return + } + + queryParams := model.SyncTaskQueryParams{ + Action: req.Action, + MediaType: req.MediaType, + StartTimestamp: req.StartTimestamp, + EndTimestamp: req.EndTimestamp, + } + items := model.TaskGetUserTasksByIdentifiers( + userId, + req.TaskIDs, + req.RequestIDs, + queryParams, + req.Limit, + ) + common.ApiSuccess(c, gin.H{ + "items": tasksToDto(items, false), + }) +} + +func GetAllTaskStats(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + + queryParams := model.SyncTaskQueryParams{ + Platform: constant.TaskPlatform(c.Query("platform")), + Status: c.Query("status"), + Action: c.Query("action"), + MediaType: c.Query("media_type"), + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + ChannelID: c.Query("channel_id"), + } + + common.ApiSuccess(c, model.TaskGetStats(queryParams)) +} + +func GetUserTaskStats(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + + queryParams := model.SyncTaskQueryParams{ + Platform: constant.TaskPlatform(c.Query("platform")), + Status: c.Query("status"), + Action: c.Query("action"), + MediaType: c.Query("media_type"), + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + } + + common.ApiSuccess(c, model.TaskGetUserStats(c.GetInt("id"), queryParams)) +} + func tasksToDto(tasks []*model.Task, fillUser bool) []*dto.TaskDto { var userIdMap map[int]*model.UserBase if fillUser { diff --git a/docs/linksky-api-usage.md b/docs/linksky-api-usage.md new file mode 100644 index 000000000000..bbf6421de775 --- /dev/null +++ b/docs/linksky-api-usage.md @@ -0,0 +1,539 @@ +# LinkSky API 调用文档 + +本文档基于 `https://linksky.top/pricing` 对应的公开定价接口 `https://linksky.top/api/pricing` 整理,抓取时间为 `2026-04-13`。 +当前公开可见模型共 `15` 个,覆盖文本、图片、图片编辑、视频生成四类能力。 + +## 1. 接入信息 + +- 服务地址: `https://linksky.top` +- OpenAI 兼容 Base URL: `https://linksky.top/v1` +- 认证方式: `Authorization: Bearer <你的_API_Key>` +- 内容类型: + - 文本、图片、视频: `application/json` + - 标准图片编辑上传文件: `multipart/form-data` + +建议先通过下面两个接口确认账号下可用模型和当前计费信息: + +```bash +curl https://linksky.top/api/pricing +``` + +```bash +curl https://linksky.top/v1/models \ + -H "Authorization: Bearer $LINKSKY_API_KEY" +``` + +## 2. 当前支持的主要接口 + +| 接口 | 用途 | 说明 | +| --- | --- | --- | +| `POST /v1/chat/completions` | 文本对话 | OpenAI Chat Completions 兼容 | +| `POST /v1/responses` | Responses 风格对话 | 当前 Grok 文本模型支持 | +| `POST /v1/images/generations` | 文生图 | 适合 `grok-imagine-1.0` | +| `POST /v1/images/edits` | 图生图/编辑 | 适合 `grok-imagine-1.0-edit` | +| `POST /v1/images/async-generations` | 异步文生图 | 立即返回 `task_id`,后台生成 | +| `POST /v1/images/async-edits` | 异步图生图/编辑 | 立即返回 `task_id`,后台生成 | +| `GET /v1/images/async-generations/{task_id}` | 查询异步图片任务 | 轮询获取状态与图片结果 | +| `GET /v1/images/async-edits/{task_id}` | 查询异步图片编辑任务 | 轮询获取状态与图片结果 | +| `POST /v1/chat/completions` | Banana 系列图片生成/编辑 | 适合 `nano-banana-pro` / `nano-banana2` | +| `POST /v1/video/generations` | 视频生成 | 异步任务,返回 `task_id` | +| `GET /v1/video/generations/{task_id}` | 查询视频任务 | 轮询获取状态与结果 | +| `POST /v1/video/async-generations` | 严格异步视频生成 | 立即返回本地 `task_id`,后台生成 | +| `GET /v1/video/async-generations/{task_id}` | 查询严格异步视频任务 | 轮询获取状态与结果 | + +## 3. 当前公开模型清单 + +### 3.1 文本模型 + +这批文本模型当前属于“倍率计费”模型。 +`model_ratio` 是输入倍率,`completion_ratio` 是输出倍率,不是直接的“每次固定价格”。 + +| 模型 | 推荐接口 | 厂商 | 输入倍率 | 输出倍率 | +| --- | --- | --- | ---: | ---: | +| `gpt-5.3-codex` | `/v1/chat/completions` | OpenAI | 0.25 | 8 | +| `gpt-5.4` | `/v1/chat/completions` | OpenAI | 0.30 | 6 | +| `gpt-5.4-mini` | `/v1/chat/completions` | OpenAI | 0.15 | 6 | +| `grok-4.1-expert` | `/v1/chat/completions` 或 `/v1/responses` | xAI | 0.04 | 1 | +| `grok-4.1-fast` | `/v1/chat/completions` 或 `/v1/responses` | xAI | 0.04 | 1 | +| `grok-4.1-mini` | `/v1/chat/completions` 或 `/v1/responses` | xAI | 0.04 | 1 | +| `grok-4.1-thinking` | `/v1/chat/completions` 或 `/v1/responses` | xAI | 0.04 | 1 | +| `grok-4.20-beta` | `/v1/chat/completions` 或 `/v1/responses` | xAI | 0.09 | 1 | + +### 3.2 图片模型 + +| 模型 | 推荐接口 | 计费方式 | 当前价格 | +| --- | --- | --- | --- | +| `grok-imagine-1.0` | `/v1/images/generations` | 按次 | `0.03/次` | +| `grok-imagine-1.0-edit` | `/v1/images/edits` | 按次 | `0.03/次` | +| `nano-banana-pro` | `/v1/chat/completions` | 按分辨率 | `1K: 0.09` / `2K: 0.18` / `4K: 0.35` | +| `nano-banana2` | `/v1/chat/completions` | 按分辨率 | `1K: 0.09` / `2K: 0.18` / `4K: 0.35` | + +说明: +`nano-banana-pro` 和 `nano-banana2` 在当前项目适配中走的是 `POST /v1/chat/completions`。 +是否属于“文生图”还是“图生图”,取决于 `messages` 里是否带了 `image_url`。 +它们仍支持 `aspect_ratio`、`output_resolution` 等参数。 + +### 3.3 视频模型 + +| 模型 | 推荐接口 | 计费方式 | 当前价格 | +| --- | --- | --- | --- | +| `grok-imagine-1.0-video` | `/v1/video/generations` | 按秒数档位 | `6s: 0.06` / `8s: 0.08` / `10s: 0.10` | +| `veo31-fast` | `/v1/video/generations` | 按秒数档位 | `4s: 0.08` / `6s: 0.12` / `8s: 0.16` | +| `veo31-ref` | `/v1/video/generations` | 按秒数档位 | `4s: 0.08` / `6s: 0.12` / `8s: 0.16` | + +`veo31-ref` 适合带参考图的视频生成。 +`grok-imagine-1.0-video` 也支持带图参考,可以在请求里传 `image`。 + +## 4. 推荐调用方式 + +### 4.1 文本对话: Chat Completions + +适合 `gpt-5.4`、`gpt-5.4-mini`、`gpt-5.3-codex`,也适合全部 Grok 文本模型。 + +```bash +curl https://linksky.top/v1/chat/completions \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.4-mini", + "messages": [ + {"role": "system", "content": "你是一个简洁的中文助手。"}, + {"role": "user", "content": "帮我写一段产品介绍。"} + ], + "temperature": 0.7, + "stream": false + }' +``` + +### 4.2 文本对话: Responses + +如果你更偏向 OpenAI Responses 风格,当前推荐使用 Grok 文本模型。 + +```bash +curl https://linksky.top/v1/responses \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-4.1-fast", + "input": "请把下面内容整理成一段正式公告:今晚 8 点上线新版本。", + "stream": false + }' +``` + +### 4.3 文生图 + +#### Grok 文生图 + +```bash +curl https://linksky.top/v1/images/generations \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0", + "prompt": "一只戴宇航员头盔的柴犬,站在霓虹城市屋顶,电影感光影,超清细节", + "size": "1024x1024", + "response_format": "url" + }' +``` + +#### Banana 系列图片生成 + +`nano-banana-pro` 和 `nano-banana2` 在当前项目适配中统一走 `POST /v1/chat/completions`。 +没有 `image_url` 时可视为文生图,带 `image_url` 时可视为图生图/参考图生成。 + +推荐参数说明: +- `output_resolution`: 建议直接使用 `1K`、`2K`、`4K` +- `aspect_ratio`: 建议使用类似 `1:1`、`4:3`、`3:4`、`16:9`、`9:16` 的写法 +- 对 Banana 系列,优先传 `output_resolution`,不建议再按传统文生图思路只传 `size` + +分辨率档位与当前公开价格: + +| output_resolution | 当前价格 | +| --- | --- | +| `1K` | `0.09` | +| `2K` | `0.18` | +| `4K` | `0.35` | + +```bash +curl https://linksky.top/v1/chat/completions \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nano-banana-pro", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "高端护肤品广告海报,极简背景,玻璃反光,商业摄影风格" + } + ] + } + ], + "aspect_ratio": "1:1", + "output_resolution": "2K", + "stream": false, + "extra_body": { + "google": { + "image_config": { + "aspect_ratio": "1:1", + "image_size": "2K" + } + } + } + }' +``` + +如果你只是做最小可用测试,可以直接替换 `output_resolution`: + +```bash +"output_resolution": "1K" +``` + +```bash +"output_resolution": "2K" +``` + +```bash +"output_resolution": "4K" +``` + +### 4.4 图生图 / 图片编辑 + +当前 `grok-imagine-1.0-edit` 可直接传远程图片 URL。 + +```bash +curl https://linksky.top/v1/images/edits \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0-edit", + "prompt": "把画面改成黄昏氛围,并增加赛博朋克霓虹灯", + "image": "https://example.com/source.png", + "response_format": "url" + }' +``` + +#### Banana 系列图生图 + +`nano-banana-pro` 和 `nano-banana2` 的图生图也走 `POST /v1/chat/completions`, +区别只是把参考图放进 `messages[].content[].image_url`。 + +推荐参数说明: +- `output_resolution`: 建议直接使用 `1K`、`2K`、`4K` +- `aspect_ratio`: 建议使用类似 `1:1`、`4:3`、`3:4`、`16:9`、`9:16` 的写法 +- 图生图时 `image` 建议传真实可访问图片 URL,或按 OpenAI 兼容方式上传文件 + +常见组合示例: +- 商品主图重绘: `aspect_ratio: "1:1"` + `output_resolution: "2K"` +- 横版海报重绘: `aspect_ratio: "16:9"` + `output_resolution: "2K"` +- 竖版封面重绘: `aspect_ratio: "3:4"` 或 `9:16` + `output_resolution: "2K"` + +```bash +curl https://linksky.top/v1/chat/completions \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nano-banana-pro", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "保留主体构图,把画面改成高级商业海报风格,增强玻璃反射和边缘高光" + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/source.png" + } + } + ] + } + ], + "aspect_ratio": "1:1", + "output_resolution": "2K", + "stream": false, + "extra_body": { + "google": { + "image_config": { + "aspect_ratio": "1:1", + "image_size": "2K" + } + } + } + }' +``` + +如果你接的是标准 OpenAI 文件上传流,也可以改用 `multipart/form-data` 上传本地图片文件。 + +### 4.5 图片异步任务 + +如果下游需要“提交任务 -> 轮询结果”的异步模式,可以使用异步图片接口。它不会改动原来的同步接口行为,后台仍复用现有图片生成/编辑链路,因此模型适配、分组价格、扣费和使用日志规则与 `/v1/images/generations`、`/v1/images/edits` 保持一致。 + +#### 异步文生图 + +```bash +curl https://linksky.top/v1/images/async-generations \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0", + "prompt": "一张复古科幻旅行海报,火星城市、火箭、胶片颗粒质感", + "size": "1024x1024", + "response_format": "url" + }' +``` + +提交成功会立即返回类似: + +```json +{ + "id": "task_xxx", + "task_id": "task_xxx", + "object": "image.task", + "model": "grok-imagine-1.0", + "status": "queued", + "progress": 10, + "created_at": 1776146800 +} +``` + +轮询查询: + +```bash +curl https://linksky.top/v1/images/async-generations/ \ + -H "Authorization: Bearer $LINKSKY_API_KEY" +``` + +完成后会返回 `status: "completed"`,并在 `result_url` 和 `data[].url` 中带图片结果;失败时会返回 `status: "failed"` 和 `error.message`。 + +#### 异步图生图 / 图片编辑 + +```bash +curl https://linksky.top/v1/images/async-edits \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0-edit", + "prompt": "保留主体构图,把画面改成黄昏氛围,并增加赛博朋克霓虹灯", + "image": "https://example.com/source.png", + "response_format": "url" + }' +``` + +轮询查询: + +```bash +curl https://linksky.top/v1/images/async-edits/ \ + -H "Authorization: Bearer $LINKSKY_API_KEY" +``` + +### 4.6 视频生成 + +视频接口是异步任务模式。 +先 `POST /v1/video/generations` 获取 `task_id`,再轮询 `GET /v1/video/generations/{task_id}`。 + +如果下游要求“提交后立刻返回,不等待上游生成完成”,请使用严格异步接口: + +```bash +curl https://linksky.top/v1/video/async-generations \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0-video", + "prompt": "夜晚的海边公路,一辆复古跑车驶过,镜头平滑跟拍,电影感", + "duration": 8, + "width": 1280, + "height": 720 + }' +``` + +提交成功会立即返回类似: + +```json +{ + "id": "task_xxx", + "task_id": "task_xxx", + "object": "video", + "model": "grok-imagine-1.0-video", + "status": "queued", + "progress": 10, + "created_at": 1776223000 +} +``` + +轮询查询: + +```bash +curl https://linksky.top/v1/video/async-generations/ \ + -H "Authorization: Bearer $LINKSKY_API_KEY" +``` + +完成后会返回 `status: "completed"`,并在 `url` 中带视频结果;失败时会返回 `status: "failed"` 和 `error.message`。 + +说明:部分上游在视频刚生成完成但结果文件还未就绪时,可能短暂返回 `Not Found`。严格异步接口轮询到这类临时 `404 Not Found` 时会继续等待,不会立即把任务写成最终失败。 + +#### 文生视频 + +```bash +curl https://linksky.top/v1/video/generations \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0-video", + "prompt": "夜晚的海边公路,一辆复古跑车驶过,镜头平滑跟拍,电影感", + "duration": 8, + "width": 1280, + "height": 720 + }' +``` + +#### Grok 图生视频 + +`grok-imagine-1.0-video` 也支持带参考图的视频生成,可以直接在请求体中传 `image`。 +适合做人像动作延展、商品镜头动画化、海报转动态短视频等场景。 + +推荐参数说明: +- `image`: 建议传真实可访问的图片 URL +- `duration`: 当前公开价格档位为 `6`、`8`、`10` 秒 +- `width` / `height`: 建议与素材构图保持一致,常见可用 `1280x720` 或 `720x1280` + +支持参数总表: +- `model`: 固定传 `grok-imagine-1.0-video` +- `prompt`: 视频生成提示词 +- `image`: 单张参考图 URL,适合最常见的图生视频调用 +- `images`: 多张参考图数组,项目会自动归并为 `image_reference` +- `image_reference`: 参考图数组,适合你想显式按上游字段传参时使用 +- `duration`: 视频时长,当前文档建议使用 `6`、`8`、`10` +- `seconds`: `duration` 的兼容写法,项目计费和任务逻辑会优先读取它 +- `quality`: 质量档位,项目内对 Grok 兼容 `standard`、`high` +- `resolution_name`: 分辨率档位,当前项目会把 `480p` 映射到 `standard`,`720p` 映射到 `high` +- `preset`: 视频风格预设,项目会原样透传给上游 +- `video_config`: 可传对象,当前项目会读取其中的 `resolution_name` 和 `preset` +- `width` / `height`: 兼容透传参数,适合在你自己的请求体里保留明确横竖版信息 + +参数关系说明: +- 如果同时传 `quality` 和 `resolution_name`,项目会自动做对齐 +- `quality: "high"` 通常会补成 `resolution_name: "720p"` +- `quality: "standard"` 通常会补成 `resolution_name: "480p"` +- 如果你传了 `image` 或 `images`,项目会自动整理成 `image_reference` +- 如果你更想贴近项目内部兼容逻辑,推荐优先使用: `prompt` + `image` + `duration` + `quality` + `preset` + +横版图生视频示例: + +```bash +curl https://linksky.top/v1/video/generations \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0-video", + "prompt": "保留主体和整体色调,让画面中的人物缓慢转身并看向镜头,背景霓虹灯轻微闪烁,镜头平滑推进", + "image": "https://example.com/reference.jpg", + "duration": 8, + "width": 1280, + "height": 720 + }' +``` + +竖版图生视频示例: + +```bash +curl https://linksky.top/v1/video/generations \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "grok-imagine-1.0-video", + "prompt": "让人物保持服装和面部特征一致,做一个轻微抬头和向前走近镜头的动作,适合短视频封面动态化", + "image": "https://example.com/reference-portrait.jpg", + "duration": 6, + "width": 720, + "height": 1280 + }' +``` + +#### 带参考图视频 + +```bash +curl https://linksky.top/v1/video/generations \ + -H "Authorization: Bearer $LINKSKY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "veo31-ref", + "prompt": "让人物做一个转身并看向镜头的动作,保持原有服装和场景风格", + "image": "https://example.com/reference.jpg", + "duration": 4, + "width": 1280, + "height": 720 + }' +``` + +#### 查询视频任务 + +```bash +curl https://linksky.top/v1/video/generations/ \ + -H "Authorization: Bearer $LINKSKY_API_KEY" +``` + +返回状态通常关注以下值: + +- `queued`: 排队中 +- `in_progress`: 生成中 +- `completed`: 已完成 +- `failed`: 失败 + +## 5. OpenAI SDK 接入示例 + +### Node.js + +```javascript +import OpenAI from "openai"; + +const client = new OpenAI({ + apiKey: process.env.LINKSKY_API_KEY, + baseURL: "https://linksky.top/v1", +}); + +const resp = await client.chat.completions.create({ + model: "gpt-5.4-mini", + messages: [{ role: "user", content: "你好,做个自我介绍" }], +}); + +console.log(resp.choices[0].message); +``` + +### Python + +```python +from openai import OpenAI + +client = OpenAI( + api_key="YOUR_LINKSKY_API_KEY", + base_url="https://linksky.top/v1", +) + +resp = client.chat.completions.create( + model="grok-4.1-fast", + messages=[{"role": "user", "content": "给我写三条营销标题"}], +) + +print(resp.choices[0].message) +``` + +## 6. 使用建议 + +- 文本通用场景优先: `gpt-5.4-mini`、`grok-4.1-fast` +- 代码和复杂开发任务优先: `gpt-5.3-codex` +- 高质量文生图优先: `nano-banana-pro` +- 图片修改优先: `grok-imagine-1.0-edit` +- 高质量图生图优先: `nano-banana-pro` +- 创意短视频优先: `grok-imagine-1.0-video` +- 参考图视频优先: `veo31-ref` + +## 7. 备注 + +- 本文档中的模型和价格取自 `2026-04-13` 抓取到的 LinkSky 公开定价数据,后续如有调整,请重新以 `https://linksky.top/api/pricing` 为准。 +- 当前公开定价数据里,所有模型的 `enable_groups` 均包含 `default`。 +- 如果你要做程序里的动态模型下拉框,推荐直接读取 `GET /api/pricing` 或带鉴权调用 `GET /v1/models`。 diff --git a/dto/asset.go b/dto/asset.go new file mode 100644 index 000000000000..53b1de8cb7b1 --- /dev/null +++ b/dto/asset.go @@ -0,0 +1,25 @@ +package dto + +type CreativeCenterAsset struct { + AssetID string `json:"asset_id"` + HistoryID int64 `json:"history_id"` + TaskID string `json:"task_id,omitempty"` + UserID int `json:"user_id"` + Username string `json:"username,omitempty"` + AssetType string `json:"asset_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + Prompt string `json:"prompt"` + ModelName string `json:"model_name"` + Group string `json:"group"` + SessionID string `json:"session_id"` + SessionName string `json:"session_name"` + RecordID string `json:"record_id"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type CreativeCenterAssetDownloadRequest struct { + AssetIDs []string `json:"asset_ids"` +} diff --git a/dto/async_image.go b/dto/async_image.go new file mode 100644 index 000000000000..890a96e33b1a --- /dev/null +++ b/dto/async_image.go @@ -0,0 +1,20 @@ +package dto + +type AsyncImageTaskError struct { + Message string `json:"message"` + Code string `json:"code,omitempty"` +} + +type AsyncImageTaskResponse struct { + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` + Object string `json:"object"` + Model string `json:"model,omitempty"` + Status string `json:"status"` + Progress int `json:"progress"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + ResultURL string `json:"result_url,omitempty"` + Data []ImageData `json:"data,omitempty"` + Error *AsyncImageTaskError `json:"error,omitempty"` +} diff --git a/dto/async_video.go b/dto/async_video.go new file mode 100644 index 000000000000..231578598afd --- /dev/null +++ b/dto/async_video.go @@ -0,0 +1,21 @@ +package dto + +type AsyncVideoTaskError struct { + Message string `json:"message"` + Code string `json:"code,omitempty"` +} + +type AsyncVideoTaskResponse struct { + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` + Object string `json:"object"` + Model string `json:"model,omitempty"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + Progress int `json:"progress"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + Error *AsyncVideoTaskError `json:"error,omitempty"` +} diff --git a/dto/openai_image.go b/dto/openai_image.go index fa09155d683d..ac2b38bb57a1 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -16,6 +16,10 @@ type ImageRequest struct { Prompt string `json:"prompt" binding:"required"` N *uint `json:"n,omitempty"` Size string `json:"size,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + OutputResolution string `json:"output_resolution,omitempty"` + Seed *float64 `json:"seed,omitempty"` + Seeds []int `json:"seeds,omitempty"` Quality string `json:"quality,omitempty"` ResponseFormat string `json:"response_format,omitempty"` Style json.RawMessage `json:"style,omitempty"` @@ -32,6 +36,8 @@ type ImageRequest struct { WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"` UserId json.RawMessage `json:"user_id,omitempty"` Image json.RawMessage `json:"image,omitempty"` + ImageUrls json.RawMessage `json:"image_urls,omitempty"` + Messages json.RawMessage `json:"messages,omitempty"` // 用匿名参数接收额外参数 Extra map[string]json.RawMessage `json:"-"` } @@ -153,10 +159,14 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { if i.N != nil { n = *i.N } + imagePriceRatio := sizeRatio * qualityRatio * float64(n) + if common.IsResolutionOnlyBillingModel(i.Model) { + imagePriceRatio = 1 + } return &types.TokenCountMeta{ CombineText: i.Prompt, MaxTokens: 1584, - ImagePriceRatio: sizeRatio * qualityRatio * float64(n), + ImagePriceRatio: imagePriceRatio, } } @@ -176,7 +186,9 @@ type ImageResponse struct { Metadata json.RawMessage `json:"metadata,omitempty"` } type ImageData struct { - Url string `json:"url"` - B64Json string `json:"b64_json"` - RevisedPrompt string `json:"revised_prompt"` + Url string `json:"url"` + PresignedURL string `json:"presignedUrl"` + PresignedURLAlt string `json:"presigned_url"` + B64Json string `json:"b64_json"` + RevisedPrompt string `json:"revised_prompt"` } diff --git a/dto/openai_image_test.go b/dto/openai_image_test.go new file mode 100644 index 000000000000..826f1f21a6a4 --- /dev/null +++ b/dto/openai_image_test.go @@ -0,0 +1,85 @@ +package dto + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestImageRequestPreserveExplicitZeroSeed(t *testing.T) { + raw := []byte(`{ + "model":"nano-banana", + "prompt":"poster", + "seed":0 + }`) + + var req ImageRequest + err := common.Unmarshal(raw, &req) + require.NoError(t, err) + + encoded, err := common.Marshal(req) + require.NoError(t, err) + + require.True(t, gjson.GetBytes(encoded, "seed").Exists()) +} + +func TestImageRequestPreservesImageUrls(t *testing.T) { + raw := []byte(`{ + "model":"nano-banana-pro", + "prompt":"poster", + "image_urls":["https://example.com/1.png","https://example.com/2.png"] + }`) + + var req ImageRequest + err := common.Unmarshal(raw, &req) + require.NoError(t, err) + + encoded, err := common.Marshal(req) + require.NoError(t, err) + + require.Equal(t, "https://example.com/1.png", gjson.GetBytes(encoded, "image_urls.0").String()) + require.Equal(t, "https://example.com/2.png", gjson.GetBytes(encoded, "image_urls.1").String()) + require.NotContains(t, req.Extra, "image_urls") +} + +func TestImageRequestPreservesMessages(t *testing.T) { + raw := []byte(`{ + "model":"gpt-image2", + "prompt":"poster", + "messages":[{ + "role":"user", + "content":[ + {"type":"text","text":"poster"}, + {"type":"image_url","image_url":{"url":"https://example.com/1.png"}} + ] + }] + }`) + + var req ImageRequest + err := common.Unmarshal(raw, &req) + require.NoError(t, err) + + encoded, err := common.Marshal(req) + require.NoError(t, err) + + require.Equal(t, "user", gjson.GetBytes(encoded, "messages.0.role").String()) + require.Equal(t, "https://example.com/1.png", gjson.GetBytes(encoded, "messages.0.content.1.image_url.url").String()) + require.NotContains(t, req.Extra, "messages") +} + +func TestImageRequestBananaUsesNeutralImagePriceRatio(t *testing.T) { + n := uint(3) + req := ImageRequest{ + Model: "nano-banana-pro", + Prompt: "poster", + N: &n, + OutputResolution: "4K", + } + + meta := req.GetTokenCountMeta() + + require.NotNil(t, meta) + require.Equal(t, 1.0, meta.ImagePriceRatio) +} diff --git a/dto/openai_request.go b/dto/openai_request.go index 76a866621a71..7518a2df88fd 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -45,13 +45,22 @@ type GeneralOpenAIRequest struct { N *int `json:"n,omitempty"` Input any `json:"input,omitempty"` Instruction string `json:"instruction,omitempty"` + ImageConfig json.RawMessage `json:"image_config,omitempty"` Size string `json:"size,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + OutputResolution string `json:"output_resolution,omitempty"` + Seconds *string `json:"seconds,omitempty"` + Duration *int `json:"duration,omitempty"` + Quality *string `json:"quality,omitempty"` + Resolution string `json:"resolution,omitempty"` + ReferenceMode string `json:"reference_mode,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"` + Seeds []int `json:"seeds,omitempty"` ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` Tools []ToolCallRequest `json:"tools,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` diff --git a/dto/openai_request_zero_value_test.go b/dto/openai_request_zero_value_test.go index 4b0dbd7c25ee..4869f39090ba 100644 --- a/dto/openai_request_zero_value_test.go +++ b/dto/openai_request_zero_value_test.go @@ -50,6 +50,38 @@ func TestGeneralOpenAIRequestPreserveExplicitZeroValues(t *testing.T) { require.True(t, gjson.GetBytes(encoded, "return_related_questions").Exists()) } +func TestGeneralOpenAIRequestPreservesImageConfig(t *testing.T) { + raw := []byte(`{ + "model":"grok-imagine-1.0-edit", + "stream":false, + "image_config":{ + "n":1, + "size":"1024x1024", + "response_format":"url" + }, + "messages":[ + { + "role":"user", + "content":[ + {"type":"text","text":"把这张图改成赛博朋克风格"}, + {"type":"image_url","image_url":{"url":"https://example.com/input.png"}} + ] + } + ] + }`) + + var req GeneralOpenAIRequest + err := common.Unmarshal(raw, &req) + require.NoError(t, err) + + encoded, err := common.Marshal(req) + require.NoError(t, err) + + require.Equal(t, "1024x1024", gjson.GetBytes(encoded, "image_config.size").String()) + require.Equal(t, "url", gjson.GetBytes(encoded, "image_config.response_format").String()) + require.Equal(t, "https://example.com/input.png", gjson.GetBytes(encoded, "messages.0.content.1.image_url.url").String()) +} + func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) { raw := []byte(`{ "model":"gpt-4.1", diff --git a/dto/task.go b/dto/task.go index 4a9a8e2e6d18..7dc0f3034fd2 100644 --- a/dto/task.go +++ b/dto/task.go @@ -34,6 +34,7 @@ type TaskDto struct { CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` TaskID string `json:"task_id"` + RequestID string `json:"request_id,omitempty"` Platform string `json:"platform"` UserId int `json:"user_id"` Group string `json:"group"` @@ -55,3 +56,13 @@ type TaskDto struct { type FetchReq struct { IDs []string `json:"ids"` } + +type ResolveTaskRequest struct { + TaskIDs []string `json:"task_ids"` + RequestIDs []string `json:"request_ids"` + Action string `json:"action,omitempty"` + MediaType string `json:"media_type,omitempty"` + StartTimestamp int64 `json:"start_timestamp,omitempty"` + EndTimestamp int64 `json:"end_timestamp,omitempty"` + Limit int `json:"limit,omitempty"` +} diff --git a/dto/task_stats.go b/dto/task_stats.go new file mode 100644 index 000000000000..b4f40da51105 --- /dev/null +++ b/dto/task_stats.go @@ -0,0 +1,20 @@ +package dto + +type TaskStatsBreakdown struct { + Running int64 `json:"running"` + Success int64 `json:"success"` + Failure int64 `json:"failure"` +} + +type TaskDailyCount struct { + Date string `json:"date"` + Total int64 `json:"total"` +} + +type TaskStatsResponse struct { + RunningCount int64 `json:"running_count"` + DailyCounts []TaskDailyCount `json:"daily_counts,omitempty"` + TotalStats TaskStatsBreakdown `json:"total_stats"` + ImageStats TaskStatsBreakdown `json:"image_stats"` + VideoStats TaskStatsBreakdown `json:"video_stats"` +} diff --git a/middleware/distributor.go b/middleware/distributor.go index d626941456c7..8bddaa95ae29 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -81,8 +81,8 @@ func Distribute() func(c *gin.Context) { } var selectGroup string usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) - // check path is /pg/chat/completions - if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + // playground requests may override group in body + if strings.HasPrefix(c.Request.URL.Path, "/pg/") { playgroundRequest := &dto.PlayGroundRequest{} err = common.UnmarshalBodyReusable(c, playgroundRequest) if err != nil { @@ -246,7 +246,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { shouldSelectChannel = false } c.Set("relay_mode", relayMode) - } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") { + } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") || strings.Contains(c.Request.URL.Path, "/v1/video/async-generations") { relayMode := relayconstant.RelayModeUnknown if c.Request.Method == http.MethodPost { req, err := getModelFromRequest(c) @@ -291,9 +291,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { modelRequest.Model = c.Param("model") } } - if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { + if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") || strings.HasPrefix(c.Request.URL.Path, "/v1/images/async-generations") { modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") - } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") || strings.HasPrefix(c.Request.URL.Path, "/v1/images/async-edits") { //modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1") contentType := c.ContentType() if slices.Contains([]string{gin.MIMEPOSTForm, gin.MIMEMultipartPOSTForm}, contentType) { diff --git a/model/creative_center_asset.go b/model/creative_center_asset.go new file mode 100644 index 000000000000..4ace599379a1 --- /dev/null +++ b/model/creative_center_asset.go @@ -0,0 +1,744 @@ +package model + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +type CreativeCenterAssetQueryParams struct { + Type string + Keyword string + ModelName string + Status string + Username string + StartTimestamp int64 + EndTimestamp int64 +} + +type creativeCenterHistoryQuery struct { + UserIDs []int +} + +func GetAllCreativeCenterAssets(queryParams CreativeCenterAssetQueryParams) ([]*dto.CreativeCenterAsset, error) { + userIDs, err := queryUserIDsByUsername(queryParams.Username) + if err != nil { + return nil, err + } + if len(userIDs) == 0 && strings.TrimSpace(queryParams.Username) != "" { + return []*dto.CreativeCenterAsset{}, nil + } + return listTaskAssets(creativeCenterHistoryQuery{UserIDs: userIDs}, queryParams) +} + +func GetUserCreativeCenterAssets(userId int, queryParams CreativeCenterAssetQueryParams) ([]*dto.CreativeCenterAsset, error) { + return listTaskAssets(creativeCenterHistoryQuery{UserIDs: []int{userId}}, queryParams) +} + +func listTaskAssets(taskQuery creativeCenterHistoryQuery, queryParams CreativeCenterAssetQueryParams) ([]*dto.CreativeCenterAsset, error) { + tasks, err := listAssetTasks(taskQuery, queryParams) + if err != nil { + return nil, err + } + + assets := make([]*dto.CreativeCenterAsset, 0) + usernameCache := make(map[int]string) + for _, task := range tasks { + if task == nil { + continue + } + username, ok := usernameCache[task.UserId] + if !ok { + username, _ = GetUsernameById(task.UserId, false) + usernameCache[task.UserId] = username + } + assets = append(assets, flattenTaskAssets(task, username)...) + } + + filtered := make([]*dto.CreativeCenterAsset, 0, len(assets)) + for _, asset := range assets { + if !matchesCreativeCenterAssetFilter(asset, queryParams) { + continue + } + filtered = append(filtered, asset) + } + + sort.SliceStable(filtered, func(i, j int) bool { + if filtered[i].UpdatedAt == filtered[j].UpdatedAt { + if filtered[i].CreatedAt == filtered[j].CreatedAt { + return filtered[i].AssetID > filtered[j].AssetID + } + return filtered[i].CreatedAt > filtered[j].CreatedAt + } + return filtered[i].UpdatedAt > filtered[j].UpdatedAt + }) + + return filtered, nil +} + +func listAssetTasks(taskQuery creativeCenterHistoryQuery, queryParams CreativeCenterAssetQueryParams) ([]*Task, error) { + tasks := make([]*Task, 0) + query := DB.Model(&Task{}) + if len(taskQuery.UserIDs) > 0 { + query = query.Where("user_id in (?)", taskQuery.UserIDs) + } + if queryParams.StartTimestamp > 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp > 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + actions := getTaskActionsForMediaType(queryParams.Type) + if len(actions) == 0 { + actions = getTaskActionsForMediaType(TaskMediaTypeAll) + } + query = query.Where("action in (?)", actions) + query = query.Where("status = ?", TaskStatusSuccess) + err := query.Order("updated_at desc").Find(&tasks).Error + if err != nil { + return nil, err + } + return tasks, nil +} + +func listCreativeCenterAssets(historyQuery creativeCenterHistoryQuery, queryParams CreativeCenterAssetQueryParams) ([]*dto.CreativeCenterAsset, error) { + histories, err := listCreativeCenterHistories(historyQuery) + if err != nil { + return nil, err + } + + assets := make([]*dto.CreativeCenterAsset, 0) + for _, history := range histories { + username, _ := GetUsernameById(history.UserId, false) + assets = append(assets, flattenCreativeCenterHistoryAssets(history, username)...) + } + + filtered := make([]*dto.CreativeCenterAsset, 0, len(assets)) + for _, asset := range assets { + if !matchesCreativeCenterAssetFilter(asset, queryParams) { + continue + } + filtered = append(filtered, asset) + } + + sort.SliceStable(filtered, func(i, j int) bool { + if filtered[i].UpdatedAt == filtered[j].UpdatedAt { + if filtered[i].CreatedAt == filtered[j].CreatedAt { + return filtered[i].AssetID > filtered[j].AssetID + } + return filtered[i].CreatedAt > filtered[j].CreatedAt + } + return filtered[i].UpdatedAt > filtered[j].UpdatedAt + }) + + return filtered, nil +} + +type taskAssetItem struct { + mediaURL string + thumbnailURL string +} + +func flattenTaskAssets(task *Task, username string) []*dto.CreativeCenterAsset { + if task == nil { + return nil + } + + assetType := detectTaskMediaType(task.Action) + if assetType == "" { + return nil + } + + createdAt := normalizeAssetTimestamp(firstPositiveInt64(task.SubmitTime, task.CreatedAt, task.StartTime, task.FinishTime)) + updatedAt := normalizeAssetTimestamp(firstPositiveInt64(task.FinishTime, task.UpdatedAt, task.StartTime, task.SubmitTime, task.CreatedAt)) + modelName := fallbackString(task.Properties.OriginModelName, task.Properties.UpstreamModelName) + prompt := strings.TrimSpace(task.Properties.Input) + + items := extractTaskAssetItems(task, assetType) + assets := make([]*dto.CreativeCenterAsset, 0, len(items)) + for index, item := range items { + if strings.TrimSpace(item.mediaURL) == "" { + continue + } + status := normalizeTaskAssetStatus(task.Status, item.mediaURL) + if !isCompletedAssetStatus(status) { + continue + } + assets = append(assets, &dto.CreativeCenterAsset{ + AssetID: fmt.Sprintf("task:%s:%s:%d", assetType, task.TaskID, index), + HistoryID: 0, + TaskID: task.TaskID, + UserID: task.UserId, + Username: username, + AssetType: assetType, + MediaURL: item.mediaURL, + ThumbnailURL: fallbackString(item.thumbnailURL, item.mediaURL), + Prompt: prompt, + ModelName: modelName, + Group: task.Group, + SessionID: "", + SessionName: "", + RecordID: task.TaskID, + Status: status, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + return assets +} + +func extractTaskAssetItems(task *Task, assetType string) []taskAssetItem { + switch assetType { + case TaskMediaTypeImage: + imageURLs := collectTaskAssetURLs(task, assetType, false) + items := make([]taskAssetItem, 0, len(imageURLs)) + for _, imageURL := range imageURLs { + items = append(items, taskAssetItem{ + mediaURL: imageURL, + thumbnailURL: imageURL, + }) + } + return items + case TaskMediaTypeVideo: + videoURLs := collectTaskAssetURLs(task, assetType, false) + thumbnailURLs := collectTaskAssetURLs(task, assetType, true) + items := make([]taskAssetItem, 0, len(videoURLs)) + for index, videoURL := range videoURLs { + thumbnailURL := "" + if index < len(thumbnailURLs) { + thumbnailURL = thumbnailURLs[index] + } + items = append(items, taskAssetItem{ + mediaURL: videoURL, + thumbnailURL: thumbnailURL, + }) + } + return items + default: + return nil + } +} + +func collectTaskAssetURLs(task *Task, assetType string, thumbnailOnly bool) []string { + candidates := make([]string, 0) + if !thumbnailOnly { + appendUniqueTaskAssetURL(&candidates, taskAssetURLCandidate(task.GetResultURL(), assetType, false)) + } + + if len(task.Data) == 0 { + return candidates + } + + var payload any + if err := common.Unmarshal(task.Data, &payload); err != nil { + return candidates + } + + keySet := taskAssetMediaKeys(assetType, thumbnailOnly) + var walk func(node any) + walk = func(node any) { + switch value := node.(type) { + case map[string]any: + for key, nested := range value { + normalizedKey := strings.ToLower(strings.TrimSpace(key)) + if _, ok := keySet[normalizedKey]; ok { + appendTaskAssetURLsByValue(&candidates, nested, assetType, thumbnailOnly) + } + walk(nested) + } + case []any: + for _, item := range value { + walk(item) + } + } + } + walk(payload) + + return candidates +} + +func taskAssetMediaKeys(assetType string, thumbnailOnly bool) map[string]struct{} { + if thumbnailOnly { + return map[string]struct{}{ + "thumbnailurl": {}, + "thumbnail_url": {}, + "coverurl": {}, + "cover_url": {}, + "image_url": {}, + "imageurl": {}, + } + } + + switch assetType { + case TaskMediaTypeImage: + return map[string]struct{}{ + "url": {}, + "presignedurl": {}, + "presigned_url": {}, + "resulturl": {}, + "result_url": {}, + "image_url": {}, + "imageurl": {}, + "image_urls": {}, + "imageurls": {}, + "images": {}, + "b64_json": {}, + "b64json": {}, + } + case TaskMediaTypeVideo: + return map[string]struct{}{ + "url": {}, + "resulturl": {}, + "result_url": {}, + "video_url": {}, + "videourl": {}, + "video_urls": {}, + "videourls": {}, + } + default: + return map[string]struct{}{} + } +} + +func appendTaskAssetURLsByValue(target *[]string, value any, assetType string, thumbnailOnly bool) { + switch typedValue := value.(type) { + case string: + appendUniqueTaskAssetURL(target, taskAssetURLCandidate(typedValue, assetType, thumbnailOnly)) + case []any: + for _, item := range typedValue { + appendTaskAssetURLsByValue(target, item, assetType, thumbnailOnly) + } + case map[string]any: + keys := []string{"url", "presignedUrl", "presigned_url", "resultUrl", "result_url", "image_url", "imageUrl", "video_url", "videoUrl", "thumbnailUrl", "thumbnail_url", "coverUrl", "cover_url"} + if thumbnailOnly { + keys = []string{"thumbnailUrl", "thumbnail_url", "coverUrl", "cover_url", "image_url", "imageUrl"} + } + for _, key := range keys { + appendUniqueTaskAssetURL(target, taskAssetURLCandidate(stringValue(typedValue, key), assetType, thumbnailOnly)) + } + if assetType == TaskMediaTypeImage && !thumbnailOnly { + if b64 := strings.TrimSpace(stringValue(typedValue, "b64_json", "b64Json")); b64 != "" { + appendUniqueTaskAssetURL(target, "data:image/png;base64,"+b64) + } + } + } +} + +func appendUniqueTaskAssetURL(target *[]string, candidate string) { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + return + } + for _, existing := range *target { + if existing == trimmed { + return + } + } + *target = append(*target, trimmed) +} + +func taskAssetURLCandidate(candidate string, assetType string, thumbnailOnly bool) string { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "data:image/") { + if assetType == TaskMediaTypeVideo && !thumbnailOnly { + return "" + } + return trimmed + } + if strings.HasPrefix(trimmed, "data:video/") { + if assetType == TaskMediaTypeImage || thumbnailOnly { + return "" + } + return trimmed + } + if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") || strings.HasPrefix(trimmed, "/") { + return trimmed + } + return "" +} + +func normalizeTaskAssetStatus(status TaskStatus, mediaURL string) string { + switch normalizeTaskStatus(status) { + case TaskStatusFailure: + return "failed" + case TaskStatusSuccess: + return "completed" + case TaskStatusQueued, TaskStatusSubmitted, TaskStatusInProgress: + if strings.TrimSpace(mediaURL) != "" { + return "completed" + } + return "processing" + default: + if strings.TrimSpace(mediaURL) != "" { + return "completed" + } + return "processing" + } +} + +func firstPositiveInt64(values ...int64) int64 { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + +func listCreativeCenterHistories(query creativeCenterHistoryQuery) ([]*CreativeCenterHistory, error) { + histories := make([]*CreativeCenterHistory, 0) + tx := DB.Model(&CreativeCenterHistory{}).Where("tab in (?)", []string{"image", "video"}) + if len(query.UserIDs) > 0 { + tx = tx.Where("user_id in (?)", query.UserIDs) + } + err := tx.Order("updated_at desc").Find(&histories).Error + if err != nil { + return nil, err + } + return histories, nil +} + +func queryUserIDsByUsername(username string) ([]int, error) { + trimmed := strings.TrimSpace(username) + if trimmed == "" { + return nil, nil + } + userIDs := make([]int, 0) + err := DB.Model(&User{}). + Where("username LIKE ?", "%"+trimmed+"%"). + Pluck("id", &userIDs).Error + if err != nil { + return nil, err + } + return userIDs, nil +} + +func flattenCreativeCenterHistoryAssets(history *CreativeCenterHistory, username string) []*dto.CreativeCenterAsset { + if history == nil { + return nil + } + + rootPayload := mapStringAny{} + if history.Payload != "" { + _ = common.UnmarshalJsonStr(string(history.Payload), &rootPayload) + } + + tab := strings.TrimSpace(history.Tab) + if tab == "" { + return nil + } + + sessions := mapSliceValue(rootPayload, "sessions") + if len(sessions) == 0 { + return flattenCreativeCenterSessionAssets(history, username, tab, rootPayload, 0) + } + + assets := make([]*dto.CreativeCenterAsset, 0) + for index, session := range sessions { + assets = append(assets, flattenCreativeCenterSessionAssets(history, username, tab, session, index)...) + } + return assets +} + +func flattenCreativeCenterSessionAssets(history *CreativeCenterHistory, username string, tab string, session mapStringAny, sessionIndex int) []*dto.CreativeCenterAsset { + sessionID := fallbackString(stringValue(session, "id"), fmt.Sprintf("session-%d", sessionIndex)) + sessionName := fallbackString(stringValue(session, "name"), fmt.Sprintf("%s-session-%d", tab, sessionIndex+1)) + sessionPrompt := fallbackString(stringValue(session, "prompt"), history.Prompt) + sessionModelName := fallbackString(stringValue(session, "model_name", "modelName"), history.ModelName) + sessionGroup := fallbackString(stringValue(session, "group"), history.Group) + sessionCreatedAt := fallbackInt64(int64Value(session, "created_at", "createdAt"), history.CreatedAt) + sessionUpdatedAt := fallbackInt64(int64Value(session, "updated_at", "updatedAt"), history.UpdatedAt) + + sessionPayload := mapValue(session, "payload") + if len(sessionPayload) == 0 { + sessionPayload = session + } + + entries := mapSliceValue(sessionPayload, "entries") + if len(entries) == 0 { + legacyEntry := mapStringAny{ + "id": fmt.Sprintf("record-%d", sessionIndex), + "prompt": sessionPrompt, + "model_name": sessionModelName, + "group": sessionGroup, + "created_at": sessionCreatedAt, + "updated_at": sessionUpdatedAt, + } + switch tab { + case "image": + if images := sliceValue(sessionPayload, "images"); len(images) > 0 { + legacyEntry["images"] = images + entries = append(entries, legacyEntry) + } + case "video": + if tasks := sliceValue(sessionPayload, "tasks"); len(tasks) > 0 { + legacyEntry["tasks"] = tasks + entries = append(entries, legacyEntry) + } + } + } + + assets := make([]*dto.CreativeCenterAsset, 0) + for entryIndex, entry := range entries { + recordID := fallbackString(stringValue(entry, "id"), fmt.Sprintf("record-%d", entryIndex)) + prompt := fallbackString(stringValue(entry, "prompt"), sessionPrompt) + modelName := fallbackString(stringValue(entry, "model_name", "modelName"), sessionModelName) + group := fallbackString(stringValue(entry, "group"), sessionGroup) + status := normalizeAssetStatus(fallbackString(stringValue(entry, "status"), "completed")) + createdAt := normalizeAssetTimestamp( + fallbackInt64(int64Value(entry, "created_at", "createdAt"), sessionCreatedAt), + ) + updatedAt := normalizeAssetTimestamp( + fallbackInt64(int64Value(entry, "updated_at", "updatedAt"), sessionUpdatedAt), + ) + + var items []any + if tab == "image" { + items = sliceValue(entry, "images") + } else { + items = sliceValue(entry, "tasks") + } + + for itemIndex, item := range items { + itemMap := anyToMap(item) + mediaURL := firstNonEmptyString(anyToString(item), stringValue(itemMap, "url"), stringValue(itemMap, "resultUrl"), stringValue(itemMap, "result_url")) + if strings.TrimSpace(mediaURL) == "" { + continue + } + + itemStatus := normalizeAssetStatus(fallbackString(stringValue(itemMap, "status"), status)) + if mediaURL != "" && !isFailedAssetStatus(itemStatus) { + itemStatus = "completed" + } + if !isCompletedAssetStatus(itemStatus) { + continue + } + assetType := tab + thumbnailURL := mediaURL + if assetType == "video" { + thumbnailURL = firstNonEmptyString(stringValue(itemMap, "thumbnailUrl"), stringValue(itemMap, "thumbnail_url"), mediaURL) + } + + assets = append(assets, &dto.CreativeCenterAsset{ + AssetID: fmt.Sprintf("cc:%s:%d:%s:%s:%d", tab, history.ID, sessionID, recordID, itemIndex), + HistoryID: history.ID, + UserID: history.UserId, + Username: username, + AssetType: assetType, + MediaURL: mediaURL, + ThumbnailURL: thumbnailURL, + Prompt: prompt, + ModelName: modelName, + Group: group, + SessionID: sessionID, + SessionName: sessionName, + RecordID: recordID, + Status: itemStatus, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + } + + return assets +} + +func matchesCreativeCenterAssetFilter(asset *dto.CreativeCenterAsset, queryParams CreativeCenterAssetQueryParams) bool { + if asset == nil { + return false + } + queryType := strings.ToLower(strings.TrimSpace(queryParams.Type)) + if queryType != "" && queryType != "all" && strings.ToLower(asset.AssetType) != queryType { + return false + } + + queryStatus := strings.ToLower(strings.TrimSpace(queryParams.Status)) + if queryStatus != "" && queryStatus != "all" && strings.ToLower(asset.Status) != queryStatus { + return false + } + + queryModel := strings.ToLower(strings.TrimSpace(queryParams.ModelName)) + if queryModel != "" && !strings.Contains(strings.ToLower(asset.ModelName), queryModel) { + return false + } + + queryKeyword := strings.ToLower(strings.TrimSpace(queryParams.Keyword)) + if queryKeyword != "" { + haystack := strings.ToLower(strings.Join([]string{ + asset.Prompt, + asset.ModelName, + asset.Group, + asset.TaskID, + asset.SessionName, + asset.Username, + asset.RecordID, + }, " ")) + if !strings.Contains(haystack, queryKeyword) { + return false + } + } + + if queryParams.StartTimestamp > 0 && asset.CreatedAt < queryParams.StartTimestamp { + return false + } + if queryParams.EndTimestamp > 0 && asset.CreatedAt > queryParams.EndTimestamp { + return false + } + + return true +} + +func normalizeAssetStatus(status string) string { + normalized := strings.TrimSpace(strings.ToLower(status)) + if normalized == "" { + return "completed" + } + switch normalized { + case "success": + return "completed" + case "in_progress": + return "processing" + default: + return normalized + } +} + +func isCompletedAssetStatus(status string) bool { + return normalizeAssetStatus(status) == "completed" +} + +func isFailedAssetStatus(status string) bool { + return normalizeAssetStatus(status) == "failed" +} + +func normalizeAssetTimestamp(timestamp int64) int64 { + if timestamp <= 0 { + return 0 + } + // Creative Center payload timestamps are often persisted in milliseconds. + if timestamp > 9999999999 { + return timestamp / 1000 + } + return timestamp +} + +type mapStringAny map[string]any + +func mapValue(m mapStringAny, keys ...string) mapStringAny { + for _, key := range keys { + if value, ok := m[key]; ok { + return anyToMap(value) + } + } + return mapStringAny{} +} + +func mapSliceValue(m mapStringAny, keys ...string) []mapStringAny { + values := sliceValue(m, keys...) + items := make([]mapStringAny, 0, len(values)) + for _, value := range values { + item := anyToMap(value) + if len(item) == 0 { + continue + } + items = append(items, item) + } + return items +} + +func sliceValue(m mapStringAny, keys ...string) []any { + for _, key := range keys { + if value, ok := m[key]; ok { + if items, ok := value.([]any); ok { + return items + } + } + } + return nil +} + +func stringValue(m mapStringAny, keys ...string) string { + for _, key := range keys { + if value, ok := m[key]; ok { + return anyToString(value) + } + } + return "" +} + +func int64Value(m mapStringAny, keys ...string) int64 { + for _, key := range keys { + if value, ok := m[key]; ok { + switch typed := value.(type) { + case float64: + return int64(typed) + case float32: + return int64(typed) + case int64: + return typed + case int32: + return int64(typed) + case int: + return int64(typed) + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + if err == nil { + return parsed + } + } + } + } + return 0 +} + +func anyToMap(value any) mapStringAny { + switch typed := value.(type) { + case mapStringAny: + return typed + case map[string]any: + return mapStringAny(typed) + default: + return mapStringAny{} + } +} + +func anyToString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return "" + } +} + +func fallbackString(value string, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} + +func fallbackInt64(value int64, fallback int64) int64 { + if value > 0 { + return value + } + return fallback +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/model/creative_center_asset_test.go b/model/creative_center_asset_test.go new file mode 100644 index 000000000000..102a7ccb2c1b --- /dev/null +++ b/model/creative_center_asset_test.go @@ -0,0 +1,251 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +func TestFlattenCreativeCenterHistoryAssetsImageSessions(t *testing.T) { + history := &CreativeCenterHistory{ + ID: 11, + UserId: 7, + Tab: "image", + ModelName: "history-model", + Group: "history-group", + Prompt: "history prompt", + CreatedAt: 1710000000, + UpdatedAt: 1710000100, + Payload: `{ + "sessions": [ + { + "id": "sess-image", + "name": "Image Session", + "payload": { + "entries": [ + { + "id": "record-image", + "prompt": "draw a cat", + "modelName": "nano-banana", + "group": "group-a", + "status": "completed", + "createdAt": 1710000001000, + "updatedAt": 1710000002000, + "images": [ + { "url": "https://example.com/image-a.png" }, + { "resultUrl": "https://example.com/image-b.png", "status": "success" }, + { "status": "failed" } + ] + } + ] + } + } + ] + }`, + } + + assets := flattenCreativeCenterHistoryAssets(history, "alice") + if len(assets) != 2 { + t.Fatalf("expected 2 assets, got %d", len(assets)) + } + + first := assets[0] + if first.AssetID != "cc:image:11:sess-image:record-image:0" { + t.Fatalf("unexpected first asset id: %s", first.AssetID) + } + if first.AssetType != "image" { + t.Fatalf("unexpected asset type: %s", first.AssetType) + } + if first.Username != "alice" { + t.Fatalf("unexpected username: %s", first.Username) + } + if first.MediaURL != "https://example.com/image-a.png" { + t.Fatalf("unexpected media url: %s", first.MediaURL) + } + if first.CreatedAt != 1710000001 { + t.Fatalf("expected milliseconds to normalize to seconds, got %d", first.CreatedAt) + } + if first.UpdatedAt != 1710000002 { + t.Fatalf("expected milliseconds to normalize to seconds, got %d", first.UpdatedAt) + } + + second := assets[1] + if second.AssetID != "cc:image:11:sess-image:record-image:1" { + t.Fatalf("unexpected second asset id: %s", second.AssetID) + } + if second.MediaURL != "https://example.com/image-b.png" { + t.Fatalf("unexpected fallback result url: %s", second.MediaURL) + } + if second.Status != "completed" { + t.Fatalf("expected normalized completed status, got %s", second.Status) + } +} + +func TestFlattenCreativeCenterHistoryAssetsVideoLegacyPayload(t *testing.T) { + history := &CreativeCenterHistory{ + ID: 21, + UserId: 9, + Tab: "video", + ModelName: "veo-3", + Group: "video-group", + Prompt: "history video prompt", + CreatedAt: 1720000000, + UpdatedAt: 1720000100, + Payload: `{ + "tasks": [ + { + "id": "task-1", + "url": "https://example.com/video-a.mp4", + "status": "in_progress", + "thumbnailUrl": "https://example.com/video-a.jpg" + }, + { + "id": "task-2", + "resultUrl": "https://example.com/video-b.mp4", + "status": "submitted" + }, + { + "id": "task-3", + "status": "failed" + } + ] + }`, + } + + assets := flattenCreativeCenterHistoryAssets(history, "bob") + if len(assets) != 2 { + t.Fatalf("expected 2 completed assets, got %d", len(assets)) + } + + first := assets[0] + if first.AssetID != "cc:video:21:session-0:record-0:0" { + t.Fatalf("unexpected first asset id: %s", first.AssetID) + } + if first.MediaURL != "https://example.com/video-a.mp4" { + t.Fatalf("unexpected media url: %s", first.MediaURL) + } + if first.ThumbnailURL != "https://example.com/video-a.jpg" { + t.Fatalf("unexpected thumbnail url: %s", first.ThumbnailURL) + } + if first.Status != "completed" { + t.Fatalf("expected completed status, got %s", first.Status) + } + + second := assets[1] + if second.AssetID != "cc:video:21:session-0:record-0:1" { + t.Fatalf("unexpected asset id: %s", second.AssetID) + } + if second.AssetType != "video" { + t.Fatalf("unexpected asset type: %s", second.AssetType) + } + if second.MediaURL != "https://example.com/video-b.mp4" { + t.Fatalf("unexpected result url fallback: %s", second.MediaURL) + } + if second.Status != "completed" { + t.Fatalf("expected completed status, got %s", second.Status) + } +} + +func TestFlattenTaskAssetsImageUsesTaskData(t *testing.T) { + task := &Task{ + ID: 31, + TaskID: "task-image-1", + UserId: 7, + Group: "default", + Action: "imageGenerate", + Status: TaskStatusSuccess, + SubmitTime: 1730000000, + UpdatedAt: 1730000100, + Properties: Properties{ + Input: "draw a cat", + OriginModelName: "nano-banana", + }, + } + task.SetData(map[string]any{ + "data": []any{ + map[string]any{"url": "https://example.com/image-a.png"}, + map[string]any{"resultUrl": "https://example.com/image-b.png"}, + }, + }) + + assets := flattenTaskAssets(task, "alice") + if len(assets) != 2 { + t.Fatalf("expected 2 task assets, got %d", len(assets)) + } + + first := assets[0] + if first.TaskID != "task-image-1" { + t.Fatalf("unexpected task id: %s", first.TaskID) + } + if first.SessionName != "" { + t.Fatalf("expected empty session name, got %q", first.SessionName) + } + if first.MediaURL != "https://example.com/image-a.png" { + t.Fatalf("unexpected media url: %s", first.MediaURL) + } + if first.AssetType != "image" { + t.Fatalf("unexpected asset type: %s", first.AssetType) + } +} + +func TestFlattenTaskAssetsVideoUsesResultAndThumbnail(t *testing.T) { + task := &Task{ + ID: 32, + TaskID: "task-video-1", + UserId: 9, + Group: "video-group", + Action: "textGenerate", + Status: TaskStatusSuccess, + SubmitTime: 1740000000, + FinishTime: 1740000060, + UpdatedAt: 1740000060, + Properties: Properties{ + Input: "generate a trailer", + OriginModelName: "veo31", + }, + PrivateData: TaskPrivateData{ + ResultURL: "https://example.com/video-a.mp4", + }, + } + task.SetData(map[string]any{ + "creations": []any{ + map[string]any{ + "url": "https://example.com/video-a.mp4", + "cover_url": "https://example.com/video-a.jpg", + }, + }, + }) + + assets := flattenTaskAssets(task, "bob") + if len(assets) != 1 { + t.Fatalf("expected 1 task asset, got %d", len(assets)) + } + + first := assets[0] + if first.MediaURL != "https://example.com/video-a.mp4" { + t.Fatalf("unexpected media url: %s", first.MediaURL) + } + if first.ThumbnailURL != "https://example.com/video-a.jpg" { + t.Fatalf("unexpected thumbnail url: %s", first.ThumbnailURL) + } + if first.Status != "completed" { + t.Fatalf("unexpected status: %s", first.Status) + } +} + +func TestMatchesCreativeCenterAssetFilterMatchesTaskIDKeyword(t *testing.T) { + asset := &dto.CreativeCenterAsset{ + AssetID: "task:image:task-image-1:0", + TaskID: "task-image-1", + AssetType: "image", + ModelName: "nano-banana", + Prompt: "draw a cat", + Status: "completed", + CreatedAt: common.GetTimestamp(), + } + + if !matchesCreativeCenterAssetFilter(asset, CreativeCenterAssetQueryParams{Keyword: "task-image-1"}) { + t.Fatalf("expected task id keyword to match asset filter") + } +} diff --git a/model/creative_center_history.go b/model/creative_center_history.go new file mode 100644 index 000000000000..755b8bae4dd3 --- /dev/null +++ b/model/creative_center_history.go @@ -0,0 +1,107 @@ +package model + +import ( + "errors" + "fmt" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +type CreativeCenterHistoryPayload string + +func (CreativeCenterHistoryPayload) GormDataType() string { + return "text" +} + +func (CreativeCenterHistoryPayload) GormDBDataType(db *gorm.DB, _ *schema.Field) string { + switch db.Dialector.Name() { + case "mysql": + return "MEDIUMTEXT" + default: + return "TEXT" + } +} + +type CreativeCenterHistory struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint;index"` + UserId int `json:"user_id" gorm:"uniqueIndex:idx_creative_center_user_tab;index"` + Tab string `json:"tab" gorm:"type:varchar(20);uniqueIndex:idx_creative_center_user_tab;index"` + ModelName string `json:"model_name" gorm:"type:varchar(191);default:''"` + Group string `json:"group" gorm:"type:varchar(50);default:''"` + Prompt string `json:"prompt" gorm:"type:text"` + Payload CreativeCenterHistoryPayload `json:"payload"` +} + +var creativeCenterAllowedTabs = map[string]struct{}{ + "chat": {}, + "image": {}, + "video": {}, +} + +func ValidateCreativeCenterTab(tab string) error { + if _, ok := creativeCenterAllowedTabs[tab]; ok { + return nil + } + return fmt.Errorf("invalid creative center tab: %s", tab) +} + +func UpsertCreativeCenterHistory(userId int, tab string, modelName string, group string, prompt string, payload any) (*CreativeCenterHistory, error) { + if err := ValidateCreativeCenterTab(tab); err != nil { + return nil, err + } + + payloadBytes, err := common.Marshal(payload) + if err != nil { + return nil, err + } + + now := common.GetTimestamp() + history := &CreativeCenterHistory{} + err = DB.Where("user_id = ? AND tab = ?", userId, tab).First(history).Error + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + history = &CreativeCenterHistory{ + UserId: userId, + Tab: tab, + ModelName: modelName, + Group: group, + Prompt: prompt, + Payload: CreativeCenterHistoryPayload(string(payloadBytes)), + CreatedAt: now, + UpdatedAt: now, + } + if err = DB.Create(history).Error; err != nil { + return nil, err + } + return history, nil + } + + history.ModelName = modelName + history.Group = group + history.Prompt = prompt + history.Payload = CreativeCenterHistoryPayload(string(payloadBytes)) + history.UpdatedAt = now + if err = DB.Save(history).Error; err != nil { + return nil, err + } + return history, nil +} + +func ListCreativeCenterHistoriesByUser(userId int) ([]*CreativeCenterHistory, error) { + var histories []*CreativeCenterHistory + err := DB.Where("user_id = ?", userId).Order("updated_at desc").Find(&histories).Error + return histories, err +} + +func DeleteCreativeCenterHistory(userId int, tab string) error { + if err := ValidateCreativeCenterTab(tab); err != nil { + return err + } + return DB.Where("user_id = ? AND tab = ?", userId, tab).Delete(&CreativeCenterHistory{}).Error +} diff --git a/model/log.go b/model/log.go index 2d4782fa564d..eb257b956825 100644 --- a/model/log.go +++ b/model/log.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -242,6 +243,16 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { } } +func UpdateConsumeLogUseTimeByRequestId(requestId string, useTimeSeconds int) error { + if strings.TrimSpace(requestId) == "" || useTimeSeconds <= 0 { + return nil + } + + return LOG_DB.Model(&Log{}). + Where("request_id = ? AND type = ? AND use_time < ?", strings.TrimSpace(requestId), LogTypeConsume, useTimeSeconds). + Update("use_time", useTimeSeconds).Error +} + func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) { var tx *gorm.DB if logType == LogTypeUnknown { diff --git a/model/main.go b/model/main.go index f37cb667cd43..93cfa1c7e2bf 100644 --- a/model/main.go +++ b/model/main.go @@ -268,6 +268,7 @@ func migrateDB() error { &TopUp{}, &QuotaData{}, &Task{}, + &CreativeCenterHistory{}, &Model{}, &Vendor{}, &PrefillGroup{}, @@ -284,6 +285,9 @@ func migrateDB() error { if err != nil { return err } + if err := migrateCreativeCenterHistoryPayloadToMediumText(); err != nil { + return err + } if common.UsingSQLite { if err := ensureSubscriptionPlanTableSQLite(); err != nil { return err @@ -316,6 +320,7 @@ func migrateDBFast() error { {&TopUp{}, "TopUp"}, {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, + {&CreativeCenterHistory{}, "CreativeCenterHistory"}, {&Model{}, "Model"}, {&Vendor{}, "Vendor"}, {&PrefillGroup{}, "PrefillGroup"}, @@ -352,6 +357,9 @@ func migrateDBFast() error { return err } } + if err := migrateCreativeCenterHistoryPayloadToMediumText(); err != nil { + return err + } if common.UsingSQLite { if err := ensureSubscriptionPlanTableSQLite(); err != nil { return err @@ -562,6 +570,50 @@ func migrateSubscriptionPlanPriceAmount() { } } +func migrateCreativeCenterHistoryPayloadToMediumText() error { + if common.UsingSQLite { + return nil + } + + tableName := "creative_center_histories" + columnName := "payload" + + if !DB.Migrator().HasTable(tableName) { + return nil + } + + if !DB.Migrator().HasColumn(&CreativeCenterHistory{}, columnName) { + return nil + } + + if common.UsingPostgreSQL { + return nil + } + + if !common.UsingMySQL { + return nil + } + + var columnType string + if err := DB.Raw(`SELECT COLUMN_TYPE FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, + tableName, columnName).Scan(&columnType).Error; err != nil { + return fmt.Errorf("failed to query metadata for %s.%s: %w", tableName, columnName, err) + } + + switch strings.ToLower(columnType) { + case "mediumtext", "longtext": + return nil + } + + alterSQL := fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN %s mediumtext", tableName, columnName) + if err := DB.Exec(alterSQL).Error; err != nil { + return fmt.Errorf("failed to migrate %s.%s to mediumtext: %w", tableName, columnName, err) + } + common.SysLog(fmt.Sprintf("Successfully migrated %s.%s to mediumtext", tableName, columnName)) + return nil +} + func closeDB(db *gorm.DB) error { sqlDB, err := db.DB() if err != nil { diff --git a/model/option.go b/model/option.go index 967fa0aa6708..24a0224a7ad2 100644 --- a/model/option.go +++ b/model/option.go @@ -69,6 +69,8 @@ func InitOptionMap() { common.OptionMap["SystemName"] = common.SystemName common.OptionMap["Logo"] = common.Logo common.OptionMap["ServerAddress"] = "" + common.OptionMap["CreativeCenterImageBedURL"] = system_setting.CreativeCenterImageBedURL + common.OptionMap["CreativeCenterImageBedApiKey"] = system_setting.CreativeCenterImageBedApiKey common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled) @@ -130,6 +132,11 @@ func InitOptionMap() { common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString() common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() + common.OptionMap["GroupModelPrice"] = ratio_setting.GroupModelPrice2JSONString() + common.OptionMap["ModelPriceBySeconds"] = ratio_setting.ModelPriceBySeconds2JSONString() + common.OptionMap["ModelPriceByResolution"] = ratio_setting.ModelPriceByResolution2JSONString() + common.OptionMap["GroupModelPriceBySeconds"] = ratio_setting.GroupModelPriceBySeconds2JSONString() + common.OptionMap["GroupModelPriceByResolution"] = ratio_setting.GroupModelPriceByResolution2JSONString() common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString() common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() @@ -332,6 +339,10 @@ func updateOptionMap(key string, value string) (err error) { common.SMTPToken = value case "ServerAddress": system_setting.ServerAddress = value + case "CreativeCenterImageBedURL": + system_setting.CreativeCenterImageBedURL = value + case "CreativeCenterImageBedApiKey": + system_setting.CreativeCenterImageBedApiKey = value case "WorkerUrl": system_setting.WorkerUrl = value case "WorkerValidKey": @@ -472,6 +483,16 @@ func updateOptionMap(key string, value string) (err error) { err = ratio_setting.UpdateCompletionRatioByJSONString(value) case "ModelPrice": err = ratio_setting.UpdateModelPriceByJSONString(value) + case "GroupModelPrice": + err = ratio_setting.UpdateGroupModelPriceByJSONString(value) + case "ModelPriceBySeconds": + err = ratio_setting.UpdateModelPriceBySecondsByJSONString(value) + case "ModelPriceByResolution": + err = ratio_setting.UpdateModelPriceByResolutionByJSONString(value) + case "GroupModelPriceBySeconds": + err = ratio_setting.UpdateGroupModelPriceBySecondsByJSONString(value) + case "GroupModelPriceByResolution": + err = ratio_setting.UpdateGroupModelPriceByResolutionByJSONString(value) case "CacheRatio": err = ratio_setting.UpdateCacheRatioByJSONString(value) case "CreateCacheRatio": diff --git a/model/pricing.go b/model/pricing.go index 54ae98451337..fb34bc35b3f0 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -15,24 +15,30 @@ import ( ) type Pricing struct { - ModelName string `json:"model_name"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - Tags string `json:"tags,omitempty"` - VendorID int `json:"vendor_id,omitempty"` - QuotaType int `json:"quota_type"` - ModelRatio float64 `json:"model_ratio"` - ModelPrice float64 `json:"model_price"` - OwnerBy string `json:"owner_by"` - CompletionRatio float64 `json:"completion_ratio"` - CacheRatio *float64 `json:"cache_ratio,omitempty"` - CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"` - ImageRatio *float64 `json:"image_ratio,omitempty"` - AudioRatio *float64 `json:"audio_ratio,omitempty"` - AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` - EnableGroup []string `json:"enable_groups"` - SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` - PricingVersion string `json:"pricing_version,omitempty"` + ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + ChannelType int `json:"channel_type,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + GroupModelPrice map[string]float64 `json:"group_model_price,omitempty"` + ModelPriceBySeconds map[string]float64 `json:"model_price_by_seconds,omitempty"` + ModelPriceByResolution map[string]float64 `json:"model_price_by_resolution,omitempty"` + GroupModelPriceBySeconds map[string]map[string]float64 `json:"group_model_price_by_seconds,omitempty"` + GroupModelPriceByResolution map[string]map[string]float64 `json:"group_model_price_by_resolution,omitempty"` + OwnerBy string `json:"owner_by"` + CompletionRatio float64 `json:"completion_ratio"` + CacheRatio *float64 `json:"cache_ratio,omitempty"` + CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"` + ImageRatio *float64 `json:"image_ratio,omitempty"` + AudioRatio *float64 `json:"audio_ratio,omitempty"` + AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` + EnableGroup []string `json:"enable_groups"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` + PricingVersion string `json:"pricing_version,omitempty"` } type PricingVendor struct { @@ -95,6 +101,39 @@ func GetModelSupportEndpointTypes(model string) []constant.EndpointType { return make([]constant.EndpointType, 0) } +func buildGroupModelPriceMap(source map[string]map[string]map[string]float64, model string) map[string]map[string]float64 { + result := make(map[string]map[string]float64) + for group, modelMap := range source { + priceMap, ok := modelMap[model] + if !ok || len(priceMap) == 0 { + continue + } + result[group] = make(map[string]float64, len(priceMap)) + for key, price := range priceMap { + result[group][key] = price + } + } + if len(result) == 0 { + return nil + } + return result +} + +func buildGroupModelPriceValueMap(source map[string]map[string]float64, model string) map[string]float64 { + result := make(map[string]float64) + for group, modelMap := range source { + price, ok := modelMap[model] + if !ok { + continue + } + result[group] = price + } + if len(result) == 0 { + return nil + } + return result +} + func updatePricing() { //modelRatios := common.GetModelRatios() enableAbilities, err := GetAllEnableAbilityWithChannels() @@ -273,12 +312,24 @@ func updatePricing() { } } + modelPriceBySecondsMap := ratio_setting.GetModelPriceBySecondsCopy() + modelPriceByResolutionMap := ratio_setting.GetModelPriceByResolutionCopy() + groupModelPriceMap := ratio_setting.GetGroupModelPriceCopy() + groupModelPriceBySecondsMap := ratio_setting.GetGroupModelPriceBySecondsCopy() + groupModelPriceByResolutionMap := ratio_setting.GetGroupModelPriceByResolutionCopy() + modelChannelTypeMap := make(map[string]int) + for _, ability := range enableAbilities { + if _, ok := modelChannelTypeMap[ability.Model]; !ok && ability.ChannelType != 0 { + modelChannelTypeMap[ability.Model] = ability.ChannelType + } + } pricingMap = make([]Pricing, 0) for model, groups := range modelGroupsMap { pricing := Pricing{ ModelName: model, EnableGroup: groups.Items(), SupportedEndpointTypes: modelSupportEndpointTypes[model], + ChannelType: modelChannelTypeMap[model], } // 补充模型元数据(描述、标签、供应商、状态) @@ -292,8 +343,26 @@ func updatePricing() { pricing.Tags = meta.Tags pricing.VendorID = meta.VendorID } + formattedModelName := ratio_setting.FormatMatchingModelName(model) + pricing.GroupModelPrice = buildGroupModelPriceValueMap(groupModelPriceMap, formattedModelName) + pricing.GroupModelPriceBySeconds = buildGroupModelPriceMap(groupModelPriceBySecondsMap, formattedModelName) + pricing.GroupModelPriceByResolution = buildGroupModelPriceMap(groupModelPriceByResolutionMap, formattedModelName) + secondsPriceMap, hasSecondsPrice := modelPriceBySecondsMap[formattedModelName] + resolutionPriceMap, hasResolutionPrice := modelPriceByResolutionMap[formattedModelName] modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) - if findPrice { + if hasSecondsPrice && len(secondsPriceMap) > 0 { + pricing.ModelPriceBySeconds = make(map[string]float64, len(secondsPriceMap)) + for seconds, price := range secondsPriceMap { + pricing.ModelPriceBySeconds[seconds] = price + } + pricing.QuotaType = 2 + } else if hasResolutionPrice && len(resolutionPriceMap) > 0 { + pricing.ModelPriceByResolution = make(map[string]float64, len(resolutionPriceMap)) + for resolution, price := range resolutionPriceMap { + pricing.ModelPriceByResolution[resolution] = price + } + pricing.QuotaType = 3 + } else if findPrice { pricing.ModelPrice = modelPrice pricing.QuotaType = 1 } else { diff --git a/model/task.go b/model/task.go index 2fbd3fd666b1..7f3c768774d0 100644 --- a/model/task.go +++ b/model/task.go @@ -4,6 +4,8 @@ import ( "bytes" "database/sql/driver" "encoding/json" + "sort" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -97,9 +99,12 @@ func (m Properties) Value() (driver.Value, error) { } type TaskPrivateData struct { - Key string `json:"key,omitempty"` - UpstreamTaskID string `json:"upstream_task_id,omitempty"` // 上游真实 task ID - ResultURL string `json:"result_url,omitempty"` // 任务成功后的结果 URL(视频地址等) + Key string `json:"key,omitempty"` + UpstreamTaskID string `json:"upstream_task_id,omitempty"` // 上游真实 task ID + UpstreamRequestPath string `json:"upstream_request_path,omitempty"` + RequestId string `json:"request_id,omitempty"` + ClientRequestId string `json:"client_request_id,omitempty"` + ResultURL string `json:"result_url,omitempty"` // 任务成功后的结果 URL(视频地址等) // 计费上下文:用于异步退款/差额结算(轮询阶段读取) BillingSource string `json:"billing_source,omitempty"` // "wallet" 或 "subscription" SubscriptionId int `json:"subscription_id,omitempty"` // 订阅 ID,用于订阅退款 @@ -109,12 +114,15 @@ type TaskPrivateData struct { // TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。 type TaskBillingContext struct { - ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 - GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 - ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 - OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) - OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName - PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 + ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 + GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 + ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 + OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) + OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName + PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 + GroupPriceOverride bool `json:"group_price_override,omitempty"` + GroupPriceOverrideGroup string `json:"group_price_override_group,omitempty"` + UsingGroup string `json:"using_group,omitempty"` } // GetUpstreamTaskID 获取上游真实 task ID(用于与 provider 通信) @@ -135,6 +143,13 @@ func (t *Task) GetResultURL() string { return t.FailReason } +func (t *Task) GetRequestID() string { + if requestID := strings.TrimSpace(t.PrivateData.ClientRequestId); requestID != "" { + return requestID + } + return strings.TrimSpace(t.PrivateData.RequestId) +} + // GenerateTaskID 生成对外暴露的 task_xxxx 格式 ID func GenerateTaskID() string { key, _ := common.GenerateRandomCharsKey(32) @@ -164,6 +179,7 @@ type SyncTaskQueryParams struct { UserID string Action string Status string + MediaType string StartTimestamp int64 EndTimestamp int64 UserIDs []int @@ -213,20 +229,8 @@ func TaskGetAllUserTask(userId int, startIdx int, num int, queryParams SyncTaskQ var err error // 初始化查询构建器 - query := DB.Where("user_id = ?", userId) + query := applySyncTaskQueryFilters(DB.Where("user_id = ?", userId), queryParams) - 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) @@ -249,7 +253,7 @@ func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []* var err error // 初始化查询构建器 - query := DB + query := applySyncTaskQueryFilters(DB, queryParams) // 添加过滤条件 if queryParams.ChannelID != "" { @@ -357,6 +361,95 @@ func GetByTaskIds(userId int, taskIds []any) ([]*Task, error) { return task, nil } +func TaskGetUserTasksByIdentifiers(userId int, taskIDs []string, requestIDs []string, queryParams SyncTaskQueryParams, limit int) []*Task { + _ = queryParams + resultMap := make(map[string]*Task) + + appendTask := func(task *Task) { + if task == nil { + return + } + taskID := strings.TrimSpace(task.TaskID) + if taskID == "" { + return + } + if _, exists := resultMap[taskID]; exists { + return + } + resultMap[taskID] = task + } + + normalizedTaskIDs := make([]string, 0, len(taskIDs)) + for _, taskID := range taskIDs { + trimmed := strings.TrimSpace(taskID) + if trimmed != "" { + normalizedTaskIDs = append(normalizedTaskIDs, trimmed) + } + } + + if len(normalizedTaskIDs) > 0 { + var exactTasks []*Task + if err := DB.Where("user_id = ?", userId). + Where("task_id IN ?", normalizedTaskIDs). + Find(&exactTasks).Error; err == nil { + for _, task := range exactTasks { + appendTask(task) + } + } + } + + normalizedRequestIDs := make(map[string]struct{}, len(requestIDs)) + for _, requestID := range requestIDs { + trimmed := strings.TrimSpace(requestID) + if trimmed != "" { + normalizedRequestIDs[trimmed] = struct{}{} + } + } + + if len(normalizedRequestIDs) > 0 { + if limit <= 0 { + limit = 2000 + } else if limit < 2000 { + limit = 2000 + } + var requestTasks []*Task + if err := DB.Where("user_id = ?", userId). + Order("id desc"). + Limit(limit). + Find(&requestTasks).Error; err == nil { + for _, task := range requestTasks { + candidateRequestIDs := []string{ + strings.TrimSpace(task.PrivateData.ClientRequestId), + strings.TrimSpace(task.PrivateData.RequestId), + } + matched := false + for _, requestID := range candidateRequestIDs { + if requestID == "" { + continue + } + if _, ok := normalizedRequestIDs[requestID]; ok { + matched = true + break + } + } + if !matched { + continue + } + appendTask(task) + } + } + } + + result := make([]*Task, 0, len(resultMap)) + for _, task := range resultMap { + result = append(result, task) + } + sort.Slice(result, func(i, j int) bool { + return result[i].SubmitTime > result[j].SubmitTime + }) + return result +} + func (Task *Task) Insert() error { var err error err = DB.Create(Task).Error @@ -438,7 +531,7 @@ type TaskQuotaUsage struct { // TaskCountAllTasks returns total tasks that match the given query params (admin usage) func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 { var total int64 - query := DB.Model(&Task{}) + query := applySyncTaskQueryFilters(DB.Model(&Task{}), queryParams) if queryParams.ChannelID != "" { query = query.Where("channel_id = ?", queryParams.ChannelID) } @@ -473,7 +566,7 @@ func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 { // 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) + query := applySyncTaskQueryFilters(DB.Model(&Task{}).Where("user_id = ?", userId), queryParams) if queryParams.TaskID != "" { query = query.Where("task_id = ?", queryParams.TaskID) } @@ -495,6 +588,7 @@ func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 { _ = query.Count(&total).Error return total } + func (t *Task) ToOpenAIVideo() *dto.OpenAIVideo { openAIVideo := dto.NewOpenAIVideo() openAIVideo.ID = t.TaskID diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 3449c6d262f7..2a523898518e 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -215,3 +215,32 @@ func TestUpdateWithStatus_ConcurrentWinner(t *testing.T) { } assert.Equal(t, 1, winCount, "exactly one goroutine should win the CAS") } + +func TestTaskGetRequestIDPrefersClientRequestID(t *testing.T) { + task := &Task{ + PrivateData: TaskPrivateData{ + RequestId: "internal-request-id", + ClientRequestId: "creative-request-id", + }, + } + + assert.Equal(t, "creative-request-id", task.GetRequestID()) +} + +func TestTaskGetUserTasksByIdentifiersMatchesClientRequestID(t *testing.T) { + truncateTables(t) + + task := &Task{ + TaskID: "task_request_match", + UserId: 1, + SubmitTime: time.Now().Unix(), + Status: TaskStatusSubmitted, + PrivateData: TaskPrivateData{RequestId: "internal-request-id", ClientRequestId: "creative-request-id"}, + Data: json.RawMessage(`{}`), + } + insertTask(t, task) + + items := TaskGetUserTasksByIdentifiers(1, nil, []string{"creative-request-id"}, SyncTaskQueryParams{}, 10) + require.Len(t, items, 1) + assert.Equal(t, task.TaskID, items[0].TaskID) +} diff --git a/model/task_stats.go b/model/task_stats.go new file mode 100644 index 000000000000..909bd3fb8b01 --- /dev/null +++ b/model/task_stats.go @@ -0,0 +1,240 @@ +package model + +import ( + "strings" + + "github.com/QuantumNous/new-api/dto" + "gorm.io/gorm" +) + +const ( + TaskMediaTypeAll = "all" + TaskMediaTypeImage = "image" + TaskMediaTypeVideo = "video" +) + +var taskMediaTypeActions = map[string][]string{ + TaskMediaTypeImage: { + "imageGenerate", + "imageEdit", + }, + TaskMediaTypeVideo: { + "generate", + "textGenerate", + "firstTailGenerate", + "referenceGenerate", + "remixGenerate", + }, +} + +func normalizeTaskStatus(status TaskStatus) TaskStatus { + normalizedStatus := strings.ToUpper(strings.TrimSpace(string(status))) + switch normalizedStatus { + case string(TaskStatusNotStart): + return TaskStatusNotStart + case string(TaskStatusSubmitted): + return TaskStatusSubmitted + case string(TaskStatusQueued), "PENDING": + return TaskStatusQueued + case string(TaskStatusInProgress), "PROCESSING", "RUNNING": + return TaskStatusInProgress + case string(TaskStatusSuccess), "SUCCEEDED", "COMPLETED", "DONE": + return TaskStatusSuccess + case string(TaskStatusFailure), "FAILED", "ERROR", "CANCELED", "CANCELLED": + return TaskStatusFailure + default: + return status + } +} + +func normalizeTaskMediaType(mediaType string) string { + switch strings.ToLower(strings.TrimSpace(mediaType)) { + case TaskMediaTypeImage: + return TaskMediaTypeImage + case TaskMediaTypeVideo: + return TaskMediaTypeVideo + case TaskMediaTypeAll: + return TaskMediaTypeAll + default: + return "" + } +} + +func getTaskActionsForMediaType(mediaType string) []string { + normalizedMediaType := normalizeTaskMediaType(mediaType) + switch normalizedMediaType { + case TaskMediaTypeImage: + return append([]string(nil), taskMediaTypeActions[TaskMediaTypeImage]...) + case TaskMediaTypeVideo: + return append([]string(nil), taskMediaTypeActions[TaskMediaTypeVideo]...) + case TaskMediaTypeAll: + allActions := make([]string, 0, len(taskMediaTypeActions[TaskMediaTypeImage])+len(taskMediaTypeActions[TaskMediaTypeVideo])) + allActions = append(allActions, taskMediaTypeActions[TaskMediaTypeImage]...) + allActions = append(allActions, taskMediaTypeActions[TaskMediaTypeVideo]...) + return allActions + default: + return nil + } +} + +func detectTaskMediaType(action string) string { + trimmedAction := strings.TrimSpace(action) + for mediaType, actions := range taskMediaTypeActions { + for _, candidate := range actions { + if candidate == trimmedAction { + return mediaType + } + } + } + return "" +} + +func isRunningTaskStatus(status TaskStatus) bool { + status = normalizeTaskStatus(status) + switch status { + case TaskStatusNotStart, TaskStatusSubmitted, TaskStatusQueued, TaskStatusInProgress: + return true + default: + return false + } +} + +func resolveTaskStatsBreakdown(status TaskStatus, progress string, failReason string) string { + normalizedStatus := normalizeTaskStatus(status) + normalizedProgress := strings.TrimSpace(progress) + normalizedFailReason := strings.TrimSpace(failReason) + + switch { + case normalizedStatus == TaskStatusFailure: + return "failure" + case normalizedStatus == TaskStatusSuccess: + return "success" + case isRunningTaskStatus(normalizedStatus): + return "running" + case normalizedFailReason != "": + return "failure" + } + + if normalizedProgress == "" { + return "" + } + + switch strings.ToUpper(normalizedProgress) { + case "100", "100%", "SUCCESS", "SUCCEEDED", "COMPLETED", "DONE": + return "success" + case "FAILED", "FAILURE", "ERROR", "CANCELED", "CANCELLED": + return "failure" + } + + if strings.HasSuffix(normalizedProgress, "%") { + trimmedProgress := strings.TrimSuffix(normalizedProgress, "%") + if trimmedProgress != "100" { + return "running" + } + return "success" + } + + return "running" +} + +func applySyncTaskQueryFilters(query *gorm.DB, queryParams SyncTaskQueryParams) *gorm.DB { + 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) + } + if actions := getTaskActionsForMediaType(queryParams.MediaType); len(actions) > 0 { + query = query.Where("action in (?)", actions) + } + return query +} + +type taskStatsAggregateRow struct { + Running int64 `gorm:"column:running"` + Success int64 `gorm:"column:success"` + Failure int64 `gorm:"column:failure"` +} + +const ( + taskStatsStatusExpr = "UPPER(TRIM(COALESCE(status, '')))" + taskStatsProgressExpr = "UPPER(TRIM(COALESCE(progress, '')))" + taskStatsFailReasonExpr = "TRIM(COALESCE(fail_reason, ''))" + taskStatsFailureCond = "(" + taskStatsStatusExpr + " IN ('FAILURE','FAILED','ERROR','CANCELED','CANCELLED') OR (" + taskStatsFailReasonExpr + " <> '' AND " + taskStatsStatusExpr + " NOT IN ('SUCCESS','SUCCEEDED','COMPLETED','DONE')) OR " + taskStatsProgressExpr + " IN ('FAILED','FAILURE','ERROR','CANCELED','CANCELLED'))" + taskStatsSuccessCond = "(" + taskStatsStatusExpr + " IN ('SUCCESS','SUCCEEDED','COMPLETED','DONE') OR (" + taskStatsFailReasonExpr + " = '' AND " + taskStatsProgressExpr + " IN ('100','100%','SUCCESS','SUCCEEDED','COMPLETED','DONE')))" + taskStatsRunningCond = "(" + taskStatsStatusExpr + " IN ('NOT_START','SUBMITTED','QUEUED','IN_PROGRESS','PENDING','PROCESSING','RUNNING') OR (" + taskStatsFailReasonExpr + " = '' AND " + taskStatsStatusExpr + " NOT IN ('SUCCESS','SUCCEEDED','COMPLETED','DONE','FAILURE','FAILED','ERROR','CANCELED','CANCELLED','NOT_START','SUBMITTED','QUEUED','IN_PROGRESS','PENDING','PROCESSING','RUNNING') AND " + taskStatsProgressExpr + " <> '' AND " + taskStatsProgressExpr + " NOT IN ('100','100%','SUCCESS','SUCCEEDED','COMPLETED','DONE','FAILED','FAILURE','ERROR','CANCELED','CANCELLED')))" +) + +func buildTaskStatsBaseQuery(queryParams SyncTaskQueryParams) *gorm.DB { + return applySyncTaskQueryFilters(DB.Model(&Task{}), queryParams) +} + +func buildTaskStatsUserBaseQuery(userId int, queryParams SyncTaskQueryParams) *gorm.DB { + return applySyncTaskQueryFilters(DB.Model(&Task{}).Where("user_id = ?", userId), queryParams) +} + +func withTaskStatsMediaType(queryParams SyncTaskQueryParams, mediaType string) SyncTaskQueryParams { + next := queryParams + next.MediaType = mediaType + return next +} + +func aggregateTaskStats(query *gorm.DB) dto.TaskStatsBreakdown { + var row taskStatsAggregateRow + err := query.Select( + "SUM(CASE WHEN "+taskStatsRunningCond+" THEN 1 ELSE 0 END) AS running, " + + "SUM(CASE WHEN "+taskStatsSuccessCond+" THEN 1 ELSE 0 END) AS success, " + + "SUM(CASE WHEN "+taskStatsFailureCond+" THEN 1 ELSE 0 END) AS failure", + ).Scan(&row).Error + if err != nil { + return dto.TaskStatsBreakdown{} + } + return dto.TaskStatsBreakdown{ + Running: row.Running, + Success: row.Success, + Failure: row.Failure, + } +} + +func buildTaskStatsResponse(baseQuery func(SyncTaskQueryParams) *gorm.DB, queryParams SyncTaskQueryParams) *dto.TaskStatsResponse { + response := &dto.TaskStatsResponse{} + response.TotalStats = aggregateTaskStats(baseQuery(withTaskStatsMediaType(queryParams, TaskMediaTypeAll))) + response.ImageStats = aggregateTaskStats(baseQuery(withTaskStatsMediaType(queryParams, TaskMediaTypeImage))) + response.VideoStats = aggregateTaskStats(baseQuery(withTaskStatsMediaType(queryParams, TaskMediaTypeVideo))) + response.RunningCount = response.TotalStats.Running + return response +} + +func TaskGetStats(queryParams SyncTaskQueryParams) *dto.TaskStatsResponse { + return buildTaskStatsResponse(buildTaskStatsBaseQuery, queryParams) +} + +func TaskGetUserStats(userId int, queryParams SyncTaskQueryParams) *dto.TaskStatsResponse { + return buildTaskStatsResponse( + func(params SyncTaskQueryParams) *gorm.DB { + return buildTaskStatsUserBaseQuery(userId, params) + }, + queryParams, + ) +} diff --git a/model/task_stats_test.go b/model/task_stats_test.go new file mode 100644 index 000000000000..38884ea6ae2f --- /dev/null +++ b/model/task_stats_test.go @@ -0,0 +1,125 @@ +package model + +import "testing" + +func TestTaskGetStatsAggregatesByMediaType(t *testing.T) { + truncateTables(t) + + insertTask(t, &Task{ + TaskID: "task-image-success", + UserId: 1, + Action: "imageGenerate", + Status: TaskStatusSuccess, + SubmitTime: 1711933200, + Progress: "100%", + }) + insertTask(t, &Task{ + TaskID: "task-image-failure", + UserId: 1, + Action: "imageEdit", + Status: TaskStatusUnknown, + SubmitTime: 1711936800, + FailReason: "upstream failed", + }) + insertTask(t, &Task{ + TaskID: "task-video-running-progress", + UserId: 1, + Action: "generate", + Status: TaskStatusUnknown, + SubmitTime: 1712019600, + Progress: "85%", + }) + insertTask(t, &Task{ + TaskID: "task-video-running-status", + UserId: 1, + Action: "textGenerate", + Status: TaskStatus("PENDING"), + SubmitTime: 1712023200, + }) + insertTask(t, &Task{ + TaskID: "task-video-success", + UserId: 1, + Action: "remixGenerate", + Status: TaskStatusSuccess, + SubmitTime: 1712026800, + Progress: "100%", + }) + insertTask(t, &Task{ + TaskID: "task-non-media", + UserId: 1, + Action: "speech", + Status: TaskStatusSuccess, + SubmitTime: 1712026800, + Progress: "100%", + }) + + stats := TaskGetStats(SyncTaskQueryParams{ + MediaType: TaskMediaTypeAll, + StartTimestamp: 1711929600, + EndTimestamp: 1712102399, + }) + + if stats.RunningCount != 2 { + t.Fatalf("expected running_count=2, got %d", stats.RunningCount) + } + if len(stats.DailyCounts) != 0 { + t.Fatalf("expected no daily counts, got %d", len(stats.DailyCounts)) + } + if stats.TotalStats.Success != 2 || stats.TotalStats.Failure != 1 || stats.TotalStats.Running != 2 { + t.Fatalf("unexpected total stats: %+v", stats.TotalStats) + } + if stats.ImageStats.Success != 1 || stats.ImageStats.Failure != 1 || stats.ImageStats.Running != 0 { + t.Fatalf("unexpected image stats: %+v", stats.ImageStats) + } + if stats.VideoStats.Success != 1 || stats.VideoStats.Running != 2 || stats.VideoStats.Failure != 0 { + t.Fatalf("unexpected video stats: %+v", stats.VideoStats) + } +} + +func TestTaskGetUserStatsFiltersByUser(t *testing.T) { + truncateTables(t) + + insertTask(t, &Task{ + TaskID: "task-user-1", + UserId: 1, + Action: "generate", + Status: TaskStatus("PROCESSING"), + SubmitTime: 1712019600, + }) + insertTask(t, &Task{ + TaskID: "task-user-2", + UserId: 2, + Action: "generate", + Status: TaskStatusFailure, + SubmitTime: 1712019600, + FailReason: "failed", + Progress: "100%", + }) + + stats := TaskGetUserStats(1, SyncTaskQueryParams{ + MediaType: TaskMediaTypeAll, + StartTimestamp: 1711929600, + EndTimestamp: 1712102399, + }) + + if stats.TotalStats.Running != 1 || stats.TotalStats.Success != 0 || stats.TotalStats.Failure != 0 { + t.Fatalf("unexpected user-scoped stats: %+v", stats.TotalStats) + } +} + +func TestGetTaskActionsForMediaType(t *testing.T) { + allActions := getTaskActionsForMediaType(TaskMediaTypeAll) + if len(allActions) != 7 { + t.Fatalf("expected 7 actions for all media type, got %d", len(allActions)) + } + + imageActions := getTaskActionsForMediaType(TaskMediaTypeImage) + if len(imageActions) != 2 { + t.Fatalf("expected 2 image actions, got %d", len(imageActions)) + } + + videoActions := getTaskActionsForMediaType(TaskMediaTypeVideo) + if len(videoActions) != 5 { + t.Fatalf("expected 5 video actions, got %d", len(videoActions)) + } +} diff --git a/model/user.go b/model/user.go index 1210b5435d04..1b1e9cead10a 100644 --- a/model/user.go +++ b/model/user.go @@ -114,6 +114,7 @@ func generateDefaultSidebarConfigForRole(userRole int) string { "token": true, "log": true, "midjourney": true, + "asset": true, "task": true, } diff --git a/old_index.jsx b/old_index.jsx new file mode 100644 index 000000000000..935987066dcb Binary files /dev/null and b/old_index.jsx differ diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 29a8f34949c4..a2182e91c5df 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -2,13 +2,17 @@ package openai import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" "io" + "mime" "mime/multipart" "net/http" "net/textproto" + neturl "net/url" + "path" "path/filepath" "strings" @@ -442,7 +446,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf writer.WriteField("model", request.Model) // 使用已解析的 multipart 表单,避免重复解析 mf := c.Request.MultipartForm - if mf == nil { + if mf == nil && strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { if _, err := c.MultipartForm(); err != nil { return nil, errors.New("failed to parse multipart form") } @@ -459,6 +463,22 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf writer.WriteField(key, value) } } + } else { + if request.Prompt != "" { + writer.WriteField("prompt", request.Prompt) + } + if request.N != nil && *request.N > 0 { + writer.WriteField("n", fmt.Sprintf("%d", *request.N)) + } + if request.Size != "" { + writer.WriteField("size", request.Size) + } + if request.Quality != "" { + writer.WriteField("quality", request.Quality) + } + if request.ResponseFormat != "" { + writer.WriteField("response_format", request.ResponseFormat) + } } if mf != nil && mf.File != nil { @@ -546,8 +566,8 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } _ = maskFile.Close() } - } else { - return nil, errors.New("no multipart form data found") + } else if err := appendImagePartsFromJSONPayload(writer, request.Image); err != nil { + return nil, err } // 关闭 multipart 编写器以设置分界线 @@ -580,6 +600,194 @@ func detectImageMimeType(filename string) string { } } +type openAIEditImagePayload struct { + URL string `json:"url"` + Data string `json:"data"` + B64JSON string `json:"b64_json"` + Filename string `json:"filename"` + Name string `json:"name"` + MimeType string `json:"mime_type"` +} + +func appendImagePartsFromJSONPayload(writer *multipart.Writer, rawImage []byte) error { + var rawImages []json.RawMessage + if err := common.Unmarshal(rawImage, &rawImages); err == nil && len(rawImages) > 0 { + fieldName := "image" + if len(rawImages) > 1 { + fieldName = "image[]" + } + for _, item := range rawImages { + if err := appendImagePartFromJSONPayload(writer, fieldName, item); err != nil { + return err + } + } + return nil + } + + return appendImagePartFromJSONPayload(writer, "image", rawImage) +} + +func appendImagePartFromJSONPayload(writer *multipart.Writer, fieldName string, rawImage []byte) error { + filename, mimeType, content, err := resolveOpenAIEditImagePayload(rawImage) + if err != nil { + return err + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, filename)) + h.Set("Content-Type", mimeType) + part, err := writer.CreatePart(h) + if err != nil { + return fmt.Errorf("create form file failed for image: %w", err) + } + if _, err = part.Write(content); err != nil { + return fmt.Errorf("write image file failed: %w", err) + } + return nil +} + +func resolveOpenAIEditImagePayload(rawImage []byte) (string, string, []byte, error) { + trimmed := strings.TrimSpace(string(rawImage)) + if len(rawImage) == 0 || trimmed == "" || trimmed == "null" { + return "", "", nil, errors.New("image is required") + } + + var simpleString string + if err := common.Unmarshal(rawImage, &simpleString); err == nil { + return resolveOpenAIEditImageSource(simpleString, "", "") + } + + var payload openAIEditImagePayload + if err := common.Unmarshal(rawImage, &payload); err == nil { + filename := payload.Filename + if filename == "" { + filename = payload.Name + } + switch { + case payload.URL != "": + return resolveOpenAIEditImageSource(payload.URL, filename, payload.MimeType) + case payload.Data != "": + return resolveOpenAIEditImageSource(payload.Data, filename, payload.MimeType) + case payload.B64JSON != "": + return resolveOpenAIEditImageSource(payload.B64JSON, filename, payload.MimeType) + } + } + + return "", "", nil, errors.New("image is required") +} + +func resolveOpenAIEditImageSource(source string, preferredFilename string, preferredMime string) (string, string, []byte, error) { + source = strings.TrimSpace(source) + if source == "" { + return "", "", nil, errors.New("image is required") + } + + if strings.HasPrefix(source, "data:") { + return decodeOpenAIEditDataURL(source, preferredFilename) + } + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + return downloadOpenAIEditRemoteImage(source, preferredFilename, preferredMime) + } + + decoded, err := base64.StdEncoding.DecodeString(source) + if err != nil { + return "", "", nil, fmt.Errorf("unsupported image source for image edit: %w", err) + } + mimeType := preferredMime + if mimeType == "" { + mimeType = "image/png" + } + return ensureOpenAIEditFilename(preferredFilename, mimeType), mimeType, decoded, nil +} + +func decodeOpenAIEditDataURL(source string, preferredFilename string) (string, string, []byte, error) { + parts := strings.SplitN(source, ",", 2) + if len(parts) != 2 { + return "", "", nil, errors.New("invalid image data url") + } + header := parts[0] + payload := parts[1] + if !strings.HasSuffix(header, ";base64") { + return "", "", nil, errors.New("image data url must be base64 encoded") + } + + mimeType := strings.TrimPrefix(strings.TrimSuffix(header, ";base64"), "data:") + if mimeType == "" { + mimeType = "image/png" + } + content, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", "", nil, fmt.Errorf("failed to decode image data url: %w", err) + } + return ensureOpenAIEditFilename(preferredFilename, mimeType), mimeType, content, nil +} + +func downloadOpenAIEditRemoteImage(source string, preferredFilename string, preferredMime string) (string, string, []byte, error) { + resp, err := service.DoDownloadRequest(source, "openai image edit source") + if err != nil { + return "", "", nil, fmt.Errorf("failed to download edit image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", "", nil, fmt.Errorf("failed to download edit image: status %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", nil, fmt.Errorf("failed to read downloaded edit image: %w", err) + } + + mimeType := preferredMime + if mimeType == "" { + mimeType = detectOpenAIEditMimeFromHeader(resp.Header.Get("Content-Type")) + } + filename := preferredFilename + if filename == "" { + filename = openAIEditFilenameFromURL(source) + } + return ensureOpenAIEditFilename(filename, mimeType), mimeType, content, nil +} + +func detectOpenAIEditMimeFromHeader(contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType == "" { + return "image/png" + } + return mediaType +} + +func openAIEditFilenameFromURL(source string) string { + parsed, err := neturl.Parse(source) + if err != nil { + return "" + } + filename := path.Base(parsed.Path) + if filename == "." || filename == "/" { + return "" + } + return filename +} + +func ensureOpenAIEditFilename(filename string, mimeType string) string { + filename = strings.TrimSpace(filename) + if filename != "" && filepath.Ext(filename) != "" { + return filename + } + + exts, err := mime.ExtensionsByType(mimeType) + if err == nil && len(exts) > 0 { + if filename == "" { + return "image" + exts[0] + } + return filename + exts[0] + } + if filename == "" { + return "image.png" + } + return filename + ".png" +} + func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { // 转换模型推理力度后缀 effort, originModel := parseReasoningEffortFromModelSuffix(request.Model) diff --git a/relay/channel/openai/adaptor_test.go b/relay/channel/openai/adaptor_test.go new file mode 100644 index 000000000000..aa6e8bc9d82c --- /dev/null +++ b/relay/channel/openai/adaptor_test.go @@ -0,0 +1,153 @@ +package openai + +import ( + "bytes" + "io" + "mime" + "mime/multipart" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/samber/lo" + "github.com/tidwall/gjson" + + "github.com/gin-gonic/gin" +) + +func TestConvertImageRequestAllowsJSONEditPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/edits", nil) + + adaptor := &Adaptor{} + converted, err := adaptor.ConvertImageRequest(ctx, &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeImagesEdits, + }, dto.ImageRequest{ + Model: "grok-imagine-1.0-edit", + Prompt: "enhance this image", + N: lo.ToPtr(uint(1)), + Size: "1024x1024", + ResponseFormat: "url", + Image: []byte(`{"url":"data:image/png;base64,aGVsbG8="}`), + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + body, ok := converted.(*bytes.Buffer) + if !ok { + t.Fatalf("expected *bytes.Buffer, got %T", converted) + } + + mediaType, params, err := mime.ParseMediaType(ctx.Request.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("parse content type failed: %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("unexpected media type: %s", mediaType) + } + + form, err := multipart.NewReader(bytes.NewReader(body.Bytes()), params["boundary"]).ReadForm(1 << 20) + if err != nil { + t.Fatalf("read multipart form failed: %v", err) + } + if form.Value["model"][0] != "grok-imagine-1.0-edit" { + t.Fatalf("unexpected model: %v", form.Value["model"]) + } + if form.Value["prompt"][0] != "enhance this image" { + t.Fatalf("unexpected prompt: %v", form.Value["prompt"]) + } + + files := form.File["image"] + if len(files) != 1 { + t.Fatalf("expected one image file, got %d", len(files)) + } + file, err := files[0].Open() + if err != nil { + t.Fatalf("open image file failed: %v", err) + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + t.Fatalf("read image file failed: %v", err) + } + if string(content) != "hello" { + t.Fatalf("unexpected image file content: %q", string(content)) + } +} + +func TestConvertImageRequestSupportsMultipleJSONEditImages(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/edits", nil) + + adaptor := &Adaptor{} + converted, err := adaptor.ConvertImageRequest(ctx, &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeImagesEdits, + }, dto.ImageRequest{ + Model: "gpt-image2", + Prompt: "combine these references", + Image: []byte(`[ + {"data":"aGVsbG8=","filename":"first.png","mime_type":"image/png"}, + {"data":"d29ybGQ=","filename":"second.png","mime_type":"image/png"} + ]`), + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + body, ok := converted.(*bytes.Buffer) + if !ok { + t.Fatalf("expected *bytes.Buffer, got %T", converted) + } + + mediaType, params, err := mime.ParseMediaType(ctx.Request.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("parse content type failed: %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("unexpected media type: %s", mediaType) + } + + form, err := multipart.NewReader(bytes.NewReader(body.Bytes()), params["boundary"]).ReadForm(1 << 20) + if err != nil { + t.Fatalf("read multipart form failed: %v", err) + } + files := form.File["image[]"] + if len(files) != 2 { + t.Fatalf("expected two image[] files, got %d", len(files)) + } +} + +func TestConvertImageRequestPreservesImageUrlsForGenerations(t *testing.T) { + adaptor := &Adaptor{} + + converted, err := adaptor.ConvertImageRequest(nil, &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeImagesGenerations, + }, dto.ImageRequest{ + Model: "nano-banana-pro", + Prompt: "put logo on toothpaste", + ImageUrls: []byte(`["https://example.com/1.png","https://example.com/2.png"]`), + AspectRatio: "16:9", + OutputResolution: "2K", + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + encoded, err := common.Marshal(converted) + if err != nil { + t.Fatalf("marshal converted request: %v", err) + } + if gjson.GetBytes(encoded, "image_urls.0").String() != "https://example.com/1.png" { + t.Fatalf("unexpected first image_urls item: %s", string(encoded)) + } + if gjson.GetBytes(encoded, "image_urls.1").String() != "https://example.com/2.png" { + t.Fatalf("unexpected second image_urls item: %s", string(encoded)) + } +} diff --git a/relay/channel/openai/constant.go b/relay/channel/openai/constant.go index 14e3d442d4ef..cbebf176e559 100644 --- a/relay/channel/openai/constant.go +++ b/relay/channel/openai/constant.go @@ -65,7 +65,7 @@ var ModelList = []string{ "text-davinci-edit-001", "davinci-002", "babbage-002", "dall-e-2", "dall-e-3", - "gpt-image-1", "gpt-image-1-mini", "gpt-image-1.5", + "gpt-image-1", "gpt-image-1-mini", "gpt-image-1.5", "gpt-image2", "chatgpt-image-latest", "whisper-1", "tts-1", "tts-1-1106", "tts-1-hd", "tts-1-hd-1106", diff --git a/relay/channel/task/gemini/adaptor.go b/relay/channel/task/gemini/adaptor.go index 48aa06319a45..ccb2844f522d 100644 --- a/relay/channel/task/gemini/adaptor.go +++ b/relay/channel/task/gemini/adaptor.go @@ -157,7 +157,7 @@ func (a *TaskAdaptor) GetChannelName() string { return "gemini" } -// EstimateBilling returns OtherRatios based on durationSeconds and resolution. +// EstimateBilling returns OtherRatios based on durationSeconds. func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { v, ok := c.Get("task_request") if !ok { @@ -169,12 +169,9 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf } seconds := ResolveVeoDuration(req.Metadata, req.Duration, req.Seconds) - resolution := ResolveVeoResolution(req.Metadata, req.Size) - resRatio := VeoResolutionRatio(info.UpstreamModelName, resolution) return map[string]float64{ - "seconds": float64(seconds), - "resolution": resRatio, + "seconds": float64(seconds), } } diff --git a/relay/channel/task/gemini/adaptor_test.go b/relay/channel/task/gemini/adaptor_test.go new file mode 100644 index 000000000000..094bbb7bc0ff --- /dev/null +++ b/relay/channel/task/gemini/adaptor_test.go @@ -0,0 +1,32 @@ +package gemini + +import ( + "strings" + "testing" + + "github.com/QuantumNous/new-api/model" +) + +func TestParseTaskResultReadsStringError(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "name": "operations/test", + "done": true, + "error": "video poll failed: 451 {\"error_code\":\"video_unsafe\",\"message\":\"The generated video appears to be unsafe. Try modifying the prompts or the seeds.\"}" + }`)) + if err != nil { + t.Fatalf("ParseTaskResult returned error: %v", err) + } + if taskInfo.Status != model.TaskStatusFailure { + t.Fatalf("expected failure status, got %s", taskInfo.Status) + } + if !strings.Contains(taskInfo.Reason, "video poll failed: 451") { + t.Fatalf("expected string error reason, got %q", taskInfo.Reason) + } + if !strings.Contains(taskInfo.Reason, "video_unsafe") { + t.Fatalf("expected upstream error code in reason, got %q", taskInfo.Reason) + } + if taskInfo.Progress != "100%" { + t.Fatalf("expected complete progress, got %q", taskInfo.Progress) + } +} diff --git a/relay/channel/task/gemini/dto.go b/relay/channel/task/gemini/dto.go index 70a13feec4fa..bf427c6aae62 100644 --- a/relay/channel/task/gemini/dto.go +++ b/relay/channel/task/gemini/dto.go @@ -1,5 +1,7 @@ package gemini +import "github.com/QuantumNous/new-api/common" + // VeoImageInput represents an image input for Veo image-to-video. // Used by both Gemini and Vertex adaptors. type VeoImageInput struct { @@ -65,7 +67,22 @@ type operationResponse struct { } `json:"generatedVideos"` } `json:"generateVideoResponse"` } `json:"response"` - Error struct { - Message string `json:"message"` - } `json:"error"` + Error OperationError `json:"error"` +} + +type OperationError struct { + Message string `json:"message"` + Code string `json:"code"` +} + +func (e *OperationError) UnmarshalJSON(data []byte) error { + switch common.GetJsonType(data) { + case "object": + type operationErrorAlias OperationError + return common.Unmarshal(data, (*operationErrorAlias)(e)) + case "string": + return common.Unmarshal(data, &e.Message) + default: + return nil + } } diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index e9029aa20d46..7c47b2542f92 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -18,9 +18,9 @@ import ( taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/service" - "github.com/gin-gonic/gin" "github.com/pkg/errors" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -39,22 +39,39 @@ type ImageURL struct { } type responseTask struct { - ID string `json:"id"` - TaskID string `json:"task_id,omitempty"` //兼容旧接口 - Object string `json:"object"` - Model string `json:"model"` - Status string `json:"status"` - Progress int `json:"progress"` - CreatedAt int64 `json:"created_at"` - CompletedAt int64 `json:"completed_at,omitempty"` - ExpiresAt int64 `json:"expires_at,omitempty"` - Seconds string `json:"seconds,omitempty"` - Size string `json:"size,omitempty"` - RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"` - Error *struct { - Message string `json:"message"` - Code string `json:"code"` - } `json:"error,omitempty"` + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` + Object string `json:"object"` + Model string `json:"model"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + VideoURL string `json:"video_url,omitempty"` + Progress float64 `json:"progress"` + Created int64 `json:"created,omitempty"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"` + Error *responseTaskError `json:"error,omitempty"` +} + +type responseTaskError struct { + Message string `json:"message"` + Code string `json:"code"` +} + +func (e *responseTaskError) UnmarshalJSON(data []byte) error { + switch common.GetJsonType(data) { + case "object": + type responseTaskErrorAlias responseTaskError + return common.Unmarshal(data, (*responseTaskErrorAlias)(e)) + case "string": + return common.Unmarshal(data, &e.Message) + default: + return nil + } } // ============================ @@ -68,6 +85,327 @@ type TaskAdaptor struct { baseURL string } +const videoGenerationsTaskPath = "/v1/video/generations" + +func trimTaskPathQuery(path string) string { + path = strings.TrimSpace(path) + if idx := strings.Index(path, "?"); idx >= 0 { + path = path[:idx] + } + return path +} + +func usesVideoGenerationsTaskPath(path string) bool { + path = trimTaskPathQuery(path) + return path == videoGenerationsTaskPath || strings.HasPrefix(path, videoGenerationsTaskPath+"/") +} + +func isVideoGenerationsTaskModel(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + return strings.HasPrefix(model, "veo") || + strings.Contains(model, "/veo") || + strings.HasPrefix(model, "sora-2") || + strings.HasPrefix(model, "sora2") +} + +func usesVideoGenerationsTaskEndpoint(path string, modelNames ...string) bool { + if !usesVideoGenerationsTaskPath(path) { + return false + } + for _, modelName := range modelNames { + if isVideoGenerationsTaskModel(modelName) { + return true + } + } + return false +} + +func taskFetchRequestPath(body map[string]any) string { + if body == nil { + return "" + } + if requestPath, ok := body["request_path"].(string); ok { + return requestPath + } + return "" +} + +func taskFetchModel(body map[string]any, key string) string { + if body == nil { + return "" + } + if model, ok := body[key].(string); ok { + return model + } + return "" +} + +func relayInfoUpstreamModelName(info *relaycommon.RelayInfo) string { + if info == nil || info.ChannelMeta == nil { + return "" + } + return info.UpstreamModelName +} + +func buildTaskFetchURL(baseURL string, body map[string]any) (string, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return "", fmt.Errorf("invalid task_id") + } + if usesVideoGenerationsTaskEndpoint( + taskFetchRequestPath(body), + taskFetchModel(body, "model"), + taskFetchModel(body, "origin_model"), + ) { + return fmt.Sprintf("%s%s/%s", baseURL, videoGenerationsTaskPath, taskID), nil + } + return fmt.Sprintf("%s/v1/videos/%s", baseURL, taskID), nil +} + +func formatTaskProgress(progress float64) string { + if progress == float64(int64(progress)) { + return fmt.Sprintf("%d%%", int64(progress)) + } + return fmt.Sprintf("%.1f%%", progress) +} + +func stringifyBodyValue(value any) string { + if value == nil { + return "" + } + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func normalizeGrokVideoQuality(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "720p": + return "high" + case "480p": + return "standard" + case "high", "standard": + return strings.ToLower(strings.TrimSpace(value)) + default: + return strings.TrimSpace(value) + } +} + +func resolutionNameFromQuality(value string) string { + switch normalizeGrokVideoQuality(value) { + case "high": + return "720p" + case "standard": + return "480p" + default: + return "" + } +} + +func qualityFromResolutionName(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "720p": + return "high" + case "480p": + return "standard" + default: + return "" + } +} + +func appendGrokVideoImageReference(target []interface{}, value interface{}) []interface{} { + if value == nil { + return target + } + switch v := value.(type) { + case string: + if trimmed := strings.TrimSpace(v); trimmed != "" { + target = append(target, trimmed) + } + case []string: + for _, item := range v { + target = appendGrokVideoImageReference(target, item) + } + case []interface{}: + for _, item := range v { + target = appendGrokVideoImageReference(target, item) + } + case map[string]interface{}: + target = append(target, v) + } + return target +} + +func normalizeGrokVideoRequest(bodyMap map[string]interface{}, upstreamModel string) { + if upstreamModel != "grok-imagine-1.0-video" { + return + } + + quality := normalizeGrokVideoQuality(stringifyBodyValue(bodyMap["quality"])) + resolutionName := stringifyBodyValue(bodyMap["resolution_name"]) + preset := stringifyBodyValue(bodyMap["preset"]) + seconds := stringifyBodyValue(bodyMap["seconds"]) + duration := stringifyBodyValue(bodyMap["duration"]) + + if videoConfig, ok := bodyMap["video_config"].(map[string]interface{}); ok { + if resolutionName == "" { + resolutionName = stringifyBodyValue(videoConfig["resolution_name"]) + } + if preset == "" { + preset = stringifyBodyValue(videoConfig["preset"]) + } + } + + if quality == "" { + quality = qualityFromResolutionName(resolutionName) + } + if resolutionName == "" { + resolutionName = resolutionNameFromQuality(quality) + } + + if quality != "" { + bodyMap["quality"] = quality + } + if seconds == "" && duration != "" { + bodyMap["seconds"] = duration + } + imageReferences := make([]interface{}, 0) + imageReferences = appendGrokVideoImageReference(imageReferences, bodyMap["image_reference"]) + imageReferences = appendGrokVideoImageReference(imageReferences, bodyMap["image"]) + imageReferences = appendGrokVideoImageReference(imageReferences, bodyMap["images"]) + if len(imageReferences) > 0 { + bodyMap["image_reference"] = imageReferences + } + delete(bodyMap, "image") + delete(bodyMap, "images") + if resolutionName != "" { + bodyMap["resolution_name"] = resolutionName + } + if preset != "" { + bodyMap["preset"] = preset + } + if resolutionName != "" || preset != "" { + videoConfig := map[string]interface{}{} + if resolutionName != "" { + videoConfig["resolution_name"] = resolutionName + } + if preset != "" { + videoConfig["preset"] = preset + } + bodyMap["video_config"] = videoConfig + } +} + +func isSoraVideoModel(upstreamModel string) bool { + upstreamModel = strings.ToLower(strings.TrimSpace(upstreamModel)) + return strings.HasPrefix(upstreamModel, "sora-2") || strings.HasPrefix(upstreamModel, "sora2") +} + +func soraSizeFromAspectRatio(value string) string { + switch strings.TrimSpace(value) { + case "16:9": + return "1280x720" + case "9:16": + return "720x1280" + default: + return "" + } +} + +func soraAspectRatioFromSize(value string) string { + switch strings.TrimSpace(value) { + case "1280x720", "1792x1024": + return "16:9" + case "720x1280", "1024x1792": + return "9:16" + default: + return "" + } +} + +func soraDurationBodyValue(value string) interface{} { + if duration, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + return duration + } + return strings.TrimSpace(value) +} + +func normalizeSoraVideoRequest(bodyMap map[string]interface{}, upstreamModel string) { + if !isSoraVideoModel(upstreamModel) { + return + } + + duration := stringifyBodyValue(bodyMap["duration"]) + aspectRatio := stringifyBodyValue(bodyMap["aspect_ratio"]) + seconds := stringifyBodyValue(bodyMap["seconds"]) + size := stringifyBodyValue(bodyMap["size"]) + imageURL := stringifyBodyValue(bodyMap["image_url"]) + inputReference := stringifyBodyValue(bodyMap["input_reference"]) + image := stringifyBodyValue(bodyMap["image"]) + + if duration == "" { + if seconds != "" { + duration = seconds + } else { + duration = "4" + } + } + if aspectRatio == "" { + if mapped := soraAspectRatioFromSize(size); mapped != "" { + aspectRatio = mapped + } else { + aspectRatio = "9:16" + } + } + + bodyMap["duration"] = soraDurationBodyValue(duration) + bodyMap["aspect_ratio"] = aspectRatio + bodyMap["async"] = true + delete(bodyMap, "seconds") + delete(bodyMap, "size") + + if imageURL == "" { + switch { + case inputReference != "": + imageURL = inputReference + case image != "": + imageURL = image + default: + if images, ok := bodyMap["images"].([]interface{}); ok && len(images) > 0 { + imageURL = stringifyBodyValue(images[0]) + } + } + } + if imageURL != "" { + bodyMap["image_url"] = imageURL + } + delete(bodyMap, "input_reference") + delete(bodyMap, "image") + delete(bodyMap, "images") +} + +func extractVideoURL(respBody []byte) string { + for _, path := range []string{ + "url", + "video_url", + "metadata.url", + "data.url", + "data.video_url", + "data.0.url", + "data.0.video_url", + "output.video_url", + "task_result.videos.0.url", + } { + if url := strings.TrimSpace(gjson.GetBytes(respBody, path).String()); url != "" { + return url + } + } + return "" +} + func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.ChannelBaseUrl @@ -94,7 +432,7 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom return relaycommon.ValidateMultipartDirect(c, info) } -// EstimateBilling 根据用户请求的 seconds 和 size 计算 OtherRatios。 +// EstimateBilling 根据用户请求的 seconds 计算 OtherRatios。 func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { // remix 路径的 OtherRatios 已在 ResolveOriginTask 中设置 if info.Action == constant.TaskActionRemix { @@ -114,25 +452,18 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf seconds = 4 } - size := req.Size - if size == "" { - size = "720x1280" - } - - ratios := map[string]float64{ + return map[string]float64{ "seconds": float64(seconds), - "size": 1, - } - if size == "1792x1024" || size == "1024x1792" { - ratios["size"] = 1.666667 } - return ratios } func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { - if info.Action == constant.TaskActionRemix { + if info != nil && info.TaskRelayInfo != nil && info.Action == constant.TaskActionRemix { return fmt.Sprintf("%s/v1/videos/%s/remix", a.baseURL, info.OriginTaskID), nil } + if info != nil && usesVideoGenerationsTaskEndpoint(info.RequestURLPath, relayInfoUpstreamModelName(info), info.OriginModelName) { + return fmt.Sprintf("%s%s", a.baseURL, videoGenerationsTaskPath), nil + } return fmt.Sprintf("%s/v1/videos", a.baseURL), nil } @@ -158,7 +489,10 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn var bodyMap map[string]interface{} if err := common.Unmarshal(cachedBody, &bodyMap); err == nil { bodyMap["model"] = info.UpstreamModelName + normalizeGrokVideoRequest(bodyMap, info.UpstreamModelName) + normalizeSoraVideoRequest(bodyMap, info.UpstreamModelName) if newBody, err := common.Marshal(bodyMap); err == nil { + c.Request.Header.Set("Content-Type", "application/json") return bytes.NewReader(newBody), nil } } @@ -173,14 +507,66 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn var buf bytes.Buffer writer := multipart.NewWriter(&buf) writer.WriteField("model", info.UpstreamModelName) + hasSeconds := false + hasDuration := false + durationValue := "" + hasSize := false + sizeValue := "" + hasAspectRatio := false + aspectRatioValue := "" for key, values := range formData.Value { if key == "model" { continue } + if key == "seconds" && len(values) > 0 && strings.TrimSpace(values[0]) != "" { + hasSeconds = true + } + if key == "duration" && len(values) > 0 && strings.TrimSpace(values[0]) != "" { + hasDuration = true + } + if key == "duration" && len(values) > 0 && durationValue == "" { + durationValue = strings.TrimSpace(values[0]) + } + if key == "size" && len(values) > 0 && strings.TrimSpace(values[0]) != "" { + hasSize = true + if sizeValue == "" { + sizeValue = strings.TrimSpace(values[0]) + } + } + if key == "aspect_ratio" && len(values) > 0 && strings.TrimSpace(values[0]) != "" { + hasAspectRatio = true + } + if key == "aspect_ratio" && len(values) > 0 && aspectRatioValue == "" { + aspectRatioValue = strings.TrimSpace(values[0]) + } + if isSoraVideoModel(info.UpstreamModelName) && (key == "seconds" || key == "size") { + continue + } for _, v := range values { writer.WriteField(key, v) } } + if info.UpstreamModelName == "grok-imagine-1.0-video" && !hasSeconds && durationValue != "" { + writer.WriteField("seconds", durationValue) + } + if isSoraVideoModel(info.UpstreamModelName) { + if !hasDuration { + if durationValue == "" { + durationValue = "4" + } + writer.WriteField("duration", durationValue) + } + if !hasAspectRatio { + if aspectRatioValue == "" && hasSize { + aspectRatioValue = soraAspectRatioFromSize(sizeValue) + } + if aspectRatioValue == "" { + aspectRatioValue = "9:16" + } + writer.WriteField("aspect_ratio", aspectRatioValue) + } + writer.WriteField("async", "true") + } for fieldName, fileHeaders := range formData.File { for _, fh := range fileHeaders { f, err := fh.Open() @@ -240,14 +626,23 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return } - upstreamID := dResp.ID + upstreamID := dResp.TaskID if upstreamID == "" { - upstreamID = dResp.TaskID + upstreamID = dResp.ID } if upstreamID == "" { taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) return } + if dResp.URL == "" { + dResp.URL = extractVideoURL(responseBody) + } + if dResp.VideoURL == "" { + dResp.VideoURL = dResp.URL + } + if dResp.URL == "" { + dResp.URL = dResp.VideoURL + } // 使用公开 task_xxxx ID 返回给客户端 dResp.ID = info.PublicTaskID @@ -258,13 +653,11 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela // FetchTask fetch task status func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { - taskID, ok := body["task_id"].(string) - if !ok { - return nil, fmt.Errorf("invalid task_id") + uri, err := buildTaskFetchURL(baseUrl, body) + if err != nil { + return nil, err } - uri := fmt.Sprintf("%s/v1/videos/%s", baseUrl, taskID) - req, err := http.NewRequest(http.MethodGet, uri, nil) if err != nil { return nil, err @@ -292,19 +685,39 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e if err := common.Unmarshal(respBody, &resTask); err != nil { return nil, errors.Wrap(err, "unmarshal task result failed") } + if resTask.URL == "" { + resTask.URL = extractVideoURL(respBody) + } + if resTask.VideoURL == "" { + resTask.VideoURL = resTask.URL + } + if resTask.URL == "" { + resTask.URL = resTask.VideoURL + } + createdAt := resTask.CreatedAt + if createdAt == 0 { + createdAt = resTask.Created + } taskResult := relaycommon.TaskInfo{ - Code: 0, + Code: 0, + CreatedAt: createdAt, + CompletedAt: resTask.CompletedAt, } - switch resTask.Status { + switch strings.ToLower(strings.TrimSpace(resTask.Status)) { case "queued", "pending": taskResult.Status = model.TaskStatusQueued - case "processing", "in_progress": + case "processing", "in_progress", "running": taskResult.Status = model.TaskStatusInProgress case "completed": - taskResult.Status = model.TaskStatusSuccess - // Url intentionally left empty — the caller constructs the proxy URL using the public task ID + if resTask.URL == "" { + taskResult.Status = model.TaskStatusFailure + taskResult.Reason = "video result url is empty" + } else { + taskResult.Status = model.TaskStatusSuccess + taskResult.Url = resTask.URL + } case "failed", "cancelled": taskResult.Status = model.TaskStatusFailure if resTask.Error != nil { @@ -315,7 +728,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e default: } if resTask.Progress > 0 && resTask.Progress < 100 { - taskResult.Progress = fmt.Sprintf("%d%%", resTask.Progress) + taskResult.Progress = formatTaskProgress(resTask.Progress) } return &taskResult, nil @@ -327,5 +740,10 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { if data, err = sjson.SetBytes(data, "id", task.TaskID); err != nil { return nil, errors.Wrap(err, "set id failed") } + if gjson.GetBytes(data, "task_id").Exists() { + if data, err = sjson.SetBytes(data, "task_id", task.TaskID); err != nil { + return nil, errors.Wrap(err, "set task_id failed") + } + } return data, nil } diff --git a/relay/channel/task/sora/adaptor_test.go b/relay/channel/task/sora/adaptor_test.go new file mode 100644 index 000000000000..6be87159cb0e --- /dev/null +++ b/relay/channel/task/sora/adaptor_test.go @@ -0,0 +1,470 @@ +package sora + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + projectcommon "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" + + relaycommon "github.com/QuantumNous/new-api/relay/common" +) + +func TestNormalizeGrokVideoRequestAddsResolutionAliases(t *testing.T) { + body := map[string]interface{}{ + "model": "grok-imagine-1.0-video", + "quality": "high", + "preset": "fun", + } + + normalizeGrokVideoRequest(body, "grok-imagine-1.0-video") + + if got := body["quality"]; got != "high" { + t.Fatalf("expected quality to stay high, got %#v", got) + } + if got := body["resolution_name"]; got != "720p" { + t.Fatalf("expected resolution_name 720p, got %#v", got) + } + + videoConfig, ok := body["video_config"].(map[string]interface{}) + if !ok { + t.Fatalf("expected video_config map, got %#v", body["video_config"]) + } + if got := videoConfig["resolution_name"]; got != "720p" { + t.Fatalf("expected video_config.resolution_name 720p, got %#v", got) + } + if got := videoConfig["preset"]; got != "fun" { + t.Fatalf("expected video_config.preset fun, got %#v", got) + } +} + +func TestNormalizeGrokVideoRequestBackfillsQualityFromResolutionName(t *testing.T) { + body := map[string]interface{}{ + "model": "grok-imagine-1.0-video", + "resolution_name": "720p", + } + + normalizeGrokVideoRequest(body, "grok-imagine-1.0-video") + + if got := body["quality"]; got != "high" { + t.Fatalf("expected quality high, got %#v", got) + } + if got := body["resolution_name"]; got != "720p" { + t.Fatalf("expected resolution_name 720p, got %#v", got) + } +} + +func TestNormalizeGrokVideoRequestBackfillsSecondsFromDuration(t *testing.T) { + body := map[string]interface{}{ + "model": "grok-imagine-1.0-video", + "duration": float64(10), + } + + normalizeGrokVideoRequest(body, "grok-imagine-1.0-video") + + if got := body["seconds"]; got != "10" { + t.Fatalf("expected seconds to be backfilled from duration, got %#v", got) + } +} + +func TestNormalizeGrokVideoRequestKeepsExplicitSeconds(t *testing.T) { + body := map[string]interface{}{ + "model": "grok-imagine-1.0-video", + "duration": float64(10), + "seconds": "8", + } + + normalizeGrokVideoRequest(body, "grok-imagine-1.0-video") + + if got := body["seconds"]; got != "8" { + t.Fatalf("expected explicit seconds to be preserved, got %#v", got) + } +} + +func TestNormalizeGrokVideoRequestPromotesImageReference(t *testing.T) { + body := map[string]interface{}{ + "model": "grok-imagine-1.0-video", + "image": "https://example.com/cover.png", + "images": []interface{}{"https://example.com/frame-2.png"}, + } + + normalizeGrokVideoRequest(body, "grok-imagine-1.0-video") + + if _, exists := body["image"]; exists { + t.Fatalf("expected legacy image field to be removed") + } + if _, exists := body["images"]; exists { + t.Fatalf("expected legacy images field to be removed") + } + + imageReference, ok := body["image_reference"].([]interface{}) + if !ok { + t.Fatalf("expected image_reference array, got %#v", body["image_reference"]) + } + if len(imageReference) != 2 { + t.Fatalf("expected 2 image references, got %#v", imageReference) + } + if imageReference[0] != "https://example.com/cover.png" { + t.Fatalf("unexpected first image reference %#v", imageReference[0]) + } + if imageReference[1] != "https://example.com/frame-2.png" { + t.Fatalf("unexpected second image reference %#v", imageReference[1]) + } +} + +func TestNormalizeSoraVideoRequestBackfillsDurationAndAspectRatio(t *testing.T) { + body := map[string]interface{}{ + "model": "sora-2", + "seconds": "10", + "size": "1280x720", + } + + normalizeSoraVideoRequest(body, "sora-2") + + if got := body["duration"]; got != 10 { + t.Fatalf("expected duration to be backfilled from seconds, got %#v", got) + } + if got := body["aspect_ratio"]; got != "16:9" { + t.Fatalf("expected aspect_ratio to be backfilled from size, got %#v", got) + } + if _, exists := body["seconds"]; exists { + t.Fatalf("expected seconds to be removed after normalization") + } + if _, exists := body["size"]; exists { + t.Fatalf("expected size to be removed after normalization") + } +} + +func TestNormalizeSoraVideoRequestKeepsExplicitDurationAndAspectRatio(t *testing.T) { + body := map[string]interface{}{ + "model": "sora-2-pro", + "duration": float64(10), + "seconds": "8", + "aspect_ratio": "9:16", + "size": "1024x1792", + } + + normalizeSoraVideoRequest(body, "sora-2-pro") + + if got := body["duration"]; got != 10 { + t.Fatalf("expected explicit duration to be preserved, got %#v", got) + } + if got := body["aspect_ratio"]; got != "9:16" { + t.Fatalf("expected explicit aspect_ratio to be preserved, got %#v", got) + } +} + +func TestBuildRequestBodyConvertsSoraInputReferenceToImageURL(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/v1/video/async-generations", strings.NewReader(`{ + "model": "sora2", + "prompt": "make it cinematic", + "duration": 10, + "aspect_ratio": "16:9", + "input_reference": "data:image/png;base64,aGVsbG8=" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + adaptor := &TaskAdaptor{} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "sora-2", + }, + } + + bodyReader, err := adaptor.BuildRequestBody(c, info) + if err != nil { + t.Fatalf("BuildRequestBody returned error: %v", err) + } + + if contentType := c.Request.Header.Get("Content-Type"); contentType != "application/json" { + t.Fatalf("expected application/json content type, got %s", contentType) + } + + raw, err := io.ReadAll(bodyReader) + if err != nil { + t.Fatalf("read request body failed: %v", err) + } + + var payload map[string]any + if err := projectcommon.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal request payload failed: %v", err) + } + if got := payload["duration"]; got != float64(10) { + t.Fatalf("expected duration=10, got %#v", got) + } + if got := payload["aspect_ratio"]; got != "16:9" { + t.Fatalf("expected aspect_ratio=16:9, got %#v", got) + } + if got, ok := payload["async"].(bool); !ok || !got { + t.Fatalf("expected async=true, got %#v", payload["async"]) + } + if _, exists := payload["input_reference"]; exists { + t.Fatalf("expected input_reference to be removed from upstream payload") + } + if got := payload["image_url"]; got != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("expected image_url to be populated, got %#v", got) + } +} + +func TestNormalizeSoraVideoRequestAcceptsSora2Alias(t *testing.T) { + body := map[string]interface{}{ + "model": "sora2", + "prompt": "make an ad", + "duration": float64(4), + "aspect_ratio": "16:9", + "image": "https://example.com/input.jpg", + } + + normalizeSoraVideoRequest(body, "sora2") + + if got := body["image_url"]; got != "https://example.com/input.jpg" { + t.Fatalf("expected image to be normalized to image_url, got %#v", got) + } + if _, exists := body["image"]; exists { + t.Fatalf("expected image to be removed after normalization") + } + if got, ok := body["async"].(bool); !ok || !got { + t.Fatalf("expected async=true, got %#v", body["async"]) + } +} + +func TestBuildRequestURLUsesVideoGenerationsPath(t *testing.T) { + adaptor := &TaskAdaptor{baseURL: "https://upstream.example"} + url, err := adaptor.BuildRequestURL(&relaycommon.RelayInfo{ + RequestURLPath: "/v1/video/generations", + OriginModelName: "veo31-fast", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "veo31-fast", + }, + }) + if err != nil { + t.Fatalf("BuildRequestURL returned error: %v", err) + } + if url != "https://upstream.example/v1/video/generations" { + t.Fatalf("expected video generations URL, got %s", url) + } +} + +func TestBuildRequestURLUsesVideoGenerationsPathForSora(t *testing.T) { + adaptor := &TaskAdaptor{baseURL: "https://upstream.example"} + url, err := adaptor.BuildRequestURL(&relaycommon.RelayInfo{ + RequestURLPath: "/v1/video/generations", + OriginModelName: "sora2", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "sora-2", + }, + }) + if err != nil { + t.Fatalf("BuildRequestURL returned error: %v", err) + } + if url != "https://upstream.example/v1/video/generations" { + t.Fatalf("expected video generations URL for sora, got %s", url) + } +} + +func TestBuildRequestURLKeepsGrokOnOpenAIVideosPath(t *testing.T) { + adaptor := &TaskAdaptor{baseURL: "https://upstream.example"} + url, err := adaptor.BuildRequestURL(&relaycommon.RelayInfo{ + RequestURLPath: "/v1/video/generations", + OriginModelName: "grok-imagine-1.0-video", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "grok-imagine-1.0-video", + }, + }) + if err != nil { + t.Fatalf("BuildRequestURL returned error: %v", err) + } + if url != "https://upstream.example/v1/videos" { + t.Fatalf("expected OpenAI videos URL for Grok, got %s", url) + } +} + +func TestBuildRequestURLKeepsOpenAIVideosPath(t *testing.T) { + adaptor := &TaskAdaptor{baseURL: "https://upstream.example"} + url, err := adaptor.BuildRequestURL(&relaycommon.RelayInfo{ + RequestURLPath: "/v1/videos", + }) + if err != nil { + t.Fatalf("BuildRequestURL returned error: %v", err) + } + if url != "https://upstream.example/v1/videos" { + t.Fatalf("expected OpenAI videos URL, got %s", url) + } +} + +func TestBuildTaskFetchURLUsesStoredVideoGenerationsPath(t *testing.T) { + url, err := buildTaskFetchURL("https://upstream.example", map[string]any{ + "task_id": "upstream-task", + "model": "veo31-fast", + "request_path": "/v1/video/generations", + }) + if err != nil { + t.Fatalf("buildTaskFetchURL returned error: %v", err) + } + if url != "https://upstream.example/v1/video/generations/upstream-task" { + t.Fatalf("expected video generations fetch URL, got %s", url) + } +} + +func TestBuildTaskFetchURLUsesStoredVideoGenerationsPathForSoraAlias(t *testing.T) { + url, err := buildTaskFetchURL("https://upstream.example", map[string]any{ + "task_id": "upstream-task", + "model": "sora2", + "origin_model": "sora2", + "request_path": "/v1/video/generations", + }) + if err != nil { + t.Fatalf("buildTaskFetchURL returned error: %v", err) + } + if url != "https://upstream.example/v1/video/generations/upstream-task" { + t.Fatalf("expected video generations fetch URL for sora alias, got %s", url) + } +} + +func TestBuildTaskFetchURLKeepsGrokOnOpenAIVideosPath(t *testing.T) { + url, err := buildTaskFetchURL("https://upstream.example", map[string]any{ + "task_id": "upstream-task", + "model": "grok-imagine-1.0-video", + "request_path": "/v1/video/generations", + }) + if err != nil { + t.Fatalf("buildTaskFetchURL returned error: %v", err) + } + if url != "https://upstream.example/v1/videos/upstream-task" { + t.Fatalf("expected OpenAI videos fetch URL for Grok, got %s", url) + } +} + +func TestBuildTaskFetchURLDefaultsToOpenAIVideosPath(t *testing.T) { + url, err := buildTaskFetchURL("https://upstream.example", map[string]any{ + "task_id": "upstream-task", + }) + if err != nil { + t.Fatalf("buildTaskFetchURL returned error: %v", err) + } + if url != "https://upstream.example/v1/videos/upstream-task" { + t.Fatalf("expected OpenAI videos fetch URL, got %s", url) + } +} + +func TestParseTaskResultAcceptsFloatProgress(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{"status":"completed","progress":100.0,"video_url":"https://cdn.example/video.mp4"}`)) + if err != nil { + t.Fatalf("ParseTaskResult returned error: %v", err) + } + if taskInfo.Status != "SUCCESS" { + t.Fatalf("expected success status, got %s", taskInfo.Status) + } + if taskInfo.Url != "https://cdn.example/video.mp4" { + t.Fatalf("expected video url, got %s", taskInfo.Url) + } +} + +func TestParseTaskResultMapsRunningToInProgress(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{"status":"running","progress":1.0,"created":1776350152}`)) + if err != nil { + t.Fatalf("ParseTaskResult returned error: %v", err) + } + if taskInfo.Status != "IN_PROGRESS" { + t.Fatalf("expected in-progress status, got %s", taskInfo.Status) + } + if taskInfo.CreatedAt != 1776350152 { + t.Fatalf("expected created timestamp, got %d", taskInfo.CreatedAt) + } +} + +func TestParseTaskResultReadsVideoURLFromDataArray(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{"status":"completed","progress":100.0,"data":[{"url":"https://cdn.example/from-data.mp4"}]}`)) + if err != nil { + t.Fatalf("ParseTaskResult returned error: %v", err) + } + if taskInfo.Status != "SUCCESS" { + t.Fatalf("expected success status, got %s", taskInfo.Status) + } + if taskInfo.Url != "https://cdn.example/from-data.mp4" { + t.Fatalf("expected data array video url, got %s", taskInfo.Url) + } +} + +func TestParseTaskResultFailsCompletedWithoutURL(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{"status":"completed","progress":100.0}`)) + if err != nil { + t.Fatalf("ParseTaskResult returned error: %v", err) + } + if taskInfo.Status != "FAILURE" { + t.Fatalf("expected failure status, got %s", taskInfo.Status) + } + if taskInfo.Reason == "" { + t.Fatalf("expected failure reason") + } +} + +func TestParseTaskResultReadsStringError(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "id": "vidgen-xxxxxxxxxxxxxxxxxxxxxxxx", + "object": "video.generation", + "created": 1776657884, + "model": "sora2", + "status": "failed", + "task_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "progress": 1.0, + "error": "video poll failed: 451 {\"error_code\":\"video_unsafe\",\"message\":\"The generated video appears to be unsafe. Try modifying the prompts or the seeds.\"}" + }`)) + if err != nil { + t.Fatalf("ParseTaskResult returned error: %v", err) + } + if taskInfo.Status != "FAILURE" { + t.Fatalf("expected failure status, got %s", taskInfo.Status) + } + if !strings.Contains(taskInfo.Reason, "video poll failed: 451") { + t.Fatalf("expected string error reason, got %q", taskInfo.Reason) + } + if !strings.Contains(taskInfo.Reason, "video_unsafe") { + t.Fatalf("expected upstream error code in reason, got %q", taskInfo.Reason) + } +} + +func TestDoResponsePrefersTaskIDForUpstreamPolling(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + resp := &http.Response{ + StatusCode: http.StatusAccepted, + Body: io.NopCloser(strings.NewReader(`{ + "id":"vidgen-abc123", + "object":"video.generation", + "created":1776410000, + "model":"sora2", + "status":"queued", + "task_id":"abc123def456", + "progress":0 + }`)), + } + + adaptor := &TaskAdaptor{} + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{ + PublicTaskID: "task_public_123", + }, + } + + upstreamID, _, taskErr := adaptor.DoResponse(c, resp, info) + if taskErr != nil { + t.Fatalf("DoResponse returned error: %v", taskErr) + } + if upstreamID != "abc123def456" { + t.Fatalf("expected upstream task_id to be preferred, got %s", upstreamID) + } +} diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go index b76364ee98f8..4228f27a1227 100644 --- a/relay/channel/task/vertex/adaptor.go +++ b/relay/channel/task/vertex/adaptor.go @@ -52,9 +52,7 @@ type operationResponse struct { Encoding string `json:"encoding"` Video string `json:"video"` } `json:"response"` - Error struct { - Message string `json:"message"` - } `json:"error"` + Error geminitask.OperationError `json:"error"` } // ============================ @@ -134,7 +132,7 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info return nil } -// EstimateBilling returns OtherRatios based on durationSeconds and resolution. +// EstimateBilling returns OtherRatios based on durationSeconds. func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { v, ok := c.Get("task_request") if !ok { @@ -143,12 +141,9 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf req := v.(relaycommon.TaskSubmitReq) seconds := geminitask.ResolveVeoDuration(req.Metadata, req.Duration, req.Seconds) - resolution := geminitask.ResolveVeoResolution(req.Metadata, req.Size) - resRatio := geminitask.VeoResolutionRatio(info.UpstreamModelName, resolution) return map[string]float64{ - "seconds": float64(seconds), - "resolution": resRatio, + "seconds": float64(seconds), } } diff --git a/relay/channel/task/vertex/adaptor_test.go b/relay/channel/task/vertex/adaptor_test.go new file mode 100644 index 000000000000..5e7d87f8ad55 --- /dev/null +++ b/relay/channel/task/vertex/adaptor_test.go @@ -0,0 +1,32 @@ +package vertex + +import ( + "strings" + "testing" + + "github.com/QuantumNous/new-api/model" +) + +func TestParseTaskResultReadsStringError(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "name": "projects/test/locations/us-central1/publishers/google/models/veo-3.0-generate-001/operations/test", + "done": true, + "error": "video poll failed: 451 {\"error_code\":\"video_unsafe\",\"message\":\"The generated video appears to be unsafe. Try modifying the prompts or the seeds.\"}" + }`)) + if err != nil { + t.Fatalf("ParseTaskResult returned error: %v", err) + } + if taskInfo.Status != model.TaskStatusFailure { + t.Fatalf("expected failure status, got %s", taskInfo.Status) + } + if !strings.Contains(taskInfo.Reason, "video poll failed: 451") { + t.Fatalf("expected string error reason, got %q", taskInfo.Reason) + } + if !strings.Contains(taskInfo.Reason, "video_unsafe") { + t.Fatalf("expected upstream error code in reason, got %q", taskInfo.Reason) + } + if taskInfo.Progress != "100%" { + t.Fatalf("expected complete progress, got %q", taskInfo.Progress) + } +} diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index e172bccf324a..32f8a5e7aefb 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -1,15 +1,27 @@ package xai import ( + "bytes" + "encoding/base64" + "encoding/json" "errors" + "fmt" "io" + "mime" + "mime/multipart" "net/http" + "net/textproto" + neturl "net/url" + "path" + "path/filepath" "strings" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/relay/constant" @@ -38,11 +50,21 @@ 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) { + if info != nil && info.RelayMode == constant.RelayModeImagesEdits { + return buildImageEditJSONRequest(request) + } + xaiRequest := ImageRequest{ - Model: request.Model, - Prompt: request.Prompt, - N: int(lo.FromPtrOr(request.N, uint(1))), - ResponseFormat: request.ResponseFormat, + Model: request.Model, + Prompt: request.Prompt, + N: int(lo.FromPtrOr(request.N, uint(1))), + Image: request.Image, + Size: request.Size, + AspectRatio: request.AspectRatio, + OutputResolution: request.OutputResolution, + Seed: request.Seed, + Seeds: request.Seeds, + ResponseFormat: request.ResponseFormat, } return xaiRequest, nil } @@ -138,3 +160,404 @@ func (a *Adaptor) GetModelList() []string { func (a *Adaptor) GetChannelName() string { return ChannelName } + +type imagePayload struct { + URL string `json:"url"` + Data string `json:"data"` + B64JSON string `json:"b64_json"` + Filename string `json:"filename"` + Name string `json:"name"` + MimeType string `json:"mime_type"` +} + +func buildImageEditJSONRequest(request dto.ImageRequest) (map[string]any, error) { + image, err := buildXAIImageEditSource(request.Image) + if err != nil { + return nil, err + } + + payload := map[string]any{ + "model": request.Model, + "prompt": request.Prompt, + "image": image, + } + if request.N != nil && *request.N > 0 { + payload["n"] = *request.N + } + if request.Seed != nil { + payload["seed"] = *request.Seed + } + if len(request.Seeds) > 0 { + payload["seeds"] = request.Seeds + } + if strings.TrimSpace(request.ResponseFormat) != "" { + payload["response_format"] = request.ResponseFormat + } + if strings.TrimSpace(request.AspectRatio) != "" { + payload["aspect_ratio"] = request.AspectRatio + } + if strings.TrimSpace(request.OutputResolution) != "" { + payload["output_resolution"] = request.OutputResolution + } + return payload, nil +} + +func buildXAIImageEditSource(rawImage []byte) (any, error) { + if len(rawImage) == 0 { + return nil, errors.New("image is required") + } + + var rawImages []json.RawMessage + if err := common.Unmarshal(rawImage, &rawImages); err == nil && len(rawImages) > 0 { + images := make([]map[string]any, 0, len(rawImages)) + for _, item := range rawImages { + image, err := buildSingleXAIImageEditSource(item) + if err != nil { + return nil, err + } + images = append(images, image) + } + return images, nil + } + + return buildSingleXAIImageEditSource(rawImage) +} + +func buildSingleXAIImageEditSource(rawImage []byte) (map[string]any, error) { + if len(rawImage) == 0 { + return nil, errors.New("image is required") + } + + var simpleString string + if err := common.Unmarshal(rawImage, &simpleString); err == nil { + return map[string]any{ + "url": normalizeXAIImageEditURL(simpleString, ""), + "type": "image_url", + }, nil + } + + var payload imagePayload + if err := common.Unmarshal(rawImage, &payload); err == nil { + source := strings.TrimSpace(payload.URL) + if source == "" { + source = strings.TrimSpace(payload.Data) + } + if source == "" { + source = strings.TrimSpace(payload.B64JSON) + } + if source == "" { + return nil, errors.New("image is required") + } + return map[string]any{ + "url": normalizeXAIImageEditURL(source, payload.MimeType), + "type": "image_url", + }, nil + } + + return nil, errors.New("unsupported image payload for xai image edit") +} + +func normalizeXAIImageEditURL(source string, preferredMime string) string { + source = strings.TrimSpace(source) + if source == "" { + return "" + } + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "data:") { + return source + } + mimeType := strings.TrimSpace(preferredMime) + if mimeType == "" { + mimeType = "image/png" + } + return fmt.Sprintf("data:%s;base64,%s", mimeType, source) +} + +func buildImageEditMultipartRequest(c *gin.Context, request dto.ImageRequest) (*bytes.Buffer, error) { + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writeField := func(key, value string) error { + if strings.TrimSpace(value) == "" { + return nil + } + return writer.WriteField(key, value) + } + + if err := writeField("model", request.Model); err != nil { + return nil, err + } + if err := writeField("prompt", request.Prompt); err != nil { + return nil, err + } + if request.N != nil && *request.N > 0 { + if err := writeField("n", fmt.Sprintf("%d", *request.N)); err != nil { + return nil, err + } + } + if err := writeField("size", request.Size); err != nil { + return nil, err + } + if err := writeField("response_format", request.ResponseFormat); err != nil { + return nil, err + } + if err := writeField("quality", request.Quality); err != nil { + return nil, err + } + + hasImage, err := copyMultipartImageFromRequest(writer, c) + if err != nil { + return nil, err + } + if !hasImage { + if err := appendImageFromPayload(writer, request.Image); err != nil { + return nil, err + } + } + + if err := writer.Close(); err != nil { + return nil, err + } + if c != nil && c.Request != nil { + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + } + return &requestBody, nil +} + +func copyMultipartImageFromRequest(writer *multipart.Writer, c *gin.Context) (bool, error) { + if c == nil || c.Request == nil || !strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { + return false, nil + } + + formData, err := common.ParseMultipartFormReusable(c) + if err != nil { + return false, fmt.Errorf("failed to parse multipart edit form: %w", err) + } + + fileHeaders := make([]*multipart.FileHeader, 0) + for key, files := range formData.File { + if key == "image" || key == "image[]" || strings.HasPrefix(key, "image[") { + fileHeaders = append(fileHeaders, files...) + } + } + if len(fileHeaders) == 0 { + return false, nil + } + + fileHeader := fileHeaders[0] + file, err := fileHeader.Open() + if err != nil { + return false, fmt.Errorf("failed to open edit image: %w", err) + } + defer file.Close() + + part, err := createImageFormPart(writer, "image", fileHeader.Filename, detectImageMimeType(fileHeader.Filename)) + if err != nil { + return false, err + } + if _, err = io.Copy(part, file); err != nil { + return false, fmt.Errorf("failed to copy edit image: %w", err) + } + return true, nil +} + +func appendImageFromPayload(writer *multipart.Writer, rawImage []byte) error { + if len(rawImage) == 0 { + return errors.New("image is required") + } + + filename, mimeType, content, err := resolveImagePayload(rawImage) + if err != nil { + return err + } + + part, err := createImageFormPart(writer, "image", filename, mimeType) + if err != nil { + return err + } + if _, err = part.Write(content); err != nil { + return fmt.Errorf("failed to write edit image: %w", err) + } + return nil +} + +func resolveImagePayload(rawImage []byte) (string, string, []byte, error) { + imageValue := strings.TrimSpace(string(rawImage)) + if imageValue == "" || imageValue == "null" { + return "", "", nil, errors.New("image is required") + } + + var simpleString string + if err := common.Unmarshal(rawImage, &simpleString); err == nil { + return resolveImageSource(simpleString, "", "") + } + + var payload imagePayload + if err := common.Unmarshal(rawImage, &payload); err == nil { + if payload.URL != "" { + return resolveImageSource(payload.URL, payload.FilenameOrName(), payload.MimeType) + } + if payload.Data != "" { + return resolveImageSource(payload.Data, payload.FilenameOrName(), payload.MimeType) + } + if payload.B64JSON != "" { + return resolveImageSource(payload.B64JSON, payload.FilenameOrName(), payload.MimeType) + } + } + + return "", "", nil, errors.New("unsupported image payload for xai image edit") +} + +func (p imagePayload) FilenameOrName() string { + if strings.TrimSpace(p.Filename) != "" { + return strings.TrimSpace(p.Filename) + } + if strings.TrimSpace(p.Name) != "" { + return strings.TrimSpace(p.Name) + } + return "" +} + +func resolveImageSource(source string, preferredFilename string, preferredMime string) (string, string, []byte, error) { + source = strings.TrimSpace(source) + if source == "" { + return "", "", nil, errors.New("image is required") + } + + if strings.HasPrefix(source, "data:") { + return decodeDataURL(source, preferredFilename) + } + + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + filename, mimeType, content, err := downloadRemoteImage(source) + if err != nil { + return "", "", nil, err + } + if preferredFilename != "" { + filename = preferredFilename + } + if preferredMime != "" { + mimeType = preferredMime + } + return ensureImageFilename(filename, mimeType), mimeType, content, nil + } + + decoded, err := base64.StdEncoding.DecodeString(source) + if err != nil { + return "", "", nil, fmt.Errorf("unsupported image source for xai image edit: %w", err) + } + mimeType := preferredMime + if mimeType == "" { + mimeType = "image/png" + } + return ensureImageFilename(preferredFilename, mimeType), mimeType, decoded, nil +} + +func downloadRemoteImage(source string) (string, string, []byte, error) { + resp, err := service.DoDownloadRequest(source, "xai image edit source") + if err != nil { + return "", "", nil, fmt.Errorf("failed to download edit image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", "", nil, fmt.Errorf("failed to download edit image: status %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", nil, fmt.Errorf("failed to read downloaded edit image: %w", err) + } + + mimeType := detectMimeFromHeader(resp.Header.Get("Content-Type")) + filename := filenameFromURL(source) + return ensureImageFilename(filename, mimeType), mimeType, content, nil +} + +func decodeDataURL(source string, preferredFilename string) (string, string, []byte, error) { + parts := strings.SplitN(source, ",", 2) + if len(parts) != 2 { + return "", "", nil, errors.New("invalid image data url") + } + + header := parts[0] + payload := parts[1] + if !strings.HasSuffix(header, ";base64") { + return "", "", nil, errors.New("image data url must be base64 encoded") + } + + mediaType := strings.TrimPrefix(strings.TrimSuffix(header, ";base64"), "data:") + mimeType := mediaType + if mimeType == "" { + mimeType = "image/png" + } + + content, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", "", nil, fmt.Errorf("failed to decode image data url: %w", err) + } + return ensureImageFilename(preferredFilename, mimeType), mimeType, content, nil +} + +func createImageFormPart(writer *multipart.Writer, fieldName string, filename string, mimeType string) (io.Writer, error) { + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, filename)) + header.Set("Content-Type", mimeType) + part, err := writer.CreatePart(header) + if err != nil { + return nil, fmt.Errorf("failed to create image form part: %w", err) + } + return part, nil +} + +func detectImageMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + default: + return "image/png" + } +} + +func detectMimeFromHeader(contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType == "" { + return "image/png" + } + return mediaType +} + +func filenameFromURL(source string) string { + parsed, err := neturl.Parse(source) + if err != nil { + return "" + } + filename := path.Base(parsed.Path) + if filename == "." || filename == "/" { + return "" + } + return filename +} + +func ensureImageFilename(filename string, mimeType string) string { + filename = strings.TrimSpace(filename) + if filename != "" && filepath.Ext(filename) != "" { + return filename + } + + exts, err := mime.ExtensionsByType(mimeType) + if err == nil && len(exts) > 0 { + ext := exts[0] + if filename == "" { + return "image" + ext + } + return filename + ext + } + + if filename == "" { + return "image.png" + } + return filename + ".png" +} diff --git a/relay/channel/xai/adaptor_test.go b/relay/channel/xai/adaptor_test.go new file mode 100644 index 000000000000..4201dc7afaf1 --- /dev/null +++ b/relay/channel/xai/adaptor_test.go @@ -0,0 +1,181 @@ +package xai + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/samber/lo" + + "github.com/gin-gonic/gin" +) + +func TestConvertImageRequestBuildsMultipartForEditImage(t *testing.T) { + adaptor := &Adaptor{} + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/edits", nil) + + converted, err := adaptor.ConvertImageRequest(ctx, &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeImagesEdits, + }, dto.ImageRequest{ + Model: "grok-imagine-1.0-edit", + Prompt: "make it watercolor", + N: lo.ToPtr(uint(2)), + Image: []byte(`{"url":"data:image/png;base64,aGVsbG8="}`), + Size: "1024x1024", + ResponseFormat: "url", + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + payload, ok := converted.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any, got %T", converted) + } + if payload["model"] != "grok-imagine-1.0-edit" { + t.Fatalf("unexpected model: %v", payload["model"]) + } + if payload["prompt"] != "make it watercolor" { + t.Fatalf("unexpected prompt: %v", payload["prompt"]) + } + if payload["n"] != uint(2) { + t.Fatalf("unexpected n: %v", payload["n"]) + } + if payload["response_format"] != "url" { + t.Fatalf("unexpected response_format: %v", payload["response_format"]) + } + image, ok := payload["image"].(map[string]any) + if !ok { + t.Fatalf("unexpected image payload: %#v", payload["image"]) + } + if image["type"] != "image_url" { + t.Fatalf("unexpected image type: %v", image["type"]) + } + if image["url"] != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("unexpected image url: %v", image["url"]) + } +} + +func TestConvertImageRequestSupportsMultipleEditImages(t *testing.T) { + adaptor := &Adaptor{} + + converted, err := adaptor.ConvertImageRequest(nil, &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeImagesEdits, + }, dto.ImageRequest{ + Model: "grok-imagine-1.0-edit", + Prompt: "blend both references", + Image: []byte(`["https://example.com/1.png","https://example.com/2.png"]`), + ResponseFormat: "url", + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + payload, ok := converted.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any, got %T", converted) + } + images, ok := payload["image"].([]map[string]any) + if !ok { + t.Fatalf("unexpected image payload: %#v", payload["image"]) + } + if len(images) != 2 { + t.Fatalf("expected two images, got %d", len(images)) + } + if images[0]["url"] != "https://example.com/1.png" { + t.Fatalf("unexpected first image url: %v", images[0]["url"]) + } + if images[1]["url"] != "https://example.com/2.png" { + t.Fatalf("unexpected second image url: %v", images[1]["url"]) + } +} + +func TestConvertImageRequestPreservesSize(t *testing.T) { + adaptor := &Adaptor{} + + converted, err := adaptor.ConvertImageRequest(nil, nil, dto.ImageRequest{ + Model: "grok-imagine-1.0", + Prompt: "draw a city skyline", + Size: "1536x1024", + ResponseFormat: "url", + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + xaiReq, ok := converted.(ImageRequest) + if !ok { + t.Fatalf("expected xai.ImageRequest, got %T", converted) + } + if xaiReq.Size != "1536x1024" { + t.Fatalf("unexpected size: %s", xaiReq.Size) + } +} + +func TestConvertImageRequestPreservesAspectRatioAndOutputResolution(t *testing.T) { + adaptor := &Adaptor{} + + converted, err := adaptor.ConvertImageRequest(nil, nil, dto.ImageRequest{ + Model: "nano-banana-pro", + Prompt: "draw a panoramic mountain range", + AspectRatio: "21:9", + OutputResolution: "4K", + Seed: lo.ToPtr(765897.0), + Seeds: []int{765897}, + ResponseFormat: "url", + }) + if err != nil { + t.Fatalf("ConvertImageRequest returned error: %v", err) + } + + xaiReq, ok := converted.(ImageRequest) + if !ok { + t.Fatalf("expected xai.ImageRequest, got %T", converted) + } + if xaiReq.AspectRatio != "21:9" { + t.Fatalf("unexpected aspect ratio: %s", xaiReq.AspectRatio) + } + if xaiReq.OutputResolution != "4K" { + t.Fatalf("unexpected output resolution: %s", xaiReq.OutputResolution) + } + if xaiReq.Seed == nil || *xaiReq.Seed != 765897 { + t.Fatalf("unexpected seed: %#v", xaiReq.Seed) + } + if len(xaiReq.Seeds) != 1 || xaiReq.Seeds[0] != 765897 { + t.Fatalf("unexpected seeds: %#v", xaiReq.Seeds) + } +} + +func TestResolveImagePayloadSupportsPlainURLObject(t *testing.T) { + filename, mimeType, content, err := resolveImagePayload([]byte(`{"url":"data:image/webp;base64,dGVzdA==","filename":"sample.webp"}`)) + if err != nil { + t.Fatalf("resolveImagePayload returned error: %v", err) + } + if filename != "sample.webp" { + t.Fatalf("unexpected filename: %s", filename) + } + if mimeType != "image/webp" { + t.Fatalf("unexpected mime type: %s", mimeType) + } + if string(content) != "test" { + t.Fatalf("unexpected content: %q", string(content)) + } +} + +func TestModelListIncludesGrokImagineOnePointZeroVariants(t *testing.T) { + expected := []string{ + "grok-imagine-1.0", + "grok-imagine-1.0-fast", + "grok-imagine-1.0-edit", + } + + for _, model := range expected { + if !lo.Contains(ModelList, model) { + t.Fatalf("model list missing %s", model) + } + } +} diff --git a/relay/channel/xai/constants.go b/relay/channel/xai/constants.go index c20532d4ce78..0466ce7af001 100644 --- a/relay/channel/xai/constants.go +++ b/relay/channel/xai/constants.go @@ -22,6 +22,10 @@ var ModelList = []string{ // grok-3-mini reasoning effort variants "grok-3-mini-high", "grok-3-mini-low", // image generation models + "grok-imagine-1.0", + "grok-imagine-1.0-fast", + "grok-imagine-1.0-edit", + "grok-imagine-1.0-video", "grok-imagine-image-pro", "grok-imagine-image", "grok-2-image-1212", diff --git a/relay/channel/xai/dto.go b/relay/channel/xai/dto.go index 371d62a43360..6985c2a9fc5b 100644 --- a/relay/channel/xai/dto.go +++ b/relay/channel/xai/dto.go @@ -13,12 +13,16 @@ type ChatCompletionResponse struct { SystemFingerprint string `json:"system_fingerprint"` } -// quality, size or style are not supported by xAI API at the moment. type ImageRequest struct { - Model string `json:"model"` - Prompt string `json:"prompt" binding:"required"` - N int `json:"n,omitempty"` - // Size string `json:"size,omitempty"` + Model string `json:"model"` + Prompt string `json:"prompt" binding:"required"` + N int `json:"n,omitempty"` + Image any `json:"image,omitempty"` + Size string `json:"size,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + OutputResolution string `json:"output_resolution,omitempty"` + Seed *float64 `json:"seed,omitempty"` + Seeds []int `json:"seeds,omitempty"` // Quality string `json:"quality,omitempty"` ResponseFormat string `json:"response_format,omitempty"` // Style string `json:"style,omitempty"` diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index ef1411af156d..4bb0c017e222 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -666,12 +666,19 @@ type TaskRelayInfo struct { type TaskSubmitReq struct { Prompt string `json:"prompt"` Model string `json:"model,omitempty"` + RequestId string `json:"request_id,omitempty"` Mode string `json:"mode,omitempty"` + Messages json.RawMessage `json:"messages,omitempty"` Image string `json:"image,omitempty"` + ImageURL string `json:"image_url,omitempty"` Images []string `json:"images,omitempty"` + ImageReference json.RawMessage `json:"image_reference,omitempty"` Size string `json:"size,omitempty"` Duration int `json:"duration,omitempty"` Seconds string `json:"seconds,omitempty"` + Quality string `json:"quality,omitempty"` + ResolutionName string `json:"resolution_name,omitempty"` + Preset string `json:"preset,omitempty"` InputReference string `json:"input_reference,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -681,7 +688,63 @@ func (t *TaskSubmitReq) GetPrompt() string { } func (t *TaskSubmitReq) HasImage() bool { - return len(t.Images) > 0 + if len(t.Images) > 0 { + return true + } + if strings.TrimSpace(t.Image) != "" || strings.TrimSpace(t.ImageURL) != "" || strings.TrimSpace(t.InputReference) != "" { + return true + } + if len(t.ImageReference) > 0 && common.GetJsonType(t.ImageReference) == "array" { + var refs []any + if err := common.Unmarshal(t.ImageReference, &refs); err == nil && len(refs) > 0 { + return true + } + } + return t.hasMessageImage() +} + +func (t *TaskSubmitReq) hasMessageImage() bool { + if len(t.Messages) == 0 || common.GetJsonType(t.Messages) != "array" { + return false + } + + var messages []any + if err := common.Unmarshal(t.Messages, &messages); err != nil { + return false + } + + for _, message := range messages { + messageMap, ok := message.(map[string]any) + if !ok { + continue + } + contentItems, ok := messageMap["content"].([]any) + if !ok { + continue + } + for _, item := range contentItems { + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + itemType := strings.ToLower(strings.TrimSpace(common.Interface2String(itemMap["type"]))) + if itemType != "image_url" { + continue + } + if imageURL := strings.TrimSpace(common.Interface2String(itemMap["image_url"])); imageURL != "" { + return true + } + imageURLMap, ok := itemMap["image_url"].(map[string]any) + if !ok { + continue + } + if imageURL := strings.TrimSpace(common.Interface2String(imageURLMap["url"])); imageURL != "" { + return true + } + } + } + + return false } func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error { @@ -737,6 +800,8 @@ type TaskInfo struct { Reason string `json:"reason,omitempty"` Url string `json:"url,omitempty"` RemoteUrl string `json:"remote_url,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` + CompletedAt int64 `json:"completed_at,omitempty"` Progress string `json:"progress,omitempty"` CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index e53ec804ca06..2aca19c20516 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -1,40 +1,39 @@ package common -import ( - "testing" - - "github.com/QuantumNous/new-api/types" - "github.com/stretchr/testify/require" -) - -func TestRelayInfoGetFinalRequestRelayFormatPrefersExplicitFinal(t *testing.T) { - info := &RelayInfo{ - RelayFormat: types.RelayFormatOpenAI, - RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude}, - FinalRequestRelayFormat: types.RelayFormatOpenAIResponses, +import "testing" + +func TestTaskSubmitReqHasImageFromMessages(t *testing.T) { + req := TaskSubmitReq{ + Messages: []byte(`[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "show the product" + }, + { + "type": "image_url", + "image_url": { + "url": "https://img688.com/file/demo.jpg" + } + } + ] + } + ]`), } - require.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), info.GetFinalRequestRelayFormat()) -} - -func TestRelayInfoGetFinalRequestRelayFormatFallsBackToConversionChain(t *testing.T) { - info := &RelayInfo{ - RelayFormat: types.RelayFormatOpenAI, - RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude}, + if !req.HasImage() { + t.Fatalf("expected HasImage to detect image_url inside messages") } - - require.Equal(t, types.RelayFormat(types.RelayFormatClaude), info.GetFinalRequestRelayFormat()) } -func TestRelayInfoGetFinalRequestRelayFormatFallsBackToRelayFormat(t *testing.T) { - info := &RelayInfo{ - RelayFormat: types.RelayFormatGemini, +func TestTaskSubmitReqHasImageFromTopLevelImageURL(t *testing.T) { + req := TaskSubmitReq{ + ImageURL: "https://img688.com/file/demo.jpg", } - require.Equal(t, types.RelayFormat(types.RelayFormatGemini), info.GetFinalRequestRelayFormat()) -} - -func TestRelayInfoGetFinalRequestRelayFormatNilReceiver(t *testing.T) { - var info *RelayInfo - require.Equal(t, types.RelayFormat(""), info.GetFinalRequestRelayFormat()) + if !req.HasImage() { + t.Fatalf("expected HasImage to detect top-level image_url") + } } diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 3cbb18c22c2f..15b6152e4c42 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -90,6 +90,7 @@ func validateMultipartTaskRequest(c *gin.Context, info *RelayInfo, action string Model: formData.Get("model"), Mode: formData.Get("mode"), Image: formData.Get("image"), + ImageURL: formData.Get("image_url"), Size: formData.Get("size"), Metadata: make(map[string]interface{}), } @@ -176,6 +177,10 @@ func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError { // OtherRatios 已移到 Sora adaptor 的 EstimateBilling 中设置 } + if len(req.Images) == 0 && strings.TrimSpace(req.ImageURL) != "" { + req.Images = []string{req.ImageURL} + } + storeTaskRequest(c, info, action, req) return nil @@ -187,9 +192,15 @@ func isKnownTaskField(field string) bool { "model": true, "mode": true, "image": true, + "image_url": true, "images": true, "size": true, "duration": true, + "seconds": true, + "quality": true, + "resolution_name": true, + "preset": true, + "image_reference": true, "input_reference": true, // Sora 特有字段 } return knownFields[field] diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 256715679213..ec4c45711312 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -66,9 +66,9 @@ func Path2RelayMode(path string) int { relayMode = RelayModeEmbeddings } else if strings.HasPrefix(path, "/v1/moderations") { relayMode = RelayModeModerations - } else if strings.HasPrefix(path, "/v1/images/generations") { + } else if strings.HasPrefix(path, "/v1/images/generations") || strings.HasPrefix(path, "/v1/images/async-generations") || strings.HasPrefix(path, "/pg/images/generations") { relayMode = RelayModeImagesGenerations - } else if strings.HasPrefix(path, "/v1/images/edits") { + } else if strings.HasPrefix(path, "/v1/images/edits") || strings.HasPrefix(path, "/v1/images/async-edits") || strings.HasPrefix(path, "/pg/images/edits") { relayMode = RelayModeImagesEdits } else if strings.HasPrefix(path, "/v1/edits") { relayMode = RelayModeEdits diff --git a/relay/constant/relay_mode_test.go b/relay/constant/relay_mode_test.go new file mode 100644 index 000000000000..d55879349c81 --- /dev/null +++ b/relay/constant/relay_mode_test.go @@ -0,0 +1,22 @@ +package constant + +import "testing" + +func TestPath2RelayModeSupportsPlaygroundImageRoutes(t *testing.T) { + tests := []struct { + path string + want int + }{ + {path: "/pg/images/generations", want: RelayModeImagesGenerations}, + {path: "/pg/images/edits", want: RelayModeImagesEdits}, + {path: "/v1/images/async-generations", want: RelayModeImagesGenerations}, + {path: "/v1/images/async-edits", want: RelayModeImagesEdits}, + {path: "/pg/chat/completions", want: RelayModeChatCompletions}, + } + + for _, tt := range tests { + if got := Path2RelayMode(tt.path); got != tt.want { + t.Fatalf("Path2RelayMode(%q) = %d, want %d", tt.path, got, tt.want) + } + } +} diff --git a/relay/helper/price.go b/relay/helper/price.go index f109040da0ed..81b9c70e8986 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -2,8 +2,11 @@ package helper import ( "fmt" + "strconv" + "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -16,6 +19,269 @@ import ( // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration const claudeCacheCreation1hMultiplier = 6 / 3.75 +func normalizeResolutionPriceKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func extractResolutionKeyFromTaskRequest(req relaycommon.TaskSubmitReq) string { + candidates := []string{ + req.Quality, + req.ResolutionName, + common.Interface2String(req.Metadata["output_resolution"]), + common.Interface2String(req.Metadata["resolution"]), + common.Interface2String(req.Metadata["resolution_name"]), + common.Interface2String(req.Metadata["quality"]), + } + for _, candidate := range candidates { + if key := normalizeResolutionPriceKey(candidate); key != "" { + return key + } + } + return "" +} + +func extractResolutionKeyFromRequest(request dto.Request) string { + switch req := request.(type) { + case *dto.GeneralOpenAIRequest: + candidates := []string{ + req.OutputResolution, + req.Resolution, + } + if req.Quality != nil { + candidates = append(candidates, *req.Quality) + } + for _, candidate := range candidates { + if key := normalizeResolutionPriceKey(candidate); key != "" { + return key + } + } + case *dto.ImageRequest: + for _, candidate := range []string{req.OutputResolution, req.Quality} { + if key := normalizeResolutionPriceKey(candidate); key != "" { + return key + } + } + } + return "" +} + +func extractPositiveIntValue(value any) (int, bool) { + switch v := value.(type) { + case int: + if v > 0 { + return v, true + } + case int64: + if v > 0 { + return int(v), true + } + case float64: + if v > 0 { + return int(v), true + } + case string: + if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && parsed > 0 { + return parsed, true + } + } + return 0, false +} + +func extractSecondsFromRequestMetadata(metadata []byte) (int, bool) { + if len(metadata) == 0 { + return 0, false + } + var metadataMap map[string]any + if err := common.Unmarshal(metadata, &metadataMap); err != nil { + return 0, false + } + for _, key := range []string{"durationSeconds", "duration_seconds", "duration", "seconds"} { + if seconds, ok := extractPositiveIntValue(metadataMap[key]); ok { + return seconds, true + } + } + return 0, false +} + +func extractSecondsFromRequest(request dto.Request) (int, bool) { + switch req := request.(type) { + case *dto.GeneralOpenAIRequest: + if seconds, ok := extractSecondsFromRequestMetadata(req.Metadata); ok { + return seconds, true + } + if req.Duration != nil { + if seconds, ok := extractPositiveIntValue(*req.Duration); ok { + return seconds, true + } + } + if req.Seconds != nil { + if seconds, ok := extractPositiveIntValue(*req.Seconds); ok { + return seconds, true + } + } + } + return 0, false +} + +func extractSecondsFromTaskRequest(req relaycommon.TaskSubmitReq) (int, bool) { + if req.Duration > 0 { + return req.Duration, true + } + if seconds, ok := extractPositiveIntValue(req.Seconds); ok { + return seconds, true + } + for _, key := range []string{"durationSeconds", "duration_seconds", "duration", "seconds"} { + if seconds, ok := extractPositiveIntValue(req.Metadata[key]); ok { + return seconds, true + } + } + return 0, false +} + +func GroupPriceCandidateGroups(info *relaycommon.RelayInfo) []string { + if info == nil { + return nil + } + seen := make(map[string]bool) + groups := make([]string, 0, 2) + for _, group := range []string{info.UserGroup, info.UsingGroup} { + group = strings.TrimSpace(group) + if group == "" || seen[group] { + continue + } + seen[group] = true + groups = append(groups, group) + } + return groups +} + +func ResolveGroupModelPrice(info *relaycommon.RelayInfo) (float64, string, bool) { + for _, group := range GroupPriceCandidateGroups(info) { + if price, ok := ratio_setting.GetGroupModelPrice(group, info.OriginModelName); ok { + return price, group, true + } + } + return 0, "", false +} + +func ResolveGroupModelPriceBySeconds(info *relaycommon.RelayInfo, seconds int) (float64, string, bool) { + for _, group := range GroupPriceCandidateGroups(info) { + if price, ok := ratio_setting.GetGroupModelPriceBySeconds(group, info.OriginModelName, seconds); ok { + return price, group, true + } + } + return 0, "", false +} + +func ResolveGroupModelPriceBySecondsMin(info *relaycommon.RelayInfo) (float64, string, bool) { + for _, group := range GroupPriceCandidateGroups(info) { + if price, ok := ratio_setting.GetGroupModelPriceBySecondsMin(group, info.OriginModelName); ok { + return price, group, true + } + } + return 0, "", false +} + +func ResolveGroupModelPriceByResolution(info *relaycommon.RelayInfo, resolution string) (float64, string, bool) { + for _, group := range GroupPriceCandidateGroups(info) { + if price, ok := ratio_setting.GetGroupModelPriceByResolution(group, info.OriginModelName, resolution); ok { + return price, group, true + } + } + return 0, "", false +} + +func ResolveGroupModelPriceByResolutionMin(info *relaycommon.RelayInfo) (float64, string, bool) { + for _, group := range GroupPriceCandidateGroups(info) { + if price, ok := ratio_setting.GetGroupModelPriceByResolutionMin(group, info.OriginModelName); ok { + return price, group, true + } + } + return 0, "", false +} + +func resolveSecondsBasedModelPrice(info *relaycommon.RelayInfo) (float64, bool) { + if info == nil || info.Request == nil { + return 0, false + } + seconds, ok := extractSecondsFromRequest(info.Request) + if !ok { + return 0, false + } + return ratio_setting.GetModelPriceBySeconds(info.OriginModelName, seconds) +} + +func resolveGroupSecondsBasedModelPrice(info *relaycommon.RelayInfo) (float64, string, bool) { + if info == nil || info.Request == nil { + return 0, "", false + } + seconds, ok := extractSecondsFromRequest(info.Request) + if !ok { + return 0, "", false + } + return ResolveGroupModelPriceBySeconds(info, seconds) +} + +func resolveGroupTaskSecondsBasedModelPrice(c *gin.Context, info *relaycommon.RelayInfo) (float64, string, bool) { + if c == nil || info == nil { + return 0, "", false + } + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return 0, "", false + } + seconds, ok := extractSecondsFromTaskRequest(req) + if !ok { + return 0, "", false + } + return ResolveGroupModelPriceBySeconds(info, seconds) +} + +func resolveResolutionBasedModelPrice(c *gin.Context, info *relaycommon.RelayInfo) (float64, bool) { + if info == nil { + return 0, false + } + if info != nil && info.Request != nil { + if resolution := extractResolutionKeyFromRequest(info.Request); resolution != "" { + return ratio_setting.GetModelPriceByResolution(info.OriginModelName, resolution) + } + } + if c != nil { + if req, err := relaycommon.GetTaskRequest(c); err == nil { + if resolution := extractResolutionKeyFromTaskRequest(req); resolution != "" { + return ratio_setting.GetModelPriceByResolution(info.OriginModelName, resolution) + } + } + } + return 0, false +} + +func resolveGroupResolutionBasedModelPrice(c *gin.Context, info *relaycommon.RelayInfo) (float64, string, bool) { + if info == nil { + return 0, "", false + } + if info.Request != nil { + if resolution := extractResolutionKeyFromRequest(info.Request); resolution != "" { + return ResolveGroupModelPriceByResolution(info, resolution) + } + } + if c != nil { + if req, err := relaycommon.GetTaskRequest(c); err == nil { + if resolution := extractResolutionKeyFromTaskRequest(req); resolution != "" { + return ResolveGroupModelPriceByResolution(info, resolution) + } + } + } + return 0, "", false +} + +func fixedPriceQuota(modelPrice float64, groupRatio float64, groupPriceOverride bool) int { + if groupPriceOverride { + return int(modelPrice * common.QuotaPerUnit) + } + return int(modelPrice * common.QuotaPerUnit * groupRatio) +} + // HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.GroupRatioInfo { groupRatioInfo := types.GroupRatioInfo{ @@ -46,10 +312,74 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types. } func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (types.PriceData, error) { - modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false) - groupRatioInfo := HandleGroupRatio(c, info) + var groupPriceOverride bool + var groupPriceOverrideGroup string + modelPrice, groupPriceOverrideGroup, usePrice := resolveGroupSecondsBasedModelPrice(info) + if usePrice { + groupPriceOverride = true + } + if !usePrice { + if resolutionPrice, overrideGroup, ok := resolveGroupResolutionBasedModelPrice(c, info); ok { + modelPrice = resolutionPrice + groupPriceOverrideGroup = overrideGroup + usePrice = true + groupPriceOverride = true + } + } + if !usePrice { + if groupModelPrice, overrideGroup, ok := ResolveGroupModelPrice(info); ok { + modelPrice = groupModelPrice + groupPriceOverrideGroup = overrideGroup + usePrice = true + groupPriceOverride = true + } + } + if !usePrice { + if secondsPrice, overrideGroup, ok := ResolveGroupModelPriceBySecondsMin(info); ok { + modelPrice = secondsPrice + groupPriceOverrideGroup = overrideGroup + usePrice = true + groupPriceOverride = true + } + } + if !usePrice { + if resolutionPrice, overrideGroup, ok := ResolveGroupModelPriceByResolutionMin(info); ok { + modelPrice = resolutionPrice + groupPriceOverrideGroup = overrideGroup + usePrice = true + groupPriceOverride = true + } + } + if !usePrice { + modelPrice, usePrice = ratio_setting.GetModelPrice(info.OriginModelName, false) + } + if !usePrice { + if secondsPrice, ok := resolveSecondsBasedModelPrice(info); ok { + modelPrice = secondsPrice + usePrice = true + } + } + if !usePrice { + if resolutionPrice, ok := resolveResolutionBasedModelPrice(c, info); ok { + modelPrice = resolutionPrice + usePrice = true + } + } + if !usePrice { + if secondsPrice, ok := ratio_setting.GetModelPriceBySecondsMin(info.OriginModelName); ok { + modelPrice = secondsPrice + usePrice = true + } + } + if !usePrice { + if resolutionPrice, ok := ratio_setting.GetModelPriceByResolutionMin(info.OriginModelName); ok { + modelPrice = resolutionPrice + usePrice = true + } + } + var preConsumedQuota int var modelRatio float64 var completionRatio float64 @@ -93,7 +423,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio } - preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + preConsumedQuota = fixedPriceQuota(modelPrice, groupRatioInfo.GroupRatio, groupPriceOverride) } // check if free model pre-consume is disabled @@ -116,20 +446,22 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - ModelRatio: modelRatio, - CompletionRatio: completionRatio, - GroupRatioInfo: groupRatioInfo, - UsePrice: usePrice, - CacheRatio: cacheRatio, - ImageRatio: imageRatio, - AudioRatio: audioRatio, - AudioCompletionRatio: audioCompletionRatio, - CacheCreationRatio: cacheCreationRatio, - CacheCreation5mRatio: cacheCreationRatio5m, - CacheCreation1hRatio: cacheCreationRatio1h, - QuotaToPreConsume: preConsumedQuota, + FreeModel: freeModel, + ModelPrice: modelPrice, + ModelRatio: modelRatio, + CompletionRatio: completionRatio, + GroupRatioInfo: groupRatioInfo, + UsePrice: usePrice, + GroupPriceOverride: groupPriceOverride, + GroupPriceOverrideGroup: groupPriceOverrideGroup, + CacheRatio: cacheRatio, + ImageRatio: imageRatio, + AudioRatio: audioRatio, + AudioCompletionRatio: audioCompletionRatio, + CacheCreationRatio: cacheCreationRatio, + CacheCreation5mRatio: cacheCreationRatio5m, + CacheCreation1hRatio: cacheCreationRatio1h, + QuotaToPreConsume: preConsumedQuota, } if common.DebugEnabled { @@ -143,8 +475,66 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { groupRatioInfo := HandleGroupRatio(c, info) - modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) + groupPriceOverride := false + groupPriceOverrideGroup := "" + modelPrice, groupPriceOverrideGroup, success := resolveGroupTaskSecondsBasedModelPrice(c, info) + if success { + groupPriceOverride = true + } + if !success { + if resolutionPrice, overrideGroup, ok := resolveGroupResolutionBasedModelPrice(c, info); ok { + modelPrice = resolutionPrice + groupPriceOverrideGroup = overrideGroup + success = true + groupPriceOverride = true + } + } + if !success { + if groupModelPrice, overrideGroup, ok := ResolveGroupModelPrice(info); ok { + modelPrice = groupModelPrice + groupPriceOverrideGroup = overrideGroup + success = true + groupPriceOverride = true + } + } + if !success { + if secondsPrice, overrideGroup, ok := ResolveGroupModelPriceBySecondsMin(info); ok { + modelPrice = secondsPrice + groupPriceOverrideGroup = overrideGroup + success = true + groupPriceOverride = true + } + } + if !success { + if resolutionPrice, overrideGroup, ok := ResolveGroupModelPriceByResolutionMin(info); ok { + modelPrice = resolutionPrice + groupPriceOverrideGroup = overrideGroup + success = true + groupPriceOverride = true + } + } + if !success { + modelPrice, success = ratio_setting.GetModelPrice(info.OriginModelName, true) + } + if !success { + if resolutionPrice, ok := resolveResolutionBasedModelPrice(c, info); ok { + modelPrice = resolutionPrice + success = true + } + } // 如果没有配置价格,检查模型倍率配置 + if !success { + if secondsPrice, ok := ratio_setting.GetModelPriceBySecondsMin(info.OriginModelName); ok { + modelPrice = secondsPrice + success = true + } + } + if !success { + if resolutionPrice, ok := ratio_setting.GetModelPriceByResolutionMin(info.OriginModelName); ok { + modelPrice = resolutionPrice + success = true + } + } if !success { // 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用 @@ -166,7 +556,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } } - quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + quota := fixedPriceQuota(modelPrice, groupRatioInfo.GroupRatio, groupPriceOverride) // 免费模型检测(与 ModelPriceHelper 对齐) freeModel := false @@ -178,10 +568,13 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - Quota: quota, - GroupRatioInfo: groupRatioInfo, + FreeModel: freeModel, + ModelPrice: modelPrice, + Quota: quota, + BaseQuota: quota, + GroupRatioInfo: groupRatioInfo, + GroupPriceOverride: groupPriceOverride, + GroupPriceOverrideGroup: groupPriceOverrideGroup, } return priceData, nil } diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go new file mode 100644 index 000000000000..8cb023d2a54b --- /dev/null +++ b/relay/helper/price_test.go @@ -0,0 +1,178 @@ +package helper + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModelPriceHelperUsesSecondsPriceForChatCompatibleVideo(t *testing.T) { + original := ratio_setting.ModelPriceBySeconds2JSONString() + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + _ = ratio_setting.UpdateModelPriceBySecondsByJSONString(original) + common.QuotaPerUnit = originalQuotaPerUnit + }() + + common.QuotaPerUnit = 500 + require.NoError(t, ratio_setting.UpdateModelPriceBySecondsByJSONString(`{ + "veo31": { + "4": 0.4, + "8": 0.8 + } + }`)) + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + duration := 8 + request := &dto.GeneralOpenAIRequest{ + Model: "veo31", + Duration: &duration, + } + info := &relaycommon.RelayInfo{ + OriginModelName: "veo31", + UsingGroup: "default", + Request: request, + } + + priceData, err := ModelPriceHelper(c, info, 0, &types.TokenCountMeta{}) + + require.NoError(t, err) + assert.True(t, priceData.UsePrice) + assert.Equal(t, 0.8, priceData.ModelPrice) + assert.Equal(t, int(0.8*common.QuotaPerUnit), priceData.QuotaToPreConsume) +} + +func TestModelPriceHelperUsesGroupResolutionPriceWithoutGroupRatio(t *testing.T) { + originalGroupResolution := ratio_setting.GroupModelPriceByResolution2JSONString() + originalGroupRatio := ratio_setting.GroupRatio2JSONString() + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + _ = ratio_setting.UpdateGroupModelPriceByResolutionByJSONString(originalGroupResolution) + _ = ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatio) + common.QuotaPerUnit = originalQuotaPerUnit + }() + + common.QuotaPerUnit = 500 + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{ + "default": 1, + "vip": 0.5 + }`)) + require.NoError(t, ratio_setting.UpdateGroupModelPriceByResolutionByJSONString(`{ + "vip": { + "nano-banana-pro": { + "2K": 0.12 + } + } + }`)) + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + request := &dto.GeneralOpenAIRequest{ + Model: "nano-banana-pro", + OutputResolution: "2K", + } + info := &relaycommon.RelayInfo{ + OriginModelName: "nano-banana-pro", + UsingGroup: "default", + UserGroup: "vip", + Request: request, + } + + priceData, err := ModelPriceHelper(c, info, 0, &types.TokenCountMeta{}) + + require.NoError(t, err) + assert.True(t, priceData.UsePrice) + assert.True(t, priceData.GroupPriceOverride) + assert.Equal(t, "vip", priceData.GroupPriceOverrideGroup) + assert.Equal(t, 0.12, priceData.ModelPrice) + assert.Equal(t, 1.0, priceData.GroupRatioInfo.GroupRatio) + assert.Equal(t, int(0.12*common.QuotaPerUnit), priceData.QuotaToPreConsume) +} + +func TestModelPriceHelperUsesGroupPerCallPriceWithoutGroupRatio(t *testing.T) { + originalGroupPrice := ratio_setting.GroupModelPrice2JSONString() + originalGroupRatio := ratio_setting.GroupRatio2JSONString() + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + _ = ratio_setting.UpdateGroupModelPriceByJSONString(originalGroupPrice) + _ = ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatio) + common.QuotaPerUnit = originalQuotaPerUnit + }() + + common.QuotaPerUnit = 500 + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{ + "default": 1, + "vip": 0.5 + }`)) + require.NoError(t, ratio_setting.UpdateGroupModelPriceByJSONString(`{ + "vip": { + "grok-imagine-1.0-edit": 0.02 + } + }`)) + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + request := &dto.ImageRequest{ + Model: "grok-imagine-1.0-edit", + } + info := &relaycommon.RelayInfo{ + OriginModelName: "grok-imagine-1.0-edit", + UsingGroup: "default", + UserGroup: "vip", + Request: request, + } + + priceData, err := ModelPriceHelper(c, info, 0, &types.TokenCountMeta{}) + + require.NoError(t, err) + assert.True(t, priceData.UsePrice) + assert.True(t, priceData.GroupPriceOverride) + assert.Equal(t, "vip", priceData.GroupPriceOverrideGroup) + assert.Equal(t, 0.02, priceData.ModelPrice) + assert.Equal(t, 1.0, priceData.GroupRatioInfo.GroupRatio) + assert.Equal(t, int(0.02*common.QuotaPerUnit), priceData.QuotaToPreConsume) +} + +func TestModelPriceHelperFallsBackToSecondsMinPrice(t *testing.T) { + original := ratio_setting.ModelPriceBySeconds2JSONString() + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + _ = ratio_setting.UpdateModelPriceBySecondsByJSONString(original) + common.QuotaPerUnit = originalQuotaPerUnit + }() + + common.QuotaPerUnit = 500 + require.NoError(t, ratio_setting.UpdateModelPriceBySecondsByJSONString(`{ + "veo31": { + "4": 0.4, + "8": 0.8 + } + }`)) + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + request := &dto.GeneralOpenAIRequest{ + Model: "veo31", + } + info := &relaycommon.RelayInfo{ + OriginModelName: "veo31", + UsingGroup: "default", + Request: request, + } + + priceData, err := ModelPriceHelper(c, info, 0, &types.TokenCountMeta{}) + + require.NoError(t, err) + assert.True(t, priceData.UsePrice) + assert.Equal(t, 0.4, priceData.ModelPrice) + assert.Equal(t, int(0.4*common.QuotaPerUnit), priceData.QuotaToPreConsume) +} diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index c5477ccead65..78a8c843ea73 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -1,7 +1,6 @@ package helper import ( - "encoding/json" "errors" "fmt" "math" @@ -17,6 +16,21 @@ import ( "github.com/gin-gonic/gin" ) +var gptImage2AspectRatios = map[string]struct{}{ + "1:1": {}, + "16:9": {}, + "9:16": {}, + "4:3": {}, + "3:4": {}, + "3:2": {}, + "2:3": {}, +} + +const ( + gptImage2MaxImages = 6 + gptImage2OutputResolution = "1K" +) + func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dto.Request, err error) { relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path) @@ -156,7 +170,7 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq imageRequest.Quality = formData.Get("quality") imageRequest.Size = formData.Get("size") if imageValue := formData.Get("image"); imageValue != "" { - imageRequest.Image, _ = json.Marshal(imageValue) + imageRequest.Image, _ = common.Marshal(imageValue) } if imageRequest.Model == "gpt-image-1" { @@ -173,6 +187,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq watermark := formData.Get("watermark") == "true" imageRequest.Watermark = &watermark } + if err := validateGPTImage2Request(c, imageRequest); err != nil { + return nil, err + } break } fallthrough @@ -214,6 +231,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq imageRequest.Quality = "auto" } } + if err := validateGPTImage2Request(c, imageRequest); err != nil { + return nil, err + } //if imageRequest.Prompt == "" { // return nil, errors.New("prompt is required") @@ -227,6 +247,179 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq return imageRequest, nil } +func validateGPTImage2Request(c *gin.Context, imageRequest *dto.ImageRequest) error { + if imageRequest == nil || !strings.EqualFold(strings.TrimSpace(imageRequest.Model), "gpt-image2") { + return nil + } + + if err := validateGPTImage2AspectRatio("size", imageRequest.Size); err != nil { + return err + } + if err := validateGPTImage2AspectRatio("aspect_ratio", imageRequest.AspectRatio); err != nil { + return err + } + if strings.TrimSpace(imageRequest.AspectRatio) == "" && strings.Contains(imageRequest.Size, ":") { + imageRequest.AspectRatio = strings.TrimSpace(imageRequest.Size) + } + if strings.TrimSpace(imageRequest.OutputResolution) == "" { + imageRequest.OutputResolution = gptImage2OutputResolution + } else if !strings.EqualFold(strings.TrimSpace(imageRequest.OutputResolution), gptImage2OutputResolution) { + return fmt.Errorf("output_resolution must be %s for gpt-image2", gptImage2OutputResolution) + } + + imageCount := countGPTImage2JSONImages(imageRequest.ImageUrls) + countGPTImage2JSONImages(imageRequest.Image) + if multipartCount := countGPTImage2MultipartImages(c); multipartCount > 0 { + imageCount = multipartCount + } + if imageCount > gptImage2MaxImages { + return fmt.Errorf("gpt-image2 supports at most %d uploaded images", gptImage2MaxImages) + } + if err := normalizeGPTImage2ReferenceMessages(imageRequest); err != nil { + return err + } + return nil +} + +func validateGPTImage2AspectRatio(fieldName string, value string) error { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + if _, ok := gptImage2AspectRatios[value]; ok { + return nil + } + return fmt.Errorf("%s must be one of 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, or 2:3 for gpt-image2", fieldName) +} + +func countGPTImage2JSONImages(raw []byte) int { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return 0 + } + + var items []any + if err := common.Unmarshal(raw, &items); err == nil { + return len(items) + } + return 1 +} + +func countGPTImage2MultipartImages(c *gin.Context) int { + if c == nil || c.Request == nil || c.Request.MultipartForm == nil { + return 0 + } + + count := 0 + for fieldName, files := range c.Request.MultipartForm.File { + if fieldName == "image" || fieldName == "image[]" || strings.HasPrefix(fieldName, "image[") { + count += len(files) + } + } + return count +} + +func normalizeGPTImage2ReferenceMessages(imageRequest *dto.ImageRequest) error { + if hasRawJSONValue(imageRequest.Messages) { + return nil + } + + imageUrls := collectGPTImage2ReferenceImageURLs(imageRequest.ImageUrls) + imageUrls = append(imageUrls, collectGPTImage2ReferenceImageURLs(imageRequest.Image)...) + if len(imageUrls) == 0 { + return nil + } + + prompt := strings.TrimSpace(imageRequest.Prompt) + if prompt == "" { + prompt = "Edit the provided media." + } + content := make([]map[string]any, 0, len(imageUrls)+1) + content = append(content, map[string]any{ + "type": "text", + "text": prompt, + }) + for _, imageUrl := range imageUrls { + content = append(content, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": imageUrl, + }, + }) + } + + messages, err := common.Marshal([]map[string]any{ + { + "role": "user", + "content": content, + }, + }) + if err != nil { + return err + } + imageRequest.Messages = messages + imageRequest.ImageUrls = nil + imageRequest.Image = nil + return nil +} + +func collectGPTImage2ReferenceImageURLs(raw []byte) []string { + if !hasRawJSONValue(raw) { + return nil + } + + var urls []string + if err := common.Unmarshal(raw, &urls); err == nil { + return compactGPTImage2ReferenceImageURLs(urls) + } + + var url string + if err := common.Unmarshal(raw, &url); err == nil { + return compactGPTImage2ReferenceImageURLs([]string{url}) + } + + var items []map[string]any + if err := common.Unmarshal(raw, &items); err == nil { + for _, item := range items { + if url := extractGPTImage2ImageURLValue(item["url"]); url != "" { + urls = append(urls, url) + continue + } + if url := extractGPTImage2ImageURLValue(item["image_url"]); url != "" { + urls = append(urls, url) + } + } + } + return compactGPTImage2ReferenceImageURLs(urls) +} + +func extractGPTImage2ImageURLValue(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case map[string]any: + if url, ok := typed["url"].(string); ok { + return strings.TrimSpace(url) + } + } + return "" +} + +func compactGPTImage2ReferenceImageURLs(urls []string) []string { + result := make([]string, 0, len(urls)) + for _, url := range urls { + url = strings.TrimSpace(url) + if url != "" { + result = append(result, url) + } + } + return result +} + +func hasRawJSONValue(raw []byte) bool { + trimmed := strings.TrimSpace(string(raw)) + return trimmed != "" && trimmed != "null" +} + func GetAndValidateClaudeRequest(c *gin.Context) (textRequest *dto.ClaudeRequest, err error) { textRequest = &dto.ClaudeRequest{} err = common.UnmarshalBodyReusable(c, textRequest) diff --git a/relay/helper/valid_request_test.go b/relay/helper/valid_request_test.go new file mode 100644 index 000000000000..1a9480cf5fec --- /dev/null +++ b/relay/helper/valid_request_test.go @@ -0,0 +1,133 @@ +package helper + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http/httptest" + "strings" + "testing" + + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/gin-gonic/gin" +) + +func TestGetAndValidOpenAIImageRequestAllowsGPTImage2SixImages(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{ + "model":"gpt-image2", + "prompt":"make a campaign image", + "size":"3:2", + "image_urls":[ + "https://example.com/1.png", + "https://example.com/2.png", + "https://example.com/3.png", + "https://example.com/4.png", + "https://example.com/5.png", + "https://example.com/6.png" + ] + }` + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/generations", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + + req, err := GetAndValidOpenAIImageRequest(ctx, relayconstant.RelayModeImagesGenerations) + + require.NoError(t, err) + require.Equal(t, "gpt-image2", req.Model) + require.Equal(t, "3:2", req.Size) + require.Equal(t, "3:2", req.AspectRatio) + require.Equal(t, "1K", req.OutputResolution) + require.Empty(t, req.ImageUrls) + require.Equal(t, "https://example.com/1.png", gjson.GetBytes(req.Messages, "0.content.1.image_url.url").String()) + require.Equal(t, "https://example.com/6.png", gjson.GetBytes(req.Messages, "0.content.6.image_url.url").String()) +} + +func TestGetAndValidOpenAIImageRequestRejectsGPTImage2InvalidSize(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{ + "model":"gpt-image2", + "prompt":"make a campaign image", + "size":"1024x1024" + }` + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/generations", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + + _, err := GetAndValidOpenAIImageRequest(ctx, relayconstant.RelayModeImagesGenerations) + + require.Error(t, err) + require.Contains(t, err.Error(), "size must be one of") +} + +func TestGetAndValidOpenAIImageRequestRejectsGPTImage2InvalidOutputResolution(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{ + "model":"gpt-image2", + "prompt":"make a campaign image", + "aspect_ratio":"1:1", + "output_resolution":"2K" + }` + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/generations", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + + _, err := GetAndValidOpenAIImageRequest(ctx, relayconstant.RelayModeImagesGenerations) + + require.Error(t, err) + require.Contains(t, err.Error(), "output_resolution must be 1K") +} + +func TestGetAndValidOpenAIImageRequestRejectsGPTImage2TooManyJSONImages(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{ + "model":"gpt-image2", + "prompt":"make a campaign image", + "aspect_ratio":"16:9", + "image_urls":[ + "https://example.com/1.png", + "https://example.com/2.png", + "https://example.com/3.png", + "https://example.com/4.png", + "https://example.com/5.png", + "https://example.com/6.png", + "https://example.com/7.png" + ] + }` + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/generations", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + + _, err := GetAndValidOpenAIImageRequest(ctx, relayconstant.RelayModeImagesGenerations) + + require.Error(t, err) + require.Contains(t, err.Error(), "at most 6 uploaded images") +} + +func TestGetAndValidOpenAIImageRequestRejectsGPTImage2TooManyMultipartImages(t *testing.T) { + gin.SetMode(gin.TestMode) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("model", "gpt-image2")) + require.NoError(t, writer.WriteField("prompt", "make a campaign image")) + require.NoError(t, writer.WriteField("size", "1:1")) + for i := 0; i < 7; i++ { + part, err := writer.CreateFormFile("image[]", fmt.Sprintf("image-%d.png", i)) + require.NoError(t, err) + _, err = part.Write([]byte("fake image")) + require.NoError(t, err) + } + require.NoError(t, writer.Close()) + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/images/edits", &body) + ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + _, err := GetAndValidOpenAIImageRequest(ctx, relayconstant.RelayModeImagesEdits) + + require.Error(t, err) + require.Contains(t, err.Error(), "at most 6 uploaded images") +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 3139c9a2dd4a..fa9f9227632e 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -153,7 +153,7 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskVidu.TaskAdaptor{} case constant.ChannelTypeDoubaoVideo, constant.ChannelTypeVolcEngine: return &taskdoubao.TaskAdaptor{} - case constant.ChannelTypeSora, constant.ChannelTypeOpenAI: + case constant.ChannelTypeSora, constant.ChannelTypeOpenAI, constant.ChannelTypeXai: return &tasksora.TaskAdaptor{} case constant.ChannelTypeGemini: return &taskGemini.TaskAdaptor{} diff --git a/relay/relay_task.go b/relay/relay_task.go index 098e23828b6c..183d7269a556 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -19,9 +19,24 @@ import ( relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" ) +func normalizeTaskTimestamp(ts int64) int64 { + if ts <= 0 { + return 0 + } + if ts > 1000000000000 { + return ts / 1000 + } + return ts +} + +func isSuccessfulTaskSubmitStatus(statusCode int) bool { + return statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices +} + type TaskSubmitResult struct { UpstreamTaskID string TaskData []byte @@ -30,6 +45,81 @@ type TaskSubmitResult struct { //PerCallPrice types.PriceData } +func extractTaskPromptFromContext(c *gin.Context) string { + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return "" + } + return strings.TrimSpace(req.GetPrompt()) +} + +func extractTaskClientRequestIDFromContext(c *gin.Context) string { + req, err := relaycommon.GetTaskRequest(c) + if err == nil { + if requestID := strings.TrimSpace(req.RequestId); requestID != "" { + return requestID + } + } + return strings.TrimSpace(c.GetHeader("X-Request-Id")) +} + +func upsertPendingRelayTaskRecord(c *gin.Context, info *relaycommon.RelayInfo, platform constant.TaskPlatform) { + if info == nil || info.PublicTaskID == "" || info.Action == "" || platform == "" { + return + } + + task, exist, err := model.GetByOnlyTaskId(info.PublicTaskID) + if err != nil { + common.SysError("get pending task for upsert error: " + err.Error()) + return + } + if !exist || task == nil { + task = model.InitTask(platform, info) + } else { + task.Platform = platform + task.UserId = info.UserId + task.Group = info.UsingGroup + task.ChannelId = info.ChannelId + } + + task.Action = info.Action + task.Status = model.TaskStatusSubmitted + task.Progress = taskcommon.ProgressSubmitted + task.PrivateData.RequestId = info.RequestId + task.PrivateData.BillingSource = info.BillingSource + task.PrivateData.SubscriptionId = info.SubscriptionId + task.PrivateData.TokenId = info.TokenId + task.PrivateData.UpstreamRequestPath = strings.TrimSpace(info.RequestURLPath) + if prompt := extractTaskPromptFromContext(c); prompt != "" { + task.Properties.Input = prompt + } + if clientRequestID := extractTaskClientRequestIDFromContext(c); clientRequestID != "" { + task.PrivateData.ClientRequestId = clientRequestID + } + task.PrivateData.BillingContext = &model.TaskBillingContext{ + ModelPrice: info.PriceData.ModelPrice, + GroupRatio: info.PriceData.GroupRatioInfo.GroupRatio, + ModelRatio: info.PriceData.ModelRatio, + OtherRatios: info.PriceData.OtherRatios, + OriginModelName: info.OriginModelName, + PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName), + GroupPriceOverride: info.PriceData.GroupPriceOverride, + GroupPriceOverrideGroup: info.PriceData.GroupPriceOverrideGroup, + UsingGroup: info.UsingGroup, + } + + if exist { + if updateErr := task.Update(); updateErr != nil { + common.SysError("update pending task error: " + updateErr.Error()) + } + return + } + + if insertErr := task.Insert(); insertErr != nil { + common.SysError("insert pending task error: " + insertErr.Error()) + } +} + // ResolveOriginTask 处理基于已有任务的提交(remix / continuation): // 查找原始任务、从中提取模型名称、将渠道锁定到原始任务的渠道 // (通过 info.LockedChannel,重试时复用同一渠道并轮换 key), @@ -194,13 +284,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } // 6. 将 OtherRatios 应用到基础额度 - if !common.StringsContains(constant.TaskPricePatches, modelName) { - for _, ra := range info.PriceData.OtherRatios { - if ra != 1.0 { - info.PriceData.Quota = int(float64(info.PriceData.Quota) * ra) - } - } - } + info.PriceData.Quota, info.PriceData.OtherRatios = calcTaskQuotaWithRatios(c, info, info.PriceData.OtherRatios) // 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过) if info.Billing == nil && !info.PriceData.FreeModel { @@ -216,12 +300,14 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe return nil, service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) } + upsertPendingRelayTaskRecord(c, info, platform) + // 9. 发送请求 resp, err := adaptor.DoRequest(c, info, requestBody) if err != nil { return nil, service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) } - if resp != nil && resp.StatusCode != http.StatusOK { + if resp != nil && !isSuccessfulTaskSubmitStatus(resp.StatusCode) { responseBody, _ := io.ReadAll(resp.Body) return nil, service.TaskErrorWrapper(fmt.Errorf("%s", string(responseBody)), "fail_to_fetch_task", resp.StatusCode) } @@ -244,7 +330,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe finalQuota := info.PriceData.Quota if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 { // 基于调整后的 ratios 重新计算 quota - finalQuota = recalcQuotaFromRatios(info, adjustedRatios) + finalQuota, adjustedRatios = calcTaskQuotaWithRatios(c, info, adjustedRatios) info.PriceData.OtherRatios = adjustedRatios info.PriceData.Quota = finalQuota } @@ -278,6 +364,103 @@ func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float6 return int(result) } +func normalizeTaskResolutionKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func extractTaskResolution(req relaycommon.TaskSubmitReq) string { + candidates := []string{ + req.Quality, + req.ResolutionName, + common.Interface2String(req.Metadata["output_resolution"]), + common.Interface2String(req.Metadata["resolution"]), + common.Interface2String(req.Metadata["resolution_name"]), + common.Interface2String(req.Metadata["quality"]), + } + for _, candidate := range candidates { + if key := normalizeTaskResolutionKey(candidate); key != "" { + return key + } + } + return "" +} + +func calcTaskQuotaWithRatios(c *gin.Context, info *relaycommon.RelayInfo, ratios map[string]float64) (int, map[string]float64) { + normalizedRatios := common.FilterOtherRatiosForBillingModel(info.OriginModelName, cloneTaskRatios(ratios)) + baseQuota := info.PriceData.BaseQuota + if baseQuota <= 0 { + baseQuota = info.PriceData.Quota + } + + if seconds, ok := extractTaskSeconds(normalizedRatios); ok { + if secondsPrice, overrideGroup, found := helper.ResolveGroupModelPriceBySeconds(info, seconds); found { + info.PriceData.ModelPrice = secondsPrice + info.PriceData.GroupPriceOverride = true + info.PriceData.GroupPriceOverrideGroup = overrideGroup + baseQuota = int(secondsPrice * common.QuotaPerUnit) + normalizedRatios["seconds"] = 1 + } else if secondsPrice, found := ratio_setting.GetModelPriceBySeconds(info.OriginModelName, seconds); found { + info.PriceData.ModelPrice = secondsPrice + info.PriceData.GroupPriceOverride = false + info.PriceData.GroupPriceOverrideGroup = "" + baseQuota = int(secondsPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio) + normalizedRatios["seconds"] = 1 + } + } + + if c != nil { + if req, err := relaycommon.GetTaskRequest(c); err == nil { + if resolution := extractTaskResolution(req); resolution != "" { + if resolutionPrice, overrideGroup, found := helper.ResolveGroupModelPriceByResolution(info, resolution); found { + info.PriceData.ModelPrice = resolutionPrice + info.PriceData.GroupPriceOverride = true + info.PriceData.GroupPriceOverrideGroup = overrideGroup + baseQuota = int(resolutionPrice * common.QuotaPerUnit) + normalizedRatios["resolution"] = 1 + } else if resolutionPrice, found := ratio_setting.GetModelPriceByResolution(info.OriginModelName, resolution); found { + info.PriceData.ModelPrice = resolutionPrice + info.PriceData.GroupPriceOverride = false + info.PriceData.GroupPriceOverrideGroup = "" + baseQuota = int(resolutionPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio) + normalizedRatios["resolution"] = 1 + } + } + } + } + + result := float64(baseQuota) + if !common.StringsContains(constant.TaskPricePatches, info.OriginModelName) { + for _, ra := range normalizedRatios { + if ra != 1.0 { + result *= ra + } + } + } + return int(result), normalizedRatios +} + +func cloneTaskRatios(ratios map[string]float64) map[string]float64 { + if len(ratios) == 0 { + return map[string]float64{} + } + cloned := make(map[string]float64, len(ratios)) + for key, value := range ratios { + cloned[key] = value + } + return cloned +} + +func extractTaskSeconds(ratios map[string]float64) (int, bool) { + if len(ratios) == 0 { + return 0, false + } + seconds, ok := ratios["seconds"] + if !ok || seconds <= 0 { + return 0, false + } + return int(seconds), true +} + var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, @@ -464,6 +647,45 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte { if ti.Progress != "" { task.Progress = ti.Progress } + if len(body) > 0 { + task.Data = body + } + now := common.GetTimestamp() + createdAt := normalizeTaskTimestamp(ti.CreatedAt) + completedAt := normalizeTaskTimestamp(ti.CompletedAt) + switch task.Status { + case model.TaskStatusInProgress: + if task.StartTime == 0 { + if createdAt > 0 { + task.StartTime = createdAt + } else if task.SubmitTime > 0 { + task.StartTime = task.SubmitTime + } else { + task.StartTime = now + } + } + case model.TaskStatusSuccess: + if createdAt > 0 && (task.StartTime == 0 || task.StartTime > createdAt) { + task.StartTime = createdAt + } + if task.StartTime == 0 { + task.StartTime = task.SubmitTime + } + if task.StartTime == 0 { + task.StartTime = now + } + if completedAt > 0 && completedAt > task.FinishTime { + task.FinishTime = completedAt + } else if task.FinishTime == 0 { + task.FinishTime = now + } + case model.TaskStatusFailure: + if completedAt > 0 && completedAt > task.FinishTime { + task.FinishTime = completedAt + } else if task.FinishTime == 0 { + task.FinishTime = now + } + } if strings.HasPrefix(ti.Url, "data:") { // data: URI — kept in Data, not ResultURL } else if ti.Url != "" { @@ -539,11 +761,19 @@ func mapTaskStatusToSimple(status model.TaskStatus) string { } func TaskModel2Dto(task *model.Task) *dto.TaskDto { + resultURL := "" + if task.Status != model.TaskStatusFailure { + resultURL = task.PrivateData.ResultURL + if resultURL == "" && task.Status == model.TaskStatusSuccess { + resultURL = task.GetResultURL() + } + } return &dto.TaskDto{ ID: task.ID, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, TaskID: task.TaskID, + RequestID: task.GetRequestID(), Platform: string(task.Platform), UserId: task.UserId, Group: task.Group, @@ -552,7 +782,7 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto { Action: task.Action, Status: string(task.Status), FailReason: task.FailReason, - ResultURL: task.GetResultURL(), + ResultURL: resultURL, SubmitTime: task.SubmitTime, StartTime: task.StartTime, FinishTime: task.FinishTime, diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go new file mode 100644 index 000000000000..ebe10443312a --- /dev/null +++ b/relay/relay_task_test.go @@ -0,0 +1,158 @@ +package relay + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCalcTaskQuotaWithRatiosUsesMappedSecondsPrice(t *testing.T) { + original := ratio_setting.ModelPriceBySeconds2JSONString() + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + _ = ratio_setting.UpdateModelPriceBySecondsByJSONString(original) + common.QuotaPerUnit = originalQuotaPerUnit + }() + + common.QuotaPerUnit = 500 + require.NoError(t, ratio_setting.UpdateModelPriceBySecondsByJSONString(`{ + "grok-imagine-1.0-video": { + "12": 0.2 + } + }`)) + + info := &relaycommon.RelayInfo{ + OriginModelName: "grok-imagine-1.0-video", + PriceData: types.PriceData{ + BaseQuota: 100, + Quota: 100, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 1, + }, + }, + } + + quota, ratios := calcTaskQuotaWithRatios(nil, info, map[string]float64{ + "seconds": 12, + "size": 1.666667, + }) + + assert.Equal(t, int(0.2*common.QuotaPerUnit), quota) + assert.Equal(t, 1.0, ratios["seconds"]) + _, hasSize := ratios["size"] + assert.False(t, hasSize) + assert.Equal(t, 0.2, info.PriceData.ModelPrice) +} + +func TestCalcTaskQuotaWithRatiosUsesGroupMappedSecondsPriceWithoutGroupRatio(t *testing.T) { + original := ratio_setting.GroupModelPriceBySeconds2JSONString() + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + _ = ratio_setting.UpdateGroupModelPriceBySecondsByJSONString(original) + common.QuotaPerUnit = originalQuotaPerUnit + }() + + common.QuotaPerUnit = 500 + require.NoError(t, ratio_setting.UpdateGroupModelPriceBySecondsByJSONString(`{ + "vip": { + "grok-imagine-1.0-video": { + "8": 0.07 + } + } + }`)) + + info := &relaycommon.RelayInfo{ + OriginModelName: "grok-imagine-1.0-video", + UsingGroup: "default", + UserGroup: "vip", + PriceData: types.PriceData{ + BaseQuota: 100, + Quota: 100, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 0.5, + }, + }, + } + + quota, ratios := calcTaskQuotaWithRatios(nil, info, map[string]float64{ + "seconds": 8, + "size": 1.666667, + }) + + assert.Equal(t, int(0.07*common.QuotaPerUnit), quota) + assert.Equal(t, 1.0, ratios["seconds"]) + _, hasSize := ratios["size"] + assert.False(t, hasSize) + assert.Equal(t, 0.07, info.PriceData.ModelPrice) + assert.True(t, info.PriceData.GroupPriceOverride) + assert.Equal(t, "vip", info.PriceData.GroupPriceOverrideGroup) +} + +func TestCalcTaskQuotaWithRatiosFallsBackToLinearSeconds(t *testing.T) { + original := ratio_setting.ModelPriceBySeconds2JSONString() + defer func() { + _ = ratio_setting.UpdateModelPriceBySecondsByJSONString(original) + }() + + require.NoError(t, ratio_setting.UpdateModelPriceBySecondsByJSONString(`{}`)) + + info := &relaycommon.RelayInfo{ + OriginModelName: "grok-imagine-1.0-video", + PriceData: types.PriceData{ + BaseQuota: 100, + Quota: 100, + }, + } + + quota, ratios := calcTaskQuotaWithRatios(nil, info, map[string]float64{ + "seconds": 12, + "size": 1.5, + }) + + assert.Equal(t, 1200, quota) + assert.Equal(t, 12.0, ratios["seconds"]) + _, hasSize := ratios["size"] + assert.False(t, hasSize) +} + +func TestTaskModel2DtoDoesNotExposeFailureReasonAsResultURL(t *testing.T) { + task := &model.Task{ + TaskID: "task_failed", + Status: model.TaskStatusFailure, + FailReason: "video poll failed", + PrivateData: model.TaskPrivateData{ + ResultURL: "https://example.com/stale-video.mp4", + }, + } + + dtoTask := TaskModel2Dto(task) + + assert.Empty(t, dtoTask.ResultURL) + assert.Equal(t, task.FailReason, dtoTask.FailReason) +} + +func TestTaskModel2DtoKeepsLegacySuccessResultURLFallback(t *testing.T) { + task := &model.Task{ + TaskID: "task_success", + Status: model.TaskStatusSuccess, + FailReason: "https://example.com/video.mp4", + } + + dtoTask := TaskModel2Dto(task) + + assert.Equal(t, task.FailReason, dtoTask.ResultURL) +} + +func TestIsSuccessfulTaskSubmitStatusAcceptsAny2xx(t *testing.T) { + assert.True(t, isSuccessfulTaskSubmitStatus(200)) + assert.True(t, isSuccessfulTaskSubmitStatus(202)) + assert.True(t, isSuccessfulTaskSubmitStatus(299)) + assert.False(t, isSuccessfulTaskSubmitStatus(199)) + assert.False(t, isSuccessfulTaskSubmitStatus(300)) +} diff --git a/replace.js b/replace.js new file mode 100644 index 000000000000..12d7cd9fed7c --- /dev/null +++ b/replace.js @@ -0,0 +1,16 @@ +const fs = require('fs'); +let content = fs.readFileSync('web/src/pages/CreativeCenter/index.jsx', 'utf8'); + +content = content.replace(/indigo-600/g, 'blue-600'); +content = content.replace(/indigo-500/g, 'blue-500'); +content = content.replace(/indigo-400/g, 'blue-400'); +content = content.replace(/indigo-300/g, 'blue-300'); +content = content.replace(/indigo-200/g, 'blue-200'); +content = content.replace(/indigo-100/g, 'blue-100'); +content = content.replace(/indigo-50/g, 'blue-50'); +content = content.replace(/purple-600/g, 'sky-500'); +content = content.replace(/purple-500/g, 'sky-400'); +content = content.replace(/99,102,241/g, '59,130,246'); // indigo-500 to blue-500 + +fs.writeFileSync('web/src/pages/CreativeCenter/index.jsx', content, 'utf8'); +console.log('Done replacing colors.'); diff --git a/router/api-router.go b/router/api-router.go index 31ab771a50bd..69024e65c026 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -28,6 +28,9 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/user-agreement", controller.GetUserAgreement) apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy) apiRouter.GET("/about", controller.GetAbout) + apiRouter.GET("/public/creative-center/image/:filename", controller.GetCreativeCenterUploadedImage) + apiRouter.GET("/public/creative-center/image/proxy", controller.ProxyCreativeCenterRemoteImage) + apiRouter.GET("/public/creative-center/media/download", controller.DownloadCreativeCenterRemoteMedia) //apiRouter.GET("/midjourney", controller.GetMidjourney) apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) @@ -72,6 +75,20 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/self/groups", controller.GetUserGroups) selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/models", controller.GetUserModels) + selfRoute.GET("/creative-center/history", controller.GetCreativeCenterHistory) + selfRoute.PUT("/creative-center/history", controller.SaveCreativeCenterHistory) + selfRoute.DELETE("/creative-center/history/:tab", controller.DeleteCreativeCenterHistory) + selfRoute.GET("/creative-center/image/upload-config", controller.GetCreativeCenterImageUploadConfig) + selfRoute.POST("/creative-center/image/upload", middleware.UploadRateLimit(), controller.UploadCreativeCenterImage) + selfRoute.GET("/creative-center/image/proxy", controller.ProxyCreativeCenterRemoteImage) + selfRoute.GET("/creative-center/media/download", controller.DownloadCreativeCenterRemoteMedia) + selfRoute.GET("/self/creative-center/history", controller.GetCreativeCenterHistory) + selfRoute.PUT("/self/creative-center/history", controller.SaveCreativeCenterHistory) + selfRoute.DELETE("/self/creative-center/history/:tab", controller.DeleteCreativeCenterHistory) + selfRoute.GET("/self/creative-center/image/upload-config", controller.GetCreativeCenterImageUploadConfig) + selfRoute.POST("/self/creative-center/image/upload", middleware.UploadRateLimit(), controller.UploadCreativeCenterImage) + selfRoute.GET("/self/creative-center/image/proxy", controller.ProxyCreativeCenterRemoteImage) + selfRoute.GET("/self/creative-center/media/download", controller.DownloadCreativeCenterRemoteMedia) selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) @@ -320,7 +337,18 @@ func SetApiRouter(router *gin.Engine) { taskRoute := apiRouter.Group("/task") { taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) + taskRoute.POST("/self/resolve", middleware.UserAuth(), controller.ResolveUserTask) + taskRoute.GET("/self/stats", middleware.UserAuth(), controller.GetUserTaskStats) taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) + taskRoute.GET("/stats", middleware.AdminAuth(), controller.GetAllTaskStats) + } + + assetRoute := apiRouter.Group("/asset") + { + assetRoute.GET("/self", middleware.UserAuth(), controller.GetUserCreativeCenterAssets) + assetRoute.POST("/self/download", middleware.UserAuth(), controller.DownloadUserCreativeCenterAssets) + assetRoute.GET("/", middleware.AdminAuth(), controller.GetAllCreativeCenterAssets) + assetRoute.POST("/download", middleware.AdminAuth(), controller.DownloadAllCreativeCenterAssets) } vendorRoute := apiRouter.Group("/vendors") diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..6ce9dfd9795a 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -62,9 +62,22 @@ func SetRelayRouter(router *gin.Engine) { playgroundRouter := router.Group("/pg") playgroundRouter.Use(middleware.RouteTag("relay")) playgroundRouter.Use(middleware.SystemPerformanceCheck()) - playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) + playgroundRouter.Use(middleware.UserAuth()) { - playgroundRouter.POST("/chat/completions", controller.Playground) + playgroundSubmitRouter := playgroundRouter.Group("") + playgroundSubmitRouter.Use(middleware.Distribute()) + playgroundSubmitRouter.POST("/chat/completions", controller.Playground) + playgroundSubmitRouter.POST("/images/generations", controller.PlaygroundImageGenerations) + playgroundSubmitRouter.POST("/images/edits", controller.PlaygroundImageEdits) + playgroundSubmitRouter.POST("/images/async-generations", controller.PlaygroundAsyncImageGenerations) + playgroundSubmitRouter.POST("/images/async-edits", controller.PlaygroundAsyncImageEdits) + playgroundSubmitRouter.POST("/video/generations", controller.PlaygroundVideoSubmit) + playgroundSubmitRouter.POST("/video/async-generations", controller.PlaygroundAsyncVideoSubmit) + + playgroundRouter.GET("/images/async-generations/:task_id", controller.PlaygroundAsyncImageFetch) + playgroundRouter.GET("/images/async-edits/:task_id", controller.PlaygroundAsyncImageFetch) + playgroundRouter.GET("/video/generations/:task_id", controller.PlaygroundVideoFetch) + playgroundRouter.GET("/video/async-generations/:task_id", controller.PlaygroundAsyncVideoFetch) } relayV1Router := router.Group("/v1") relayV1Router.Use(middleware.RouteTag("relay")) @@ -115,6 +128,8 @@ func SetRelayRouter(router *gin.Engine) { httpRouter.POST("/images/edits", func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIImage) }) + httpRouter.POST("/images/async-generations", controller.RelayAsyncImageGenerations) + httpRouter.POST("/images/async-edits", controller.RelayAsyncImageEdits) // embedding related routes httpRouter.POST("/embeddings", func(c *gin.Context) { @@ -164,6 +179,11 @@ func SetRelayRouter(router *gin.Engine) { httpRouter.GET("/fine-tunes/:id/events", controller.RelayNotImplemented) httpRouter.DELETE("/models/:model", controller.RelayNotImplemented) } + { + asyncImageRouter := relayV1Router.Group("") + asyncImageRouter.GET("/images/async-generations/:task_id", controller.RelayAsyncImageFetch) + asyncImageRouter.GET("/images/async-edits/:task_id", controller.RelayAsyncImageFetch) + } relayMjRouter := router.Group("/mj") relayMjRouter.Use(middleware.RouteTag("relay")) diff --git a/router/video-router.go b/router/video-router.go index 461451104520..b7bc8c3c91bf 100644 --- a/router/video-router.go +++ b/router/video-router.go @@ -22,6 +22,8 @@ func SetVideoRouter(router *gin.Engine) { { videoV1Router.POST("/video/generations", controller.RelayTask) videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch) + videoV1Router.POST("/video/async-generations", controller.RelayAsyncVideoGenerations) + videoV1Router.GET("/video/async-generations/:task_id", controller.RelayAsyncVideoFetch) videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask) } // openai compatible API video routes diff --git a/service/billing_session.go b/service/billing_session.go index f24b68e55a80..d90564d58c08 100644 --- a/service/billing_session.go +++ b/service/billing_session.go @@ -56,7 +56,7 @@ func (s *BillingSession) Settle(actualQuota int) error { } // 2) 调整令牌额度 var tokenErr error - if !s.relayInfo.IsPlayground { + if !s.relayInfo.IsPlayground && !s.relayInfo.TokenUnlimited { if delta > 0 { tokenErr = model.DecreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, delta) } else { @@ -105,7 +105,7 @@ func (s *BillingSession) Refund(c *gin.Context) { common.SysLog("error refunding billing source: " + err.Error()) } // 2) 退还令牌额度 - if tokenConsumed > 0 && !isPlayground { + if tokenConsumed > 0 && !isPlayground && !s.relayInfo.TokenUnlimited { if err := model.IncreaseTokenQuota(tokenId, tokenKey, tokenConsumed); err != nil { common.SysLog("error refunding token quota: " + err.Error()) } @@ -168,7 +168,7 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro // ---- 2) 预扣资金来源 ---- if err := s.funding.PreConsume(effectiveQuota); err != nil { // 预扣费失败,回滚令牌额度 - if s.tokenConsumed > 0 && !s.relayInfo.IsPlayground { + if s.tokenConsumed > 0 && !s.relayInfo.IsPlayground && !s.relayInfo.TokenUnlimited { if rollbackErr := model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, s.tokenConsumed); rollbackErr != nil { common.SysLog(fmt.Sprintf("error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s", s.relayInfo.UserId, s.relayInfo.TokenId, s.tokenConsumed, err.Error(), rollbackErr.Error())) diff --git a/service/channel_affinity_usage_cache_test.go b/service/channel_affinity_usage_cache_test.go index 64d3d715b547..332d99070905 100644 --- a/service/channel_affinity_usage_cache_test.go +++ b/service/channel_affinity_usage_cache_test.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http/httptest" "testing" - "time" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/types" @@ -26,9 +25,9 @@ func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) } func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + ruleName := fmt.Sprintf("rule_%s", t.Name()) usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + keyFP := fmt.Sprintf("fp_%s", t.Name()) ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) usage := &dto.Usage{ @@ -53,9 +52,9 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) } func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + ruleName := fmt.Sprintf("rule_%s", t.Name()) usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + keyFP := fmt.Sprintf("fp_%s", t.Name()) ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) openAIUsage := &dto.Usage{ @@ -83,9 +82,9 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) { } func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + ruleName := fmt.Sprintf("rule_%s", t.Name()) usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + keyFP := fmt.Sprintf("fp_%s", t.Name()) ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) usage := &dto.Usage{ diff --git a/service/creative_center_asset_archive.go b/service/creative_center_asset_archive.go new file mode 100644 index 000000000000..2a121a3e0e3b --- /dev/null +++ b/service/creative_center_asset_archive.go @@ -0,0 +1,286 @@ +package service + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "fmt" + "io" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/QuantumNous/new-api/dto" +) + +type CreativeCenterAssetArchiveResult struct { + FilePath string + DownloadName string + SuccessCount int + FailureCount int +} + +func CreateCreativeCenterAssetArchive(assets []*dto.CreativeCenterAsset, baseURL string) (*CreativeCenterAssetArchiveResult, error) { + tempFile, err := os.CreateTemp("", "creative-center-assets-*.zip") + if err != nil { + return nil, err + } + + cleanupOnError := func(originalErr error) (*CreativeCenterAssetArchiveResult, error) { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + return nil, originalErr + } + + zipWriter := zip.NewWriter(tempFile) + nameCounter := make(map[string]int) + failures := make([]string, 0) + successCount := 0 + + for index, asset := range assets { + content, ext, err := fetchCreativeCenterAssetContent(asset, baseURL) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %s", asset.AssetID, err.Error())) + continue + } + + fileName := uniqueArchiveFileName(nameCounter, buildCreativeCenterAssetFileName(asset, index, ext)) + writer, err := zipWriter.Create(fileName) + if err != nil { + return cleanupOnError(err) + } + if _, err = writer.Write(content); err != nil { + return cleanupOnError(err) + } + successCount++ + } + + if len(failures) > 0 { + writer, err := zipWriter.Create("failed-assets.txt") + if err != nil { + return cleanupOnError(err) + } + if _, err = writer.Write([]byte(strings.Join(failures, "\n"))); err != nil { + return cleanupOnError(err) + } + } + + if err = zipWriter.Close(); err != nil { + return cleanupOnError(err) + } + if err = tempFile.Close(); err != nil { + return cleanupOnError(err) + } + + if successCount == 0 { + _ = os.Remove(tempFile.Name()) + return nil, fmt.Errorf("no downloadable assets available") + } + + return &CreativeCenterAssetArchiveResult{ + FilePath: tempFile.Name(), + DownloadName: fmt.Sprintf("creative-center-assets-%s.zip", time.Now().Format("20060102-150405")), + SuccessCount: successCount, + FailureCount: len(failures), + }, nil +} + +func fetchCreativeCenterAssetContent(asset *dto.CreativeCenterAsset, baseURL string) ([]byte, string, error) { + mediaURL := strings.TrimSpace(asset.MediaURL) + if mediaURL == "" { + return nil, "", fmt.Errorf("media url is empty") + } + + if strings.HasPrefix(mediaURL, "data:") { + return decodeCreativeCenterAssetDataURL(mediaURL) + } + + resolvedURL := resolveCreativeCenterAssetURL(mediaURL, baseURL) + resp, err := DoDownloadRequest(resolvedURL, "creative_center_asset_zip") + if err != nil { + return nil, "", err + } + defer CloseResponseBodyGracefully(resp) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, "", fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + + ext := extensionFromURL(resolvedURL) + if ext == "" { + ext = extensionFromContentType(resp.Header.Get("Content-Type"), asset.AssetType) + } + + return content, ext, nil +} + +func decodeCreativeCenterAssetDataURL(dataURL string) ([]byte, string, error) { + commaIndex := strings.Index(dataURL, ",") + if commaIndex < 0 { + return nil, "", fmt.Errorf("invalid data url") + } + + meta := dataURL[:commaIndex] + payload := dataURL[commaIndex+1:] + if !strings.HasSuffix(meta, ";base64") { + return nil, "", fmt.Errorf("unsupported data url encoding") + } + + content, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, "", err + } + + ext := extensionFromContentType(strings.TrimPrefix(strings.TrimSuffix(meta, ";base64"), "data:"), "") + return content, ext, nil +} + +func resolveCreativeCenterAssetURL(rawURL string, baseURL string) string { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") { + return trimmed + } + if strings.TrimSpace(baseURL) == "" { + return trimmed + } + + base, err := url.Parse(baseURL) + if err != nil { + return trimmed + } + ref, err := url.Parse(trimmed) + if err != nil { + return trimmed + } + return base.ResolveReference(ref).String() +} + +func buildCreativeCenterAssetFileName(asset *dto.CreativeCenterAsset, index int, ext string) string { + normalizedExt := strings.TrimPrefix(strings.TrimSpace(ext), ".") + if normalizedExt == "" { + if asset.AssetType == "video" { + normalizedExt = "mp4" + } else { + normalizedExt = "png" + } + } + + sessionName := sanitizeArchiveSegment(asset.SessionName) + if sessionName == "" { + sessionName = sanitizeArchiveSegment(asset.TaskID) + } + if sessionName == "" { + sessionName = "task" + } + + modelName := sanitizeArchiveSegment(asset.ModelName) + if modelName == "" { + modelName = asset.AssetType + } + + return fmt.Sprintf("%s-%s-%s-%d.%s", asset.AssetType, modelName, sessionName, index+1, normalizedExt) +} + +func sanitizeArchiveSegment(value string) string { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + return "" + } + + var builder strings.Builder + for _, char := range trimmed { + switch { + case char >= 'a' && char <= 'z': + builder.WriteRune(char) + case char >= '0' && char <= '9': + builder.WriteRune(char) + case char == '-' || char == '_': + builder.WriteRune(char) + case char == ' ' || char == '/' || char == '\\': + builder.WriteRune('-') + } + } + + result := strings.Trim(builder.String(), "-_") + if result == "" { + return "" + } + return result +} + +func uniqueArchiveFileName(counter map[string]int, baseName string) string { + if counter[baseName] == 0 { + counter[baseName] = 1 + return baseName + } + + counter[baseName]++ + ext := path.Ext(baseName) + name := strings.TrimSuffix(baseName, ext) + return fmt.Sprintf("%s-%d%s", name, counter[baseName], ext) +} + +func extensionFromURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + ext := strings.TrimPrefix(path.Ext(parsed.Path), ".") + if ext == "" { + return "" + } + return strings.ToLower(ext) +} + +func extensionFromContentType(contentType string, assetType string) string { + contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + switch contentType { + case "image/png": + return "png" + case "image/jpeg": + return "jpg" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + case "video/mp4": + return "mp4" + case "video/webm": + return "webm" + case "video/quicktime": + return "mov" + } + + if assetType == "video" { + return "mp4" + } + if assetType == "image" { + return "png" + } + return "" +} + +func ReadCreativeCenterArchiveFile(filePath string) ([]byte, error) { + return os.ReadFile(filePath) +} + +func CleanupCreativeCenterArchiveFile(filePath string) { + if strings.TrimSpace(filePath) == "" { + return + } + _ = os.Remove(filePath) +} + +func BlobFromBytes(payload []byte) io.Reader { + return bytes.NewReader(payload) +} diff --git a/service/creative_center_asset_archive_test.go b/service/creative_center_asset_archive_test.go new file mode 100644 index 000000000000..668d2b403dbc --- /dev/null +++ b/service/creative_center_asset_archive_test.go @@ -0,0 +1,123 @@ +package service + +import ( + "archive/zip" + "encoding/base64" + "io" + "strings" + "testing" + + "github.com/QuantumNous/new-api/dto" +) + +func TestCreateCreativeCenterAssetArchiveWithDataURLs(t *testing.T) { + imageContent := []byte("image-binary") + videoContent := []byte("video-binary") + + archive, err := CreateCreativeCenterAssetArchive([]*dto.CreativeCenterAsset{ + { + AssetID: "cc:image:1:s1:r1:0", + AssetType: "image", + ModelName: "nano-banana", + SessionName: "Image Session", + MediaURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageContent), + }, + { + AssetID: "cc:video:2:s2:r2:0", + AssetType: "video", + ModelName: "veo-3", + SessionName: "Video Session", + MediaURL: "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(videoContent), + }, + }, "") + if err != nil { + t.Fatalf("expected archive creation to succeed, got error: %v", err) + } + defer CleanupCreativeCenterArchiveFile(archive.FilePath) + + if archive.SuccessCount != 2 { + t.Fatalf("expected 2 successful assets, got %d", archive.SuccessCount) + } + if archive.FailureCount != 0 { + t.Fatalf("expected 0 failed assets, got %d", archive.FailureCount) + } + + reader, err := zip.OpenReader(archive.FilePath) + if err != nil { + t.Fatalf("failed to open zip: %v", err) + } + defer reader.Close() + + if len(reader.File) != 2 { + t.Fatalf("expected 2 zip entries, got %d", len(reader.File)) + } + + names := []string{reader.File[0].Name, reader.File[1].Name} + joined := strings.Join(names, ",") + if !strings.Contains(joined, "image-nano-banana-image-session-1.png") { + t.Fatalf("expected image file name in archive, got %v", names) + } + if !strings.Contains(joined, "video-veo-3-video-session-2.mp4") { + t.Fatalf("expected video file name in archive, got %v", names) + } +} + +func TestCreateCreativeCenterAssetArchiveKeepsFailuresList(t *testing.T) { + validContent := []byte("valid-image") + archive, err := CreateCreativeCenterAssetArchive([]*dto.CreativeCenterAsset{ + { + AssetID: "cc:image:1:s1:r1:0", + AssetType: "image", + ModelName: "nano", + SessionName: "Session One", + MediaURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(validContent), + }, + { + AssetID: "cc:image:1:s1:r1:1", + AssetType: "image", + ModelName: "nano", + SessionName: "Session One", + MediaURL: "data:image/png,not-base64", + }, + }, "") + if err != nil { + t.Fatalf("expected partial archive creation to succeed, got error: %v", err) + } + defer CleanupCreativeCenterArchiveFile(archive.FilePath) + + if archive.SuccessCount != 1 { + t.Fatalf("expected 1 successful asset, got %d", archive.SuccessCount) + } + if archive.FailureCount != 1 { + t.Fatalf("expected 1 failed asset, got %d", archive.FailureCount) + } + + reader, err := zip.OpenReader(archive.FilePath) + if err != nil { + t.Fatalf("failed to open zip: %v", err) + } + defer reader.Close() + + var hasFailureFile bool + for _, file := range reader.File { + if file.Name == "failed-assets.txt" { + hasFailureFile = true + rc, openErr := file.Open() + if openErr != nil { + t.Fatalf("failed to open failure file: %v", openErr) + } + contentBytes, readErr := io.ReadAll(rc) + rc.Close() + if readErr != nil { + t.Fatalf("failed to read failure file: %v", readErr) + } + if !strings.Contains(string(contentBytes), "cc:image:1:s1:r1:1") { + t.Fatalf("expected failed asset id in report, got %s", string(contentBytes)) + } + } + } + + if !hasFailureFile { + t.Fatal("expected failed-assets.txt to exist") + } +} diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 373e32d65d3d..0f47846c70e4 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -41,6 +41,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other["cache_ratio"] = cacheRatio other["model_price"] = modelPrice other["user_group_ratio"] = userGroupRatio + appendGroupPriceOverrideInfo(relayInfo, relayInfo.PriceData, other) other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) if relayInfo.ReasoningEffort != "" { other["reasoning_effort"] = relayInfo.ReasoningEffort @@ -86,6 +87,26 @@ func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string] other["po"] = relayInfo.ParamOverrideAudit } +func appendGroupPriceOverrideInfo(relayInfo *relaycommon.RelayInfo, priceData types.PriceData, other map[string]interface{}) { + if other == nil || !priceData.GroupPriceOverride { + return + } + other["group_price_override"] = true + billingGroup := strings.TrimSpace(priceData.GroupPriceOverrideGroup) + if billingGroup == "" && relayInfo != nil { + billingGroup = strings.TrimSpace(relayInfo.UserGroup) + if billingGroup == "" { + billingGroup = strings.TrimSpace(relayInfo.UsingGroup) + } + } + if billingGroup != "" { + other["billing_group"] = billingGroup + } + if relayInfo != nil && strings.TrimSpace(relayInfo.UsingGroup) != "" { + other["using_group"] = strings.TrimSpace(relayInfo.UsingGroup) + } +} + func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { if relayInfo == nil || other == nil { return @@ -231,6 +252,7 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price if priceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio } + appendGroupPriceOverrideInfo(relayInfo, priceData, other) appendRequestPath(nil, relayInfo, other) return other } diff --git a/service/quota.go b/service/quota.go index 9dc84ab4be9f..2252f3b3063e 100644 --- a/service/quota.go +++ b/service/quota.go @@ -344,12 +344,9 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { if quota < 0 { return errors.New("quota 不能为负数!") } - if relayInfo.IsPlayground { + if relayInfo.IsPlayground || relayInfo.TokenUnlimited { return nil } - //if relayInfo.TokenUnlimited { - // return nil - //} token, err := model.GetTokenByKey(relayInfo.TokenKey, false) if err != nil { return err diff --git a/service/task_billing.go b/service/task_billing.go index b887f6682502..90c093ac3ab9 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -19,6 +20,13 @@ import ( func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { tokenName := c.GetString("token_name") logContent := fmt.Sprintf("操作 %s", info.Action) + useTimeSeconds := 0 + if !info.StartTime.IsZero() { + elapsedSeconds := time.Now().Unix() - info.StartTime.Unix() + if elapsedSeconds > 0 { + useTimeSeconds = int(elapsedSeconds) + } + } // 支持任务仅按次计费 if common.StringsContains(constant.TaskPricePatches, info.OriginModelName) { logContent = fmt.Sprintf("%s,按次计费", logContent) @@ -36,25 +44,28 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { } } other := make(map[string]interface{}) + other["task_id"] = info.PublicTaskID other["request_path"] = c.Request.URL.Path other["model_price"] = info.PriceData.ModelPrice other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio if info.PriceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio } + appendGroupPriceOverrideInfo(info, info.PriceData, other) if info.IsModelMapped { other["is_model_mapped"] = true other["upstream_model_name"] = info.UpstreamModelName } model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{ - ChannelId: info.ChannelId, - ModelName: info.OriginModelName, - TokenName: tokenName, - Quota: info.PriceData.Quota, - Content: logContent, - TokenId: info.TokenId, - Group: info.UsingGroup, - Other: other, + ChannelId: info.ChannelId, + ModelName: info.OriginModelName, + TokenName: tokenName, + Quota: info.PriceData.Quota, + Content: logContent, + TokenId: info.TokenId, + UseTimeSeconds: useTimeSeconds, + Group: info.UsingGroup, + Other: other, }) model.UpdateUserUsedQuotaAndRequestCount(info.UserId, info.PriceData.Quota) model.UpdateChannelUsedQuota(info.ChannelId, info.PriceData.Quota) @@ -123,6 +134,15 @@ func taskBillingOther(task *model.Task) map[string]interface{} { other[k] = v } } + if bc.GroupPriceOverride { + other["group_price_override"] = true + if strings.TrimSpace(bc.GroupPriceOverrideGroup) != "" { + other["billing_group"] = strings.TrimSpace(bc.GroupPriceOverrideGroup) + } + if strings.TrimSpace(bc.UsingGroup) != "" { + other["using_group"] = strings.TrimSpace(bc.UsingGroup) + } + } } props := task.Properties if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName { diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 79c8c49eb48d..856eeeca6854 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -712,3 +712,34 @@ func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) { require.NotNil(t, log) assert.Equal(t, model.LogTypeRefund, log.Type) } + +func TestPreConsumeTokenQuota_SkipsUnlimitedToken(t *testing.T) { + truncate(t) + + relayInfo := &relaycommon.RelayInfo{ + TokenId: 0, + TokenKey: "playground_1_playground-video", + TokenUnlimited: true, + } + + require.NoError(t, PreConsumeTokenQuota(relayInfo, 1000)) +} + +func TestBillingSessionSettle_SkipsUnlimitedTokenAdjustment(t *testing.T) { + truncate(t) + + const userID = 40 + seedUser(t, userID, 10000) + + session := &BillingSession{ + relayInfo: &relaycommon.RelayInfo{ + UserId: userID, + TokenId: 0, + TokenKey: "playground_1_playground-video", + TokenUnlimited: true, + }, + funding: &WalletFunding{userId: userID}, + } + + require.NoError(t, session.Settle(1000)) +} diff --git a/service/task_polling.go b/service/task_polling.go index dc85e579e8cc..f28e54a3409c 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -35,6 +35,90 @@ type TaskPollingAdaptor interface { // 打破 service -> relay -> relay/channel -> service 的循环依赖。 var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor +func RefreshVideoTask(ctx context.Context, task *model.Task) error { + if task == nil { + return errors.New("task is nil") + } + if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure { + return nil + } + if task.ChannelId <= 0 { + return errors.New("task channel id is invalid") + } + if GetTaskAdaptorFunc == nil { + return errors.New("task adaptor factory is not initialized") + } + + channelModel, err := model.GetChannelById(task.ChannelId, true) + if err != nil { + return err + } + adaptor := GetTaskAdaptorFunc(task.Platform) + if adaptor == nil { + return fmt.Errorf("video adaptor not found for platform %s", task.Platform) + } + + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelBaseUrl: channelModel.GetBaseURL(), + ApiKey: channelModel.Key, + }, + } + adaptor.Init(info) + return updateVideoSingleTask(ctx, adaptor, channelModel, task.TaskID, map[string]*model.Task{ + task.TaskID: task, + }) +} + +func isTransientVideoNotFoundResponse(statusCode int, responseBody []byte, submitTime int64, now int64, modelNames ...string) bool { + if statusCode != http.StatusNotFound { + return false + } + message := extractVideoPollingErrorMessage(responseBody) + if message == "" { + message = string(responseBody) + } + message = strings.Trim(strings.ToLower(strings.TrimSpace(message)), "\"' .") + + // Some video providers briefly return a generic 404 while the result file is + // being published. Sora2 and Veo can also return "video generation not found" + // before their tasks become queryable, so keep polling them within the same + // grace window. + isGenericNotFound := message == "not found" || message == "404 not found" + isGracefulVideoGenerationNotFound := message == "video generation not found" && isGracefulVideoGenerationNotFoundModel(modelNames...) + if !isGenericNotFound && !isGracefulVideoGenerationNotFound { + return false + } + if constant.TaskNotFoundGraceMinutes <= 0 { + return false + } + if submitTime <= 0 || now <= 0 { + return true + } + return now-submitTime <= int64(constant.TaskNotFoundGraceMinutes)*60 +} + +func extractVideoPollingErrorMessage(responseBody []byte) string { + errorResult := &dto.GeneralErrorResponse{} + if err := common.Unmarshal(responseBody, errorResult); err != nil { + return "" + } + return strings.TrimSpace(errorResult.ToMessage()) +} + +func isGracefulVideoGenerationNotFoundModel(modelNames ...string) bool { + for _, modelName := range modelNames { + modelName = strings.ToLower(strings.TrimSpace(modelName)) + if modelName == "sora2" || modelName == "sora-2" || strings.HasPrefix(modelName, "sora2-") || strings.HasPrefix(modelName, "sora-2-") { + return true + } + if strings.HasPrefix(modelName, "veo") || strings.Contains(modelName, "/veo") { + return true + } + } + return false +} + // sweepTimedOutTasks 在主轮询之前独立清理超时任务。 // 每次最多处理 100 条,剩余的下个周期继续处理。 // 使用 per-task CAS (UpdateWithStatus) 防止覆盖被正常轮询已推进的任务。 @@ -360,8 +444,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * key = privateData.Key } resp, err := adaptor.FetchTask(baseURL, key, map[string]any{ - "task_id": task.GetUpstreamTaskID(), - "action": task.Action, + "task_id": task.GetUpstreamTaskID(), + "action": task.Action, + "model": task.Properties.UpstreamModelName, + "origin_model": task.Properties.OriginModelName, + "request_path": strings.TrimSpace(task.PrivateData.UpstreamRequestPath), }, proxy) if err != nil { return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) @@ -412,9 +499,20 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * // 其他错误认为是任务失败,记录错误信息并更新任务状态 taskResult = relaycommon.FailTaskInfo("upstream returned error") } else { - // unknown error format, log original response - logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody))) - taskResult = relaycommon.FailTaskInfo("upstream returned unrecognized message") + bodyLower := strings.ToLower(string(responseBody)) + if isTransientVideoNotFoundResponse(resp.StatusCode, responseBody, task.SubmitTime, now, task.Properties.OriginModelName, task.Properties.UpstreamModelName) { + logger.LogInfo(ctx, fmt.Sprintf("Task %s upstream result not ready yet, keep polling, response: %s", taskId, string(responseBody))) + return nil + } + if strings.Contains(bodyLower, "not found") { + taskResult = relaycommon.FailTaskInfo("upstream task not found") + taskResult.Reason = strings.TrimSpace(string(responseBody)) + } else { + // Keep polling when the upstream response is temporarily unrecognized instead of + // flipping the task to failed and later correcting it back to success. + logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, keep polling, response: %s", taskId, string(responseBody))) + return nil + } } } } @@ -497,6 +595,17 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * if shouldRefund { RefundTaskQuota(ctx, task, task.FailReason) } + if isDone { + startAt := task.SubmitTime + if startAt <= 0 { + startAt = task.StartTime + } + if task.PrivateData.RequestId != "" && startAt > 0 && task.FinishTime > startAt { + if err := model.UpdateConsumeLogUseTimeByRequestId(task.PrivateData.RequestId, int(task.FinishTime-startAt)); err != nil { + logger.LogWarn(ctx, fmt.Sprintf("failed to update consume log use_time for task %s: %s", task.TaskID, err.Error())) + } + } + } return nil } diff --git a/service/task_polling_transient_test.go b/service/task_polling_transient_test.go new file mode 100644 index 000000000000..664c968328e5 --- /dev/null +++ b/service/task_polling_transient_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "net/http" + "testing" + + "github.com/QuantumNous/new-api/constant" +) + +func TestIsTransientVideoNotFoundResponse(t *testing.T) { + oldGraceMinutes := constant.TaskNotFoundGraceMinutes + constant.TaskNotFoundGraceMinutes = 10 + defer func() { + constant.TaskNotFoundGraceMinutes = oldGraceMinutes + }() + + now := int64(1000) + + tests := []struct { + name string + statusCode int + body []byte + submitTime int64 + models []string + want bool + }{ + { + name: "upstream result not ready", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"Not Found"}`), + submitTime: now - 9*60, + want: true, + }, + { + name: "upstream result not ready at grace boundary", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"Not Found"}`), + submitTime: now - 10*60, + want: true, + }, + { + name: "upstream result not found after grace expires", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"Not Found"}`), + submitTime: now - 11*60, + want: false, + }, + { + name: "video generation missing is terminal for unlisted model", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"video generation not found"}`), + submitTime: now - 1*60, + models: []string{"grok-imagine-1.0-video"}, + want: false, + }, + { + name: "sora video generation missing is transient inside grace", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"video generation not found"}`), + submitTime: now - 9*60, + models: []string{"sora2"}, + want: true, + }, + { + name: "sora video generation missing is transient at grace boundary", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"video generation not found"}`), + submitTime: now - 10*60, + models: []string{"sora-2"}, + want: true, + }, + { + name: "sora video generation missing is terminal after grace expires", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"video generation not found"}`), + submitTime: now - 11*60, + models: []string{"sora2"}, + want: false, + }, + { + name: "veo video generation missing is transient inside grace", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"video generation not found"}`), + submitTime: now - 9*60, + models: []string{"veo31-fast"}, + want: true, + }, + { + name: "veo path model video generation missing is transient inside grace", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"video generation not found"}`), + submitTime: now - 9*60, + models: []string{"publishers/google/models/veo-3.0-generate-001"}, + want: true, + }, + { + name: "veo video generation missing is terminal after grace expires", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"video generation not found"}`), + submitTime: now - 11*60, + models: []string{"veo-3.1-generate-preview"}, + want: false, + }, + { + name: "task missing is terminal", + statusCode: http.StatusNotFound, + body: []byte(`{"message":"task not found"}`), + submitTime: now - 1*60, + want: false, + }, + { + name: "non not found 404", + statusCode: http.StatusNotFound, + body: []byte(`{"detail":"permission denied"}`), + submitTime: now - 1*60, + want: false, + }, + { + name: "not found body without 404", + statusCode: http.StatusOK, + body: []byte(`{"detail":"Not Found"}`), + submitTime: now - 1*60, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTransientVideoNotFoundResponse(tt.statusCode, tt.body, tt.submitTime, now, tt.models...); got != tt.want { + t.Fatalf("isTransientVideoNotFoundResponse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsTransientVideoNotFoundResponseWithZeroGrace(t *testing.T) { + oldGraceMinutes := constant.TaskNotFoundGraceMinutes + constant.TaskNotFoundGraceMinutes = 0 + defer func() { + constant.TaskNotFoundGraceMinutes = oldGraceMinutes + }() + + if got := isTransientVideoNotFoundResponse(http.StatusNotFound, []byte(`{"detail":"Not Found"}`), 100, 101); got { + t.Fatalf("isTransientVideoNotFoundResponse() = %v, want false", got) + } +} diff --git a/service/text_quota.go b/service/text_quota.go index 8caee8f28799..0d828eac476d 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -37,6 +37,7 @@ type textQuotaSummary struct { ModelRatio float64 GroupRatio float64 ModelPrice float64 + GroupPriceOverride bool CacheCreationRatio float64 CacheCreationRatio5m float64 CacheCreationRatio1h float64 @@ -88,6 +89,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf ModelRatio: relayInfo.PriceData.ModelRatio, GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, ModelPrice: relayInfo.PriceData.ModelPrice, + GroupPriceOverride: relayInfo.PriceData.GroupPriceOverride, CacheCreationRatio: relayInfo.PriceData.CacheCreationRatio, CacheCreationRatio5m: relayInfo.PriceData.CacheCreation5mRatio, CacheCreationRatio1h: relayInfo.PriceData.CacheCreation1hRatio, @@ -247,8 +249,10 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) - if len(relayInfo.PriceData.OtherRatios) > 0 { - for _, otherRatio := range relayInfo.PriceData.OtherRatios { + otherRatios := common.FilterOtherRatiosForBillingModel(relayInfo.OriginModelName, relayInfo.PriceData.OtherRatios) + relayInfo.PriceData.OtherRatios = otherRatios + if len(otherRatios) > 0 { + for _, otherRatio := range otherRatios { quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) } } @@ -258,14 +262,19 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf } summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart()) } else { - quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) + quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit) + if !summary.GroupPriceOverride { + quotaCalculateDecimal = quotaCalculateDecimal.Mul(dGroupRatio) + } quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dClaudeWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) - if len(relayInfo.PriceData.OtherRatios) > 0 { - for _, otherRatio := range relayInfo.PriceData.OtherRatios { + otherRatios := common.FilterOtherRatiosForBillingModel(relayInfo.OriginModelName, relayInfo.PriceData.OtherRatios) + relayInfo.PriceData.OtherRatios = otherRatios + if len(otherRatios) > 0 { + for _, otherRatio := range otherRatios { quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) } } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index e995de17ae8b..9d8b663ef1cc 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -316,3 +317,65 @@ func TestCalculateTextQuotaSummaryKeepsPrePRClaudeOpenRouterBilling(t *testing.T require.Equal(t, 172, summary.PromptTokens) require.Equal(t, 798, summary.Quota) } + +func TestCalculateTextQuotaSummaryIgnoresBananaOtherRatios(t *testing.T) { + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + common.QuotaPerUnit = originalQuotaPerUnit + }() + common.QuotaPerUnit = 500 + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "nano-banana-pro", + PriceData: types.PriceData{ + ModelPrice: 2, + UsePrice: true, + OtherRatios: map[string]float64{ + "n": 3, + "resolution": 4, + }, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}, + }, + StartTime: time.Now(), + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{ + PromptTokens: 1, + }) + + require.Equal(t, 1000, summary.Quota) + require.Empty(t, relayInfo.PriceData.OtherRatios) +} + +func TestCalculateTextQuotaSummaryUsesGroupPriceOverrideWithoutGroupRatio(t *testing.T) { + originalQuotaPerUnit := common.QuotaPerUnit + defer func() { + common.QuotaPerUnit = originalQuotaPerUnit + }() + common.QuotaPerUnit = 500 + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + relayInfo := &relaycommon.RelayInfo{ + OriginModelName: "nano-banana-pro", + PriceData: types.PriceData{ + ModelPrice: 0.12, + UsePrice: true, + GroupPriceOverride: true, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.5}, + }, + StartTime: time.Now(), + } + + summary := calculateTextQuotaSummary(ctx, relayInfo, &dto.Usage{ + PromptTokens: 1, + }) + + require.Equal(t, int(0.12*common.QuotaPerUnit), summary.Quota) +} diff --git a/setting/ratio_setting/group_model_price_test.go b/setting/ratio_setting/group_model_price_test.go new file mode 100644 index 000000000000..7509c31c7e83 --- /dev/null +++ b/setting/ratio_setting/group_model_price_test.go @@ -0,0 +1,73 @@ +package ratio_setting + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetGroupModelPrice(t *testing.T) { + original := GroupModelPrice2JSONString() + defer func() { + _ = UpdateGroupModelPriceByJSONString(original) + }() + + require.NoError(t, UpdateGroupModelPriceByJSONString(`{ + "vip": { + "grok-imagine-1.0-edit": 0.02 + } + }`)) + + price, ok := GetGroupModelPrice("vip", "grok-imagine-1.0-edit") + require.True(t, ok) + require.Equal(t, 0.02, price) + + _, ok = GetGroupModelPrice("default", "grok-imagine-1.0-edit") + require.False(t, ok) +} + +func TestGetGroupModelPriceBySeconds(t *testing.T) { + original := GroupModelPriceBySeconds2JSONString() + defer func() { + _ = UpdateGroupModelPriceBySecondsByJSONString(original) + }() + + require.NoError(t, UpdateGroupModelPriceBySecondsByJSONString(`{ + "vip": { + "grok-imagine-1.0-video": { + "6": 0.05, + "8": 0.07 + } + } + }`)) + + price, ok := GetGroupModelPriceBySeconds("vip", "grok-imagine-1.0-video", 8) + require.True(t, ok) + require.Equal(t, 0.07, price) + + _, ok = GetGroupModelPriceBySeconds("default", "grok-imagine-1.0-video", 8) + require.False(t, ok) +} + +func TestGetGroupModelPriceByResolution(t *testing.T) { + original := GroupModelPriceByResolution2JSONString() + defer func() { + _ = UpdateGroupModelPriceByResolutionByJSONString(original) + }() + + require.NoError(t, UpdateGroupModelPriceByResolutionByJSONString(`{ + "vip": { + "nano-banana-pro": { + "1K": 0.07, + "2K": 0.12 + } + } + }`)) + + price, ok := GetGroupModelPriceByResolution("vip", "nano-banana-pro", "2k") + require.True(t, ok) + require.Equal(t, 0.12, price) + + _, ok = GetGroupModelPriceByResolution("vip", "nano-banana-pro", "4K") + require.False(t, ok) +} diff --git a/setting/ratio_setting/model_price_by_resolution_test.go b/setting/ratio_setting/model_price_by_resolution_test.go new file mode 100644 index 000000000000..e7ab95782ed1 --- /dev/null +++ b/setting/ratio_setting/model_price_by_resolution_test.go @@ -0,0 +1,34 @@ +package ratio_setting + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetModelPriceByResolution(t *testing.T) { + original := ModelPriceByResolution2JSONString() + defer func() { + _ = UpdateModelPriceByResolutionByJSONString(original) + }() + + require.NoError(t, UpdateModelPriceByResolutionByJSONString(`{ + "nano-banana": { + "1K": 0.02, + "2k": 0.05, + "4K": 0.1 + } + }`)) + + price, ok := GetModelPriceByResolution("nano-banana", "1k") + require.True(t, ok) + require.Equal(t, 0.02, price) + + price, ok = GetModelPriceByResolution("nano-banana", "2K") + require.True(t, ok) + require.Equal(t, 0.05, price) + + price, ok = GetModelPriceByResolution("nano-banana", "4k") + require.True(t, ok) + require.Equal(t, 0.1, price) +} diff --git a/setting/ratio_setting/model_price_by_seconds_test.go b/setting/ratio_setting/model_price_by_seconds_test.go new file mode 100644 index 000000000000..6ef66710be0a --- /dev/null +++ b/setting/ratio_setting/model_price_by_seconds_test.go @@ -0,0 +1,29 @@ +package ratio_setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetModelPriceBySeconds(t *testing.T) { + original := ModelPriceBySeconds2JSONString() + defer func() { + _ = UpdateModelPriceBySecondsByJSONString(original) + }() + + require.NoError(t, UpdateModelPriceBySecondsByJSONString(`{ + "grok-imagine-1.0-video": { + "12": 0.2, + "20": 0.28 + } + }`)) + + price, ok := GetModelPriceBySeconds("grok-imagine-1.0-video", 12) + require.True(t, ok) + assert.Equal(t, 0.2, price) + + _, ok = GetModelPriceBySeconds("grok-imagine-1.0-video", 15) + assert.False(t, ok) +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 62fc8b3e10db..1196b1e72c5c 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -1,6 +1,7 @@ package ratio_setting import ( + "strconv" "strings" "github.com/QuantumNous/new-api/common" @@ -59,6 +60,7 @@ var defaultModelRatio = map[string]float64{ "gpt-4.1-nano": 0.05, // $0.1 / 1M tokens "gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens "gpt-image-1": 2.5, // $5 / 1M tokens + "gpt-image2": 2.5, "o1": 7.5, // $15 / 1M tokens "o1-2024-12-17": 7.5, // $15 / 1M tokens "o1-preview": 7.5, // $15 / 1M tokens @@ -323,6 +325,11 @@ var defaultAudioCompletionRatio = map[string]float64{ } var modelPriceMap = types.NewRWMap[string, float64]() +var groupModelPriceMap = types.NewRWMap[string, map[string]float64]() +var modelPriceBySecondsMap = types.NewRWMap[string, map[string]float64]() +var modelPriceByResolutionMap = types.NewRWMap[string, map[string]float64]() +var groupModelPriceBySecondsMap = types.NewRWMap[string, map[string]map[string]float64]() +var groupModelPriceByResolutionMap = types.NewRWMap[string, map[string]map[string]float64]() var modelRatioMap = types.NewRWMap[string, float64]() var completionRatioMap = types.NewRWMap[string, float64]() @@ -349,14 +356,158 @@ func GetModelPriceMap() map[string]float64 { return modelPriceMap.ReadAll() } +func ModelPriceBySeconds2JSONString() string { + return modelPriceBySecondsMap.MarshalJSONString() +} + +func GroupModelPriceBySeconds2JSONString() string { + return groupModelPriceBySecondsMap.MarshalJSONString() +} + +func normalizeResolutionKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func ModelPriceByResolution2JSONString() string { + return modelPriceByResolutionMap.MarshalJSONString() +} + +func GroupModelPriceByResolution2JSONString() string { + return groupModelPriceByResolutionMap.MarshalJSONString() +} + func ModelPrice2JSONString() string { return modelPriceMap.MarshalJSONString() } +func GroupModelPrice2JSONString() string { + return groupModelPriceMap.MarshalJSONString() +} + +func UpdateModelPriceBySecondsByJSONString(jsonStr string) error { + return types.LoadFromJsonStringWithCallback(modelPriceBySecondsMap, jsonStr, InvalidateExposedDataCache) +} + +func UpdateGroupModelPriceBySecondsByJSONString(jsonStr string) error { + var parsed map[string]map[string]map[string]float64 + if err := common.UnmarshalJsonStr(jsonStr, &parsed); err != nil { + return err + } + groupModelPriceBySecondsMap.Clear() + for group, modelMap := range parsed { + group = strings.TrimSpace(group) + if group == "" { + continue + } + normalizedModelMap := make(map[string]map[string]float64, len(modelMap)) + for modelName, secondsMap := range modelMap { + modelName = FormatMatchingModelName(modelName) + normalizedSecondsMap := make(map[string]float64, len(secondsMap)) + for seconds, price := range secondsMap { + seconds = strings.TrimSpace(seconds) + if seconds == "" { + continue + } + normalizedSecondsMap[seconds] = price + } + if len(normalizedSecondsMap) > 0 { + normalizedModelMap[modelName] = normalizedSecondsMap + } + } + if len(normalizedModelMap) > 0 { + groupModelPriceBySecondsMap.Set(group, normalizedModelMap) + } + } + InvalidateExposedDataCache() + return nil +} + +func UpdateModelPriceByResolutionByJSONString(jsonStr string) error { + var parsed map[string]map[string]float64 + if err := common.UnmarshalJsonStr(jsonStr, &parsed); err != nil { + return err + } + modelPriceByResolutionMap.Clear() + for modelName, resolutionMap := range parsed { + normalizedResolutionMap := make(map[string]float64, len(resolutionMap)) + for resolution, price := range resolutionMap { + key := normalizeResolutionKey(resolution) + if key == "" { + continue + } + normalizedResolutionMap[key] = price + } + modelPriceByResolutionMap.Set(modelName, normalizedResolutionMap) + } + InvalidateExposedDataCache() + return nil +} + +func UpdateGroupModelPriceByResolutionByJSONString(jsonStr string) error { + var parsed map[string]map[string]map[string]float64 + if err := common.UnmarshalJsonStr(jsonStr, &parsed); err != nil { + return err + } + groupModelPriceByResolutionMap.Clear() + for group, modelMap := range parsed { + group = strings.TrimSpace(group) + if group == "" { + continue + } + normalizedModelMap := make(map[string]map[string]float64, len(modelMap)) + for modelName, resolutionMap := range modelMap { + modelName = FormatMatchingModelName(modelName) + normalizedResolutionMap := make(map[string]float64, len(resolutionMap)) + for resolution, price := range resolutionMap { + key := normalizeResolutionKey(resolution) + if key == "" { + continue + } + normalizedResolutionMap[key] = price + } + if len(normalizedResolutionMap) > 0 { + normalizedModelMap[modelName] = normalizedResolutionMap + } + } + if len(normalizedModelMap) > 0 { + groupModelPriceByResolutionMap.Set(group, normalizedModelMap) + } + } + InvalidateExposedDataCache() + return nil +} + func UpdateModelPriceByJSONString(jsonStr string) error { return types.LoadFromJsonStringWithCallback(modelPriceMap, jsonStr, InvalidateExposedDataCache) } +func UpdateGroupModelPriceByJSONString(jsonStr string) error { + var parsed map[string]map[string]float64 + if err := common.UnmarshalJsonStr(jsonStr, &parsed); err != nil { + return err + } + groupModelPriceMap.Clear() + for group, modelMap := range parsed { + group = strings.TrimSpace(group) + if group == "" { + continue + } + normalizedModelMap := make(map[string]float64, len(modelMap)) + for modelName, price := range modelMap { + modelName = FormatMatchingModelName(modelName) + if modelName == "" { + continue + } + normalizedModelMap[modelName] = price + } + if len(normalizedModelMap) > 0 { + groupModelPriceMap.Set(group, normalizedModelMap) + } + } + InvalidateExposedDataCache() + return nil +} + // GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false func GetModelPrice(name string, printErr bool) (float64, bool) { name = FormatMatchingModelName(name) @@ -382,6 +533,216 @@ func GetModelPrice(name string, printErr bool) (float64, bool) { return price, true } +func GetGroupModelPrice(group string, name string) (float64, bool) { + group = strings.TrimSpace(group) + name = FormatMatchingModelName(name) + if group == "" || name == "" { + return 0, false + } + modelMap, ok := groupModelPriceMap.Get(group) + if !ok { + return 0, false + } + price, ok := modelMap[name] + return price, ok +} + +func GetModelPriceBySeconds(name string, seconds int) (float64, bool) { + name = FormatMatchingModelName(name) + if seconds <= 0 { + return 0, false + } + secondsPriceMap, ok := modelPriceBySecondsMap.Get(name) + if !ok { + return 0, false + } + price, ok := secondsPriceMap[strconv.Itoa(seconds)] + return price, ok +} + +func GetGroupModelPriceBySeconds(group string, name string, seconds int) (float64, bool) { + group = strings.TrimSpace(group) + name = FormatMatchingModelName(name) + if group == "" || seconds <= 0 { + return 0, false + } + modelMap, ok := groupModelPriceBySecondsMap.Get(group) + if !ok { + return 0, false + } + secondsPriceMap, ok := modelMap[name] + if !ok { + return 0, false + } + price, ok := secondsPriceMap[strconv.Itoa(seconds)] + return price, ok +} + +func GetModelPriceBySecondsMap(name string) (map[string]float64, bool) { + name = FormatMatchingModelName(name) + secondsPriceMap, ok := modelPriceBySecondsMap.Get(name) + if !ok || len(secondsPriceMap) == 0 { + return nil, false + } + cloned := make(map[string]float64, len(secondsPriceMap)) + for seconds, price := range secondsPriceMap { + cloned[seconds] = price + } + return cloned, true +} + +func GetGroupModelPriceBySecondsMap(group string, name string) (map[string]float64, bool) { + group = strings.TrimSpace(group) + name = FormatMatchingModelName(name) + if group == "" { + return nil, false + } + modelMap, ok := groupModelPriceBySecondsMap.Get(group) + if !ok { + return nil, false + } + secondsPriceMap, ok := modelMap[name] + if !ok || len(secondsPriceMap) == 0 { + return nil, false + } + cloned := make(map[string]float64, len(secondsPriceMap)) + for seconds, price := range secondsPriceMap { + cloned[seconds] = price + } + return cloned, true +} + +func GetModelPriceByResolution(name string, resolution string) (float64, bool) { + name = FormatMatchingModelName(name) + key := normalizeResolutionKey(resolution) + if key == "" { + return 0, false + } + resolutionPriceMap, ok := modelPriceByResolutionMap.Get(name) + if !ok { + return 0, false + } + price, ok := resolutionPriceMap[key] + return price, ok +} + +func GetGroupModelPriceByResolution(group string, name string, resolution string) (float64, bool) { + group = strings.TrimSpace(group) + name = FormatMatchingModelName(name) + key := normalizeResolutionKey(resolution) + if group == "" || key == "" { + return 0, false + } + modelMap, ok := groupModelPriceByResolutionMap.Get(group) + if !ok { + return 0, false + } + resolutionPriceMap, ok := modelMap[name] + if !ok { + return 0, false + } + price, ok := resolutionPriceMap[key] + return price, ok +} + +func GetModelPriceByResolutionMap(name string) (map[string]float64, bool) { + name = FormatMatchingModelName(name) + resolutionPriceMap, ok := modelPriceByResolutionMap.Get(name) + if !ok || len(resolutionPriceMap) == 0 { + return nil, false + } + cloned := make(map[string]float64, len(resolutionPriceMap)) + for resolution, price := range resolutionPriceMap { + cloned[resolution] = price + } + return cloned, true +} + +func GetGroupModelPriceByResolutionMap(group string, name string) (map[string]float64, bool) { + group = strings.TrimSpace(group) + name = FormatMatchingModelName(name) + if group == "" { + return nil, false + } + modelMap, ok := groupModelPriceByResolutionMap.Get(group) + if !ok { + return nil, false + } + resolutionPriceMap, ok := modelMap[name] + if !ok || len(resolutionPriceMap) == 0 { + return nil, false + } + cloned := make(map[string]float64, len(resolutionPriceMap)) + for resolution, price := range resolutionPriceMap { + cloned[resolution] = price + } + return cloned, true +} + +func GetModelPriceByResolutionMin(name string) (float64, bool) { + resolutionPriceMap, ok := GetModelPriceByResolutionMap(name) + if !ok { + return 0, false + } + minPrice := 0.0 + found := false + for _, price := range resolutionPriceMap { + if !found || price < minPrice { + minPrice = price + found = true + } + } + return minPrice, found +} + +func GetGroupModelPriceByResolutionMin(group string, name string) (float64, bool) { + resolutionPriceMap, ok := GetGroupModelPriceByResolutionMap(group, name) + if !ok { + return 0, false + } + minPrice := 0.0 + found := false + for _, price := range resolutionPriceMap { + if !found || price < minPrice { + minPrice = price + found = true + } + } + return minPrice, found +} + +func GetModelPriceBySecondsMin(name string) (float64, bool) { + secondsPriceMap, ok := GetModelPriceBySecondsMap(name) + if !ok { + return 0, false + } + minPrice := 0.0 + found := false + for _, price := range secondsPriceMap { + if !found || price < minPrice { + minPrice = price + found = true + } + } + return minPrice, found +} + +func GetGroupModelPriceBySecondsMin(group string, name string) (float64, bool) { + secondsPriceMap, ok := GetGroupModelPriceBySecondsMap(group, name) + if !ok { + return 0, false + } + minPrice := 0.0 + found := false + for _, price := range secondsPriceMap { + if !found || price < minPrice { + minPrice = price + found = true + } + } + return minPrice, found +} + func UpdateModelRatioByJSONString(jsonStr string) error { return types.LoadFromJsonStringWithCallback(modelRatioMap, jsonStr, InvalidateExposedDataCache) } @@ -426,6 +787,22 @@ func GetDefaultModelPriceMap() map[string]float64 { return defaultModelPrice } +func GetModelPriceBySecondsCopy() map[string]map[string]float64 { + return modelPriceBySecondsMap.ReadAll() +} + +func GetModelPriceByResolutionCopy() map[string]map[string]float64 { + return modelPriceByResolutionMap.ReadAll() +} + +func GetGroupModelPriceBySecondsCopy() map[string]map[string]map[string]float64 { + return groupModelPriceBySecondsMap.ReadAll() +} + +func GetGroupModelPriceByResolutionCopy() map[string]map[string]map[string]float64 { + return groupModelPriceByResolutionMap.ReadAll() +} + func CompletionRatio2JSONString() string { return completionRatioMap.MarshalJSONString() } @@ -651,6 +1028,7 @@ func ModelRatio2JSONString() string { var defaultImageRatio = map[string]float64{ "gpt-image-1": 2, + "gpt-image2": 2, } var imageRatioMap = types.NewRWMap[string, float64]() var audioRatioMap = types.NewRWMap[string, float64]() @@ -696,6 +1074,10 @@ func GetModelPriceCopy() map[string]float64 { return modelPriceMap.ReadAll() } +func GetGroupModelPriceCopy() map[string]map[string]float64 { + return groupModelPriceMap.ReadAll() +} + func GetCompletionRatioCopy() map[string]float64 { return completionRatioMap.ReadAll() } diff --git a/setting/system_setting/image_bed.go b/setting/system_setting/image_bed.go new file mode 100644 index 000000000000..5e4301de6b46 --- /dev/null +++ b/setting/system_setting/image_bed.go @@ -0,0 +1,11 @@ +package system_setting + +import "strings" + +var CreativeCenterImageBedURL = "" +var CreativeCenterImageBedApiKey = "" + +func EnableCreativeCenterImageBed() bool { + return strings.TrimSpace(CreativeCenterImageBedURL) != "" && + strings.TrimSpace(CreativeCenterImageBedApiKey) != "" +} diff --git a/types/price_data.go b/types/price_data.go index 93bc6ae8d168..468a214d9adc 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -9,22 +9,25 @@ type GroupRatioInfo struct { } type PriceData struct { - FreeModel bool - ModelPrice float64 - ModelRatio float64 - CompletionRatio float64 - CacheRatio float64 - CacheCreationRatio float64 - CacheCreation5mRatio float64 - CacheCreation1hRatio float64 - ImageRatio float64 - AudioRatio float64 - AudioCompletionRatio float64 - OtherRatios map[string]float64 - UsePrice bool - Quota int // 按次计费的最终额度(MJ / Task) - QuotaToPreConsume int // 按量计费的预消耗额度 - GroupRatioInfo GroupRatioInfo + FreeModel bool + ModelPrice float64 + ModelRatio float64 + CompletionRatio float64 + CacheRatio float64 + CacheCreationRatio float64 + CacheCreation5mRatio float64 + CacheCreation1hRatio float64 + ImageRatio float64 + AudioRatio float64 + AudioCompletionRatio float64 + OtherRatios map[string]float64 + UsePrice bool + GroupPriceOverride bool + GroupPriceOverrideGroup string + Quota int + BaseQuota int + QuotaToPreConsume int + GroupRatioInfo GroupRatioInfo } func (p *PriceData) AddOtherRatio(key string, ratio float64) { @@ -38,5 +41,5 @@ func (p *PriceData) AddOtherRatio(key string, ratio float64) { } func (p *PriceData) ToSetting() string { - return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) + return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, GroupPriceOverride: %t, GroupPriceOverrideGroup: %s, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.GroupPriceOverride, p.GroupPriceOverrideGroup, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) } diff --git a/web/bun.lock b/web/bun.lock index e3b293cb12a6..9a8419226a6e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "react-template", @@ -10,7 +11,7 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", - "axios": "1.12.0", + "axios": "1.13.5", "clsx": "^2.1.1", "dayjs": "^1.11.11", "history": "^5.3.0", @@ -776,7 +777,7 @@ "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], - "axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="], + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], @@ -1104,13 +1105,13 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], diff --git a/web/src/App.jsx b/web/src/App.jsx index a5d1ebc00b32..e1c0ab7b5c14 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -53,6 +53,8 @@ import SetupCheck from './components/layout/SetupCheck'; const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); const About = lazy(() => import('./pages/About')); +const CreativeCenter = lazy(() => import('./pages/CreativeCenter')); +const AssetLibrary = lazy(() => import('./pages/Asset')); const UserAgreement = lazy(() => import('./pages/UserAgreement')); const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy')); @@ -106,6 +108,14 @@ function App() { } /> + } key={location.pathname}> + + + } + /> } /> } /> + + } key={location.pathname}> + + + + } + /> { setLoading(true); try { - const res = await API.get('/api/notice'); + const res = await API.get('/api/notice', { skipErrorHandler: true }); const { success, message, data } = res.data; if (success) { if (data !== '') { @@ -98,7 +98,9 @@ const NoticeModal = ({ showError(message); } } catch (error) { - showError(error.message); + if (error?.response?.status !== 429) { + showError(error.message); + } } finally { setLoading(false); } diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index 51666b5ef392..7ee95148b772 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -56,12 +56,16 @@ const PageLayout = () => { '/console/user', '/console/token', '/console/midjourney', + '/console/assets', '/console/task', '/console/models', '/pricing', + '/creative-center', ]; const shouldHideFooter = cardProPages.includes(location.pathname); + const shouldAllowPageScroll = + isMobile || location.pathname === '/creative-center'; const shouldInnerPadding = location.pathname.includes('/console') && @@ -87,7 +91,7 @@ const PageLayout = () => { const loadStatus = async () => { try { - const res = await API.get('/api/status'); + const res = await API.get('/api/status', { skipErrorHandler: true }); const { success, data } = res.data; if (success) { statusDispatch({ type: 'set', payload: data }); @@ -96,7 +100,9 @@ const PageLayout = () => { showError('Unable to connect to server'); } } catch (error) { - showError('Failed to load status'); + if (error?.response?.status !== 429) { + showError('Failed to load status'); + } } }; @@ -210,7 +216,7 @@ const PageLayout = () => { {} }) => { className: localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', }, + { + text: t('资产库'), + itemKey: 'asset', + to: '/assets', + }, ]; // 根据配置过滤项目 diff --git a/web/src/components/layout/headerbar/ActionButtons.jsx b/web/src/components/layout/headerbar/ActionButtons.jsx index 545b5227b224..fedf03534631 100644 --- a/web/src/components/layout/headerbar/ActionButtons.jsx +++ b/web/src/components/layout/headerbar/ActionButtons.jsx @@ -20,7 +20,6 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import NewYearButton from './NewYearButton'; import NotificationButton from './NotificationButton'; -import ThemeToggle from './ThemeToggle'; import LanguageSelector from './LanguageSelector'; import UserArea from './UserArea'; @@ -28,8 +27,6 @@ const ActionButtons = ({ isNewYear, unreadCount, onNoticeOpen, - theme, - onThemeToggle, currentLang, onLanguageChange, userState, @@ -50,8 +47,6 @@ const ActionButtons = ({ t={t} /> - - { docsLink, isDemoSiteMode, isConsoleRoute, - theme, headerNavModules, pricingRequireAuth, logout, handleLanguageChange, - handleThemeToggle, handleMobileMenuToggle, navigate, t, @@ -111,8 +109,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { isNewYear={isNewYear} unreadCount={unreadCount} onNoticeOpen={handleNoticeOpen} - theme={theme} - onThemeToggle={handleThemeToggle} currentLang={currentLang} onLanguageChange={handleLanguageChange} userState={userState} diff --git a/web/src/components/playground/SettingsPanel.jsx b/web/src/components/playground/SettingsPanel.jsx index 3899e596fa6e..61aef3df4a17 100644 --- a/web/src/components/playground/SettingsPanel.jsx +++ b/web/src/components/playground/SettingsPanel.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { useEffect } from 'react'; import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui'; import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -47,6 +47,191 @@ const SettingsPanel = ({ messages, }) => { const { t } = useTranslation(); + const normalizeGrokImageSize = (size) => { + if (size === '1536x1024') { + return '1792x1024'; + } + if (size === '1024x1536') { + return '1024x1792'; + } + return size; + }; + const grokImagineImageModels = new Set([ + 'grok-imagine-1.0', + 'grok-imagine-1.0-fast', + ]); + const grokImagineImageEditModels = new Set(['grok-imagine-1.0-edit']); + const restrictedImageUploadModels = new Set(['grok-imagine-1.0']); + const adobeImageModels = new Set([ + 'nano-banana', + 'nano-banana2', + 'nano-banana-pro', + 'gpt-image2', + ]); + const chatAdobeImageModels = new Set(['nano-banana2', 'nano-banana-pro']); + const adobeVideoModels = new Set([ + 'sora2', + 'sora2-pro', + 'veo31', + 'veo31-ref', + 'veo31-fast', + ]); + const isGrokImagineImageModel = + grokImagineImageModels.has(inputs.model) || + grokImagineImageEditModels.has(inputs.model); + const isGrokImagineImageEditModel = grokImagineImageEditModels.has(inputs.model); + const isAdobeImageModel = adobeImageModels.has(inputs.model); + const isAdobeVideoModel = adobeVideoModels.has(inputs.model); + const isAdobeSoraModel = + inputs.model === 'sora2' || inputs.model === 'sora2-pro'; + const isAdobeVeoModel = + inputs.model === 'veo31' || + inputs.model === 'veo31-ref' || + inputs.model === 'veo31-fast'; + const isVideoModel = + typeof inputs.model === 'string' && inputs.model.includes('video'); + const isGrokImagineVideoModel = inputs.model === 'grok-imagine-1.0-video'; + const imageSizeOptions = [ + { label: '1:1 方图 (1024x1024)', value: '1024x1024' }, + { label: '3:2 横图 (1792x1024)', value: '1792x1024' }, + { label: '2:3 竖图 (1024x1792)', value: '1024x1792' }, + { label: '16:9 宽屏 (1280x720)', value: '1280x720' }, + { label: '9:16 竖屏 (720x1280)', value: '720x1280' }, + ]; + const videoSizeOptions = [ + { label: '1280x720', value: '1280x720' }, + { label: '720x1280', value: '720x1280' }, + { label: '1792x1024', value: '1792x1024' }, + { label: '1024x1792', value: '1024x1792' }, + { label: '1024x1024', value: '1024x1024' }, + ]; + const grokImageRatioOptions = [ + { label: '3:2', value: '1792x1024' }, + { label: '2:3', value: '1024x1792' }, + { label: '16:9', value: '1280x720' }, + { label: '9:16', value: '720x1280' }, + { label: '1:1', value: '1024x1024' }, + ]; + const grokVideoRatioOptions = [ + { label: '3:2', value: '1792x1024' }, + { label: '2:3', value: '1024x1792' }, + { label: '16:9', value: '1280x720' }, + { label: '9:16', value: '720x1280' }, + { label: '1:1', value: '1024x1024' }, + ]; + const videoSecondsOptions = [6, 8, 10, 12, 15, 20, 25, 30].map((v) => ({ + label: `${v}s`, + value: String(v), + })); + const videoPresetOptions = [ + { label: 'Normal', value: 'normal' }, + { label: 'Fun', value: 'fun' }, + { label: 'Spicy', value: 'spicy' }, + { label: 'Custom', value: 'custom' }, + ]; + const videoQualityOptions = [ + { label: '480p', value: '480p' }, + { label: '720p', value: '720p' }, + ]; + const defaultAdobeAspectRatioOptions = [ + { label: 'Auto', value: 'auto' }, + { label: '1:1', value: '1:1' }, + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '4:3', value: '4:3' }, + { label: '3:4', value: '3:4' }, + ]; + const chatAdobeAspectRatioOptions = [ + { label: '1:1', value: '1:1' }, + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '4:3', value: '4:3' }, + { label: '3:4', value: '3:4' }, + ]; + const gptImage2SizeOptions = [ + { label: '1:1', value: '1:1' }, + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '4:3', value: '4:3' }, + { label: '3:4', value: '3:4' }, + { label: '3:2', value: '3:2' }, + { label: '2:3', value: '2:3' }, + ]; + const adobeAutoImageSizeOptions = [ + { label: 'Square (1024x1024)', value: '1024x1024' }, + { label: 'Landscape (1792x1024)', value: '1792x1024' }, + { label: 'Portrait (1024x1792)', value: '1024x1792' }, + { label: 'Classic (2048x1536)', value: '2048x1536' }, + { label: 'Tall (1536x2048)', value: '1536x2048' }, + ]; + const adobeVideoAspectRatioOptions = [ + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + ]; + const adobeOutputResolutionOptions = [ + { label: '1K', value: '1K' }, + { label: '2K', value: '2K' }, + { label: '4K', value: '4K' }, + ]; + const adobeSoraDurationOptions = [4, 8, 12].map((v) => ({ + label: `${v}s`, + value: String(v), + })); + const adobeVeoDurationOptions = [4, 6, 8].map((v) => ({ + label: `${v}s`, + value: String(v), + })); + const getAdobeVideoDurationOptions = (modelName) => { + if (modelName === 'veo31-ref') { + return adobeVeoDurationOptions.filter((option) => option.value === '8'); + } + if (modelName === 'sora2' || modelName === 'sora2-pro') { + return adobeSoraDurationOptions; + } + return adobeVeoDurationOptions; + }; + const getAdobeVideoAspectRatioOptions = (modelName) => { + if (modelName === 'veo31-ref') { + return adobeVideoAspectRatioOptions.filter( + (option) => option.value === '16:9', + ); + } + return adobeVideoAspectRatioOptions; + }; + const getAdobeVideoDefaultDuration = (modelName) => + getAdobeVideoDurationOptions(modelName)[0]?.value || '4'; + const getAdobeVideoDefaultAspectRatio = (modelName) => + getAdobeVideoAspectRatioOptions(modelName)[0]?.value || '16:9'; + const adobeVideoResolutionOptions = [ + { label: '1080p', value: '1080p' }, + { label: '720p', value: '720p' }, + ]; + const adobeReferenceModeOptions = [ + { label: 'Frame', value: 'frame' }, + { label: 'Image', value: 'image' }, + ]; + const isGPTImage2Model = inputs.model === 'gpt-image2'; + const currentAdobeAspectRatioOptions = isGPTImage2Model + ? gptImage2SizeOptions + : chatAdobeImageModels.has(inputs.model) + ? chatAdobeAspectRatioOptions + : defaultAdobeAspectRatioOptions; + const currentAdobeSupportsAutoImageSize = currentAdobeAspectRatioOptions.some( + (option) => option.value === 'auto', + ); + const isImageUploadAllowed = !restrictedImageUploadModels.has(inputs.model); + + useEffect(() => { + if (isImageUploadAllowed) { + return; + } + if (inputs.imageEnabled) { + onInputChange('imageEnabled', false); + } + if (Array.isArray(inputs.imageUrls) && inputs.imageUrls.some((url) => url)) { + onInputChange('imageUrls', ['']); + } + }, [inputs.imageEnabled, inputs.imageUrls, isImageUploadAllowed, onInputChange]); const currentConfig = { inputs, @@ -55,6 +240,20 @@ const SettingsPanel = ({ customRequestMode, customRequestBody, }; + const currentAdobeVideoDurationOptions = getAdobeVideoDurationOptions(inputs.model); + const currentAdobeVideoAspectRatioOptions = getAdobeVideoAspectRatioOptions( + inputs.model, + ); + const selectedAdobeVideoDuration = currentAdobeVideoDurationOptions.some( + (option) => option.value === inputs.videoDuration, + ) + ? inputs.videoDuration + : getAdobeVideoDefaultDuration(inputs.model); + const selectedAdobeVideoAspectRatio = currentAdobeVideoAspectRatioOptions.some( + (option) => option.value === inputs.aspectRatio, + ) + ? inputs.aspectRatio + : getAdobeVideoDefaultAspectRatio(inputs.model); return ( - onInputChange('imageUrls', urls)} - onImageEnabledChange={(enabled) => - onInputChange('imageEnabled', enabled) - } - disabled={customRequestMode} - /> + {isImageUploadAllowed ? ( + onInputChange('imageUrls', urls)} + onImageEnabledChange={(enabled) => + onInputChange('imageEnabled', enabled) + } + disabled={customRequestMode} + /> + ) : ( + + {t('当前模型暂不支持上传图片。')} + + )} {/* 参数控制组件 */} @@ -200,6 +405,205 @@ const SettingsPanel = ({ /> + {/* 视频参数(仅视频模型显示) */} + {isGrokImagineImageModel && !isGrokImagineImageEditModel && ( +
+
+ + {t('图片尺寸')} + + onInputChange('aspectRatio', value)} + disabled={customRequestMode} + /> +
+ {currentAdobeSupportsAutoImageSize && + (inputs.aspectRatio || 'auto') === 'auto' && ( +
+ + Auto Size + + onInputChange('outputResolution', value)} + disabled={customRequestMode} + /> +
+ )} +
+ + )} + + {isVideoModel && ( +
+
+
+ + {t('视频尺寸')} + + onInputChange('videoSeconds', value)} + disabled={customRequestMode} + /> +
+ {isGrokImagineVideoModel && ( +
+ + {t('风格预设')} + + onInputChange('videoQuality', value)} + disabled={customRequestMode} + /> +
+
+
+ )} + + {isAdobeVideoModel && ( +
+
+
+ + Duration + + onInputChange('aspectRatio', value)} + disabled={customRequestMode} + /> +
+ {isAdobeVeoModel && ( +
+ + Resolution + + onInputChange('referenceMode', value)} + disabled={customRequestMode} + /> +
+ )} +
+
+ )} + {/* 流式输出开关 */}
diff --git a/web/src/components/settings/OtherSetting.jsx b/web/src/components/settings/OtherSetting.jsx index f8e0b53756a1..93ecb2f48ebc 100644 --- a/web/src/components/settings/OtherSetting.jsx +++ b/web/src/components/settings/OtherSetting.jsx @@ -95,7 +95,7 @@ const OtherSetting = () => { try { setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: true })); await updateOption('Notice', inputs.Notice); - showSuccess(t('公告已更新')); + showSuccess(t('\u516c\u544a\u5df2\u66f4\u65b0')); } catch (error) { console.error(t('公告更新失败'), error); showError(t('公告更新失败')); @@ -114,7 +114,7 @@ const OtherSetting = () => { LEGAL_USER_AGREEMENT_KEY, inputs[LEGAL_USER_AGREEMENT_KEY], ); - showSuccess(t('用户协议已更新')); + showSuccess(t('\u7528\u6237\u534f\u8bae\u5df2\u66f4\u65b0')); } catch (error) { console.error(t('用户协议更新失败'), error); showError(t('用户协议更新失败')); @@ -136,7 +136,7 @@ const OtherSetting = () => { LEGAL_PRIVACY_POLICY_KEY, inputs[LEGAL_PRIVACY_POLICY_KEY], ); - showSuccess(t('隐私政策已更新')); + showSuccess(t('\u9690\u79c1\u653f\u7b56\u5df2\u66f4\u65b0')); } catch (error) { console.error(t('隐私政策更新失败'), error); showError(t('隐私政策更新失败')); @@ -157,7 +157,7 @@ const OtherSetting = () => { SystemName: true, })); await updateOption('SystemName', inputs.SystemName); - showSuccess(t('系统名称已更新')); + showSuccess(t('\u7cfb\u7edf\u540d\u79f0\u5df2\u66f4\u65b0')); } catch (error) { console.error(t('系统名称更新失败'), error); showError(t('系统名称更新失败')); @@ -174,7 +174,7 @@ const OtherSetting = () => { try { setLoadingInput((loadingInput) => ({ ...loadingInput, Logo: true })); await updateOption('Logo', inputs.Logo); - showSuccess('Logo 已更新'); + showSuccess('Logo \u5df2\u66f4\u65b0'); } catch (error) { console.error('Logo 更新失败', error); showError('Logo 更新失败'); @@ -190,7 +190,7 @@ const OtherSetting = () => { HomePageContent: true, })); await updateOption(key, inputs[key]); - showSuccess('首页内容已更新'); + showSuccess('\u9996\u9875\u5185\u5bb9\u5df2\u66f4\u65b0'); } catch (error) { console.error('首页内容更新失败', error); showError('首页内容更新失败'); @@ -206,10 +206,10 @@ const OtherSetting = () => { try { setLoadingInput((loadingInput) => ({ ...loadingInput, About: true })); await updateOption('About', inputs.About); - showSuccess('关于内容已更新'); + showSuccess('\u8054\u7cfb\u6211\u4eec\u5185\u5bb9\u5df2\u66f4\u65b0'); } catch (error) { - console.error('关于内容更新失败', error); - showError('关于内容更新失败'); + console.error('\u8054\u7cfb\u6211\u4eec\u5185\u5bb9\u66f4\u65b0\u5931\u8d25', error); + showError('\u8054\u7cfb\u6211\u4eec\u5185\u5bb9\u66f4\u65b0\u5931\u8d25'); } finally { setLoadingInput((loadingInput) => ({ ...loadingInput, About: false })); } @@ -219,7 +219,7 @@ const OtherSetting = () => { try { setLoadingInput((loadingInput) => ({ ...loadingInput, Footer: true })); await updateOption('Footer', inputs.Footer); - showSuccess('页脚内容已更新'); + showSuccess('\u9875\u811a\u5185\u5bb9\u5df2\u66f4\u65b0'); } catch (error) { console.error('页脚内容更新失败', error); showError('页脚内容更新失败'); @@ -270,7 +270,7 @@ const OtherSetting = () => { } } catch (error) { console.error('Failed to check for updates:', error); - showError('检查更新失败,请稍后再试'); + showError('\u68c0\u67e5\u66f4\u65b0\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5'); } finally { setLoadingInput((loadingInput) => ({ ...loadingInput, @@ -332,15 +332,14 @@ const OtherSetting = () => { - {t('当前版本')}: - {statusState?.status?.version || t('未知')} + {t('当前版本')}? {statusState?.status?.version || t('未知')} @@ -365,7 +364,7 @@ const OtherSetting = () => { { { { { {t('设置首页内容')} { autosize={{ minRows: 6, maxRows: 12 }} /> {/* */} { { setShowUpdateModal(false)} footer={[ @@ -522,3 +521,4 @@ const OtherSetting = () => { }; export default OtherSetting; + diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx index 90858bf81376..d3608222c6ee 100644 --- a/web/src/components/settings/RatioSetting.jsx +++ b/web/src/components/settings/RatioSetting.jsx @@ -34,6 +34,11 @@ const RatioSetting = () => { let [inputs, setInputs] = useState({ ModelPrice: '', + GroupModelPrice: '', + ModelPriceBySeconds: '', + ModelPriceByResolution: '', + GroupModelPriceBySeconds: '', + GroupModelPriceByResolution: '', ModelRatio: '', CacheRatio: '', CreateCacheRatio: '', diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 8334c01db04b..add96983301a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -69,6 +69,8 @@ const SystemSetting = () => { SMTPAccount: '', SMTPFrom: '', SMTPToken: '', + CreativeCenterImageBedURL: '', + CreativeCenterImageBedApiKey: '', WorkerUrl: '', WorkerValidKey: '', WorkerAllowHttpImageRequestEnabled: '', @@ -317,6 +319,51 @@ const SystemSetting = () => { await updateOptions([{ key: 'ServerAddress', value: ServerAddress }]); }; + const submitCreativeCenterImageBed = async () => { + const options = []; + const creativeCenterImageBedURL = removeTrailingSlash( + inputs.CreativeCenterImageBedURL || '', + ); + + if ( + originInputs['CreativeCenterImageBedURL'] !== creativeCenterImageBedURL + ) { + options.push({ + key: 'CreativeCenterImageBedURL', + value: creativeCenterImageBedURL, + }); + } + if ( + inputs.CreativeCenterImageBedApiKey && + inputs.CreativeCenterImageBedApiKey !== '' + ) { + options.push({ + key: 'CreativeCenterImageBedApiKey', + value: inputs.CreativeCenterImageBedApiKey, + }); + } + + if (options.length > 0) { + await updateOptions(options); + setInputs((prev) => ({ + ...prev, + CreativeCenterImageBedURL: creativeCenterImageBedURL, + CreativeCenterImageBedApiKey: '', + })); + setOriginInputs((prev) => ({ + ...prev, + CreativeCenterImageBedURL: creativeCenterImageBedURL, + })); + if (formApiRef.current) { + formApiRef.current.setValue( + 'CreativeCenterImageBedURL', + creativeCenterImageBedURL, + ); + formApiRef.current.setValue('CreativeCenterImageBedApiKey', ''); + } + } + }; + const submitSMTP = async () => { const options = []; @@ -733,6 +780,51 @@ const SystemSetting = () => { + + + + {t( + '用于创作中心上传图片时,浏览器直接上传到外部图床,再将返回的图片 URL 提交给模型;未配置时会回退到本地上传。', + )} + + + + + + + + + + + + + + { diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx index 7c4bdbc52026..47f19c61a7d0 100644 --- a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -21,11 +21,6 @@ import React from 'react'; import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; const PricingDisplaySettings = ({ - showWithRecharge, - setShowWithRecharge, - currency, - setCurrency, - siteDisplayType, showRatio, setShowRatio, viewMode, @@ -35,17 +30,7 @@ const PricingDisplaySettings = ({ loading = false, t, }) => { - const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS'; - const items = [ - ...(supportsCurrencyDisplay - ? [ - { - value: 'recharge', - label: t('充值价格显示'), - }, - ] - : []), { value: 'ratio', label: t('显示倍率'), @@ -60,17 +45,8 @@ const PricingDisplaySettings = ({ }, ]; - const currencyItems = [ - { value: 'USD', label: 'USD ($)' }, - { value: 'CNY', label: 'CNY (¥)' }, - { value: 'CUSTOM', label: t('自定义货币') }, - ]; - const handleChange = (value) => { switch (value) { - case 'recharge': - setShowWithRecharge(!showWithRecharge); - break; case 'ratio': setShowRatio(!showRatio); break; @@ -85,7 +61,6 @@ const PricingDisplaySettings = ({ const getActiveValues = () => { const activeValues = []; - if (supportsCurrencyDisplay && showWithRecharge) activeValues.push('recharge'); if (showRatio) activeValues.push('ratio'); if (viewMode === 'table') activeValues.push('tableView'); if (tokenUnit === 'K') activeValues.push('tokenUnit'); @@ -93,30 +68,16 @@ const PricingDisplaySettings = ({ }; return ( -
- - - {supportsCurrencyDisplay && showWithRecharge && ( - - )} -
+ ); }; diff --git a/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx index b4bafbb10291..f9312e0bed3a 100644 --- a/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx +++ b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx @@ -16,18 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ - import React from 'react'; import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; -/** - * 计费类型筛选组件 - * @param {string|'all'|0|1} filterQuotaType 当前值 - * @param {Function} setFilterQuotaType setter - * @param {Array} models 模型列表 - * @param {boolean} loading 是否加载中 - * @param {Function} t i18n - */ const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, @@ -36,13 +27,14 @@ const PricingQuotaTypes = ({ t, }) => { const qtyCount = (type) => - models.filter((m) => (type === 'all' ? true : m.quota_type === type)) - .length; + models.filter((m) => (type === 'all' ? true : m.quota_type === type)).length; const items = [ { value: 'all', label: t('全部类型'), tagCount: qtyCount('all') }, { value: 0, label: t('按量计费'), tagCount: qtyCount(0) }, { value: 1, label: t('按次计费'), tagCount: qtyCount(1) }, + { value: 2, label: t('按时长计费'), tagCount: qtyCount(2) }, + { value: 3, label: t('按画质计费'), tagCount: qtyCount(3) }, ]; return ( diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index 968c4bc8b454..08835088ee71 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -29,8 +29,6 @@ import { resetPricingFilters } from '../../../../helpers/utils'; import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts'; const PricingSidebar = ({ - showWithRecharge, - setShowWithRecharge, currency, setCurrency, handleChange, @@ -77,7 +75,6 @@ const PricingSidebar = ({ const handleResetFilters = () => resetPricingFilters({ handleChange, - setShowWithRecharge, setCurrency, setShowRatio, setViewMode, diff --git a/web/src/components/table/model-pricing/layout/content/PricingContent.jsx b/web/src/components/table/model-pricing/layout/content/PricingContent.jsx index fd4e37a2a2de..c8209e191ede 100644 --- a/web/src/components/table/model-pricing/layout/content/PricingContent.jsx +++ b/web/src/components/table/model-pricing/layout/content/PricingContent.jsx @@ -32,8 +32,6 @@ const PricingContent = ({ isMobile, sidebarProps, ...props }) => { {...props} isMobile={isMobile} sidebarProps={sidebarProps} - showWithRecharge={sidebarProps.showWithRecharge} - setShowWithRecharge={sidebarProps.setShowWithRecharge} currency={sidebarProps.currency} setCurrency={sidebarProps.setCurrency} showRatio={sidebarProps.showRatio} diff --git a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx index fe04e4cc3fdb..7a6477dc41af 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx @@ -36,8 +36,6 @@ const PricingTopSection = memo( filteredModels, loading, searchValue, - showWithRecharge, - setShowWithRecharge, currency, setCurrency, siteDisplayType, @@ -65,8 +63,6 @@ const PricingTopSection = memo( isMobile={isMobile} searchValue={searchValue} setShowFilterModal={setShowFilterModal} - showWithRecharge={showWithRecharge} - setShowWithRecharge={setShowWithRecharge} currency={currency} setCurrency={setCurrency} siteDisplayType={siteDisplayType} @@ -101,8 +97,6 @@ const PricingTopSection = memo( isMobile={isMobile} searchValue={searchValue} setShowFilterModal={setShowFilterModal} - showWithRecharge={showWithRecharge} - setShowWithRecharge={setShowWithRecharge} currency={currency} setCurrency={setCurrency} siteDisplayType={siteDisplayType} diff --git a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx index 60226264470b..9b17d1c6c95a 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx @@ -146,8 +146,6 @@ const PricingVendorIntro = memo( isMobile = false, searchValue = '', setShowFilterModal, - showWithRecharge, - setShowWithRecharge, currency, setCurrency, showRatio, @@ -281,8 +279,6 @@ const PricingVendorIntro = memo( isMobile={isMobile} searchValue={searchValue} setShowFilterModal={setShowFilterModal} - showWithRecharge={showWithRecharge} - setShowWithRecharge={setShowWithRecharge} currency={currency} setCurrency={setCurrency} showRatio={showRatio} @@ -303,8 +299,6 @@ const PricingVendorIntro = memo( isMobile, searchValue, setShowFilterModal, - showWithRecharge, - setShowWithRecharge, currency, setCurrency, showRatio, diff --git a/web/src/components/table/model-pricing/layout/header/SearchActions.jsx b/web/src/components/table/model-pricing/layout/header/SearchActions.jsx index e285d3fba348..c1f188448221 100644 --- a/web/src/components/table/model-pricing/layout/header/SearchActions.jsx +++ b/web/src/components/table/model-pricing/layout/header/SearchActions.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { memo, useCallback } from 'react'; -import { Input, Button, Switch, Select, Divider } from '@douyinfe/semi-ui'; +import { Input, Button, Switch, Divider } from '@douyinfe/semi-ui'; import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons'; const SearchActions = memo( @@ -31,11 +31,6 @@ const SearchActions = memo( isMobile = false, searchValue = '', setShowFilterModal, - showWithRecharge, - setShowWithRecharge, - currency, - setCurrency, - siteDisplayType, showRatio, setShowRatio, viewMode, @@ -44,8 +39,6 @@ const SearchActions = memo( setTokenUnit, t, }) => { - const supportsCurrencyDisplay = siteDisplayType !== 'TOKENS'; - const handleCopyClick = useCallback(() => { if (copyText && selectedRowKeys.length > 0) { copyText(selectedRowKeys); @@ -93,37 +86,11 @@ const SearchActions = memo( <> - {/* 充值价格显示开关 */} - {supportsCurrencyDisplay && ( -
- {t('充值价格显示')} - -
- )} - - {/* 货币单位选择 */} - {supportsCurrencyDisplay && showWithRecharge && ( - handleToggleSelect(asset)} + /> + + {asset.asset_type === 'video' ? '视频' : '图片'} + + +
+ } + > +
+ {showPreview ? ( + + ) : null} + +
+ + {asset.model_name || '未命名模型'} + + {asset.group ? ( + + {asset.group} + + ) : null} + {isAdminUser && asset.username ? ( + + {asset.username} + + ) : null} +
+ +
+ + {asset.prompt || '未记录提示词'} + +
+ +
+
+ + 创建时间 + +
+ {formatAssetTime(asset.created_at)} +
+
+
+ +
+ + +
+
+ + ); + })} +
+ )} + + + + setPreviewAsset(null)} + footer={null} + width={previewAsset?.asset_type === 'video' ? 920 : 760} + > + {previewAsset ? ( +
+ {previewAsset.asset_type === 'video' ? ( +
+ ) : null} +
+ + ); +}; + +export default AssetLibrary; diff --git a/web/src/pages/CreativeCenter/index.jsx b/web/src/pages/CreativeCenter/index.jsx new file mode 100644 index 000000000000..c3a74ba9917b --- /dev/null +++ b/web/src/pages/CreativeCenter/index.jsx @@ -0,0 +1,8931 @@ +import React, { useContext, useMemo, useRef, useState, useEffect } from 'react'; +import { SSE } from 'sse.js'; +import { + ArrowUp, + Check, + CheckSquare, + ChevronDown, + Clock, + Copy, + Eye, + History, + Image as ImageIcon, + Layers, + Loader2, + MessageSquare, + Plus, + Square, + Video, + Download, + Trash2, + User, + Sparkles, + Send, + X, + ImagePlus, + Wallet +} from 'lucide-react'; +import { + API, + buildApiPayload, + buildMessageContent, + getChannelIcon, + getLobeHubIcon, + getUserIdFromLocalStorage, + processGroupsData, + processThinkTags, + showWarning, +} from '../../helpers'; +import { API_ENDPOINTS } from '../../constants/playground.constants'; +import { UserContext } from '../../context/User'; +import { StatusContext } from '../../context/Status'; + +const tabs = [ + { id: 'chat', label: '对话', icon: MessageSquare }, + { id: 'image', label: '图片', icon: ImageIcon }, + { id: 'video', label: '视频', icon: Video, badge: 'HOT' }, +]; + +const GROK_IMAGINE_IMAGE_MODELS = new Set([ + 'grok-imagine-1.0', + 'grok-imagine-1.0-fast', + 'grok-imagine-1.0-edit', +]); +const GROK_IMAGE_EDIT_MODELS = new Set(['grok-imagine-1.0-edit']); +const GROK_IMAGE_GENERATION_MODELS = new Set([ + 'grok-imagine-1.0', + 'grok-imagine-1.0-fast', +]); +const ADOBE_IMAGE_MODELS = new Set([ + 'nano-banana', + 'nano-banana2', + 'nano-banana-pro', + 'gpt-image2', +]); +const GPT_IMAGE2_MODEL = 'gpt-image2'; +const ADOBE_CHAT_IMAGE_MODELS = new Set([ + 'nano-banana2', + 'nano-banana-pro', +]); +const ADOBE_VIDEO_MODELS = new Set([ + 'sora2', + 'sora2-pro', + 'veo31', + 'veo31-ref', + 'veo31-fast', +]); +const CREATIVE_CENTER_IMAGE_UPLOAD_LIMITS = { + 'grok-imagine-1.0-edit': 3, + 'grok-imagine-1.0-video': 7, + 'nano-banana': 4, + 'nano-banana2': 6, + 'nano-banana-pro': 6, + 'gpt-image2': 6, + 'sora2': 1, + 'sora2-pro': 1, + 'veo31-fast': 2, + 'veo31-ref': 3, +}; + +const GROK_IMAGE_SIZE_OPTIONS = [ + { label: '3:2', value: '1792x1024' }, + { label: '2:3', value: '1024x1792' }, + { label: '16:9', value: '1280x720' }, + { label: '9:16', value: '720x1280' }, + { label: '1:1', value: '1024x1024' }, +]; +const DEFAULT_ADOBE_IMAGE_ASPECT_RATIO_OPTIONS = [ + { label: 'Auto', value: 'auto' }, + { label: '1:1', value: '1:1' }, + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '4:3', value: '4:3' }, + { label: '3:4', value: '3:4' }, +]; +const CHAT_ADOBE_IMAGE_ASPECT_RATIO_OPTIONS = [ + { label: '1:1', value: '1:1' }, + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '4:3', value: '4:3' }, + { label: '3:4', value: '3:4' }, +]; +const GPT_IMAGE2_SIZE_OPTIONS = [ + { label: '1:1', value: '1:1' }, + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '4:3', value: '4:3' }, + { label: '3:4', value: '3:4' }, + { label: '3:2', value: '3:2' }, + { label: '2:3', value: '2:3' }, +]; +const ADOBE_AUTO_IMAGE_SIZE_OPTIONS = [ + { label: '1024x1024', value: '1024x1024' }, + { label: '1792x1024', value: '1792x1024' }, + { label: '1024x1792', value: '1024x1792' }, + { label: '2048x1536', value: '2048x1536' }, + { label: '1536x2048', value: '1536x2048' }, +]; +const ADOBE_OUTPUT_RESOLUTION_OPTIONS = [ + { label: '1K', value: '1K' }, + { label: '2K', value: '2K' }, + { label: '4K', value: '4K' }, +]; +const GENERIC_VIDEO_SIZE_OPTIONS = [ + { label: '3:2', value: '1792x1024' }, + { label: '2:3', value: '1024x1792' }, + { label: '16:9', value: '1280x720' }, + { label: '9:16', value: '720x1280' }, + { label: '1:1', value: '1024x1024' }, +]; +const GENERIC_VIDEO_SECONDS_OPTIONS = [6, 8, 10, 12, 15, 20, 25, 30].map( + (value) => ({ label: `${value}s`, value: String(value) }), +); +const GROK_IMAGINE_VIDEO_SECONDS_OPTIONS = [6, 8, 10].map((value) => ({ + label: `${value}s`, + value: String(value), +})); +const GENERIC_VIDEO_QUALITY_OPTIONS = [ + { label: '480p', value: '480p' }, + { label: '720p', value: '720p' }, +]; +const GROK_VIDEO_PRESET_OPTIONS = [ + { label: 'Normal', value: 'normal' }, + { label: 'Fun', value: 'fun' }, + { label: 'Spicy', value: 'spicy' }, + { label: 'Custom', value: 'custom' }, +]; +const ADOBE_VIDEO_DURATION_OPTIONS = { + sora: [4, 8, 12].map((value) => ({ label: `${value}s`, value: String(value) })), + veo: [4, 6, 8].map((value) => ({ label: `${value}s`, value: String(value) })), +}; +const ADOBE_VIDEO_ASPECT_RATIO_OPTIONS = [ + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, +]; +const getAdobeVideoDurationOptions = (modelName) => { + if (modelName === 'veo31-ref') { + return ADOBE_VIDEO_DURATION_OPTIONS.veo.filter((option) => option.value === '8'); + } + if (modelName === 'sora2' || modelName === 'sora2-pro') { + return ADOBE_VIDEO_DURATION_OPTIONS.sora; + } + return ADOBE_VIDEO_DURATION_OPTIONS.veo; +}; +const getAdobeVideoAspectRatioOptions = (modelName) => { + if (modelName === 'veo31-ref') { + return ADOBE_VIDEO_ASPECT_RATIO_OPTIONS.filter( + (option) => option.value === '16:9', + ); + } + return ADOBE_VIDEO_ASPECT_RATIO_OPTIONS; +}; +const getAdobeVideoDefaultDuration = (modelName) => + getAdobeVideoDurationOptions(modelName)[0]?.value || '4'; +const getAdobeVideoDefaultAspectRatio = (modelName) => + getAdobeVideoAspectRatioOptions(modelName)[0]?.value || '16:9'; +const ADOBE_VIDEO_RESOLUTION_OPTIONS = [ + { label: '1080p', value: '1080p' }, + { label: '720p', value: '720p' }, +]; +const ADOBE_REFERENCE_MODE_OPTIONS = [ + { label: 'Frame', value: 'frame' }, + { label: 'Image', value: 'image' }, +]; +const GENERATION_COUNT_OPTIONS = Array.from({ length: 10 }, (_, index) => ({ + label: `${index + 1}条`, + value: String(index + 1), +})); +const PARAMETER_TOGGLES_DISABLED = { + temperature: false, + top_p: false, + max_tokens: false, + frequency_penalty: false, + presence_penalty: false, + seed: false, +}; +const EMPTY_HISTORY_SNAPSHOTS = { + chat: null, + image: null, + video: null, +}; +const ACTIVE_VIDEO_POLL_STATUSES = new Set([ + 'submitted', + 'queued', + 'generating', + 'processing', + 'in_progress', +]); +const CREATIVE_CENTER_VIDEO_TASK_ACTIONS = new Set([ + 'generate', + 'textGenerate', + 'firstTailGenerate', + 'referenceGenerate', + 'remixGenerate', +]); +const UNIFORM_CREATIVE_VIDEO_CARD_MODELS = new Set([ + 'grok-imagine-1.0-video', + 'veo31-fast', + 'veo31-ref', +]); +const CREATIVE_CENTER_IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; +const CREATIVE_CENTER_IMAGE_UPLOAD_CONCURRENCY = 2; +const CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_MAX_TASKS = 20; +const CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_CONCURRENCY = 4; +const CREATIVE_CENTER_IMAGE_POLL_INTERVAL_MS = 6000; +const CREATIVE_CENTER_IMAGE_POLL_CONCURRENCY = 2; +const CREATIVE_CENTER_IMAGE_POLL_429_BACKOFF_MS = 15000; +const CREATIVE_CENTER_VIDEO_POLL_INTERVAL_MS = 6000; +const CREATIVE_CENTER_VIDEO_POLL_CONCURRENCY = 2; +const CREATIVE_CENTER_VIDEO_POLL_429_BACKOFF_MS = 15000; +const CREATIVE_CENTER_VIDEO_PENDING_TO_GENERATING_MS = 10000; +const CREATIVE_CENTER_HISTORY_PERSIST_DEBOUNCE_MS = 2000; +const CREATIVE_CENTER_VIDEO_HISTORY_PERSIST_DEBOUNCE_MS = 6000; +const CREATIVE_CENTER_HISTORY_PERSIST_429_BACKOFF_MS = 15000; +const CREATIVE_BATCH_REQUEST_SPACING_MS = 300; +const ESTIMATED_PROGRESS_TICK_MS = 500; +const ESTIMATED_PROGRESS_FINALIZING_MS = 1400; + +const clampProgress = (value) => Math.min(Math.max(value, 0), 100); +const createBatchSeedBase = () => + Math.floor(Date.now() % 1000000000) + Math.floor(Math.random() * 1000000); +const createTaskSeed = (batchSeedBase, index) => batchSeedBase + index * 9973; +const createTaskRequestUser = (batchSeedBase, index) => + `creative-center-${batchSeedBase}-${index + 1}`; +const createTaskRequestId = (batchSeedBase, index) => + `creative-request-${batchSeedBase}-${index + 1}`; +const waitForMs = (ms) => + new Promise((resolve) => { + window.setTimeout(resolve, Math.max(0, ms)); + }); + +const parseProgressValue = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + return clampProgress(Math.round(value)); + } + + if (typeof value === 'string') { + const normalizedValue = value.trim().replace(/%$/, ''); + const parsedValue = Number(normalizedValue); + if (Number.isFinite(parsedValue)) { + return clampProgress(Math.round(parsedValue)); + } + } + + return null; +}; + +const parseTimestampValue = (value, fallback = 0) => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + + if (typeof value === 'string' && value.trim()) { + const numericValue = Number(value); + if (Number.isFinite(numericValue) && numericValue > 0) { + return numericValue; + } + + const parsedDate = Date.parse(value); + if (Number.isFinite(parsedDate) && parsedDate > 0) { + return parsedDate; + } + } + + return fallback; +}; + +const shouldUseEstimatedImageProgress = (modelName) => Boolean(modelName); +const shouldUseEstimatedVideoProgress = (modelName) => Boolean(modelName); +const shouldUseCreativeCenterChatStream = (modelName) => { + const normalizedModelName = + typeof modelName === 'string' ? modelName.trim().toLowerCase() : ''; + return normalizedModelName.includes('gpt'); +}; + +const getEstimatedImageDurationMs = (params = {}) => { + switch (params?.outputResolution) { + case '4K': + return 36000; + case '1K': + return 16000; + case '2K': + default: + return 24000; + } +}; + +const getEstimatedVeoDurationMs = (params = {}) => { + const durationMap = { + '4': 45000, + '6': 65000, + '8': 85000, + }; + const baseDuration = + durationMap[String(params?.videoDuration || params?.duration || '4')] || 65000; + const resolutionOffset = params?.videoResolution === '1080p' ? 8000 : 0; + return baseDuration + resolutionOffset; +}; + +const getEstimatedTaskProgress = ({ + task, + modelName, + params, + taskType, + now = Date.now(), +}) => { + const isEstimatedModel = + taskType === 'image' + ? shouldUseEstimatedImageProgress(modelName) + : shouldUseEstimatedVideoProgress(modelName); + const actualProgress = parseProgressValue(task?.progress); + const normalizedStatus = normalizeVideoTaskStatus(task?.status || 'submitted'); + + if (!isEstimatedModel) { + if (typeof actualProgress === 'number' && actualProgress > 0) { + return { + progress: actualProgress, + progressText: `${actualProgress}%`, + statusText: '实时生成中', + indeterminate: false, + }; + } + + if (['completed', 'failed'].includes(normalizedStatus)) { + const completedProgress = actualProgress ?? 100; + return { + progress: completedProgress, + progressText: `${completedProgress}%`, + statusText: normalizedStatus === 'failed' ? '任务失败' : '已完成', + indeterminate: false, + }; + } + + return { + progress: 0, + progressText: '生成中', + statusText: '实时生成中', + indeterminate: true, + }; + } + + if (normalizedStatus === 'completed') { + return { + progress: 100, + progressText: '100%', + statusText: '已完成', + indeterminate: false, + }; + } + + if (normalizedStatus === 'failed') { + const failedProgress = actualProgress ?? 100; + return { + progress: failedProgress, + progressText: `${failedProgress}%`, + statusText: '任务失败', + indeterminate: false, + }; + } + + const estimateStartAt = parseTimestampValue( + task?.estimateStartAt || task?.estimate_start_at, + parseTimestampValue(task?.submittedAt || task?.submitted_at, now), + ); + const finalizingAt = parseTimestampValue( + task?.finalizingAt || task?.finalizing_at, + 0, + ); + + if (normalizedStatus === 'finalizing' || finalizingAt > 0) { + const finalizingElapsed = Math.max(0, now - (finalizingAt || now)); + const finalizingRatio = Math.min( + finalizingElapsed / ESTIMATED_PROGRESS_FINALIZING_MS, + 1, + ); + const estimatedProgress = clampProgress( + Math.round(90 + finalizingRatio * 9), + ); + const mergedProgress = Math.max(actualProgress ?? 0, estimatedProgress); + return { + progress: mergedProgress, + progressText: `${mergedProgress}%`, + statusText: '整理结果中', + indeterminate: false, + }; + } + + if (now < estimateStartAt) { + return { + progress: Math.max(actualProgress ?? 0, 3), + progressText: `${Math.max(actualProgress ?? 0, 3)}%`, + statusText: '提交成功', + indeterminate: false, + }; + } + + const estimatedDurationMs = + taskType === 'image' + ? getEstimatedImageDurationMs(params) + : getEstimatedVeoDurationMs(params); + const activeElapsed = Math.max(0, now - estimateStartAt); + const activeRatio = Math.min(activeElapsed / estimatedDurationMs, 1); + const estimatedProgress = clampProgress(Math.round(5 + activeRatio * 80)); + const mergedProgress = Math.max(actualProgress ?? 0, estimatedProgress); + + return { + progress: mergedProgress, + progressText: `${mergedProgress}%`, + statusText: '预计进度', + indeterminate: false, + }; +}; + +const normalizeVideoTaskStatus = (status) => { + const normalizedStatus = String(status || '').trim().toLowerCase(); + + if ( + ['completed', 'complete', 'succeeded', 'success'].includes(normalizedStatus) + ) { + return 'completed'; + } + + if (['failed', 'failure', 'error', 'cancelled', 'canceled'].includes(normalizedStatus)) { + return 'failed'; + } + + if (['queued', 'queueing'].includes(normalizedStatus)) { + return 'queued'; + } + + if (['submitted', 'pending'].includes(normalizedStatus)) { + return 'submitted'; + } + + if (['finalizing', 'finalising'].includes(normalizedStatus)) { + return 'finalizing'; + } + + if (['processing', 'generating', 'in_progress', 'running'].includes(normalizedStatus)) { + return 'generating'; + } + + return normalizedStatus || 'submitted'; +}; + +const createPersistedImageTaskItem = (item, index = 0) => { + const requestId = typeof item?.requestId === 'string' ? item.requestId.trim() : ''; + const submittedAt = parseTimestampValue( + item?.submittedAt || item?.submitted_at, + 0, + ); + const mediaUrl = getImageTaskMediaUrl(item); + const taskId = String(item?.taskId || item?.task_id || '').trim(); + const normalizedStatus = normalizeVideoTaskStatus( + item?.status || (mediaUrl ? 'completed' : 'submitted'), + ); + return { + id: item?.id || createCreativeRecordId(`image-task-${index}`), + requestId, + taskId, + submittedAt, + status: mediaUrl ? 'completed' : normalizedStatus, + ...(mediaUrl ? { resultUrl: mediaUrl } : {}), + }; +}; + +const normalizeCreativeSourceImageItem = (item, index = 0) => { + const rawUrl = + typeof item === 'string' + ? item + : typeof item?.url === 'string' + ? item.url + : ''; + const url = rawUrl.trim(); + if (!url) { + return null; + } + + const fallbackName = + getCreativeCenterFilenameFromUrl(url) || `image-${index + 1}.png`; + const rawFileName = + typeof item?.fileName === 'string' + ? item.fileName + : typeof item?.file_name === 'string' + ? item.file_name + : fallbackName; + const fileName = rawFileName.trim() || fallbackName; + const rawName = + typeof item?.name === 'string' ? item.name : fileName || fallbackName; + const name = rawName.trim() || fileName || fallbackName; + + return { + id: item?.id || createCreativeRecordId(`source-image-${index}`), + name, + url, + fileName, + previewUrl: + typeof item?.previewUrl === 'string' && item.previewUrl.trim() + ? item.previewUrl.trim() + : '', + status: item?.status === 'uploading' ? 'uploading' : 'uploaded', + }; +}; + +const createPersistedSourceImageItem = (item, index = 0) => { + const normalizedItem = normalizeCreativeSourceImageItem(item, index); + if (!normalizedItem) { + return null; + } + + return { + name: normalizedItem.name, + url: normalizedItem.url, + fileName: normalizedItem.fileName, + }; +}; + +const createPersistedVideoTaskItem = (item, index = 0) => { + const requestId = typeof item?.requestId === 'string' ? item.requestId.trim() : ''; + const taskId = String(item?.taskId || item?.task_id || '').trim(); + const submittedAt = parseTimestampValue( + item?.submittedAt || item?.submitted_at, + 0, + ); + const mediaUrl = getVideoTaskMediaUrl(item); + const normalizedStatus = normalizeVideoTaskStatus( + item?.status || (mediaUrl ? 'completed' : 'submitted'), + ); + const resolvedStatus = + mediaUrl + ? 'completed' + : normalizedStatus === 'completed' + ? 'failed' + : normalizedStatus; + return { + id: item?.id || createCreativeRecordId(`video-task-${index}`), + requestId, + taskId, + submittedAt, + status: resolvedStatus, + ...(resolvedStatus === 'failed' + ? { + error: + item?.error || + item?.content || + '任务生成失败', + } + : {}), + ...(mediaUrl ? { resultUrl: mediaUrl } : {}), + }; +}; + +const createPersistedImageRecord = (record, index = 0) => ({ + id: record?.id || createCreativeRecordId(`image-history-${index}`), + prompt: record?.prompt || '', + modelName: record?.modelName || '', + params: record?.params && typeof record.params === 'object' ? record.params : {}, + sourceImages: Array.isArray(record?.sourceImages) + ? record.sourceImages + .map((item, sourceImageIndex) => + createPersistedSourceImageItem(item, sourceImageIndex), + ) + .filter(Boolean) + : [], + group: record?.group || '', + createdAt: parseTimestampValue( + record?.createdAt || record?.created_at, + Date.now(), + ), + updatedAt: parseTimestampValue( + record?.updatedAt || record?.updated_at, + Date.now(), + ), + images: Array.isArray(record?.images) + ? record.images.map((item, imageIndex) => + createPersistedImageTaskItem(item, imageIndex), + ) + : [], +}); + +const createPersistedVideoRecord = (record, index = 0) => ({ + id: record?.id || createCreativeRecordId(`video-history-${index}`), + prompt: record?.prompt || '', + modelName: record?.modelName || '', + params: record?.params && typeof record.params === 'object' ? record.params : {}, + sourceImages: Array.isArray(record?.sourceImages) + ? record.sourceImages + .map((item, sourceImageIndex) => + createPersistedSourceImageItem(item, sourceImageIndex), + ) + .filter(Boolean) + : [], + group: record?.group || '', + createdAt: parseTimestampValue( + record?.createdAt || record?.created_at, + Date.now(), + ), + updatedAt: parseTimestampValue( + record?.updatedAt || record?.updated_at, + Date.now(), + ), + tasks: Array.isArray(record?.tasks) + ? record.tasks.map((item, taskIndex) => + createPersistedVideoTaskItem(item, taskIndex), + ) + : [], +}); + +const buildPersistableCreativeSessionPayload = (tabKey, payload) => { + const normalizedPayload = + payload && typeof payload === 'object' ? payload : {}; + + if (tabKey === 'chat') { + return normalizedPayload; + } + + const normalizedSessions = Array.isArray(normalizedPayload.sessions) + ? normalizedPayload.sessions + : []; + + return { + current_session_id: + typeof normalizedPayload.current_session_id === 'string' + ? normalizedPayload.current_session_id + : '', + sessions: normalizedSessions.map((session, index) => { + const normalizedSession = normalizeCreativeSessionSnapshot( + tabKey, + session, + null, + index, + ); + const records = + tabKey === 'image' + ? normalizeImageHistoryRecords(normalizedSession).map((record, recordIndex) => + createPersistedImageRecord(record, recordIndex), + ) + : normalizeVideoHistoryRecords(normalizedSession).map((record, recordIndex) => + createPersistedVideoRecord(record, recordIndex), + ); + + return { + id: normalizedSession.id, + name: normalizedSession.name, + model_name: normalizedSession.model_name, + group: normalizedSession.group, + prompt: normalizedSession.prompt, + created_at: normalizedSession.created_at, + updated_at: normalizedSession.updated_at, + payload: { + entries: records, + params: + normalizedSession?.payload?.params && + typeof normalizedSession.payload.params === 'object' + ? normalizedSession.payload.params + : {}, + }, + }; + }), + }; +}; + +const summarizeImageTasks = (images) => { + const completedCount = images.filter((item) => + ['completed', 'failed'].includes(item.status), + ).length; + const successCount = images.filter((item) => item.status === 'completed').length; + const hasActiveTask = images.some( + (item) => !['completed', 'failed'].includes(item.status), + ); + + return { + completedCount, + successCount, + status: hasActiveTask + ? 'generating' + : successCount > 0 + ? 'completed' + : 'failed', + }; +}; + +const summarizeVideoTasks = (tasks) => { + const completedCount = tasks.filter((item) => + ['completed', 'failed'].includes(item.status), + ).length; + const successCount = tasks.filter((item) => item.status === 'completed').length; + const hasActiveTask = tasks.some( + (item) => !['completed', 'failed'].includes(item.status), + ); + + return { + completedCount, + successCount, + status: hasActiveTask + ? 'generating' + : successCount > 0 + ? 'completed' + : 'failed', + }; +}; + +const normalizeGrokImageSize = (size) => { + if (size === '1536x1024') { + return '1792x1024'; + } + if (size === '1024x1536') { + return '1024x1792'; + } + return size; +}; + +const getOptionLabel = (options, value) => + options.find((option) => option.value === value)?.label || value; + +const extractVideoUrlFromMessage = (content) => { + if (typeof content !== 'string') { + return ''; + } + + const htmlMatch = content.match(/]+src=['"]([^'"]+)['"]/i); + if (htmlMatch?.[1]) { + return htmlMatch[1]; + } + + const markdownMatch = content.match(/\((https?:\/\/[^)\s]+)\)/i); + if (markdownMatch?.[1]) { + return markdownMatch[1]; + } + + const plainUrlMatch = content.match(/https?:\/\/[^\s'"]+/i); + return plainUrlMatch?.[0] || ''; +}; + +const extractImageUrlsFromMessage = (content) => { + if (typeof content !== 'string' || !content.trim()) { + return []; + } + + const matches = [ + ...content.matchAll(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/gi), + ...content.matchAll(/\[[^\]]*]\((https?:\/\/[^)\s]+)\)/gi), + ...content.matchAll(/(https?:\/\/[^\s'"]+\.(?:png|jpe?g|webp|gif)(?:\?[^\s'"]*)?)/gi), + ]; + + return [...new Set(matches.map((match) => match[1]).filter(Boolean))]; +}; + +const extractImageUrlsFromCreativeResponse = (data) => { + const directUrls = Array.isArray(data?.data) + ? data.data + .map((item) => (typeof item?.url === 'string' ? item.url.trim() : '')) + .filter(Boolean) + : []; + if (directUrls.length > 0) { + return directUrls; + } + + const messageContent = data?.choices?.[0]?.message?.content; + if (typeof messageContent === 'string') { + return extractImageUrlsFromMessage(messageContent); + } + + if (Array.isArray(messageContent)) { + return messageContent + .filter((item) => item?.type === 'image_url') + .map((item) => + typeof item?.image_url === 'string' + ? item.image_url.trim() + : item?.image_url?.url?.trim?.() || '', + ) + .filter(Boolean); + } + + return []; +}; + +const CREATIVE_CENTER_TEXT_FRAGMENT_KEYS = [ + 'text', + 'output_text', + 'summary_text', + 'generated_text', + 'generation', + 'completion', + 'content', + 'message', + 'response', + 'result', + 'answer', + 'value', + 'refusal', + 'transcript', + 'markdown', + 'outputText', + 'responseText', + 'content_text', + 'text_content', +]; + +const CREATIVE_CENTER_NESTED_RESPONSE_KEYS = [ + 'data', + 'payload', + 'body', + 'choice', + 'message', + 'output', + 'outputs', + 'choices', + 'candidates', + 'parts', + 'segments', + 'items', + 'messages', + 'delta', +]; + +const CREATIVE_CENTER_DIAGNOSTIC_MESSAGE_PREFIXES = [ + '模型已返回响应', + '请求失败', +]; +const CREATIVE_CENTER_RAW_RESPONSE_PREVIEW_LIMIT = 4000; + +const isCreativeCenterDiagnosticAssistantMessage = (message) => { + if (!message || message.role !== 'assistant' || typeof message.content !== 'string') { + return false; + } + + const content = message.content.trim(); + return CREATIVE_CENTER_DIAGNOSTIC_MESSAGE_PREFIXES.some((prefix) => + content.startsWith(prefix), + ); +}; + +const buildCreativeCenterChatRequestMessages = (messages) => { + if (!Array.isArray(messages)) { + return []; + } + + return messages.filter((message) => { + if (!message || !message.role) { + return false; + } + if (message.role !== 'assistant') { + return true; + } + if (isCreativeCenterDiagnosticAssistantMessage(message)) { + return false; + } + if (typeof message.content === 'string' && !message.content.trim()) { + return false; + } + return true; + }); +}; + +const collectCreativeCenterTextFragments = (value, visited = new WeakSet()) => { + if (typeof value === 'string') { + return value.trim() ? [value] : []; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => + collectCreativeCenterTextFragments(item, visited), + ); + } + + if (!value || typeof value !== 'object') { + return []; + } + if (visited.has(value)) { + return []; + } + visited.add(value); + + const fragments = []; + const append = (nextValue) => { + collectCreativeCenterTextFragments(nextValue, visited).forEach((fragment) => { + if (fragment.trim()) { + fragments.push(fragment); + } + }); + }; + + CREATIVE_CENTER_TEXT_FRAGMENT_KEYS.forEach((key) => { + if (value[key] !== undefined && value[key] !== null) { + append(value[key]); + } + }); + + CREATIVE_CENTER_NESTED_RESPONSE_KEYS.forEach((key) => { + if ( + value[key] && + typeof value[key] === 'object' && + !CREATIVE_CENTER_TEXT_FRAGMENT_KEYS.includes(key) + ) { + append(value[key]); + } + }); + + return [...new Set(fragments)]; +}; + +const formatCreativeCenterRawResponsePreview = (payload) => { + const formatTextPreview = (value) => { + const trimmedValue = typeof value === 'string' ? value.trim() : ''; + return trimmedValue.length > CREATIVE_CENTER_RAW_RESPONSE_PREVIEW_LIMIT + ? `${trimmedValue.slice(0, CREATIVE_CENTER_RAW_RESPONSE_PREVIEW_LIMIT)}\n...` + : trimmedValue; + }; + + if (payload === undefined || payload === null) { + return ''; + } + if (typeof payload === 'string') { + return formatTextPreview(payload); + } + try { + const serialized = JSON.stringify(payload, null, 2); + return formatTextPreview(serialized); + } catch { + return ''; + } +}; + +const extractCreativeCenterChatResponse = (payload) => { + const rootPayload = + payload && typeof payload === 'object' && payload.data && typeof payload.data === 'object' + ? payload.data + : payload; + const choice = rootPayload?.choices?.[0]; + const message = choice?.message || {}; + const candidate = rootPayload?.candidates?.[0]; + const outputItems = Array.isArray(rootPayload?.output) + ? rootPayload.output + : []; + + const reasoningFragments = [ + message.reasoning_content, + message.reasoning, + choice?.reasoning_content, + choice?.reasoning, + rootPayload?.reasoning_content, + rootPayload?.reasoning, + ] + .flatMap((value) => collectCreativeCenterTextFragments(value)) + .filter(Boolean); + + const contentFragments = [ + choice, + message, + message.content, + choice?.text, + choice?.delta?.content, + rootPayload?.output_text, + rootPayload?.text, + rootPayload?.content, + rootPayload?.message, + rootPayload?.response, + rootPayload?.result, + rootPayload?.answer, + rootPayload?.data, + rootPayload?.payload, + rootPayload?.body, + candidate?.content?.parts, + outputItems, + ] + .flatMap((value) => collectCreativeCenterTextFragments(value)) + .filter(Boolean); + + const content = [...new Set(contentFragments)].join('\n\n').trim(); + const reasoningContent = [...new Set(reasoningFragments)].join('\n\n').trim(); + const rawResponsePreview = + !content && !reasoningContent + ? formatCreativeCenterRawResponsePreview(rootPayload || payload) + : ''; + + return { + content, + reasoningContent, + rawResponsePreview, + }; +}; + +const buildCreativeCenterImageDisplayUrl = (url) => { + if (typeof url !== 'string') { + return ''; + } + + const trimmedURL = url.trim(); + if (!trimmedURL) { + return ''; + } + + if (!/^https?:\/\//i.test(trimmedURL)) { + return trimmedURL; + } + + return `${API_ENDPOINTS.CREATIVE_CENTER_IMAGE_PROXY}?url=${encodeURIComponent(trimmedURL)}`; +}; + +const buildCreativeCenterImageBedUploadUrl = ( + uploadUrl, + returnType = 'full', + autoRetry = true, +) => { + const trimmedUploadUrl = typeof uploadUrl === 'string' ? uploadUrl.trim() : ''; + if (!trimmedUploadUrl) { + return ''; + } + + const requestUrl = new URL(`${trimmedUploadUrl.replace(/\/+$/, '')}/upload`); + requestUrl.searchParams.set('returnFormat', returnType || 'full'); + if (autoRetry) { + requestUrl.searchParams.set('autoRetry', 'true'); + } + return requestUrl.toString(); +}; + +const normalizeCreativeCenterDirectImageUrl = (uploadUrl, src) => { + const trimmedSrc = typeof src === 'string' ? src.trim() : ''; + if (!trimmedSrc) { + return ''; + } + + try { + return new URL(trimmedSrc, `${uploadUrl.replace(/\/+$/, '')}/upload`).toString(); + } catch (error) { + console.error('Failed to normalize creative center direct image url:', error); + return ''; + } +}; + +const parseCreativeCenterDirectUploadImageUrl = (uploadUrl, payload) => { + const items = Array.isArray(payload) + ? payload + : Array.isArray(payload?.data) + ? payload.data + : []; + + const firstSrc = items[0]?.src; + return normalizeCreativeCenterDirectImageUrl(uploadUrl, firstSrc); +}; + +const getCreativeCenterFilenameFromUrl = (url) => { + if (typeof url !== 'string' || !url.trim()) { + return ''; + } + + try { + const parsedUrl = new URL(url); + const pathnameParts = parsedUrl.pathname.split('/').filter(Boolean); + return pathnameParts[pathnameParts.length - 1] || ''; + } catch (error) { + return ''; + } +}; + +const revokeCreativeCenterPreviewURL = (previewUrl) => { + if (typeof previewUrl === 'string' && previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } +}; + +const isGPTImage2Model = (modelName) => modelName === GPT_IMAGE2_MODEL; + +const getAdobeImageAspectRatioOptions = (modelName) => { + if (isGPTImage2Model(modelName)) { + return GPT_IMAGE2_SIZE_OPTIONS; + } + return ADOBE_CHAT_IMAGE_MODELS.has(modelName) + ? CHAT_ADOBE_IMAGE_ASPECT_RATIO_OPTIONS + : DEFAULT_ADOBE_IMAGE_ASPECT_RATIO_OPTIONS; +}; + +const supportsAdobeImageOutputResolution = (modelName) => + !isGPTImage2Model(modelName); + +const supportsAdobeAutoImageSize = (modelName) => + getAdobeImageAspectRatioOptions(modelName).some( + (option) => option.value === 'auto', + ); + +const buildGPTImage2ReferenceMessages = (prompt, imageUrls = []) => { + const normalizedPrompt = prompt?.trim() || 'Edit the provided media.'; + return [ + { + role: 'user', + content: [ + { type: 'text', text: normalizedPrompt }, + ...imageUrls + .filter((url) => typeof url === 'string' && url.trim()) + .map((url) => ({ + type: 'image_url', + image_url: { url: url.trim() }, + })), + ], + }, + ]; +}; + +const getCreativeCenterImageUploadLimit = (modelName) => { + const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : ''; + if (!normalizedModelName) { + return null; + } + return CREATIVE_CENTER_IMAGE_UPLOAD_LIMITS[normalizedModelName] ?? null; +}; + +const isCreativeCenterImageUploadEnabled = (tabKey, modelName) => { + if (tabKey === 'chat') { + return true; + } + return getCreativeCenterImageUploadLimit(modelName) !== null; +}; + +const resolveCreativeCenterDisplayCurrency = (quotaDisplayType = 'USD') => + quotaDisplayType === 'CNY' || quotaDisplayType === 'CUSTOM' + ? quotaDisplayType + : 'USD'; + +const getCreativeCenterCurrencySymbol = ( + currency = 'USD', + customCurrencySymbol = '¤', +) => { + if (currency === 'CNY') { + return '¥'; + } + if (currency === 'CUSTOM') { + return customCurrencySymbol || '¤'; + } + return '$'; +}; + +const convertCreativeCenterUsdPrice = ( + usdAmount, + currency = 'USD', + options = {}, +) => { + const safeAmount = Number(usdAmount); + if (!Number.isFinite(safeAmount)) { + return null; + } + + if (currency === 'CNY') { + return safeAmount * Number(options.usdExchangeRate || 1); + } + + if (currency === 'CUSTOM') { + return safeAmount * Number(options.customExchangeRate || 1); + } + + return safeAmount; +}; + +const formatCreativeCenterPriceNumber = (amount) => { + const safeAmount = Number(amount); + if (!Number.isFinite(safeAmount)) { + return ''; + } + + const absAmount = Math.abs(safeAmount); + let maximumFractionDigits = 3; + if (absAmount >= 100) { + maximumFractionDigits = 2; + } else if (absAmount >= 1) { + maximumFractionDigits = 3; + } else if (absAmount >= 0.01) { + maximumFractionDigits = 4; + } else { + maximumFractionDigits = 6; + } + + return safeAmount.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits, + }); +}; + +const resolveCreativeCenterGroupRatio = ( + pricingModel, + activeGroup, + groupRatioMap, +) => { + const enableGroups = Array.isArray(pricingModel?.enable_groups) + ? pricingModel.enable_groups + : []; + + if ( + activeGroup && + enableGroups.includes(activeGroup) && + Number.isFinite(Number(groupRatioMap?.[activeGroup])) + ) { + return Number(groupRatioMap[activeGroup]); + } + + let minRatio = Number.POSITIVE_INFINITY; + enableGroups.forEach((group) => { + const ratio = Number(groupRatioMap?.[group]); + if (Number.isFinite(ratio) && ratio < minRatio) { + minRatio = ratio; + } + }); + + return Number.isFinite(minRatio) ? minRatio : 1; +}; + +const buildCreativeCenterModelPriceLabel = ( + pricingModel, + activeGroup, + groupRatioMap, + currencyOptions = {}, +) => { + if (!pricingModel || typeof pricingModel !== 'object') { + return ''; + } + + const displayCurrency = resolveCreativeCenterDisplayCurrency( + currencyOptions.quotaDisplayType, + ); + const groupRatio = resolveCreativeCenterGroupRatio( + pricingModel, + activeGroup, + groupRatioMap, + ); + const activePricingGroup = + activeGroup && + Array.isArray(pricingModel.enable_groups) && + pricingModel.enable_groups.includes(activeGroup) + ? activeGroup + : null; + const prices = []; + const appendPrice = (value) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue < 0) { + return; + } + + const convertedValue = convertCreativeCenterUsdPrice( + numericValue, + displayCurrency, + currencyOptions, + ); + if (Number.isFinite(convertedValue)) { + prices.push(convertedValue); + } + }; + + if (pricingModel.quota_type === 0) { + const inputPrice = Number(pricingModel.model_ratio) * 2 * groupRatio; + appendPrice(inputPrice); + appendPrice(inputPrice * Number(pricingModel.completion_ratio)); + appendPrice(inputPrice * Number(pricingModel.cache_ratio)); + appendPrice(inputPrice * Number(pricingModel.create_cache_ratio)); + appendPrice(inputPrice * Number(pricingModel.image_ratio)); + appendPrice(inputPrice * Number(pricingModel.audio_ratio)); + appendPrice( + inputPrice * + Number(pricingModel.audio_ratio) * + Number(pricingModel.audio_completion_ratio), + ); + } else if (pricingModel.quota_type === 1) { + const groupModelPrice = + activePricingGroup && pricingModel.group_model_price?.[activePricingGroup] !== undefined + ? Number(pricingModel.group_model_price[activePricingGroup]) + : null; + appendPrice( + groupModelPrice !== null + ? groupModelPrice + : Number(pricingModel.model_price) * groupRatio, + ); + } else if (pricingModel.quota_type === 2) { + const groupSecondsPriceMap = activePricingGroup + ? pricingModel.group_model_price_by_seconds?.[activePricingGroup] + : null; + Object.values(groupSecondsPriceMap || pricingModel.model_price_by_seconds || {}).forEach( + (value) => { + appendPrice(Number(value) * (groupSecondsPriceMap ? 1 : groupRatio)); + }, + ); + } else if (pricingModel.quota_type === 3) { + const groupResolutionPriceMap = activePricingGroup + ? pricingModel.group_model_price_by_resolution?.[activePricingGroup] + : null; + Object.values(groupResolutionPriceMap || pricingModel.model_price_by_resolution || {}).forEach( + (value) => { + appendPrice(Number(value) * (groupResolutionPriceMap ? 1 : groupRatio)); + }, + ); + } + + if (prices.length === 0) { + return ''; + } + + const sortedPrices = [...new Set(prices.map((value) => Number(value.toFixed(8))))].sort( + (left, right) => left - right, + ); + const minPrice = sortedPrices[0]; + const maxPrice = sortedPrices[sortedPrices.length - 1]; + const symbol = getCreativeCenterCurrencySymbol( + displayCurrency, + currencyOptions.customCurrencySymbol, + ); + + if (!Number.isFinite(minPrice)) { + return ''; + } + + if (!Number.isFinite(maxPrice) || Math.abs(maxPrice - minPrice) < 0.000001) { + return `${symbol}${formatCreativeCenterPriceNumber(minPrice)}`; + } + + return `${symbol}${formatCreativeCenterPriceNumber(minPrice)}~${symbol}${formatCreativeCenterPriceNumber(maxPrice)}`; +}; + +const triggerDownload = (url, filename) => { + if (!url) { + return; + } + + const trimmedURL = String(url).trim(); + const downloadUrl = trimmedURL.startsWith('data:') + ? trimmedURL + : `${API_ENDPOINTS.CREATIVE_CENTER_MEDIA_DOWNLOAD}?url=${encodeURIComponent(trimmedURL)}&filename=${encodeURIComponent(filename || '')}`; + const link = document.createElement('a'); + link.href = downloadUrl; + link.rel = 'noopener noreferrer'; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +const escapePreviewHtml = (value) => + String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const openVideoPreviewInNewWindow = ( + url, + title = '视频预览', + promptText = '', +) => { + if (!url) { + return; + } + + const previewWindow = window.open('', '_blank'); + if (!previewWindow) { + return; + } + + const safeUrl = escapePreviewHtml(url); + const safeTitle = escapePreviewHtml(title); + const safePromptText = escapePreviewHtml(promptText || '未填写提示词'); + previewWindow.opener = null; + previewWindow.document.open(); + previewWindow.document.write(` + + + + + ${safeTitle} + + + +
+
+
+
${safeTitle}
+
${safePromptText}
+
+
+ +
+ +
+
+ + +`); + previewWindow.document.close(); +}; + +const normalizeVideoMediaUrl = (value) => { + if (typeof value !== 'string') { + return ''; + } + + const trimmedValue = value.trim(); + if ( + /^(https?:\/\/|blob:|data:video\/|\/(?!\/))/i.test(trimmedValue) + ) { + return trimmedValue; + } + + return ''; +}; + +const getVideoTaskMediaUrl = (task) => { + const directUrl = normalizeVideoMediaUrl(task?.url); + if (directUrl) { + return directUrl; + } + + const resultUrl = + normalizeVideoMediaUrl(task?.resultUrl) || + normalizeVideoMediaUrl(task?.result_url); + if (resultUrl) { + return resultUrl; + } + + return ''; +}; + +const formatCreativeRecordTime = (timestamp) => { + const date = new Date(Number(timestamp) || 0); + if (Number.isNaN(date.getTime()) || date.getTime() <= 0) { + return ''; + } + const pad = (value) => String(value).padStart(2, '0'); + return `${date.getFullYear()}年${pad(date.getMonth() + 1)}月${pad(date.getDate())}日 ${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +const buildCreativePersistSignature = (records, taskType) => + JSON.stringify( + (records || []).map((record) => ({ + id: record?.id || '', + prompt: record?.prompt || '', + modelName: record?.modelName || '', + group: record?.group || '', + params: record?.params || {}, + sourceImages: Array.isArray(record?.sourceImages) + ? record.sourceImages + .map((item, sourceImageIndex) => + createPersistedSourceImageItem(item, sourceImageIndex), + ) + .filter(Boolean) + : [], + items: + taskType === 'video' + ? (record?.tasks || []).map((item) => ({ + ...createPersistedVideoTaskItem(item), + })) + : (record?.images || []).map((item) => ({ + ...createPersistedImageTaskItem(item), + })), + })), + ); + +const buildCreativeReconcileSignature = (sessionId, records, taskType) => + `${sessionId || 'no-session'}:${buildCreativePersistSignature(records, taskType)}`; + +const createCreativeRecordId = (prefix) => + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const getImageTaskMediaUrl = (item) => { + if (typeof item?.url === 'string' && item.url.trim()) { + return item.url.trim(); + } + if (typeof item?.resultUrl === 'string' && item.resultUrl.trim()) { + return item.resultUrl.trim(); + } + if (typeof item?.result_url === 'string' && item.result_url.trim()) { + return item.result_url.trim(); + } + return ''; +}; + +const getRecoverableVideoTaskId = (task) => { + const rawTaskId = String(task?.taskId || task?.task_id || '').trim(); + if (rawTaskId.startsWith('task_')) { + return rawTaskId; + } + + const fallbackId = String(task?.id || '').trim(); + if (fallbackId.startsWith('task_')) { + return fallbackId; + } + + return ''; +}; + +const getRecoverableImageTaskId = (task) => { + const rawTaskId = String(task?.taskId || task?.task_id || '').trim(); + if (rawTaskId.startsWith('task_')) { + return rawTaskId; + } + return ''; +}; + +const normalizeCreativeTimestampToSeconds = (value) => { + const numericValue = Number(value) || 0; + if (numericValue <= 0) { + return 0; + } + return numericValue > 9999999999 + ? Math.floor(numericValue / 1000) + : Math.floor(numericValue); +}; + +const getTaskDtoResultUrl = (task) => { + if (typeof task?.result_url === 'string' && task.result_url.trim()) { + return task.result_url.trim(); + } + + if (typeof task?.resultUrl === 'string' && task.resultUrl.trim()) { + return task.resultUrl.trim(); + } + + return ''; +}; + +const getTaskDtoRequestId = (task) => { + if (typeof task?.request_id === 'string' && task.request_id.trim()) { + return task.request_id.trim(); + } + if (typeof task?.requestId === 'string' && task.requestId.trim()) { + return task.requestId.trim(); + } + return ''; +}; + +const getTaskDtoModelName = (task) => { + const properties = task?.properties; + if (properties && typeof properties === 'object') { + const candidate = String( + properties.origin_model_name || + properties.originModelName || + properties.upstream_model_name || + properties.upstreamModelName || + '', + ).trim(); + if (candidate) { + return candidate; + } + } + + const data = task?.data; + if (data && typeof data === 'object') { + const candidate = String(data.model || '').trim(); + if (candidate) { + return candidate; + } + } + + return ''; +}; + +const normalizeTaskDtoDataPayload = (task) => { + const rawData = task?.data; + if (!rawData) { + return null; + } + if (typeof rawData === 'string') { + try { + return JSON.parse(rawData); + } catch (error) { + return null; + } + } + if (typeof rawData === 'object') { + return rawData; + } + return null; +}; + +const parseTaskDtoVideoState = (task) => { + const taskId = String(task?.task_id || task?.taskId || '').trim(); + const url = normalizeVideoMediaUrl(getTaskDtoResultUrl(task)); + const normalizedStatus = normalizeVideoTaskStatus(task?.status || ''); + const completedWithoutVideo = !url && normalizedStatus === 'completed'; + const isFailed = normalizedStatus === 'failed' || completedWithoutVideo; + const isCompleted = Boolean(url) && !isFailed; + const progress = + parseProgressValue(task?.progress) ?? (isCompleted || isFailed ? 100 : 0); + const submitTime = normalizeCreativeTimestampToSeconds( + task?.submit_time || task?.submitTime || task?.created_at || task?.createdAt, + ); + const shouldPromotePendingToGenerating = + !isCompleted && + !isFailed && + ['submitted', 'queued'].includes(normalizedStatus) && + submitTime > 0 && + Date.now() - submitTime * 1000 >= CREATIVE_CENTER_VIDEO_PENDING_TO_GENERATING_MS; + const resolvedStatus = isCompleted + ? 'completed' + : isFailed + ? 'failed' + : shouldPromotePendingToGenerating + ? 'generating' + : normalizedStatus; + + return { + taskId, + status: resolvedStatus, + progress, + url, + content: '', + error: + completedWithoutVideo + ? '任务生成失败' + : typeof task?.fail_reason === 'string' + ? task.fail_reason + : typeof task?.failReason === 'string' + ? task.failReason + : '', + }; +}; + +const isTerminalVideoTaskStatus = (status) => { + const normalizedStatus = normalizeVideoTaskStatus(status); + return normalizedStatus === 'completed' || normalizedStatus === 'failed'; +}; + +const isTerminalImageTaskStatus = (status) => { + const normalizedStatus = String(status || '').trim().toLowerCase(); + return normalizedStatus === 'completed' || normalizedStatus === 'failed'; +}; + +const parseImageFetchPayload = (rawResponse) => { + const rootPayload = + rawResponse?.data && + (rawResponse?.headers || typeof rawResponse?.status === 'number') + ? rawResponse.data + : rawResponse; + const dataPayload = + rootPayload && typeof rootPayload === 'object' && rootPayload.data && typeof rootPayload.data === 'object' + ? rootPayload.data + : rootPayload; + + if (!dataPayload || typeof dataPayload !== 'object') { + return { + status: 'submitted', + progress: null, + url: '', + error: '', + }; + } + + const status = String( + dataPayload.status || + dataPayload.task_status || + dataPayload.state || + rootPayload?.status || + 'submitted', + ) + .trim() + .toLowerCase(); + const progress = + parseProgressValue(dataPayload.progress) ?? + parseProgressValue(rootPayload?.progress); + const imageUrls = extractImageUrlsFromCreativeResponse(dataPayload); + const rootImageUrls = + dataPayload === rootPayload ? [] : extractImageUrlsFromCreativeResponse(rootPayload); + const url = + imageUrls[0] || + rootImageUrls[0] || + dataPayload.result_url || + dataPayload.resultUrl || + rootPayload?.result_url || + rootPayload?.resultUrl || + ''; + const error = + dataPayload.error?.message || + dataPayload.fail_reason || + rootPayload?.error?.message || + ''; + + return { + status, + progress, + url: typeof url === 'string' ? url.trim() : '', + error, + }; +}; + +const buildResolvedImageTaskPatch = (queryTaskId, nextTaskState) => (currentTask) => { + const normalizedStatus = String( + nextTaskState?.status || currentTask?.status || 'submitted', + ) + .trim() + .toLowerCase(); + const resolvedUrl = + (typeof nextTaskState?.url === 'string' ? nextTaskState.url.trim() : '') || + getImageTaskMediaUrl(currentTask); + const isFailed = normalizedStatus === 'failed'; + const isCompleted = Boolean(resolvedUrl) && !isFailed; + + return { + taskId: queryTaskId || currentTask?.taskId || '', + status: isCompleted ? 'completed' : isFailed ? 'failed' : normalizedStatus, + progress: + isCompleted || isFailed + ? 100 + : nextTaskState?.progress ?? currentTask?.progress ?? 0, + url: isCompleted ? resolvedUrl : currentTask?.url || '', + resultUrl: isCompleted ? resolvedUrl : currentTask?.resultUrl || '', + error: isFailed + ? nextTaskState?.error || currentTask?.error || '图片生成失败' + : '', + finalizingAt: 0, + progressUnavailable: false, + requestPollable: Boolean(queryTaskId || currentTask?.taskId) && !isTerminalImageTaskStatus(isCompleted ? 'completed' : normalizedStatus), + }; +}; + +const buildResolvedVideoTaskPatch = (queryTaskId, nextTaskState) => (currentTask) => { + const normalizedStatus = normalizeVideoTaskStatus( + nextTaskState?.status || currentTask?.status || '', + ); + const resolvedUrl = normalizeVideoMediaUrl(nextTaskState?.url); + const currentMediaUrl = getVideoTaskMediaUrl(currentTask); + const currentTaskId = getRecoverableVideoTaskId(currentTask); + const canReuseCurrentMediaUrl = + Boolean(currentMediaUrl) && + (!queryTaskId || currentTaskId === queryTaskId); + const safeFinalUrl = resolvedUrl || (canReuseCurrentMediaUrl ? currentMediaUrl : ''); + const completedWithoutVideo = normalizedStatus === 'completed' && !safeFinalUrl; + const isFailed = normalizedStatus === 'failed' || completedWithoutVideo; + const isCompleted = Boolean(safeFinalUrl) && !isFailed; + const nextStatus = isCompleted + ? 'completed' + : isFailed + ? 'failed' + : normalizedStatus; + const nextUrl = isFailed ? '' : safeFinalUrl; + + return { + taskId: queryTaskId || currentTask?.taskId || '', + status: nextStatus, + progress: + isCompleted || isFailed + ? 100 + : nextTaskState?.progress ?? currentTask?.progress ?? 0, + url: nextUrl, + resultUrl: nextUrl, + content: + typeof nextTaskState?.content === 'string' + ? nextTaskState.content + : currentTask?.content || '', + error: isFailed + ? nextTaskState?.error || currentTask?.error || '任务生成失败' + : '', + finalizingAt: 0, + requestPollable: false, + pollable: Boolean(queryTaskId || currentTask?.taskId) && !isTerminalVideoTaskStatus(nextStatus), + }; +}; + +const buildResolvedVideoTaskIdPatch = (queryTaskId) => (currentTask) => ({ + taskId: queryTaskId || currentTask?.taskId || '', + requestPollable: false, + pollable: + Boolean(queryTaskId || currentTask?.taskId) && + !isTerminalVideoTaskStatus(currentTask?.status) && + !Boolean(getVideoTaskMediaUrl(currentTask)), +}); + +const getTaskDtoImageUrls = (task) => { + const dataPayload = normalizeTaskDtoDataPayload(task); + const urls = []; + const appendUniqueUrl = (candidate) => { + if (typeof candidate !== 'string') { + return; + } + const trimmedCandidate = candidate.trim(); + if (!trimmedCandidate || urls.includes(trimmedCandidate)) { + return; + } + urls.push(trimmedCandidate); + }; + + appendUniqueUrl(getTaskDtoResultUrl(task)); + + const items = Array.isArray(dataPayload?.data) + ? dataPayload.data + : Array.isArray(dataPayload) + ? dataPayload + : []; + + items.forEach((item) => { + if (typeof item?.url === 'string' && item.url.trim()) { + appendUniqueUrl(item.url.trim()); + } + if (typeof item?.b64_json === 'string' && item.b64_json.trim()) { + appendUniqueUrl(`data:image/png;base64,${item.b64_json.trim()}`); + } + if (typeof item?.b64Json === 'string' && item.b64Json.trim()) { + appendUniqueUrl(`data:image/png;base64,${item.b64Json.trim()}`); + } + }); + + const messageContent = dataPayload?.choices?.[0]?.message?.content; + if (typeof messageContent === 'string') { + extractImageUrlsFromMessage(messageContent).forEach(appendUniqueUrl); + } else if (Array.isArray(messageContent)) { + messageContent.forEach((item) => { + if (item?.type === 'image_url') { + if (typeof item?.image_url === 'string' && item.image_url.trim()) { + appendUniqueUrl(item.image_url.trim()); + } else if (typeof item?.image_url?.url === 'string' && item.image_url.url.trim()) { + appendUniqueUrl(item.image_url.url.trim()); + } + return; + } + + const textContent = + typeof item?.text === 'string' + ? item.text + : (typeof item?.content === 'string' ? item.content : ''); + extractImageUrlsFromMessage(textContent).forEach(appendUniqueUrl); + }); + } + + return urls; +}; + +const getCreativeRequestErrorMessage = (error) => { + const responseData = error?.response?.data; + + if ( + typeof responseData?.error?.message === 'string' && + responseData.error.message.trim() + ) { + return responseData.error.message.trim(); + } + + if (typeof responseData?.message === 'string' && responseData.message.trim()) { + return responseData.message.trim(); + } + + if (typeof responseData === 'string' && responseData.trim()) { + return responseData.trim(); + } + + if (typeof error?.message === 'string' && error.message.trim()) { + return error.message.trim(); + } + + return '请稍后再试。'; +}; + +const shouldTreatCreativeRequestErrorAsRecoverable = (error) => { + const statusCode = Number(error?.response?.status) || 0; + const responseData = error?.response?.data; + const errorCode = String( + responseData?.error?.code || responseData?.code || '', + ) + .trim() + .toLowerCase(); + const errorType = String( + responseData?.error?.type || responseData?.type || '', + ) + .trim() + .toLowerCase(); + const errorMessage = getCreativeRequestErrorMessage(error).toLowerCase(); + + if ( + errorCode === 'insufficient_user_quota' || + errorCode === 'pre_consume_token_quota_failed' || + errorType === 'insufficient_quota' || + errorType === 'insufficient_user_quota' || + errorMessage.includes('用户额度不足') || + errorMessage.includes('订阅额度不足') || + errorMessage.includes('额度不足') || + errorMessage.includes('subscription quota insufficient') || + errorMessage.includes('user quota is not enough') || + errorMessage.includes('token quota is not enough') || + errorMessage.includes('insufficient quota') + ) { + return false; + } + + if (!statusCode) { + return true; + } + + if ([408, 409, 425, 429].includes(statusCode)) { + return true; + } + + if (statusCode >= 500) { + return true; + } + + return false; +}; + +const buildRecoverableImageCandidateKey = (candidate) => + `${candidate.sessionId}:${candidate.recordId}:${candidate.imageId}`; + +const normalizeImageTaskItem = (item, index = 0) => { + if (typeof item === 'string') { + return { + id: createCreativeRecordId(`image-task-${index}`), + url: item, + status: 'completed', + progress: 100, + error: '', + }; + } + + const resolvedImageUrl = getImageTaskMediaUrl(item); + const progress = + parseProgressValue(item?.progress) ?? (resolvedImageUrl ? 100 : 0); + const normalizedStatus = + item?.status || (resolvedImageUrl ? 'completed' : 'pending'); + + return { + id: item?.id || createCreativeRecordId(`image-task-${index}`), + taskId: item?.taskId || item?.task_id || '', + url: resolvedImageUrl, + status: resolvedImageUrl ? 'completed' : normalizedStatus, + progress, + error: item?.error || '', + resultUrl: + typeof item?.resultUrl === 'string' + ? item.resultUrl + : (typeof item?.result_url === 'string' ? item.result_url : ''), + requestId: typeof item?.requestId === 'string' ? item.requestId : '', + submittedAt: parseTimestampValue( + item?.submittedAt || item?.submitted_at, + 0, + ), + estimateStartAt: parseTimestampValue( + item?.estimateStartAt || item?.estimate_start_at, + 0, + ), + finalizingAt: parseTimestampValue( + item?.finalizingAt || item?.finalizing_at, + 0, + ), + requestPollable: + typeof item?.requestPollable === 'boolean' + ? item.requestPollable + : !resolvedImageUrl && + !['completed', 'failed'].includes(normalizedStatus), + }; +}; + +const normalizeVideoTaskItem = (item, index = 0) => { + const resolvedVideoUrl = getVideoTaskMediaUrl(item); + const normalizedStatus = normalizeVideoTaskStatus( + item?.status || (resolvedVideoUrl ? 'completed' : 'submitted'), + ); + const completedWithoutVideo = + !resolvedVideoUrl && normalizedStatus === 'completed'; + const recoveredTaskId = getRecoverableVideoTaskId(item); + const progress = + parseProgressValue(item?.progress) ?? + ((resolvedVideoUrl || completedWithoutVideo || normalizedStatus === 'failed') + ? 100 + : 0); + const resolvedStatus = resolvedVideoUrl + ? 'completed' + : completedWithoutVideo + ? 'failed' + : normalizedStatus; + + return { + id: item?.id || createCreativeRecordId(`video-task-${index}`), + taskId: item?.taskId || item?.task_id || item?.id || '', + status: resolvedStatus, + url: resolvedVideoUrl, + content: item?.content || '', + progress, + error: + item?.error || + (completedWithoutVideo ? '任务生成失败' : ''), + resultUrl: item?.resultUrl || '', + resultContent: item?.resultContent || '', + requestId: item?.requestId || '', + submittedAt: parseTimestampValue( + item?.submittedAt || item?.submitted_at, + 0, + ), + estimateStartAt: parseTimestampValue( + item?.estimateStartAt || item?.estimate_start_at, + 0, + ), + finalizingAt: parseTimestampValue( + item?.finalizingAt || item?.finalizing_at, + 0, + ), + requestPollable: + typeof item?.requestPollable === 'boolean' + ? item.requestPollable + : !resolvedVideoUrl && + !recoveredTaskId && + Boolean(String(item?.requestId || '').trim()) && + ACTIVE_VIDEO_POLL_STATUSES.has(normalizedStatus), + pollable: + typeof item?.pollable === 'boolean' + ? (resolvedVideoUrl ? false : item.pollable) + : !resolvedVideoUrl && ACTIVE_VIDEO_POLL_STATUSES.has(normalizedStatus), + }; +}; + +const getTaskStatusLabel = (status) => { + switch (status) { + case 'completed': + return '已完成'; + case 'failed': + return '失败'; + case 'queued': + return '排队中'; + case 'submitted': + return '已提交'; + case 'finalizing': + return '整理结果中'; + case 'generating': + case 'processing': + case 'in_progress': + case 'pending': + default: + return '生成中'; + } +}; + +const normalizeImageHistoryRecords = (snapshot) => { + const payload = snapshot?.payload || {}; + + if (Array.isArray(payload?.entries)) { + return payload.entries.map((entry, index) => { + const images = Array.isArray(entry?.images) + ? entry.images + .filter(Boolean) + .map((item, imageIndex) => normalizeImageTaskItem(item, imageIndex)) + : []; + const summary = summarizeImageTasks(images); + + return { + id: entry?.id || createCreativeRecordId(`image-history-${index}`), + prompt: entry?.prompt || '', + modelName: entry?.modelName || entry?.model_name || snapshot?.model_name || '', + params: entry?.params && typeof entry.params === 'object' ? entry.params : {}, + sourceImages: Array.isArray(entry?.sourceImages || entry?.source_images) + ? (entry?.sourceImages || entry?.source_images) + .map((item, sourceImageIndex) => + normalizeCreativeSourceImageItem(item, sourceImageIndex), + ) + .filter(Boolean) + : [], + group: entry?.group || snapshot?.group || '', + status: summary.status, + images, + error: entry?.error || '', + total: Number(entry?.total) || images.length, + completedCount: summary.completedCount, + successCount: summary.successCount, + createdAt: entry?.createdAt || entry?.created_at || snapshot?.updated_at || Date.now(), + updatedAt: entry?.updatedAt || entry?.updated_at || snapshot?.updated_at || Date.now(), + }; + }); + } + + if (Array.isArray(payload?.images) && payload.images.length > 0) { + return [ + { + id: createCreativeRecordId('image-history'), + prompt: snapshot?.prompt || '', + modelName: snapshot?.model_name || '', + params: payload?.params && typeof payload.params === 'object' ? payload.params : {}, + sourceImages: Array.isArray(payload?.sourceImages || payload?.source_images) + ? (payload?.sourceImages || payload?.source_images) + .map((item, sourceImageIndex) => + normalizeCreativeSourceImageItem(item, sourceImageIndex), + ) + .filter(Boolean) + : [], + group: snapshot?.group || '', + status: 'completed', + images: payload.images + .filter(Boolean) + .map((item, imageIndex) => normalizeImageTaskItem(item, imageIndex)), + error: '', + total: payload.images.length, + completedCount: payload.images.length, + successCount: payload.images.length, + createdAt: snapshot?.updated_at || Date.now(), + updatedAt: snapshot?.updated_at || Date.now(), + }, + ]; + } + + return []; +}; + +const normalizeVideoHistoryRecords = (snapshot) => { + const payload = snapshot?.payload || {}; + + if (Array.isArray(payload?.entries)) { + return payload.entries.map((entry, index) => { + const tasks = Array.isArray(entry?.tasks) + ? entry.tasks.map((item, taskIndex) => normalizeVideoTaskItem(item, taskIndex)) + : []; + const summary = summarizeVideoTasks(tasks); + + return { + id: entry?.id || createCreativeRecordId(`video-history-${index}`), + prompt: entry?.prompt || '', + modelName: entry?.modelName || entry?.model_name || snapshot?.model_name || '', + params: entry?.params && typeof entry.params === 'object' ? entry.params : {}, + sourceImages: Array.isArray(entry?.sourceImages || entry?.source_images) + ? (entry?.sourceImages || entry?.source_images) + .map((item, sourceImageIndex) => + normalizeCreativeSourceImageItem(item, sourceImageIndex), + ) + .filter(Boolean) + : [], + group: entry?.group || snapshot?.group || '', + status: summary.status, + tasks, + error: entry?.error || '', + total: Number(entry?.total) || tasks.length, + completedCount: summary.completedCount, + successCount: summary.successCount, + createdAt: entry?.createdAt || entry?.created_at || snapshot?.updated_at || Date.now(), + updatedAt: entry?.updatedAt || entry?.updated_at || snapshot?.updated_at || Date.now(), + }; + }); + } + + if (Array.isArray(payload?.tasks) && payload.tasks.length > 0) { + const tasks = payload.tasks.map((item, taskIndex) => + normalizeVideoTaskItem(item, taskIndex), + ); + const summary = summarizeVideoTasks(tasks); + return [ + { + id: createCreativeRecordId('video-history'), + prompt: snapshot?.prompt || '', + modelName: snapshot?.model_name || '', + params: payload?.params && typeof payload.params === 'object' ? payload.params : {}, + sourceImages: Array.isArray(payload?.sourceImages || payload?.source_images) + ? (payload?.sourceImages || payload?.source_images) + .map((item, sourceImageIndex) => + normalizeCreativeSourceImageItem(item, sourceImageIndex), + ) + .filter(Boolean) + : [], + status: summary.status, + tasks, + error: + summary.completedCount === tasks.length && summary.successCount === 0 + ? '全部视频任务都生成失败了,请稍后重试。' + : '', + total: payload.tasks.length, + completedCount: summary.completedCount, + successCount: summary.successCount, + createdAt: snapshot?.updated_at || Date.now(), + updatedAt: snapshot?.updated_at || Date.now(), + }, + ]; + } + + return []; +}; + +const applyVideoTaskPatchToRecords = (records, recordId, taskId, taskPatch) => { + let recordsChanged = false; + + const nextRecords = (records || []).map((record) => { + if (record.id !== recordId) { + return record; + } + + let hasChanged = false; + const nextTasks = record.tasks.map((task) => { + if (task.id !== taskId) { + return task; + } + + hasChanged = true; + const nextTask = { + ...task, + ...(typeof taskPatch === 'function' ? taskPatch(task) : taskPatch), + }; + return { + ...nextTask, + status: normalizeVideoTaskStatus(nextTask.status), + }; + }); + + if (!hasChanged) { + return record; + } + + recordsChanged = true; + const summary = summarizeVideoTasks(nextTasks); + return { + ...record, + tasks: nextTasks, + ...summary, + error: + summary.completedCount === record.total && summary.successCount === 0 + ? '全部视频任务都生成失败了,请稍后重试。' + : '', + updatedAt: Date.now(), + }; + }); + + return { + nextRecords: recordsChanged ? nextRecords : records, + hasChanged: recordsChanged, + }; +}; + +const applyImageTaskPatchToRecords = (records, recordId, imageId, taskPatch) => { + let recordsChanged = false; + + const nextRecords = (records || []).map((record) => { + if (record.id !== recordId) { + return record; + } + + let hasChanged = false; + const nextImages = record.images.map((image) => { + if (image.id !== imageId) { + return image; + } + + hasChanged = true; + return { + ...image, + ...(typeof taskPatch === 'function' ? taskPatch(image) : taskPatch), + }; + }); + + if (!hasChanged) { + return record; + } + + recordsChanged = true; + const summary = summarizeImageTasks(nextImages); + return { + ...record, + images: nextImages, + ...summary, + error: + summary.completedCount === record.total && summary.successCount === 0 + ? '全部图片任务都生成失败了,请稍后重试。' + : '', + updatedAt: Date.now(), + }; + }); + + return { + nextRecords: recordsChanged ? nextRecords : records, + hasChanged: recordsChanged, + }; +}; + +const collectRecoverableImageCandidatesFromSnapshot = (snapshot) => { + const normalizedSnapshot = normalizeCreativeHistorySnapshot('image', snapshot); + const sessions = normalizedSnapshot?.payload?.sessions || []; + + return sessions + .flatMap((session) => { + const records = normalizeImageHistoryRecords(session); + return records.flatMap((record) => + record.images.map((image, imageIndex) => ({ + sessionId: session.id, + recordId: record.id, + imageId: image.id, + itemIndex: imageIndex, + queryTaskId: getRecoverableImageTaskId(image), + requestId: String(image?.requestId || '').trim(), + hasMedia: Boolean(getImageTaskMediaUrl(image)), + status: String(image?.status || '').trim().toLowerCase(), + recordModelName: String(record?.modelName || '').trim(), + recordCreatedAt: Number(record?.createdAt) || 0, + recordUpdatedAt: Number(record?.updatedAt) || 0, + sortTimestamp: + Number(image?.submittedAt) || + Number(record?.updatedAt) || + Number(record?.createdAt) || + Number(session?.updated_at) || + 0, + })), + ); + }) + .filter( + (item) => + !item.hasMedia && + !isTerminalImageTaskStatus(item.status) && + Boolean(item.queryTaskId || item.requestId), + ) + .sort((left, right) => right.sortTimestamp - left.sortTimestamp); +}; + +const patchImageTaskInHistorySnapshot = (snapshot, candidate, taskPatch) => { + const normalizedSnapshot = normalizeCreativeHistorySnapshot('image', snapshot); + let snapshotChanged = false; + + const nextSessions = normalizedSnapshot.payload.sessions.map((session) => { + if (session.id !== candidate.sessionId) { + return session; + } + + const sessionRecords = normalizeImageHistoryRecords(session); + const { nextRecords, hasChanged } = applyImageTaskPatchToRecords( + sessionRecords, + candidate.recordId, + candidate.imageId, + taskPatch, + ); + + if (!hasChanged) { + return session; + } + + snapshotChanged = true; + return { + ...session, + updated_at: Date.now(), + payload: { + ...buildCreativeSessionPayload('image', session.payload), + entries: nextRecords, + }, + }; + }); + + return { + snapshot: snapshotChanged + ? { + ...normalizedSnapshot, + updated_at: Date.now(), + payload: { + ...normalizedSnapshot.payload, + sessions: nextSessions, + }, + } + : normalizedSnapshot, + hasChanged: snapshotChanged, + }; +}; + +const collectRecoverableVideoCandidatesFromSnapshot = (snapshot) => { + const normalizedSnapshot = normalizeCreativeHistorySnapshot('video', snapshot); + const sessions = normalizedSnapshot?.payload?.sessions || []; + + return sessions + .flatMap((session) => { + const records = normalizeVideoHistoryRecords(session); + return records.flatMap((record) => + record.tasks.map((task, taskIndex) => ({ + sessionId: session.id, + recordId: record.id, + taskId: task.id, + itemIndex: taskIndex, + queryTaskId: getRecoverableVideoTaskId(task), + requestId: String(task?.requestId || '').trim(), + hasMedia: Boolean(getVideoTaskMediaUrl(task)), + status: normalizeVideoTaskStatus(task.status), + recordModelName: String(record?.modelName || '').trim(), + recordCreatedAt: Number(record?.createdAt) || 0, + recordUpdatedAt: Number(record?.updatedAt) || 0, + sortTimestamp: + Number(task?.submittedAt) || + Number(record?.updatedAt) || + Number(record?.createdAt) || + Number(session?.updated_at) || + 0, + })), + ); + }) + .filter( + (task) => + !task.hasMedia && + !isTerminalVideoTaskStatus(task.status) && + Boolean( + task.queryTaskId || + task.requestId || + task.sortTimestamp || + task.recordCreatedAt || + task.recordUpdatedAt, + ), + ) + .sort((left, right) => right.sortTimestamp - left.sortTimestamp); +}; + +const patchVideoTaskInHistorySnapshot = (snapshot, candidate, taskPatch) => { + const normalizedSnapshot = normalizeCreativeHistorySnapshot('video', snapshot); + let snapshotChanged = false; + + const nextSessions = normalizedSnapshot.payload.sessions.map((session) => { + if (session.id !== candidate.sessionId) { + return session; + } + + const sessionRecords = normalizeVideoHistoryRecords(session); + const { nextRecords, hasChanged } = applyVideoTaskPatchToRecords( + sessionRecords, + candidate.recordId, + candidate.taskId, + taskPatch, + ); + + if (!hasChanged) { + return session; + } + + snapshotChanged = true; + return { + ...session, + updated_at: Date.now(), + payload: { + ...buildCreativeSessionPayload('video', session.payload), + entries: nextRecords, + }, + }; + }); + + return { + snapshot: snapshotChanged + ? { + ...normalizedSnapshot, + updated_at: Date.now(), + payload: { + ...normalizedSnapshot.payload, + sessions: nextSessions, + }, + } + : normalizedSnapshot, + hasChanged: snapshotChanged, + }; +}; + +const getEmptyCreativeSessionPayload = (tabKey) => { + if (tabKey === 'chat') { + return { messages: [] }; + } + return { + entries: [], + params: {}, + }; +}; + +const getDefaultCreativeSessionName = (tabKey, index = 1) => { + const tabLabelMap = { + chat: '对话', + image: '图片', + video: '视频', + }; + return `${tabLabelMap[tabKey] || '创作'}会话 ${index}`; +}; + +const hasCreativeSessionContent = (tabKey, payload) => { + if (!payload || typeof payload !== 'object') { + return false; + } + + if (tabKey === 'chat') { + return Array.isArray(payload.messages) && payload.messages.length > 0; + } + + return Array.isArray(payload.entries) && payload.entries.length > 0; +}; + +const createCreativeSessionSnapshot = (tabKey, overrides = {}) => { + const now = Date.now(); + return { + id: overrides.id || createCreativeRecordId(`${tabKey}-session`), + name: overrides.name || getDefaultCreativeSessionName(tabKey), + model_name: overrides.model_name || overrides.modelName || '', + group: overrides.group || '', + prompt: overrides.prompt || '', + payload: + overrides.payload && typeof overrides.payload === 'object' + ? overrides.payload + : getEmptyCreativeSessionPayload(tabKey), + created_at: overrides.created_at || overrides.createdAt || now, + updated_at: overrides.updated_at || overrides.updatedAt || now, + }; +}; + +const normalizeCreativeSessionSnapshot = ( + tabKey, + session, + fallbackSnapshot = null, + index = 0, +) => + createCreativeSessionSnapshot(tabKey, { + id: session?.id, + name: + session?.name || + session?.title || + getDefaultCreativeSessionName(tabKey, index + 1), + model_name: + session?.model_name || + session?.modelName || + fallbackSnapshot?.model_name || + '', + group: session?.group || fallbackSnapshot?.group || '', + prompt: session?.prompt || fallbackSnapshot?.prompt || '', + payload: + session?.payload && typeof session.payload === 'object' + ? session.payload + : getEmptyCreativeSessionPayload(tabKey), + created_at: + session?.created_at || + session?.createdAt || + fallbackSnapshot?.created_at || + fallbackSnapshot?.updated_at || + Date.now(), + updated_at: + session?.updated_at || + session?.updatedAt || + fallbackSnapshot?.updated_at || + Date.now(), + }); + +const normalizeCreativeHistorySnapshot = (tabKey, snapshot) => { + const rawPayload = + snapshot?.payload && typeof snapshot.payload === 'object' + ? snapshot.payload + : {}; + + let sessions = Array.isArray(rawPayload?.sessions) + ? rawPayload.sessions + .filter(Boolean) + .map((session, index) => + normalizeCreativeSessionSnapshot(tabKey, session, snapshot, index), + ) + : []; + + if (sessions.length === 0) { + const legacyPayload = + snapshot?.payload && typeof snapshot.payload === 'object' + ? snapshot.payload + : getEmptyCreativeSessionPayload(tabKey); + + if ( + snapshot || + hasCreativeSessionContent(tabKey, legacyPayload) || + snapshot?.model_name || + snapshot?.prompt + ) { + sessions = [ + normalizeCreativeSessionSnapshot( + tabKey, + { + name: getDefaultCreativeSessionName(tabKey, 1), + model_name: snapshot?.model_name || '', + group: snapshot?.group || '', + prompt: snapshot?.prompt || '', + payload: legacyPayload, + created_at: snapshot?.created_at, + updated_at: snapshot?.updated_at, + }, + snapshot, + 0, + ), + ]; + } + } + + if (sessions.length === 0) { + sessions = [createCreativeSessionSnapshot(tabKey, { name: getDefaultCreativeSessionName(tabKey, 1) })]; + } + + const requestedCurrentSessionId = + typeof rawPayload?.current_session_id === 'string' + ? rawPayload.current_session_id + : ''; + const currentSessionId = sessions.some( + (session) => session.id === requestedCurrentSessionId, + ) + ? requestedCurrentSessionId + : sessions[0]?.id || ''; + const currentSession = + sessions.find((session) => session.id === currentSessionId) || sessions[0] || null; + + return { + id: snapshot?.id || null, + tab: tabKey, + model_name: currentSession?.model_name || snapshot?.model_name || '', + group: currentSession?.group || snapshot?.group || '', + prompt: currentSession?.prompt || snapshot?.prompt || '', + payload: { + current_session_id: currentSessionId, + sessions, + }, + created_at: + snapshot?.created_at || currentSession?.created_at || Date.now(), + updated_at: + snapshot?.updated_at || currentSession?.updated_at || Date.now(), + }; +}; + +const getCreativeHistorySessions = (snapshot, tabKey) => + normalizeCreativeHistorySnapshot(tabKey, snapshot)?.payload?.sessions || []; + +const getCreativeCurrentSessionSnapshot = (snapshot, tabKey) => { + const normalizedSnapshot = normalizeCreativeHistorySnapshot(tabKey, snapshot); + return ( + normalizedSnapshot.payload.sessions.find( + (session) => session.id === normalizedSnapshot.payload.current_session_id, + ) || + normalizedSnapshot.payload.sessions[0] || + null + ); +}; + +const buildCreativeSessionPayload = (tabKey, payload) => + payload && typeof payload === 'object' + ? payload + : getEmptyCreativeSessionPayload(tabKey); + +const formatCreativeSessionMeta = (tabKey, session) => { + const payload = buildCreativeSessionPayload(tabKey, session?.payload); + + if (tabKey === 'chat') { + const messageCount = Array.isArray(payload.messages) ? payload.messages.length : 0; + return `${messageCount} 条消息`; + } + + const entryCount = Array.isArray(payload.entries) ? payload.entries.length : 0; + return `${entryCount} 条记录`; +}; + +const renderCreativeModelIcon = ( + channelType, + iconName, + fallbackTab, + vendorIconName = '', +) => { + if (iconName) { + return
{getLobeHubIcon(iconName, 20)}
; + } + + if (vendorIconName) { + return ( +
{getLobeHubIcon(vendorIconName, 20)}
+ ); + } + + const channelIcon = channelType ? getChannelIcon(channelType) : null; + if (channelIcon) { + return
{channelIcon}
; + } + + if (fallbackTab === 'image') { + return IM; + } + + if (fallbackTab === 'video') { + return ; + } + + return ; +}; + +const GPTIcon = ({ size = 24, className = '' }) => ( + + + +); + +const GrokIcon = ({ size = 24, className = '' }) => ( + + + + + +); + +const DropButton = ({ icon, label, open, onClick, children }) => ( +
+ + {children} +
+); + +const DropSelectButton = ({ + menuKey, + icon, + label, + value, + options, + openMenu, + setOpenMenu, + onSelect, + widthClass = 'w-40', +}) => { + if (!Array.isArray(options) || options.length === 0) { + return null; + } + + return ( + setOpenMenu(openMenu === menuKey ? null : menuKey)} + > + {openMenu === menuKey && ( +
+
+ {options.map((option) => ( + + ))} +
+
+ )} +
+ ); +}; + +export default function App() { + const [userState] = useContext(UserContext); + const [statusState] = useContext(StatusContext); + const [activeTab, setActiveTab] = useState('chat'); + const [activeModel, setActiveModel] = useState('chat1'); + const [hoveredSidebarModelId, setHoveredSidebarModelId] = useState(''); + const [prompt, setPrompt] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [chatMessages, setChatMessages] = useState([]); + const [imageRecords, setImageRecords] = useState([]); + const [videoRecords, setVideoRecords] = useState([]); + const [activeGroup, setActiveGroup] = useState(''); + const [modelsHydrated, setModelsHydrated] = useState(false); + const [historyLoaded, setHistoryLoaded] = useState(false); + const [openMenu, setOpenMenu] = useState(null); + const [params, setParams] = useState({ + generationCount: '1', + imageSize: '1024x1024', + aspectRatio: '1:1', + autoImageSize: '1024x1024', + outputResolution: '2K', + videoSize: '1280x720', + videoSeconds: '10', + videoQuality: '480p', + videoPreset: 'normal', + videoDuration: '4', + videoResolution: '1080p', + referenceMode: 'frame', + }); + + const textareaRef = useRef(null); + const scrollRef = useRef(null); + const fileInputRef = useRef(null); + const imagePollingTimerRef = useRef(null); + const imagePollingInFlightRef = useRef(new Set()); + const videoPollingTimerRef = useRef(null); + const videoPollingInFlightRef = useRef(new Set()); + const chatMessagesRef = useRef([]); + const imageRecordsRef = useRef([]); + const videoRecordsRef = useRef([]); + const uploadedImagesRef = useRef([]); + const creativeCenterUploadConfigRef = useRef(null); + const historyHydratedRef = useRef(false); + const lastPersistedImageSignatureRef = useRef(''); + const lastPersistedVideoSignatureRef = useRef(''); + const startupImageRecoveryRunRef = useRef(false); + const startupVideoRecoveryRunRef = useRef(false); + const creativeHistoryPersistWarningAtRef = useRef(0); + const creativeHistoryPersistBlockedUntilRef = useRef(0); + const lastActiveImageReconcileSignatureRef = useRef(''); + const lastActiveVideoReconcileSignatureRef = useRef(''); + const creativeImagePollingBlockedUntilRef = useRef(0); + const creativeVideoPollingBlockedUntilRef = useRef(0); + const isLoggedIn = Boolean(userState?.user); + const [uploadedImages, setUploadedImages] = useState([]); + const [uploadImageNotice, setUploadImageNotice] = useState(''); + const [isUploadDragActive, setIsUploadDragActive] = useState(false); + const isUploadingImage = uploadedImages.some((item) => item?.status === 'uploading'); + + useEffect(() => { + chatMessagesRef.current = chatMessages; + }, [chatMessages]); + + useEffect(() => { + imageRecordsRef.current = imageRecords; + }, [imageRecords]); + + useEffect(() => { + videoRecordsRef.current = videoRecords; + }, [videoRecords]); + + const syncImageRecordsState = (nextRecords) => { + imageRecordsRef.current = nextRecords; + setImageRecords(nextRecords); + }; + + const syncVideoRecordsState = (nextRecords) => { + videoRecordsRef.current = nextRecords; + setVideoRecords(nextRecords); + }; + + useEffect(() => { + uploadedImagesRef.current = uploadedImages; + }, [uploadedImages]); + + useEffect(() => { + creativeCenterUploadConfigRef.current = null; + }, [isLoggedIn]); + + useEffect(() => { + creativeHistoryPersistBlockedUntilRef.current = 0; + creativeImagePollingBlockedUntilRef.current = 0; + creativeVideoPollingBlockedUntilRef.current = 0; + }, [isLoggedIn]); + + const notifyCreativeHistoryPersistFailure = (tabKey) => { + const now = Date.now(); + if (now - creativeHistoryPersistWarningAtRef.current < 5000) { + return; + } + creativeHistoryPersistWarningAtRef.current = now; + showWarning( + tabKey === 'chat' + ? '对话记录保存失败,请稍后重试。' + : '创作中心记录保存失败,刷新后可能看不到最新结果。', + ); + }; + + useEffect(() => { + startupImageRecoveryRunRef.current = false; + }, [isLoggedIn]); + + useEffect(() => { + startupVideoRecoveryRunRef.current = false; + }, [isLoggedIn]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [activeTab, chatMessages, imageRecords, videoRecords, isGenerating]); + + useEffect(() => { + return () => { + if (imagePollingTimerRef.current) { + window.clearTimeout(imagePollingTimerRef.current); + } + if (videoPollingTimerRef.current) { + window.clearTimeout(videoPollingTimerRef.current); + } + imagePollingTimerRef.current = null; + videoPollingTimerRef.current = null; + imagePollingInFlightRef.current.clear(); + videoPollingInFlightRef.current.clear(); + uploadedImagesRef.current.forEach((item) => { + if (item?.previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(item.previewUrl); + } + }); + }; + }, []); + const fallbackModels = useMemo( + () => ({ + chat: [ + { + id: 'chat1', + name: 'GPT-4o', + desc: '通用旗舰模型,适合对话问答、写作整理与多场景创作。', + icon: renderCreativeModelIcon(1, '', 'chat'), + }, + ], + image: [ + { + id: 'img1', + name: 'FLUX', + desc: '高质量图片生成模型,适合海报、插画与视觉概念创作。', + icon: renderCreativeModelIcon(0, '', 'image'), + }, + ], + video: [ + { + id: 'v1', + name: 'grok-video-3-plus', + desc: '视频生成模型,适合生成短片分镜、动态概念与创意演示。', + icon: renderCreativeModelIcon(48, '', 'video'), + }, + ], + }), + [], + ); + + const [syncedModels, setSyncedModels] = useState({ + chat: [], + image: [], + video: [], + }); + const [pricingGroupRatio, setPricingGroupRatio] = useState({}); + const [historySnapshots, setHistorySnapshots] = useState(EMPTY_HISTORY_SNAPSHOTS); + const [isSessionPanelOpen, setIsSessionPanelOpen] = useState(false); + const [collapsedImageRecordIds, setCollapsedImageRecordIds] = useState({}); + const [selectedImageTaskIds, setSelectedImageTaskIds] = useState({}); + const [previewImage, setPreviewImage] = useState(null); + const [collapsedVideoRecordIds, setCollapsedVideoRecordIds] = useState({}); + const [selectedVideoTaskIds, setSelectedVideoTaskIds] = useState({}); + const [progressClock, setProgressClock] = useState(() => Date.now()); + const creativeCenterCurrencyOptions = useMemo( + () => ({ + quotaDisplayType: statusState?.status?.quota_display_type || 'USD', + usdExchangeRate: + statusState?.status?.usd_exchange_rate ?? + statusState?.status?.price ?? + 1, + customExchangeRate: + statusState?.status?.custom_currency_exchange_rate ?? 1, + customCurrencySymbol: + statusState?.status?.custom_currency_symbol ?? '¤', + }), + [statusState], + ); + const imagePersistSignature = useMemo( + () => buildCreativePersistSignature(imageRecords, 'image'), + [imageRecords], + ); + const videoPersistSignature = useMemo( + () => buildCreativePersistSignature(videoRecords, 'video'), + [videoRecords], + ); + const hasActiveEstimatedTasks = useMemo(() => { + if (activeTab === 'image') { + return imageRecords.some( + (record) => + shouldUseEstimatedImageProgress(record.modelName) && + record.images.some( + (task) => !['completed', 'failed'].includes(task.status || 'pending'), + ), + ); + } + + if (activeTab === 'video') { + return videoRecords.some( + (record) => + shouldUseEstimatedVideoProgress(record.modelName) && + record.tasks.some( + (task) => !['completed', 'failed'].includes(task.status || 'submitted'), + ), + ); + } + + return false; + }, [activeTab, imageRecords, videoRecords]); + + useEffect(() => { + if (!hasActiveEstimatedTasks) { + return undefined; + } + + const timer = window.setInterval(() => { + setProgressClock(Date.now()); + }, ESTIMATED_PROGRESS_TICK_MS); + + return () => { + window.clearInterval(timer); + }; + }, [hasActiveEstimatedTasks]); + + useEffect(() => { + let mounted = true; + setModelsHydrated(false); + + const tabTagMap = { + chat: ['文本', '对话', '聊天'], + image: ['图片'], + video: ['视频'], + }; + + const inferTabsFromModelName = (modelName) => { + const normalizedName = String(modelName || '').toLowerCase(); + const videoKeywords = [ + 'video', + 'veo', + 'sora', + 'kling', + 'runway', + 'pixverse', + 'hailuo', + 'wanx', + 'mov', + ]; + const imageKeywords = [ + 'image', + 'img', + 'imagen', + 'imagine', + 'flux', + 'stable-diffusion', + 'sdxl', + 'midjourney', + 'mj', + 'banana', + ]; + + if (videoKeywords.some((keyword) => normalizedName.includes(keyword))) { + return ['video']; + } + + if (imageKeywords.some((keyword) => normalizedName.includes(keyword))) { + return ['image']; + } + + return ['chat']; + }; + + const resolveTabsForModel = (modelName, model) => { + const tags = String(model?.tags || '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); + const endpointTypes = Array.isArray(model?.supported_endpoint_types) + ? model.supported_endpoint_types + : []; + const normalizedEndpoints = endpointTypes.map((type) => + String(type || '').toLowerCase(), + ); + + const matchedTabs = Object.entries(tabTagMap) + .filter(([, aliases]) => aliases.some((alias) => tags.includes(alias))) + .map(([tabKey]) => tabKey); + + if (matchedTabs.length > 0) { + return matchedTabs; + } + + if (normalizedEndpoints.some((endpoint) => endpoint.includes('video'))) { + return ['video']; + } + + if ( + normalizedEndpoints.some( + (endpoint) => + endpoint.includes('image') || endpoint.includes('images'), + ) + ) { + return ['image']; + } + + return inferTabsFromModelName(modelName); + }; + + const createModelCard = (model, tabKey, modelName, vendorMap) => { + const tags = String(model?.tags || '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); + const resolvedModelName = model?.model_name || model?.name || modelName || '未命名模型'; + const vendor = + model?.vendor_id && vendorMap[model.vendor_id] + ? vendorMap[model.vendor_id] + : null; + const resolvedDescription = + model?.description || + vendor?.description || + (tags.length > 0 ? `标签:${tags.join('、')}` : '来自模型管理'); + + return { + id: `${tabKey}:${resolvedModelName}`, + value: resolvedModelName, + name: resolvedModelName, + desc: resolvedDescription, + fullDesc: resolvedDescription, + pricingModel: model, + icon: renderCreativeModelIcon( + Number(model?.channel_type || 0), + model?.icon, + tabKey, + vendor?.icon, + ), + }; + }; + + const loadManagedModels = async () => { + try { + const [pricingResult, userModelsResult, userGroupsResult] = + await Promise.allSettled([ + API.get('/api/pricing', { skipErrorHandler: true }), + isLoggedIn + ? API.get(API_ENDPOINTS.USER_MODELS, { skipErrorHandler: true }) + : Promise.resolve({ data: { success: false, data: [] } }), + isLoggedIn + ? API.get(API_ENDPOINTS.USER_GROUPS, { skipErrorHandler: true }) + : Promise.resolve({ data: { success: false, data: {} } }), + ]); + + const pricingModels = + pricingResult.status === 'fulfilled' && pricingResult.value?.data?.success + ? (Array.isArray(pricingResult.value.data.data) + ? pricingResult.value.data.data + : []) + : []; + const pricingVendors = + pricingResult.status === 'fulfilled' && pricingResult.value?.data?.success + ? (Array.isArray(pricingResult.value.data.vendors) + ? pricingResult.value.data.vendors + : []) + : []; + + const userModels = + userModelsResult.status === 'fulfilled' && userModelsResult.value?.data?.success + ? (Array.isArray(userModelsResult.value.data.data) + ? userModelsResult.value.data.data + : []) + : []; + + const pricingModelMap = new Map(); + pricingModels.forEach((item) => { + const modelName = item?.model_name || item?.name; + if (modelName) { + pricingModelMap.set(modelName, item); + } + }); + + const pricingVendorMap = pricingVendors.reduce((map, vendor) => { + if (vendor?.id) { + map[vendor.id] = vendor; + } + return map; + }, {}); + + const visibleModelNames = + isLoggedIn && userModels.length > 0 + ? userModels + : pricingModels + .map((item) => item?.model_name || item?.name || '') + .filter(Boolean); + + const nextModels = { chat: [], image: [], video: [] }; + visibleModelNames.forEach((modelName) => { + const pricingModel = pricingModelMap.get(modelName); + const tabsForModel = resolveTabsForModel(modelName, pricingModel); + + tabsForModel.forEach((tabKey) => { + nextModels[tabKey].push( + createModelCard( + pricingModel || { model_name: modelName }, + tabKey, + modelName, + pricingVendorMap, + ), + ); + }); + }); + + const dedupedModels = Object.fromEntries( + Object.entries(nextModels).map(([tabKey, list]) => [ + tabKey, + list.filter( + (model, index, array) => + array.findIndex((item) => item.value === model.value) === index, + ), + ]), + ); + + let resolvedGroup = ''; + const localUserGroup = (() => { + try { + return JSON.parse(localStorage.getItem('user') || '{}')?.group || ''; + } catch { + return ''; + } + })(); + + if ( + isLoggedIn && + userGroupsResult.status === 'fulfilled' && + userGroupsResult.value?.data?.success + ) { + const groupOptions = processGroupsData( + userGroupsResult.value.data.data || {}, + localUserGroup, + ); + resolvedGroup = + groupOptions.find((group) => group.value === localUserGroup)?.value || + groupOptions[0]?.value || + localUserGroup; + } else { + resolvedGroup = localUserGroup; + } + + if (mounted) { + setSyncedModels(dedupedModels); + setPricingGroupRatio( + pricingResult.status === 'fulfilled' && + pricingResult.value?.data?.success && + pricingResult.value?.data?.group_ratio && + typeof pricingResult.value.data.group_ratio === 'object' + ? pricingResult.value.data.group_ratio + : {}, + ); + setActiveGroup(resolvedGroup); + } + } catch (error) { + console.error('Failed to sync creative center models:', error); + } finally { + if (mounted) { + setModelsHydrated(true); + } + } + }; + + loadManagedModels(); + + return () => { + mounted = false; + }; + }, [isLoggedIn]); + + const modelPools = useMemo( + () => ({ + chat: + syncedModels.chat.length > 0 + ? syncedModels.chat.map((model) => ({ + ...model, + priceLabel: buildCreativeCenterModelPriceLabel( + model.pricingModel, + activeGroup, + pricingGroupRatio, + creativeCenterCurrencyOptions, + ), + })) + : fallbackModels.chat, + image: + syncedModels.image.length > 0 + ? syncedModels.image.map((model) => ({ + ...model, + priceLabel: buildCreativeCenterModelPriceLabel( + model.pricingModel, + activeGroup, + pricingGroupRatio, + creativeCenterCurrencyOptions, + ), + })) + : fallbackModels.image, + video: + syncedModels.video.length > 0 + ? syncedModels.video.map((model) => ({ + ...model, + priceLabel: buildCreativeCenterModelPriceLabel( + model.pricingModel, + activeGroup, + pricingGroupRatio, + creativeCenterCurrencyOptions, + ), + })) + : fallbackModels.video, + }), + [ + activeGroup, + creativeCenterCurrencyOptions, + fallbackModels, + pricingGroupRatio, + syncedModels, + ], + ); + + const currentDisplayModels = modelPools[activeTab] || []; + const hoveredSidebarModel = + currentDisplayModels.find((model) => model.id === hoveredSidebarModelId) || null; + const currentTabHistorySnapshot = useMemo( + () => + historySnapshots[activeTab] + ? normalizeCreativeHistorySnapshot(activeTab, historySnapshots[activeTab]) + : null, + [activeTab, historySnapshots], + ); + const currentTabSessions = currentTabHistorySnapshot?.payload?.sessions || []; + const activeHistorySnapshot = useMemo( + () => + currentTabHistorySnapshot + ? getCreativeCurrentSessionSnapshot(currentTabHistorySnapshot, activeTab) + : null, + [activeTab, currentTabHistorySnapshot], + ); + const findModelCard = (tabKey, modelName) => + (modelPools[tabKey] || []).find( + (model) => model.value === modelName || model.name === modelName, + ) || null; + const selectedModel = + currentDisplayModels.find((model) => model.id === activeModel) || + currentDisplayModels[0] || + null; + const isCreativeCenterBootstrapping = !modelsHydrated || !historyLoaded; + const currentModelName = selectedModel?.value || selectedModel?.name || ''; + const isGrokImagineImageModel = + GROK_IMAGINE_IMAGE_MODELS.has(currentModelName); + const isGrokImageEditModel = GROK_IMAGE_EDIT_MODELS.has(currentModelName); + const isGrokImageGenerationModel = + GROK_IMAGE_GENERATION_MODELS.has(currentModelName); + const isAdobeImageModel = ADOBE_IMAGE_MODELS.has(currentModelName); + const isCurrentGPTImage2Model = isGPTImage2Model(currentModelName); + const isAdobeVideoModel = ADOBE_VIDEO_MODELS.has(currentModelName); + const isAdobeSoraModel = + currentModelName === 'sora2' || currentModelName === 'sora2-pro'; + const isAdobeVeoModel = + currentModelName === 'veo31' || + currentModelName === 'veo31-ref' || + currentModelName === 'veo31-fast'; + const isChatCompletionVideoModel = false; + const isChatTab = activeTab === 'chat'; + const isSubmitPending = (isChatTab && isGenerating) || isUploadingImage; + const isVideoModel = + typeof currentModelName === 'string' && currentModelName.includes('video'); + const isGrokImagineVideoModel = currentModelName === 'grok-imagine-1.0-video'; + const currentVideoSecondsOptions = isGrokImagineVideoModel + ? GROK_IMAGINE_VIDEO_SECONDS_OPTIONS + : GENERIC_VIDEO_SECONDS_OPTIONS; + const currentImageUploadLimit = getCreativeCenterImageUploadLimit(currentModelName); + const currentAdobeImageAspectRatioOptions = + getAdobeImageAspectRatioOptions(currentModelName); + const currentAdobeSupportsAutoImageSize = + supportsAdobeAutoImageSize(currentModelName); + const isCurrentModelImageUploadEnabled = isCreativeCenterImageUploadEnabled( + activeTab, + currentModelName, + ); + useEffect(() => { + if (!currentImageUploadLimit || uploadedImages.length <= currentImageUploadLimit) { + return; + } + + setUploadedImages((prev) => { + if (prev.length <= currentImageUploadLimit) { + return prev; + } + const removedItems = prev.slice(currentImageUploadLimit); + removedItems.forEach((item) => { + revokeCreativeCenterPreviewURL(item.previewUrl); + }); + return prev.slice(0, currentImageUploadLimit); + }); + setUploadImageNotice(`当前模型最多上传 ${currentImageUploadLimit} 张图片,已自动保留前 ${currentImageUploadLimit} 张`); + showWarning(`当前模型最多上传 ${currentImageUploadLimit} 张图片`); + }, [currentImageUploadLimit, uploadedImages.length]); + useEffect(() => { + if (isCurrentModelImageUploadEnabled || uploadedImages.length === 0) { + return; + } + + setIsUploadDragActive(false); + setUploadedImages((prev) => { + prev.forEach((item) => { + revokeCreativeCenterPreviewURL(item.previewUrl); + }); + return []; + }); + setUploadImageNotice('当前模型不支持上传图片,已清空已选图片'); + showWarning('当前模型不支持上传图片'); + }, [isCurrentModelImageUploadEnabled, uploadedImages.length]); + const renderPendingTaskProgress = ({ + task, + taskIndex, + modelName, + params: taskParams, + taskType, + detailText = '', + detailClassName = 'text-slate-400', + }) => { + const progressMeta = getEstimatedTaskProgress({ + task, + modelName, + params: taskParams, + taskType, + now: progressClock, + }); + const progressBarClass = + task.status === 'failed' ? 'bg-red-400' : 'bg-blue-500'; + + return ( +
+
+ 任务 {taskIndex + 1} + {progressMeta.progressText} +
+
+ {progressMeta.indeterminate ? ( +
+ ) : ( +
+ )} +
+

+ {detailText || progressMeta.statusText} +

+
+ ); + }; + const createEffectiveParamsSnapshot = ( + tabKey = activeTab, + modelName = currentModelName, + sourceParams = params, + ) => { + const snapshot = { + generationCount: sourceParams.generationCount, + }; + const isCurrentGrokImagineImageModel = + GROK_IMAGINE_IMAGE_MODELS.has(modelName); + const isCurrentGrokImageEditModel = GROK_IMAGE_EDIT_MODELS.has(modelName); + const isCurrentAdobeImageModel = ADOBE_IMAGE_MODELS.has(modelName); + const isCurrentAdobeVideoModel = ADOBE_VIDEO_MODELS.has(modelName); + const isCurrentAdobeSoraModel = + modelName === 'sora2' || modelName === 'sora2-pro'; + const isCurrentAdobeVeoModel = + modelName === 'veo31' || + modelName === 'veo31-ref' || + modelName === 'veo31-fast'; + const isCurrentVideoModel = + typeof modelName === 'string' && modelName.includes('video'); + const isCurrentGrokImagineVideoModel = modelName === 'grok-imagine-1.0-video'; + + if (tabKey === 'image') { + if (isCurrentGrokImagineImageModel && !isCurrentGrokImageEditModel) { + snapshot.imageSize = normalizeGrokImageSize(sourceParams.imageSize); + } + + if (isCurrentAdobeImageModel) { + const adobeAspectRatioOptions = getAdobeImageAspectRatioOptions(modelName); + const defaultAdobeAspectRatio = + adobeAspectRatioOptions[0]?.value || '1:1'; + snapshot.aspectRatio = sourceParams.aspectRatio || defaultAdobeAspectRatio; + if ( + supportsAdobeAutoImageSize(modelName) && + snapshot.aspectRatio === 'auto' + ) { + snapshot.autoImageSize = sourceParams.autoImageSize; + } + if (supportsAdobeImageOutputResolution(modelName)) { + snapshot.outputResolution = sourceParams.outputResolution || '2K'; + } + } + } + + if (tabKey === 'video') { + if (isCurrentVideoModel && !isCurrentAdobeVideoModel) { + snapshot.videoSize = sourceParams.videoSize; + snapshot.videoSeconds = sourceParams.videoSeconds; + snapshot.videoQuality = sourceParams.videoQuality; + if (isCurrentGrokImagineVideoModel) { + snapshot.videoPreset = sourceParams.videoPreset; + } + } + + if (isCurrentAdobeVideoModel) { + snapshot.videoDuration = + sourceParams.videoDuration || getAdobeVideoDefaultDuration(modelName); + snapshot.aspectRatio = + sourceParams.aspectRatio || getAdobeVideoDefaultAspectRatio(modelName); + if (isCurrentAdobeVeoModel) { + snapshot.videoResolution = sourceParams.videoResolution || '1080p'; + } + if (modelName === 'veo31') { + snapshot.referenceMode = sourceParams.referenceMode || 'frame'; + } + } + } + + return snapshot; + }; + const formatImageRecordSummary = (record) => { + const summary = []; + const recordParams = record?.params || {}; + + if (recordParams.aspectRatio && recordParams.aspectRatio !== 'auto') { + summary.push(recordParams.aspectRatio); + } + if (recordParams.imageSize) { + summary.push(getOptionLabel(GROK_IMAGE_SIZE_OPTIONS, recordParams.imageSize)); + } + if (recordParams.outputResolution) { + summary.push(recordParams.outputResolution); + } + if (Array.isArray(record?.images) && record.images.length > 0) { + summary.push( + `${record.images.filter((item) => item?.status === 'completed' && item?.url).length}张`, + ); + } + + return summary.join(' · '); + }; + + const formatVideoRecordSummary = (record) => { + const summary = []; + const recordParams = record?.params || {}; + + if (recordParams.videoDuration) { + summary.push(`${recordParams.videoDuration}s`); + } else if (recordParams.videoSeconds) { + summary.push(`${recordParams.videoSeconds}s`); + } + + if (recordParams.aspectRatio && recordParams.aspectRatio !== 'auto') { + summary.push(recordParams.aspectRatio); + } else if (recordParams.videoSize) { + summary.push(getOptionLabel(GENERIC_VIDEO_SIZE_OPTIONS, recordParams.videoSize)); + } + + if (recordParams.videoResolution) { + summary.push(recordParams.videoResolution); + } else if (recordParams.videoQuality) { + summary.push(recordParams.videoQuality); + } + + if (Array.isArray(record?.tasks) && record.tasks.length > 0) { + summary.push( + `${record.tasks.filter((item) => item?.status !== 'failed').length}条`, + ); + } + + return summary.join(' · '); + }; + +const resolveCreativeAspectRatio = (ratio, fallback = '3 / 4') => { + if (!ratio || ratio === 'auto' || typeof ratio !== 'string') { + return fallback; + } + const normalized = ratio.trim(); + if (!normalized.includes(':')) { + return fallback; + } + const [width, height] = normalized.split(':').map((item) => item.trim()); + if (!width || !height) { + return fallback; + } + return `${width} / ${height}`; +}; + +const getCreativeVideoCardAspectRatio = (record) => { + if (UNIFORM_CREATIVE_VIDEO_CARD_MODELS.has(record?.modelName || '')) { + return '9 / 16'; + } + return resolveCreativeAspectRatio(record?.params?.aspectRatio, '9 / 16'); +}; + +const getCreativeVideoCardObjectFitClass = (record) => + UNIFORM_CREATIVE_VIDEO_CARD_MODELS.has(record?.modelName || '') + ? 'object-contain' + : 'object-cover'; + + useEffect(() => { + if (!currentDisplayModels.some((model) => model.id === activeModel)) { + setActiveModel(currentDisplayModels[0]?.id || ''); + } + }, [activeModel, currentDisplayModels]); + + useEffect(() => { + if (!currentDisplayModels.some((model) => model.id === hoveredSidebarModelId)) { + setHoveredSidebarModelId(''); + } + }, [currentDisplayModels, hoveredSidebarModelId]); + + useEffect(() => { + setIsSessionPanelOpen(false); + }, [activeTab]); + + useEffect(() => { + const savedModelName = activeHistorySnapshot?.model_name; + if (!savedModelName || currentDisplayModels.length === 0) { + return; + } + + const matchedModel = currentDisplayModels.find( + (model) => + model.value === savedModelName || + model.name === savedModelName, + ); + if (matchedModel && matchedModel.id !== activeModel) { + setActiveModel(matchedModel.id); + } + }, [activeHistorySnapshot, activeTab, currentDisplayModels]); + + useEffect(() => { + const savedParams = activeHistorySnapshot?.payload?.params; + if (savedParams && typeof savedParams === 'object') { + setParams((prev) => ({ + ...prev, + ...savedParams, + })); + } + }, [activeHistorySnapshot, activeTab]); + + useEffect(() => { + setParams((prev) => { + const next = { ...prev }; + + if ( + isGrokImagineImageModel && + !GROK_IMAGE_SIZE_OPTIONS.some((option) => option.value === next.imageSize) + ) { + next.imageSize = '1024x1024'; + } + + if (isAdobeImageModel) { + const adobeAspectRatioOptions = + getAdobeImageAspectRatioOptions(currentModelName); + const defaultAdobeAspectRatio = + adobeAspectRatioOptions[0]?.value || '1:1'; + if ( + !adobeAspectRatioOptions.some( + (option) => option.value === next.aspectRatio, + ) + ) { + next.aspectRatio = defaultAdobeAspectRatio; + } + if ( + supportsAdobeAutoImageSize(currentModelName) && + !ADOBE_AUTO_IMAGE_SIZE_OPTIONS.some( + (option) => option.value === next.autoImageSize, + ) + ) { + next.autoImageSize = '1024x1024'; + } + if (supportsAdobeImageOutputResolution(currentModelName)) { + if ( + !ADOBE_OUTPUT_RESOLUTION_OPTIONS.some( + (option) => option.value === next.outputResolution, + ) + ) { + next.outputResolution = '2K'; + } + } + } + + if (isVideoModel && !isAdobeVideoModel) { + if ( + !GENERIC_VIDEO_SIZE_OPTIONS.some( + (option) => option.value === next.videoSize, + ) + ) { + next.videoSize = '1280x720'; + } + if ( + !currentVideoSecondsOptions.some( + (option) => option.value === next.videoSeconds, + ) + ) { + next.videoSeconds = currentVideoSecondsOptions[0]?.value || '10'; + } + if ( + !GENERIC_VIDEO_QUALITY_OPTIONS.some( + (option) => option.value === next.videoQuality, + ) + ) { + next.videoQuality = '480p'; + } + if ( + !GROK_VIDEO_PRESET_OPTIONS.some( + (option) => option.value === next.videoPreset, + ) + ) { + next.videoPreset = 'normal'; + } + } + + if (isAdobeVideoModel) { + const durationOptions = getAdobeVideoDurationOptions(currentModelName); + const aspectRatioOptions = getAdobeVideoAspectRatioOptions(currentModelName); + if ( + !durationOptions.some((option) => option.value === next.videoDuration) + ) { + next.videoDuration = getAdobeVideoDefaultDuration(currentModelName); + } + if ( + !aspectRatioOptions.some((option) => option.value === next.aspectRatio) + ) { + next.aspectRatio = getAdobeVideoDefaultAspectRatio(currentModelName); + } + if ( + isAdobeVeoModel && + !ADOBE_VIDEO_RESOLUTION_OPTIONS.some( + (option) => option.value === next.videoResolution, + ) + ) { + next.videoResolution = '1080p'; + } + if ( + currentModelName === 'veo31' && + !ADOBE_REFERENCE_MODE_OPTIONS.some( + (option) => option.value === next.referenceMode, + ) + ) { + next.referenceMode = 'frame'; + } + } + + return JSON.stringify(next) === JSON.stringify(prev) ? prev : next; + }); + }, [ + currentModelName, + isAdobeImageModel, + isAdobeVeoModel, + isAdobeVideoModel, + isGrokImagineImageModel, + isVideoModel, + ]); + + const createCreativeInputs = ( + baseParams = params, + modelName = currentModelName, + tabKey = activeTab, + ) => { + const effectiveParams = createEffectiveParamsSnapshot( + tabKey, + modelName, + baseParams, + ); + + return { + model: modelName, + group: activeGroup, + stream: false, + imageSize: effectiveParams.imageSize, + aspectRatio: effectiveParams.aspectRatio, + autoImageSize: effectiveParams.autoImageSize, + outputResolution: effectiveParams.outputResolution, + videoSize: effectiveParams.videoSize, + videoSeconds: effectiveParams.videoSeconds, + videoQuality: effectiveParams.videoQuality, + videoPreset: effectiveParams.videoPreset, + videoDuration: effectiveParams.videoDuration, + videoResolution: effectiveParams.videoResolution, + referenceMode: effectiveParams.referenceMode, + }; + }; + + const applyCreativeSessionToView = (tabKey, sessionSnapshot) => { + clearUploadedImages(); + setUploadImageNotice(''); + setPrompt(''); + + if (tabKey === 'chat') { + setChatMessages( + Array.isArray(sessionSnapshot?.payload?.messages) + ? sessionSnapshot.payload.messages + : [], + ); + return; + } + + if (tabKey === 'image') { + const nextImageRecords = normalizeImageHistoryRecords(sessionSnapshot); + setImageRecords(nextImageRecords); + setCollapsedImageRecordIds( + Object.fromEntries(nextImageRecords.map((record) => [record.id, true])), + ); + setSelectedImageTaskIds({}); + lastPersistedImageSignatureRef.current = buildCreativePersistSignature( + nextImageRecords, + 'image', + ); + return; + } + + const nextVideoRecords = normalizeVideoHistoryRecords(sessionSnapshot); + setVideoRecords(nextVideoRecords); + setCollapsedVideoRecordIds( + Object.fromEntries(nextVideoRecords.map((record) => [record.id, true])), + ); + setSelectedVideoTaskIds({}); + lastPersistedVideoSignatureRef.current = buildCreativePersistSignature( + nextVideoRecords, + 'video', + ); + }; + + const commitCreativeHistorySnapshot = (tabKey, nextSnapshot, options = {}) => { + const normalizedSnapshot = normalizeCreativeHistorySnapshot(tabKey, nextSnapshot); + const activeSession = getCreativeCurrentSessionSnapshot( + normalizedSnapshot, + tabKey, + ); + + setHistorySnapshots((prev) => ({ + ...prev, + [tabKey]: normalizedSnapshot, + })); + + if (options.applySessionState) { + applyCreativeSessionToView(tabKey, activeSession); + } + + return { + normalizedSnapshot, + activeSession, + }; + }; + + const persistCreativeHistorySnapshot = async (tabKey, nextSnapshot, options = {}) => { + const { normalizedSnapshot, activeSession } = commitCreativeHistorySnapshot( + tabKey, + nextSnapshot, + options, + ); + + if (!isLoggedIn) { + return normalizedSnapshot; + } + + if (Date.now() < creativeHistoryPersistBlockedUntilRef.current) { + return normalizedSnapshot; + } + + try { + const persistedPayload = buildPersistableCreativeSessionPayload( + tabKey, + normalizedSnapshot.payload, + ); + await API.put( + API_ENDPOINTS.CREATIVE_CENTER_HISTORY, + { + tab: tabKey, + model_name: activeSession?.model_name || '', + group: activeSession?.group || '', + prompt: activeSession?.prompt || '', + payload: persistedPayload, + }, + { + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + }, + skipErrorHandler: true, + }, + ); + } catch (error) { + if (error?.response?.status === 429) { + creativeHistoryPersistBlockedUntilRef.current = + Date.now() + CREATIVE_CENTER_HISTORY_PERSIST_429_BACKOFF_MS; + } + console.error('Failed to save creative center history:', error); + notifyCreativeHistoryPersistFailure(tabKey); + } + + return normalizedSnapshot; + }; + + const buildNextSessionName = (tabKey, sessions) => { + const existingNames = new Set( + (sessions || []).map((session) => String(session?.name || '').trim()).filter(Boolean), + ); + let nextIndex = (sessions || []).length + 1; + let nextName = getDefaultCreativeSessionName(tabKey, nextIndex); + + while (existingNames.has(nextName)) { + nextIndex += 1; + nextName = getDefaultCreativeSessionName(tabKey, nextIndex); + } + + return nextName; + }; + + const createNextBlankSessionSnapshot = (tabKey, baseSnapshot = null) => { + const normalizedBaseSnapshot = normalizeCreativeHistorySnapshot( + tabKey, + baseSnapshot || historySnapshots[tabKey], + ); + return createCreativeSessionSnapshot(tabKey, { + name: buildNextSessionName( + tabKey, + normalizedBaseSnapshot?.payload?.sessions || [], + ), + model_name: currentModelName, + group: activeGroup, + payload: getEmptyCreativeSessionPayload(tabKey), + }); + }; + + const openCreativeSession = async (tabKey, sessionId) => { + const baseSnapshot = normalizeCreativeHistorySnapshot( + tabKey, + historySnapshots[tabKey], + ); + if (!baseSnapshot.payload.sessions.some((session) => session.id === sessionId)) { + return; + } + + const nextSnapshot = { + ...baseSnapshot, + payload: { + ...baseSnapshot.payload, + current_session_id: sessionId, + }, + updated_at: Date.now(), + }; + + await persistCreativeHistorySnapshot(tabKey, nextSnapshot, { + applySessionState: true, + }); + setIsSessionPanelOpen(false); + }; + + const createCreativeSession = async (tabKey) => { + const baseSnapshot = normalizeCreativeHistorySnapshot( + tabKey, + historySnapshots[tabKey], + ); + const newSession = createNextBlankSessionSnapshot(tabKey, baseSnapshot); + const nextSnapshot = { + ...baseSnapshot, + model_name: newSession.model_name, + group: newSession.group, + prompt: '', + updated_at: newSession.updated_at, + payload: { + current_session_id: newSession.id, + sessions: [...baseSnapshot.payload.sessions, newSession], + }, + }; + + await persistCreativeHistorySnapshot(tabKey, nextSnapshot, { + applySessionState: true, + }); + setIsSessionPanelOpen(false); + }; + + const renameCreativeSession = async (tabKey, sessionId) => { + const baseSnapshot = normalizeCreativeHistorySnapshot( + tabKey, + historySnapshots[tabKey], + ); + const targetSession = baseSnapshot.payload.sessions.find( + (session) => session.id === sessionId, + ); + if (!targetSession) { + return; + } + + const nextName = window.prompt('重命名会话', targetSession.name || ''); + if (nextName === null) { + return; + } + + const trimmedName = nextName.trim(); + if (!trimmedName) { + showWarning('会话名称不能为空'); + return; + } + + const nextSnapshot = { + ...baseSnapshot, + updated_at: Date.now(), + payload: { + ...baseSnapshot.payload, + sessions: baseSnapshot.payload.sessions.map((session) => + session.id === sessionId + ? { + ...session, + name: trimmedName, + updated_at: Date.now(), + } + : session, + ), + }, + }; + + await persistCreativeHistorySnapshot(tabKey, nextSnapshot); + }; + + const deleteCreativeSession = async ( + tabKey, + sessionId, + options = { createFallback: true }, + ) => { + const baseSnapshot = normalizeCreativeHistorySnapshot( + tabKey, + historySnapshots[tabKey], + ); + const targetSession = baseSnapshot.payload.sessions.find( + (session) => session.id === sessionId, + ); + if (!targetSession) { + return; + } + + const shouldDelete = window.confirm( + `确认删除“${targetSession.name || '当前会话'}”吗?只删除会话,图片视频资源仍保留。`, + ); + if (!shouldDelete) { + return; + } + + let nextSessions = baseSnapshot.payload.sessions.filter( + (session) => session.id !== sessionId, + ); + if (nextSessions.length === 0 && options.createFallback !== false) { + nextSessions = [createNextBlankSessionSnapshot(tabKey, baseSnapshot)]; + } + + const nextCurrentSessionId = + baseSnapshot.payload.current_session_id === sessionId + ? nextSessions[0]?.id || '' + : baseSnapshot.payload.current_session_id; + + const nextSnapshot = { + ...baseSnapshot, + updated_at: Date.now(), + payload: { + current_session_id: nextCurrentSessionId, + sessions: nextSessions, + }, + }; + + await persistCreativeHistorySnapshot(tabKey, nextSnapshot, { + applySessionState: baseSnapshot.payload.current_session_id === sessionId, + }); + setIsSessionPanelOpen(false); + }; + + const updateCurrentCreativeSessionSnapshot = (tabKey, sessionPatch) => { + const baseSnapshot = normalizeCreativeHistorySnapshot( + tabKey, + historySnapshots[tabKey], + ); + const currentSessionId = baseSnapshot.payload.current_session_id; + const nextSessions = baseSnapshot.payload.sessions.map((session) => + session.id === currentSessionId + ? { + ...session, + ...sessionPatch, + payload: buildCreativeSessionPayload( + tabKey, + sessionPatch?.payload ?? session.payload, + ), + updated_at: sessionPatch?.updated_at || Date.now(), + } + : session, + ); + + return { + ...baseSnapshot, + model_name: sessionPatch?.model_name ?? baseSnapshot.model_name, + group: sessionPatch?.group ?? baseSnapshot.group, + prompt: sessionPatch?.prompt ?? baseSnapshot.prompt, + updated_at: sessionPatch?.updated_at || Date.now(), + payload: { + ...baseSnapshot.payload, + sessions: nextSessions, + }, + }; + }; + + const saveCreativeHistory = async ( + tabKey, + payload, + options = {}, + ) => { + if (!isLoggedIn) { + return; + } + + const requestBody = { + tab: tabKey, + model_name: options.modelName || currentModelName, + group: options.group ?? activeGroup, + prompt: options.prompt ?? '', + payload: buildPersistableCreativeSessionPayload(tabKey, payload), + }; + + if (Date.now() < creativeHistoryPersistBlockedUntilRef.current) { + return; + } + + try { + await API.put(API_ENDPOINTS.CREATIVE_CENTER_HISTORY, requestBody, { + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + }, + skipErrorHandler: true, + }); + + const nextSnapshot = normalizeCreativeHistorySnapshot(tabKey, { + ...(historySnapshots[tabKey] || {}), + tab: tabKey, + model_name: requestBody.model_name, + group: requestBody.group, + prompt: requestBody.prompt, + payload, + }); + setHistorySnapshots((prev) => ({ + ...prev, + [tabKey]: nextSnapshot, + })); + } catch (error) { + if (error?.response?.status === 429) { + creativeHistoryPersistBlockedUntilRef.current = + Date.now() + CREATIVE_CENTER_HISTORY_PERSIST_429_BACKOFF_MS; + } + console.error('Failed to save creative center history:', error); + notifyCreativeHistoryPersistFailure(tabKey); + } + }; + + const deleteCreativeHistory = async (tabKey) => { + if (!isLoggedIn) { + return; + } + + try { + await API.delete(`${API_ENDPOINTS.CREATIVE_CENTER_HISTORY}/${tabKey}`, { + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + }, + }); + + setHistorySnapshots((prev) => ({ + ...prev, + [tabKey]: normalizeCreativeHistorySnapshot(tabKey, null), + })); + } catch (error) { + console.error('Failed to delete creative center history:', error); + } + }; + + const createBasePayload = ( + currentPrompt, + baseParams = params, + modelName = currentModelName, + tabKey = activeTab, + imageUrls = [], + ) => { + return buildApiPayload( + [ + { + role: 'user', + content: buildMessageContent(currentPrompt, imageUrls, imageUrls.length > 0), + }, + ], + '', + createCreativeInputs(baseParams, modelName, tabKey), + PARAMETER_TOGGLES_DISABLED, + ); + }; + + const postCreativeRequest = async (endpoint, payload, requestHeaders = {}) => { + const response = await API.post(endpoint, payload, { + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + ...requestHeaders, + }, + }); + return response.data; + }; + + const buildCreativeTaskStatusRequestConfig = (config = {}) => ({ + ...config, + skipErrorHandler: true, + disableStaleCache: true, + disableDuplicate: true, + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + ...(config.headers || {}), + }, + }); + + const postCreativeChatStreamRequest = (payload) => + new Promise((resolve, reject) => { + const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, { + headers: { + 'Content-Type': 'application/json', + 'New-API-User': getUserIdFromLocalStorage(), + }, + method: 'POST', + payload: JSON.stringify({ + ...payload, + stream: true, + }), + }); + + let settled = false; + const contentFragments = []; + const reasoningFragments = []; + const rawFragments = []; + const cleanup = () => { + try { + source.close(); + } catch { + // ignore close errors from already-closed SSE connections + } + }; + const finish = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve({ + content: contentFragments.join('').trim(), + reasoningContent: reasoningFragments.join('').trim(), + rawResponsePreview: formatCreativeCenterRawResponsePreview( + rawFragments.join('\n'), + ), + }); + }; + const fail = (error) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + + source.addEventListener('message', (event) => { + if (event.data === '[DONE]') { + finish(); + return; + } + + rawFragments.push(event.data); + try { + const chunk = JSON.parse(event.data); + const chunkResponse = extractCreativeCenterChatResponse(chunk); + if (chunkResponse.reasoningContent) { + reasoningFragments.push(chunkResponse.reasoningContent); + } + if (chunkResponse.content) { + contentFragments.push(chunkResponse.content); + } + } catch (error) { + fail(error); + } + }); + + source.addEventListener('error', (event) => { + fail(new Error(event?.data || 'SSE request failed')); + }); + + try { + source.stream(); + } catch (error) { + fail(error); + } + }); + + const persistImageRecords = async (records, options = {}) => { + lastPersistedImageSignatureRef.current = buildCreativePersistSignature(records, 'image'); + const nextSnapshot = updateCurrentCreativeSessionSnapshot('image', { + model_name: + options.modelName || records[records.length - 1]?.modelName || currentModelName, + group: options.group ?? activeGroup, + prompt: options.prompt || records[records.length - 1]?.prompt || '', + payload: { + entries: records, + params: options.params || records[records.length - 1]?.params || params, + }, + updated_at: Date.now(), + }); + await persistCreativeHistorySnapshot('image', nextSnapshot); + }; + + const persistVideoRecords = async (records, options = {}) => { + lastPersistedVideoSignatureRef.current = buildCreativePersistSignature(records, 'video'); + const nextSnapshot = updateCurrentCreativeSessionSnapshot('video', { + model_name: + options.modelName || records[records.length - 1]?.modelName || currentModelName, + group: options.group ?? activeGroup, + prompt: options.prompt || records[records.length - 1]?.prompt || '', + payload: { + entries: records, + params: options.params || records[records.length - 1]?.params || params, + }, + updated_at: Date.now(), + }); + await persistCreativeHistorySnapshot('video', nextSnapshot); + }; + + const buildImageDownloadFilename = (record, recordIndex, imageIndex) => + `${record.modelName || 'creative-image'}-${recordIndex + 1}-${imageIndex + 1}.png`; + + const getCompletedImageItems = (record) => + Array.isArray(record?.images) ? record.images.filter((item) => Boolean(item?.url)) : []; + + const getSelectedImageItems = (record) => { + const selectedIds = new Set(selectedImageTaskIds[record.id] || []); + return Array.isArray(record?.images) + ? record.images.filter((item) => item?.url && selectedIds.has(item.id)) + : []; + }; + + const toggleImageTaskSelection = (recordId, imageId) => { + setSelectedImageTaskIds((prev) => { + const current = new Set(prev[recordId] || []); + if (current.has(imageId)) { + current.delete(imageId); + } else { + current.add(imageId); + } + + if (current.size === 0) { + const next = { ...prev }; + delete next[recordId]; + return next; + } + + return { + ...prev, + [recordId]: Array.from(current), + }; + }); + }; + + const clearImageTaskSelection = (recordId) => { + setSelectedImageTaskIds((prev) => { + if (!prev[recordId]) { + return prev; + } + const next = { ...prev }; + delete next[recordId]; + return next; + }); + }; + + const selectAllCompletedImageTasks = (record) => { + const completedItems = getCompletedImageItems(record); + if (completedItems.length === 0) { + return; + } + + setSelectedImageTaskIds((prev) => ({ + ...prev, + [record.id]: completedItems.map((item) => item.id), + })); + }; + + const downloadImageItems = (record, recordIndex, imageItems) => { + imageItems.forEach((item, selectionIndex) => { + const originalIndex = record.images.findIndex((candidate) => candidate.id === item.id); + window.setTimeout(() => { + triggerDownload( + item.url, + buildImageDownloadFilename( + record, + recordIndex, + originalIndex >= 0 ? originalIndex : selectionIndex, + ), + ); + }, selectionIndex * 120); + }); + }; + + const buildVideoDownloadFilename = (record, recordIndex, taskIndex) => + `${record.modelName || 'creative-video'}-${recordIndex + 1}-${taskIndex + 1}.mp4`; + + const getCompletedVideoTasks = (record) => + Array.isArray(record?.tasks) + ? record.tasks.filter((item) => Boolean(getVideoTaskMediaUrl(item))) + : []; + + const getSelectedVideoTasks = (record) => { + const selectedIds = new Set(selectedVideoTaskIds[record.id] || []); + return Array.isArray(record?.tasks) + ? record.tasks.filter( + (item) => getVideoTaskMediaUrl(item) && selectedIds.has(item.id), + ) + : []; + }; + + const toggleVideoTaskSelection = (recordId, taskId) => { + setSelectedVideoTaskIds((prev) => { + const current = new Set(prev[recordId] || []); + if (current.has(taskId)) { + current.delete(taskId); + } else { + current.add(taskId); + } + + if (current.size === 0) { + const next = { ...prev }; + delete next[recordId]; + return next; + } + + return { + ...prev, + [recordId]: Array.from(current), + }; + }); + }; + + const clearVideoTaskSelection = (recordId) => { + setSelectedVideoTaskIds((prev) => { + if (!prev[recordId]) { + return prev; + } + const next = { ...prev }; + delete next[recordId]; + return next; + }); + }; + + const selectAllCompletedVideoTasks = (record) => { + const completedTasks = getCompletedVideoTasks(record); + if (completedTasks.length === 0) { + return; + } + + setSelectedVideoTaskIds((prev) => ({ + ...prev, + [record.id]: completedTasks.map((item) => item.id), + })); + }; + + const downloadVideoTasks = (record, recordIndex, tasks) => { + tasks.forEach((task, selectionIndex) => { + const originalIndex = record.tasks.findIndex((candidate) => candidate.id === task.id); + window.setTimeout(() => { + triggerDownload( + getVideoTaskMediaUrl(task), + buildVideoDownloadFilename( + record, + recordIndex, + originalIndex >= 0 ? originalIndex : selectionIndex, + ), + ); + }, selectionIndex * 120); + }); + }; + + const patchImageTask = (recordId, taskId, taskPatch) => { + const { nextRecords, hasChanged } = applyImageTaskPatchToRecords( + imageRecordsRef.current, + recordId, + taskId, + taskPatch, + ); + + if (!hasChanged) { + return; + } + + syncImageRecordsState(nextRecords); + }; + + const patchVideoTask = (recordId, taskId, taskPatch) => { + const { nextRecords, hasChanged } = applyVideoTaskPatchToRecords( + videoRecordsRef.current, + recordId, + taskId, + taskPatch, + ); + + if (!hasChanged) { + return; + } + + syncVideoRecordsState(nextRecords); + }; + + const fetchCreativeVideoTasksByIdentifiers = async (candidates) => { + const safeCandidates = Array.isArray(candidates) ? candidates : []; + const requestIds = [...new Set( + safeCandidates + .map((candidate) => String(candidate?.requestId || '').trim()) + .filter(Boolean), + )]; + const taskIds = [...new Set( + safeCandidates + .map((candidate) => String(candidate?.queryTaskId || candidate?.taskId || '').trim()) + .filter((value) => value.startsWith('task_')), + )]; + + if (requestIds.length === 0 && taskIds.length === 0) { + return []; + } + + const candidateTimes = safeCandidates + .map((candidate) => + normalizeCreativeTimestampToSeconds( + candidate?.sortTimestamp || + candidate?.recordUpdatedAt || + candidate?.recordCreatedAt, + ), + ) + .filter((value) => value > 0); + const baseStartTimestamp = + candidateTimes.length > 0 ? Math.min(...candidateTimes) : 0; + const baseEndTimestamp = + candidateTimes.length > 0 ? Math.max(...candidateTimes) : baseStartTimestamp; + const startTimestamp = Math.max(0, baseStartTimestamp - 120); + const endTimestamp = Math.max(startTimestamp + 1, baseEndTimestamp + 1800); + + const response = await API.post('/api/task/self/resolve', { + task_ids: taskIds, + request_ids: requestIds, + media_type: 'video', + start_timestamp: startTimestamp, + end_timestamp: endTimestamp, + limit: Math.max(300, safeCandidates.length * 20), + }, { + skipErrorHandler: true, + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + }, + }); + + const items = Array.isArray(response?.data?.data?.items) + ? response.data.data.items + : []; + const requestIdSet = new Set(requestIds); + const taskIdSet = new Set(taskIds); + return items.filter((item) => { + const requestId = getTaskDtoRequestId(item); + const taskId = String(item?.task_id || item?.taskId || '').trim(); + return requestIdSet.has(requestId) || taskIdSet.has(taskId); + }); + }; + + const fetchCreativeVideoTasksAroundCandidates = async (candidates) => { + const safeCandidates = Array.isArray(candidates) ? candidates : []; + const candidateTimes = safeCandidates + .map((candidate) => + normalizeCreativeTimestampToSeconds( + candidate?.sortTimestamp || + candidate?.recordUpdatedAt || + candidate?.recordCreatedAt, + ), + ) + .filter((value) => value > 0); + + if (candidateTimes.length === 0) { + return []; + } + + const baseStartTimestamp = Math.min(...candidateTimes); + const baseEndTimestamp = Math.max(...candidateTimes); + const startTimestamp = Math.max(0, baseStartTimestamp - 120); + const endTimestamp = Math.max(startTimestamp + 1, baseEndTimestamp + 1800); + + const response = await API.get( + '/api/task/self', + buildCreativeTaskStatusRequestConfig({ + params: { + p: 1, + page_size: Math.max(100, Math.min(300, safeCandidates.length * 20)), + media_type: 'video', + start_timestamp: startTimestamp, + end_timestamp: endTimestamp, + }, + }), + ); + + const items = Array.isArray(response?.data?.data?.items) + ? response.data.data.items + : []; + return items.filter((item) => + CREATIVE_CENTER_VIDEO_TASK_ACTIONS.has(String(item?.action || '').trim()), + ); + }; + + const getCreativeVideoTaskDtoId = (task) => + String(task?.task_id || task?.taskId || '').trim(); + + const getCreativeVideoCandidateKey = (candidate) => + String(candidate?.taskId || candidate?.localTaskId || '').trim(); + + const getCreativeVideoTaskMatchKey = (task) => { + const taskId = getCreativeVideoTaskDtoId(task); + if (taskId) { + return `task:${taskId}`; + } + + const requestId = getTaskDtoRequestId(task); + if (requestId) { + return `request:${requestId}`; + } + + const resultUrl = normalizeVideoMediaUrl(getTaskDtoResultUrl(task)); + if (resultUrl) { + return `url:${resultUrl}`; + } + + const id = String(task?.id || '').trim(); + return id ? `id:${id}` : ''; + }; + + const mergeCreativeTaskDtoLists = (...lists) => { + const taskByKey = new Map(); + const anonymousTasks = []; + + lists.flat().forEach((task) => { + if (!task) { + return; + } + const matchKey = getCreativeVideoTaskMatchKey(task); + if (matchKey) { + taskByKey.set(matchKey, task); + return; + } + anonymousTasks.push(task); + }); + + return [...taskByKey.values(), ...anonymousTasks]; + }; + + const matchCreativeVideoTasksToCandidates = (candidates, tasks) => { + const safeCandidates = Array.isArray(candidates) ? candidates : []; + const videoTasks = (Array.isArray(tasks) ? tasks : []) + .filter((task) => + CREATIVE_CENTER_VIDEO_TASK_ACTIONS.has(String(task?.action || '').trim()), + ) + .sort( + (left, right) => + normalizeCreativeTimestampToSeconds(left?.submit_time || left?.submitTime) - + normalizeCreativeTimestampToSeconds(right?.submit_time || right?.submitTime), + ); + const matches = new Map(); + const usedTaskKeys = new Set(); + + const isTaskAlreadyUsed = (task) => { + const taskKey = getCreativeVideoTaskMatchKey(task); + return Boolean(taskKey && usedTaskKeys.has(taskKey)); + }; + + const rememberMatch = (candidate, task) => { + const candidateKey = getCreativeVideoCandidateKey(candidate); + if (!candidateKey || !task || isTaskAlreadyUsed(task)) { + return; + } + matches.set(candidateKey, task); + const taskKey = getCreativeVideoTaskMatchKey(task); + if (taskKey) { + usedTaskKeys.add(taskKey); + } + }; + + safeCandidates.forEach((candidate) => { + const queryTaskId = String(candidate?.queryTaskId || '').trim(); + if (!queryTaskId) { + return; + } + const matchedTask = videoTasks.find( + (task) => + !isTaskAlreadyUsed(task) && + getCreativeVideoTaskDtoId(task) === queryTaskId, + ); + if (matchedTask) { + rememberMatch(candidate, matchedTask); + } + }); + + safeCandidates.forEach((candidate) => { + if (matches.has(getCreativeVideoCandidateKey(candidate))) { + return; + } + const requestId = String(candidate?.requestId || '').trim(); + if (!requestId) { + return; + } + const matchedTask = videoTasks.find( + (task) => + !isTaskAlreadyUsed(task) && + getTaskDtoRequestId(task) === requestId, + ); + if (matchedTask) { + rememberMatch(candidate, matchedTask); + } + }); + + const groupedCandidates = Array.from( + safeCandidates.reduce((map, candidate) => { + if (matches.has(getCreativeVideoCandidateKey(candidate))) { + return map; + } + if (String(candidate?.queryTaskId || '').trim()) { + return map; + } + const key = candidate.recordId || 'default'; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(candidate); + return map; + }, new Map()), + ); + + groupedCandidates.forEach(([, group]) => { + const recordModelName = String( + group[0]?.recordModelName || group[0]?.modelName || '', + ) + .trim() + .toLowerCase(); + const recordTimes = group + .map((candidate) => + normalizeCreativeTimestampToSeconds( + candidate.sortTimestamp || + candidate.recordUpdatedAt || + candidate.recordCreatedAt, + ), + ) + .filter((value) => value > 0); + const recordStart = recordTimes.length > 0 ? Math.min(...recordTimes) - 120 : 0; + const recordEnd = recordTimes.length > 0 ? Math.max(...recordTimes) + 1800 : 0; + + const matchedTasks = videoTasks.filter((task) => { + if (isTaskAlreadyUsed(task)) { + return false; + } + const taskModelName = getTaskDtoModelName(task).toLowerCase(); + if (recordModelName && taskModelName && taskModelName !== recordModelName) { + return false; + } + const submitTime = normalizeCreativeTimestampToSeconds( + task?.submit_time || task?.submitTime, + ); + if (recordStart > 0 && submitTime > 0 && submitTime < recordStart) { + return false; + } + if (recordEnd > 0 && submitTime > 0 && submitTime > recordEnd) { + return false; + } + return true; + }); + + [...group] + .sort((left, right) => (left.sortTimestamp || 0) - (right.sortTimestamp || 0)) + .forEach((candidate, index) => { + const matchedTask = matchedTasks[index]; + if (matchedTask) { + rememberMatch(candidate, matchedTask); + } + }); + }); + + return matches; + }; + + const parseVideoFetchPayload = (rawResponse) => { + const rootPayload = rawResponse?.data; + const dataPayload = + rootPayload && typeof rootPayload === 'object' && rootPayload.data && typeof rootPayload.data === 'object' + ? rootPayload.data + : rootPayload; + + if (!dataPayload || typeof dataPayload !== 'object') { + return { + status: 'submitted', + progress: null, + url: '', + content: '', + error: '', + }; + } + + const status = normalizeVideoTaskStatus( + dataPayload.status || + dataPayload.task_status || + dataPayload.state || + rootPayload?.status, + ); + const progress = + parseProgressValue(dataPayload.progress) ?? + parseProgressValue(rootPayload?.progress); + const rawUrl = + dataPayload.url || + dataPayload.presignedUrl || + dataPayload.presigned_url || + dataPayload.result_url || + dataPayload.resultUrl || + dataPayload.video_url || + dataPayload.output_url || + dataPayload?.data?.[0]?.url || + dataPayload?.data?.[0]?.video_url || + dataPayload?.metadata?.url || + dataPayload?.metadata?.remote_url || + rootPayload?.url || + rootPayload?.presignedUrl || + rootPayload?.presigned_url || + rootPayload?.result_url || + rootPayload?.resultUrl || + rootPayload?.video_url || + rootPayload?.output_url || + rootPayload?.data?.[0]?.url || + rootPayload?.data?.[0]?.video_url || + rootPayload?.metadata?.url || + rootPayload?.metadata?.remote_url || + ''; + const url = normalizeVideoMediaUrl(rawUrl); + const content = + dataPayload.content || + dataPayload.message || + rootPayload?.message || + ''; + const error = + dataPayload.error?.message || + dataPayload.fail_reason || + rootPayload?.error?.message || + ''; + const completedWithoutVideo = status === 'completed' && !url; + + return { + status: completedWithoutVideo ? 'failed' : status, + progress, + url, + content, + error: completedWithoutVideo ? error || 'video generation failed' : error, + }; + }; + + const getMessageText = (content) => { + if (typeof content === 'string') { + return content; + } + + if (!Array.isArray(content)) { + return ''; + } + + return content + .filter((item) => item?.type === 'text') + .map((item) => item?.text || '') + .filter(Boolean) + .join('\n'); + }; + + const getMessageImages = (content) => { + if (!Array.isArray(content)) { + return []; + } + + return content + .filter((item) => item?.type === 'image_url') + .map((item) => + typeof item?.image_url === 'string' + ? item.image_url + : item?.image_url?.url || '', + ) + .filter(Boolean); + }; + + const removeUploadedImage = (imageId) => { + setUploadedImages((prev) => { + const target = prev.find((item) => item.id === imageId); + if (target) { + revokeCreativeCenterPreviewURL(target.previewUrl); + } + return prev.filter((item) => item.id !== imageId); + }); + }; + + const clearUploadedImages = () => { + setUploadedImages((prev) => { + prev.forEach((item) => { + revokeCreativeCenterPreviewURL(item.previewUrl); + }); + return []; + }); + setUploadImageNotice(''); + }; + + const handleUploadButtonClick = () => { + if (!isCurrentModelImageUploadEnabled) { + setUploadImageNotice('当前模型不支持上传图片'); + showWarning('当前模型不支持上传图片'); + return; + } + fileInputRef.current?.click(); + }; + + const getCreativeCenterImageUploadConfig = async () => { + if (creativeCenterUploadConfigRef.current) { + return creativeCenterUploadConfigRef.current; + } + + try { + const response = await API.get( + API_ENDPOINTS.CREATIVE_CENTER_IMAGE_UPLOAD_CONFIG, + { + skipErrorHandler: true, + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + }, + }, + ); + + const { success, data, message } = response?.data || {}; + if (!success) { + throw new Error(message || '获取图片上传配置失败'); + } + + const nextConfig = + data?.mode === 'direct' && data?.upload_url && data?.api_key + ? data + : { mode: 'backend' }; + creativeCenterUploadConfigRef.current = nextConfig; + return nextConfig; + } catch (error) { + creativeCenterUploadConfigRef.current = { mode: 'backend' }; + return creativeCenterUploadConfigRef.current; + } + }; + + const uploadCreativeCenterImageViaBackend = async (file) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await API.post( + API_ENDPOINTS.CREATIVE_CENTER_IMAGE_UPLOAD, + formData, + { + skipErrorHandler: true, + headers: { + 'Content-Type': 'multipart/form-data', + 'New-API-User': getUserIdFromLocalStorage(), + }, + }, + ); + + const { success, data, message } = response?.data || {}; + if (!success || !data?.url) { + throw new Error(message || '图片上传失败,请稍后重试'); + } + + return data; + }; + + const uploadCreativeCenterImageDirectly = async (file, uploadConfig) => { + const requestUrl = buildCreativeCenterImageBedUploadUrl( + uploadConfig?.upload_url, + uploadConfig?.return_type, + uploadConfig?.auto_retry !== false, + ); + if (!requestUrl) { + throw new Error('图床配置无效,请检查系统设置'); + } + + const formData = new FormData(); + formData.append('file', file); + + let response; + try { + response = await window.fetch(requestUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${uploadConfig.api_key}`, + }, + body: formData, + cache: 'no-store', + }); + } catch (error) { + throw new Error('浏览器直连图床失败,请检查图床 CORS 配置或网络状态'); + } + + const responseText = await response.text(); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + creativeCenterUploadConfigRef.current = null; + } + throw new Error( + `图床上传失败,状态码 ${response.status}${ + responseText.trim() ? `:${responseText.trim()}` : '' + }`, + ); + } + + let payload = null; + if (responseText.trim()) { + try { + payload = JSON.parse(responseText); + } catch (error) { + throw new Error('图床上传成功,但返回内容不是有效 JSON'); + } + } + + const imageUrl = parseCreativeCenterDirectUploadImageUrl( + uploadConfig.upload_url, + payload, + ); + if (!imageUrl) { + throw new Error('图床上传成功但未返回可用图片链接'); + } + + return { + url: imageUrl, + name: file.name, + filename: getCreativeCenterFilenameFromUrl(imageUrl), + size: file.size, + }; + }; + + const uploadCreativeCenterImage = async (file) => { + const uploadConfig = await getCreativeCenterImageUploadConfig(); + if (uploadConfig?.mode === 'direct') { + return uploadCreativeCenterImageDirectly(file, uploadConfig); + } + return uploadCreativeCenterImageViaBackend(file); + }; + + const handleCreativeCenterImageFiles = async (files) => { + setIsUploadDragActive(false); + if (files.length === 0) { + return; + } + + if (!isCurrentModelImageUploadEnabled) { + setUploadImageNotice('当前模型不支持上传图片'); + showWarning('当前模型不支持上传图片'); + return; + } + + if (!isLoggedIn) { + showWarning('请先登录后再上传图片'); + return; + } + + const rawImageFiles = files.filter((file) => file.type.startsWith('image/')); + if (rawImageFiles.length !== files.length) { + showWarning('请上传图片文件'); + } + if (rawImageFiles.length === 0) { + return; + } + + const imageFiles = rawImageFiles.filter( + (file) => file.size <= CREATIVE_CENTER_IMAGE_UPLOAD_MAX_BYTES, + ); + if (imageFiles.length !== rawImageFiles.length) { + showWarning('图片大小不能超过 10MB'); + } + if (imageFiles.length === 0) { + setUploadImageNotice('上传失败,请重新上传不大于 10MB 的图片'); + return; + } + + const remainingSlots = + typeof currentImageUploadLimit === 'number' + ? currentImageUploadLimit - uploadedImages.length + : null; + if (remainingSlots !== null && remainingSlots <= 0) { + const message = `当前模型最多上传 ${currentImageUploadLimit} 张图片`; + setUploadImageNotice(message); + showWarning(message); + return; + } + + const acceptedFiles = + remainingSlots !== null ? imageFiles.slice(0, remainingSlots) : imageFiles; + let limitNotice = ''; + if (acceptedFiles.length < imageFiles.length && currentImageUploadLimit) { + limitNotice = `当前模型最多上传 ${currentImageUploadLimit} 张图片,本次仅保留前 ${acceptedFiles.length} 张`; + showWarning(limitNotice); + } + if (acceptedFiles.length === 0) { + return; + } + + setUploadImageNotice(limitNotice); + const pendingItems = acceptedFiles.map((file) => ({ + id: createCreativeRecordId('hosted-image'), + name: file.name, + url: '', + fileName: '', + previewUrl: URL.createObjectURL(file), + status: 'uploading', + })); + + setUploadedImages((prev) => [...prev, ...pendingItems]); + + for ( + let batchStartIndex = 0; + batchStartIndex < acceptedFiles.length; + batchStartIndex += CREATIVE_CENTER_IMAGE_UPLOAD_CONCURRENCY + ) { + const fileBatch = acceptedFiles.slice( + batchStartIndex, + batchStartIndex + CREATIVE_CENTER_IMAGE_UPLOAD_CONCURRENCY, + ); + + await Promise.all( + fileBatch.map(async (file, offset) => { + const index = batchStartIndex + offset; + const pendingItem = pendingItems[index]; + + try { + const uploaded = await uploadCreativeCenterImage(file); + setUploadedImages((prev) => + prev.map((item) => + item.id === pendingItem.id + ? { + ...item, + name: uploaded.name || file.name, + url: uploaded.url, + fileName: uploaded.filename || '', + status: 'uploaded', + } + : item, + ), + ); + } catch (error) { + console.error('Failed to upload creative center image:', error); + revokeCreativeCenterPreviewURL(pendingItem.previewUrl); + setUploadedImages((prev) => + prev.filter((item) => item.id !== pendingItem.id), + ); + setUploadImageNotice('上传失败,请重新上传'); + } + }), + ); + } + }; + + const handleImageFileChange = async (event) => { + const files = Array.from(event.target.files || []); + event.target.value = ''; + await handleCreativeCenterImageFiles(files); + }; + + const handleUploadDragEnter = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!isCurrentModelImageUploadEnabled) { + return; + } + setIsUploadDragActive(true); + }; + + const handleUploadDragOver = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!isCurrentModelImageUploadEnabled) { + return; + } + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + setIsUploadDragActive(true); + }; + + const handleUploadDragLeave = (event) => { + event.preventDefault(); + event.stopPropagation(); + const relatedTarget = event.relatedTarget; + if ( + relatedTarget instanceof Node && + event.currentTarget instanceof Node && + event.currentTarget.contains(relatedTarget) + ) { + return; + } + setIsUploadDragActive(false); + }; + + const handleUploadDrop = async (event) => { + event.preventDefault(); + event.stopPropagation(); + setIsUploadDragActive(false); + const files = Array.from(event.dataTransfer?.files || []); + await handleCreativeCenterImageFiles(files); + }; + + useEffect(() => { + const collectPendingImageTasks = () => + imageRecordsRef.current.flatMap((record) => + record.images + .filter((image) => { + const queryTaskId = getRecoverableImageTaskId(image); + return ( + Boolean(queryTaskId) && + image.requestPollable !== false && + !getImageTaskMediaUrl(image) && + !isTerminalImageTaskStatus(image.status) + ); + }) + .map((image) => ({ + recordId: record.id, + modelName: record.modelName, + localTaskId: image.id, + queryTaskId: getRecoverableImageTaskId(image), + })), + ); + + const clearImagePollingTimer = () => { + if (imagePollingTimerRef.current) { + window.clearTimeout(imagePollingTimerRef.current); + imagePollingTimerRef.current = null; + } + }; + + const scheduleImagePollingCycle = (delay = 0) => { + if (imagePollingTimerRef.current) { + return; + } + + imagePollingTimerRef.current = window.setTimeout(async () => { + imagePollingTimerRef.current = null; + + const now = Date.now(); + if (creativeImagePollingBlockedUntilRef.current > now) { + scheduleImagePollingCycle( + creativeImagePollingBlockedUntilRef.current - now, + ); + return; + } + + const pendingTasks = collectPendingImageTasks(); + if (pendingTasks.length === 0) { + imagePollingInFlightRef.current.clear(); + return; + } + + const activeTaskIds = new Set( + pendingTasks.map((task) => task.localTaskId), + ); + imagePollingInFlightRef.current.forEach((taskId) => { + if (!activeTaskIds.has(taskId)) { + imagePollingInFlightRef.current.delete(taskId); + } + }); + + const tasksToPoll = pendingTasks + .filter( + (task) => !imagePollingInFlightRef.current.has(task.localTaskId), + ) + .slice(0, CREATIVE_CENTER_IMAGE_POLL_CONCURRENCY); + + if (tasksToPoll.length === 0) { + scheduleImagePollingCycle(CREATIVE_CENTER_IMAGE_POLL_INTERVAL_MS); + return; + } + + await Promise.all( + tasksToPoll.map(async (task) => { + imagePollingInFlightRef.current.add(task.localTaskId); + try { + const response = await API.get( + `${API_ENDPOINTS.IMAGE_ASYNC_GENERATIONS}/${encodeURIComponent(task.queryTaskId)}`, + buildCreativeTaskStatusRequestConfig(), + ); + const nextTaskState = parseImageFetchPayload(response); + patchImageTask( + task.recordId, + task.localTaskId, + buildResolvedImageTaskPatch(task.queryTaskId, nextTaskState), + ); + } catch (error) { + if (error?.response?.status === 429) { + creativeImagePollingBlockedUntilRef.current = Math.max( + creativeImagePollingBlockedUntilRef.current, + Date.now() + CREATIVE_CENTER_IMAGE_POLL_429_BACKOFF_MS, + ); + return; + } + console.error('Failed to poll creative center image task:', error); + } finally { + imagePollingInFlightRef.current.delete(task.localTaskId); + } + }), + ); + + if (collectPendingImageTasks().length > 0) { + scheduleImagePollingCycle(CREATIVE_CENTER_IMAGE_POLL_INTERVAL_MS); + } + }, delay); + }; + + const pendingTasks = collectPendingImageTasks(); + const activeTaskIds = new Set(pendingTasks.map((task) => task.localTaskId)); + imagePollingInFlightRef.current.forEach((taskId) => { + if (!activeTaskIds.has(taskId)) { + imagePollingInFlightRef.current.delete(taskId); + } + }); + + if (pendingTasks.length === 0) { + clearImagePollingTimer(); + return; + } + + scheduleImagePollingCycle(0); + }, [imageRecords]); + + useEffect(() => { + const collectPendingVideoTasks = () => + videoRecordsRef.current.flatMap((record) => + record.tasks + .filter((task) => { + const queryTaskId = getRecoverableVideoTaskId(task); + const requestId = String(task?.requestId || '').trim(); + const canPollByRequestId = + Boolean(requestId) && + !queryTaskId && + task.requestPollable !== false; + return ( + Boolean(queryTaskId || canPollByRequestId) && + (queryTaskId ? task.pollable !== false : canPollByRequestId) && + ACTIVE_VIDEO_POLL_STATUSES.has(normalizeVideoTaskStatus(task.status)) + ); + }) + .map((task) => ({ + recordId: record.id, + modelName: record.modelName, + recordModelName: String(record?.modelName || '').trim().toLowerCase(), + localTaskId: task.id, + queryTaskId: getRecoverableVideoTaskId(task), + requestId: typeof task?.requestId === 'string' ? task.requestId.trim() : '', + recordCreatedAt: Number(record?.createdAt) || 0, + recordUpdatedAt: Number(record?.updatedAt) || 0, + sortTimestamp: + Number(task?.submittedAt) || + Number(record?.updatedAt) || + Number(record?.createdAt) || + 0, + })), + ); + + const clearVideoPollingTimer = () => { + if (videoPollingTimerRef.current) { + window.clearTimeout(videoPollingTimerRef.current); + videoPollingTimerRef.current = null; + } + }; + + const scheduleVideoPollingCycle = (delay = 0) => { + if (videoPollingTimerRef.current) { + return; + } + + videoPollingTimerRef.current = window.setTimeout(async () => { + videoPollingTimerRef.current = null; + + const now = Date.now(); + if (creativeVideoPollingBlockedUntilRef.current > now) { + scheduleVideoPollingCycle( + creativeVideoPollingBlockedUntilRef.current - now, + ); + return; + } + + const pendingTasks = collectPendingVideoTasks(); + if (pendingTasks.length === 0) { + videoPollingInFlightRef.current.clear(); + return; + } + + const activeTaskIds = new Set( + pendingTasks.map((task) => task.localTaskId), + ); + videoPollingInFlightRef.current.forEach((taskId) => { + if (!activeTaskIds.has(taskId)) { + videoPollingInFlightRef.current.delete(taskId); + } + }); + + const tasksToPoll = pendingTasks + .filter( + (task) => !videoPollingInFlightRef.current.has(task.localTaskId), + ) + .slice(0, CREATIVE_CENTER_VIDEO_POLL_CONCURRENCY); + + if (tasksToPoll.length === 0) { + scheduleVideoPollingCycle(CREATIVE_CENTER_VIDEO_POLL_INTERVAL_MS); + return; + } + + let exactTaskByRequestId = new Map(); + let exactTaskByTaskId = new Map(); + let fallbackTaskMatches = new Map(); + try { + const exactTasks = await fetchCreativeVideoTasksByIdentifiers( + tasksToPoll, + ); + exactTaskByRequestId = new Map(); + exactTaskByTaskId = new Map(); + exactTasks.forEach((task) => { + const requestId = getTaskDtoRequestId(task); + if (requestId) { + exactTaskByRequestId.set(requestId, task); + } + const taskId = String(task?.task_id || task?.taskId || '').trim(); + if (taskId) { + exactTaskByTaskId.set(taskId, task); + } + }); + + const unresolvedTasks = tasksToPoll.filter( + (task) => + !task.queryTaskId && + !( + task.requestId && + exactTaskByRequestId.has(task.requestId) + ), + ); + if (unresolvedTasks.length > 0) { + const nearbyTasks = await fetchCreativeVideoTasksAroundCandidates( + unresolvedTasks, + ); + fallbackTaskMatches = matchCreativeVideoTasksToCandidates( + unresolvedTasks, + mergeCreativeTaskDtoLists(exactTasks, nearbyTasks), + ); + } + } catch (error) { + console.error('Failed to fetch exact creative center video task states:', error); + } + + await Promise.all( + tasksToPoll.map(async (task) => { + videoPollingInFlightRef.current.add(task.localTaskId); + try { + let queryTaskId = task.queryTaskId; + const exactTaskByRequest = exactTaskByRequestId.get(task.requestId); + if (!queryTaskId && exactTaskByRequest) { + const exactTaskState = parseTaskDtoVideoState(exactTaskByRequest); + queryTaskId = exactTaskState.taskId; + patchVideoTask( + task.recordId, + task.localTaskId, + buildResolvedVideoTaskPatch(queryTaskId, exactTaskState), + ); + return; + } + + const fallbackTask = fallbackTaskMatches.get(task.localTaskId); + if (fallbackTask) { + const fallbackTaskState = parseTaskDtoVideoState(fallbackTask); + queryTaskId = fallbackTaskState.taskId || queryTaskId; + patchVideoTask( + task.recordId, + task.localTaskId, + buildResolvedVideoTaskPatch(queryTaskId, fallbackTaskState), + ); + return; + } + + if (!queryTaskId) { + return; + } + + const exactTaskById = exactTaskByTaskId.get(queryTaskId); + if (exactTaskById) { + const exactTaskState = parseTaskDtoVideoState(exactTaskById); + patchVideoTask( + task.recordId, + task.localTaskId, + buildResolvedVideoTaskPatch(queryTaskId, exactTaskState), + ); + return; + } + + patchVideoTask( + task.recordId, + task.localTaskId, + buildResolvedVideoTaskIdPatch(queryTaskId), + ); + + const response = await API.get( + `${API_ENDPOINTS.VIDEO_ASYNC_GENERATIONS}/${encodeURIComponent(queryTaskId)}`, + buildCreativeTaskStatusRequestConfig(), + ); + + const nextTaskState = parseVideoFetchPayload(response); + const nextStatus = normalizeVideoTaskStatus(nextTaskState.status); + const isFailed = nextStatus === 'failed'; + const isCompleted = + !isFailed && (nextStatus === 'completed' || Boolean(nextTaskState.url)); + + if ( + isCompleted && + shouldUseEstimatedVideoProgress(task.modelName) + ) { + patchVideoTask(task.recordId, task.localTaskId, (currentTask) => ({ + status: 'finalizing', + progress: 96, + url: '', + resultUrl: + nextTaskState.url || + currentTask.resultUrl || + currentTask.url, + content: nextTaskState.content || currentTask.content, + error: '', + finalizingAt: Date.now(), + pollable: false, + })); + window.setTimeout(() => { + patchVideoTask(task.recordId, task.localTaskId, (currentTask) => ({ + status: 'completed', + progress: 100, + url: + nextTaskState.url || + currentTask.resultUrl || + currentTask.url, + content: nextTaskState.content || currentTask.content, + error: '', + finalizingAt: 0, + pollable: false, + })); + }, 180); + } else { + patchVideoTask(task.recordId, task.localTaskId, (currentTask) => ({ + taskId: queryTaskId, + status: isCompleted + ? 'completed' + : isFailed + ? 'failed' + : nextStatus, + progress: isCompleted + ? 100 + : nextTaskState.progress ?? currentTask.progress ?? 0, + url: isCompleted + ? nextTaskState.url || currentTask.url + : currentTask.url, + content: nextTaskState.content || currentTask.content, + error: isFailed + ? nextTaskState.error || + currentTask.error || + '任务生成失败' + : '', + finalizingAt: 0, + pollable: !(isCompleted || isFailed), + })); + } + } catch (error) { + if (error?.response?.status === 429) { + creativeVideoPollingBlockedUntilRef.current = Math.max( + creativeVideoPollingBlockedUntilRef.current, + Date.now() + CREATIVE_CENTER_VIDEO_POLL_429_BACKOFF_MS, + ); + return; + } + console.error('Failed to poll creative center video task:', error); + } finally { + videoPollingInFlightRef.current.delete(task.localTaskId); + } + }), + ); + + if (collectPendingVideoTasks().length > 0) { + scheduleVideoPollingCycle(CREATIVE_CENTER_VIDEO_POLL_INTERVAL_MS); + } + }, delay); + }; + + const pendingTasks = collectPendingVideoTasks(); + const activeTaskIds = new Set(pendingTasks.map((task) => task.localTaskId)); + videoPollingInFlightRef.current.forEach((taskId) => { + if (!activeTaskIds.has(taskId)) { + videoPollingInFlightRef.current.delete(taskId); + } + }); + + if (pendingTasks.length === 0) { + clearVideoPollingTimer(); + return; + } + + scheduleVideoPollingCycle(0); + }, [videoRecords]); + + const applyReusedUploadedImages = (sourceImages = []) => { + const nextImages = (Array.isArray(sourceImages) ? sourceImages : []) + .map((item, index) => normalizeCreativeSourceImageItem(item, index)) + .filter(Boolean) + .map((item, index) => ({ + ...item, + id: createCreativeRecordId(`reused-image-${index + 1}`), + previewUrl: '', + status: 'uploaded', + })); + + setUploadedImages((prev) => { + prev.forEach((item) => { + revokeCreativeCenterPreviewURL(item.previewUrl); + }); + return nextImages; + }); + setUploadImageNotice(''); + }; + + const handleReuseRecord = (record) => { + if (!record) { + return; + } + + applyReusedUploadedImages(record.sourceImages || []); + if (record.prompt) { + setPrompt(record.prompt); + } + if (record.params && typeof record.params === 'object') { + setParams((prev) => ({ + ...prev, + ...record.params, + })); + } + textareaRef.current?.focus(); + }; + + const handleClearCurrentSession = async () => { + const activeSessionId = currentTabHistorySnapshot?.payload?.current_session_id; + if (!activeSessionId) { + return; + } + await deleteCreativeSession(activeTab, activeSessionId, { + createFallback: true, + }); + }; + + const handleRemoveImageRecord = async (recordId) => { + const nextRecords = imageRecords.filter((record) => record.id !== recordId); + setImageRecords(nextRecords); + setCollapsedImageRecordIds((prev) => { + const next = { ...prev }; + delete next[recordId]; + return next; + }); + await persistImageRecords(nextRecords); + }; + + const handleRemoveVideoRecord = async (recordId) => { + const nextRecords = videoRecords.filter((record) => record.id !== recordId); + setVideoRecords(nextRecords); + setCollapsedVideoRecordIds((prev) => { + const next = { ...prev }; + delete next[recordId]; + return next; + }); + await persistVideoRecords(nextRecords); + }; + + const toggleImageRecordCollapsed = (recordId) => { + setCollapsedImageRecordIds((prev) => ({ + ...prev, + [recordId]: !(prev[recordId] ?? false), + })); + }; + + const toggleVideoRecordCollapsed = (recordId) => { + setCollapsedVideoRecordIds((prev) => ({ + ...prev, + [recordId]: !(prev[recordId] ?? false), + })); + }; + + useEffect(() => { + let mounted = true; + setHistoryLoaded(false); + + const loadCreativeHistory = async () => { + if (!isLoggedIn) { + if (!mounted) { + return; + } + historyHydratedRef.current = true; + const emptySnapshots = { + chat: normalizeCreativeHistorySnapshot('chat', null), + image: normalizeCreativeHistorySnapshot('image', null), + video: normalizeCreativeHistorySnapshot('video', null), + }; + setHistorySnapshots(emptySnapshots); + setChatMessages([]); + setImageRecords([]); + setVideoRecords([]); + setCollapsedImageRecordIds({}); + setCollapsedVideoRecordIds({}); + setSelectedImageTaskIds({}); + setSelectedVideoTaskIds({}); + setHistoryLoaded(true); + return; + } + + try { + const response = await API.get(API_ENDPOINTS.CREATIVE_CENTER_HISTORY, { + skipErrorHandler: true, + headers: { + 'New-API-User': getUserIdFromLocalStorage(), + }, + }); + if (!mounted || !response?.data?.success) { + return; + } + + const nextSnapshots = { + chat: normalizeCreativeHistorySnapshot('chat', response.data.data?.chat || null), + image: normalizeCreativeHistorySnapshot('image', response.data.data?.image || null), + video: normalizeCreativeHistorySnapshot('video', response.data.data?.video || null), + }; + const nextChatSession = getCreativeCurrentSessionSnapshot(nextSnapshots.chat, 'chat'); + const nextImageSession = getCreativeCurrentSessionSnapshot( + nextSnapshots.image, + 'image', + ); + const nextVideoSession = getCreativeCurrentSessionSnapshot( + nextSnapshots.video, + 'video', + ); + const nextImageRecords = normalizeImageHistoryRecords(nextImageSession); + const nextVideoRecords = normalizeVideoHistoryRecords(nextVideoSession); + setHistorySnapshots(nextSnapshots); + setChatMessages( + Array.isArray(nextChatSession?.payload?.messages) + ? nextChatSession.payload.messages + : [], + ); + setImageRecords(nextImageRecords); + setVideoRecords(nextVideoRecords); + setCollapsedImageRecordIds( + Object.fromEntries(nextImageRecords.map((record) => [record.id, true])), + ); + setCollapsedVideoRecordIds( + Object.fromEntries(nextVideoRecords.map((record) => [record.id, true])), + ); + setSelectedImageTaskIds({}); + setSelectedVideoTaskIds({}); + lastPersistedImageSignatureRef.current = buildCreativePersistSignature( + nextImageRecords, + 'image', + ); + lastPersistedVideoSignatureRef.current = buildCreativePersistSignature( + nextVideoRecords, + 'video', + ); + historyHydratedRef.current = true; + setHistoryLoaded(true); + } catch (error) { + console.error('Failed to load creative center history:', error); + historyHydratedRef.current = true; + if (mounted) { + setHistoryLoaded(true); + } + } + }; + + loadCreativeHistory(); + + return () => { + mounted = false; + }; + }, [isLoggedIn]); + + useEffect(() => { + if (!isLoggedIn || !historyHydratedRef.current) { + return undefined; + } + if (imagePersistSignature === lastPersistedImageSignatureRef.current) { + return undefined; + } + + const timer = window.setTimeout(() => { + persistImageRecords(imageRecordsRef.current).catch((error) => { + console.error('Failed to persist creative center image records:', error); + }); + }, CREATIVE_CENTER_HISTORY_PERSIST_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [imagePersistSignature, isLoggedIn]); + + useEffect(() => { + if (!isLoggedIn || !historyHydratedRef.current) { + return undefined; + } + if (videoPersistSignature === lastPersistedVideoSignatureRef.current) { + return undefined; + } + + const timer = window.setTimeout(() => { + persistVideoRecords(videoRecordsRef.current).catch((error) => { + console.error('Failed to persist creative center video records:', error); + }); + }, CREATIVE_CENTER_VIDEO_HISTORY_PERSIST_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [videoPersistSignature, isLoggedIn]); + + useEffect(() => { + if (!isLoggedIn || !historyHydratedRef.current || startupImageRecoveryRunRef.current) { + return undefined; + } + + startupImageRecoveryRunRef.current = true; + + const candidates = collectRecoverableImageCandidatesFromSnapshot( + historySnapshots.image, + ); + const limitedCandidates = candidates.slice( + 0, + CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_MAX_TASKS, + ); + + if (limitedCandidates.length === 0) { + return undefined; + } + + let cancelled = false; + + const fetchCompletedImageTasksForRecord = async (recordCandidates) => { + if (!Array.isArray(recordCandidates) || recordCandidates.length === 0) { + return []; + } + + const modelName = String(recordCandidates[0]?.recordModelName || '').trim().toLowerCase(); + const candidateTimes = recordCandidates + .map((candidate) => + normalizeCreativeTimestampToSeconds( + candidate.sortTimestamp || + candidate.recordUpdatedAt || + candidate.recordCreatedAt, + ), + ) + .filter((value) => value > 0); + const baseStartTimestamp = + candidateTimes.length > 0 ? Math.min(...candidateTimes) : 0; + const baseEndTimestamp = + candidateTimes.length > 0 ? Math.max(...candidateTimes) : baseStartTimestamp; + const startTimestamp = Math.max(0, baseStartTimestamp - 120); + const endTimestamp = Math.max(startTimestamp + 1, baseEndTimestamp + 1800); + + try { + const response = await API.get( + '/api/task/self', + buildCreativeTaskStatusRequestConfig({ + params: { + p: 1, + page_size: 100, + status: 'SUCCESS', + start_timestamp: startTimestamp, + end_timestamp: endTimestamp, + }, + }), + ); + + const items = Array.isArray(response?.data?.data?.items) + ? response.data.data.items + : []; + + return items + .filter((item) => { + const action = String(item?.action || '').trim(); + if ( + action !== 'imageGenerate' && + action !== 'imageEdit' + ) { + return false; + } + + const imageUrls = getTaskDtoImageUrls(item); + if (imageUrls.length === 0) { + return false; + } + + const taskModelName = getTaskDtoModelName(item).toLowerCase(); + if (modelName && taskModelName && taskModelName !== modelName) { + return false; + } + + const submitTime = normalizeCreativeTimestampToSeconds(item?.submit_time); + if (submitTime > 0 && (submitTime < startTimestamp || submitTime > endTimestamp)) { + return false; + } + + return true; + }) + .sort( + (left, right) => + normalizeCreativeTimestampToSeconds(left?.submit_time) - + normalizeCreativeTimestampToSeconds(right?.submit_time), + ); + } catch (error) { + console.error('Failed to recover creative center image tasks from task list:', error); + return []; + } + }; + + const buildFallbackImageMatchMap = async (recordCandidateGroups) => { + const fallbackMatches = new Map(); + + for (const group of recordCandidateGroups) { + if (cancelled || group.length === 0) { + break; + } + + const matchedTasks = await fetchCompletedImageTasksForRecord(group); + if (matchedTasks.length === 0) { + continue; + } + + const sortedCandidates = [...group].sort( + (left, right) => + normalizeCreativeTimestampToSeconds(left.sortTimestamp) - + normalizeCreativeTimestampToSeconds(right.sortTimestamp), + ); + + sortedCandidates.forEach((candidate, index) => { + const matchedTask = matchedTasks[index]; + if (!matchedTask) { + return; + } + fallbackMatches.set( + buildRecoverableImageCandidateKey(candidate), + matchedTask, + ); + }); + } + + return fallbackMatches; + }; + + const recoverStartupImageTasks = async () => { + let recoveredSnapshot = normalizeCreativeHistorySnapshot( + 'image', + historySnapshots.image, + ); + let hasRecoveredChanges = false; + + try { + const groupedCandidates = Array.from( + limitedCandidates.reduce((map, candidate) => { + const key = `${candidate.sessionId}:${candidate.recordId}`; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(candidate); + return map; + }, new Map()), + ).map(([, group]) => group); + const fallbackTaskMatches = await buildFallbackImageMatchMap(groupedCandidates); + + for ( + let startIndex = 0; + startIndex < limitedCandidates.length && !cancelled; + startIndex += CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_CONCURRENCY + ) { + const currentBatch = limitedCandidates.slice( + startIndex, + startIndex + CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_CONCURRENCY, + ); + + currentBatch.forEach((candidate) => { + const matchedTask = fallbackTaskMatches.get( + buildRecoverableImageCandidateKey(candidate), + ); + if (!matchedTask || cancelled) { + return; + } + + const imageUrls = getTaskDtoImageUrls(matchedTask); + const primaryImageUrl = imageUrls[0] || ''; + if (!primaryImageUrl) { + return; + } + + const taskPatch = patchImageTaskInHistorySnapshot( + recoveredSnapshot, + candidate, + { + url: primaryImageUrl, + resultUrl: primaryImageUrl, + status: 'completed', + progress: 100, + error: '', + finalizingAt: 0, + progressUnavailable: false, + requestPollable: false, + }, + ); + if (taskPatch.hasChanged) { + recoveredSnapshot = taskPatch.snapshot; + hasRecoveredChanges = true; + } + }); + } + + if (!cancelled && hasRecoveredChanges) { + await persistCreativeHistorySnapshot('image', recoveredSnapshot, { + applySessionState: true, + }); + } + } catch (error) { + console.error('Failed to finish startup recovery for creative center image tasks:', error); + } + }; + + recoverStartupImageTasks(); + + return () => { + cancelled = true; + }; + }, [historySnapshots.image, isLoggedIn]); + + useEffect(() => { + if (!isLoggedIn || !historyHydratedRef.current || startupVideoRecoveryRunRef.current) { + return undefined; + } + + startupVideoRecoveryRunRef.current = true; + + const candidates = collectRecoverableVideoCandidatesFromSnapshot( + historySnapshots.video, + ); + const limitedCandidates = candidates.slice( + 0, + CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_MAX_TASKS, + ); + + if (limitedCandidates.length === 0) { + return undefined; + } + + let cancelled = false; + + const recoverStartupVideoTasks = async () => { + let recoveredSnapshot = normalizeCreativeHistorySnapshot( + 'video', + historySnapshots.video, + ); + let hasRecoveredChanges = false; + + try { + const exactTasks = await fetchCreativeVideoTasksByIdentifiers( + limitedCandidates, + ); + const nearbyTasks = await fetchCreativeVideoTasksAroundCandidates( + limitedCandidates, + ); + const taskMatches = matchCreativeVideoTasksToCandidates( + limitedCandidates, + mergeCreativeTaskDtoLists(exactTasks, nearbyTasks), + ); + const exactTaskByRequestId = new Map(); + const exactTaskByTaskId = new Map(); + exactTasks.forEach((task) => { + const requestId = getTaskDtoRequestId(task); + if (requestId) { + exactTaskByRequestId.set(requestId, task); + } + const taskId = String(task?.task_id || task?.taskId || '').trim(); + if (taskId) { + exactTaskByTaskId.set(taskId, task); + } + }); + + for ( + let startIndex = 0; + startIndex < limitedCandidates.length && !cancelled; + startIndex += CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_CONCURRENCY + ) { + const currentBatch = limitedCandidates.slice( + startIndex, + startIndex + CREATIVE_CENTER_STARTUP_VIDEO_RECOVERY_CONCURRENCY, + ); + + const batchResults = await Promise.allSettled( + currentBatch.map(async (candidate) => { + let queryTaskId = candidate.queryTaskId; + const matchedTask = taskMatches.get(candidate.taskId); + if (matchedTask) { + const matchedTaskState = parseTaskDtoVideoState(matchedTask); + queryTaskId = matchedTaskState.taskId || queryTaskId; + if (queryTaskId || ['completed', 'failed'].includes(matchedTaskState.status)) { + return { + candidate, + queryTaskId, + nextTaskState: matchedTaskState, + }; + } + } + + const exactTask = exactTaskByRequestId.get(candidate.requestId); + + if (!queryTaskId && exactTask) { + const exactTaskState = parseTaskDtoVideoState(exactTask); + queryTaskId = exactTaskState.taskId; + if (queryTaskId) { + return { + candidate, + queryTaskId, + nextTaskState: exactTaskState, + }; + } + } + + if (!queryTaskId || cancelled) { + return null; + } + + const exactTaskById = exactTaskByTaskId.get(queryTaskId); + if (exactTaskById) { + return { + candidate, + queryTaskId, + nextTaskState: parseTaskDtoVideoState(exactTaskById), + }; + } + + try { + const response = await API.get( + `${API_ENDPOINTS.VIDEO_ASYNC_GENERATIONS}/${encodeURIComponent(queryTaskId)}`, + buildCreativeTaskStatusRequestConfig(), + ); + + if (cancelled) { + return null; + } + + return { + candidate, + queryTaskId, + nextTaskState: parseVideoFetchPayload(response), + }; + } catch (error) { + console.error('Failed to recover creative center video task from history:', error); + return { + candidate, + queryTaskId, + nextTaskState: null, + }; + } + }), + ); + + batchResults.forEach((result) => { + if (result.status !== 'fulfilled' || !result.value || cancelled) { + return; + } + + const { candidate, queryTaskId, nextTaskState } = result.value; + if (!queryTaskId) { + return; + } + + const taskIdPatch = patchVideoTaskInHistorySnapshot( + recoveredSnapshot, + candidate, + buildResolvedVideoTaskIdPatch(queryTaskId), + ); + if (taskIdPatch.hasChanged) { + recoveredSnapshot = taskIdPatch.snapshot; + hasRecoveredChanges = true; + } + + if (!nextTaskState) { + return; + } + + const taskStatePatch = patchVideoTaskInHistorySnapshot( + recoveredSnapshot, + candidate, + buildResolvedVideoTaskPatch(queryTaskId, nextTaskState), + /* + ? (nextTaskState.error || currentTask.error || '任务生成失败') + : '', + resultUrl: isCompleted + ? (resolvedURL || currentTask.resultUrl || currentTask.url) + : (currentTask.resultUrl || resolvedURL), + finalizingAt: 0, + pollable: Boolean(queryTaskId) && !(isCompleted || isFailed), + }), + */ + ); + if (taskStatePatch.hasChanged) { + recoveredSnapshot = taskStatePatch.snapshot; + hasRecoveredChanges = true; + } + }); + } + + if (!cancelled && hasRecoveredChanges) { + await persistCreativeHistorySnapshot('video', recoveredSnapshot, { + applySessionState: true, + }); + } + } catch (error) { + console.error('Failed to finish startup recovery for creative center video tasks:', error); + } + }; + + recoverStartupVideoTasks(); + + return () => { + cancelled = true; + }; + }, [historySnapshots.video, isLoggedIn]); + + useEffect(() => { + if ( + !isLoggedIn || + !historyHydratedRef.current || + activeTab !== 'image' || + !activeHistorySnapshot + ) { + return undefined; + } + + const reconcileSignature = buildCreativeReconcileSignature( + activeHistorySnapshot.id, + imageRecords, + 'image', + ); + if (reconcileSignature === lastActiveImageReconcileSignatureRef.current) { + return undefined; + } + lastActiveImageReconcileSignatureRef.current = reconcileSignature; + + const sessionRecords = normalizeImageHistoryRecords(activeHistorySnapshot); + const candidates = sessionRecords + .flatMap((record) => + record.images.map((image, imageIndex) => ({ + recordId: record.id, + imageId: image.id, + itemIndex: imageIndex, + queryTaskId: getRecoverableImageTaskId(image), + requestId: String(image?.requestId || '').trim(), + hasMedia: Boolean(getImageTaskMediaUrl(image)), + status: String(image?.status || '').trim().toLowerCase(), + recordModelName: String(record?.modelName || '').trim().toLowerCase(), + recordCreatedAt: Number(record?.createdAt) || 0, + recordUpdatedAt: Number(record?.updatedAt) || 0, + sortTimestamp: + Number(image?.submittedAt) || + Number(record?.updatedAt) || + Number(record?.createdAt) || + 0, + })), + ) + .filter( + (candidate) => + !candidate.hasMedia && + !isTerminalImageTaskStatus(candidate.status) && + Boolean(candidate.queryTaskId || candidate.requestId), + ); + + if (candidates.length === 0) { + return undefined; + } + + let cancelled = false; + + const reconcileActiveImageSession = async () => { + let recoveredSnapshot = normalizeCreativeHistorySnapshot( + 'image', + historySnapshots.image, + ); + let hasRecoveredChanges = false; + + const candidateTimes = candidates + .map((candidate) => + normalizeCreativeTimestampToSeconds( + candidate.sortTimestamp || + candidate.recordUpdatedAt || + candidate.recordCreatedAt, + ), + ) + .filter((value) => value > 0); + const baseStartTimestamp = + candidateTimes.length > 0 ? Math.min(...candidateTimes) : 0; + const baseEndTimestamp = + candidateTimes.length > 0 ? Math.max(...candidateTimes) : baseStartTimestamp; + const startTimestamp = Math.max(0, baseStartTimestamp - 120); + const endTimestamp = Math.max(startTimestamp + 1, baseEndTimestamp + 1800); + + try { + const response = await API.get( + '/api/task/self', + buildCreativeTaskStatusRequestConfig({ + params: { + p: 1, + page_size: 100, + status: 'SUCCESS', + start_timestamp: startTimestamp, + end_timestamp: endTimestamp, + }, + }), + ); + + if (cancelled) { + return; + } + + const items = Array.isArray(response?.data?.data?.items) + ? response.data.data.items + : []; + const imageTasks = items + .filter((item) => { + const action = String(item?.action || '').trim(); + if (action !== 'imageGenerate' && action !== 'imageEdit') { + return false; + } + return getTaskDtoImageUrls(item).length > 0; + }) + .sort( + (left, right) => + normalizeCreativeTimestampToSeconds(left?.submit_time) - + normalizeCreativeTimestampToSeconds(right?.submit_time), + ); + + if (imageTasks.length === 0) { + return; + } + + const usedTaskIds = new Set(); + const taskMatches = new Map(); + + candidates.forEach((candidate) => { + if (!candidate.queryTaskId) { + return; + } + const matchedTask = imageTasks.find( + (item) => String(item?.task_id || '').trim() === candidate.queryTaskId, + ); + if (!matchedTask) { + return; + } + taskMatches.set(candidate.imageId, matchedTask); + usedTaskIds.add(candidate.queryTaskId); + }); + + const groupedCandidates = Array.from( + candidates.reduce((map, candidate) => { + if (taskMatches.has(candidate.imageId)) { + return map; + } + const key = candidate.recordId; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(candidate); + return map; + }, new Map()), + ); + + groupedCandidates.forEach(([, group]) => { + const recordModelName = String(group[0]?.recordModelName || '').trim(); + const recordTimes = group + .map((candidate) => + normalizeCreativeTimestampToSeconds( + candidate.sortTimestamp || + candidate.recordUpdatedAt || + candidate.recordCreatedAt, + ), + ) + .filter((value) => value > 0); + const recordStart = recordTimes.length > 0 ? Math.min(...recordTimes) - 120 : 0; + const recordEnd = recordTimes.length > 0 ? Math.max(...recordTimes) + 1800 : 0; + + const matchedTasks = imageTasks.filter((item) => { + const taskId = String(item?.task_id || '').trim(); + if (!taskId || usedTaskIds.has(taskId)) { + return false; + } + const taskModelName = getTaskDtoModelName(item).toLowerCase(); + if (recordModelName && taskModelName && taskModelName !== recordModelName) { + return false; + } + const submitTime = normalizeCreativeTimestampToSeconds(item?.submit_time); + if (recordStart > 0 && submitTime > 0 && submitTime < recordStart) { + return false; + } + if (recordEnd > 0 && submitTime > 0 && submitTime > recordEnd) { + return false; + } + return true; + }); + + const sortedCandidates = [...group].sort( + (left, right) => left.sortTimestamp - right.sortTimestamp, + ); + + sortedCandidates.forEach((candidate, index) => { + const matchedTask = matchedTasks[index]; + if (!matchedTask) { + return; + } + const taskId = String(matchedTask?.task_id || '').trim(); + if (taskId) { + usedTaskIds.add(taskId); + } + taskMatches.set(candidate.imageId, matchedTask); + }); + }); + + for (const candidate of candidates) { + if (cancelled) { + break; + } + + const matchedTask = taskMatches.get(candidate.imageId); + if (!matchedTask) { + continue; + } + const imageUrls = getTaskDtoImageUrls(matchedTask); + const primaryImageUrl = imageUrls[0] || ''; + if (!primaryImageUrl) { + continue; + } + + const taskPatch = patchImageTaskInHistorySnapshot( + recoveredSnapshot, + candidate, + { + taskId: String(matchedTask?.task_id || '').trim(), + url: primaryImageUrl, + resultUrl: primaryImageUrl, + status: 'completed', + progress: 100, + error: '', + finalizingAt: 0, + progressUnavailable: false, + requestPollable: false, + }, + ); + if (taskPatch.hasChanged) { + recoveredSnapshot = taskPatch.snapshot; + hasRecoveredChanges = true; + } + } + + if (!cancelled && hasRecoveredChanges) { + await persistCreativeHistorySnapshot('image', recoveredSnapshot, { + applySessionState: true, + }); + } + } catch (error) { + console.error('Failed to reconcile active creative center image session:', error); + } + }; + + reconcileActiveImageSession(); + + return () => { + cancelled = true; + }; + }, [activeHistorySnapshot, activeTab, historySnapshots.image, imageRecords, isLoggedIn]); + + useEffect(() => { + if ( + !isLoggedIn || + !historyHydratedRef.current || + activeTab !== 'video' || + !activeHistorySnapshot + ) { + return undefined; + } + + const reconcileSignature = buildCreativeReconcileSignature( + activeHistorySnapshot.id, + videoRecords, + 'video', + ); + if (reconcileSignature === lastActiveVideoReconcileSignatureRef.current) { + return undefined; + } + lastActiveVideoReconcileSignatureRef.current = reconcileSignature; + + const sessionRecords = normalizeVideoHistoryRecords(activeHistorySnapshot); + const candidates = sessionRecords + .flatMap((record) => + record.tasks.map((task, taskIndex) => ({ + recordId: record.id, + taskId: task.id, + itemIndex: taskIndex, + queryTaskId: getRecoverableVideoTaskId(task), + requestId: String(task?.requestId || '').trim(), + hasMedia: Boolean(getVideoTaskMediaUrl(task)), + status: normalizeVideoTaskStatus(task.status), + recordModelName: String(record?.modelName || '').trim().toLowerCase(), + recordCreatedAt: Number(record?.createdAt) || 0, + recordUpdatedAt: Number(record?.updatedAt) || 0, + sortTimestamp: + Number(task?.submittedAt) || + Number(record?.updatedAt) || + Number(record?.createdAt) || + 0, + })), + ) + .filter( + (candidate) => + !candidate.hasMedia && + !isTerminalVideoTaskStatus(candidate.status) && + Boolean( + candidate.requestId || + candidate.queryTaskId || + candidate.sortTimestamp || + candidate.recordCreatedAt || + candidate.recordUpdatedAt, + ), + ); + + if (candidates.length === 0) { + return undefined; + } + + let cancelled = false; + + const reconcileActiveVideoSession = async () => { + let recoveredSnapshot = normalizeCreativeHistorySnapshot( + 'video', + historySnapshots.video, + ); + let hasRecoveredChanges = false; + + try { + const exactTasks = await fetchCreativeVideoTasksByIdentifiers(candidates); + + if (cancelled) { + return; + } + + const nearbyTasks = await fetchCreativeVideoTasksAroundCandidates(candidates); + const taskMatches = matchCreativeVideoTasksToCandidates( + candidates, + mergeCreativeTaskDtoLists(exactTasks, nearbyTasks), + ); + + for (const candidate of candidates) { + if (cancelled) { + break; + } + + let queryTaskId = candidate.queryTaskId; + const matchedTask = taskMatches.get(candidate.taskId); + if (matchedTask) { + const matchedTaskState = parseTaskDtoVideoState(matchedTask); + queryTaskId = matchedTaskState.taskId || queryTaskId; + const taskPatch = patchVideoTaskInHistorySnapshot( + recoveredSnapshot, + candidate, + buildResolvedVideoTaskPatch(queryTaskId, matchedTaskState), + ); + if (taskPatch.hasChanged) { + recoveredSnapshot = taskPatch.snapshot; + hasRecoveredChanges = true; + } + if (['completed', 'failed'].includes(matchedTaskState.status)) { + continue; + } + } + + if (queryTaskId) { + const taskIdPatch = patchVideoTaskInHistorySnapshot( + recoveredSnapshot, + candidate, + buildResolvedVideoTaskIdPatch(queryTaskId), + ); + if (taskIdPatch.hasChanged) { + recoveredSnapshot = taskIdPatch.snapshot; + hasRecoveredChanges = true; + } + + try { + const response = await API.get( + `${API_ENDPOINTS.VIDEO_ASYNC_GENERATIONS}/${encodeURIComponent(queryTaskId)}`, + buildCreativeTaskStatusRequestConfig(), + ); + + if (cancelled) { + break; + } + + const nextTaskState = parseVideoFetchPayload(response); + const nextStatus = normalizeVideoTaskStatus(nextTaskState.status); + const resolvedURL = nextTaskState.url || ''; + const isFailed = nextStatus === 'failed'; + const isCompleted = + !isFailed && (nextStatus === 'completed' || Boolean(resolvedURL)); + + const taskPatch = patchVideoTaskInHistorySnapshot( + recoveredSnapshot, + candidate, + { + taskId: queryTaskId, + status: isCompleted ? 'completed' : isFailed ? 'failed' : nextStatus, + progress: isCompleted ? 100 : nextTaskState.progress ?? 0, + url: isCompleted ? resolvedURL : '', + resultUrl: isCompleted ? resolvedURL : '', + content: nextTaskState.content || '', + error: isFailed ? (nextTaskState.error || '') : '', + finalizingAt: 0, + requestPollable: false, + pollable: Boolean(queryTaskId) && !(isCompleted || isFailed), + }, + ); + if (taskPatch.hasChanged) { + recoveredSnapshot = taskPatch.snapshot; + hasRecoveredChanges = true; + } + continue; + } catch (error) { + console.error('Failed to reconcile active creative center video task from history:', error); + } + } + } + + if (!cancelled && hasRecoveredChanges) { + await persistCreativeHistorySnapshot('video', recoveredSnapshot, { + applySessionState: true, + }); + } + } catch (error) { + console.error('Failed to reconcile active creative center video session:', error); + } + }; + + reconcileActiveVideoSession(); + + return () => { + cancelled = true; + }; + }, [activeHistorySnapshot, activeTab, historySnapshots.video, isLoggedIn, videoRecords]); + + const handleSubmit = async () => { + const currentUploadedImageItems = uploadedImages + .filter((item) => item?.status === 'uploaded' && item?.url) + .map((item, index) => normalizeCreativeSourceImageItem(item, index)) + .filter(Boolean) + .map((item) => ({ + ...item, + previewUrl: '', + status: 'uploaded', + })); + const uploadedImageUrls = currentUploadedImageItems.map((item) => item.url); + if ((!prompt.trim() && uploadedImageUrls.length === 0) || (isChatTab && isGenerating)) return; + if (!isLoggedIn) { + showWarning('\u8bf7\u5148\u767b\u5f55\u540e\u518d\u4f7f\u7528\u521b\u4f5c\u4e2d\u5fc3'); + window.setTimeout(() => { + window.location.href = '/login'; + }, 250); + return; + } + const currentPrompt = prompt; + const currentUploadedImageUrls = uploadedImageUrls; + const currentUploadedImageSources = currentUploadedImageItems; + setPrompt(''); + clearUploadedImages(); + if (isChatTab) { + setIsGenerating(true); + } + + if (activeTab === 'chat') { + const userMsg = { + role: 'user', + content: buildMessageContent( + currentPrompt, + currentUploadedImageUrls, + currentUploadedImageUrls.length > 0, + ), + id: Date.now(), + }; + const currentChatHistory = Array.isArray(chatMessagesRef.current) + ? chatMessagesRef.current + : []; + const nextUserMessages = [...currentChatHistory, userMsg]; + setChatMessages(nextUserMessages); + try { + const requestMessages = + buildCreativeCenterChatRequestMessages(nextUserMessages); + const payload = buildApiPayload( + requestMessages, + '', + createCreativeInputs(params, currentModelName, 'chat'), + PARAMETER_TOGGLES_DISABLED, + ); + const chatResponse = shouldUseCreativeCenterChatStream(currentModelName) + ? await postCreativeChatStreamRequest(payload) + : extractCreativeCenterChatResponse( + await postCreativeRequest(API_ENDPOINTS.CHAT_COMPLETIONS, payload), + ); + const processed = processThinkTags( + chatResponse.content, + chatResponse.reasoningContent, + ); + const content = + [processed.reasoningContent, processed.content].filter(Boolean).join('\n\n') || + (chatResponse.rawResponsePreview + ? `模型已返回响应,但格式未识别,以下是原始响应摘要:\n\n\`\`\`json\n${chatResponse.rawResponsePreview}\n\`\`\`` + : '模型已返回响应,但未解析到可展示内容。'); + const assistantMsg = { + role: 'assistant', + content, + id: Date.now() + 1, + }; + const nextMessages = [...nextUserMessages, assistantMsg]; + setChatMessages(nextMessages); + await persistCreativeHistorySnapshot( + 'chat', + updateCurrentCreativeSessionSnapshot('chat', { + model_name: currentModelName, + group: activeGroup, + prompt: currentPrompt, + payload: { + messages: nextMessages, + }, + updated_at: Date.now(), + }), + ); + } catch (error) { + console.error('Creative center chat error:', error); + const errorMsg = { + role: 'assistant', + content: `请求失败:${error.message || '请稍后再试。'}`, + id: Date.now() + 1, + }; + const nextMessages = [...nextUserMessages, errorMsg]; + setChatMessages(nextMessages); + await persistCreativeHistorySnapshot( + 'chat', + updateCurrentCreativeSessionSnapshot('chat', { + model_name: currentModelName, + group: activeGroup, + prompt: currentPrompt, + payload: { + messages: nextMessages, + }, + updated_at: Date.now(), + }), + ); + } + } else if (activeTab === 'image') { + const currentParamsSnapshot = createEffectiveParamsSnapshot( + 'image', + currentModelName, + params, + ); + const useEstimatedImageProgress = + shouldUseEstimatedImageProgress(currentModelName); + const generationCount = Number(params.generationCount) || 1; + const batchSeedBase = createBatchSeedBase(); + const taskRequestMetas = Array.from({ length: generationCount }, (_, index) => ({ + requestSeed: createTaskSeed(batchSeedBase, index), + requestUser: createTaskRequestUser(batchSeedBase, index), + requestId: createTaskRequestId(batchSeedBase, index), + })); + const recordId = createCreativeRecordId('image'); + const pendingRecord = { + id: recordId, + prompt: currentPrompt, + modelName: currentModelName, + group: activeGroup, + params: currentParamsSnapshot, + sourceImages: currentUploadedImageSources, + images: Array.from({ length: generationCount }, (_, index) => ({ + id: createCreativeRecordId(`image-task-${index + 1}`), + taskId: '', + url: '', + status: useEstimatedImageProgress ? 'submitted' : 'generating', + progress: useEstimatedImageProgress ? 3 : 0, + error: '', + resultUrl: '', + requestId: taskRequestMetas[index]?.requestId || '', + submittedAt: 0, + estimateStartAt: 0, + finalizingAt: 0, + progressUnavailable: false, + requestPollable: false, + })), + status: 'generating', + error: '', + total: generationCount, + completedCount: 0, + successCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const pendingRecords = [...imageRecordsRef.current, pendingRecord]; + syncImageRecordsState(pendingRecords); + setCollapsedImageRecordIds((prev) => ({ + ...prev, + [recordId]: false, + })); + persistImageRecords(pendingRecords, { + modelName: currentModelName, + prompt: currentPrompt, + params: pendingRecord.params, + }).catch((error) => { + console.error('Failed to persist initial creative center image record:', error); + }); + + try { + const imageTasks = Array.from({ length: generationCount }, (_, index) => + (async () => { + const taskId = pendingRecord.images[index].id; + const requestSeed = taskRequestMetas[index]?.requestSeed; + const requestUser = taskRequestMetas[index]?.requestUser; + const requestId = taskRequestMetas[index]?.requestId; + const submittedAt = Date.now(); + const estimateStartAt = submittedAt + index * CREATIVE_BATCH_REQUEST_SPACING_MS; + const basePayload = createBasePayload( + currentPrompt, + currentParamsSnapshot, + currentModelName, + 'image', + currentUploadedImageUrls, + ); + const shouldUseImageEditEndpoint = + !isAdobeImageModel && + (isGrokImageEditModel || currentUploadedImageUrls.length > 0); + const payload = isAdobeImageModel + ? { + model: currentModelName, + group: activeGroup, + prompt: + currentPrompt || + (currentUploadedImageUrls.length > 0 + ? 'Edit the provided media.' + : ''), + request_id: requestId, + seed: requestSeed, + seeds: [requestSeed], + user: requestUser, + } + : { + model: currentModelName, + group: activeGroup, + prompt: + shouldUseImageEditEndpoint && !currentPrompt + ? 'Edit the provided media.' + : currentPrompt, + n: 1, + response_format: 'url', + request_id: requestId, + seed: requestSeed, + seeds: [requestSeed], + user: requestUser, + }; + + if (isAdobeImageModel) { + if (isCurrentGPTImage2Model) { + payload.output_resolution = '1K'; + payload.aspect_ratio = + basePayload.aspect_ratio || + (currentParamsSnapshot.aspectRatio === 'auto' + ? '' + : currentParamsSnapshot.aspectRatio) || + '1:1'; + if (currentUploadedImageUrls.length > 0) { + payload.messages = buildGPTImage2ReferenceMessages( + currentPrompt, + currentUploadedImageUrls, + ); + } + } else { + if (basePayload.size) { + payload.size = basePayload.size; + } + payload.output_resolution = + basePayload.output_resolution || + currentParamsSnapshot.outputResolution || + '2K'; + if (basePayload.aspect_ratio) { + payload.aspect_ratio = basePayload.aspect_ratio; + } else if (basePayload.size) { + payload.size = basePayload.size; + } + if (currentUploadedImageUrls.length > 0) { + payload.image_urls = currentUploadedImageUrls; + } + } + } else { + if (!isGrokImageEditModel && basePayload.size) { + payload.size = basePayload.size; + } + if (shouldUseImageEditEndpoint) { + if (currentUploadedImageUrls.length === 1) { + payload.image = currentUploadedImageUrls[0]; + } else if (currentUploadedImageUrls.length > 1) { + payload.image = currentUploadedImageUrls; + } + } else { + if (currentUploadedImageUrls[0]) { + payload.image = currentUploadedImageUrls[0]; + } + } + if (basePayload.extra_body) { + payload.extra_body = basePayload.extra_body; + } + if (basePayload.aspect_ratio) { + payload.aspect_ratio = basePayload.aspect_ratio; + } + if (basePayload.output_resolution) { + payload.output_resolution = basePayload.output_resolution; + } + } + + patchImageTask(recordId, taskId, { + requestId, + requestPollable: true, + submittedAt, + estimateStartAt, + finalizingAt: 0, + status: useEstimatedImageProgress ? 'submitted' : 'generating', + progress: useEstimatedImageProgress ? 3 : 0, + }); + await waitForMs(index * CREATIVE_BATCH_REQUEST_SPACING_MS); + if (useEstimatedImageProgress) { + patchImageTask(recordId, taskId, { + status: 'generating', + progress: 5, + }); + } + const imageSubmitEndpoint = isAdobeImageModel + ? API_ENDPOINTS.IMAGE_ASYNC_GENERATIONS + : shouldUseImageEditEndpoint + ? API_ENDPOINTS.IMAGE_ASYNC_EDITS + : API_ENDPOINTS.IMAGE_ASYNC_GENERATIONS; + const data = await postCreativeRequest( + imageSubmitEndpoint, + payload, + { + 'X-Request-Id': requestId, + }, + ); + const remoteTaskId = data?.task_id || data?.id || ''; + const nextTaskState = parseImageFetchPayload(data); + const imageUrl = nextTaskState.url || ''; + const isSubmitFailed = nextTaskState.status === 'failed'; + const imageUrls = imageUrl ? [imageUrl] : []; + + if (useEstimatedImageProgress && imageUrls[0]) { + patchImageTask(recordId, taskId, { + status: 'finalizing', + progress: 96, + resultUrl: imageUrls[0], + finalizingAt: Date.now(), + error: '', + requestPollable: false, + }); + await waitForMs(180); + } + patchImageTask(recordId, taskId, { + taskId: remoteTaskId, + url: imageUrls[0] || '', + status: imageUrls[0] + ? 'completed' + : isSubmitFailed + ? 'failed' + : normalizeVideoTaskStatus(nextTaskState.status), + progress: + imageUrls[0] || isSubmitFailed + ? 100 + : nextTaskState.progress ?? (useEstimatedImageProgress ? 5 : 0), + error: isSubmitFailed ? nextTaskState.error || 'image generation failed' : '', + resultUrl: imageUrls[0] || '', + finalizingAt: 0, + progressUnavailable: false, + requestPollable: Boolean(remoteTaskId) && !(imageUrls[0] || isSubmitFailed), + }); + })() + .catch((requestError) => { + const requestErrorMessage = + getCreativeRequestErrorMessage(requestError); + const isRecoverableRequestError = + shouldTreatCreativeRequestErrorAsRecoverable(requestError); + patchImageTask(recordId, pendingRecord.images[index].id, { + status: isRecoverableRequestError ? 'submitted' : 'failed', + progress: isRecoverableRequestError ? 0 : 100, + finalizingAt: 0, + error: requestErrorMessage, + progressUnavailable: false, + requestPollable: isRecoverableRequestError, + }); + }), + ); + await Promise.allSettled(imageTasks); + + await persistImageRecords(imageRecordsRef.current, { + modelName: currentModelName, + prompt: currentPrompt, + params: pendingRecord.params, + }); + } catch (error) { + console.error('Creative center image error:', error); + const failedRecord = { + ...pendingRecord, + status: 'failed', + error: `生成失败:${error.message || '请稍后再试。'}`, + updatedAt: Date.now(), + }; + const failedRecords = pendingRecords.map((record) => + record.id === recordId ? failedRecord : record, + ); + setImageRecords(failedRecords); + await persistImageRecords(failedRecords, { + modelName: currentModelName, + prompt: currentPrompt, + params: failedRecord.params, + }); + } + } else if (activeTab === 'video') { + const currentParamsSnapshot = createEffectiveParamsSnapshot( + 'video', + currentModelName, + params, + ); + const useEstimatedVideoProgress = + shouldUseEstimatedVideoProgress(currentModelName); + const generationCount = Number(params.generationCount) || 1; + const batchSeedBase = createBatchSeedBase(); + const taskRequestMetas = Array.from({ length: generationCount }, (_, index) => ({ + requestSeed: createTaskSeed(batchSeedBase, index), + requestUser: createTaskRequestUser(batchSeedBase, index), + requestId: createTaskRequestId(batchSeedBase, index), + })); + const recordId = createCreativeRecordId('video'); + const pendingRecord = { + id: recordId, + prompt: currentPrompt, + modelName: currentModelName, + group: activeGroup, + params: currentParamsSnapshot, + sourceImages: currentUploadedImageSources, + tasks: Array.from({ length: generationCount }, (_, index) => ({ + id: createCreativeRecordId(`video-task-${index + 1}`), + taskId: '', + status: useEstimatedVideoProgress ? 'submitted' : 'generating', + url: '', + content: '', + progress: useEstimatedVideoProgress ? 3 : 0, + error: '', + resultUrl: '', + resultContent: '', + requestId: taskRequestMetas[index]?.requestId || '', + submittedAt: 0, + estimateStartAt: 0, + finalizingAt: 0, + progressUnavailable: false, + requestPollable: false, + pollable: false, + })), + status: 'generating', + error: '', + total: generationCount, + completedCount: 0, + successCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const pendingRecords = [...videoRecordsRef.current, pendingRecord]; + syncVideoRecordsState(pendingRecords); + setCollapsedVideoRecordIds((prev) => ({ + ...prev, + [recordId]: false, + })); + persistVideoRecords(pendingRecords, { + modelName: currentModelName, + prompt: currentPrompt, + params: pendingRecord.params, + }).catch((error) => { + console.error('Failed to persist initial creative center video record:', error); + }); + + try { + const videoRequests = Array.from({ length: generationCount }, (_, index) => + (async () => { + const localTaskId = pendingRecord.tasks[index].id; + const requestSeed = taskRequestMetas[index]?.requestSeed; + const requestUser = taskRequestMetas[index]?.requestUser; + const requestId = taskRequestMetas[index]?.requestId; + const submittedAt = Date.now(); + const estimateStartAt = submittedAt + index * CREATIVE_BATCH_REQUEST_SPACING_MS; + const basePayload = createBasePayload( + currentPrompt, + currentParamsSnapshot, + currentModelName, + 'video', + currentUploadedImageUrls, + ); + let data; + + if (isChatCompletionVideoModel) { + basePayload.seed = requestSeed; + basePayload.seeds = [requestSeed]; + basePayload.user = requestUser; + basePayload.request_id = requestId; + basePayload.metadata = { + creative_request_id: requestUser, + creative_seed: requestSeed, + creative_index: index + 1, + }; + patchVideoTask(recordId, localTaskId, { + requestId, + requestPollable: true, + submittedAt, + estimateStartAt, + finalizingAt: 0, + status: useEstimatedVideoProgress ? 'submitted' : 'generating', + progress: useEstimatedVideoProgress ? 3 : 0, + }); + await waitForMs(index * CREATIVE_BATCH_REQUEST_SPACING_MS); + if (useEstimatedVideoProgress) { + patchVideoTask(recordId, localTaskId, { + status: 'generating', + progress: 5, + }); + } + data = await postCreativeRequest( + API_ENDPOINTS.CHAT_COMPLETIONS, + basePayload, + { + 'X-Request-Id': requestId, + }, + ); + const content = data?.choices?.[0]?.message?.content || ''; + const videoUrl = extractVideoUrlFromMessage(content); + if (useEstimatedVideoProgress && videoUrl) { + patchVideoTask(recordId, localTaskId, { + taskId: data?.id || '', + status: 'finalizing', + content: '', + progress: 96, + error: '', + resultUrl: videoUrl, + resultContent: content, + requestId, + finalizingAt: Date.now(), + progressUnavailable: false, + requestPollable: false, + pollable: false, + }); + await waitForMs(180); + } + patchVideoTask(recordId, localTaskId, { + taskId: data?.id || '', + status: videoUrl ? 'completed' : 'failed', + url: videoUrl || '', + content: videoUrl ? '' : content, + progress: 100, + error: videoUrl ? '' : '未获取到视频结果', + resultUrl: videoUrl || '', + resultContent: content, + requestId, + finalizingAt: 0, + progressUnavailable: false, + requestPollable: false, + pollable: false, + }); + return; + } + + const payload = isAdobeSoraModel + ? { + model: currentModelName, + prompt: currentPrompt, + async: true, + request_id: requestId, + seed: requestSeed, + seeds: [requestSeed], + user: requestUser, + metadata: { + creative_request_id: requestUser, + creative_seed: requestSeed, + creative_index: index + 1, + }, + } + : { + model: currentModelName, + group: activeGroup, + prompt: currentPrompt, + request_id: requestId, + seed: requestSeed, + seeds: [requestSeed], + user: requestUser, + metadata: { + creative_request_id: requestUser, + creative_seed: requestSeed, + creative_index: index + 1, + }, + }; + [ + 'size', + 'seconds', + 'quality', + 'preset', + 'resolution_name', + 'video_config', + 'duration', + 'aspect_ratio', + 'resolution', + 'reference_mode', + ].forEach((key) => { + if (basePayload[key] !== undefined) { + payload[key] = basePayload[key]; + } + }); + if (isAdobeSoraModel && currentUploadedImageUrls[0]) { + payload.image_url = currentUploadedImageUrls[0]; + } else if ( + currentModelName === 'grok-imagine-1.0-video' && + currentUploadedImageUrls.length > 0 + ) { + payload.image_reference = currentUploadedImageUrls; + } else if (currentUploadedImageUrls[0]) { + payload.image = currentUploadedImageUrls[0]; + } + patchVideoTask(recordId, localTaskId, { + requestId, + requestPollable: true, + submittedAt, + estimateStartAt, + finalizingAt: 0, + status: useEstimatedVideoProgress ? 'submitted' : 'generating', + progress: useEstimatedVideoProgress ? 3 : 0, + }); + await waitForMs(index * CREATIVE_BATCH_REQUEST_SPACING_MS); + if (useEstimatedVideoProgress) { + patchVideoTask(recordId, localTaskId, { + status: 'generating', + progress: 5, + }); + } + data = await postCreativeRequest(API_ENDPOINTS.VIDEO_ASYNC_GENERATIONS, payload, { + 'X-Request-Id': requestId, + }); + const submitPayload = + data?.data && typeof data.data === 'object' ? data.data : data; + const immediateResultUrl = + normalizeVideoMediaUrl(submitPayload?.url) || + normalizeVideoMediaUrl(submitPayload?.video_url) || + normalizeVideoMediaUrl(submitPayload?.data?.[0]?.url) || + normalizeVideoMediaUrl(submitPayload?.data?.[0]?.video_url) || + normalizeVideoMediaUrl(submitPayload?.result_url); + const normalizedStatus = normalizeVideoTaskStatus( + submitPayload?.status || + (immediateResultUrl ? 'completed' : 'submitted'), + ); + const isImmediateFailed = normalizedStatus === 'failed'; + const isImmediateCompleted = !isImmediateFailed && Boolean(immediateResultUrl); + if (useEstimatedVideoProgress && isImmediateCompleted) { + patchVideoTask(recordId, localTaskId, { + taskId: submitPayload?.task_id || submitPayload?.id || '', + status: 'finalizing', + url: '', + content: submitPayload?.message || '', + progress: 96, + error: '', + resultUrl: immediateResultUrl || '', + requestId, + finalizingAt: Date.now(), + progressUnavailable: false, + requestPollable: false, + pollable: false, + }); + await waitForMs(180); + } + patchVideoTask(recordId, localTaskId, { + taskId: submitPayload?.task_id || submitPayload?.id || '', + status: isImmediateCompleted ? 'completed' : normalizedStatus, + url: isImmediateCompleted ? immediateResultUrl : '', + content: submitPayload?.message || '', + progress: + isImmediateCompleted || isImmediateFailed + ? 100 + : parseProgressValue(submitPayload?.progress) ?? 0, + error: isImmediateFailed + ? submitPayload?.error?.message || + submitPayload?.fail_reason || + submitPayload?.message || + '浠诲姟鐢熸垚澶辫触' + : '', + resultUrl: isImmediateCompleted ? immediateResultUrl : '', + requestId, + finalizingAt: 0, + progressUnavailable: false, + requestPollable: + !isImmediateCompleted && + !isImmediateFailed && + !Boolean(submitPayload?.task_id || submitPayload?.id) && + Boolean(requestId), + pollable: + !isImmediateCompleted && + !isImmediateFailed && + Boolean( + submitPayload?.task_id || submitPayload?.id || requestId, + ), + }); + })() + .catch((requestError) => { + const requestErrorMessage = + getCreativeRequestErrorMessage(requestError); + const isRecoverableRequestError = + shouldTreatCreativeRequestErrorAsRecoverable(requestError); + patchVideoTask(recordId, pendingRecord.tasks[index].id, { + status: isRecoverableRequestError ? 'submitted' : 'failed', + url: '', + progress: isRecoverableRequestError ? 0 : 100, + finalizingAt: 0, + progressUnavailable: false, + requestPollable: isRecoverableRequestError, + content: requestErrorMessage, + error: requestErrorMessage, + pollable: isRecoverableRequestError, + }); + }), + ); + + await Promise.allSettled(videoRequests); + + await persistVideoRecords(videoRecordsRef.current, { + modelName: currentModelName, + prompt: currentPrompt, + params: pendingRecord.params, + }); + } catch (error) { + console.error('Creative center video error:', error); + const failedRecord = { + ...pendingRecord, + status: 'failed', + error: `生成失败:${error.message || '请稍后再试。'}`, + updatedAt: Date.now(), + }; + const failedRecords = pendingRecords.map((record) => + record.id === recordId ? failedRecord : record, + ); + setVideoRecords(failedRecords); + await persistVideoRecords(failedRecords, { + modelName: currentModelName, + prompt: currentPrompt, + params: failedRecord.params, + }); + } + } + if (isChatTab) { + setIsGenerating(false); + } + }; + + if (isCreativeCenterBootstrapping) { + return ( +
+
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+ {/* 动态背景光效 */} +
+
+
+
+ + +
+ {activeTab === 'chat' && ( +
+
+
+ {chatMessages.length === 0 && !isGenerating && ( +
+
+
+
+ {selectedModel?.icon || } +
+
+
+ 当前模型 +
+
+

+ {selectedModel?.name || '对话模型'} +

+

+ {selectedModel?.desc || '这里会显示当前对话模型的介绍,帮助你在开始前快速了解它适合做什么。'} +

+
+
+ )} + {chatMessages.map((msg) => ( +
+
+ {getMessageImages(msg.content).length > 0 && ( +
+ {getMessageImages(msg.content).map((imageUrl, index) => ( + {`uploaded-${index + ))} +
+ )} + {getMessageText(msg.content) ? ( +

+ {getMessageText(msg.content)} +

+ ) : null} +
+
+ ))} + {isGenerating && ( +
+
+ + 正在深度思考... +
+
+ )} +
+
+
+ )} + + {activeTab !== 'chat' && ( +
+ {activeTab === 'image' && imageRecords.length > 0 ? ( +
+
+ {imageRecords.map((record, recordIndex) => { + const recordModel = findModelCard('image', record.modelName); + const metaSummary = formatImageRecordSummary(record); + const completedImageItems = getCompletedImageItems(record); + const selectedImageItems = getSelectedImageItems(record); + const selectedImageIdSet = new Set(selectedImageTaskIds[record.id] || []); + const isImageRecordCollapsed = collapsedImageRecordIds[record.id] ?? false; + const recordTime = formatCreativeRecordTime( + record.updatedAt || record.createdAt, + ); + + return ( +
+
+
+ {recordModel?.icon || } +
+
+
+
+ + +
+
+ + {!isImageRecordCollapsed && (record.status === 'generating' ? ( +
+
+
+ + + 正在生成图片,已完成 {record.completedCount || 0} / {record.total || 0} + +
+
+
+
+
+ {record.images.length > 0 ? ( +
+ {record.images.map((imageItem, imageIndex) => ( +
+ {imageItem.url ? ( + <> + {`Generating +
+ + + +
+
+ 已完成 +
+ + ) : ( +
+
+ {imageItem.status === 'failed' ? ( + + ) : ( + + )} + + {getTaskStatusLabel(imageItem.status)} + +
+ {renderPendingTaskProgress({ + task: imageItem, + taskIndex: imageIndex, + modelName: record.modelName, + params: record.params, + taskType: 'image', + detailText: imageItem.error || '', + detailClassName: 'text-red-500', + })} +
+ )} +
+ ))} +
+ ) : null} +
+ ) : record.status === 'failed' ? ( +
+ {record.error || '本次图片生成失败,请稍后重试。'} +
+ ) : ( +
+ {record.images.map((imageItem, imageIndex) => ( +
+ {imageItem.url ? ( + <> + {`Generated +
+ + + +
+ + ) : ( +
+
+ {imageItem.status === 'failed' ? ( + + ) : ( + + )} + + {getTaskStatusLabel(imageItem.status)} + +
+ {renderPendingTaskProgress({ + task: imageItem, + taskIndex: imageIndex, + modelName: record.modelName, + params: record.params, + taskType: 'image', + detailText: imageItem.error || '', + detailClassName: 'text-red-500', + })} +
+ )} +
+ ))} +
+ ))} + + {!isImageRecordCollapsed ? ( +
+ {completedImageItems.length > 0 ? ( + <> + + {selectedImageItems.length > 0 ? ( + <> + + + + ) : null} + + ) : null} + +
+ ) : null} +
+
+
+ ); + })} +
+
+ ) : activeTab === 'video' && videoRecords.length > 0 ? ( +
+
+ {videoRecords.map((record, recordIndex) => { + const recordModel = findModelCard('video', record.modelName); + const metaSummary = formatVideoRecordSummary(record); + const completedVideoTasks = getCompletedVideoTasks(record); + const selectedVideoTasks = getSelectedVideoTasks(record); + const selectedVideoIdSet = new Set(selectedVideoTaskIds[record.id] || []); + const isVideoRecordCollapsed = collapsedVideoRecordIds[record.id] ?? false; + const recordTime = formatCreativeRecordTime( + record.updatedAt || record.createdAt, + ); + const videoCardAspectRatio = getCreativeVideoCardAspectRatio(record); + const videoCardObjectFitClass = getCreativeVideoCardObjectFitClass(record); + + return ( +
+
+
+ {recordModel?.icon ||
+
+
+
+ + +
+
+ + {!isVideoRecordCollapsed && (record.status === 'generating' ? ( +
+
+
+ + + 正在提交视频任务,已完成 {record.completedCount || 0} / {record.total || 0} + +
+
+
+
+
+ {record.tasks.length > 0 ? ( +
+ {record.tasks.map((task, taskIndex) => ( +
+ {getVideoTaskMediaUrl(task) ? ( +
+
+ ) : ( +
+
+ {task.status === 'failed' ? ( + + ) : ( + + )} + + {getTaskStatusLabel(task.status)} + +
+ {renderPendingTaskProgress({ + task, + taskIndex, + modelName: record.modelName, + params: record.params, + taskType: 'video', + detailText: + task.content || + task.error || + '', + detailClassName: + task.status === 'failed' + ? 'text-red-500' + : 'text-slate-500', + })} +
+ )} +
+ ))} +
+ ) : null} +
+ ) : record.status === 'failed' ? ( +
+ {record.error || '本次视频生成失败,请稍后重试。'} +
+ ) : ( +
+ {record.tasks.map((task, taskIndex) => ( +
+ {getVideoTaskMediaUrl(task) ? ( +
+
+ ) : ( +
+
+ {task.status === 'failed' ? ( + + ) : ( + + )} + + {getTaskStatusLabel(task.status)} + +
+ {renderPendingTaskProgress({ + task, + taskIndex, + modelName: record.modelName, + params: record.params, + taskType: 'video', + detailText: + task.content || + task.error || + '', + detailClassName: + task.status === 'failed' + ? 'text-red-500' + : 'text-slate-500', + })} +
+ )} +
+ ))} +
+ ))} + + {!isVideoRecordCollapsed ? ( +
+ {completedVideoTasks.length > 0 ? ( + <> + + {selectedVideoTasks.length > 0 ? ( + <> + + + + ) : null} + + ) : null} + +
+ ) : null} +
+
+
+ ); + })} +
+
+ ) : ( +
+
+
+ {selectedModel?.icon || (activeTab === 'image' ? :
+
+
+ 当前模型 +
+
+

+ {selectedModel?.name || (activeTab === 'image' ? '图片模型' : '视频模型')} +

+

+ {selectedModel?.desc || '这里会显示当前模型的介绍,帮助你在开始创作前快速了解它更擅长生成什么内容。'} +

+
+
+ )} +
+ )} + +
+
+
+
+ +
+ {isCurrentModelImageUploadEnabled ? ( +
+
+ +
+
+ ) : null} +