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",