chore(release): 版本发布 - 集成本地模型编辑与画布改动并发布 v1.2.6.rc3
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,23 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.2.6.rc3
|
||||
|
||||
- 发布 v1.2.6 第三个候选版本,补齐此前未提交的本地模型编辑、后台切页和画布网格改动;镜像标签为 `1.2.6.rc3`,不覆盖已发布的 rc2。
|
||||
- 系统渠道模型编辑改为分组弹窗,固定底部操作栏,增加未保存修改确认、跨页签校验定位与价格配置校验。
|
||||
- 前台模型和自定义渠道统一使用居中编辑界面;协议与供应线路采用可搜索的单选浏览器,保留当前选择的详情展示并适配窄屏。
|
||||
- 后台路由共用持久布局内的加载边界,移除切页入场透明度动画;线网格与点阵保持固定屏幕间距,减少画布缩放时的背景密度跳变。
|
||||
- 保留 rc2 的画布任务恢复、图片规格计价、任务租约及邮箱公告修复;实际保存、真实生成与部署升级仍需按待测试清单验收。
|
||||
|
||||
## v1.2.6.rc2
|
||||
|
||||
- 发布 v1.2.6 第二个候选版本,镜像标签为 `1.2.6.rc2`,GitHub Release 标记为预发布。
|
||||
- 修复画布刷新后的生成任务找回与节点关联,完善名称同步;图片规格积分按分辨率匹配,自动质量保持报价与生成参数一致。
|
||||
- 限制任务缓存回源,增加有界读缓存、并发租约续期及过期执行者写入保护,减少重复查询和任务状态竞争。
|
||||
- 新增注册邮箱域名白名单,允许仅标题公告并完善未读公告自动提醒、关闭与今日免打扰逻辑;修复历史数据库迁移冲突。
|
||||
- 改造剪辑素材面板,完善存量视频 codec 重判;渠道模型支持勾选导入与离线启停。
|
||||
- 本版本包含 PR #417、#419、#423 的集成修复;真实部署、邮件发送及生成链路仍需按待测试清单验收。
|
||||
|
||||
## v1.2.6.rc1
|
||||
|
||||
- 发布 v1.2.6 首个候选版本;发布流程将 `.rc` 标签识别为 GitHub 预发布,镜像标签使用 `1.2.6.rc1`。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
if context.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
return &TaskClientContext{
|
||||
DomainProjectID: metadata.DomainProjectID,
|
||||
ChapterID: metadata.ChapterID,
|
||||
ChapterOperation: chapterOperation,
|
||||
return context
|
||||
}
|
||||
context.DomainProjectID = metadata.DomainProjectID
|
||||
context.ChapterID = metadata.ChapterID
|
||||
context.ChapterOperation = chapterOperation
|
||||
return context
|
||||
}
|
||||
|
||||
// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -13,6 +13,12 @@ description: 热点回源保护、并发租约、故障边界和上线验收
|
||||
- 新增缓存是每个后端实例的有界内存读缓存,不是通用 Redis 业务缓存。实例增加时,缓存容量与不同实例的回源量会随之增加。
|
||||
- handler 负责 SSE 协议和登录上下文;service 负责读缓存、并发协调和执行生命周期;repository 负责有条件的租约写入与事务。
|
||||
|
||||
## 画布任务摘要与刷新恢复
|
||||
|
||||
任务列表的摘要通过 `clientContext.nodeId` 返回画布节点关联,不为恢复任务返回完整 `inputJson`、渠道配置或密钥。前端优先读取摘要中的节点 ID,任务详情仍可从输入元数据取得节点 ID;原有创作会话、章节和镜头工作流上下文继续保留。
|
||||
|
||||
已持久化任务 ID 的节点直接按 ID 恢复。缺少任务 ID 时,画布查询当前项目最近 100 条任务摘要并按节点 ID 匹配;这不是全量历史扫描,超过该范围的旧任务仍可能无法自动发现。任务读取继续遵循当前用户与项目的访问边界,不新增数据库字段或迁移。
|
||||
|
||||
## 热点读取保护
|
||||
|
||||
| 读路径 | 缓存 / 回源限制 | 故障行为 |
|
||||
|
||||
@@ -5,6 +5,22 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## v1.2.6.rc3 候选版本发布
|
||||
|
||||
- 本版本在 rc2 基础上纳入此前未提交的模型编辑弹窗、协议浏览器、后台切页与画布网格源码及相邻回归测试;版本为 `v1.2.6.rc3`,镜像标签为 `1.2.6.rc3`,不覆盖 rc2。
|
||||
- 默认前端测试命令已接入模型编辑表单、价格档、前台模型与画布网格回归;最终 `bun run test` 的 39 + 4 + 1120 + 1 项测试全部通过,前端生产构建通过,仍有既有包体积与混合静动态导入警告。
|
||||
- 隔离组件页使用明确的测试协议数据完成浅色桌面、390px 暗色窄屏、协议搜索选择与详情联动、长内容滚动和固定底栏验收;后台编辑器跨页签必填校验能定位基本信息,取消修改会确认且可继续编辑。校验期间无 XHR/fetch 请求及控制台错误,不代表真实路由或后端保存已验收。
|
||||
- 本轮相对 rc2 无后端源码变更,未重跑后端全量;rc2 记录的既有后端测试失败仍不视为解决。
|
||||
- [ ] 发布产物验收:前后端 Linux amd64/arm64 镜像、双架构更新器校验和、部署 Compose 附件以及镜像内版本和提交信息。
|
||||
- [ ] 部署后验收:实际模型新增/编辑保存、真实协议与计费组合、后台路由切换、画布缩放以及登录和任务生成。本次不部署、不写入真实模型配置、不执行收费生成。
|
||||
|
||||
## v1.2.6.rc2 候选版本发布
|
||||
|
||||
- 发布版本与镜像标签分别为 `v1.2.6.rc2` 和 `1.2.6.rc2`,GitHub Release 应标记为预发布。发布范围包含 PR #423 合并后的远程主分支,不包含本地未提交改动。
|
||||
- 发布前代码验证:前端完整测试与生产构建通过;后端相关专项通过,全量测试仍有 4 项在合并前基线同样失败,另一次素材清理测试出现 SQLite 表锁且独立复跑通过,不将后端全量标记为全绿。
|
||||
- [ ] 发布产物验收:前后端 Linux amd64/arm64 镜像、Release 双架构更新器、SHA256SUMS 与部署 Compose 附件,以及镜像内版本和提交信息。
|
||||
- [ ] 部署后验收:版本展示、登录、画布任务恢复、图片规格报价、真实邮件与生成流程;本次仅发布镜像和附件,不部署服务或修改线上数据库。
|
||||
|
||||
## 缓存回源与任务队列止血
|
||||
|
||||
- 已加入进程内有界读缓存:SSE 按用户、任务、游标合并回源;并发策略短缓存;路由版本短缓存和目录回源失败冷却。强校验的计费、配额、权限读取不改为缓存。
|
||||
@@ -81,6 +97,26 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
- [ ] 素材预览端到端验证受当前资源接口 500、本机 Agent 连接失败影响;不将界面验证视为媒体链路全部通过。
|
||||
- [ ] 真实素材端到端复核:局部重绘与表情生成、质感设置、音频提取保留原声、项目时间线定位;本次不自动触发收费模型任务。
|
||||
|
||||
## 验证码邮件
|
||||
|
||||
- [ ] 邮件配置保存自定义发件名称后,新发送的注册验证码邮件发件名称、标题和正文保持一致;密码重置验证码邮件同样使用该名称。
|
||||
- [ ] 发件名称留空或使用默认名称时,验证码邮件继续继承站点品牌名称;再次修改自定义发件名称后,新邮件使用最新名称。
|
||||
|
||||
## 画布任务恢复
|
||||
|
||||
- [ ] 缺少任务 ID 的节点,通过当前项目最近 100 条任务摘要的 `clientContext.nodeId` 恢复;完成后正常回填。超出发现范围的旧任务不保证自动恢复。
|
||||
- [x] 回归覆盖任务摘要节点关联、敏感输入不泄露,以及前端在没有完整输入时读取节点 ID。
|
||||
|
||||
## 图片规格价格
|
||||
|
||||
- [ ] 仅配置 1K、2K、4K 三档价格(无统一兜底),画布逐一切换分辨率,显示与对应档位一致的积分价格。
|
||||
- [ ] 分别配置文生图、图生图规格价格;添加、移除参考图或角色参考后,报价与实际生成命中对应档位。
|
||||
- [ ] 新建图片节点时,报价使用节点模型默认规格,而不是全局旧参数;刷新后再次核对。
|
||||
- [ ] 配置统一价格时仍正常兜底;未配置且没有兜底的规格不能被计为其他档位价格。
|
||||
- [x] 合并检查补修:缺少质量参数时按有效尺寸匹配档位;不把空参数转为 `"<nil>"`;面板保留实际生成的“自动”参数,不以价格表第一档替换请求规格。
|
||||
- [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)且输入不可变。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts",
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"build": "bun run build:bridge && tsc --noEmit && vite build",
|
||||
"pretest": "bun test test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts",
|
||||
"pretest": "bun test test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts test/channel-model-editor-form.test.ts test/channel-model-price-tier-form.test.ts test/admin-logical-models.test.ts && bun test test/canvas-grid-viewport.test.tsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts",
|
||||
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-pricing.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
|
||||
@@ -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<number | null>(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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string>; 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");
|
||||
|
||||
Reference in New Issue
Block a user