diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index e96dcdc87..2af034c2a 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "unicode/utf8" "infinite-canvas/backend/internal/model" ) @@ -593,6 +594,9 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { return nil } if isWorkflowProviderInterface(taskInput.Config.InterfaceType) { + if err := validateWorkflowProviderPromptLength(taskInput); err != nil { + return err + } return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config) } // 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。 @@ -672,6 +676,12 @@ func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapab } func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error { + if profile == nil { + return BadAuthRequest("当前视频模型能力参数无效") + } + if err := validateModelPromptLength("视频", input.Prompt, profile.References.PromptMaxChars); err != nil { + return err + } if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { return BadAuthRequest("参考素材数量超过当前模型限制") } @@ -767,6 +777,25 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return nil } +func validateWorkflowProviderPromptLength(input canvasGenerationInput) error { + profile := input.Config.CapabilityConfig + if input.Mode != "video" || profile == nil || profile.Video == nil { + return nil + } + return validateModelPromptLength("视频", input.Prompt, profile.Video.References.PromptMaxChars) +} + +func validateModelPromptLength(label string, prompt string, maxChars int) error { + if maxChars <= 0 { + return nil + } + actualChars := utf8.RuneCountInString(prompt) + if actualChars <= maxChars { + return nil + } + return BadAuthRequest(fmt.Sprintf("当前%s模型提示词最多 %d 个字符,完整提示词为 %d 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试", label, maxChars, actualChars)) +} + func validateGPTImage2CustomSize(value string) error { value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x"))) if value == "" || value == "auto" { diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 5ce98f587..1efa0f715 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -37,6 +37,39 @@ func TestValidateImageTaskDoesNotApplyQualityPromptLimitToGrokLite(t *testing.T) } } +func TestValidateVideoTaskRejectsPromptAboveCapabilityLimit(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("autodl-comfyui", "MiniMax H3").Video + profile.References.PromptMaxChars = 10_000 + input := canvasGenerationInput{ + Mode: "video", + Prompt: strings.Repeat("镜", 23_142), + Config: providerConfig{Model: "MiniMax H3", VideoSeconds: "15", Size: "16:9", VQuality: "768p横"}, + } + + err := validateVideoTask(profile, input) + if err == nil || !strings.Contains(err.Error(), "最多 10000 个字符") || !strings.Contains(err.Error(), "23142 个字符") || !strings.Contains(err.Error(), "不会自动截断") { + t.Fatalf("validateVideoTask() error = %v", err) + } +} + +func TestValidateTaskCapabilityRejectsWorkflowPromptAboveConfiguredLimit(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("runninghub-workflow-video", "workflow-video") + profile.Video.References.PromptMaxChars = 10_000 + input := map[string]any{ + "mode": "video", + "prompt": strings.Repeat("镜", 10_001), + "config": map[string]any{ + "interfaceType": "runninghub-workflow-video", + "capabilityConfig": profile, + }, + } + + err := (&Service{}).ValidateTaskCapability(input) + if err == nil || !strings.Contains(err.Error(), "完整提示词为 10001 个字符") { + t.Fatalf("ValidateTaskCapability() error = %v", err) + } +} + func TestValidateImageTaskEnforcesGPTImage2CustomSizeLimits(t *testing.T) { profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2") profile.Size.AllowCustom = true diff --git a/backend/internal/service/project_workflow.go b/backend/internal/service/project_workflow.go index 2c1e93699..ed0281ca1 100644 --- a/backend/internal/service/project_workflow.go +++ b/backend/internal/service/project_workflow.go @@ -1,6 +1,8 @@ package service import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "strings" @@ -590,8 +592,8 @@ func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string, return "", err } now := time.Now() - assetID := "workflow-asset-" + task.ID - versionID := "workflow-version-" + task.ID + assetID := workflowGeneratedEntityID("asset", task.ID) + versionID := workflowGeneratedEntityID("version", task.ID) label := "图片" if mediaType == "video" { label = "视频" @@ -627,6 +629,12 @@ func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string, return versionID, nil } +// 工作流产物重试必须复用同一实体 ID,同时需要满足素材版本及外键的 varchar(36) 约束。 +func workflowGeneratedEntityID(namespace string, taskID string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(namespace) + ":" + strings.TrimSpace(taskID))) + return hex.EncodeToString(sum[:16]) +} + func validWorkflowStepStatus(status model.WorkflowStepStatus) bool { switch status { case model.WorkflowStepStatusPending, model.WorkflowStepStatusReady, model.WorkflowStepStatusRunning, model.WorkflowStepStatusReview, model.WorkflowStepStatusCompleted, model.WorkflowStepStatusFailed, model.WorkflowStepStatusSkipped: diff --git a/backend/internal/service/project_workflow_v2_test.go b/backend/internal/service/project_workflow_v2_test.go index 07004ef4e..e712a5d19 100644 --- a/backend/internal/service/project_workflow_v2_test.go +++ b/backend/internal/service/project_workflow_v2_test.go @@ -59,7 +59,7 @@ func TestRegisterTaskOutputFromTaskPersistsMediaAssetAndArtifactIdempotently(t * if err := db.Create(&resource).Error; err != nil { t.Fatal(err) } - task := model.Task{ID: "workflow-video-task-1", UserID: "user-1", ProjectID: project.ID, Type: "canvas_video", Status: model.TaskStatusSucceeded, + task := model.Task{ID: "ac745990450a86d3365eb92ec26f378e", UserID: "user-1", ProjectID: project.ID, Type: "canvas_video", Status: model.TaskStatusSucceeded, InputJSON: `{"metadata":{"workflowStepId":"` + videoStep.ID + `","domainProjectId":"` + project.ID + `","unitId":"` + unit.ID + `","shotId":"` + shot.ID + `","shotRevisionId":"` + submittedRevisionID + `","artifactType":"video","role":"output","artifactMetadata":{"model":"MiniMax-H3"}}}`, ResultJSON: `{"mode":"video","video":{"resourceId":"resource-video-1","storageKey":"resource:resource-video-1","mimeType":"video/mp4"}}`, CreatedAt: now, UpdatedAt: now} if err := db.Create(&task).Error; err != nil { @@ -71,17 +71,38 @@ func TestRegisterTaskOutputFromTaskPersistsMediaAssetAndArtifactIdempotently(t * if err := service.RegisterTaskOutputFromTask(task); err != nil { t.Fatal(err) } - for table, query := range map[string]string{ - "assets": "id = 'workflow-asset-workflow-video-task-1'", - "project_asset_links": "asset_id = 'workflow-asset-workflow-video-task-1'", - "asset_representations": "task_id = 'workflow-video-task-1' AND role = 'output'", - "shot_artifacts": "task_id = 'workflow-video-task-1' AND type = 'video'", - } { + assetID := workflowGeneratedEntityID("asset", task.ID) + versionID := workflowGeneratedEntityID("version", task.ID) + checks := []struct { + table string + where string + value string + }{ + {table: "assets", where: "id = ?", value: assetID}, + {table: "project_asset_links", where: "asset_id = ?", value: assetID}, + {table: "asset_representations", where: "task_id = ? AND role = 'output'", value: task.ID}, + {table: "shot_artifacts", where: "task_id = ? AND type = 'video'", value: task.ID}, + } + for _, check := range checks { var count int64 - if err := db.Table(table).Where(query).Count(&count).Error; err != nil || count != 1 { - t.Fatalf("%s count = %d, error = %v", table, count, err) + if err := db.Table(check.table).Where(check.where, check.value).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("%s count = %d, error = %v", check.table, count, err) } } + var asset model.Asset + if err := db.First(&asset, "id = ?", assetID).Error; err != nil { + t.Fatal(err) + } + if len(asset.PrimaryVersionID) > 36 || asset.PrimaryVersionID != versionID { + t.Fatalf("primary version ID = %q, want %q with at most 36 characters", asset.PrimaryVersionID, versionID) + } + var version model.AssetVersion + if err := db.First(&version, "id = ?", versionID).Error; err != nil { + t.Fatal(err) + } + if len(version.ID) > 36 { + t.Fatalf("asset version ID length = %d, want at most 36", len(version.ID)) + } var artifact model.ShotArtifact if err := db.First(&artifact, "task_id = ?", task.ID).Error; err != nil { t.Fatal(err) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index fb5fa3006..5be30a771 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -344,6 +344,9 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string if err := s.RequireWorkflowPluginForInterface(input.Config.InterfaceType); err != nil { return nil, err } + if err := validateWorkflowProviderPromptLength(input); err != nil { + return nil, err + } if err := validateWorkflowProviderConfig(input.Mode, input.Config); err != nil { return nil, err } @@ -4950,8 +4953,7 @@ func doJSON(req *http.Request, target interface{}) error { } } if payload, ok := target.(*map[string]interface{}); ok { - if code, ok := (*payload)["code"].(float64); ok && code != 0 { - rawMessage := stringField(*payload, "msg") + if _, rawMessage, failed := providerPayloadBusinessFailure(*payload); failed { return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)} } if errValue, ok := (*payload)["error"].(map[string]interface{}); ok && stringField(errValue, "message") != "" { @@ -5138,12 +5140,16 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in if requestErr != nil || statusCode < 200 || statusCode >= 300 { status = model.ApiCallStatusFailed errorCode, errorText = providerRequestErrorDetails(requestErr) + } else if businessCode, businessMessage, failed := providerResponseBusinessFailure(responseBody); failed { + status = model.ApiCallStatusFailed + errorCode = businessCode + errorText = businessMessage } requestKind := providerRequestKind(req.Method, req.URL.Path) if metadata.RequestKind != "" { requestKind = metadata.RequestKind } - if requestErr == nil && statusCode >= 200 && statusCode < 300 && (requestKind == "create" || requestKind == "poll") && (metadata.Capability == "image" || metadata.Capability == "video") { + if status == model.ApiCallStatusSucceeded && (requestKind == "create" || requestKind == "poll") && (metadata.Capability == "image" || metadata.Capability == "video") { metadata.Service.syncProviderTaskProgress(metadata.TaskID, responseBody) } apiFormat := "openai" diff --git a/backend/internal/service/provider_error.go b/backend/internal/service/provider_error.go index 9ecfdb568..bbd03b9ce 100644 --- a/backend/internal/service/provider_error.go +++ b/backend/internal/service/provider_error.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "fmt" "strings" ) @@ -27,11 +28,49 @@ func providerFailureDetails(payload map[string]any) (string, string) { } if message == "" { message = strings.TrimSpace(stringField(candidate, "message")) + if message == "" { + message = strings.TrimSpace(stringField(candidate, "msg")) + } } } return code, truncateRunes(message, 500) } +func providerResponseBusinessFailure(responseBody []byte) (string, string, bool) { + if len(responseBody) == 0 { + return "", "", false + } + var payload map[string]any + if json.Unmarshal(responseBody, &payload) != nil { + return "", "", false + } + return providerPayloadBusinessFailure(payload) +} + +func providerPayloadBusinessFailure(payload map[string]any) (string, string, bool) { + if errorValue, ok := payload["error"].(map[string]any); ok { + code, message := providerFailureDetails(map[string]any{"error": errorValue}) + if code != "" || message != "" { + return code, message, true + } + } + if !providerBusinessCodeFailed(payload["code"]) { + return "", "", false + } + code, message := providerFailureDetails(payload) + return code, message, true +} + +func providerBusinessCodeFailed(value any) bool { + code := strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) + switch code { + case "", "0", "success", "succeeded", "ok", "": + return false + default: + return true + } +} + func normalizedProviderErrorCode(value any) string { var code string switch current := value.(type) { diff --git a/backend/internal/service/provider_error_test.go b/backend/internal/service/provider_error_test.go index 2075c6691..b9ad47b15 100644 --- a/backend/internal/service/provider_error_test.go +++ b/backend/internal/service/provider_error_test.go @@ -42,3 +42,20 @@ func TestContentModerationFailureRequiresExactProviderCode(t *testing.T) { t.Fatal("generic HTTP 400 must remain retryable") } } + +func TestProviderPayloadBusinessFailureRecognizesStringErrorCode(t *testing.T) { + code, message, failed := providerPayloadBusinessFailure(map[string]any{ + "code": "RequestParameterIsWrong", + "data": nil, + "msg": "参数: prompt 的长度: 23142 大于最大长度 10000", + }) + if !failed || code != "RequestParameterIsWrong" || message != "参数: prompt 的长度: 23142 大于最大长度 10000" { + t.Fatalf("business failure = (%q, %q, %v)", code, message, failed) + } +} + +func TestProviderPayloadBusinessFailureAcceptsStringSuccessCode(t *testing.T) { + if code, message, failed := providerPayloadBusinessFailure(map[string]any{"code": "Success", "data": map[string]any{"task_id": "task-1"}}); failed { + t.Fatalf("success payload was marked failed: (%q, %q)", code, message) + } +} diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index e4d48ff3a..45c34bc94 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -101,6 +101,19 @@ export function modelCompatibilityError(config: AiConfig, model: string, require return input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0 ? "音频模型只接受文本或单个角色卡输入" : ""; } +export function modelPromptLengthError(config: AiConfig, model: string, capability: ModelCapability, prompt: string) { + const channel = resolveModelChannel(config, model); + const configuredProfile = channel.modelCosts?.find((item) => item.model === modelOptionName(model))?.capabilityConfig; + if (!configuredProfile) return ""; + const profile = modelCapabilityConfigFor(config, model); + const maxChars = capability === "text" ? profile.text?.references.promptMaxChars : capability === "image" ? profile.image?.references.promptMaxChars : capability === "video" ? profile.video?.references.promptMaxChars : undefined; + if (!maxChars || maxChars <= 0) return ""; + const actualChars = Array.from(prompt).length; + if (actualChars <= maxChars) return ""; + const label = capability === "text" ? "文本" : capability === "image" ? "图片" : "视频"; + return `当前${label}模型提示词最多 ${maxChars} 个字符,完整提示词为 ${actualChars} 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试`; +} + export function modelRequestOptions(config: AiConfig, capability: ModelCapability) { switch (capability) { case "image": diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index 61cac6c84..c0375148f 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -9,7 +9,7 @@ import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-ba import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution"; import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error"; -import { modelCompatibilityError, modelGroupReferenceLimits, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection"; +import { modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import type { Skill } from "@/services/api/skills"; import { skillRuntime } from "@/services/skill-runtime"; @@ -185,6 +185,11 @@ export function useCanvasGenerationExecutor({ return; } } + const promptLengthError = mode === "video" ? modelPromptLengthError(generationConfig, generationConfig.model, mode, effectivePrompt) : ""; + if (promptLengthError) { + message.error(promptLengthError); + return; + } const generationContext = { ...rawGenerationContext, prompt: effectivePrompt }; if (mode === "audio" && generationContext.characterReferences.length) { if (generationContext.characterReferences.length !== 1) { diff --git a/web/test/canvas-model-policy.test.ts b/web/test/canvas-model-policy.test.ts index 7afdc2074..829f3f43c 100644 --- a/web/test/canvas-model-policy.test.ts +++ b/web/test/canvas-model-policy.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"; import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy"; import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; -import { groupModelsByDisplayName, inferVideoOperation, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel, resolveModelGenerationDefaults } from "../src/lib/model-selection"; +import { groupModelsByDisplayName, inferVideoOperation, modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, resolveCompatibleModel, resolveModelGenerationDefaults } from "../src/lib/model-selection"; import { defaultConfig, normalizeModelOptionValue, type AiConfig, type ModelChannel } from "../src/stores/use-config-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; @@ -53,6 +53,18 @@ function node(id: string, type: CanvasNodeType, generationMode?: "image" | "vide } describe("逻辑模型选择", () => { + test("技能上下文展开后按最终视频模型字符上限拒绝提交", () => { + const config = policyConfig(); + const model = config.videoModels[0]!; + config.channels[0]!.modelCosts![0]!.capabilityConfig!.video!.references.promptMaxChars = 10_000; + + const error = modelPromptLengthError(config, model, "video", "镜".repeat(23_142)); + + expect(error).toContain("最多 10000 个字符"); + expect(error).toContain("完整提示词为 23142 个字符"); + expect(error).toContain("不会自动截断"); + }); + test("图片逻辑模型忽略全局视频时长,不应显示为不支持当前时长", () => { const model = "platform::gpt-image-2"; const channel: ModelChannel = {