diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go index a831f28c1..6b2f97813 100644 --- a/backend/internal/service/email.go +++ b/backend/internal/service/email.go @@ -195,9 +195,8 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error { if err := s.repo.Create(&record); err != nil { return err } - brandName := s.appearanceBrandName() - setting = resolveEmailSender(setting, brandName) - if err := s.deliverEmail(setting, email, brandName+"注册验证码", registrationEmailBody(brandName, code)); err != nil { + setting = resolveEmailSender(setting, s.appearanceBrandName()) + if err := s.deliverEmail(setting, email, setting.FromName+"注册验证码", registrationEmailBody(setting.FromName, code)); err != nil { cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID) if cleanupErr != nil { return errors.Join( diff --git a/backend/internal/service/finance_price_tier_test.go b/backend/internal/service/finance_price_tier_test.go index 4b3930cbb..c95a9f032 100644 --- a/backend/internal/service/finance_price_tier_test.go +++ b/backend/internal/service/finance_price_tier_test.go @@ -1,7 +1,10 @@ package service import ( + "fmt" + "strings" "testing" + "time" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" @@ -10,6 +13,72 @@ import ( "gorm.io/gorm" ) +func TestImageSpecificationQuoteAgreesWithTaskBilling(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil { + t.Fatal(err) + } + channelModel := model.ChannelModel{ + ID: "image-channel-model", ChannelID: "image-channel", ModelKey: "image-model", Capability: "image", + Protocol: model.ChannelInterfaceOpenAIImage, Enabled: true, PriceConfigured: true, + } + for operationIndex, operation := range []string{"text_to_image", "image_to_image"} { + for index, quality := range []string{"1k", "2k", "4k"} { + selector := fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality) + channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{ + ID: operation + "-" + quality, ChannelModelID: channelModel.ID, SelectorKey: selector, SelectorJSON: selector, + Resolution: "*", BillingMode: "fixed_request", UnitPriceMicrocredits: int64(index+1+operationIndex*3) * 1_000_000, + Enabled: true, PriceConfigured: true, + }) + } + } + if err := db.Create(&channelModel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&channelModel.PriceTiers).Error; err != nil { + t.Fatal(err) + } + spec := CapabilitySpec{Version: 1, Capability: "image", Inputs: map[string]InputConstraint{"image": {Max: 9}}, Options: map[string]OptionConstraint{"quality": {Values: []any{"1k", "2k", "4k"}}}} + cached := cachedLogicalModel{ + Model: model.LogicalModel{ID: "logical-image", PricePolicy: "channel", Capability: "image"}, + ProductSpec: spec, Defaults: map[string]any{"quality": "2k"}, + Routes: []cachedLogicalRoute{{Route: model.LogicalModelRoute{ID: "image-route", Enabled: true, Weight: 1}, CapabilitySpec: spec, ChannelModel: channelModel}}, + } + svc := &Service{repo: repository.New(db), routeCatalogTTL: time.Hour, routeCatalog: &routeCatalogSnapshot{LoadedAt: time.Now(), Models: map[string]cachedLogicalModel{"logical-image": cached}}} + for _, tier := range channelModel.PriceTiers { + selector := model.DecodeSKUSelector(tier.SelectorJSON) + imageCount := 0 + if selector["operation"] == "image_to_image" { + imageCount = 1 + } + input := map[string]any{"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{ + "channelId": channelModel.ChannelID, "model": channelModel.ModelKey, "quality": strings.ToUpper(selector["quality"]), + }} + intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image") + quote, err := svc.QuoteLogicalModel("logical-image", intent) + if err != nil { + t.Fatalf("quote %s: %v", tier.ID, err) + } + order, err := svc.taskBillingOrder("user", &model.Task{ID: "task", Type: "canvas_image", Operation: "image"}, input) + if err != nil { + t.Fatalf("billing %s: %v", tier.ID, err) + } + if quote.AmountMicrocredits != tier.UnitPriceMicrocredits || order.AmountMicrocredits != quote.AmountMicrocredits || order.PriceTierID != tier.ID { + t.Fatalf("tier %s: quote=%#v order=%#v", tier.ID, quote, order) + } + } + quote, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image"}) + if err != nil || quote.AmountMicrocredits != 2_000_000 { + t.Fatalf("default resolution quote=%#v error=%v", quote, err) + } + if _, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); err == nil { + t.Fatal("unconfigured resolution must not be quoted") + } +} + func TestTaskBillingOrderMatchesSystemImagePriceTierFromRequestedSpec(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index 22cf25a24..d294d909f 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -85,7 +85,14 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper } func normalizeModelRequestOption(name string, value any) any { - if canonicalCapabilityOptionName(name) != "vquality" { + canonicalName := canonicalCapabilityOptionName(name) + if canonicalName == "quality" || canonicalName == "size" { + if text, ok := value.(string); ok { + return strings.ToLower(strings.TrimSpace(text)) + } + return value + } + if canonicalName != "vquality" { return value } resolution, ok := value.(string) @@ -730,8 +737,22 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { selector["videoSeconds"] = strconv.Itoa(seconds) } case "image": + if intent.Inputs["image"] > 0 { + selector["operation"] = "image_to_image" + } else { + selector["operation"] = "text_to_image" + } + rawQuality, _ := intent.Options["quality"].(string) + rawSize, _ := intent.Options["size"].(string) + if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" { + selector["quality"] = quality + } for _, key := range []string{"quality", "size"} { - if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" { + if key == "quality" && selector["quality"] != "" { + continue + } + text, _ := intent.Options[key].(string) + if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" { selector[key] = value } } @@ -739,6 +760,33 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { return selector } +func normalizeImagePriceQuality(rawQuality string, rawSize string) string { + quality := strings.ToLower(strings.TrimSpace(rawQuality)) + if quality != "" && quality != "auto" && quality != "any" { + return quality + } + parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x") + if len(parts) != 2 { + return "" + } + width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height { + return "" + } + pixels := width * height + switch { + case pixels <= 2_000_000: + return "1k" + case pixels <= 4_300_000: + return "2k" + case pixels <= 8_294_400: + return "4k" + default: + return "" + } +} + func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string { selector := model.DecodeSKUSelector(tier.SelectorJSON) if len(selector) == 0 { diff --git a/backend/internal/service/model_router_test.go b/backend/internal/service/model_router_test.go index e076f1e74..d2efcddde 100644 --- a/backend/internal/service/model_router_test.go +++ b/backend/internal/service/model_router_test.go @@ -1,11 +1,43 @@ package service import ( + "fmt" "testing" "infinite-canvas/backend/internal/model" ) +func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) { + channelModel := model.ChannelModel{} + for _, operation := range []string{"text_to_image", "image_to_image"} { + for _, quality := range []string{"1k", "2k", "4k"} { + channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{ + ID: operation + "-" + quality, SelectorJSON: fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality), Enabled: true, PriceConfigured: true, + }) + } + } + for _, operation := range []string{"", "image", "text_to_image", "image_to_image"} { + for _, quality := range []string{"1K", "2K", "4K"} { + for _, imageCount := range []int{0, 1, 3} { + intent := ModelRequestIntentFromTaskInput(map[string]any{ + "mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{"quality": quality}, + }, "canvas_image", operation) + wantOperation := "text_to_image" + if imageCount > 0 { + wantOperation = "image_to_image" + } + tier := channelModelPriceTierForIntent(channelModel, intent) + if tier == nil || tier.ID != wantOperation+"-"+skuSelectorForIntent(intent)["quality"] { + t.Fatalf("operation=%q quality=%q imageCount=%d: tier=%#v", operation, quality, imageCount, tier) + } + } + } + } + if tier := channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); tier != nil { + t.Fatalf("unconfigured resolution matched tier: %#v", tier) + } +} + func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) { input := map[string]any{ "mode": "video", @@ -17,6 +49,55 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) { } } +func TestModelRequestIntentNormalizesImageSpecificationValues(t *testing.T) { + input := map[string]any{ + "mode": "image", + "config": map[string]any{"quality": "1K", "size": "3:2"}, + } + intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image") + if got := intent.Options["quality"]; got != "1k" { + t.Fatalf("quality = %#v, want 1k", got) + } + if got := intent.Options["size"]; got != "3:2" { + t.Fatalf("size = %#v, want 3:2", got) + } +} + +func TestSKUSelectorInfersImageResolutionFromSizeWhenQualityIsAutomatic(t *testing.T) { + for _, test := range []struct { + size string + want string + }{ + {size: "1024x1024", want: "1k"}, + {size: "2048x2048", want: "2k"}, + {size: "2880x2880", want: "4k"}, + } { + selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "auto", "size": test.size}}) + if selector["quality"] != test.want { + t.Fatalf("size %s quality = %q, want %q", test.size, selector["quality"], test.want) + } + } +} + +func TestSKUSelectorHandlesMissingImageOptions(t *testing.T) { + for _, quality := range []any{nil, "", "auto", "any"} { + options := map[string]any{"size": "2048x2048"} + if quality != nil { + options["quality"] = quality + } + selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: options}) + if selector["quality"] != "2k" { + t.Errorf("quality=%#v: selector=%#v, want 2k", quality, selector) + } + } + for _, options := range []map[string]any{nil, {}, {"quality": nil, "size": nil}} { + selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: options}) + if len(selector) != 1 || selector["operation"] != "text_to_image" { + t.Errorf("missing options produced synthetic specification: %#v", selector) + } + } +} + func TestSKUSelectorIncludesVideoReferenceImageCount(t *testing.T) { selector := skuSelectorForIntent(ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}}) if selector["imageCount"] != "5" || selector["vquality"] != "720p" { diff --git a/backend/internal/service/password_reset.go b/backend/internal/service/password_reset.go index f4e99648e..432c60b62 100644 --- a/backend/internal/service/password_reset.go +++ b/backend/internal/service/password_reset.go @@ -74,9 +74,8 @@ func (s *Service) SendPasswordResetEmailCode(rawEmail string) error { if err := s.repo.Create(&record); err != nil { return err } - brandName := s.appearanceBrandName() - setting = resolveEmailSender(setting, brandName) - if err := s.deliverEmail(setting, email, brandName+"密码重置验证码", passwordResetEmailBody(brandName, code)); err != nil { + setting = resolveEmailSender(setting, s.appearanceBrandName()) + if err := s.deliverEmail(setting, email, setting.FromName+"密码重置验证码", passwordResetEmailBody(setting.FromName, code)); err != nil { if cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID); cleanupErr != nil { log.Printf("password reset email cleanup failed: recipient=%s error=%v", maskedEmail(email), cleanupErr) } diff --git a/backend/internal/service/password_reset_test.go b/backend/internal/service/password_reset_test.go index 2ae4b5789..1db9c849b 100644 --- a/backend/internal/service/password_reset_test.go +++ b/backend/internal/service/password_reset_test.go @@ -188,8 +188,38 @@ func TestPasswordResetDeliveryFailureRemovesUnusableCode(t *testing.T) { } } -func TestCustomAppearanceBrandsRegistrationAndPasswordResetEmails(t *testing.T) { +func TestSenderNameBrandsRegistrationAndPasswordResetEmails(t *testing.T) { + for _, tc := range []struct { + name string + fromName string + wantName string + }{ + {name: "default inherits appearance", fromName: defaultAppearanceBrandName, wantName: "HIMA Studio"}, + {name: "empty inherits appearance", fromName: "", wantName: "HIMA Studio"}, + {name: "custom sender overrides appearance", fromName: "Custom Canvas", wantName: "Custom Canvas"}, + {name: "custom sender is trimmed", fromName: " 自定义工作台 ", wantName: "自定义工作台"}, + } { + t.Run(tc.name, func(t *testing.T) { + assertRegistrationAndPasswordResetEmailBrand(t, tc.fromName, tc.wantName) + }) + } +} + +func assertRegistrationAndPasswordResetEmailBrand(t *testing.T, fromName, wantName string) { + t.Helper() svc, db := newPasswordResetTestService(t) + _, emailSetting, err := svc.readEmailSetting() + if err != nil { + t.Fatal(err) + } + emailSetting.FromName = fromName + settingJSON, err := json.Marshal(emailSetting) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", string(settingJSON)).Error; err != nil { + t.Fatal(err) + } appearanceJSON, err := json.Marshal(AppearanceSetting{ SchemaVersion: appearanceSchemaVersion, BrandName: "HIMA Studio", @@ -238,13 +268,16 @@ func TestCustomAppearanceBrandsRegistrationAndPasswordResetEmails(t *testing.T) t.Fatalf("deliveries = %d, want 2", len(deliveries)) } for _, delivered := range deliveries { - if delivered.fromName != "HIMA Studio" || !strings.Contains(delivered.subject, "HIMA Studio") || !strings.Contains(delivered.body, "HIMA Studio") || strings.Contains(delivered.subject, defaultAppearanceBrandName) || strings.Contains(delivered.body, defaultAppearanceBrandName) { - t.Fatalf("email did not inherit custom appearance: %#v", delivered) + if delivered.fromName != wantName || !strings.Contains(delivered.subject, wantName) || !strings.Contains(delivered.body, wantName) || strings.Contains(delivered.subject, defaultAppearanceBrandName) || strings.Contains(delivered.body, defaultAppearanceBrandName) { + t.Fatalf("email did not use resolved sender name %q: %#v", wantName, delivered) } } - if deliveries[0].subject != "HIMA Studio注册验证码" || deliveries[1].subject != "HIMA Studio密码重置验证码" { + if deliveries[0].subject != wantName+"注册验证码" || deliveries[1].subject != wantName+"密码重置验证码" { t.Fatalf("unexpected branded subjects: %#v", deliveries) } + if !strings.Contains(deliveries[0].body, "你正在注册"+wantName+"。") { + t.Fatalf("registration body did not use resolved sender name: %q", deliveries[0].body) + } } func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) { diff --git a/backend/internal/service/task_output.go b/backend/internal/service/task_output.go index 7ffc21cad..130d3458a 100644 --- a/backend/internal/service/task_output.go +++ b/backend/internal/service/task_output.go @@ -42,6 +42,7 @@ type TaskSummary struct { } type TaskClientContext struct { + NodeID string `json:"nodeId,omitempty"` ConversationID string `json:"conversationId,omitempty"` MessageID string `json:"messageId,omitempty"` BatchIndex int `json:"batchIndex,omitempty"` @@ -136,6 +137,7 @@ func taskClientContext(raw string) *TaskClientContext { var input struct { Metadata struct { Source string `json:"source"` + NodeID string `json:"nodeId"` ConversationID string `json:"conversationId"` MessageID string `json:"messageId"` BatchIndex int `json:"batchIndex"` @@ -152,16 +154,20 @@ func taskClientContext(raw string) *TaskClientContext { return nil } metadata := input.Metadata + context := &TaskClientContext{NodeID: metadata.NodeID} if metadata.Source == "create-page" && metadata.ConversationID != "" && metadata.MessageID != "" { - return &TaskClientContext{ - ConversationID: metadata.ConversationID, - MessageID: metadata.MessageID, - BatchIndex: metadata.BatchIndex, - BatchCount: metadata.BatchCount, - } + context.ConversationID = metadata.ConversationID + context.MessageID = metadata.MessageID + context.BatchIndex = metadata.BatchIndex + context.BatchCount = metadata.BatchCount + return context } if metadata.ShotID != "" && metadata.WorkflowStepID != "" { - return &TaskClientContext{DomainProjectID: metadata.DomainProjectID, ShotID: metadata.ShotID, WorkflowStepID: metadata.WorkflowStepID, ArtifactType: metadata.ArtifactType} + context.DomainProjectID = metadata.DomainProjectID + context.ShotID = metadata.ShotID + context.WorkflowStepID = metadata.WorkflowStepID + context.ArtifactType = metadata.ArtifactType + return context } chapterOperation := "" if metadata.Operation == "chapter_character_breakdown" { @@ -170,13 +176,15 @@ func taskClientContext(raw string) *TaskClientContext { chapterOperation = "storyboard" } if chapterOperation == "" || metadata.DomainProjectID == "" || metadata.ChapterID == "" { - return nil - } - return &TaskClientContext{ - DomainProjectID: metadata.DomainProjectID, - ChapterID: metadata.ChapterID, - ChapterOperation: chapterOperation, + if context.NodeID == "" { + return nil + } + return context } + context.DomainProjectID = metadata.DomainProjectID + context.ChapterID = metadata.ChapterID + context.ChapterOperation = chapterOperation + return context } // 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。 diff --git a/backend/internal/service/task_output_test.go b/backend/internal/service/task_output_test.go index 92ff6c44c..68f058623 100644 --- a/backend/internal/service/task_output_test.go +++ b/backend/internal/service/task_output_test.go @@ -61,6 +61,37 @@ func TestTaskClientContextRequiresCreatePageMetadata(t *testing.T) { } } +func TestTaskClientContextPreservesCanvasNodeID(t *testing.T) { + context := taskClientContext(`{"mode":"image","metadata":{"nodeId":"canvas-node-1","source":"canvas"}}`) + if context == nil || context.NodeID != "canvas-node-1" { + t.Fatalf("canvas node context was not preserved: %+v", context) + } +} + +func TestTaskSummaryPreservesCanvasNodeWithoutExposingInput(t *testing.T) { + summary := taskSummaryForOutput(model.Task{ + ID: "task-1", ProjectID: "project-1", Type: "canvas_image", + InputJSON: `{"metadata":{"nodeId":"node-1"},"config":{"apiKey":"private-test-value"}}`, + }) + encoded, err := json.Marshal(summary) + if err != nil { + t.Fatal(err) + } + var output map[string]any + if err := json.Unmarshal(encoded, &output); err != nil { + t.Fatal(err) + } + context, ok := output["clientContext"].(map[string]any) + if !ok || context["nodeId"] != "node-1" || len(context) != 1 { + t.Fatalf("summary lost safe canvas association: %#v", output) + } + for _, key := range []string{"inputJson", "config", "apiKey"} { + if _, exists := output[key]; exists { + t.Fatalf("summary leaked %s", key) + } + } +} + func TestTaskClientContextProjectsChapterOperations(t *testing.T) { characters := taskClientContext(`{"metadata":{"domainProjectId":"project-1","chapterId":"chapter-1","operation":"chapter_character_breakdown"}}`) if characters == nil || characters.DomainProjectID != "project-1" || characters.ChapterID != "chapter-1" || characters.ChapterOperation != "characters" { diff --git a/docs/content/docs/backend/task-runtime-stability.mdx b/docs/content/docs/backend/task-runtime-stability.mdx index 74e8029ea..4c6eb3885 100644 --- a/docs/content/docs/backend/task-runtime-stability.mdx +++ b/docs/content/docs/backend/task-runtime-stability.mdx @@ -13,6 +13,12 @@ description: 热点回源保护、并发租约、故障边界和上线验收 - 新增缓存是每个后端实例的有界内存读缓存,不是通用 Redis 业务缓存。实例增加时,缓存容量与不同实例的回源量会随之增加。 - handler 负责 SSE 协议和登录上下文;service 负责读缓存、并发协调和执行生命周期;repository 负责有条件的租约写入与事务。 +## 画布任务摘要与刷新恢复 + +任务列表的摘要通过 `clientContext.nodeId` 返回画布节点关联,不为恢复任务返回完整 `inputJson`、渠道配置或密钥。前端优先读取摘要中的节点 ID,任务详情仍可从输入元数据取得节点 ID;原有创作会话、章节和镜头工作流上下文继续保留。 + +已持久化任务 ID 的节点直接按 ID 恢复。缺少任务 ID 时,画布查询当前项目最近 100 条任务摘要并按节点 ID 匹配;这不是全量历史扫描,超过该范围的旧任务仍可能无法自动发现。任务读取继续遵循当前用户与项目的访问边界,不新增数据库字段或迁移。 + ## 热点读取保护 | 读路径 | 缓存 / 回源限制 | 故障行为 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 7dbf568b5..897a9a5a5 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -68,6 +68,26 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 素材预览端到端验证受当前资源接口 500、本机 Agent 连接失败影响;不将界面验证视为媒体链路全部通过。 - [ ] 真实素材端到端复核:局部重绘与表情生成、质感设置、音频提取保留原声、项目时间线定位;本次不自动触发收费模型任务。 +## 验证码邮件 + +- [ ] 邮件配置保存自定义发件名称后,新发送的注册验证码邮件发件名称、标题和正文保持一致;密码重置验证码邮件同样使用该名称。 +- [ ] 发件名称留空或使用默认名称时,验证码邮件继续继承站点品牌名称;再次修改自定义发件名称后,新邮件使用最新名称。 + +## 画布任务恢复 + +- [ ] 缺少任务 ID 的节点,通过当前项目最近 100 条任务摘要的 `clientContext.nodeId` 恢复;完成后正常回填。超出发现范围的旧任务不保证自动恢复。 +- [x] 回归覆盖任务摘要节点关联、敏感输入不泄露,以及前端在没有完整输入时读取节点 ID。 + +## 图片规格价格 + +- [ ] 仅配置 1K、2K、4K 三档价格(无统一兜底),画布逐一切换分辨率,显示与对应档位一致的积分价格。 +- [ ] 分别配置文生图、图生图规格价格;添加、移除参考图或角色参考后,报价与实际生成命中对应档位。 +- [ ] 新建图片节点时,报价使用节点模型默认规格,而不是全局旧参数;刷新后再次核对。 +- [ ] 配置统一价格时仍正常兜底;未配置且没有兜底的规格不能被计为其他档位价格。 +- [x] 合并检查补修:缺少质量参数时按有效尺寸匹配档位;不把空参数转为 `""`;面板保留实际生成的“自动”参数,不以价格表第一档替换请求规格。 +- [x] PR #423 合并验证:前端完整测试脚本 1139 项通过、生产构建通过;后端邮件、任务摘要和图片计价专项通过。后端全量仍有 4 项既有失败(私网上游校验 2 项、视频恢复策略 2 项),已在未合并的 `main` 基线上复现;最后一轮另有资源清理测试 SQLite 表锁失败,该项在合并分支与基线单独复跑均通过。未为通过测试而放宽安全或恢复策略。 +- [ ] 真实 SMTP 投递、浏览器刷新恢复和收费生成仍待人工验证。本次不触发外部邮件或收费请求。 + ## 编辑器(M2 命令状态机) - [x] 命令协议黄金文件:`editor-commands` 8 个黄金 op(addClip/moveClip/trimClip/splitClip/removeClip/setClipProperty/addSubtitle/removeSubtitle)全量快照比对通过;未知 op、非法 payload 一律抛错(fail-closed)且输入不可变。 diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index cde42642f..25fc62037 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -107,6 +107,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi }, mode), }; const config = buildNodeConfig(globalConfig, node, mode, requirements); + const resolvedRequirements: ModelRequirements = { + ...requirements, + options: modelRequestOptions(config, mode), + videoSeconds: mode === "video" ? config.videoSeconds : undefined, + }; const promptOptimizerProvider = useMemo(() => { if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null; return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig)); @@ -121,9 +126,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi seconds: mode === "video" ? config.videoSeconds : 1, capability: mode, config, - requirements, + requirements: resolvedRequirements, }); - const quoteRequest = modelQuoteRequest(config, config.model, mode, requirements); + const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements); const quoteRequestKey = JSON.stringify(quoteRequest || null); const [quotedCredits, setQuotedCredits] = useState(null); const credits = quotedCredits ?? configuredCredits; @@ -341,9 +346,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi value={config.model} onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} - requirements={requirements} + requirements={resolvedRequirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} + showOptionPrices={creditsEnabled} variant="creation" showConfiguredModelName /> @@ -690,7 +696,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode { return type === CanvasNodeType.Text || type === CanvasNodeType.Skill ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image"; } -function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig { +export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig { const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel; diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts index 5f6677519..31f45e94f 100644 --- a/web/src/lib/canvas/canvas-generation-task-sync.ts +++ b/web/src/lib/canvas/canvas-generation-task-sync.ts @@ -22,7 +22,7 @@ export function generationTaskInput(task: GenerationTask) { } export function generationTaskNodeId(task: GenerationTask) { - return generationTaskInput(task)?.metadata?.nodeId || ""; + return task.clientContext?.nodeId || generationTaskInput(task)?.metadata?.nodeId || ""; } export function generationTaskMode(task: GenerationTask, fallback?: CanvasGenerationMode): CanvasGenerationMode { diff --git a/web/src/lib/model-pricing.ts b/web/src/lib/model-pricing.ts index 92bfab8af..1e32d798c 100644 --- a/web/src/lib/model-pricing.ts +++ b/web/src/lib/model-pricing.ts @@ -1,5 +1,6 @@ import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection"; import { videoResolutionComparisonKey } from "@/lib/video-generation-options"; +import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers"; import type { ModelRequestIntent } from "@/services/api/logical-models"; import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; @@ -79,7 +80,7 @@ export function modelQuoteRequest(config: AiConfig, value: string, capability?: const input = requirements?.input; const intent: ModelRequestIntent = { capability, - operation: capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation, + operation: capability === "image" ? imagePriceOperation(requirements) : capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation, inputs: { image: (input?.imageCount || 0) + (input?.characterCount || 0), video: input?.videoCount || 0, @@ -115,12 +116,27 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config if (seconds > 0) requested.videoSeconds = String(seconds); } if (capability === "image") { - if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase(); - if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase(); + requested.operation = imagePriceOperation(requirements); + const options = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) }; + const imageQuality = String(options.quality ?? "").trim().toLowerCase(); + const imageSize = String(options.size ?? "").trim(); + if ((imageQuality === "" || imageQuality === "auto" || imageQuality === "any") && imageSize) { + const resolution = imageResolutionOption(buildImageResolutionOptions([imageSize]), imageSize)?.tier; + if (resolution) requested.quality = resolution; + } + for (const key of ["quality", "size"] as const) { + const value = String(options[key] ?? "").trim().toLowerCase(); + if (value && value !== "auto" && value !== "any" && !requested[key]) requested[key] = value; + } } return requested; } +function imagePriceOperation(requirements?: ModelRequirements) { + const input = requirements?.input; + return (input?.imageCount || 0) + (input?.characterCount || 0) > 0 ? "image_to_image" : "text_to_image"; +} + function priceSelectorForTier(tier: ModelPriceTier) { const selector = { ...(tier.selector || {}) }; if (!Object.keys(selector).length) { diff --git a/web/src/pages/canvas/use-canvas-generation.ts b/web/src/pages/canvas/use-canvas-generation.ts index f7342e8e6..0bd146b0d 100644 --- a/web/src/pages/canvas/use-canvas-generation.ts +++ b/web/src/pages/canvas/use-canvas-generation.ts @@ -377,7 +377,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, const needsDiscovery = recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId); const projectTasks = needsDiscovery ? ( - await listGenerationTasks(30, { projectId: startedProjectId }, undefined, signal).catch((error) => { + await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal).catch((error) => { if (!isCurrentProject()) throw error; return []; }) diff --git a/web/test/generation-task-local.test.ts b/web/test/generation-task-local.test.ts index 582cb2425..b3553de8b 100644 --- a/web/test/generation-task-local.test.ts +++ b/web/test/generation-task-local.test.ts @@ -11,6 +11,15 @@ import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas"; import { onlineToolToOps } from "../src/components/canvas/canvas-assistant-panel"; import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "../src/lib/generation-task-display"; import { generationErrorMessage } from "../src/lib/generation-error"; +import { generationTaskNodeId } from "../src/lib/canvas/canvas-generation-task-sync"; + +test("canvas recovery reads node identity from task summaries without full task input", () => { + const task: GenerationTask = { id: "task-summary", type: "canvas_image", status: "running", prompt: "", attempts: 1, createdAt: "", updatedAt: "", clientContext: { nodeId: "node-summary" } }; + expect(generationTaskNodeId(task)).toBe("node-summary"); + expect(generationTaskNodeId({ ...task, inputJson: "invalid json" })).toBe("node-summary"); + expect(generationTaskNodeId({ ...task, clientContext: undefined, inputJson: JSON.stringify({ metadata: { nodeId: "node-detail" } }) })).toBe("node-detail"); + expect(generationTaskNodeId({ ...task, clientContext: undefined })).toBe(""); +}); function compactSource(source: string) { return source.replace(/\s+/g, " ").trim(); diff --git a/web/test/model-pricing.test.ts b/web/test/model-pricing.test.ts index e20436421..2bb5e7933 100644 --- a/web/test/model-pricing.test.ts +++ b/web/test/model-pricing.test.ts @@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test"; import { modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing"; import type { ModelRequirements } from "../src/lib/model-selection"; import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store"; +import { buildNodeConfig } from "../src/components/canvas/canvas-node-prompt-panel"; +import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generation"; +import { modelRequestOptions } from "../src/lib/model-selection"; +import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas"; function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) { const capability = input.capability || "video"; @@ -58,6 +62,84 @@ const textVideoRequirements: ModelRequirements = { }; describe("model request pricing", () => { + test("keeps the panel quote and generation options aligned without selecting a price tier as quality", () => { + const config = systemConfig({ + capability: "image", + tiers: [ + { selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 }, + { selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 }, + { selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 4_000_000 }, + ], + }); + for (const size of ["auto", "1024x1024", "2048x2048"]) { + const node: CanvasNodeData = { id: "image-1", type: CanvasNodeType.Image, title: "Image", position: { x: 0, y: 0 }, width: 400, height: 400, metadata: { model: config.model, quality: "auto", size } }; + const requirements: ModelRequirements = { capability: "image", options: { quality: "auto", size } }; + const panel = buildNodeConfig(config, node, "image", requirements); + const generation = buildGenerationConfig(config, node, "image", requirements); + expect(modelRequestOptions(panel, "image")).toEqual(modelRequestOptions(generation, "image")); + expect(panel.quality).toBe("auto"); + } + }); + + test("uses image dimensions to match resolution pricing when quality is automatic", () => { + const config = systemConfig({ + capability: "image", + tiers: [ + { selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 }, + { selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 3_000_000 }, + { selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 5_000_000 }, + ], + }); + for (const [size, price] of [["1024x1024", 2], ["2048x2048", 3], ["2880x2880", 5]] as const) { + expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "image-model", capability: "image", config: { ...config, quality: "auto", size }, requirements: { capability: "image", options: { quality: "auto", size } }, count: 1 })).toBe(price); + } + }); + + test("matches each image resolution and generation operation for quotes and displayed costs", () => { + const qualities = ["1k", "2k", "4k"]; + const config = systemConfig({ + capability: "image", + logicalModelId: "logical-image", + tiers: ["text_to_image", "image_to_image"].flatMap((operation, operationIndex) => + qualities.map((quality, index) => ({ + selector: { operation, quality }, + billingMode: "fixed_request" as const, + unitPriceMicrocredits: (index + 1 + operationIndex * 3) * 1_000_000, + })), + ), + }); + const channel = resolveModelChannel(config, config.model); + for (const imageCount of [0, 1]) { + for (const [index, quality] of qualities.entries()) { + const requirements: ModelRequirements = { + capability: "image", + input: { textCount: 1, imageCount, characterCount: 0, videoCount: 0, audioCount: 0 }, + options: { quality: quality.toUpperCase(), size: "16:9" }, + }; + expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements, count: 1 })).toBe(index + 1 + imageCount * 3); + expect(modelQuoteRequest(config, config.model, "image", requirements)?.intent).toMatchObject({ + operation: imageCount ? "image_to_image" : "text_to_image", + inputs: { image: imageCount }, + options: { quality: quality.toUpperCase() }, + }); + } + } + const characterRequirements: ModelRequirements = { capability: "image", input: { textCount: 1, imageCount: 0, characterCount: 1, videoCount: 0, audioCount: 0 }, options: { quality: "2k" } }; + expect(modelQuoteRequest(config, config.model, "image", characterRequirements)?.intent).toMatchObject({ operation: "image_to_image", inputs: { image: 1 } }); + expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements: characterRequirements, count: 1 })).toBe(5); + expect(priceTiersForCurrentSelection(channel.modelCosts![0]!.logicalPriceTiers!, "image", { ...config, quality: "8k" })).toHaveLength(0); + }); + + test("prefers a matching image specification over the uniform fallback", () => { + const config = systemConfig({ capability: "image", tiers: [ + { selector: {}, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 }, + { selector: { operation: "text_to_image", quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 }, + ] }); + const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!; + expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "2k" })[0]?.unitPriceMicrocredits).toBe(2_000_000); + expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "4k" })[0]?.unitPriceMicrocredits).toBe(1_000_000); + }); + test("preserves provider-specific resolution enums when matching price tiers", () => { expect(normalizeTierResolution("768P竖")).toBe("768p竖"); expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");