fix(canvas): 生成任务 - 校验视频提示词长度并修复工作流产物ID与供应商错误识别
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"infinite-canvas/backend/internal/model"
|
"infinite-canvas/backend/internal/model"
|
||||||
)
|
)
|
||||||
@@ -593,6 +594,9 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if isWorkflowProviderInterface(taskInput.Config.InterfaceType) {
|
if isWorkflowProviderInterface(taskInput.Config.InterfaceType) {
|
||||||
|
if err := validateWorkflowProviderPromptLength(taskInput); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config)
|
return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config)
|
||||||
}
|
}
|
||||||
// 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。
|
// 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。
|
||||||
@@ -672,6 +676,12 @@ func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapab
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error {
|
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 {
|
if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios {
|
||||||
return BadAuthRequest("参考素材数量超过当前模型限制")
|
return BadAuthRequest("参考素材数量超过当前模型限制")
|
||||||
}
|
}
|
||||||
@@ -767,6 +777,25 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
|
|||||||
return nil
|
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 {
|
func validateGPTImage2CustomSize(value string) error {
|
||||||
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x")))
|
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x")))
|
||||||
if value == "" || value == "auto" {
|
if value == "" || value == "auto" {
|
||||||
|
|||||||
@@ -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) {
|
func TestValidateImageTaskEnforcesGPTImage2CustomSizeLimits(t *testing.T) {
|
||||||
profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2")
|
profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2")
|
||||||
profile.Size.AllowCustom = true
|
profile.Size.AllowCustom = true
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -590,8 +592,8 @@ func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string,
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
assetID := "workflow-asset-" + task.ID
|
assetID := workflowGeneratedEntityID("asset", task.ID)
|
||||||
versionID := "workflow-version-" + task.ID
|
versionID := workflowGeneratedEntityID("version", task.ID)
|
||||||
label := "图片"
|
label := "图片"
|
||||||
if mediaType == "video" {
|
if mediaType == "video" {
|
||||||
label = "视频"
|
label = "视频"
|
||||||
@@ -627,6 +629,12 @@ func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string,
|
|||||||
return versionID, nil
|
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 {
|
func validWorkflowStepStatus(status model.WorkflowStepStatus) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case model.WorkflowStepStatusPending, model.WorkflowStepStatusReady, model.WorkflowStepStatusRunning, model.WorkflowStepStatusReview, model.WorkflowStepStatusCompleted, model.WorkflowStepStatusFailed, model.WorkflowStepStatusSkipped:
|
case model.WorkflowStepStatusPending, model.WorkflowStepStatusReady, model.WorkflowStepStatusRunning, model.WorkflowStepStatusReview, model.WorkflowStepStatusCompleted, model.WorkflowStepStatusFailed, model.WorkflowStepStatusSkipped:
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestRegisterTaskOutputFromTaskPersistsMediaAssetAndArtifactIdempotently(t *
|
|||||||
if err := db.Create(&resource).Error; err != nil {
|
if err := db.Create(&resource).Error; err != nil {
|
||||||
t.Fatal(err)
|
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"}}}`,
|
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}
|
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 {
|
if err := db.Create(&task).Error; err != nil {
|
||||||
@@ -71,17 +71,38 @@ func TestRegisterTaskOutputFromTaskPersistsMediaAssetAndArtifactIdempotently(t *
|
|||||||
if err := service.RegisterTaskOutputFromTask(task); err != nil {
|
if err := service.RegisterTaskOutputFromTask(task); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for table, query := range map[string]string{
|
assetID := workflowGeneratedEntityID("asset", task.ID)
|
||||||
"assets": "id = 'workflow-asset-workflow-video-task-1'",
|
versionID := workflowGeneratedEntityID("version", task.ID)
|
||||||
"project_asset_links": "asset_id = 'workflow-asset-workflow-video-task-1'",
|
checks := []struct {
|
||||||
"asset_representations": "task_id = 'workflow-video-task-1' AND role = 'output'",
|
table string
|
||||||
"shot_artifacts": "task_id = 'workflow-video-task-1' AND type = 'video'",
|
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
|
var count int64
|
||||||
if err := db.Table(table).Where(query).Count(&count).Error; err != nil || count != 1 {
|
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", table, count, err)
|
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
|
var artifact model.ShotArtifact
|
||||||
if err := db.First(&artifact, "task_id = ?", task.ID).Error; err != nil {
|
if err := db.First(&artifact, "task_id = ?", task.ID).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -344,6 +344,9 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
|||||||
if err := s.RequireWorkflowPluginForInterface(input.Config.InterfaceType); err != nil {
|
if err := s.RequireWorkflowPluginForInterface(input.Config.InterfaceType); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := validateWorkflowProviderPromptLength(input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := validateWorkflowProviderConfig(input.Mode, input.Config); err != nil {
|
if err := validateWorkflowProviderConfig(input.Mode, input.Config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -4950,8 +4953,7 @@ func doJSON(req *http.Request, target interface{}) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if payload, ok := target.(*map[string]interface{}); ok {
|
if payload, ok := target.(*map[string]interface{}); ok {
|
||||||
if code, ok := (*payload)["code"].(float64); ok && code != 0 {
|
if _, rawMessage, failed := providerPayloadBusinessFailure(*payload); failed {
|
||||||
rawMessage := stringField(*payload, "msg")
|
|
||||||
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
|
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
|
||||||
}
|
}
|
||||||
if errValue, ok := (*payload)["error"].(map[string]interface{}); ok && stringField(errValue, "message") != "" {
|
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 {
|
if requestErr != nil || statusCode < 200 || statusCode >= 300 {
|
||||||
status = model.ApiCallStatusFailed
|
status = model.ApiCallStatusFailed
|
||||||
errorCode, errorText = providerRequestErrorDetails(requestErr)
|
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)
|
requestKind := providerRequestKind(req.Method, req.URL.Path)
|
||||||
if metadata.RequestKind != "" {
|
if metadata.RequestKind != "" {
|
||||||
requestKind = 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)
|
metadata.Service.syncProviderTaskProgress(metadata.TaskID, responseBody)
|
||||||
}
|
}
|
||||||
apiFormat := "openai"
|
apiFormat := "openai"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -27,11 +28,49 @@ func providerFailureDetails(payload map[string]any) (string, string) {
|
|||||||
}
|
}
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = strings.TrimSpace(stringField(candidate, "message"))
|
message = strings.TrimSpace(stringField(candidate, "message"))
|
||||||
|
if message == "" {
|
||||||
|
message = strings.TrimSpace(stringField(candidate, "msg"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return code, truncateRunes(message, 500)
|
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", "<nil>":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizedProviderErrorCode(value any) string {
|
func normalizedProviderErrorCode(value any) string {
|
||||||
var code string
|
var code string
|
||||||
switch current := value.(type) {
|
switch current := value.(type) {
|
||||||
|
|||||||
@@ -42,3 +42,20 @@ func TestContentModerationFailureRequiresExactProviderCode(t *testing.T) {
|
|||||||
t.Fatal("generic HTTP 400 must remain retryable")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ export function modelCompatibilityError(config: AiConfig, model: string, require
|
|||||||
return input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0 ? "音频模型只接受文本或单个角色卡输入" : "";
|
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) {
|
export function modelRequestOptions(config: AiConfig, capability: ModelCapability) {
|
||||||
switch (capability) {
|
switch (capability) {
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-ba
|
|||||||
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
||||||
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
|
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
|
||||||
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
|
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 { navigateToSettings } from "@/lib/settings-navigation";
|
||||||
import type { Skill } from "@/services/api/skills";
|
import type { Skill } from "@/services/api/skills";
|
||||||
import { skillRuntime } from "@/services/skill-runtime";
|
import { skillRuntime } from "@/services/skill-runtime";
|
||||||
@@ -185,6 +185,11 @@ export function useCanvasGenerationExecutor({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const promptLengthError = mode === "video" ? modelPromptLengthError(generationConfig, generationConfig.model, mode, effectivePrompt) : "";
|
||||||
|
if (promptLengthError) {
|
||||||
|
message.error(promptLengthError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
|
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
|
||||||
if (mode === "audio" && generationContext.characterReferences.length) {
|
if (mode === "audio" && generationContext.characterReferences.length) {
|
||||||
if (generationContext.characterReferences.length !== 1) {
|
if (generationContext.characterReferences.length !== 1) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
|
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
|
||||||
import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
|
import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
|
||||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
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 { defaultConfig, normalizeModelOptionValue, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
|
||||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||||
|
|
||||||
@@ -53,6 +53,18 @@ function node(id: string, type: CanvasNodeType, generationMode?: "image" | "vide
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("逻辑模型选择", () => {
|
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("图片逻辑模型忽略全局视频时长,不应显示为不支持当前时长", () => {
|
test("图片逻辑模型忽略全局视频时长,不应显示为不支持当前时长", () => {
|
||||||
const model = "platform::gpt-image-2";
|
const model = "platform::gpt-image-2";
|
||||||
const channel: ModelChannel = {
|
const channel: ModelChannel = {
|
||||||
|
|||||||
Reference in New Issue
Block a user