From 0071e9416c22bd15243c087ab1aefb60c468aa4c Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 20 Aug 2026 22:58:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E6=A8=A1=E5=9E=8B=E4=B8=8E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=90=8C=E6=AD=A5=20-=20=E5=AE=8C=E5=96=84=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E3=80=81=E4=BE=9B=E5=BA=94=E5=95=86=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=92=8C=E6=9C=AC=E5=9C=B0=E6=8C=81=E4=B9=85=E5=8C=96=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth.go | 28 ++- backend/internal/handler/security.go | 25 ++- backend/internal/handler/security_test.go | 20 +++ backend/internal/model/models.go | 1 + backend/internal/service/admin.go | 2 +- backend/internal/service/channel_models.go | 17 +- backend/internal/service/model_capability.go | 15 ++ backend/internal/service/provider.go | 168 +++++++++++++++++- .../service/provider_request_types.go | 22 +++ backend/internal/service/provider_test.go | 75 ++++++++ web/src/components/layout/app-top-nav.tsx | 14 -- .../components/layout/client-root-init.tsx | 5 +- .../layout/system-announcement-center.tsx | 78 +++----- web/src/components/model-logo.tsx | 53 ++++-- web/src/hooks/use-wallet-balance.ts | 8 +- web/src/lib/generation-error.ts | 11 ++ web/src/lib/model-capabilities.ts | 15 ++ web/src/lib/model-protocols.ts | 4 +- web/src/lib/user-session.ts | 6 +- web/src/services/api/auth.ts | 33 +++- web/src/services/api/resources.ts | 8 +- web/src/services/api/skills.ts | 33 +++- web/src/services/api/video.ts | 100 ++++++++++- web/src/services/resource-blob-cache.ts | 17 +- web/src/services/user-data-sync.ts | 65 ++----- web/src/stores/use-asset-store.ts | 49 +++-- web/src/stores/use-config-store.ts | 1 + web/src/stores/use-local-runtime-store.ts | 7 +- web/test/generation-task-local.test.ts | 5 + ...user-scoped-generation-persistence.test.ts | 6 +- 30 files changed, 688 insertions(+), 203 deletions(-) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index bbc2ffb79..82583cff9 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -726,14 +726,28 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, capability := "text" var channelModel *model.ChannelModel if !(c.Request.Method == http.MethodGet && path == "/models") { - var modelErr error - channelModel, modelErr = svc.SystemChannelModel(channel.ID, modelName) - if modelErr != nil || channelModel.Protocol == "" { - fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权该模型或模型协议尚未配置")) - return + if c.Request.Method == http.MethodGet && systemMiniMaxTaskPath.MatchString(path) && modelName == "" { + supported, supportErr := svc.SystemChannelHasProtocol(channel.ID, model.ChannelInterfaceMiniMaxVideo) + if supportErr != nil { + failService(c, supportErr) + return + } + if !supported { + fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权 MiniMax 视频协议")) + return + } + protocol = model.ChannelInterfaceMiniMaxVideo + capability = "video" + } else { + var modelErr error + channelModel, modelErr = svc.SystemChannelModel(channel.ID, modelName) + if modelErr != nil || channelModel.Protocol == "" { + fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权该模型或模型协议尚未配置")) + return + } + protocol = channelModel.Protocol + capability = channelModel.Capability } - protocol = channelModel.Protocol - capability = channelModel.Capability } if err := authorizeSystemProxy(channel, protocol, c.Request.Method, path, c.GetHeader("Content-Type"), body); err != nil { fail(c, http.StatusForbidden, err) diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index b1febb58b..b10b9167b 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -32,6 +32,8 @@ var ( customArkVideoTaskPath = regexp.MustCompile(`(?:^|/)contents/generations/tasks/[^/]+$`) customGeminiOperationPath = regexp.MustCompile(`(?:^|/)(?:models/[^/]+/)?operations/[^/]+$`) customNovitaTaskResultPath = regexp.MustCompile(`(?:^|/)async/task-result$`) + customMiniMaxTaskPath = regexp.MustCompile(`(?:^|/)v2/query/video_generation/[^/]+$`) + systemMiniMaxTaskPath = regexp.MustCompile(`^/v2/query/video_generation/[^/]+$`) openAIPostEndpoints = map[string]bool{ "/responses": true, "/chat/completions": true, "/images/generations": true, "/images/edits": true, "/audio/speech": true, @@ -71,7 +73,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont } allowed := requestPath == "/models" || strings.HasSuffix(requestPath, "/models") if apiFormat == "openai" { - allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customVideoContentPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath) + allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customVideoContentPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath) || customMiniMaxTaskPath.MatchString(requestPath) } else { allowed = allowed || customGeminiOperationPath.MatchString(requestPath) } @@ -89,7 +91,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont } if apiFormat == "openai" { multipartAllowed := mediaType == "multipart/form-data" && (strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/videos")) - jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks") || strings.HasSuffix(requestPath, "/video/create")) + jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks") || strings.HasSuffix(requestPath, "/video/create") || strings.HasSuffix(requestPath, "/v2/video_generation")) if len(query) != 0 || (!multipartAllowed && !jsonAllowed) { return errors.New("自定义渠道不允许访问该上游接口") } @@ -160,6 +162,23 @@ func authorizeSystemProxy(channel *model.ModelChannel, protocol model.ChannelInt if method == http.MethodGet && requestPath == "/models" { return nil } + if protocol == model.ChannelInterfaceMiniMaxVideo { + if method == http.MethodGet && systemMiniMaxTaskPath.MatchString(requestPath) { + return nil + } + if method != http.MethodPost || requestPath != "/v2/video_generation" { + return errors.New("系统渠道不允许访问该上游接口") + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "application/json" { + return errors.New("MiniMax 视频生成请求必须使用 application/json") + } + modelName := proxyRequestModel(contentType, body) + if modelName == "" || !channelAllowsModel(channel, modelName) { + return errors.New("当前系统渠道未授权该模型") + } + return nil + } if protocol == model.ChannelInterfaceGeminiVeo || protocol == model.ChannelInterfaceGeminiImage { matches := geminiGeneratePath.FindStringSubmatch(requestPath) if method != http.MethodPost || len(matches) != 3 { @@ -196,7 +215,7 @@ func interfaceAllowsProxyPath(interfaceType model.ChannelInterfaceType, requestP return requestPath == "/images/generations" case model.ChannelInterfaceOpenAIAudio: return requestPath == "/audio/speech" - case model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceGeminiImage, model.ChannelInterfaceNovitaVideo: + case model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceGeminiImage, model.ChannelInterfaceNovitaVideo, model.ChannelInterfaceMiniMaxVideo: return false default: return true diff --git a/backend/internal/handler/security_test.go b/backend/internal/handler/security_test.go index 73f18c25f..57d9c1991 100644 --- a/backend/internal/handler/security_test.go +++ b/backend/internal/handler/security_test.go @@ -125,6 +125,26 @@ func TestAuthorizeSystemProxyRestrictsModelProtocol(t *testing.T) { } } +func TestAuthorizeSystemProxyMiniMaxVideoCreateAndPoll(t *testing.T) { + channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["MiniMax-H3"]`} + createBody := []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"test"}]}`) + if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "application/json", createBody); err != nil { + t.Fatalf("MiniMax create should be allowed: %v", err) + } + if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodGet, "/v2/query/video_generation/task-1", "", nil); err != nil { + t.Fatalf("MiniMax poll should be allowed: %v", err) + } + if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodGet, "/v2/account", "", nil); err == nil { + t.Fatal("arbitrary MiniMax GET should be rejected") + } + if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "text/plain", createBody); err == nil { + t.Fatal("MiniMax non-JSON create should be rejected") + } + if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "application/json", []byte(`{"model":"unapproved"}`)); err == nil { + t.Fatal("unapproved MiniMax model should be rejected") + } +} + func TestAuthorizeSystemProxyVolcengineArkImageOnlyAllowsGenerations(t *testing.T) { body := []byte(`{"model":"doubao-seedream-test"}`) channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["doubao-seedream-test"]`} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 93d5fb37d..830f014bc 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -79,6 +79,7 @@ const ( ChannelInterfaceVolcengineJiMengVideo ChannelInterfaceType = "volcengine-jimeng-video" ChannelInterfaceGeminiVeo ChannelInterfaceType = "gemini-veo" ChannelInterfaceNovitaVideo ChannelInterfaceType = "novita-video" + ChannelInterfaceMiniMaxVideo ChannelInterfaceType = "minimax-video" ApiCallStatusSucceeded ApiCallStatus = "succeeded" ApiCallStatusFailed ApiCallStatus = "failed" diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 545fff8b6..987a9b326 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -816,7 +816,7 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel func validChannelInterfaceType(value model.ChannelInterfaceType) bool { switch value { - case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceGeminiImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo: + case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceGeminiImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceNovitaVideo, model.ChannelInterfaceMiniMaxVideo: return true default: return false diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index adacbc78d..487cce864 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -85,6 +85,21 @@ func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model. return s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(modelKey), "models/")) } +// SystemChannelHasProtocol 用于没有携带 model 字段的轮询请求:先确认渠道确实配置了该协议, +// 再由 handler 按协议限定请求路径,避免用空模型绕过系统渠道授权。 +func (s *Service) SystemChannelHasProtocol(channelID string, protocol model.ChannelInterfaceType) (bool, error) { + items, err := s.repo.ChannelModels(channelID, false) + if err != nil { + return false, err + } + for _, item := range items { + if item.Protocol == protocol { + return true, nil + } + } + return false, nil +} + func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err @@ -517,7 +532,7 @@ func capabilityForProtocol(protocol model.ChannelInterfaceType) string { return "image" case model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio: return "audio" - case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo: + case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceNovitaVideo, model.ChannelInterfaceMiniMaxVideo: return "video" case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse: return "text" diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index a663eab52..9769bd6c3 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -228,6 +228,21 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo video.Ratios = []string{"16:9", "9:16", "1:1"} video.Resolutions = []string{"1080p"} video.DefaultResolution = "1080p" + case model.ChannelInterfaceMiniMaxVideo: + video.References.MaxImages = 9 + video.References.MaxImageBytes = 30 * 1024 * 1024 + video.References.MaxVideos = 3 + video.References.MaxVideoBytes = 50 * 1024 * 1024 + video.References.MaxVideoDuration = 15 + video.References.MaxAudios = 3 + video.References.MaxAudioBytes = 15 * 1024 * 1024 + video.References.MaxAudioDuration = 15 + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Default: 5} + video.Ratios = []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} + video.DefaultRatio = "16:9" + video.Resolutions = []string{"768P", "2K"} + video.DefaultResolution = "768P" + video.Watermark = VideoBooleanConfig{Supported: true, Default: false} } return &ModelCapabilityConfig{Version: 1, Text: text, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video} } diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index c96c65e31..67737c82e 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -239,7 +239,7 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } if resumedProviderRequestID(ctx) == "" { - requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) + requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) || input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo) if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil { return nil, err } @@ -1689,6 +1689,9 @@ func audioFormatMimeType(format string) string { } func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + if input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo) { + return runMiniMaxVideoTask(ctx, input) + } if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengVideo) { return runVolcengineJiMengVideoTask(ctx, input) } @@ -1818,6 +1821,159 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string] return nil, errors.New("视频生成超时") } +func runMiniMaxVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + if strings.TrimSpace(input.Prompt) == "" { + return nil, errors.New("MiniMax 视频提示词不能为空") + } + if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 { + return nil, errors.New("MiniMax 视频最多支持 9 张参考图、3 个参考视频和 3 个参考音频") + } + content := []miniMaxVideoContent{{Type: "text", Text: strings.TrimSpace(input.Prompt)}} + for index, image := range input.ReferenceImages { + url, err := miniMaxMediaURLValue(image) + if err != nil { + return nil, fmt.Errorf("MiniMax 参考图无效:%w", err) + } + role := "reference_image" + if len(input.ReferenceVideos) == 0 && len(input.ReferenceAudios) == 0 && len(input.ReferenceImages) <= 2 { + if index == 0 { + role = "first_frame" + } else if index == 1 { + role = "last_frame" + } + } + content = append(content, miniMaxVideoContent{Type: "image_url", ImageURL: &miniMaxMediaURL{URL: url}, Role: role}) + } + for _, video := range input.ReferenceVideos { + url, err := miniMaxMediaURLValue(video) + if err != nil { + return nil, fmt.Errorf("MiniMax 参考视频无效:%w", err) + } + content = append(content, miniMaxVideoContent{Type: "video_url", VideoURL: &miniMaxMediaURL{URL: url}, Role: "reference_video"}) + } + for _, audio := range input.ReferenceAudios { + url, err := miniMaxMediaURLValue(audio) + if err != nil { + return nil, fmt.Errorf("MiniMax 参考音频无效:%w", err) + } + content = append(content, miniMaxVideoContent{Type: "audio_url", AudioURL: &miniMaxMediaURL{URL: url}, Role: "reference_audio"}) + } + watermark := parseBool(input.Config.VideoWatermark, false) + frameMode := len(input.ReferenceImages) > 0 && len(input.ReferenceImages) <= 2 && len(input.ReferenceVideos) == 0 && len(input.ReferenceAudios) == 0 + body := miniMaxVideoRequest{ + Model: input.Config.Model, + Content: content, + Resolution: normalizeMiniMaxResolution(input.Config.VQuality), + Duration: normalizeMiniMaxDuration(input.Config.VideoSeconds), + Ratio: normalizeMiniMaxRatio(input.Config.Size, frameMode), + AIGCWatermark: &watermark, + } + id := resumedProviderRequestID(ctx) + if id == "" { + var created map[string]interface{} + if err := postJSON(ctx, input.Config, "/v2/video_generation", body, &created); err != nil { + return nil, err + } + id = firstNonEmptyString(stringField(created, "task_id"), stringField(created, "id")) + if data, ok := created["data"].(map[string]interface{}); ok { + id = firstNonEmptyString(id, stringField(data, "task_id"), stringField(data, "id")) + } + } + if id == "" { + return nil, errors.New("MiniMax 视频接口没有返回任务 ID") + } + for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); { + var response map[string]interface{} + if err := getJSON(ctx, input.Config, "/v2/query/video_generation/"+url.PathEscape(id), &response); err != nil { + return nil, err + } + task, _ := response["task"].(map[string]interface{}) + if task == nil { + task = response + } + status := strings.ToLower(stringField(task, "status")) + if status == "succeeded" || status == "completed" { + contentValue, _ := task["content"].(map[string]interface{}) + videoURL := stringField(contentValue, "url") + if videoURL == "" { + return nil, fmt.Errorf("MiniMax 视频任务 %s 已完成但没有返回视频 URL", id) + } + data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), input.Config, videoURL) + if err != nil { + return nil, fmt.Errorf("MiniMax 视频结果下载失败(任务 %s):%w", id, err) + } + mimeType = normalizedMediaMimeType(mimeType, data) + return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil + } + if status == "failed" || status == "cancelled" { + return nil, fmt.Errorf("MiniMax 视频生成失败(任务 %s):%s", id, miniMaxTaskError(task)) + } + if err := sleepContext(ctx, 2500*time.Millisecond); err != nil { + return nil, err + } + } + return nil, fmt.Errorf("MiniMax 视频生成超时(任务 %s)", id) +} + +func miniMaxMediaURLValue(media providerMedia) (string, error) { + value := strings.TrimSpace(media.URL) + if !isPublicMediaURL(value) { + return "", errors.New("参考素材必须使用公网 HTTP(S) URL,请启用对象存储或提供公网素材地址") + } + if _, err := ValidateOutboundURL(value); err != nil { + return "", err + } + return value, nil +} + +func normalizeMiniMaxResolution(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "2k" || normalized == "4k" || normalized == "high" || normalized == "1080" || normalized == "1080p" || normalized == "1440p" || normalized == "2160p" { + return "2K" + } + return "768P" +} + +func normalizeMiniMaxDuration(value string) int { + duration, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + duration = 5 + } + if duration < 4 { + return 4 + } + if duration > 15 { + return 15 + } + return duration +} + +func normalizeMiniMaxRatio(value string, frameMode bool) string { + if frameMode { + return "adaptive" + } + allowed := map[string]bool{"adaptive": true, "21:9": true, "16:9": true, "4:3": true, "1:1": true, "3:4": true, "9:16": true} + value = strings.TrimSpace(value) + if allowed[value] { + return value + } + return "16:9" +} + +func miniMaxTaskError(task map[string]interface{}) string { + if value, ok := task["error"].(map[string]interface{}); ok { + message := stringField(value, "message") + code := stringField(value, "code") + if message != "" && code != "" { + return code + ":" + message + } + if message != "" { + return message + } + } + return "上游返回失败" +} + func runGeminiVeoVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 { return nil, errors.New("Gemini Veo 当前只支持 1 张起始图,不支持参考视频或音频") @@ -2530,7 +2686,7 @@ func validateGenerationInterface(mode string, interfaceType string) error { allowed := map[string]map[string]bool{ "text": {"chat-completion": true, "openai-response": true}, "image": {"openai-image": true, "grok-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true, "gemini-image": true}, - "video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true, "novita-video": true}, + "video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true, "novita-video": true, "minimax-video": true}, "audio": {"openai-audio": true, "async-audio": true}, } if allowed[mode] != nil && !allowed[mode][interfaceType] { @@ -3269,7 +3425,13 @@ func providerRequestKind(method string, path string) string { func apiURL(baseURL string, path string) string { base := strings.TrimRight(strings.TrimSpace(baseURL), "/") - if strings.HasSuffix(base, "/v1") || strings.HasSuffix(base, "/v1beta") || strings.HasSuffix(base, "/api/v3") || strings.HasSuffix(base, "/api/plan/v3") { + if strings.HasPrefix(path, "/v2/") { + if strings.HasSuffix(base, "/v2") { + return base + strings.TrimPrefix(path, "/v2") + } + return base + path + } + if strings.HasSuffix(base, "/v1") || strings.HasSuffix(base, "/v1beta") || strings.HasSuffix(base, "/v2") || strings.HasSuffix(base, "/api/v3") || strings.HasSuffix(base, "/api/plan/v3") { return base + path } return base + "/v1" + path diff --git a/backend/internal/service/provider_request_types.go b/backend/internal/service/provider_request_types.go index f3a2f11db..0ddeae35c 100644 --- a/backend/internal/service/provider_request_types.go +++ b/backend/internal/service/provider_request_types.go @@ -130,3 +130,25 @@ type seedanceAgentPlanRequest struct { GenerateAudio *bool `json:"generate_audio,omitempty"` Watermark *bool `json:"watermark,omitempty"` } + +type miniMaxVideoRequest struct { + Model string `json:"model"` + Content []miniMaxVideoContent `json:"content"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio string `json:"ratio,omitempty"` + AIGCWatermark *bool `json:"aigc_watermark,omitempty"` +} + +type miniMaxVideoContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *miniMaxMediaURL `json:"image_url,omitempty"` + VideoURL *miniMaxMediaURL `json:"video_url,omitempty"` + AudioURL *miniMaxMediaURL `json:"audio_url,omitempty"` + Role string `json:"role,omitempty"` +} + +type miniMaxMediaURL struct { + URL string `json:"url"` +} diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 6ed8aba79..e57f143e0 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -1880,3 +1880,78 @@ func TestRunNovitaVideoTaskReturnsFailureReason(t *testing.T) { t.Fatalf("runVideoTask() error = %v, want reason in message", err) } } + +func TestRunMiniMaxVideoTaskCreatesPollsAndDownloads(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + paths := make([]string, 0, 3) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + switch r.Method + " " + r.URL.Path { + case "POST /v2/video_generation": + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q", got) + } + var body miniMaxVideoRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body.Model != "MiniMax-H3" || body.Resolution != "768P" || body.Duration != 5 || body.Ratio != "16:9" || len(body.Content) != 1 || body.Content[0].Text != "make it move" { + t.Errorf("body = %#v", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"minimax-task-1"}`)) + case "GET /v2/query/video_generation/minimax-task-1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task":{"id":"minimax-task-1","status":"succeeded","content":{"url":"` + server.URL + `/video.mp4"}}}`)) + case "GET /video.mp4": + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("video")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + result, err := runVideoTask(context.Background(), canvasGenerationInput{ + Mode: "video", + Prompt: "make it move", + Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video", VideoSeconds: "5", VQuality: "720", Size: "16:9"}, + }) + if err != nil { + t.Fatalf("runVideoTask() error = %v", err) + } + video := result["video"].(map[string]interface{}) + if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { + t.Fatalf("video = %#v", video) + } + if got := strings.Join(paths, ","); got != "POST /v2/video_generation,GET /v2/query/video_generation/minimax-task-1,GET /video.mp4" { + t.Fatalf("paths = %q", got) + } +} + +func TestRunMiniMaxVideoTaskReturnsFailureReason(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "POST /v2/video_generation": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"minimax-task-2"}`)) + case "GET /v2/query/video_generation/minimax-task-2": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task":{"status":"failed","error":{"code":"1026","message":"content violates policy"}}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + _, err := runVideoTask(context.Background(), canvasGenerationInput{ + Mode: "video", + Prompt: "make it move", + Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video"}, + }) + if err == nil || !strings.Contains(err.Error(), "1026:content violates policy") { + t.Fatalf("runVideoTask() error = %v", err) + } +} diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index edb0e3a7a..5ca1b16f2 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -6,14 +6,11 @@ import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-p import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav"; import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar"; import { cn } from "@/lib/utils"; -import { refreshFeatureAvailability } from "@/lib/user-session"; import { isSpatialWorkbenchPath } from "@/lib/workspace-routes"; -import { useUserStore } from "@/stores/use-user-store"; export function AppWorkspaceShell({ children }: { children: ReactNode }) { const { pathname } = useLocation(); const navigate = useNavigate(); - const user = useUserStore((state) => state.user); const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false); const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); @@ -60,17 +57,6 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { return () => window.removeEventListener("workspace:navigate", handleWorkspaceNavigation); }, [navigate]); - useEffect(() => { - if (!user) return; - const refresh = () => void refreshFeatureAvailability().catch((error) => console.warn("功能开放状态刷新失败", error)); - const timer = window.setInterval(refresh, 30_000); - window.addEventListener("focus", refresh); - return () => { - window.clearInterval(timer); - window.removeEventListener("focus", refresh); - }; - }, [user]); - return ( <>
diff --git a/web/src/components/layout/client-root-init.tsx b/web/src/components/layout/client-root-init.tsx index 2692ec41f..b52376739 100644 --- a/web/src/components/layout/client-root-init.tsx +++ b/web/src/components/layout/client-root-init.tsx @@ -8,12 +8,13 @@ import { useLocalDreaminaModelBootstrap } from "@/stores/use-local-dreamina-mode import { useLocalRuntimeBootstrap } from "@/stores/use-local-runtime-store"; export function ClientRootInit({ children }: { children: ReactNode }) { - useLocalRuntimeBootstrap(); + const config = useConfigStore((state) => state.config); + const localRuntimeConfigured = config.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false); + useLocalRuntimeBootstrap(localRuntimeConfigured); useLocalDreaminaModelBootstrap(); const { message } = App.useApp(); const handledConfigParams = useRef(false); const updateConfig = useConfigStore((state) => state.updateConfig); - const config = useConfigStore((state) => state.config); useEffect(() => { const interactiveSelector = 'button, [role="button"], a, [class*="card"], [class*="Card"]'; diff --git a/web/src/components/layout/system-announcement-center.tsx b/web/src/components/layout/system-announcement-center.tsx index 273d615e5..741ddd2ea 100644 --- a/web/src/components/layout/system-announcement-center.tsx +++ b/web/src/components/layout/system-announcement-center.tsx @@ -1,13 +1,17 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Tag } from "antd"; import { Bell } from "lucide-react"; -import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import { useState, type CSSProperties } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AnnouncementTimelineModal } from "@/components/ui/aceternity/announcement-timeline-modal"; import { aceternityMotion } from "@/lib/aceternity-motion"; -import { getAnnouncementFeed, markAnnouncementsRead, type SystemAnnouncement } from "@/services/api/announcements"; +import { getAnnouncementFeed, markAnnouncementsRead } from "@/services/api/announcements"; -const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 60_000; +const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000; +const ANNOUNCEMENT_CACHE_TTL_MS = 60_000; + +type AnnouncementFeed = Awaited>; type SystemAnnouncementCenterProps = { userId: string; @@ -20,63 +24,31 @@ type SystemAnnouncementCenterProps = { export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) { const reducedMotion = useReducedMotion(); - const activeUserIdRef = useRef(userId); - activeUserIdRef.current = userId; + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); - const [announcements, setAnnouncements] = useState([]); - const [unreadCount, setUnreadCount] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - - const refresh = useCallback(async (showLoading = false) => { - if (!userId) return null; - const requestUserId = userId; - if (showLoading) setLoading(true); - try { - const feed = await getAnnouncementFeed(); - if (activeUserIdRef.current !== requestUserId) return null; - setAnnouncements(feed.announcements || []); - setUnreadCount(Math.max(0, feed.unreadCount || 0)); - setError(""); - return feed; - } catch (requestError) { - if (activeUserIdRef.current !== requestUserId) return null; - setError(requestError instanceof Error ? requestError.message : "读取公告失败"); - return null; - } finally { - if (showLoading && activeUserIdRef.current === requestUserId) setLoading(false); - } - }, [userId]); - - useEffect(() => { - setAnnouncements([]); - setUnreadCount(0); - setError(""); - if (!userId) return; - void refresh(); - const timer = window.setInterval(() => void refresh(), ANNOUNCEMENT_REFRESH_INTERVAL_MS); - const onFocus = () => void refresh(); - const onVisibilityChange = () => { - if (document.visibilityState === "visible") void refresh(); - }; - window.addEventListener("focus", onFocus); - document.addEventListener("visibilitychange", onVisibilityChange); - return () => { - window.clearInterval(timer); - window.removeEventListener("focus", onFocus); - document.removeEventListener("visibilitychange", onVisibilityChange); - }; - }, [refresh, userId]); + const queryKey = ["system-announcements", userId] as const; + const feedQuery = useQuery({ + queryKey, + queryFn: getAnnouncementFeed, + enabled: Boolean(userId), + staleTime: ANNOUNCEMENT_CACHE_TTL_MS, + refetchInterval: ANNOUNCEMENT_REFRESH_INTERVAL_MS, + // 公告在打开面板时会显式 refetch;不把浏览器 focus 变成每个工作区实例的请求触发器。 + refetchOnWindowFocus: false, + }); + const announcements = feedQuery.data?.announcements || []; + const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0); + const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : ""; const openAnnouncements = async () => { setOpen(true); - const feed = await refresh(announcements.length === 0); + const feed = (await feedQuery.refetch()).data; if (!feed?.unreadCount) return; try { const result = await markAnnouncementsRead(feed.announcements.map((announcement) => announcement.id)); const nextUnreadCount = Math.max(0, result.unreadCount || 0); - setUnreadCount(nextUnreadCount); - if (nextUnreadCount > 0) void refresh(); + queryClient.setQueryData(queryKey, (current) => current ? { ...current, unreadCount: nextUnreadCount } : current); + if (nextUnreadCount > 0) void queryClient.invalidateQueries({ queryKey }); } catch { // 已读状态是辅助读路径,失败时保留角标,下一次打开或轮询会继续尝试同步。 } @@ -118,7 +90,7 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = ) : null} - setOpen(false)} onRetry={() => void refresh(true)} /> + setOpen(false)} onRetry={() => void feedQuery.refetch()} /> ); } diff --git a/web/src/components/model-logo.tsx b/web/src/components/model-logo.tsx index cb1e101e9..b821ef78b 100644 --- a/web/src/components/model-logo.tsx +++ b/web/src/components/model-logo.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ComponentType, type SVGProps } from "react"; +import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react"; import { Input, Popover } from "antd"; import { Cpu, Search, X } from "lucide-react"; @@ -8,22 +8,53 @@ import { cn } from "@/lib/utils"; type LobeIconComponent = ComponentType & { size?: number | string }>; -// 只加载各图标的 Mono 组件;完整 icons barrel 会经由 Avatar/Combine 与 @lobehub/ui 形成循环依赖,Bun 测试环境会触发未初始化导出。 -const iconModules = typeof import.meta.glob === "function" - ? import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { eager: true, import: "default" }) - : {}; -const iconRegistry = Object.fromEntries( +// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块, +// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。 +const iconModules = import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" }); +const iconLoaders = Object.fromEntries( Object.entries(iconModules) - .map(([path, icon]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], icon]) - .filter((entry): entry is [string, LobeIconComponent] => Boolean(entry[0] && entry[1])), -) as Record; + .map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader]) + .filter((entry): entry is [string, () => Promise] => Boolean(entry[0] && entry[1])), +) as Record Promise>; +const iconRegistry = new Map(); +const iconLoadPromises = new Map>(); const iconOptions = toc .filter((item) => item.group === "model" || item.group === "provider") .map((item) => ({ id: item.id, title: item.fullTitle || item.title })) - .filter((item) => Boolean(iconRegistry[item.id])); + .filter((item) => Boolean(iconLoaders[item.id])); + +function loadIcon(icon?: string) { + if (!icon) return Promise.resolve(undefined); + const cached = iconRegistry.get(icon); + if (cached) return Promise.resolve(cached); + const existing = iconLoadPromises.get(icon); + if (existing) return existing; + const loader = iconLoaders[icon]; + if (!loader) return Promise.resolve(undefined); + const promise = loader() + .then((module) => { + const loaded = (module as unknown as { default?: LobeIconComponent }).default || module; + iconRegistry.set(icon, loaded); + return loaded; + }) + .catch(() => undefined); + iconLoadPromises.set(icon, promise); + return promise; +} export function ModelLogo({ icon, size = 18, className }: { icon?: string; size?: number; className?: string }) { - const Icon = icon ? iconRegistry[icon] : undefined; + const [Icon, setIcon] = useState(() => icon ? iconRegistry.get(icon) : undefined); + useEffect(() => { + let cancelled = false; + setIcon(icon ? iconRegistry.get(icon) : undefined); + if (!icon || !iconLoaders[icon]) return () => { cancelled = true; }; + void loadIcon(icon).then((loaded) => { + if (!cancelled) setIcon(loaded); + }); + return () => { + cancelled = true; + }; + }, [icon]); if (!Icon) return ; return ; } diff --git a/web/src/hooks/use-wallet-balance.ts b/web/src/hooks/use-wallet-balance.ts index 6e62ce439..67bf7cf79 100644 --- a/web/src/hooks/use-wallet-balance.ts +++ b/web/src/hooks/use-wallet-balance.ts @@ -3,7 +3,9 @@ import { useEffect } from "react"; import { getWallet } from "@/services/api/wallet"; -const WALLET_REFRESH_INTERVAL_MS = 30_000; +// 余额不是页面心跳数据。工作区内多个位置共享同一个 Query,只有首次进入、显式 +// wallet:updated 事件或低频后台刷新才需要访问服务端,避免每个窗口 focus 都打一次列表接口。 +const WALLET_REFRESH_INTERVAL_MS = 60_000; const walletBalanceQueryKey = (userId: string) => ["wallet-balance", userId] as const; export function useWalletBalance(userId?: string, enabled = true) { @@ -18,9 +20,9 @@ export function useWalletBalance(userId?: string, enabled = true) { if (wallet.account.userId !== activeUserId) throw new Error("积分账户与当前用户不一致"); return wallet.account.availableMicrocredits; }, - staleTime: 15_000, + staleTime: WALLET_REFRESH_INTERVAL_MS, refetchInterval: WALLET_REFRESH_INTERVAL_MS, - refetchOnWindowFocus: true, + refetchOnWindowFocus: false, }); useEffect(() => { diff --git a/web/src/lib/generation-error.ts b/web/src/lib/generation-error.ts index b177d24a8..8724d9658 100644 --- a/web/src/lib/generation-error.ts +++ b/web/src/lib/generation-error.ts @@ -31,6 +31,8 @@ export function generationErrorMessage(error: unknown) { const providerMessage = extractStructuredProviderMessage(raw) || extractWrappedProviderMessage(raw); const displayMessage = providerMessage || raw; if (isContentModerationError(displayMessage)) return CONTENT_MODERATION_MESSAGE; + const resourceStorageMessage = resourceStorageFailureMessage(raw) || resourceStorageFailureMessage(displayMessage); + if (resourceStorageMessage) return resourceStorageMessage; if (isNetworkFailure(displayMessage)) return NETWORK_ERROR_MESSAGE; if (!providerMessage) { if (hasHttpStatus(raw, 429)) return "服务当前繁忙,请稍后重试。"; @@ -133,3 +135,12 @@ function hasHttpStatus(value: string, ...statuses: number[]) { function containsInfrastructureDetails(value: string) { return /(?:接口请求失败|Request failed with status code|https?:\/\/|\b(?:GET|POST|PUT|PATCH|DELETE)\s+["']?|Bad Gateway|Service Unavailable|Gateway Timeout|upstream_error)/i.test(value); } + +function resourceStorageFailureMessage(value: string) { + if (!value) return ""; + if (/\bUserDisable\b/i.test(value)) return "对象存储账号已停用,请检查或更换对象存储配置。"; + if (/(?:参考(?:图片|媒体)上传失败|OSS 上传失败|对象存储|腾讯云 COS|七牛云)/i.test(value)) { + return "参考素材上传到对象存储失败,请检查对象存储配置后重试。"; + } + return ""; +} diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index f56f5c468..1cb4b5013 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -301,6 +301,21 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = " video.resolutions = ["1080p"]; video.defaultResolution = "1080p"; } + if (protocol === "minimax-video") { + video.references.maxImages = 9; + video.references.maxImageBytes = 30 * 1024 * 1024; + video.references.maxVideos = 3; + video.references.maxVideoBytes = 50 * 1024 * 1024; + video.references.maxVideoDurationSeconds = 15; + video.references.maxAudios = 3; + video.references.maxAudioBytes = 15 * 1024 * 1024; + video.references.maxAudioDurationSeconds = 15; + video.duration = { selection: "enum", values: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], default: 5 }; + video.ratios = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]; + video.resolutions = ["768P", "2K"]; + video.defaultResolution = "768P"; + video.watermark = { supported: true, default: false }; + } return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video }; } diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts index d6a9cc707..520e3f98c 100644 --- a/web/src/lib/model-protocols.ts +++ b/web/src/lib/model-protocols.ts @@ -15,7 +15,8 @@ export type ModelProtocol = | "volcengine-ark-video" | "volcengine-jimeng-video" | "gemini-veo" - | "novita-video"; + | "novita-video" + | "minimax-video"; export type ProtocolCapability = "text" | "image" | "video" | "audio"; @@ -63,6 +64,7 @@ export const MODEL_PROTOCOLS: ModelProtocolDefinition[] = [ { value: "volcengine-jimeng-video", label: "即梦官方视频", capability: "video", create: "POST CVSync2AsyncSubmitTask", poll: "POST CVSync2AsyncGetResult", contentType: "application/json + AK/SK 签名", media: "文本或一张首帧图,模型标识填写 req_key" }, { value: "gemini-veo", label: "Gemini Veo", capability: "video", create: "POST /v1beta/models/{model}:predictLongRunning", poll: "GET /v1beta/{operation_name}", contentType: "application/json", media: "文本与单张起始图" }, { value: "novita-video", label: "Novita 视频", capability: "video", create: "POST /v3/video/create", poll: "GET /v3/async/task-result?task_id={id}", contentType: "application/json", media: "文本或单张起始图" }, + { value: "minimax-video", label: "MiniMax 视频", capability: "video", create: "POST /v2/video_generation", poll: "GET /v2/query/video_generation/{task_id}", contentType: "application/json", media: "文本、图片、视频、音频参考素材" }, ]; export const MODEL_PROTOCOL_OPTIONS = protocolGroups(MODEL_PROTOCOLS.filter((item) => !item.value.startsWith("volcengine-jimeng-"))); diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 7ce789557..a3d6916f7 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -54,8 +54,10 @@ export async function applyUserSession(payload: AuthSessionPayload) { useConfigStore.getState().mergeSystemChannels(managedModelChannels(payload.logicalModels || [])); } installRemoteUserDataAutoSync(); - if (payload.user?.id) await syncRemoteUserData(payload.user.id); - else resetRemoteUserDataSync(); + if (payload.user?.id) { + // 认证状态先完成,云端数据在后台合并;远端同步失败不能伪装成登录失败。 + void syncRemoteUserData(payload.user.id).catch((error) => console.warn("登录后云端数据同步失败,保留本地数据等待重试", error)); + } else resetRemoteUserDataSync(); } finally { useUserStore.getState().setHydrated(true); } diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 65920c4e0..f2ded001c 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -8,6 +8,13 @@ import type { PublicLogicalModel } from "@/services/api/logical-models"; const api = apiClient; +let authSessionRequest: Promise | null = null; +let authSessionCache: { payload: AuthSessionPayload; expiresAt: number } | null = null; + +function invalidateAuthSessionCache() { + authSessionCache = null; +} + export type LocalUser = { id: string; username: string; @@ -337,7 +344,18 @@ export function linuxDOLoginURL(next: string) { } export function getAuthSession() { - return request(api.get("/auth/session")); + const now = Date.now(); + if (authSessionCache && authSessionCache.expiresAt > now) return Promise.resolve(authSessionCache.payload); + if (authSessionRequest) return authSessionRequest; + authSessionRequest = request(api.get("/auth/session")) + .then((payload) => { + authSessionCache = { payload, expiresAt: Date.now() + 5_000 }; + return payload; + }) + .finally(() => { + authSessionRequest = null; + }); + return authSessionRequest; } export function getSystemChannels() { @@ -356,8 +374,11 @@ export function updateAdminFeatureAvailability(features: Pick(api.patch("/admin/settings/features", features)); } -export function login(input: { username: string; password: string }) { - return request<{ user: LocalUser }>(api.post("/auth/login", input)); +export async function login(input: { username: string; password: string }) { + const result = await request<{ user: LocalUser }>(api.post("/auth/login", input)); + // 登录会改变服务端会话身份,不能让登录前缓存的游客 session 污染后续恢复。 + invalidateAuthSessionCache(); + return result; } export function sendRegistrationEmailCode(email: string) { @@ -368,8 +389,10 @@ export function register(input: { username: string; email?: string; emailCode?: return request<{ user: LocalUser }>(api.post("/auth/register", input)); } -export function logout() { - return request<{ ok: boolean }>(api.post("/auth/logout")); +export async function logout() { + const result = await request<{ ok: boolean }>(api.post("/auth/logout")); + invalidateAuthSessionCache(); + return result; } export type AdminListParams = { keyword?: string; status?: string; role?: string; page?: number; limit?: number }; diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 7c6e0d7b1..33de90386 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -145,11 +145,11 @@ function resourceProxyFileUrl(id: string) { return `${base}/resources/${encodeURIComponent(id)}/file?proxy=1`; } -export async function resolveResourceUrl(storageKey?: string, fallback = "") { +export function resolveResourceUrl(storageKey?: string, fallback = "") { const id = resourceIdFromStorageKey(storageKey); - if (!id) return fallback; - const resource = await getResource(id).catch(() => null); - return resource ? resource.publicUrl || resourceFileUrl(id) : fallback; + // 资源引用本身已经包含稳定 ID;恢复/展示阶段不需要再查一遍元数据。 + // 需要 publicUrl、mime 或尺寸时必须显式调用 getResource,避免隐式 N+1。 + return id ? resourceFileUrl(id) : fallback; } export async function getResourceBlob(storageKey: string) { diff --git a/web/src/services/api/skills.ts b/web/src/services/api/skills.ts index 29b3e1e80..7c6137378 100644 --- a/web/src/services/api/skills.ts +++ b/web/src/services/api/skills.ts @@ -1,8 +1,12 @@ import { compactApiParams, serializeApiParams, type ApiParams } from "@/services/api/request"; import { apiClient, request } from "@/services/api/request"; +import { getActiveUserScope } from "@/lib/user-scope"; const api = apiClient; +let addedSkillsRequest: { scope: string; promise: Promise<{ skills: Skill[] }> } | null = null; +let addedSkillsCache: { scope: string; value: { skills: Skill[] }; expiresAt: number } | null = null; + export type SkillSort = "popular" | "new" | "updated"; export type SkillScope = "public" | "mine" | "created" | "favorites"; export type SkillMediaType = "image" | "video"; @@ -82,27 +86,44 @@ export function getSkill(id: string) { } export function listAddedSkills() { - return request<{ skills: Skill[] }>(api.get("/skills/added")); + const scope = getActiveUserScope(); + const now = Date.now(); + if (addedSkillsCache?.scope === scope && addedSkillsCache.expiresAt > now) return Promise.resolve(addedSkillsCache.value); + if (addedSkillsRequest?.scope === scope) return addedSkillsRequest.promise; + const promise = request<{ skills: Skill[] }>(api.get("/skills/added")) + .then((value) => { + addedSkillsCache = { scope, value, expiresAt: Date.now() + 15_000 }; + return value; + }) + .finally(() => { + if (addedSkillsRequest?.promise === promise) addedSkillsRequest = null; + }); + addedSkillsRequest = { scope, promise }; + return promise; +} + +function invalidateAddedSkillsCache() { + addedSkillsCache = null; } export function createSkill(input: SkillMutationInput) { - return request<{ skill: Skill }>(api.post("/skills", input)); + return request<{ skill: Skill }>(api.post("/skills", input)).finally(invalidateAddedSkillsCache); } export function updateSkill(id: string, input: SkillMutationInput) { - return request<{ skill: Skill }>(api.put(`/skills/${encodeURIComponent(id)}`, input)); + return request<{ skill: Skill }>(api.put(`/skills/${encodeURIComponent(id)}`, input)).finally(invalidateAddedSkillsCache); } export function deleteSkill(id: string) { - return request<{ deleted: boolean }>(api.delete(`/skills/${encodeURIComponent(id)}`)); + return request<{ deleted: boolean }>(api.delete(`/skills/${encodeURIComponent(id)}`)).finally(invalidateAddedSkillsCache); } export function addSkill(id: string) { - return request<{ skill: Skill }>(api.post(`/skills/${encodeURIComponent(id)}/add`)); + return request<{ skill: Skill }>(api.post(`/skills/${encodeURIComponent(id)}/add`)).finally(invalidateAddedSkillsCache); } export function removeSkill(id: string) { - return request<{ skill: Skill }>(api.delete(`/skills/${encodeURIComponent(id)}/add`)); + return request<{ skill: Skill }>(api.delete(`/skills/${encodeURIComponent(id)}/add`)).finally(invalidateAddedSkillsCache); } export function likeSkill(id: string) { diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 2cd0d3053..ea2f64bbe 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -13,6 +13,8 @@ import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; type VideoResponse = { id?: string; request_id?: string; task_id?: string; status?: string; error?: { message?: string }; video?: { url?: string }; video_url?: string; result_url?: string }; +type MiniMaxVideoTask = { id?: string; status?: string; content?: { url?: string }; error?: { code?: string | number; message?: string } }; +type MiniMaxVideoCreateResponse = { task_id?: string; request_id?: string; data?: { task_id?: string; id?: string } }; type ApiVideoResponse = VideoResponse | { code?: number; data?: VideoResponse | null; msg?: string }; type ResolvedAiConfig = ReturnType; type SeedanceTask = { @@ -28,7 +30,7 @@ type ApiEnvelope = T | { code?: number; data?: T | null; msg?: string }; type RequestOptions = { signal?: AbortSignal }; export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string }; -export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita"; model: string }; +export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string }; export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string }; function aiApiUrl(config: AiConfig, path: string) { @@ -71,6 +73,9 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string if (requestConfig.interfaceType === "novita-video") { return createNovitaVideoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); } + if (requestConfig.interfaceType === "minimax-video") { + return createMiniMaxVideoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); + } if (requestConfig.interfaceType === "volcengine-ark-video") { return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); } @@ -105,6 +110,7 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener if (task.provider === "video-generations") return pollVideoGenerationsTask(requestConfig, task, options); if (task.provider === "gemini-veo") return pollGeminiVeoTask(requestConfig, task, options); if (task.provider === "novita") return pollNovitaVideoTask(requestConfig, task, options); + if (task.provider === "minimax") return pollMiniMaxVideoTask(requestConfig, task, options); return pollOpenAIVideoTask(requestConfig, task, options); } @@ -272,6 +278,98 @@ async function pollNovitaVideoTask(config: ResolvedAiConfig, task: VideoGenerati } } +async function createMiniMaxVideoTask(config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise { + const imageUrls = await Promise.all(references.slice(0, 9).map((image) => resolveMiniMaxImageUrl(image))); + const videoUrls = await Promise.all(videoReferences.slice(0, 3).map((video) => resolveMiniMaxMediaUrl(video, "参考视频"))); + const audioUrls = await Promise.all(audioReferences.slice(0, 3).map((audio) => resolveMiniMaxMediaUrl(audio, "参考音频"))); + const content: Array> = [{ type: "text", text: prompt.trim() }]; + imageUrls.forEach((url, index) => { + // 首尾帧只允许成对出现;三张及以上图片统一作为多模态参考图,避免提交 first/last 与 reference 混合的非法组合。 + const role = videoUrls.length || audioUrls.length || imageUrls.length > 2 ? "reference_image" : index === 0 ? "first_frame" : "last_frame"; + content.push({ type: "image_url", image_url: { url }, role }); + }); + videoUrls.forEach((url) => content.push({ type: "video_url", video_url: { url }, role: "reference_video" })); + audioUrls.forEach((url) => content.push({ type: "audio_url", audio_url: { url }, role: "reference_audio" })); + const frameMode = imageUrls.length > 0 && + imageUrls.length <= 2 && + videoUrls.length === 0 && + audioUrls.length === 0; + const payload = { + model: modelOptionName(model), + content, + resolution: normalizeMiniMaxResolution(config.vquality), + duration: normalizeMiniMaxDuration(config.videoSeconds), + ratio: normalizeMiniMaxRatio(config.size, frameMode), + aigc_watermark: boolConfig(config.videoWatermark, false), + }; + try { + const created = await channelPost(config, miniMaxVideoUrl(config, "/video_generation"), payload, options); + const id = created.task_id || created.request_id || created.data?.task_id || created.data?.id || ""; + if (!id) throw new Error("MiniMax 视频接口没有返回任务 ID"); + return { id, provider: "minimax", model }; + } catch (error) { + throw new Error(readAxiosError(error, "MiniMax 视频任务创建失败")); + } +} + +async function pollMiniMaxVideoTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise { + try { + const response = await channelGet<{ task?: MiniMaxVideoTask }>(config, miniMaxVideoUrl(config, `/query/video_generation/${encodeURIComponent(task.id)}`), options); + const state = response.task || {}; + const status = String(state.status || "").toLowerCase(); + if (status === "succeeded" || status === "completed") { + const url = state.content?.url || ""; + if (!url) return { status: "failed", error: "MiniMax 视频任务已完成但没有返回视频地址" }; + return { status: "completed", result: await videoResultFromUrl(url, options) }; + } + if (status === "failed" || status === "cancelled") { + const code = state.error?.code ? `${state.error.code}:` : ""; + return { status: "failed", error: `${code}${state.error?.message || "MiniMax 视频生成失败"}` }; + } + return { status: "pending" }; + } catch (error) { + throw new Error(readAxiosError(error, "MiniMax 视频任务查询失败")); + } +} + +function miniMaxVideoUrl(config: ResolvedAiConfig, path: string) { + const base = config.baseUrl.replace(/\/+$/, ""); + if (/\/v2$/i.test(base)) return `${base}${path}`; + return `${base}/v2${path}`; +} + +function normalizeMiniMaxResolution(value: string) { + const normalized = value.trim().toLowerCase(); + return ["2k", "4k", "high", "1080", "1080p", "1440p", "2160p"].includes(normalized) ? "2K" : "768P"; +} + +function normalizeMiniMaxDuration(value: string) { + const seconds = Math.floor(Number(value) || 5); + return Math.max(4, Math.min(15, seconds)); +} + +function normalizeMiniMaxRatio(value: string, frameMode: boolean) { + if (frameMode) return "adaptive"; + return ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(value) ? value : "16:9"; +} + +async function resolveMiniMaxImageUrl(image: ReferenceImage) { + const directUrl = image.url || image.dataUrl; + if (isPublicMediaUrl(directUrl) || directUrl.startsWith("data:")) return directUrl; + const dataUrl = await imageToDataUrl(image); + if (!dataUrl) throw new Error("MiniMax 参考图读取失败,请换一张图片或重新上传"); + return dataUrl; +} + +async function resolveMiniMaxMediaUrl(media: ReferenceVideo | ReferenceAudio, label: string) { + if (isPublicMediaUrl(media.url) || media.url?.startsWith("data:")) return media.url; + let blob: Blob | null = null; + if (media.storageKey) blob = await getMediaBlob(media.storageKey); + if (!blob && media.url?.startsWith("blob:")) blob = await (await fetch(media.url)).blob(); + if (!blob) throw new Error(`MiniMax ${label}必须是公网 URL 或本地已保存素材`); + return blobToDataUrl(blob); +} + function novitaVideoUrl(config: ResolvedAiConfig, path: string) { return `${config.baseUrl.replace(/\/+$/, "")}${path}`; } diff --git a/web/src/services/resource-blob-cache.ts b/web/src/services/resource-blob-cache.ts index 6e4299d76..0b37587be 100644 --- a/web/src/services/resource-blob-cache.ts +++ b/web/src/services/resource-blob-cache.ts @@ -1,7 +1,7 @@ import localforage from "localforage"; import { getActiveUserScope } from "@/lib/user-scope"; -import { getResource, getResourceBlob, resourceIdFromStorageKey, type RemoteResource } from "@/services/api/resources"; +import { getResourceBlob, resourceIdFromStorageKey } from "@/services/api/resources"; type ResourceCacheMeta = { key: string; @@ -147,25 +147,22 @@ async function readCachedObjectUrl(target: ResourceCacheMeta) { async function cacheTarget(storageKey: string): Promise { const resourceId = resourceIdFromStorageKey(storageKey); if (!resourceId) return null; - const resource = await getResource(resourceId); const userScope = getActiveUserScope(); - if (userScope === "guest" || resource.userId !== userScope) throw new Error("当前用户不能读取该媒体缓存"); - const version = resourceVersion(resource); + if (userScope === "guest") throw new Error("游客不能读取远程媒体缓存"); + // resource ID 是不可变资源的稳定标识,文件接口本身负责鉴权。 + // 缓存初始化不能先对每个资源读取一遍元数据,否则首屏会重新形成 N+1 请求。 + const version = "file"; return { key: `${userScope}:${resourceId}:${version}`, userScope, resourceId, version, - size: resource.size || 0, - mimeType: resource.mimeType || "application/octet-stream", + size: 0, + mimeType: "application/octet-stream", lastAccessedAt: Date.now(), }; } -function resourceVersion(resource: RemoteResource) { - return (resource.etag || `${resource.size}:${resource.updatedAt}`).replace(/[^a-zA-Z0-9:._-]/g, "_"); -} - async function touchCacheMeta(target: ResourceCacheMeta) { const current = await metaStore.getItem(target.key); if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return; diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 43b9f1b87..446c90592 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -1,6 +1,6 @@ import { getMediaBlob } from "@/services/file-storage"; -import { getImageBlob, resolveImageUrl } from "@/services/image-storage"; -import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteCanvasProject, listRemoteAssets, listRemoteCanvasProjects, upsertRemoteAsset, upsertRemoteCanvasProject, type RemoteUserDataSummary } from "@/services/api/user-data"; +import { getImageBlob } from "@/services/image-storage"; +import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data"; import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import type { Asset } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store"; @@ -29,17 +29,16 @@ export async function syncRemoteUserData(userId?: string | null) { if (!activeRemoteUserId) return; applyingRemoteState = true; try { - const [remoteCanvas, remoteAssets] = await Promise.all([listRemoteCanvasProjects(), listRemoteAssets()]); - remoteProjectVersions = versionMap(remoteCanvas.projects); - remoteAssetVersions = versionMap(remoteAssets.assets); + // 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求, + // 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。 + const snapshot = await getRemoteUserDataSnapshot(); + remoteProjectVersions = versionMap(snapshot.projects); + remoteAssetVersions = versionMap(snapshot.assets); const localProjects = useCanvasStore.getState().projects; const localAssets = useAssetStore.getState().assets; - const [changedProjects, changedAssets] = await Promise.all([ - fetchNewerRemoteItems(localProjects, remoteCanvas.projects, async (id) => (await getRemoteCanvasProject(id)).project), - fetchNewerRemoteItems(localAssets, remoteAssets.assets, async (id) => (await getRemoteAsset(id)).asset), - ]); - const mergedProjects = mergeById(localProjects, changedProjects); - const mergedAssets = mergeById(localAssets, await hydrateAssets(changedAssets)); + const mergedProjects = mergeById(localProjects, snapshot.projects); + // 这里只合并结构化素材数据,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。 + const mergedAssets = mergeById(localAssets, snapshot.assets); useCanvasStore.getState().replaceProjects(mergedProjects); useAssetStore.getState().replaceAssets(mergedAssets); } finally { @@ -242,30 +241,6 @@ async function saveRemoteUserDataBatch() { } } -async function hydrateAssets(assets: Asset[]): Promise { - return Promise.all( - assets.map(async (asset) => { - if (asset.kind === "image" && asset.data.storageKey) { - const dataUrl = await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl); - return { ...asset, coverUrl: shouldReplaceEphemeralUrl(asset.coverUrl) ? dataUrl : asset.coverUrl, data: { ...asset.data, dataUrl } }; - } - if (asset.kind === "video" && asset.data.storageKey) { - const url = await resolveResourceOrMediaUrl(asset.data.storageKey, asset.data.url); - return { ...asset, data: { ...asset.data, url } }; - } - if (asset.kind === "audio" && asset.data.storageKey) { - const url = await resolveResourceOrMediaUrl(asset.data.storageKey, asset.data.url); - return { ...asset, data: { ...asset.data, url } }; - } - if (asset.kind === "model" && asset.data.storageKey) { - const url = await resolveResourceOrMediaUrl(asset.data.storageKey, asset.data.url); - return { ...asset, data: { ...asset.data, url } }; - } - return asset; - }), - ); -} - async function prepareRemoteAssets(assets: Asset[], uploaded: Map) { const result: Asset[] = []; for (const asset of assets) result.push(await ensureRemoteResourceReferences(asset, uploaded)); @@ -358,15 +333,6 @@ function mergeById(local: T[], re return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt)); } -async function fetchNewerRemoteItems(local: T[], remote: RemoteUserDataSummary[], fetchItem: (id: string) => Promise) { - const localById = new Map(local.map((item) => [item.id, item])); - const pending = remote.filter((item) => { - const current = localById.get(item.id); - return !current || timeValue(item.updatedAt) > timeValue(current.updatedAt); - }); - return Promise.all(pending.map((item) => fetchItem(item.id))); -} - function versionMap(items: Array<{ id: string; updatedAt?: string }>) { return new Map(items.map((item) => [item.id, item.updatedAt || ""])); } @@ -394,17 +360,6 @@ function isLocalStorageKey(value: string) { return LOCAL_STORAGE_KEY_PATTERN.test(value) && !resourceIdFromStorageKey(value); } -function shouldReplaceEphemeralUrl(value: string) { - return !value || value.startsWith("blob:") || value.startsWith("data:"); -} - -async function resolveResourceOrMediaUrl(storageKey: string, fallback: string) { - const resourceId = resourceIdFromStorageKey(storageKey); - if (resourceId) return resourceFileUrl(resourceId); - const { resolveMediaUrl } = await import("@/services/file-storage"); - return resolveMediaUrl(storageKey, fallback); -} - function numberValue(value: unknown) { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : undefined; diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index ab7a90017..f5cd8b627 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -6,6 +6,7 @@ import { parseAssetStorageDocument, rebaseAssetSnapshot, serializeAssetStorageDo import { parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision"; import { localForageStorageForScope } from "@/lib/localforage-storage"; import { getActiveUserScope } from "@/lib/user-scope"; +import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadImage } from "@/services/image-storage"; import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage"; import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository"; @@ -213,24 +214,9 @@ const assetStorage: PersistStorage = { return null; } const document = parseAssetStorageDocument(value); - const assets = await Promise.all( - document.state.assets.map(async (asset) => { - // 视频和音频的数据结构不同,分别缩窄以保持 Asset 判别联合关系。 - if (asset.kind === "video" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } }; - if (asset.kind === "audio" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } }; - if (asset.kind === "model" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } }; - if (asset.kind !== "image") return asset; - if (asset.data.storageKey) - return { - ...asset, - coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(asset.data.storageKey, asset.coverUrl) : asset.coverUrl, - data: { ...asset.data, dataUrl: await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl) }, - }; - if (!asset.data.dataUrl.startsWith("data:image/")) return asset; - const image = await uploadImage(asset.data.dataUrl); - return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } }; - }), - ); + // 持久化恢复只恢复结构化记录,不能为每个 resource: key 逐条读取资源元数据或 Blob。 + // 远程资源直接使用受鉴权的 file URL;本地 legacy key 仍恢复为 Blob URL,避免兼容性回归。 + const assets = await Promise.all(document.state.assets.map((asset) => normalizePersistedAsset(asset))); const hydratedDocument = { ...document, state: { assets } }; assetMemoryStates.set(scope, { assets }); recordAssetStorageDocument(scope, hydratedDocument); @@ -243,6 +229,33 @@ const assetStorage: PersistStorage = { }, }; +async function normalizePersistedAsset(asset: Asset): Promise { + const storageKey = "data" in asset && asset.data && "storageKey" in asset.data ? asset.data.storageKey : undefined; + const resourceId = resourceIdFromStorageKey(storageKey); + if (resourceId) { + const url = resourceFileUrl(resourceId); + if (asset.kind === "video" || asset.kind === "audio" || asset.kind === "model") return { ...asset, data: { ...asset.data, url } } as Asset; + if (asset.kind === "image") return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? url : asset.coverUrl, data: { ...asset.data, dataUrl: url } }; + } + + // 非 resource: key 是早期本地存储格式,必须继续从 localForage 恢复, + // 但只在确有本地 key 时读取,不让远程资源重新走逐条网络查询。 + if (asset.kind === "video" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } }; + if (asset.kind === "audio" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } }; + if (asset.kind === "model" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } }; + if (asset.kind !== "image") return asset; + if (storageKey) { + return { + ...asset, + coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(storageKey, asset.coverUrl) : asset.coverUrl, + data: { ...asset.data, dataUrl: await resolveImageUrl(storageKey, asset.data.dataUrl) }, + }; + } + if (!asset.data.dataUrl.startsWith("data:image/")) return asset; + const image = await uploadImage(asset.data.dataUrl); + return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } }; +} + async function generationAssetId(effectKey: string) { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(effectKey)); return `generation_${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 6a13eff9c..9b098df56 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -564,6 +564,7 @@ export function defaultBaseUrlForChannelInterface(interfaceType?: ChannelInterfa if (interfaceType === "novita-video") return "https://api.novita.ai/v3"; if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-video") return "https://ark.cn-beijing.volces.com/api/v3"; if (interfaceType === "volcengine-jimeng-image" || interfaceType === "volcengine-jimeng-video") return "https://visual.volcengineapi.com"; + if (interfaceType === "minimax-video") return "https://api.minimaxi.com"; if (interfaceType === "grok-image" || interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || interfaceType === "xai-video") return ""; return OPENAI_BASE_URL; } diff --git a/web/src/stores/use-local-runtime-store.ts b/web/src/stores/use-local-runtime-store.ts index 5d5565034..fbd6e6beb 100644 --- a/web/src/stores/use-local-runtime-store.ts +++ b/web/src/stores/use-local-runtime-store.ts @@ -155,9 +155,12 @@ export function startLocalRuntimeBootstrap( }; } -export function useLocalRuntimeBootstrap() { +export function useLocalRuntimeBootstrap(enabled = true) { const connect = useLocalRuntimeStore((state) => state.connect); - useEffect(() => startLocalRuntimeBootstrap(connect), [connect]); + useEffect(() => { + if (!enabled) return; + return startLocalRuntimeBootstrap(connect); + }, [connect, enabled]); } function connectionFailure(error: unknown, timedOut: boolean) { diff --git a/web/test/generation-task-local.test.ts b/web/test/generation-task-local.test.ts index 0fc2b12b1..e709879f9 100644 --- a/web/test/generation-task-local.test.ts +++ b/web/test/generation-task-local.test.ts @@ -36,6 +36,11 @@ test("Dreamina submit failure categories have bounded user-facing messages", () } }); +test("resource storage 403 is not reported as generation channel authentication", () => { + const raw = "参考图片上传失败:OSS 上传失败:403 Forbidden UserDisableUserDisable"; + expect(generationErrorMessage(raw)).toBe("对象存储账号已停用,请检查或更换对象存储配置。"); +}); + test("durable Dreamina submit failures keep their stable user-facing category in task center", () => { const task = projectLocalDreaminaTask({ id: "dreamina-submit-timeout-task-0001", diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts index e7639ac90..ec1d2baad 100644 --- a/web/test/user-scoped-generation-persistence.test.ts +++ b/web/test/user-scoped-generation-persistence.test.ts @@ -3978,6 +3978,7 @@ test("account scope transition drains an active remote deletion before entering const localStorageValues = new Map(); const previousAdapter = apiClient.defaults.adapter; const previousAssets = useAssetStore.getState().assets; + const requestUrls: string[] = []; let releaseDelete!: () => void; const deleteReleased = new Promise((resolve) => { releaseDelete = resolve; @@ -3985,12 +3986,13 @@ test("account scope transition drains an active remote deletion before entering let deleteStarted = false; apiClient.defaults.adapter = async (config) => { const url = String(config.url || ""); + requestUrls.push(url); if (config.method === "delete") { deleteStarted = true; await deleteReleased; return { data: { code: 0, data: { id: "shared-asset" }, msg: "" }, status: 200, statusText: "OK", headers: {}, config }; } - const data = url.includes("canvas-projects") ? { projects: [] } : { assets: [] }; + const data = url.includes("user-data/snapshot") ? { projects: [], assets: [] } : url.includes("canvas-projects") ? { projects: [] } : { assets: [] }; return { data: { code: 0, data, msg: "" }, status: 200, statusText: "OK", headers: {}, config }; }; Object.defineProperty(globalThis, "window", { @@ -4013,6 +4015,8 @@ test("account scope transition drains an active remote deletion before entering try { useAssetStore.getState().replaceAssets([]); await syncRemoteUserData("account-A"); + expect(requestUrls.filter((url) => url.includes("user-data/snapshot"))).toHaveLength(1); + expect(requestUrls.some((url) => /\/(assets|canvas-projects)\/[^/]+/.test(url))).toBe(false); useAssetStore.getState().replaceAssets([ { id: "shared-asset",