From f5bb9ed4f924dd8559284f4f19960a936c6881a3 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 15 Aug 2026 17:00:17 +0800 Subject: [PATCH 01/25] =?UTF-8?q?fix(database):=20=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=AD=98=E5=82=A8=20-=20=E6=89=A9=E5=AE=B9=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=20ID=20=E5=8F=8A=E5=85=B3=E8=81=94=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/schema.go | 48 ++++++++ .../internal/database/schema_postgres_test.go | 106 ++++++++++++++++++ backend/internal/database/schema_test.go | 62 ++++++++++ backend/internal/model/models_project.go | 10 +- 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 backend/internal/database/schema_postgres_test.go create mode 100644 backend/internal/database/schema_test.go diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index a81de6ea3..f64611278 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -1,6 +1,10 @@ package database import ( + "database/sql" + "errors" + "fmt" + "infinite-canvas/backend/internal/model" "gorm.io/gorm" @@ -71,6 +75,9 @@ func MigrateSchema(db *gorm.DB) error { return err } } + if err := widenPostgresAssetIDColumns(db); err != nil { + return err + } if err := db.AutoMigrate(Models()...); err != nil { return err } @@ -83,3 +90,44 @@ func MigrateSchema(db *gorm.DB) error { } return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error } + +type varcharColumnMigration struct { + table string + column string + statement string +} + +var assetIDColumnMigrations = []varcharColumnMigration{ + {table: "assets", column: "id", statement: `ALTER TABLE "assets" ALTER COLUMN "id" TYPE varchar(80)`}, + {table: "project_asset_links", column: "asset_id", statement: `ALTER TABLE "project_asset_links" ALTER COLUMN "asset_id" TYPE varchar(80)`}, + {table: "project_asset_candidates", column: "resolved_asset_id", statement: `ALTER TABLE "project_asset_candidates" ALTER COLUMN "resolved_asset_id" TYPE varchar(80)`}, + {table: "asset_versions", column: "asset_id", statement: `ALTER TABLE "asset_versions" ALTER COLUMN "asset_id" TYPE varchar(80)`}, +} + +// PostgreSQL Migrator 跳过主键列变更,素材 ID 扩容必须在 AutoMigrate 前显式执行。 +func widenPostgresAssetIDColumns(db *gorm.DB) error { + if db.Dialector.Name() != "postgres" { + return nil + } + for _, migration := range assetIDColumnMigrations { + var currentLength sql.NullInt64 + err := db.Raw( + "SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", + migration.table, + migration.column, + ).Row().Scan(¤tLength) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return fmt.Errorf("检查 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err) + } + if !currentLength.Valid || currentLength.Int64 >= model.AssetIDMaxLength { + continue + } + if err := db.Exec(migration.statement).Error; err != nil { + return fmt.Errorf("扩容 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err) + } + } + return nil +} diff --git a/backend/internal/database/schema_postgres_test.go b/backend/internal/database/schema_postgres_test.go new file mode 100644 index 000000000..388f354b2 --- /dev/null +++ b/backend/internal/database/schema_postgres_test.go @@ -0,0 +1,106 @@ +package database + +import ( + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestPostgresAssetIDMigration(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("CANVAS_TEST_POSTGRES_DSN")) + if dsn == "" { + t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured") + } + + base, err := Open(Config{Driver: "postgres", DSN: dsn}) + if err != nil { + t.Fatalf("open postgres: %v", err) + } + baseSQL, err := base.DB() + if err != nil { + t.Fatalf("postgres sql db: %v", err) + } + defer baseSQL.Close() + + schemaName := fmt.Sprintf("asset_id_migration_%d", time.Now().UnixNano()) + if err := base.Exec(`CREATE SCHEMA "` + schemaName + `"`).Error; err != nil { + t.Fatalf("create test schema: %v", err) + } + defer func() { + if err := base.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`).Error; err != nil { + t.Errorf("drop test schema: %v", err) + } + }() + + testDSN, err := postgresDSNWithSearchPath(dsn, schemaName) + if err != nil { + t.Fatalf("test postgres dsn: %v", err) + } + db, err := Open(Config{Driver: "postgres", DSN: testDSN}) + if err != nil { + t.Fatalf("open test schema: %v", err) + } + dbSQL, err := db.DB() + if err != nil { + t.Fatalf("test schema sql db: %v", err) + } + defer dbSQL.Close() + + for _, statement := range []string{ + `CREATE TABLE assets (id varchar(36) PRIMARY KEY)`, + `CREATE TABLE project_asset_links (id varchar(36) PRIMARY KEY, asset_id varchar(36))`, + `CREATE TABLE project_asset_candidates (id varchar(36) PRIMARY KEY, resolved_asset_id varchar(36))`, + `CREATE TABLE asset_versions (id varchar(36) PRIMARY KEY, asset_id varchar(36))`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("create legacy table: %v", err) + } + } + if err := MigrateSchema(db); err != nil { + t.Fatalf("migrate schema: %v", err) + } + + for _, migration := range assetIDColumnMigrations { + var length int64 + if err := db.Raw( + "SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", + migration.table, + migration.column, + ).Scan(&length).Error; err != nil { + t.Fatalf("read migrated column %s.%s: %v", migration.table, migration.column, err) + } + if length != model.AssetIDMaxLength { + t.Fatalf("%s.%s length = %d, want %d", migration.table, migration.column, length, model.AssetIDMaxLength) + } + } + + assetID := "generation_" + strings.Repeat("a", 64) + if err := db.Create(&model.Asset{ID: assetID, UserID: "user-1", Kind: "image"}).Error; err != nil { + t.Fatalf("insert deterministic generation asset: %v", err) + } + if err := db.Create(&model.ProjectAssetLink{ID: "link-1", ProjectID: "project-1", AssetID: assetID}).Error; err != nil { + t.Fatalf("insert project asset link: %v", err) + } + if err := db.Create(&model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: "project-1", ResolvedAssetID: assetID}).Error; err != nil { + t.Fatalf("insert project asset candidate: %v", err) + } + if err := db.Create(&model.AssetVersion{ID: "version-1", AssetID: assetID, Version: 1}).Error; err != nil { + t.Fatalf("insert asset version: %v", err) + } +} + +func postgresDSNWithSearchPath(dsn string, schemaName string) (string, error) { + parsed, err := url.Parse(dsn) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("search_path", schemaName) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} diff --git a/backend/internal/database/schema_test.go b/backend/internal/database/schema_test.go new file mode 100644 index 000000000..8945608b3 --- /dev/null +++ b/backend/internal/database/schema_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "fmt" + "strings" + "sync" + "testing" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm/schema" +) + +func TestAssetIDColumnsUseSharedLimit(t *testing.T) { + tests := []struct { + value any + field string + }{ + {value: &model.Asset{}, field: "ID"}, + {value: &model.ProjectAssetLink{}, field: "AssetID"}, + {value: &model.ProjectAssetCandidate{}, field: "ResolvedAssetID"}, + {value: &model.AssetVersion{}, field: "AssetID"}, + } + + for _, test := range tests { + parsed, err := schema.Parse(test.value, &sync.Map{}, schema.NamingStrategy{}) + if err != nil { + t.Fatalf("parse schema: %v", err) + } + field := parsed.LookUpField(test.field) + if field == nil { + t.Fatalf("field %s not found in %s", test.field, parsed.Table) + } + if field.Size != model.AssetIDMaxLength { + t.Fatalf("%s.%s size = %d, want %d", parsed.Table, field.DBName, field.Size, model.AssetIDMaxLength) + } + } +} + +func TestPostgresAssetIDMigrationsCoverEveryAssetIDColumn(t *testing.T) { + want := map[string]bool{ + "assets.id": false, + "project_asset_links.asset_id": false, + "project_asset_candidates.resolved_asset_id": false, + "asset_versions.asset_id": false, + } + for _, migration := range assetIDColumnMigrations { + key := migration.table + "." + migration.column + if _, exists := want[key]; !exists { + t.Fatalf("unexpected asset ID migration %s", key) + } + if !strings.Contains(migration.statement, fmt.Sprintf("varchar(%d)", model.AssetIDMaxLength)) { + t.Fatalf("asset ID migration %s does not use limit %d", key, model.AssetIDMaxLength) + } + want[key] = true + } + for key, covered := range want { + if !covered { + t.Fatalf("missing asset ID migration %s", key) + } + } +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index d2c2bce90..91b8170c6 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -2,6 +2,8 @@ package model import "time" +const AssetIDMaxLength = 80 + type Resource struct { ID string `json:"id" gorm:"primaryKey;size:36"` UserID string `json:"userId" gorm:"index;size:36;index:idx_resources_user_created,priority:1"` @@ -26,7 +28,7 @@ type Resource struct { } type Asset struct { - ID string `json:"id" gorm:"primaryKey;size:36"` + ID string `json:"id" gorm:"primaryKey;size:80"` UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` Kind string `json:"kind" gorm:"index;size:24"` Category AssetCategory `json:"category" gorm:"index;size:32"` @@ -41,7 +43,7 @@ type Asset struct { type ProjectAssetLink struct { ID string `json:"id" gorm:"primaryKey;size:36"` ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` + AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_project_asset_links_unique,priority:2"` CreatedAt time.Time `json:"createdAt"` } @@ -54,14 +56,14 @@ type ProjectAssetCandidate struct { Category AssetCategory `json:"category" gorm:"index;size:32"` Status string `json:"status" gorm:"index;size:32"` DetailsJSON string `json:"detailsJson" gorm:"type:text"` - ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"` + ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:80"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type AssetVersion struct { ID string `json:"id" gorm:"primaryKey;size:36"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"` + AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_asset_versions_number,priority:1"` Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"` Status AssetVersionStatus `json:"status" gorm:"index;size:24"` DefinitionJSON string `json:"definitionJson" gorm:"type:text"` From fdb31832e5167251c82aa6fca541f29f6f3831fb Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 15 Aug 2026 17:00:30 +0800 Subject: [PATCH 02/25] =?UTF-8?q?fix(api):=20=E7=B4=A0=E6=9D=90=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20-=20=E5=A2=9E=E5=8A=A0=20ID=20=E9=95=BF=E5=BA=A6?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/user_data.go | 10 ++++- backend/internal/service/user_data_test.go | 46 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 17b22facd..3b886be48 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "time" + "unicode/utf8" "infinite-canvas/backend/internal/model" @@ -295,6 +296,13 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { if id == "" { id = newID() } + if utf8.RuneCountInString(id) > model.AssetIDMaxLength { + return model.Asset{}, BadAuthRequest("素材 ID 不能超过 80 个字符") + } + primaryVersionID := strings.TrimSpace(payload.PrimaryVersionID) + if utf8.RuneCountInString(primaryVersionID) > 36 { + return model.Asset{}, BadAuthRequest("素材主版本 ID 不能超过 36 个字符") + } category := model.AssetCategory(strings.TrimSpace(payload.Category)) if category == "" { category = model.AssetCategoryOther @@ -309,7 +317,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { Kind: strings.TrimSpace(payload.Kind), Category: category, Status: status, - PrimaryVersionID: strings.TrimSpace(payload.PrimaryVersionID), + PrimaryVersionID: primaryVersionID, Title: strings.TrimSpace(payload.Title), PayloadJSON: string(raw), CreatedAt: createdAt, diff --git a/backend/internal/service/user_data_test.go b/backend/internal/service/user_data_test.go index 95cd1d6ac..76445545c 100644 --- a/backend/internal/service/user_data_test.go +++ b/backend/internal/service/user_data_test.go @@ -2,9 +2,55 @@ package service import ( "encoding/json" + "strings" "testing" + + "infinite-canvas/backend/internal/model" ) +func TestAssetFromJSONAcceptsDeterministicGenerationID(t *testing.T) { + id := "generation_" + strings.Repeat("a", 64) + raw, err := json.Marshal(map[string]any{"id": id, "kind": "image", "title": "生成图片"}) + if err != nil { + t.Fatalf("marshal asset: %v", err) + } + + asset, err := assetFromJSON("user-1", raw) + if err != nil { + t.Fatalf("assetFromJSON: %v", err) + } + if asset.ID != id { + t.Fatalf("asset ID = %q, want %q", asset.ID, id) + } + if len([]rune(asset.ID)) > model.AssetIDMaxLength { + t.Fatalf("generation asset ID length = %d, limit %d", len([]rune(asset.ID)), model.AssetIDMaxLength) + } +} + +func TestAssetFromJSONRejectsIDOverLimit(t *testing.T) { + raw, err := json.Marshal(map[string]any{"id": strings.Repeat("a", model.AssetIDMaxLength+1), "kind": "image"}) + if err != nil { + t.Fatalf("marshal asset: %v", err) + } + + _, err = assetFromJSON("user-1", raw) + if err == nil || !strings.Contains(err.Error(), "素材 ID 不能超过 80 个字符") { + t.Fatalf("assetFromJSON error = %v", err) + } +} + +func TestAssetFromJSONRejectsPrimaryVersionIDOverLimit(t *testing.T) { + raw, err := json.Marshal(map[string]any{"id": "asset-1", "primaryVersionId": strings.Repeat("v", 37)}) + if err != nil { + t.Fatalf("marshal asset: %v", err) + } + + _, err = assetFromJSON("user-1", raw) + if err == nil || !strings.Contains(err.Error(), "素材主版本 ID 不能超过 36 个字符") { + t.Fatalf("assetFromJSON error = %v", err) + } +} + func TestValidateSyncedPayloadAllowsDataURLMentionInErrorMessage(t *testing.T) { raw, err := json.Marshal(map[string]interface{}{ "nodes": []interface{}{ From 34a11a356cdf05fa39a3d94bc007a13798ecd566 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 15 Aug 2026 17:04:37 +0800 Subject: [PATCH 03/25] =?UTF-8?q?docs(platform):=20=E5=9B=A2=E9=98=9F?= =?UTF-8?q?=E6=88=90=E5=91=98=20-=20=E6=96=B0=E5=A2=9E=20dyh=20=E4=B8=8E?= =?UTF-8?q?=20kyori=20=E8=B4=A1=E7=8C=AE=E8=80=85=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ assets/user-dyh.jpg | Bin 0 -> 111933 bytes assets/user-kyori.jpg | Bin 0 -> 69081 bytes 3 files changed, 2 insertions(+) create mode 100644 assets/user-dyh.jpg create mode 100644 assets/user-kyori.jpg diff --git a/README.md b/README.md index b8c2fb8fe..09649be8c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ | delve-s | delve-s | [3013141136@qq.com](mailto:3013141136@qq.com) | 我亦无他,惟手熟尔 | | CyrusAuyeung | CyrusAuyeung | [cyrusauyeungho@gmail.com](mailto:cyrusauyeungho@gmail.com) | HKUST(GZ) UG | | 奶大佬 | 奶大佬 | [1304634970@qq.com](mailto:1304634970@qq.com) | 人生就是要不断的探索 | +| dyh | dyh | [1613203335@qq.com](mailto:1613203335@qq.com) | 无 | +| kyori | kyori | [1771634408@qq.com](mailto:1771634408@qq.com) | 励志成为未来最好用的画布仓库的贡献者 | ## 主要功能 diff --git a/assets/user-dyh.jpg b/assets/user-dyh.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6b8267779206748361ca6da3d5ab7ca091e3dc72 GIT binary patch literal 111933 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 OMqk1E`>*=nlm7$3Yqa+O literal 0 HcmV?d00001 diff --git a/assets/user-kyori.jpg b/assets/user-kyori.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cb89f87c8c5751b5c1e5b66d397d4b0c229b95e0 GIT binary patch literal 69081 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 R;bYC-Se2FEZ!$Rk{|DXkgChU{ literal 0 HcmV?d00001 From 5fc3483d9075d37fa5831d0080436e62ea75ed08 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 15 Aug 2026 17:05:57 +0800 Subject: [PATCH 04/25] =?UTF-8?q?chore(release):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=91=E5=B8=83=20-=20publish=20v1.0.59?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6379c23..b9acda36a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## v1.0.59 + +- 画布媒体工作流支持批量连线、失败重试与模型能力增强。 +- 修复 PostgreSQL 素材同步无法保存稳定生成 ID 的问题,素材主键及关联字段扩容至 80 个字符,并增加输入边界校验。 +- 项目贡献者列表新增 dyh 与 kyori 资料。 + ## v1.0.58 - 管理后台“功能开放”新增自定义渠道开关,关闭后保留用户已有配置,但隐藏配置入口、过滤自定义模型,并拦截模型目录拉取、渠道中转与新的异步生成任务。 diff --git a/VERSION b/VERSION index ab18d35a6..1e440c0b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.58 +v1.0.59 From 11d9d469f5c2c1808bf9a03f9f398a2df7adba72 Mon Sep 17 00:00:00 2001 From: echoD886 Date: Sat, 15 Aug 2026 21:23:26 +0800 Subject: [PATCH 05/25] =?UTF-8?q?fix(canvas):=20=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=20-=20=E5=8F=82=E8=80=83=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=A7=BD=E4=BD=8D=E9=81=B5=E5=BE=AA=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E5=B9=B6=E4=BF=9D=E7=95=99=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E8=A7=86=E8=A7=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留当前项目样式,仅吸收参考内容主槽位展示、模型上限收敛和对应行为测试。 Refs #232 --- web/package.json | 2 +- web/src/pages/create/creation-references.ts | 14 +++++ web/src/pages/create/index.tsx | 60 +++++++++++++++------ web/src/styles/globals.css | 9 ++++ web/test/create-library-button.test.ts | 9 ++++ web/test/creation-references.test.ts | 39 ++++++++++++++ 6 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 web/test/creation-references.test.ts diff --git a/web/package.json b/web/package.json index d0cf7ce4c..68de65b85 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.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-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.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/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/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/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/agent-prompt-cache.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-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.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/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/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/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index e9954b356..6b6e73860 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -20,6 +20,20 @@ export function selectedCreationReferences(prompt: string, references: CreationR return references.filter((reference) => prompt.includes(canvasResourceMentionToken(reference))); } +export function reconcileCreationAttachmentLimit(attachments: CreationAttachment[], references: CreationReference[], maxReferences: number) { + const limit = Math.max(0, Math.floor(maxReferences)); + if (attachments.length <= limit) return { attachments, removedReferences: [] as CreationReference[] }; + + const nextAttachments = attachments.slice(0, limit); + const removedAttachmentIds = new Set(attachments.slice(limit).map((attachment) => attachment.id)); + const removedReferences = references.filter((reference) => reference.attachmentId && removedAttachmentIds.has(reference.attachmentId)); + return { attachments: nextAttachments, removedReferences }; +} + +export function removeCreationReferenceTokens(value: string, references: CreationReference[]) { + return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); +} + export function displayCreationPrompt(prompt: string, references: CreationReference[]) { return references.reduce((value, reference) => value.split(canvasResourceMentionToken(reference)).join(`@${reference.label}`), prompt); } diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index d0d53f374..2f8924d61 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -8,7 +8,6 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { ModelPicker } from "@/components/model-picker"; -import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references"; import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; @@ -30,7 +29,7 @@ import { beginGenerationConsumer, runGenerationConsumer } from "@/services/gener import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; -import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, selectedCreationReferences, type CreationReference } from "./creation-references"; +import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references"; import { creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; @@ -171,6 +170,7 @@ export default function CreatePage() { const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); + const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]); @@ -184,7 +184,6 @@ export default function CreatePage() { setRatio(normalized.size); setQuality(normalized.quality); setCount(normalized.count); - if (attachments.length > imageProfile.references.maxImages) setAttachments((current) => current.slice(0, imageProfile.references.maxImages)); }, [mode, selectedModel, imageProfile]); useEffect(() => { @@ -195,6 +194,13 @@ export default function CreatePage() { setVideoQuality(normalized.resolution.replace(/p$/i, "")); }, [mode, selectedModel, videoProfile]); + useEffect(() => { + const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); + if (reconciled.attachments === attachments) return; + setAttachments(reconciled.attachments); + if (reconciled.removedReferences.length) setPrompt((current) => removeCreationReferenceTokens(current, reconciled.removedReferences)); + }, [attachments, maxReferences, mentionReferences]); + useEffect(() => { let cancelled = false; void loadCreationConversations().then((stored) => { @@ -304,7 +310,6 @@ export default function CreatePage() { } }; - const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; const libraryItems = useMemo(() => assets .filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video") .map((asset) => ({ @@ -384,7 +389,7 @@ export default function CreatePage() { const removeAttachment = (id: string) => { const reference = mentionReferences.find((item) => item.attachmentId === id); setAttachments((current) => current.filter((item) => item.id !== id)); - if (reference) setPrompt((current) => removeReferenceTokens(current, [reference])); + if (reference) setPrompt((current) => removeCreationReferenceTokens(current, [reference])); }; const submit = async (retryContext?: CreationRetryContext, retryLockKey?: string) => { @@ -406,6 +411,11 @@ export default function CreatePage() { releaseRetryLock(); return; } + if (attachments.length > maxReferences) { + toast.warning("参考内容正在按当前模型能力调整,请稍后重试"); + releaseRetryLock(); + return; + } const settings = { ratio, seconds, quality, videoQuality, count }; const references = selectedCreationReferences(text, mentionReferences); // 后端对图片和视频使用不同的参考字段;这里先拆分,避免媒体类型在写入任务时被误判。 @@ -668,6 +678,7 @@ export default function CreatePage() { setPrompt, busy, attachments, + maxReferences, references: mentionReferences, onRemoveAttachment: removeAttachment, onOpenLibrary: () => setLibraryOpen(true), @@ -820,6 +831,26 @@ function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: return {url ? type === "video" ? ; } +function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false, onPreview, onRemove, onAdd }: { + item: CreationAttachment; + primary?: boolean; + canAddMore?: boolean; + onPreview: (type: "image" | "video", url: string) => void; + onRemove: (id: string) => void; + onAdd?: () => void; +}) { + const isVideo = isVideoAttachment(item); + const url = isVideo ? item.url : item.previewUrl; + return
+ + + {primary && canAddMore && onAdd ? : null} +
; +} + type ComposerProps = { variant: "empty" | "thread"; mode: CreationMode; @@ -827,6 +858,7 @@ type ComposerProps = { setPrompt: (value: string) => void; busy: boolean; attachments: CreationAttachment[]; + maxReferences: number; references: CreationReference[]; onRemoveAttachment: (id: string) => void; onOpenLibrary: () => void; @@ -867,18 +899,20 @@ function CreationComposer(props: ComposerProps) { const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; const imageReferencesSupported = props.imageProfile.references.maxImages > 0; const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video"); + const [primaryAttachment, ...secondaryAttachments] = props.attachments; + const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1; + const previewAttachment = (type: "image" | "video", url: string) => { + setPreviewType(type); + setPreviewUrl(url); + }; return
- + {primaryAttachment ? : }
- {props.attachments.length ?
{props.attachments.map((item) => { - const isVideo = isVideoAttachment(item); - const url = isVideo ? item.url : item.previewUrl; - return
; - })}
: null} + {secondaryAttachments.length ?
{secondaryAttachments.map((item) => )}
: null}
@@ -1203,10 +1237,6 @@ function isImageAttachment(attachment: CreationAttachment): attachment is Creati return !isVideoAttachment(attachment); } -function removeReferenceTokens(value: string, references: CreationReference[]) { - return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); -} - type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string }; function attachCreationTaskContexts(tasks: GenerationTask[], conversations: CreationConversation[]) { diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 5aa346842..3b030d20f 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -7335,6 +7335,15 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage .creation-chat-reference.is-paper:hover:not(:disabled) { background: #2b2e37; color: #f1f3f5; transform: rotate(-4deg) translateY(-2px); } .creation-chat-reference.is-paper:active:not(:disabled) { transform: rotate(-4deg) translateY(0) scale(.97); } .creation-chat-reference.is-paper:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 3px; } +.creation-chat-reference-media { position: relative; overflow: hidden; padding: 0; } +.creation-chat-reference-media .creation-chat-attachment-preview > span { display: grid; } +.creation-chat-reference-media:hover .creation-chat-attachment-remove, +.creation-chat-reference-media .creation-chat-attachment-remove:focus-visible { opacity: 1; } +.creation-chat-reference-media button svg { width: var(--space-3); height: var(--space-3); } +.creation-chat-reference-add { position: absolute; bottom: var(--space-1); left: var(--space-1); display: grid; width: var(--space-5); height: var(--space-5); place-items: center; border: var(--stroke-1) solid color-mix(in srgb, var(--color-neutral-0) 18%, transparent); border-radius: var(--r-xs); background: color-mix(in srgb, var(--color-neutral-950) 76%, transparent); color: var(--color-neutral-0); transition: background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out); } +.creation-chat-reference-add:hover { background: color-mix(in srgb, var(--color-neutral-950) 90%, transparent); transform: translateY(-1px); } +.creation-chat-reference-add:active { transform: scale(.96); } +.creation-chat-reference-add:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: var(--space-half); } .creation-chat-composer .creation-chat-writing-surface { grid-template-columns: 62px minmax(0, 1fr); gap: 16px; } .creation-custom-value { diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts index 90baddac2..480e9052f 100644 --- a/web/test/create-library-button.test.ts +++ b/web/test/create-library-button.test.ts @@ -45,4 +45,13 @@ describe("creation library button", () => { expect(canvasSource).toContain(" onInsert(reference)}"); }); + + test("promotes the first reference into the primary reference slot", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); + + expect(source).toContain("const [primaryAttachment, ...secondaryAttachments] = props.attachments"); + expect(source).toContain(" { + test("removes attachments and prompt tokens beyond the current model limit", () => { + const attachments = [imageAttachment("first"), imageAttachment("second"), imageAttachment("third")]; + const references = buildCreationMentionReferences([], attachments); + const result = reconcileCreationAttachmentLimit(attachments, references, 1); + const prompt = references.map(canvasResourceMentionToken).join(" "); + const nextPrompt = removeCreationReferenceTokens(prompt, result.removedReferences); + + expect(result.attachments).toEqual([attachments[0]]); + expect(result.removedReferences.map((reference) => reference.attachmentId)).toEqual(["second", "third"]); + expect(nextPrompt).toContain(canvasResourceMentionToken(references[0])); + expect(nextPrompt).not.toContain(canvasResourceMentionToken(references[1])); + expect(nextPrompt).not.toContain(canvasResourceMentionToken(references[2])); + }); + + test("returns the original attachment list when it is already within the limit", () => { + const attachments = [imageAttachment("first")]; + const result = reconcileCreationAttachmentLimit(attachments, buildCreationMentionReferences([], attachments), 1); + + expect(result.attachments).toBe(attachments); + expect(result.removedReferences).toEqual([]); + }); +}); From 7804574f9fec47131655af6942fde43ff77d891c Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 15 Aug 2026 22:01:42 +0800 Subject: [PATCH 06/25] =?UTF-8?q?fix(canvas):=20=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=20-=20=E6=81=A2=E5=A4=8D=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E5=AF=B9=E8=AF=9D=E5=B9=B6=E5=AE=8C=E5=96=84=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E3=80=81=E5=AA=92=E4=BD=93=E5=8D=A0=E4=BD=8D=E4=B8=8E?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=88=86=E8=BE=A8=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #231 Fixes #233 --- backend/internal/service/model_capability.go | 6 +- .../internal/service/model_capability_test.go | 20 + backend/internal/service/provider.go | 12 +- backend/internal/service/provider_test.go | 16 + .../service/provider_video_options.go | 5 + web/src/lib/model-capabilities.ts | 8 +- web/src/lib/video-generation-options.ts | 11 +- web/src/pages/create/creation-assets.ts | 52 ++- web/src/pages/create/creation-references.ts | 6 +- web/src/pages/create/index.tsx | 273 +++++++++++-- web/src/services/api/image.ts | 44 ++- web/src/services/api/video.ts | 1 + web/src/styles/globals.css | 364 ++++++++++++++++-- web/test/creation-references.test.ts | 26 +- web/test/video-generation-options.test.ts | 24 ++ 15 files changed, 753 insertions(+), 115 deletions(-) create mode 100644 web/test/video-generation-options.test.ts diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 6488dcd0a..7bf4ff3d6 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -158,7 +158,7 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, DefaultRatio: "16:9", - Resolutions: []string{"480p", "720p", "1080p", "2160p"}, + Resolutions: []string{"480p", "720p", "1080p", "1440p", "2160p"}, DefaultResolution: "720p", GenerateAudio: VideoBooleanConfig{Supported: false, Default: false}, Watermark: VideoBooleanConfig{Supported: false, Default: false}, @@ -178,11 +178,15 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} video.Watermark = VideoBooleanConfig{Supported: true, Default: false} + video.Resolutions = []string{"480p", "720p", "1080p"} case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2: video.References.MaxVideos, video.References.MaxAudios = 3, 3 video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} + if model.ChannelInterfaceType(protocol) == model.ChannelInterfaceNewAPIChannel1 { + video.Resolutions = []string{"480p", "720p", "1080p"} + } case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} case model.ChannelInterfaceNovitaVideo: diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 61b2983ef..296620569 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -62,3 +62,23 @@ func TestValidateImageTaskEnforcesGPTImage2CustomSizeLimits(t *testing.T) { }) } } + +func TestDefaultVideoCapabilityUsesProtocolSpecificResolutionTiers(t *testing.T) { + tests := map[string][]string{ + "newapi-channel-2": {"480p", "720p", "1080p", "1440p", "2160p"}, + "volcengine-ark-video": {"480p", "720p", "1080p"}, + "volcengine-jimeng-video": {"720p"}, + "gemini-veo": {"720p", "1080p"}, + } + for protocol, want := range tests { + t.Run(protocol, func(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(protocol, "") + if profile == nil || profile.Video == nil { + t.Fatalf("DefaultModelCapabilityConfigForModel(%q) video profile = nil", protocol) + } + if fmt.Sprint(profile.Video.Resolutions) != fmt.Sprint(want) { + t.Fatalf("resolutions = %v, want %v", profile.Video.Resolutions, want) + } + }) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 152743be2..f6b71360f 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -2025,16 +2025,22 @@ func normalizeNewAPIChannel2Resolution(value string, modelName string) string { if modelName == "grok-video-1.5-1080p" { return "1080p" } - switch strings.ToLower(strings.TrimSpace(value)) { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { case "480", "480p", "low": return "480p" case "1080", "1080p": return "1080p" + case "1440", "1440p", "2k": + return "1440p" case "2160", "2160p", "4k": return "2160p" - default: - return "720p" } + numeric := strings.TrimSuffix(normalized, "p") + if resolution, err := strconv.Atoi(numeric); err == nil && resolution > 0 { + return strconv.Itoa(resolution) + "p" + } + return "720p" } func runNewAPIChannel1VideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 6a54587bc..0ff88ef91 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -116,6 +116,22 @@ func TestProviderHTTPErrorWarnsAboutUncertain524Billing(t *testing.T) { } } +func TestNormalizeNewAPIChannel2ResolutionPreservesDeclaredTiers(t *testing.T) { + tests := map[string]string{ + "1440": "1440p", + "2k": "1440p", + "4K": "2160p", + "768p": "768p", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + if got := normalizeNewAPIChannel2Resolution(input, "custom-video-model"); got != want { + t.Fatalf("normalizeNewAPIChannel2Resolution(%q) = %q, want %q", input, got, want) + } + }) + } +} + func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) { body, err := volcengineArkImageBody(canvasGenerationInput{ Prompt: "combine the references", diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go index b7cf41c14..f19b7e5b0 100644 --- a/backend/internal/service/provider_video_options.go +++ b/backend/internal/service/provider_video_options.go @@ -160,6 +160,9 @@ func normalizeVideoResolution(value string) string { if strings.EqualFold(value, "4k") { return "2160p" } + if strings.EqualFold(value, "2k") { + return "1440p" + } if strings.HasSuffix(value, "p") { return value } @@ -180,6 +183,8 @@ func normalizeXAIVideoResolution(value string) string { return "480p" case "1080", "1080p": return "1080p" + case "1440", "1440p", "2k": + return "1440p" case "2160", "2160p", "4k": return "2160p" default: diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 2e3b6a581..0f5a8390e 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -145,14 +145,17 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = " duration: { selection: "range", min: 1, max: 15, step: 1, default: 6 }, ratios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"], defaultRatio: "16:9", - resolutions: ["480p", "720p", "1080p", "2160p"], + resolutions: ["480p", "720p", "1080p", "1440p", "2160p"], defaultResolution: "720p", generateAudio: { supported: false, default: false }, watermark: { supported: false, default: false }, operations: ["text_to_video", "image_to_video"], defaultOperation: "text_to_video", }; - if (protocol === "volcengine-jimeng-video") video.duration = { selection: "enum", values: [5, 10], default: 5 }; + if (protocol === "volcengine-jimeng-video") { + video.duration = { selection: "enum", values: [5, 10], default: 5 }; + video.resolutions = ["720p"]; + } if (protocol === "gemini-veo") { video.duration = { selection: "enum", values: [4, 6, 8], default: 6 }; video.resolutions = ["720p", "1080p"]; @@ -166,6 +169,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = " video.references.maxAudioDurationSeconds = 15; video.generateAudio = { supported: true, default: true }; } + if (protocol === "volcengine-ark-video" || protocol === "newapi-channel-1") video.resolutions = ["480p", "720p", "1080p"]; if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false }; if (protocol === "novita-video") { video.references.maxImages = 1; diff --git a/web/src/lib/video-generation-options.ts b/web/src/lib/video-generation-options.ts index 1d76d64f6..1c83f8622 100644 --- a/web/src/lib/video-generation-options.ts +++ b/web/src/lib/video-generation-options.ts @@ -1,5 +1,5 @@ export const VIDEO_DURATION_OPTIONS = [6, 9, 10, 15] as const; -export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 2160] as const; +export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 1440, 2160] as const; export const VIDEO_DURATION_MIN = 1; export function normalizeVideoDuration(value: string | number | undefined) { @@ -11,11 +11,8 @@ export function normalizeVideoResolution(value: string | number | undefined) { const token = String(value || "").trim().toLowerCase(); if (token === "low") return "480"; if (token === "auto" || token === "medium" || token === "high") return "720"; + if (token === "2k") return "1440"; if (token === "4k") return "2160"; - const resolution = Number(token.replace(/p$/i, "")) || 720; - return String(nearestOption(resolution, VIDEO_RESOLUTION_OPTIONS)); -} - -function nearestOption(value: number, options: readonly number[]) { - return options.reduce((nearest, option) => Math.abs(option - value) < Math.abs(nearest - value) ? option : nearest, options[0]); + const resolution = Number(token.replace(/p$/i, "")); + return Number.isFinite(resolution) && resolution > 0 ? String(Math.floor(resolution)) : "720"; } diff --git a/web/src/pages/create/creation-assets.ts b/web/src/pages/create/creation-assets.ts index 925ce3a2a..ccab33a50 100644 --- a/web/src/pages/create/creation-assets.ts +++ b/web/src/pages/create/creation-assets.ts @@ -4,9 +4,20 @@ import type { Asset, AudioAsset, ImageAsset, NewAsset } from "@/stores/use-asset import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; -export type CreationAttachment = (ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string }; +export type CreationDocumentAttachment = { + id: string; + name: string; + type: string; + url: string; + storageKey: string; + bytes: number; + previewUrl: string; +}; +export type CreationAttachment = ((ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string }) | CreationDocumentAttachment; export type CreationMode = "text" | "image" | "video"; -export type CreationAttachmentKind = "image" | "video" | "audio"; +export type CreationAttachmentKind = "image" | "video" | "audio" | "file"; + +const textDocumentExtensions = [".pdf", ".txt", ".md", ".csv", ".json", ".html", ".xml", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"]; export type CreationAssetIdentity = { taskId?: string; @@ -15,17 +26,24 @@ export type CreationAssetIdentity = { }; export function creationUploadAccept(mode: CreationMode) { - return mode === "video" ? "image/*,video/*,audio/*" : "image/*"; + if (mode === "video") return "image/*,video/*,audio/*"; + if (mode === "text") return `image/*,video/*,audio/*,${textDocumentExtensions.join(",")}`; + return "image/*"; } -export function creationFileAccepted(mode: CreationMode, file: Pick) { - return file.type.startsWith("image/") || (mode === "video" && (file.type.startsWith("video/") || file.type.startsWith("audio/"))); +export function creationFileAccepted(mode: CreationMode, file: Pick) { + if (file.type.startsWith("image/")) return true; + if (mode === "video") return file.type.startsWith("video/") || file.type.startsWith("audio/"); + if (mode !== "text") return false; + const name = file.name.toLowerCase(); + return file.type.startsWith("video/") || file.type.startsWith("audio/") || file.type.startsWith("text/") || textDocumentExtensions.some((extension) => name.endsWith(extension)); } export function creationAttachmentKind(attachment: Pick): CreationAttachmentKind { if (attachment.type.startsWith("video/")) return "video"; if (attachment.type.startsWith("audio/")) return "audio"; - return "image"; + if (attachment.type.startsWith("image/")) return "image"; + return "file"; } export function splitCreationAttachments(attachments: CreationAttachment[]) { @@ -42,6 +60,16 @@ export function creationAttachmentPreview(attachment: CreationAttachment): { kin return { kind, url }; } +export function creationMediaAspectRatio(value: string | undefined, mode: CreationMode) { + const fallback = mode === "video" ? "16 / 9" : "1 / 1"; + const match = value?.trim().match(/^(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)$/i); + if (!match) return fallback; + const width = Number(match[1]); + const height = Number(match[2]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return fallback; + return `${width} / ${height}`; +} + export function removeCreationAttachment(attachments: T[], id: string) { return attachments.filter((attachment) => attachment.id !== id); } @@ -108,6 +136,18 @@ export function creationAttachmentFromAudio(file: File, uploaded: UploadedFile): }; } +export function creationAttachmentFromDocument(file: File, uploaded: UploadedFile): CreationAttachment { + return { + id: `upload:${file.name}:${uploaded.storageKey}`, + name: file.name, + type: uploaded.mimeType || file.type || "application/octet-stream", + url: uploaded.url, + storageKey: uploaded.storageKey, + bytes: uploaded.bytes, + previewUrl: "", + }; +} + export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachment { const url = asset.data.dataUrl || asset.coverUrl; return { diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index 6b6e73860..a7e8aa506 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -52,7 +52,7 @@ export function expandCreationPrompt(prompt: string, references: CreationReferen } if (reference.attachmentId) { const position = attachmentPositions.get(reference.attachmentId); - const kindLabel = reference.kind === "video" ? "视频" : reference.kind === "audio" ? "音频" : "图片"; + const kindLabel = reference.kind === "video" ? "视频" : reference.kind === "audio" ? "音频" : reference.kind === "text" ? "文件" : "图片"; mediaMappings.push(`- @${reference.label}:参考${kindLabel} ${position || 1}`); return; } @@ -70,11 +70,11 @@ export function creationReferenceMetadata(references: CreationReference[]) { function attachmentReference(attachment: CreationAttachment, index: number): CreationReference { const kind = creationAttachmentKind(attachment); - const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : "图片"; + const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "file" ? "文件" : "图片"; return { id: `upload:${attachment.id}`, nodeId: `upload:${attachment.id}`, - kind, + kind: kind === "file" ? "text" : kind, label: `${label}${index + 1}`, title: "当前参考内容", previewUrl: attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url), diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 2f8924d61..16c6a8406 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; -import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; -import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react"; +import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; +import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react"; import { Link } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -8,31 +8,35 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { ModelPicker } from "@/components/model-picker"; +import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; +import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; -import { requestImageQuestion } from "@/services/api/image"; +import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center"; import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection"; -import { uploadMediaFile } from "@/services/file-storage"; +import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe"; import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; -import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; +import { useUserStore } from "@/stores/use-user-store"; import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references"; -import { creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; +import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; +type CreationViewMode = "chat" | "storyboard"; type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled"; type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string }; type CreationRetryContext = GenerationRetryContext & { retryContextsByBatchIndex?: GenerationRetryContext[] }; @@ -81,6 +85,7 @@ const qualityOptions = [ ]; const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) })); const countOptions = ["1", "2", "3", "4"]; +const TEXT_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024; const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }); const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); @@ -135,6 +140,7 @@ export default function CreatePage() { const [videoQuality, setVideoQuality] = useState(config.vquality || "720"); const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1)))); const [busy, setBusy] = useState(false); + const [viewMode, setViewMode] = useState("chat"); const [selectedShotIndex, setSelectedShotIndex] = useState(-1); const [composingNextShot, setComposingNextShot] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); @@ -311,15 +317,15 @@ export default function CreatePage() { }; const libraryItems = useMemo(() => assets - .filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video") + .filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio") .map((asset) => ({ id: asset.id, title: asset.title, category: asset.category || "other", - kindLabel: asset.kind === "video" ? "视频" : "图片", + kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片", asset, searchText: asset.tags.join(" "), - disabledReason: mode !== "video" && asset.kind === "video" ? "视频仅支持视频创作" : undefined, + disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined, })), [assets, mode]); const uploadCreationAsset = async (file: File) => { if (file.type.startsWith("video/")) { @@ -329,6 +335,17 @@ export default function CreatePage() { attachment: creationAttachmentFromVideo(file, uploaded), }; } + if (file.type.startsWith("audio/")) { + const uploaded = await uploadMediaFile(file, "create-upload"); + return { + asset: creationAudioAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), + attachment: creationAttachmentFromAudio(file, uploaded), + }; + } + if (!file.type.startsWith("image/")) { + const uploaded = await uploadMediaFile(file, "create-upload"); + return { attachment: creationAttachmentFromDocument(file, uploaded) }; + } const uploaded = await uploadImage(file); return { asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), @@ -341,12 +358,12 @@ export default function CreatePage() { return; } const next = Array.from(files) - .filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))) + .filter((file) => creationFileAccepted(mode, file)) .slice(0, Math.max(0, maxReferences - attachments.length)); if (!next.length) return; void Promise.allSettled(next.map(async (file) => { const { asset, attachment } = await uploadCreationAsset(file); - addAsset(asset); + if (asset) addAsset(asset); return attachment; })).then((settled) => { const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); @@ -357,13 +374,13 @@ export default function CreatePage() { }; const uploadLibraryAssets = async (files: FileList | File[]) => { - const next = Array.from(files).filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))); + const next = Array.from(files).filter((file) => creationFileAccepted(mode, file)); if (!next.length) return []; const settled = await Promise.allSettled(next.map(async (file) => { const { asset } = await uploadCreationAsset(file); - return addAsset(asset); + return asset ? addAsset(asset) : ""; })); - const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); + const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" && entry.value ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`); if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`); @@ -378,7 +395,8 @@ export default function CreatePage() { const handleLibrarySelect = (selected: Asset[]) => { const next = selected.flatMap((asset): CreationAttachment[] => { if (asset.kind === "image") return [creationAttachmentFromAsset(asset)]; - if (asset.kind === "video" && mode === "video") return [creationAttachmentFromVideoAsset(asset)]; + if (asset.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)]; + if (asset.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)]; return []; }); if (!next.length) return; @@ -458,12 +476,12 @@ export default function CreatePage() { const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, size: ratio, videoSeconds: seconds, quality, vquality: videoQuality, count }; try { if (mode === "text") { - const history = [...(activeConversation.messages || []), userMessage].map((item) => ({ + const history = await Promise.all([...(activeConversation.messages || []), userMessage].map(async (item) => ({ role: item.role, content: item.role === "user" - ? buildTextMessageContent(item) + ? await buildTextMessageContent(item) : item.content, - })); + }))); await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), { signal: requestLifecycle.signal }); } else if (mode === "image") { const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1))); @@ -702,7 +720,7 @@ export default function CreatePage() { count, setCount, composerFocusRef, - placeholderOverride: composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, + placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, onSubmit: () => void submit(), onStop: () => abortRef.current?.abort(), }; @@ -731,7 +749,20 @@ export default function CreatePage() { onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }} /> - :
+ : viewMode === "chat" ?
+ setHistoryOpen(true)} /> +
+
{activeConversation.messages.map((item, index) => retryFailedMessage(item, index)} + onCreateVariant={() => createVariant(item, index)} + onCancel={() => void cancelPendingMessage(item)} + />)}
+
+
+
:
setHistoryOpen(true)} + viewMode={viewMode} + onViewModeChange={setViewMode} />
@@ -766,7 +799,7 @@ export default function CreatePage() { items={libraryItems} categoryLabels={creationAssetCategoryLabels} initialSelectedIds={attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6))} - upload={{ accept: mode === "video" ? "image/*,video/*" : "image/*", description: `支持图片${mode === "video" ? "和视频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }} + upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }} onClose={() => setLibraryOpen(false)} onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))} /> @@ -820,6 +853,75 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec ; } +function CreationViewSwitch({ viewMode, onChange }: { viewMode: CreationViewMode; onChange: (mode: CreationViewMode) => void }) { + return
+ + +
; +} + +function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversation, onOpenHistory }: { viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void; onNewConversation: () => void; onOpenHistory: () => void }) { + return
+ +
+ + +
+
; +} + +function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { + if (item.role === "user") return ; + const mode = item.mode || "text"; + const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : ""; + return
+
{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}{mode !== "text" ? {item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null} : null}{modelName ? {modelName} : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null}
+ {mode === "text" ?
{item.content ? {item.content} : 正在生成…}
: } + {item.error && mode === "text" ?
{generationErrorMessage(item.error)}
: null} +
; +} + +function CreationUserMessage({ item }: { item: CreationMessage }) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); + const copyText = useCopyText(); + const visiblePrompt = displayCreationPrompt(item.content, item.references || []); + return
+
你{item.createdAt ? : null}
+

{visiblePrompt}

+ {item.references?.length ? : null} + {item.attachments?.length ?
{item.attachments.map((attachment) => { + const kind = creationAttachmentKind(attachment); + const previewable = kind === "image" || kind === "video"; + const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; + return ; + })}
: null} + setPreviewUrl("")} /> +
; +} + +function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); + const assets = useAssetStore((state) => state.assets); + const resultUrls = item.resultUrls || []; + const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : []; + const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas"; + if (item.status === "pending") return ; + if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return
{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}
; + if (!resultUrls.length) return
没有返回可预览结果
; + const isVideo = item.mode === "video"; + return
+ {isVideo ? :
{resultUrls.map((url) => )}
} +
{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}{resultAssetIds.length ? "添加到画布" : "打开画布"}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
+ setPreviewUrl("")} /> +
; +} + +function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) { + return
影策正在生成{mode === "video" ? "视频" : "图像"}{onCancel ? : null}
; +} + function CreationMessageReferences({ references }: { references: CreationReference[] }) { return
{references.map((reference) => { const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText; @@ -839,12 +941,13 @@ function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false onRemove: (id: string) => void; onAdd?: () => void; }) { - const isVideo = isVideoAttachment(item); - const url = isVideo ? item.url : item.previewUrl; + const kind = creationAttachmentKind(item); + const previewable = kind === "image" || kind === "video"; + const url = (kind === "video" ? item.url : item.previewUrl) || ""; return
- {primary && canAddMore && onAdd ? : null} @@ -891,6 +994,18 @@ function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const canSubmit = Boolean(props.prompt.trim()) && !props.busy; + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); + const priceChannel = resolveModelChannel(props.config, props.model); + const credits = requestCreditCost({ + channelMode: priceChannel.scope === "system" ? "remote" : "local", + modelCosts: priceChannel.modelCosts, + model: modelOptionName(props.model), + count: props.mode === "image" ? props.count : 1, + seconds: props.mode === "video" ? props.seconds : 1, + }); + const showCost = creditsEnabled && credits !== null; + const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); + const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" @@ -908,7 +1023,7 @@ function CreationComposer(props: ComposerProps) { }; return
- + {primaryAttachment ? : }
@@ -922,12 +1037,29 @@ function CreationComposer(props: ComposerProps) { onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null}
- {props.busy ? : } +
setPreviewUrl("")} />
; @@ -951,17 +1083,42 @@ function GenerationSettingsMenu(props: ComposerProps) { const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; - const ratios = props.mode === "video" ? props.videoProfile.ratios : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); + const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(props.imageProfile.size); + const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(props.imageProfile.size.values) : []; + const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined; + const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1"); + const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto"; + const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(props.imageProfile.size.values) : []; + const imageRatios = usesImageResolutionPicker + ? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio))) + : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); + const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios; const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; + const selectImageRatio = (nextRatio: string) => { + if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") { + props.setRatio(nextRatio); + return; + } + props.setRatio(imageSizeForResolution(imageResolutionOptions, activeImageResolutionChoice, nextRatio) || nextRatio); + }; + const selectImageResolution = (choice: ImageResolutionChoice) => { + if (choice === "auto") { + props.setRatio(props.imageProfile.size.values.includes("auto") ? "auto" : activeImageRatio); + return; + } + const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size; + if (nextSize) props.setRatio(nextSize); + }; const imageSummary = [ - ...(props.imageProfile.size.parameter !== "none" ? [props.ratio] : []), + ...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary; const panel =
- {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => )}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} + {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} {props.mode === "video" ?
{resolutions.map((option) => )}
: <> + {imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null} {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} {props.imageProfile.maxOutputs > 1 ?
{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
: null} } @@ -1042,7 +1199,7 @@ function directorNoteFor(mode: CreationMode, settings: CreationSettings): string return ""; } -function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void }) { +function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory, viewMode, onViewModeChange }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void; viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void }) { const [railOpen, setRailOpen] = useState(false); const nextShotNumber = shots.length + 1; const closeRail = () => setRailOpen(false); @@ -1075,6 +1232,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
: null}
+ @@ -1114,9 +1272,8 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
{briefVisible && user ?
-
{shotScriptLabels[mode]}{user.createdAt ? : null}
+
{shotScriptLabels[mode]}{user.createdAt ? : null}
-

{visiblePrompt}

{user.references?.length ? : null} {user.attachments?.length ? : null} @@ -1164,9 +1321,10 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); return <>
{attachments.map((attachment) => { - const isVideo = isVideoAttachment(attachment); + const kind = creationAttachmentKind(attachment); + const previewable = kind === "image" || kind === "video"; const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; - return ; + return ; })}
setPreviewUrl("")} />; } @@ -1222,19 +1380,50 @@ function conversationPreviewMessage(conversation: CreationConversation) { return fallback; } -function buildTextMessageContent(item: CreationMessage) { +async function buildTextMessageContent(item: CreationMessage) { const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []); - const images = (item.attachments || []).filter(isImageAttachment); - if (!images.length) return content; - return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))]; + const attachments = item.attachments || []; + if (!attachments.length) return content; + const parts: AiTextContentPart[] = [{ type: "text", text: content }]; + for (const attachment of attachments) { + if (isImageAttachment(attachment)) { + parts.push({ type: "image_url", image_url: { url: attachment.dataUrl || attachment.url || "" } }); + continue; + } + const url = await creationAttachmentDataUrl(attachment); + parts.push({ type: "file_url", file_url: { url, name: attachment.name || "附件", mimeType: attachment.type || "application/octet-stream" } }); + } + return parts; +} + +async function creationAttachmentDataUrl(attachment: CreationAttachment) { + if ((attachment.bytes || 0) > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); + const attachmentUrl = attachment.url || ""; + if (attachmentUrl.startsWith("data:")) return attachmentUrl; + const blob = attachment.storageKey ? await getMediaBlob(attachment.storageKey) : null; + if (blob) { + if (blob.size > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); + return blobToDataUrl(blob); + } + if (/^https:\/\//i.test(attachmentUrl)) return attachmentUrl; + throw new Error(`${attachment.name} 无法读取,请重新上传后再试`); +} + +function blobToDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("附件读取失败")); + reader.readAsDataURL(blob); + }); } function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } { - return attachment.type.startsWith("video/"); + return creationAttachmentKind(attachment) === "video"; } function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } { - return !isVideoAttachment(attachment); + return creationAttachmentKind(attachment) === "image"; } type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string }; diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 6fbdbbbe1..df4eaed3c 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -14,9 +14,14 @@ import { imageSizeRequest, modelCapabilityConfigFor, normalizeImageValue, type I export type AiTextMessage = { role: "system" | "user" | "assistant"; - content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; + content: string | AiTextContentPart[]; }; +export type AiTextContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + | { type: "file_url"; file_url: { url: string; name: string; mimeType: string } }; + export type ResponseToolCall = { id: string; type: "function"; @@ -43,7 +48,7 @@ export type ToolResponseResult = { type ToolChoice = "auto" | "required" | { type: "function"; name: string }; type ResponseMessageContent = AiTextMessage["content"] | string; -type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; +type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string }; type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string }; type ResponseApiToolDefinition = { type: "function"; @@ -347,7 +352,13 @@ function toResponseInput(messages: ResponseInputMessage[]): ResponseInputItem[] function toResponseContent(content: ResponseMessageContent): string | ResponseInputContent[] { if (!Array.isArray(content)) return String(content || ""); - return content.map((item) => (item.type === "text" ? { type: "input_text" as const, text: item.text } : { type: "input_image" as const, image_url: item.image_url.url })); + return content.map((item) => { + if (item.type === "text") return { type: "input_text" as const, text: item.text }; + if (item.type === "image_url") return { type: "input_image" as const, image_url: item.image_url.url }; + return item.file_url.url.startsWith("data:") + ? { type: "input_file" as const, filename: item.file_url.name, file_data: item.file_url.url } + : { type: "input_file" as const, filename: item.file_url.name, file_url: item.file_url.url }; + }); } function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefinition { @@ -377,13 +388,24 @@ function toChatCompletionMessages(messages: ResponseInputMessage[]) { if (message.role === "tool") { result.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content }); } else { - result.push({ role: message.role, content: message.content }); + result.push({ role: message.role, content: toChatCompletionContent(message.content) }); } index += 1; } return result; } +function toChatCompletionContent(content: ResponseMessageContent) { + if (!Array.isArray(content)) return content; + return content.map((item) => { + if (item.type !== "file_url") return item; + const file = item.file_url.url.startsWith("data:") + ? { filename: item.file_url.name, file_data: item.file_url.url } + : { filename: item.file_url.name, file_url: item.file_url.url }; + return { type: "file", file }; + }); +} + function toChatCompletionToolChoice(toolChoice: ToolChoice) { return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice; } @@ -653,18 +675,22 @@ function toGeminiContents(messages: ResponseInputMessage[]): GeminiContent[] { function toGeminiParts(content: ResponseMessageContent): GeminiPart[] { if (!Array.isArray(content)) return [{ text: String(content || "") }]; - return content.map((item) => (item.type === "text" ? { text: item.text } : toGeminiImagePart(item.image_url.url))); + return content.map((item) => { + if (item.type === "text") return { text: item.text }; + if (item.type === "image_url") return toGeminiFilePart(item.image_url.url, "image/png"); + return toGeminiFilePart(item.file_url.url, item.file_url.mimeType); + }); } -function toGeminiImagePart(url: string): GeminiPart { +function toGeminiFilePart(url: string, mimeType: string): GeminiPart { const match = url.match(/^data:([^;,]+);base64,(.+)$/); if (match) return { inlineData: { mimeType: match[1], data: match[2] } }; - return { fileData: { fileUri: url, mimeType: "image/png" } }; + return { fileData: { fileUri: url, mimeType } }; } function geminiTextContent(content: ResponseMessageContent) { if (!Array.isArray(content)) return String(content || ""); - return content.map((item) => (item.type === "text" ? item.text : item.image_url.url)).join("\n"); + return content.map((item) => item.type === "text" ? item.text : item.type === "image_url" ? item.image_url.url : `${item.file_url.name}: ${item.file_url.url}`).join("\n"); } function jsonObject(value: string): Record { @@ -782,7 +808,7 @@ async function requestGeminiImages(config: AiConfig, prompt: string, references: async function requestGeminiImagesOnce(config: AiConfig, prompt: string, references: ReferenceImage[], options?: RequestOptions) { const parts: GeminiPart[] = [{ text: prompt }]; for (const image of references) { - parts.push(toGeminiImagePart(await imageToDataUrl(image))); + parts.push(toGeminiFilePart(await imageToDataUrl(image), image.type || "image/png")); } const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config)); const response = await axios.post( diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 4d5186915..e792a8a79 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -623,6 +623,7 @@ function normalizeVideoSize(value: string) { function normalizeVideoResolution(value: string) { if (value === "low") return "480p"; if (value === "auto" || value === "high" || value === "medium") return "720p"; + if (value.toLowerCase() === "2k") return "1440p"; if (value.toLowerCase() === "4k") return "2160p"; const resolution = value.replace(/p$/i, "") || "720"; return `${resolution}p`; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 3b030d20f..72aec7a27 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -765,6 +765,7 @@ --prompt-panel-label-fs: var(--fs-label); /* 11px 标签 */ --settings-model-option-min-height: 76px; --prompt-panel-heading-fs: var(--fs-heading); /* 16px 区标题 */ + --creation-media-pending-max-width: 320px; /* 进度条(对应 #98 决策3 任务进度)*/ --progress-bar-height: 6px; @@ -3183,23 +3184,6 @@ html:not(.dark) .creation-chat-control:hover, html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { border-color: rgba(20, 26, 35, .2) !important; background: #e9edf1 !important; } - .creation-chat-submit { - display: grid; - width: 38px; - height: 38px; - flex: 0 0 auto; - place-items: center; - border: 0; - border-radius: 50%; - background: #303540; - color: #f1f3f5; - transition: background-color 140ms ease, color 140ms ease, transform 140ms var(--motion-ease-out); - } - .creation-chat-submit:hover:not(:disabled) { background: #f3f4f6; color: #121417; transform: translateY(-1px); } - .creation-chat-submit:active:not(:disabled) { transform: scale(.96); } - .creation-chat-submit:disabled { cursor: not-allowed; background: #282c33; color: #5c6370; } - .creation-chat-submit.is-stopping { background: #bd4949; color: white; } - .creation-control-popover-surface { border: 0 !important; border-radius: 12px !important; @@ -3299,7 +3283,6 @@ .creation-chat-controls { max-width: calc(100% - 42px); gap: 5px; } .creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 34px !important; padding: 0 8px !important; font-size: 11px; } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(152px, 48vw) !important; } - .creation-chat-submit { width: 36px; height: 36px; } .creation-thread-stage { padding-top: 28px; } .creation-assistant-message { margin-bottom: 32px; } .creation-message-content, .creation-message-error { padding-left: 0; } @@ -6714,6 +6697,7 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba .creation-chat-controls { display: flex; min-width: 0; + flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 2px; @@ -6763,26 +6747,6 @@ html:not(.dark) .creation-chat-control.is-mode, html:not(.dark) .creation-chat-control:hover, html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(29, 33, 37, .06) !important; color: var(--creation-text) !important; } -.creation-chat-submit { - display: grid; - width: 32px; - height: 32px; - flex: 0 0 auto; - place-items: center; - border: 0; - border-radius: 9px; - background: #f3f4f5; - color: #1b1d21; - cursor: pointer; - transition: transform 140ms var(--motion-ease-out), background-color 140ms ease; -} - -.creation-chat-submit:hover:not(:disabled) { background: #fff; transform: translateY(-1px); } -.creation-chat-submit:disabled { background: #292d34; color: #606771; cursor: not-allowed; } -.creation-chat-submit.is-stopping { background: #d06060; color: #fff; } -html:not(.dark) .creation-chat-submit { background: #202328; color: #fff; } -html:not(.dark) .creation-chat-submit:hover:not(:disabled) { background: #2d3139; } - .creation-control-popover-surface, .creation-model-picker-surface { overflow: hidden; @@ -6892,7 +6856,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f .creation-empty-workspace { width: calc(100% - 28px); padding-bottom: 8vh; } .creation-chat-writing-surface, .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; padding: 14px 12px 5px; } .creation-chat-dock { align-items: flex-end; padding: 4px 7px 8px 8px; } - .creation-chat-controls { max-width: calc(100% - 38px); } + .creation-chat-controls { max-width: none; } .creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 29px !important; padding: 0 6px !important; font-size: 10px; } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(136px, 42vw) !important; } .creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); } @@ -6901,8 +6865,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f @media (prefers-reduced-motion: reduce) { .creation-empty-composer, - .creation-chat-reference, - .creation-chat-submit { animation: none; transition: none; } + .creation-chat-reference { animation: none; transition: none; } } /* 创作入口与空间导航在同一块画布上,避免视觉断层。 */ @@ -10373,3 +10336,322 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou vertical-align: -0.08em; accent-color: var(--primary); } + +/* ===== 创作页 · 连续对话与媒体状态 ===== */ +.creation-home .creation-thread-workbench { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.creation-home .creation-thread-toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); +} + +.creation-home .creation-thread-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.creation-home .creation-view-switch { + display: inline-flex; + align-items: center; + gap: var(--space-1); + border-radius: var(--r-md); + background: var(--creation-surface-muted); + padding: var(--space-1); +} + +.creation-home .creation-view-switch button { + display: inline-flex; + min-height: var(--space-8); + align-items: center; + gap: var(--space-1); + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--creation-muted); + padding: 0 var(--space-3); + font-size: var(--fs-caption); + cursor: pointer; + transition: background-color var(--motion-state) ease, color var(--motion-state) ease; +} + +.creation-home .creation-view-switch button[aria-pressed="true"] { + background: var(--creation-surface-selected); + color: var(--creation-text); +} + +.creation-home .creation-view-switch button:focus-visible { + outline: 2px solid var(--creation-accent); + outline-offset: 1px; +} + +.creation-home .creation-view-switch svg { width: var(--space-4); height: var(--space-4); } + +.creation-home .storyboard-workbench-bar-actions .creation-view-switch { margin-right: var(--space-2); } + +.creation-home .creation-thread-stage { + width: min(900px, calc(100% - 56px)); + min-height: 100%; + margin: 0 auto; + padding: var(--space-8) 0 var(--space-6); +} + +.creation-home .creation-thread-composer { + position: relative; + z-index: 20; + bottom: auto; + width: min(900px, calc(100% - 56px)); + margin: 0 auto; + padding: var(--space-2) 0 var(--space-5); + background: var(--creation-bg); +} + +.creation-home .creation-user-message { + display: flex; + width: min(680px, 86%); + max-width: none; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2); + margin: 0 0 var(--space-10) auto; + border-radius: 0; + background: transparent; + padding: 0; + color: var(--creation-text); + white-space: normal; +} + +html:not(.dark) .creation-home .creation-user-message { background: transparent; } + +.creation-home .creation-user-message-meta { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--creation-faint); + font-size: var(--fs-label); +} + +.creation-home .creation-user-message-meta time, +.creation-home .creation-message-heading time { color: var(--creation-faint); font-size: var(--fs-micro); } + +.creation-home .creation-user-message-copy { + display: grid; + width: var(--space-5); + height: var(--space-5); + flex: 0 0 var(--space-5); + place-items: center; + border: 0; + border-radius: var(--r-xs); + background: transparent; + color: var(--creation-faint); + padding: 0; + cursor: pointer; + opacity: .66; + transition: background-color var(--motion-state) ease, color var(--motion-state) ease, opacity var(--motion-state) ease; +} + +.creation-home .creation-user-message-copy:hover { + background: var(--creation-surface-hover); + color: var(--creation-text); + opacity: 1; +} + +.creation-home .creation-user-message-copy:focus-visible { outline: 2px solid var(--creation-accent); outline-offset: 1px; } +.creation-home .creation-user-message-copy svg { width: var(--space-3); height: var(--space-3); } + +.creation-home .creation-user-message-copy-wrap { + max-width: 100%; +} + +.creation-home .creation-user-message-copy-wrap p { + margin: 0; + color: var(--creation-text); + font-size: var(--fs-body); + line-height: 1.75; + text-align: right; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.creation-home .creation-assistant-message { + width: min(760px, 100%); + max-width: none; + margin: 0 0 var(--space-10); + background: transparent; +} + +.creation-home .creation-message-heading { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.creation-home .creation-message-heading strong { color: var(--creation-text); font-size: var(--fs-body); font-weight: 620; } +.creation-home .creation-message-progress-copy { color: var(--creation-muted); font-size: var(--fs-body); } + +.creation-home .creation-message-content { + max-width: 720px; + padding: var(--space-3) 0 0 var(--space-8); +} + +.creation-home .creation-media-pending { + position: relative; + display: grid; + width: min(var(--creation-media-pending-max-width), calc(100% - var(--space-8))); + min-height: 0; + isolation: isolate; + place-items: center; + overflow: hidden; + margin: var(--space-3) 0 0 var(--space-8); + border: 0; + border-radius: var(--r-lg); + background: linear-gradient(115deg, color-mix(in srgb, var(--status-loading) 16%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 7%, var(--creation-surface)), color-mix(in srgb, var(--status-loading) 28%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 10%, var(--creation-surface))); + background-size: 280% 280%; + padding: 0; + animation: creation-media-breathe 2.4s ease-in-out infinite; +} + +.creation-home .creation-media-pending::before { + position: absolute; + z-index: -1; + inset: 0; + background: linear-gradient(105deg, transparent 12%, color-mix(in srgb, var(--status-loading) 24%, transparent) 48%, color-mix(in srgb, var(--creation-text) 12%, transparent) 58%, transparent 88%); + content: ""; + opacity: .38; + transform: translateX(-38%); + animation: creation-media-breathe-layer 2.4s ease-in-out infinite; +} + +.creation-home .creation-media-pending-icon { + display: grid; + width: var(--space-12); + height: var(--space-12); + place-items: center; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--creation-surface) 72%, transparent); + color: var(--creation-text); + animation: creation-media-icon-breathe 1.8s ease-in-out infinite; +} + +.creation-home .creation-media-pending-icon svg { width: var(--space-5); height: var(--space-5); } + +.creation-home .creation-media-pending > button { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + min-height: var(--space-8); + border: 0; + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--creation-surface) 78%, transparent); + color: var(--creation-muted); + padding: 0 var(--space-3); + font-size: var(--fs-label); + cursor: pointer; +} + +.creation-home .creation-media-result { max-width: 680px; padding: var(--space-3) 0 0 var(--space-8); } +.creation-home .creation-image-result-grid { display: flex; max-width: 680px; flex-wrap: wrap; gap: var(--space-2); } +.creation-home .creation-image-result { width: min(320px, 100%); aspect-ratio: auto; background: var(--creation-surface-muted); } +.creation-home .creation-image-result img { display: block; width: 100%; height: auto; max-height: 360px; object-fit: contain; } +.creation-home .creation-video-result { width: min(680px, 100%); } +.creation-home .creation-video-result video { display: block; width: 100%; height: 100%; object-fit: contain; } + +.creation-home .creation-media-error { + display: flex; + width: min(680px, calc(100% - var(--space-8))); + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + margin: var(--space-3) 0 0 var(--space-8); + color: var(--status-error); + font-size: var(--fs-caption); +} + +.creation-home .creation-media-error button { + display: inline-flex; + min-height: var(--space-8); + align-items: center; + gap: var(--space-1); + border: 0; + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--status-error) 10%, transparent); + color: inherit; + padding: 0 var(--space-3); + cursor: pointer; +} + +.creation-home .creation-media-error svg { width: var(--space-4); height: var(--space-4); } + +.creation-home .creation-chat-attachment-preview.is-file { + width: min(220px, 100%); + cursor: default; +} + +.creation-home .creation-chat-file-icon { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: var(--space-2); + color: var(--creation-muted); + padding: 0 var(--space-2); +} + +.creation-home .creation-chat-file-icon > svg { width: var(--space-4); height: var(--space-4); flex: 0 0 auto; } +.creation-home .creation-chat-file-icon em { overflow: hidden; font-size: var(--fs-label); font-style: normal; text-overflow: ellipsis; white-space: nowrap; } + +.creation-home .creation-user-message-attachments button.is-file { + display: grid; + place-items: center; + background: var(--creation-surface-muted); + color: var(--creation-muted); + cursor: default; +} + +.creation-home .creation-user-message-attachments button.is-file > svg { width: var(--space-4); height: var(--space-4); } + +@keyframes creation-media-breathe { + 0%, 100% { background-position: 0% 50%; filter: brightness(.9) saturate(.82); } + 50% { background-position: 100% 50%; filter: brightness(1.12) saturate(1.18); } +} + +@keyframes creation-media-breathe-layer { + 0%, 100% { opacity: .24; transform: translateX(-38%) scale(1); } + 50% { opacity: .72; transform: translateX(38%) scale(1.04); } +} + +@keyframes creation-media-icon-breathe { + 0%, 100% { opacity: .58; transform: scale(.94); } + 50% { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .creation-home .creation-media-pending, + .creation-home .creation-media-pending::before, + .creation-home .creation-media-pending-icon { animation: none; } +} + +@media (max-width: 800px) { + .creation-home .creation-thread-toolbar { align-items: flex-end; padding: var(--space-3); } + .creation-home .creation-view-switch button { padding: 0 var(--space-2); } + .creation-home .creation-thread-stage, + .creation-home .creation-thread-composer { width: calc(100% - 28px); } + .creation-home .creation-thread-stage { padding-top: var(--space-5); } + .creation-home .creation-user-message { width: 92%; max-width: none; } + .creation-home .creation-message-content, + .creation-home .creation-media-result { padding-left: 0; } + .creation-home .creation-media-pending { width: min(var(--creation-media-pending-max-width), 100%); margin-left: 0; } + .creation-home .creation-media-error { width: 100%; margin-left: 0; } +} diff --git a/web/test/creation-references.test.ts b/web/test/creation-references.test.ts index 147ef30fb..a5ae1ab08 100644 --- a/web/test/creation-references.test.ts +++ b/web/test/creation-references.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { canvasResourceMentionToken } from "../src/lib/canvas/canvas-resource-references"; -import type { CreationAttachment } from "../src/pages/create/creation-assets"; +import { creationAttachmentKind, creationFileAccepted, creationMediaAspectRatio, creationUploadAccept, type CreationAttachment } from "../src/pages/create/creation-assets"; import { buildCreationMentionReferences, reconcileCreationAttachmentLimit, removeCreationReferenceTokens } from "../src/pages/create/creation-references"; function imageAttachment(id: string): CreationAttachment { @@ -36,4 +36,28 @@ describe("creation references", () => { expect(result.attachments).toBe(attachments); expect(result.removedReferences).toEqual([]); }); + + test("文本创作允许媒体和常用文档,图片创作仍只接受图片", () => { + expect(creationFileAccepted("text", { name: "story.pdf", type: "application/pdf" })).toBe(true); + expect(creationFileAccepted("text", { name: "clip.mp4", type: "video/mp4" })).toBe(true); + expect(creationFileAccepted("image", { name: "story.pdf", type: "application/pdf" })).toBe(false); + expect(creationUploadAccept("text")).toContain(".docx"); + }); + + test("文档附件会作为文本资源参与引用", () => { + const attachment: CreationAttachment = { id: "document", name: "script.pdf", type: "application/pdf", url: "https://example.com/script.pdf", storageKey: "resource:document", bytes: 1024, previewUrl: "" }; + const [reference] = buildCreationMentionReferences([], [attachment]); + + expect(creationAttachmentKind(attachment)).toBe("file"); + expect(reference.kind).toBe("text"); + expect(reference.label).toBe("文件1"); + }); + + test("媒体占位按本次选择的画幅展示并为异常值提供模式回退", () => { + expect(creationMediaAspectRatio("16:9", "video")).toBe("16 / 9"); + expect(creationMediaAspectRatio("1:1", "image")).toBe("1 / 1"); + expect(creationMediaAspectRatio("1920x1080", "image")).toBe("1920 / 1080"); + expect(creationMediaAspectRatio("auto", "video")).toBe("16 / 9"); + expect(creationMediaAspectRatio("auto", "image")).toBe("1 / 1"); + }); }); diff --git a/web/test/video-generation-options.test.ts b/web/test/video-generation-options.test.ts new file mode 100644 index 000000000..6e7d39aac --- /dev/null +++ b/web/test/video-generation-options.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { normalizeVideoResolution, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options"; + +describe("video generation resolution options", () => { + test("统一档位包含 1440P 与 4K,并识别常见别名", () => { + expect(VIDEO_RESOLUTION_OPTIONS).toEqual([480, 720, 1080, 1440, 2160]); + expect(normalizeVideoResolution("2k")).toBe("1440"); + expect(normalizeVideoResolution("1440p")).toBe("1440"); + expect(normalizeVideoResolution("4K")).toBe("2160"); + }); + + test("保留模型声明的非标准档位而不是静默降级", () => { + expect(normalizeVideoResolution("768p")).toBe("768"); + }); + + test("按协议限制实际可选档位", () => { + expect(defaultModelCapabilityConfig("newapi-channel-2").video?.resolutions).toEqual(["480p", "720p", "1080p", "1440p", "2160p"]); + expect(defaultModelCapabilityConfig("volcengine-ark-video").video?.resolutions).toEqual(["480p", "720p", "1080p"]); + expect(defaultModelCapabilityConfig("volcengine-jimeng-video").video?.resolutions).toEqual(["720p"]); + expect(defaultModelCapabilityConfig("gemini-veo").video?.resolutions).toEqual(["720p", "1080p"]); + }); +}); From ee1e1fa2bef1cbd14c7447b839c2f22d306aa029 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 15 Aug 2026 22:11:02 +0800 Subject: [PATCH 07/25] =?UTF-8?q?fix(admin):=20=E6=B8=A0=E9=81=93=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20-=20=E8=A1=A5=E5=85=85=201440P=20=E5=88=86=E8=BE=A8?= =?UTF-8?q?=E7=8E=87=E5=B9=B6=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/model_capability.go | 3 +++ backend/internal/service/model_capability_test.go | 15 +++++++++++++++ web/src/components/model-capability-editor.tsx | 5 +++-- web/src/lib/video-generation-options.ts | 1 + web/test/video-generation-options.test.ts | 3 ++- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 7bf4ff3d6..f48976315 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -540,6 +540,9 @@ func ratioValue(value string) float64 { func normalizeResolution(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.TrimSuffix(value, "p") + if value == "2k" { + return "1440p" + } if value == "4k" { return "2160p" } diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 296620569..51057c318 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -82,3 +82,18 @@ func TestDefaultVideoCapabilityUsesProtocolSpecificResolutionTiers(t *testing.T) }) } } + +func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) { + tests := map[string]string{ + "1440": "1440p", + "1440p": "1440p", + "2K": "1440p", + "4K": "2160p", + "768P": "768p", + } + for input, want := range tests { + if got := normalizeResolution(input); got != want { + t.Fatalf("normalizeResolution(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/web/src/components/model-capability-editor.tsx b/web/src/components/model-capability-editor.tsx index ba1757fa8..8186a2963 100644 --- a/web/src/components/model-capability-editor.tsx +++ b/web/src/components/model-capability-editor.tsx @@ -3,9 +3,9 @@ import type { ReactNode } from "react"; import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import type { ModelProtocol } from "@/lib/model-protocols"; +import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options"; const ratioOptions = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]; -const resolutionOptions = ["480p", "720p", "1080p", "2160p"]; const operationOptions = [ { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, @@ -35,6 +35,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability = const updateReferences = (patch: Partial) => update({ references: { ...profile.references, ...patch } }); const updateDuration = (patch: Partial) => update({ duration: { ...profile.duration, ...patch } }); const durationValues = (profile.duration.values || []).join(","); + const resolutionOptions = Array.from(new Set([...VIDEO_RESOLUTION_CAPABILITY_OPTIONS, ...profile.resolutions])); return (
@@ -75,7 +76,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /> - ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "720p" })} /> { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} /> setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。 + 确定删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除;若仍被画布、任务或其他素材占用,本次删除将被阻止。 setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除已选择的 {selectedAssets.length} 个素材吗?此操作会同步移除远端记录。 + 确定删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除;仍被画布、任务或其他素材占用的素材会保留并提示具体来源。 ); From a48b58e5f686f1cebcf29fcdec4b43ed6e96f816 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:12:41 +0800 Subject: [PATCH 10/25] =?UTF-8?q?fix(billing):=20=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20-=20=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=20Token=20=E7=94=A8=E9=87=8F=E7=BB=93=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Token 计费的流式 Chat Completions 请求开启 usage 终态块,并解析 Chat Completions 与 Responses SSE 用量。 --- backend/internal/handler/auth.go | 11 +- backend/internal/service/analytics.go | 102 +++++++++++++++--- .../internal/service/finance_token_test.go | 47 ++++++++ backend/internal/service/provider.go | 13 +++ backend/internal/service/text_stream_usage.go | 37 +++++++ 5 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 backend/internal/service/text_stream_usage.go diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 3d4565c29..3fc62eb24 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -715,8 +715,10 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, modelName := proxyRequestModelForPath(path, c.GetHeader("Content-Type"), body) protocol := model.ChannelInterfaceType("") capability := "text" + var channelModel *model.ChannelModel if !(c.Request.Method == http.MethodGet && path == "/models") { - channelModel, modelErr := svc.SystemChannelModel(channel.ID, modelName) + var modelErr error + channelModel, modelErr = svc.SystemChannelModel(channel.ID, modelName) if modelErr != nil || channelModel.Protocol == "" { fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权该模型或模型协议尚未配置")) return @@ -728,6 +730,13 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, fail(c, http.StatusForbidden, err) return } + if channelModel != nil && channelModel.BillingMode == "token" && protocol == model.ChannelInterfaceChatCompletion { + body, err = service.EnsureChatCompletionStreamUsageRequest(body) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + } billingOrderID := "" query := c.Request.URL.Query() for _, key := range []string{"key", "api_key", "access_token", "token"} { diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index 581a099c4..62abd9f18 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -1,6 +1,7 @@ package service import ( + "bufio" "bytes" "encoding/csv" "encoding/json" @@ -939,13 +940,16 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { if log.ProviderRequestID == "" { log.ProviderRequestID = providerRequestIDFromPath(log.Path) } - if len(responseBody) == 0 || !json.Valid(responseBody) { + payloads := providerResponsePayloads(responseBody) + if len(payloads) == 0 { return } - var payload map[string]any - if json.Unmarshal(responseBody, &payload) != nil { - return + for _, payload := range payloads { + s.enrichAPICallLogPayload(log, payload) } +} + +func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) { if data, ok := payload["data"].(map[string]any); ok { for key, value := range data { if _, exists := payload[key]; !exists { @@ -953,6 +957,14 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } } } + // Responses API 的终态 SSE 把实际响应(包括 usage)放在 response 字段中。 + if response, ok := payload["response"].(map[string]any); ok { + for key, value := range response { + if _, exists := payload[key]; !exists { + payload[key] = value + } + } + } if log.Status == model.ApiCallStatusFailed { errorCode, errorMessage := providerFailureDetails(payload) log.ErrorCode = errorCode @@ -962,9 +974,17 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } usage, _ := payload["usage"].(map[string]any) if usage != nil { - log.UsageAvailable = true - log.InputTokens = firstInt64(usage, "input_tokens", "prompt_tokens") - log.OutputTokens = firstInt64(usage, "output_tokens", "completion_tokens") + inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens") + outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens") + if inputAvailable { + log.InputTokens = inputTokens + } + if outputAvailable { + log.OutputTokens = outputTokens + } + if inputAvailable || outputAvailable { + log.UsageAvailable = true + } // 火山方舟视频的输入 Token 恒为 0;查询任务以 completion_tokens 为实际用量, // 兼容只返回 total_tokens 的同协议中转实现。 if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens == 0 { @@ -981,9 +1001,17 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } } if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok { - log.UsageAvailable = true - log.InputTokens = firstInt64(usageMetadata, "promptTokenCount") - log.OutputTokens = firstInt64(usageMetadata, "candidatesTokenCount") + inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount") + outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount") + if inputAvailable { + log.InputTokens = inputTokens + } + if outputAvailable { + log.OutputTokens = outputTokens + } + if inputAvailable || outputAvailable { + log.UsageAvailable = true + } log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount") } log.ProviderRequestID = firstNonEmpty(stringField(payload, "task_id"), stringField(payload, "id"), stringField(payload, "request_id"), stringField(payload, "name"), log.ProviderRequestID) @@ -1003,6 +1031,45 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } } +func providerResponsePayloads(responseBody []byte) []map[string]any { + if len(responseBody) == 0 { + return nil + } + var payload map[string]any + if json.Unmarshal(responseBody, &payload) == nil { + return []map[string]any{payload} + } + + // 流式文本的用量只出现在最后一个 SSE data 事件中,不能把整段响应当作 JSON。 + result := make([]map[string]any, 0) + scanner := bufio.NewScanner(bytes.NewReader(responseBody)) + scanner.Buffer(make([]byte, 64<<10), max(len(responseBody)+1, 64<<10)) + dataLines := make([]string, 0, 1) + flush := func() { + raw := strings.TrimSpace(strings.Join(dataLines, "\n")) + dataLines = dataLines[:0] + if raw == "" || raw == "[DONE]" { + return + } + var event map[string]any + if json.Unmarshal([]byte(raw), &event) == nil { + result = append(result, event) + } + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + flush() + return result +} + func providerRequestIDFromPath(path string) string { parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/") for index := len(parts) - 1; index >= 0; index-- { @@ -1019,18 +1086,23 @@ func providerRequestIDFromPath(path string) string { } func firstInt64(values map[string]any, keys ...string) int64 { + value, _ := firstInt64Value(values, keys...) + return value +} + +func firstInt64Value(values map[string]any, keys ...string) (int64, bool) { for _, key := range keys { switch value := values[key].(type) { case float64: - return int64(value) + return int64(value), true case int64: - return value + return value, true case json.Number: - parsed, _ := value.Int64() - return parsed + parsed, err := value.Int64() + return parsed, err == nil } } - return 0 + return 0, false } func (s *Service) recordActivity(userID string, event string, count int) { diff --git a/backend/internal/service/finance_token_test.go b/backend/internal/service/finance_token_test.go index a83bbab26..b222b4724 100644 --- a/backend/internal/service/finance_token_test.go +++ b/backend/internal/service/finance_token_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "testing" "infinite-canvas/backend/internal/model" @@ -83,3 +84,49 @@ func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) { t.Fatalf("EnrichAPICallLog() accepted empty Ark usage: %#v", missing) } } + +func TestEnrichAPICallLogReadsChatCompletionStreamUsage(t *testing.T) { + log := &model.ApiCallLog{Capability: "text", Path: "/v1/chat/completions"} + stream := []byte("data: {\"id\":\"chatcmpl-test\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n" + + "data: {\"id\":\"chatcmpl-test\",\"choices\":[],\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":7,\"prompt_tokens_details\":{\"cached_tokens\":5}}}\n\n" + + "data: [DONE]\n\n") + (&Service{}).EnrichAPICallLog(log, stream) + if !log.UsageAvailable || log.InputTokens != 23 || log.OutputTokens != 7 || log.CachedTokens != 5 { + t.Fatalf("EnrichAPICallLog() = %#v", log) + } +} + +func TestEnrichAPICallLogReadsResponsesStreamUsage(t *testing.T) { + log := &model.ApiCallLog{Capability: "text", Path: "/v1/responses"} + stream := []byte("event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-test\",\"status\":\"completed\",\"usage\":{\"input_tokens\":31,\"output_tokens\":11,\"input_tokens_details\":{\"cached_tokens\":9}}}}\n\n") + (&Service{}).EnrichAPICallLog(log, stream) + if !log.UsageAvailable || log.InputTokens != 31 || log.OutputTokens != 11 || log.CachedTokens != 9 || log.ProviderRequestID != "resp-test" { + t.Fatalf("EnrichAPICallLog() = %#v", log) + } +} + +func TestEnrichAPICallLogRejectsEmptyTextUsage(t *testing.T) { + log := &model.ApiCallLog{Capability: "text", Path: "/v1/chat/completions"} + (&Service{}).EnrichAPICallLog(log, []byte(`{"usage":{}}`)) + if log.UsageAvailable { + t.Fatalf("EnrichAPICallLog() accepted empty text usage: %#v", log) + } +} + +func TestEnsureChatCompletionStreamUsageRequest(t *testing.T) { + data, err := EnsureChatCompletionStreamUsageRequest([]byte(`{"model":"gpt-test","stream":true,"stream_options":{"custom":true}}`)) + if err != nil { + t.Fatalf("EnsureChatCompletionStreamUsageRequest() error = %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + options, _ := payload["stream_options"].(map[string]any) + if options["include_usage"] != true || options["custom"] != true { + t.Fatalf("stream_options = %#v", options) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index f6b71360f..11866b29d 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -113,6 +113,7 @@ type providerAnalyticsContext struct { UserID string TaskID string BillingOrderID string + BillingMode string Capability string Operation string ChannelID string @@ -125,6 +126,12 @@ type providerAnalyticsContext struct { func withProviderAnalytics(ctx context.Context, service *Service, task model.Task) context.Context { metadata := providerAnalyticsContext{Service: service, UserID: task.UserID, TaskID: task.ID, BillingOrderID: task.BillingOrderID, Capability: capabilityFromTaskType(task.Type), Operation: task.Operation, Model: task.Model, ProviderRequestID: task.ProviderRequestID} + // 账单模式随请求上下文传递,流式协议据此只为 Token 计费开启 usage 终态块。 + if service != nil && task.BillingOrderID != "" { + if order, err := service.repo.BillingOrder(task.BillingOrderID); err == nil { + metadata.BillingMode = order.BillingMode + } + } var input struct { Mode string `json:"mode"` Config providerConfig `json:"config"` @@ -2417,6 +2424,12 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool) (string, error) { if stream { + metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + if protocol == "chat-completion" && metadata.BillingMode == "token" { + if err := ensureChatCompletionStreamUsage(body); err != nil { + return "", err + } + } return postStreamingText(ctx, config, path, body, protocol) } var payload map[string]interface{} diff --git a/backend/internal/service/text_stream_usage.go b/backend/internal/service/text_stream_usage.go new file mode 100644 index 000000000..501f9027d --- /dev/null +++ b/backend/internal/service/text_stream_usage.go @@ -0,0 +1,37 @@ +package service + +import ( + "encoding/json" + "errors" +) + +// EnsureChatCompletionStreamUsageRequest 为 Token 计费的流式 Chat Completions +// 请求开启最终 usage 块;非流式请求保持原样,避免改变普通接口合同。 +func EnsureChatCompletionStreamUsageRequest(data []byte) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + stream, _ := payload["stream"].(bool) + if !stream { + return data, nil + } + if err := ensureChatCompletionStreamUsage(payload); err != nil { + return nil, err + } + return json.Marshal(payload) +} + +func ensureChatCompletionStreamUsage(payload map[string]any) error { + options := map[string]any{} + if value, exists := payload["stream_options"]; exists { + var ok bool + options, ok = value.(map[string]any) + if !ok { + return errors.New("stream_options 必须是 JSON 对象") + } + } + options["include_usage"] = true + payload["stream_options"] = options + return nil +} From 4b8b9db9edb004297ddc43799a2a7b1fb0525808 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 16 Aug 2026 13:15:45 +0800 Subject: [PATCH 11/25] =?UTF-8?q?fix(billing):=20Token=20=E7=BB=93?= =?UTF-8?q?=E7=AE=97=20-=20=E4=BF=AE=E5=A4=8D=E8=A7=86=E9=A2=91=E6=80=BB?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E5=9B=9E=E9=80=80=E6=9C=AA=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E6=9C=89=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/analytics.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index 62abd9f18..dcda1bf62 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -987,11 +987,11 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str } // 火山方舟视频的输入 Token 恒为 0;查询任务以 completion_tokens 为实际用量, // 兼容只返回 total_tokens 的同协议中转实现。 - if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens == 0 { - log.OutputTokens = firstInt64(usage, "total_tokens") - } - if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens <= 0 { - log.UsageAvailable = false + if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") { + if log.OutputTokens == 0 { + log.OutputTokens = firstInt64(usage, "total_tokens") + } + log.UsageAvailable = log.OutputTokens > 0 } if details, ok := usage["input_tokens_details"].(map[string]any); ok { log.CachedTokens = firstInt64(details, "cached_tokens") From de65ffed9a5ce21f99a3b71c980765aab15e2368 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 16 Aug 2026 13:15:51 +0800 Subject: [PATCH 12/25] =?UTF-8?q?test(canvas):=20=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=20-=20=E6=9B=B4=E6=96=B0=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E7=B4=A0=E6=9D=90=E5=BA=93=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/test/create-library-button.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts index 480e9052f..347a98346 100644 --- a/web/test/create-library-button.test.ts +++ b/web/test/create-library-button.test.ts @@ -14,9 +14,16 @@ describe("creation library button", () => { expect(dockStart).toBeGreaterThanOrEqual(0); expect(dockEnd).toBeGreaterThan(dockStart); - expect(compactSource(source.slice(dockStart, dockEnd))).toMatch( - / ]*>\s*]*className="creation-chat-control")(?=[^>]*onClick=\{props\.onOpenLibrary\})(?=[^>]*disabled=\{props\.busy \|\| !referencesSupported\})(?=[^>]*aria-label="打开素材库选择参考内容")[^>]*>\s*\s*素材库<\/span>\s*<\/button>\s*<\/Tooltip>/, - ); + const dockSource = compactSource(source.slice(dockStart, dockEnd)); + const modePickerIndex = dockSource.indexOf(" { @@ -39,7 +46,8 @@ describe("creation library button", () => { const createSource = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); const canvasSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-prompt-panel.tsx"), "utf8"); - expect(createSource).toContain('className="creation-chat-attachment-preview"'); + expect(createSource).toContain('className="creation-user-message-attachments"'); + expect(createSource).toContain('setPreviewType(kind === "video" ? "video" : "image")'); expect(createSource).toContain(" Date: Sun, 16 Aug 2026 13:41:48 +0800 Subject: [PATCH 13/25] =?UTF-8?q?docs(platform):=20=E5=BC=80=E6=BA=90?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=20-=20=E7=BB=9F=E4=B8=80=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=20MIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + CONTRIBUTING.md | 2 +- LICENSE | 683 +----------------- NOTICE | 7 +- README.md | 4 +- canvas-agent/package.json | 2 +- .../infinite-canvas/.codex-plugin/plugin.json | 2 +- 7 files changed, 33 insertions(+), 668 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b281347d..44da3ea67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- 项目开源协议由 AGPL-3.0 调整为 MIT,保留上游及项目贡献者版权声明。 - 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。 ## v1.0.59 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c127fa7f1..3fbd732c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,4 +18,4 @@ - 涉及上传、外部请求或模型调用时,必须说明大小、频率、费用和 SSRF 边界。 - 用户可见文案使用中文;核心业务入口和安全边界使用简短中文注释说明原因。 -Pull Request 应包含改动摘要、风险、验证方式和必要截图。提交即表示你有权按本项目 AGPL-3.0 许可证贡献相关代码或素材,并保留已有上游署名和许可证通知。 +Pull Request 应包含改动摘要、风险、验证方式和必要截图。提交即表示你有权按本项目 MIT 许可证贡献相关代码或素材,并保留已有上游署名和许可证通知。 diff --git a/LICENSE b/LICENSE index 29ebfa545..77cd9dbea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,22 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. \ No newline at end of file +MIT License + +Copyright (c) 2026 basketikun +Copyright (c) 2026 ddcat and Open AI Canvas contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE index dff3963be..cf0666c83 100644 --- a/NOTICE +++ b/NOTICE @@ -1,7 +1,7 @@ Open AI Canvas Copyright (C) 2026 ddcat -This program is licensed under the GNU Affero General Public License v3.0. +This program is licensed under the MIT License. The complete license text is provided in LICENSE. This project is a derivative work based on: @@ -13,7 +13,10 @@ This project is a derivative work based on: The upstream project is maintained under the basketikun account. The base commit is authored by HouYunFei. Upstream authors and contributors retain -their copyright and attribution for their respective contributions. +their copyright and attribution for their respective contributions. The +upstream project was relicensed to MIT in commit +890ba95858bbb13496d23978003716656109abb2; its copyright and permission notice +are preserved in LICENSE. Material changes in this repository are maintained by ddcat. They include a multi-user Go backend, task processing and analytics, private resource diff --git a/README.md b/README.md index b05c4a877..330ed7d70 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

GitHub stars Version - License + License

一个故事也许始于一页小说、一个人物,或一句还没写完的对白。影策从章节中梳理角色与情节,让人物的外观、声音和气质成为可复用的角色资产,再把分镜、图片、视频和音频组织在同一张画布上。从最初的文字到可以被看见、被听见的镜头,创作者始终掌握故事的方向。 @@ -282,4 +282,4 @@ docker compose -f docker-compose.local.yml up -d --build 本项目基于 [basketikun/infinite-canvas](https://github.com/basketikun/infinite-canvas) `v0.5.0`(提交 `568f0f1838df8de31fe885a4e130e2f346dd14ab`)进行二次开发。上游项目由 `basketikun` 维护,该基线提交作者为 `HouYunFei`;上游作者和贡献者继续保留其对应代码的权利与署名。 -漏洞请按 [SECURITY.md](SECURITY.md) 提交。项目采用 [AGPL-3.0](LICENSE) 协议。 +漏洞请按 [SECURITY.md](SECURITY.md) 提交。项目采用 [MIT](LICENSE) 协议。 diff --git a/canvas-agent/package.json b/canvas-agent/package.json index 84165c6cc..8f1ddb090 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -43,7 +43,7 @@ "url": "https://github.com/ddcat-ai/open-ai-canvas.git", "directory": "canvas-agent" }, - "license": "AGPL-3.0", + "license": "MIT", "publishConfig": { "access": "public" } diff --git a/plugins/infinite-canvas/.codex-plugin/plugin.json b/plugins/infinite-canvas/.codex-plugin/plugin.json index 53d21a94b..5b138660e 100644 --- a/plugins/infinite-canvas/.codex-plugin/plugin.json +++ b/plugins/infinite-canvas/.codex-plugin/plugin.json @@ -6,7 +6,7 @@ "name": "ddcat" }, "repository": "https://github.com/ddcat-ai/open-ai-canvas", - "license": "AGPL-3.0", + "license": "MIT", "keywords": ["infinite-canvas", "canvas", "mcp", "agent"], "skills": "./skills/", "mcpServers": "./.mcp.json", From 6cdece9e04cf1a74b2d8366ac7c3ca522ea08081 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:44:53 +0800 Subject: [PATCH 14/25] =?UTF-8?q?feat(create):=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=20-=20=E6=94=AF=E6=8C=81=E5=8D=95=E6=9D=A1?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=88=A0=E9=99=A4=20(#237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/create/index.tsx | 56 +++++++++++++++++-- .../services/creation-conversation-store.ts | 8 +++ web/src/styles/globals.css | 14 +++-- web/test/create-canvas-handoff.test.ts | 13 ++++- 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 16c6a8406..5b61ca8c7 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; -import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react"; +import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Trash2, X } from "lucide-react"; import { Link } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -28,7 +28,7 @@ import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe"; import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; -import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; +import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -120,7 +120,7 @@ function completedCreationGenerationTask(input: { taskId: string; task?: Generat } export default function CreatePage() { - const { message: toast } = App.useApp(); + const { message: toast, modal } = App.useApp(); const config = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const assets = useAssetStore((state) => state.assets); @@ -128,6 +128,7 @@ export default function CreatePage() { const [conversations, setConversations] = useState([]); const conversationsRef = useRef([]); const [activeId, setActiveId] = useState(""); + const activeIdRef = useRef(""); const [hydrated, setHydrated] = useState(false); const [mode, setMode] = useState("video"); const [prompt, setPrompt] = useState(""); @@ -225,6 +226,10 @@ export default function CreatePage() { useEffect(() => () => abortRef.current?.abort(), []); + useEffect(() => { + activeIdRef.current = activeId; + }, [activeId]); + useEffect(() => { conversationsRef.current = conversations; if (hydrated) void saveCreationConversations(conversations); @@ -599,6 +604,44 @@ export default function CreatePage() { setHistoryOpen(false); }; + const confirmDeleteConversation = (conversation: CreationConversation) => { + const title = conversation.title.trim() || "新创作"; + const label = title.length > 32 ? `${title.slice(0, 32)}...` : title; + modal.confirm({ + className: "workspace-modal workspace-modal-compact", + title: "删除历史对话?", + content: `确定删除「${label}」吗?这只会删除历史对话记录,不会删除已上传或生成的任何素材。此操作不可撤销。`, + okText: "删除对话", + okButtonProps: { danger: true }, + cancelText: "保留", + onOk: async () => { + try { + const remaining = removeCreationConversationSnapshot(conversationsRef.current, conversation.id); + const sortedRemaining = [...remaining].sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)); + const fallback = sortedRemaining.find((item) => item.messages.length > 0) || sortedRemaining[0] || newConversation(); + const next = remaining.length ? remaining : [fallback]; + await saveCreationConversations(next); + conversationsRef.current = next; + setConversations(next); + if (activeIdRef.current === conversation.id) { + followLatestMessageRef.current = true; + activeIdRef.current = fallback.id; + setActiveId(fallback.id); + setPrompt(""); + setAttachments([]); + setDraftReferences([]); + setSelectedShotIndex(-1); + setComposingNextShot(false); + } + toast.success("历史对话已删除,素材仍保留"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "历史对话删除失败"); + throw error; + } + }, + }); + }; + const restoreMessageDraft = (item: CreationMessage) => { const nextMode = item.mode || "text"; const nextSettings = item.settings; @@ -793,7 +836,7 @@ export default function CreatePage() {
}
- setHistoryOpen(false)} onSelect={selectConversation} /> + setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} /> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; -function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) { +function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect, onDelete }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void }) { const [keyword, setKeyword] = useState(""); useEffect(() => { @@ -841,11 +884,12 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec const latest = conversationPreviewMessage(conversation); const active = conversation.id === activeId; return
  • - +
  • ; })} :
    {keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}
    } diff --git a/web/src/services/creation-conversation-store.ts b/web/src/services/creation-conversation-store.ts index 4162e01b1..8295e3b4a 100644 --- a/web/src/services/creation-conversation-store.ts +++ b/web/src/services/creation-conversation-store.ts @@ -20,6 +20,14 @@ export function updateCreationConversationSnapshot(con return conversations.map((conversation) => (conversation.id === conversationId ? updater(conversation) : conversation)); } +// 对话、生成任务与素材是独立持久状态;删除历史记录不能在这里级联清理任务或资源。 +export function removeCreationConversationSnapshot(conversations: T[], conversationId: string) { + if (!conversationId) throw new Error("缺少要删除的创作对话 ID"); + const next = conversations.filter((conversation) => conversation.id !== conversationId); + if (next.length === conversations.length) throw new Error("要删除的创作对话不存在"); + return next; +} + export function pendingCreationMediaKey(conversations: StoredCreationConversation[]) { return conversations .flatMap((conversation) => conversation.messages.flatMap((message) => (message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : []))) diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 72aec7a27..fed2644a3 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -7933,11 +7933,15 @@ html:not(.dark) .creation-history-drawer-root { .creation-history-search input::placeholder { color: var(--creation-history-muted); } .creation-history-list { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; } -.creation-history-list > li { min-width: 0; } -.creation-history-list button { display: flex; width: 100%; min-height: 78px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 5px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 12px 10px 13px; color: var(--creation-history-text); text-align: left; transition: background-color 150ms ease, border-color 150ms ease; } -.creation-history-list button:hover { background: var(--creation-history-hover); } -.creation-history-list button:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; } -.creation-history-list > li.is-active button { border-color: color-mix(in srgb, var(--creation-history-accent) 28%, transparent); background: color-mix(in srgb, var(--creation-history-accent) 7%, var(--creation-history-surface)); } +.creation-history-list > li { position: relative; min-width: 0; } +.creation-history-item-main { display: flex; width: 100%; min-height: 78px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 5px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 12px 50px 13px 10px; color: var(--creation-history-text); text-align: left; transition: background-color 150ms ease, border-color 150ms ease; } +.creation-history-item-main:hover { background: var(--creation-history-hover); } +.creation-history-item-main:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; } +.creation-history-list > li.is-active .creation-history-item-main { border-color: color-mix(in srgb, var(--creation-history-accent) 28%, transparent); background: color-mix(in srgb, var(--creation-history-accent) 7%, var(--creation-history-surface)); } +.creation-history-delete { position: absolute; z-index: 1; top: 50%; right: 9px; display: grid; width: 32px; height: 32px; place-items: center; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--creation-history-muted); transform: translateY(-50%); transition: background-color 150ms ease, color 150ms ease; } +.creation-history-delete svg { width: 16px; height: 16px; } +.creation-history-delete:hover { background: color-mix(in srgb, var(--destructive) 12%, transparent); color: var(--destructive); } +.creation-history-delete:focus-visible { outline: 2px solid color-mix(in srgb, var(--destructive) 60%, transparent); outline-offset: 1px; } .creation-history-time { display: flex; width: 100%; align-items: center; justify-content: space-between; color: var(--creation-history-muted); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; line-height: 1.3; } .creation-history-time em { border: 1px solid var(--creation-history-line); border-radius: var(--r-xs); padding: 2px 5px; color: var(--creation-history-muted); font-size: var(--fs-micro); font-style: normal; } .creation-history-item-heading { width: 100%; min-width: 0; overflow: hidden; font-size: var(--fs-label); font-weight: 650; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } diff --git a/web/test/create-canvas-handoff.test.ts b/web/test/create-canvas-handoff.test.ts index 9d20ab33c..4529c6a57 100644 --- a/web/test/create-canvas-handoff.test.ts +++ b/web/test/create-canvas-handoff.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createGenerationTaskSubscriptionService, type GenerationTask } from "../src/services/api/task-center"; -import { updateCreationConversationSnapshot } from "../src/services/creation-conversation-store"; +import { removeCreationConversationSnapshot, updateCreationConversationSnapshot } from "../src/services/creation-conversation-store"; test("Create exposes one accessible copy action beside each displayed user prompt", async () => { const source = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text(); @@ -35,6 +35,17 @@ test("Create keeps optimistic messages when the first local task binds immediate expect(bound[0].messages[1]).toMatchObject({ id: "assistant-0001", taskIds: ["local:dreamina-cli:task-0001"] }); }); +test("Create history deletion removes only the selected conversation snapshot", () => { + const assets = [{ id: "asset-0001", storageKey: "resource:asset-0001" }]; + const conversations = [ + { id: "conversation-0001", messages: [{ id: "message-0001", resultUrls: ["resource:asset-0001"] }] }, + { id: "conversation-0002", messages: [{ id: "message-0002", attachments: [{ storageKey: "resource:asset-0001" }] }] }, + ]; + + expect(removeCreationConversationSnapshot(conversations, "conversation-0001")).toEqual([conversations[1]]); + expect(assets).toEqual([{ id: "asset-0001", storageKey: "resource:asset-0001" }]); +}); + test("Create refresh subscriptions share one durable scheduler observation without page polling", async () => { let queryCalls = 0; let waitCalls = 0; From f8cbcd1b6f54dfcb53e314f3a033df17b9361066 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 16 Aug 2026 22:27:29 +0800 Subject: [PATCH 15/25] =?UTF-8?q?fix(models):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=20-=20=E8=AF=86=E5=88=AB=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E8=83=BD=E5=8A=9B=E5=B9=B6=E6=8C=89=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E5=8F=91=E9=80=81=E5=88=86=E8=BE=A8=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/channel_model_catalog.go | 99 ++++++- .../desktop_local_channel_flow_test.go | 61 +++++ backend/internal/service/model_capability.go | 2 +- .../internal/service/model_capability_test.go | 19 ++ backend/internal/service/provider.go | 4 +- backend/internal/service/provider_test.go | 58 ++++ .../service/provider_video_options.go | 37 +++ web/package.json | 2 +- .../canvas/canvas-video-settings-popover.tsx | 8 +- web/src/components/video-settings-panel.tsx | 4 +- web/src/lib/channel-model-catalog.ts | 221 +++++++++++++++ web/src/lib/model-capabilities.ts | 16 ++ web/src/lib/model-protocols.ts | 6 +- web/src/pages/create/index.tsx | 7 +- .../pages/settings/channel-settings-pane.tsx | 30 +-- .../pages/settings/channel-video-pricing.tsx | 5 +- web/src/services/api/image.ts | 7 +- web/src/services/api/video.ts | 5 +- web/test/channel-model-catalog.test.ts | 252 ++++++++++++++++++ 19 files changed, 792 insertions(+), 51 deletions(-) create mode 100644 web/src/lib/channel-model-catalog.ts create mode 100644 web/test/channel-model-catalog.test.ts diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go index 6ac9ca5ab..31fad5b39 100644 --- a/backend/internal/service/channel_model_catalog.go +++ b/backend/internal/service/channel_model_catalog.go @@ -29,9 +29,28 @@ type channelModelsPayload struct { } type channelModelItem struct { - ID string `json:"id"` - Name string `json:"name"` - SupportedEndpointTypes []string `json:"supported_endpoint_types"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + ModelType string `json:"model_type"` + SupportedEndpointTypes []string `json:"supported_endpoint_types"` + DefaultParameters channelModelCatalogParameters `json:"default_parameters"` + Options channelModelCatalogOptions `json:"options"` + SupportsImages *bool `json:"supports_images"` + MinImages *int `json:"min_images"` + MaxImages *int `json:"max_images"` +} + +type channelModelCatalogParameters struct { + AspectRatio string `json:"aspect_ratio"` + DurationSeconds string `json:"duration_seconds"` + Resolution string `json:"resolution"` +} + +type channelModelCatalogOptions struct { + AspectRatio []ChannelModelCatalogOption `json:"aspect_ratio"` + DurationSeconds []ChannelModelCatalogOption `json:"duration_seconds"` + Resolution []ChannelModelCatalogOption `json:"resolution"` } func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) { @@ -54,10 +73,34 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp } // ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同; -// supportedEndpointTypes 由上游 /models 返回,前端据此推导能力与协议,不依赖模型名猜测。 +// 协议、能力和可选参数均来自上游公开元数据,不展开供应商内部兼容模型。 type ChannelModelCatalogItem struct { - ID string `json:"id"` - SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"` + ID string `json:"id"` + DisplayName string `json:"displayName,omitempty"` + ModelType string `json:"modelType,omitempty"` + SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"` + DefaultParameters ChannelModelCatalogDefaultParameters `json:"defaultParameters,omitempty"` + Options ChannelModelCatalogOptions `json:"options,omitempty"` + SupportsImages *bool `json:"supportsImages,omitempty"` + MinImages *int `json:"minImages,omitempty"` + MaxImages *int `json:"maxImages,omitempty"` +} + +type ChannelModelCatalogDefaultParameters struct { + AspectRatio string `json:"aspectRatio,omitempty"` + DurationSeconds string `json:"durationSeconds,omitempty"` + Resolution string `json:"resolution,omitempty"` +} + +type ChannelModelCatalogOptions struct { + AspectRatio []ChannelModelCatalogOption `json:"aspectRatio,omitempty"` + DurationSeconds []ChannelModelCatalogOption `json:"durationSeconds,omitempty"` + Resolution []ChannelModelCatalogOption `json:"resolution,omitempty"` +} + +type ChannelModelCatalogOption struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` } func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) { @@ -134,7 +177,25 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use continue } seen[name] = true - catalog = append(catalog, ChannelModelCatalogItem{ID: name, SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes)}) + catalog = append(catalog, ChannelModelCatalogItem{ + ID: name, + DisplayName: strings.TrimSpace(item.DisplayName), + ModelType: normalizeCatalogModelType(item.ModelType), + SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes), + DefaultParameters: ChannelModelCatalogDefaultParameters{ + AspectRatio: strings.TrimSpace(item.DefaultParameters.AspectRatio), + DurationSeconds: strings.TrimSpace(item.DefaultParameters.DurationSeconds), + Resolution: strings.TrimSpace(item.DefaultParameters.Resolution), + }, + Options: ChannelModelCatalogOptions{ + AspectRatio: normalizeCatalogOptions(item.Options.AspectRatio), + DurationSeconds: normalizeCatalogOptions(item.Options.DurationSeconds), + Resolution: normalizeCatalogOptions(item.Options.Resolution), + }, + SupportsImages: item.SupportsImages, + MinImages: item.MinImages, + MaxImages: item.MaxImages, + }) } sort.Slice(catalog, func(left int, right int) bool { return catalog[left].ID < catalog[right].ID @@ -142,6 +203,30 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use return catalog, nil } +func normalizeCatalogModelType(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "text", "image", "video", "audio": + return normalized + default: + return "" + } +} + +func normalizeCatalogOptions(options []ChannelModelCatalogOption) []ChannelModelCatalogOption { + seen := make(map[string]bool, len(options)) + normalized := make([]ChannelModelCatalogOption, 0, len(options)) + for _, option := range options { + value := strings.TrimSpace(option.Value) + if value == "" || seen[value] { + continue + } + seen[value] = true + normalized = append(normalized, ChannelModelCatalogOption{Value: value, Label: strings.TrimSpace(option.Label)}) + } + return normalized +} + func normalizeCatalogEndpointTypes(values []string) []string { seen := make(map[string]bool, len(values)) normalized := make([]string, 0, len(values)) diff --git a/backend/internal/service/desktop_local_channel_flow_test.go b/backend/internal/service/desktop_local_channel_flow_test.go index 39468f1f9..b3d5b8f58 100644 --- a/backend/internal/service/desktop_local_channel_flow_test.go +++ b/backend/internal/service/desktop_local_channel_flow_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -101,6 +102,66 @@ func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testi } } +func TestCustomModelCatalogPreservesPublicCapabilityMetadataWithoutCompatibilityIDs(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[ + {"id":"image-fast","display_name":"Image Fast","model_type":"image"}, + {"id":"image-quality","display_name":"Image Quality","model_type":"image"}, + {"id":"omni","capability_id":"omni-flash","display_name":"Omni Flash","model_type":"video","supports_images":false,"min_images":0,"max_images":0,"default_parameters":{"aspect_ratio":"16:9","duration_seconds":"10"},"options":{"aspect_ratio":[{"value":"16:9"},{"value":"9:16"}],"duration_seconds":[{"value":"8"},{"value":"10"}]},"compatibility_map":[{"parameters":{"aspect_ratio":"9:16","duration_seconds":"10"},"model_id":"omni_portrait_10s"}]}, + {"id":"veo-lite","display_name":"Veo Lite","model_type":"video"}, + {"id":"veo-fast","display_name":"Veo Fast","model_type":"video"}, + {"id":"veo-quality","display_name":"Veo Quality","model_type":"video"} + ]}`)) + })) + defer upstream.Close() + + svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}} + catalog, err := svc.FetchChannelModelCatalog(context.Background(), &model.User{ID: "user-1"}, ChannelModelsRequest{ + BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true, + }) + if err != nil { + t.Fatalf("FetchChannelModelCatalog() error = %v", err) + } + if len(catalog) != 6 { + t.Fatalf("catalog length = %d; want 6", len(catalog)) + } + var omni *ChannelModelCatalogItem + for index := range catalog { + if catalog[index].ID == "omni" { + omni = &catalog[index] + break + } + } + if omni == nil { + t.Fatal("catalog is missing public omni capability") + } + if omni.DisplayName != "Omni Flash" || omni.ModelType != "video" { + t.Fatalf("omni metadata = %#v", omni) + } + if omni.DefaultParameters.AspectRatio != "16:9" || omni.DefaultParameters.DurationSeconds != "10" { + t.Fatalf("omni defaults = %#v", omni.DefaultParameters) + } + if got := optionValues(omni.Options.DurationSeconds); len(got) != 2 || got[0] != "8" || got[1] != "10" { + t.Fatalf("omni duration options = %#v", got) + } + encoded, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "omni_portrait_10s") || strings.Contains(string(encoded), "compatibility") { + t.Fatalf("ordinary catalog exposed compatibility IDs: %s", encoded) + } +} + +func optionValues(options []ChannelModelCatalogOption) []string { + values := make([]string, 0, len(options)) + for _, option := range options { + values = append(values, option.Value) + } + return values +} + func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(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_capability.go b/backend/internal/service/model_capability.go index f48976315..c90eaed94 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -405,7 +405,7 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) { return BadAuthRequest("画面比例不在当前模型支持范围内") } - if input.Config.VQuality != "" && !containsCapabilityString(profile.Resolutions, normalizeResolution(input.Config.VQuality)) { + if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" { return BadAuthRequest("输出分辨率不在当前模型支持范围内") } operation := metadataString(input.Metadata, "videoEditOperation") diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 51057c318..7ac821b8f 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -97,3 +97,22 @@ func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) { } } } + +func TestValidateVideoTaskIgnoresGlobalResolutionWhenCatalogDeclaresNone(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi", "omni").Video + profile.Duration = VideoDurationConfig{Selection: "enum", Values: []int{8, 10}, Default: 10} + profile.Ratios = []string{"16:9", "9:16"} + profile.DefaultRatio = "16:9" + profile.Resolutions = nil + profile.DefaultResolution = "" + profile.References.MaxImages = 0 + profile.Operations = []string{"text_to_video"} + profile.DefaultOperation = "text_to_video" + + err := validateVideoTask(profile, canvasGenerationInput{ + Config: providerConfig{Model: "omni", VideoSeconds: "10", Size: "16:9", VQuality: "720"}, + }) + if err != nil { + t.Fatalf("validateVideoTask() error = %v", err) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 11866b29d..cf346e5e4 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -1420,7 +1420,9 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string] if size := normalizeVideoSize(input.Config.Size); size != "" { writeField(writer, "size", size) } - writeField(writer, "resolution_name", normalizeVideoResolution(input.Config.VQuality)) + if resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality); resolution != "" { + writeField(writer, "resolution_name", resolution) + } writeField(writer, "preset", "normal") if shouldSendNewAPIVideoImages(input) { for _, image := range input.ReferenceImages { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0ff88ef91..f14e1b872 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -640,6 +640,64 @@ func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) { } } +func TestRunVideoTaskSendsOnlyDeclaredResolutionName(t *testing.T) { + tests := []struct { + name string + resolutions []string + quality string + withoutProfile bool + want string + }{ + {name: "catalog omits resolution capability", quality: "720"}, + {name: "auto never invents 720p", resolutions: []string{"720p", "1080p"}, quality: "auto"}, + {name: "legacy auto never invents 720p", quality: "auto", withoutProfile: true}, + {name: "missing profile explicit 720 never invents resolution", quality: "720", withoutProfile: true}, + {name: "declared HD resolution", resolutions: []string{"1080p"}, quality: "1080", want: "1080p"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + var got string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "POST /v1/videos": + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm() error = %v", err) + } + got = r.FormValue("resolution_name") + _, _ = w.Write([]byte(`{"id":"video-resolution","status":"queued"}`)) + case "GET /v1/videos/video-resolution": + _, _ = w.Write([]byte(`{"id":"video-resolution","status":"completed"}`)) + case "GET /v1/videos/video-resolution/content": + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("video")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + profile := DefaultModelCapabilityConfigForModel("newapi", "public-video").Video + profile.Resolutions = test.resolutions + profile.DefaultResolution = "" + if test.withoutProfile { + profile = nil + } + _, err := runVideoTask(context.Background(), canvasGenerationInput{ + Prompt: "synthetic prompt", + Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "public-video", VideoSeconds: "8", Size: "16:9", VQuality: test.quality}, + VideoCapability: profile, + }) + if err != nil { + t.Fatalf("runVideoTask() error = %v", err) + } + if got != test.want { + t.Fatalf("resolution_name = %q; want %q", got, test.want) + } + }) + } +} + func TestRunVideoTaskUsesNestedURLBeforeResultURL(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go index f19b7e5b0..057cb31dc 100644 --- a/backend/internal/service/provider_video_options.go +++ b/backend/internal/service/provider_video_options.go @@ -169,6 +169,43 @@ func normalizeVideoResolution(value string) string { return value + "p" } +func videoResolutionNameRequest(profile *VideoCapabilityConfig, value string) string { + requested := strings.ToLower(strings.TrimSpace(value)) + if requested == "" || requested == "auto" || requested == "default" || requested == "medium" || requested == "high" { + return "" + } + if profile == nil { + return "" + } + if len(profile.Resolutions) == 0 { + return "" + } + candidates := []string{requested, strings.ToLower(normalizeVideoResolution(requested))} + if requested == "4k" { + candidates = append(candidates, "2160p") + } + if requested == "2160" || requested == "2160p" { + candidates = append(candidates, "4k") + } + for _, supported := range profile.Resolutions { + for _, candidate := range candidates { + if strings.EqualFold(strings.TrimSpace(supported), candidate) { + return strings.TrimSpace(supported) + } + } + } + return "" +} + +func isAutomaticVideoResolution(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "auto", "default", "medium", "high": + return true + default: + return false + } +} + func normalizeXAIVideoDuration(value string) int { duration, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || duration <= 0 { diff --git a/web/package.json b/web/package.json index 68de65b85..5485d69c3 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.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-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.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/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/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/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.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-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.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/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/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/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/canvas/canvas-video-settings-popover.tsx b/web/src/components/canvas/canvas-video-settings-popover.tsx index 77690a38a..e2b11169f 100644 --- a/web/src/components/canvas/canvas-video-settings-popover.tsx +++ b/web/src/components/canvas/canvas-video-settings-popover.tsx @@ -5,6 +5,7 @@ import { Button } from "antd"; import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel"; import { canvasThemes } from "@/lib/canvas-theme"; +import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { useThemeStore } from "@/stores/use-theme-store"; import type { AiConfig } from "@/stores/use-config-store"; @@ -21,7 +22,12 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass const panelRef = useRef(null); const [open, setOpen] = useState(false); const [buttonRect, setButtonRect] = useState(null); - const summary = `${videoResolutionLabel(config.vquality)} · ${videoSizeLabel(config.size)} · ${videoSecondsLabel(config.videoSeconds)}`; + const resolutionSupported = Boolean(modelCapabilityConfigFor(config, config.model).video?.resolutions.length); + const summary = [ + ...(resolutionSupported ? [videoResolutionLabel(config.vquality)] : []), + videoSizeLabel(config.size), + videoSecondsLabel(config.videoSeconds), + ].join(" · "); useEffect(() => { if (!open) return; diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 9000931a8..e653afaff 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -50,7 +50,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
    event.stopPropagation()}> {showTitle ?
    视频设置
    : null} - + {configuredResolutions.length ?
    {configuredResolutions.map((item) => ( onConfigChange("vquality", item.value)}> @@ -58,7 +58,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = ))}
    -
    +
    : null}
    updateDimension("width", value)} /> diff --git a/web/src/lib/channel-model-catalog.ts b/web/src/lib/channel-model-catalog.ts new file mode 100644 index 000000000..49b96bda6 --- /dev/null +++ b/web/src/lib/channel-model-catalog.ts @@ -0,0 +1,221 @@ +import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; +import { modelProtocolCapability, protocolForModelCatalog, type ModelProtocol } from "@/lib/model-protocols"; +import type { ModelChannel } from "@/stores/use-config-store"; + +export type ChannelModelCatalogOption = { value: string; label?: string }; + +export type ChannelModelCatalogItem = { + id: string; + displayName?: string; + modelType?: "text" | "image" | "video" | "audio"; + supportedEndpointTypes?: string[]; + defaultParameters?: { + aspectRatio?: string; + durationSeconds?: string; + resolution?: string; + }; + options?: { + aspectRatio?: ChannelModelCatalogOption[]; + durationSeconds?: ChannelModelCatalogOption[]; + resolution?: ChannelModelCatalogOption[]; + }; + supportsImages?: boolean; + minImages?: number; + maxImages?: number; +}; + +type ChannelModelCost = NonNullable[number]; + +export function sanitizeChannelModelCatalogItem(value: unknown): ChannelModelCatalogItem | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const id = stringValue(record.id); + if (!id) return null; + const modelType = stringValue(record.modelType).toLowerCase(); + const defaultParameters = objectValue(record.defaultParameters); + const options = objectValue(record.options); + const normalizedDefaults = defaultParameters + ? { + ...(stringValue(defaultParameters.aspectRatio) ? { aspectRatio: stringValue(defaultParameters.aspectRatio) } : {}), + ...(stringValue(defaultParameters.durationSeconds) ? { durationSeconds: stringValue(defaultParameters.durationSeconds) } : {}), + ...(stringValue(defaultParameters.resolution) ? { resolution: stringValue(defaultParameters.resolution) } : {}), + } + : undefined; + const normalizedOptions = options + ? { + ...(catalogOptions(options.aspectRatio).length ? { aspectRatio: catalogOptions(options.aspectRatio) } : {}), + ...(catalogOptions(options.durationSeconds).length ? { durationSeconds: catalogOptions(options.durationSeconds) } : {}), + ...(catalogOptions(options.resolution).length ? { resolution: catalogOptions(options.resolution) } : {}), + } + : undefined; + return compactCatalogItem({ + id, + displayName: stringValue(record.displayName), + modelType: ["text", "image", "video", "audio"].includes(modelType) ? (modelType as ChannelModelCatalogItem["modelType"]) : undefined, + supportedEndpointTypes: stringArray(record.supportedEndpointTypes), + defaultParameters: normalizedDefaults, + options: normalizedOptions, + supportsImages: typeof record.supportsImages === "boolean" ? record.supportsImages : undefined, + minImages: nonNegativeInteger(record.minImages), + maxImages: nonNegativeInteger(record.maxImages), + }); +} + +export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] { + const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost])); + const next: ChannelModelCost[] = []; + for (const item of catalog) { + const existing = existingByModel.get(item.id); + const catalogBackedCapability = hasCatalogCapabilityMetadata(item); + const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes, item.modelType); + if (existing && !catalogBackedCapability && !inferredProtocol) { + next.push({ + ...existing, + ...(item.displayName ? { displayName: item.displayName } : {}), + }); + continue; + } + const protocol = inferredProtocol || existing?.protocol || channel.interfaceType; + const capability = modelProtocolCapability(protocol) || item.modelType; + if (!protocol || !capability) continue; + const protocolChanged = Boolean(existing && inferredProtocol && existing.protocol !== inferredProtocol); + const replaceCapabilityConfig = !existing || catalogBackedCapability || protocolChanged; + const capabilityConfig = replaceCapabilityConfig && (capability === "image" || capability === "video") + ? (catalogBackedCapability ? catalogCapabilityConfig(item, protocol, capability) : defaultModelCapabilityConfig(protocol, item.id)) + : undefined; + if (existing) { + next.push({ + ...existing, + ...(item.displayName ? { displayName: item.displayName } : {}), + capability, + protocol, + ...(replaceCapabilityConfig ? { capabilityConfig } : {}), + }); + continue; + } + next.push({ + model: item.id, + ...(item.displayName ? { displayName: item.displayName } : {}), + capability, + protocol, + billingMode: "fixed_request", + unitPriceMicrocredits: 0, + ...(capabilityConfig ? { capabilityConfig } : {}), + }); + } + return next; +} + +function hasCatalogCapabilityMetadata(item: ChannelModelCatalogItem) { + return Boolean( + item.modelType || + item.defaultParameters || + item.options || + item.supportsImages !== undefined || + item.minImages !== undefined || + item.maxImages !== undefined, + ); +} + +function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol, capability: "image" | "video"): ModelCapabilityConfig { + const config = defaultModelCapabilityConfig(protocol, item.id); + if (capability === "image") { + if (item.supportsImages === false) config.image!.references.maxImages = 0; + else if (item.maxImages !== undefined) config.image!.references.maxImages = item.maxImages; + return config; + } + + const video = config.video!; + const durations = uniqueNumbers(optionValues(item.options?.durationSeconds)); + const defaultDuration = positiveNumber(item.defaultParameters?.durationSeconds); + if (durations.length) { + video.duration = { selection: "enum", values: durations, default: durations.includes(defaultDuration) ? defaultDuration : durations[0]! }; + } else if (defaultDuration > 0) { + video.duration = { selection: "enum", values: [defaultDuration], default: defaultDuration }; + } + const ratios = optionValues(item.options?.aspectRatio); + const defaultRatio = item.defaultParameters?.aspectRatio?.trim() || ""; + if (ratios.length) { + video.ratios = ratios; + video.defaultRatio = ratios.includes(defaultRatio) ? defaultRatio : ratios[0]!; + } else if (defaultRatio) { + video.ratios = [defaultRatio]; + video.defaultRatio = defaultRatio; + } + + // A catalog-backed video may only emit resolution_name when the provider + // explicitly advertises compatible values. An empty list means omit it. + const resolutions = optionValues(item.options?.resolution); + const defaultResolution = item.defaultParameters?.resolution?.trim() || ""; + video.resolutions = resolutions; + video.defaultResolution = resolutions.includes(defaultResolution) ? defaultResolution : resolutions[0] || ""; + + if (item.supportsImages === false) { + video.references.maxImages = 0; + video.operations = video.operations.filter((operation) => operation !== "image_to_video"); + video.defaultOperation = "text_to_video"; + } else if (item.maxImages !== undefined) { + video.references.maxImages = item.maxImages; + } + return config; +} + +function compactCatalogItem(item: ChannelModelCatalogItem): ChannelModelCatalogItem { + const defaultParameters = item.defaultParameters && Object.values(item.defaultParameters).some(Boolean) ? item.defaultParameters : undefined; + const options = item.options && Object.values(item.options).some((values) => values?.length) ? item.options : undefined; + return { + id: item.id, + ...(item.displayName ? { displayName: item.displayName } : {}), + ...(item.modelType ? { modelType: item.modelType } : {}), + ...(item.supportedEndpointTypes?.length ? { supportedEndpointTypes: item.supportedEndpointTypes } : {}), + ...(defaultParameters ? { defaultParameters } : {}), + ...(options ? { options } : {}), + ...(item.supportsImages !== undefined ? { supportsImages: item.supportsImages } : {}), + ...(item.minImages !== undefined ? { minImages: item.minImages } : {}), + ...(item.maxImages !== undefined ? { maxImages: item.maxImages } : {}), + }; +} + +function objectValue(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function stringArray(value: unknown) { + return Array.isArray(value) ? Array.from(new Set(value.map(stringValue).filter(Boolean))) : []; +} + +function catalogOptions(value: unknown): ChannelModelCatalogOption[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const options: ChannelModelCatalogOption[] = []; + for (const raw of value) { + const record = objectValue(raw); + const optionValue = stringValue(record?.value); + if (!optionValue || seen.has(optionValue)) continue; + seen.add(optionValue); + const label = stringValue(record?.label); + options.push({ value: optionValue, ...(label ? { label } : {}) }); + } + return options; +} + +function optionValues(options: ChannelModelCatalogOption[] | undefined) { + return Array.from(new Set((options || []).map((option) => option.value.trim()).filter(Boolean))); +} + +function positiveNumber(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function uniqueNumbers(values: string[]) { + return Array.from(new Set(values.map(positiveNumber).filter((value) => value > 0))); +} + +function nonNegativeInteger(value: unknown) { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined; +} diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 0f5a8390e..dbbe125f8 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -228,6 +228,22 @@ export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { sec return { seconds: String(duration), ratio, resolution }; } +export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) { + const requested = String(value || "").trim().toLowerCase(); + if (!requested || requested === "auto" || requested === "default" || requested === "medium" || requested === "high") return undefined; + const candidates = [requested]; + if (/^\d+$/.test(requested)) candidates.push(`${requested}p`); + if (requested === "low") candidates.push("480p"); + if (requested === "4k") candidates.push("2160p"); + if (requested === "2160" || requested === "2160p") candidates.push("4k"); + const supported = new Map(profile.resolutions.map((resolution) => [resolution.trim().toLowerCase(), resolution.trim()])); + for (const candidate of candidates) { + const match = supported.get(candidate); + if (match) return match; + } + return undefined; +} + function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) { const min = profile.duration.min || 1; const max = profile.duration.max || min; diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts index 867082a97..7c53d9edf 100644 --- a/web/src/lib/model-protocols.ts +++ b/web/src/lib/model-protocols.ts @@ -92,7 +92,7 @@ export function modelProtocolSupportsTokenBilling(capability?: string, protocol? return capability === "text" || (capability === "video" && protocol === "volcengine-ark-video"); } -export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProtocol | undefined { +export function protocolForModelCatalog(endpointTypes: string[] = [], modelType?: string): ModelProtocol | undefined { const normalized = new Set(endpointTypes.map((value) => value.trim().toLowerCase())); if (normalized.has("openai-chat") || normalized.has("chat-completion") || normalized.has("chat")) return "chat-completion"; if (normalized.has("openai-response") || normalized.has("responses")) return "openai-response"; @@ -102,6 +102,10 @@ export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProt if (normalized.has("grok-image")) return "grok-image"; if (normalized.has("gemini-veo") || normalized.has("gemini-video") || normalized.has("veo")) return "gemini-veo"; if (normalized.has("volcengine-ark-video") || normalized.has("volcengine-ark")) return "volcengine-ark-video"; + if (modelType?.trim().toLowerCase() === "video") return "newapi"; + if (modelType?.trim().toLowerCase() === "image") return "openai-image"; + if (modelType?.trim().toLowerCase() === "audio") return "openai-audio"; + if (modelType?.trim().toLowerCase() === "text") return "chat-completion"; return undefined; } diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 5b61ca8c7..4e43be9ac 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1153,15 +1153,16 @@ function GenerationSettingsMenu(props: ComposerProps) { const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size; if (nextSize) props.setRatio(nextSize); }; + const videoResolutionSupported = props.mode === "video" && resolutions.length > 0; const imageSummary = [ ...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); - const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary; + const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary; const panel =
    {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
    {ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
    {props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
    : null} - {props.mode === "video" ?
    {resolutions.map((option) => )}
    : <> + {props.mode === "video" ? (videoResolutionSupported ?
    {resolutions.map((option) => )}
    : null) : <> {imageResolutionChoiceOptions.length ?
    {imageResolutionChoiceOptions.map((choice) => )}
    : null} {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
    {activeQualityOptions.map((option) => )}
    : null} {props.imageProfile.maxOutputs > 1 ?
    {countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
    : null} @@ -1238,7 +1239,7 @@ function thinkingFor(mode: CreationMode): CreationThinking { } function directorNoteFor(mode: CreationMode, settings: CreationSettings): string { - if (mode === "video") return `已按 ${settings.seconds}s · ${videoResolutionLabel(settings.videoQuality)} · ${settings.ratio} 渲染这一镜,等待你的下一句指令。`; + if (mode === "video") return `已按 ${[`${settings.seconds}s`, ...(settings.videoQuality ? [videoResolutionLabel(settings.videoQuality)] : []), settings.ratio].join(" · ")} 渲染这一镜,等待你的下一句指令。`; if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`; return ""; } diff --git a/web/src/pages/settings/channel-settings-pane.tsx b/web/src/pages/settings/channel-settings-pane.tsx index 2825cde79..0f8f24cc0 100644 --- a/web/src/pages/settings/channel-settings-pane.tsx +++ b/web/src/pages/settings/channel-settings-pane.tsx @@ -4,10 +4,9 @@ import { useState } from "react"; import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor"; import { WorkspaceState } from "@/components/layout/workspace-state"; -import { defaultModelCapabilityConfig } from "@/lib/model-capabilities"; -import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols"; +import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog"; import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel"; -import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image"; +import { fetchChannelModels } from "@/services/api/image"; import { createModelChannel, defaultBaseUrlForApiFormat, @@ -21,8 +20,6 @@ import { ChannelModelSettings } from "./channel-video-pricing"; import { useUserStore } from "@/stores/use-user-store"; type UserChannelConnection = "openai" | "gemini"; -type ChannelModelCost = NonNullable[number]; - export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) { const { message } = App.useApp(); const config = useConfigStore((state) => state.config); @@ -108,7 +105,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void return; } updateChannels( - latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)), + latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedChannelModelCosts(item, result.catalog) } : item)), latestConfig, ); message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`); @@ -155,7 +152,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void updateChannels( latestConfig.channels.map((channel) => { const fetched = resultMap.get(channel.id); - return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel; + return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedChannelModelCosts(channel, fetched.catalog) } : channel; }), latestConfig, ); @@ -310,25 +307,6 @@ function normalizeDefaultModel(value: string, options: string[]) { return options.includes(value) ? value : options[0] || ""; } -function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] { - const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost])); - const next: ChannelModelCost[] = []; - for (const item of catalog) { - const existing = existingByModel.get(item.id); - const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes); - if (existing) { - const inferredCapability = modelProtocolCapability(inferredProtocol); - next.push(inferredProtocol && existing.protocol !== inferredProtocol ? { ...existing, protocol: inferredProtocol, capability: inferredCapability || existing.capability, capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined } : existing); - continue; - } - const catalogProtocol = inferredProtocol || channel.interfaceType; - const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined; - if (!catalogProtocol || !catalogCapability) continue; - next.push({ model: item.id, capability: catalogCapability, protocol: catalogProtocol, billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined }); - } - return next; -} - function uniqueModels(models: string[]) { return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean))); } diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index 39086fe15..ea835247c 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -64,14 +64,15 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan const protocol = cost?.protocol || defaultProtocolForModel(channel, model); const capability = cost?.capability || modelProtocolCapability(protocol) || "text"; const billingMode = cost?.billingMode || "fixed_request"; + const displayName = cost?.displayName?.trim() || model; return (
    -
    - {model} +
    + {displayName}
    diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index df4eaed3c..7c2348198 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store"; +import { sanitizeChannelModelCatalogItem, type ChannelModelCatalogItem } from "@/lib/channel-model-catalog"; import { nanoid } from "nanoid"; import { dataUrlToFile } from "@/lib/image-utils"; import { buildImageReferencePromptText } from "@/lib/image-reference-prompt"; @@ -1139,8 +1140,6 @@ export async function fetchImageModels(config: Pick { @@ -1167,8 +1166,8 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal } const catalog = new Map(); for (const item of response.data.data?.models || []) { - const entry = typeof item === "string" ? { id: item.trim() } : { id: String(item.id || "").trim(), supportedEndpointTypes: Array.isArray(item.supportedEndpointTypes) ? item.supportedEndpointTypes : undefined }; - if (!entry.id) continue; + const entry = typeof item === "string" ? sanitizeChannelModelCatalogItem({ id: item }) : sanitizeChannelModelCatalogItem(item); + if (!entry) continue; const existing = catalog.get(entry.id); catalog.set(entry.id, existing || entry); } diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index e792a8a79..30417c405 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -6,7 +6,7 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil import { getResourceOSSUrl } from "@/services/api/resources"; import { channelRequest } from "@/services/api/custom-channel-relay"; import { imageToDataUrl } from "@/services/image-storage"; -import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities"; +import { modelCapabilityConfigFor, videoDurationAllowed, videoResolutionRequest } from "@/lib/model-capabilities"; import { boolConfig, buildSeedancePromptText, isArkPlanBaseUrl, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video"; import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; @@ -356,7 +356,8 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr body.append("prompt", prompt); body.append("seconds", normalizeVideoSeconds(config.videoSeconds)); if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!); - body.append("resolution_name", normalizeVideoResolution(config.vquality)); + const resolution = videoResolutionRequest(modelCapabilityConfigFor(config, model).video!, config.vquality); + if (resolution) body.append("resolution_name", resolution); body.append("preset", "normal"); const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) }))); files.forEach((file) => body.append("input_reference[]", file)); diff --git a/web/test/channel-model-catalog.test.ts b/web/test/channel-model-catalog.test.ts new file mode 100644 index 000000000..a4cd11ac1 --- /dev/null +++ b/web/test/channel-model-catalog.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { App } from "antd"; +import axios from "axios"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover"; +import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog"; +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing"; +import { fetchChannelModels } from "../src/services/api/image"; +import { createVideoGenerationTask } from "../src/services/api/video"; +import { createModelChannel, defaultConfig, modelDisplayName, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "../src/stores/use-config-store"; + +const originalAxiosPost = axios.post; + +afterEach(() => { + axios.post = originalAxiosPost; +}); + +const omniCatalog: ChannelModelCatalogItem = { + id: "omni", + displayName: "Omni Flash", + modelType: "video", + supportsImages: false, + minImages: 0, + maxImages: 0, + defaultParameters: { aspectRatio: "16:9", durationSeconds: "10" }, + options: { + aspectRatio: [{ value: "16:9" }, { value: "9:16" }], + durationSeconds: [{ value: "8" }, { value: "10" }], + }, +}; + +function configForCatalog(catalog: ChannelModelCatalogItem[], input: Partial = {}) { + const channel = createModelChannel({ + id: "flow", + name: "Flow2API", + baseUrl: "https://flow.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + models: catalog.map((item) => item.id), + }); + const configured = { ...channel, modelCosts: mergeFetchedChannelModelCosts(channel, catalog) }; + const normalized = normalizeConfigSnapshot({ + config: { + ...defaultConfig, + ...input, + channels: [configured], + model: `flow::${catalog[0]!.id}`, + videoModel: `flow::${catalog[0]!.id}`, + }, + }).config; + return input.vquality ? { ...normalized, vquality: input.vquality } : normalized; +} + +function formEntries(body: unknown) { + expect(body).toBeInstanceOf(FormData); + return Object.fromEntries(Array.from((body as FormData).entries()).filter((entry): entry is [string, string] => typeof entry[1] === "string")); +} + +describe("public channel model catalog", () => { + test("preserves six public capabilities without expanding compatibility IDs", async () => { + axios.post = (async () => ({ + data: { + code: 0, + data: { + models: [ + { id: "image-fast", displayName: "Image Fast", modelType: "image" }, + { id: "image-quality", displayName: "Image Quality", modelType: "image" }, + { ...omniCatalog, compatibilityMap: [{ modelId: "omni_portrait_10s" }] }, + { id: "veo-lite", displayName: "Veo Lite", modelType: "video" }, + { id: "veo-fast", displayName: "Veo Fast", modelType: "video" }, + { id: "veo-quality", displayName: "Veo Quality", modelType: "video" }, + ], + }, + }, + })) as typeof axios.post; + const channel = createModelChannel({ baseUrl: "https://flow.example", apiKey: "synthetic-test-key", models: [] }); + + const result = await fetchChannelModels(channel, true); + + expect(result.models).toHaveLength(6); + expect(result.models).toContain("omni"); + expect(result.models).not.toContain("omni_portrait_10s"); + expect(result.catalog.find((item) => item.id === "omni")).toEqual(omniCatalog); + expect(result.catalog.find((item) => item.id === "omni")).not.toHaveProperty("compatibilityMap"); + }); + + test("maps Omni metadata to friendly video/NewAPI configuration with 8/10 seconds and both ratios", () => { + const config = configForCatalog([omniCatalog]); + const model = "flow::omni"; + const cost = config.channels[0]!.modelCosts![0]!; + + expect(modelDisplayName(config, model)).toBe("Omni Flash"); + expect(selectableModelsByCapability(config, "video")).toEqual([model]); + expect(selectableModelsByCapability(config, "text")).toEqual([]); + expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi"); + expect(cost.capabilityConfig?.video).toMatchObject({ + duration: { selection: "enum", values: [8, 10], default: 10 }, + ratios: ["16:9", "9:16"], + defaultRatio: "16:9", + resolutions: [], + defaultResolution: "", + }); + }); + + test("preserves a manually configured capability profile when a catalog only returns an ID", () => { + const channel = createModelChannel({ + id: "manual", + name: "Manual", + baseUrl: "https://manual.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + models: ["manual-video"], + }); + const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video"); + capabilityConfig.video!.resolutions = ["1440p"]; + capabilityConfig.video!.defaultResolution = "1440p"; + channel.modelCosts = [{ model: "manual-video", displayName: "Manual Video", capability: "video", protocol: "newapi", billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig }]; + + const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video" }]); + + expect(costs[0]?.capabilityConfig).toEqual(capabilityConfig); + expect(costs[0]?.displayName).toBe("Manual Video"); + }); + + test("preserves an ID-only manual model cost when the per-model protocol is missing", () => { + const channel = createModelChannel({ + id: "manual-text-channel", + name: "Manual Text Channel", + baseUrl: "https://manual.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + interfaceType: "chat-completion", + models: ["manual-video"], + }); + const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video"); + capabilityConfig.video!.resolutions = ["1440p"]; + capabilityConfig.video!.defaultResolution = "1440p"; + const existing = { + model: "manual-video", + displayName: "Manual Video", + capability: "video" as const, + billingMode: "per_second" as const, + unitPriceMicrocredits: 4321, + inputTokenPriceMicrocredits: 11, + outputTokenPriceMicrocredits: 22, + cachedTokenPriceMicrocredits: 3, + capabilityConfig, + }; + channel.modelCosts = [existing]; + + const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]); + + expect(costs[0]).toEqual({ ...existing, displayName: "Catalog Video" }); + }); + + test("preserves an ID-only manual video model cost when both protocol sources are missing", () => { + const channel = createModelChannel({ + id: "manual-no-protocol", + name: "Manual No Protocol", + baseUrl: "https://manual.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + models: ["manual-video"], + }); + const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video"); + capabilityConfig.video!.resolutions = ["1440p"]; + capabilityConfig.video!.defaultResolution = "1440p"; + const existing = { + model: "manual-video", + displayName: "Manual Video", + capability: "video" as const, + billingMode: "per_second" as const, + unitPriceMicrocredits: 8765, + inputTokenPriceMicrocredits: 31, + outputTokenPriceMicrocredits: 47, + cachedTokenPriceMicrocredits: 5, + capabilityConfig, + }; + channel.modelCosts = [existing]; + + const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]); + + expect(costs).toEqual([{ ...existing, displayName: "Catalog Video" }]); + }); + + test("does not show a synthetic 720P label in the Canvas summary when the model declares no resolutions", () => { + const config = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" }); + + const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined })); + + expect(html).toContain("横屏 · 10s"); + expect(html).not.toContain("720P"); + }); + + test("shows the public display name in channel model settings instead of only the internal ID", () => { + const config = configForCatalog([omniCatalog]); + + const html = renderToStaticMarkup(React.createElement(App, null, React.createElement(ChannelModelSettings, { channel: config.channels[0]!, onChange: () => undefined }))); + + expect(html).toContain("Omni Flash"); + }); + + test("omits resolution_name for Omni and for auto instead of inventing 720p", async () => { + const bodies: Record[] = []; + axios.post = (async (_url: string, body: unknown) => { + bodies.push(formEntries(body)); + return { data: { id: `synthetic-${bodies.length}` } }; + }) as typeof axios.post; + + const omniConfig = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" }); + await createVideoGenerationTask(omniConfig, "synthetic prompt"); + + const declaredResolution: ChannelModelCatalogItem = { + ...omniCatalog, + id: "veo-public", + displayName: "Veo Public", + defaultParameters: { ...omniCatalog.defaultParameters, durationSeconds: "8", resolution: "1080p" }, + options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "720p" }, { value: "1080p" }] }, + }; + const autoConfig = configForCatalog([declaredResolution], { videoSeconds: "8", size: "16:9", vquality: "auto" }); + await createVideoGenerationTask(autoConfig, "synthetic prompt"); + + expect(bodies[0]).not.toHaveProperty("resolution_name"); + expect(bodies[1]).not.toHaveProperty("resolution_name"); + }); + + test("sends a declared compatible HD resolution for a video capability", async () => { + let body: Record = {}; + axios.post = (async (_url: string, requestBody: unknown) => { + body = formEntries(requestBody); + return { data: { id: "synthetic-hd" } }; + }) as typeof axios.post; + const catalog: ChannelModelCatalogItem = { + ...omniCatalog, + id: "veo-public", + displayName: "Veo Public", + defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "1080p" }, + options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "1080p" }] }, + }; + const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "1080" }); + + await createVideoGenerationTask(config, "synthetic prompt"); + + expect(body.resolution_name).toBe("1080p"); + expect(body.model).toBe("veo-public"); + expect(body.seconds).toBe("8"); + expect(body.size).toBe("1280x720"); + }); +}); From 9661123b544692ebe72ff609f580ae7e93f80aee Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 16 Aug 2026 23:00:26 +0800 Subject: [PATCH 16/25] =?UTF-8?q?fix(web):=20=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20-=20=E7=A6=81=E6=AD=A2=E7=AB=AF=E7=82=B9=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BC=AA=E9=80=A0=E8=A7=86=E9=A2=91=E5=88=86?= =?UTF-8?q?=E8=BE=A8=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/lib/channel-model-catalog.ts | 13 ++----------- web/test/channel-model-catalog.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/web/src/lib/channel-model-catalog.ts b/web/src/lib/channel-model-catalog.ts index 49b96bda6..d79da97ff 100644 --- a/web/src/lib/channel-model-catalog.ts +++ b/web/src/lib/channel-model-catalog.ts @@ -80,9 +80,7 @@ export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: Ch if (!protocol || !capability) continue; const protocolChanged = Boolean(existing && inferredProtocol && existing.protocol !== inferredProtocol); const replaceCapabilityConfig = !existing || catalogBackedCapability || protocolChanged; - const capabilityConfig = replaceCapabilityConfig && (capability === "image" || capability === "video") - ? (catalogBackedCapability ? catalogCapabilityConfig(item, protocol, capability) : defaultModelCapabilityConfig(protocol, item.id)) - : undefined; + const capabilityConfig = replaceCapabilityConfig && (capability === "image" || capability === "video") ? (catalogBackedCapability ? catalogCapabilityConfig(item, protocol, capability) : defaultModelCapabilityConfig(protocol, item.id)) : undefined; if (existing) { next.push({ ...existing, @@ -107,14 +105,7 @@ export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: Ch } function hasCatalogCapabilityMetadata(item: ChannelModelCatalogItem) { - return Boolean( - item.modelType || - item.defaultParameters || - item.options || - item.supportsImages !== undefined || - item.minImages !== undefined || - item.maxImages !== undefined, - ); + return Boolean(item.modelType || item.supportedEndpointTypes?.length || item.defaultParameters || item.options || item.supportsImages !== undefined || item.minImages !== undefined || item.maxImages !== undefined); } function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol, capability: "image" | "video"): ModelCapabilityConfig { diff --git a/web/test/channel-model-catalog.test.ts b/web/test/channel-model-catalog.test.ts index a4cd11ac1..1e2faba33 100644 --- a/web/test/channel-model-catalog.test.ts +++ b/web/test/channel-model-catalog.test.ts @@ -105,6 +105,30 @@ describe("public channel model catalog", () => { }); }); + test("treats endpoint-only video metadata as authoritative without inventing 720P", async () => { + const catalog: ChannelModelCatalogItem = { + id: "endpoint-video", + supportedEndpointTypes: ["openai-video"], + }; + const config = configForCatalog([catalog], { videoSeconds: "6", size: "16:9", vquality: "720" }); + const model = "flow::endpoint-video"; + const cost = config.channels[0]!.modelCosts![0]!; + + expect(cost.capability).toBe("video"); + expect(cost.protocol).toBe("newapi-channel-2"); + expect(cost.capabilityConfig?.video?.resolutions).toEqual([]); + expect(cost.capabilityConfig?.video?.defaultResolution).toBe(""); + expect(selectableModelsByCapability(config, "video")).toEqual([model]); + expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi-channel-2"); + + const canvasHtml = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined })); + expect(canvasHtml).not.toContain("720P"); + + const createSource = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text(); + expect(createSource).toContain('const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;'); + expect(createSource).toContain("...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])"); + }); + test("preserves a manually configured capability profile when a catalog only returns an ID", () => { const channel = createModelChannel({ id: "manual", From 42855008028ad2dc1b6535f84fe10f08cf9a0b25 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:50:55 +0800 Subject: [PATCH 17/25] =?UTF-8?q?fix(ui):=20=E4=BB=BB=E5=8A=A1=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=20-=20=E4=BF=AE=E5=A4=8D=E7=AB=96=E5=9B=BE=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E6=BA=A2=E5=87=BA=20(#240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/styles/globals.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index fed2644a3..4bdf3a752 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -4040,8 +4040,9 @@ .task-record-dot.is-queued { background: var(--palette-status-loading); } .task-record-dot.is-failed { background: var(--palette-status-error); } .task-record-dot.is-idle { background: color-mix(in srgb, var(--foreground) 30%, transparent); } - .task-record-thumb { position: relative; display: grid; width: 84px; height: 60px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 28%, transparent); } - .task-record-thumb img, .task-record-thumb video { width: 100%; height: 100%; object-fit: cover; } + .task-record-thumb { position: relative; display: grid; width: 84px; height: 60px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); padding: 0; color: color-mix(in srgb, var(--foreground) 28%, transparent); } + /* 媒体脱离 Grid 固有尺寸计算,避免竖图按原始宽高比把固定缩略图撑出任务行。 */ + .task-record-thumb img, .task-record-thumb video { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; } .task-record-meta { display: flex; min-width: 0; align-items: center; gap: 8px; margin-top: 7px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); white-space: nowrap; } .task-record-meta-canvas { display: inline-flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; text-overflow: ellipsis; } .task-record-meta-canvas svg { flex: 0 0 auto; opacity: .7; } From d5e22a8c493194045464b81470b1d999c310d446 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:51:11 +0800 Subject: [PATCH 18/25] =?UTF-8?q?fix(provider):=20=E7=81=AB=E5=B1=B1?= =?UTF-8?q?=E6=96=B9=E8=88=9F=E5=9B=BE=E7=89=87=20-=20=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=B0=B4=E5=8D=B0=E5=B9=B6=E6=8C=81=E4=B9=85=E5=8C=96=20CDN=20?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=20(#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + backend/internal/service/provider.go | 39 ++++++++++++++++++++--- backend/internal/service/provider_test.go | 37 +++++++++++++++++++++ web/src/services/api/image.ts | 2 ++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44da3ea67..3fd85ead4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- 火山方舟 Seedream 图片生成显式关闭“AI 生成”水印,并由后端下载方舟 CDN 结果后写入服务器或对象存储,恢复素材库自动入库。 - 项目开源协议由 AGPL-3.0 调整为 MIT,保留上游及项目贡献者版权声明。 - 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。 diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 11866b29d..2392dbf2c 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -881,18 +881,49 @@ func runVolcengineArkImageTask(ctx context.Context, input canvasGenerationInput) if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil { return nil, err } - images, err := imageDataURLs(payload) + images, err := volcengineArkImageDataURLs(ctx, input.Config, payload) if err != nil { return nil, err } return map[string]interface{}{"mode": "image", "images": images}, nil } +func volcengineArkImageDataURLs(ctx context.Context, config providerConfig, payload imageResponse) ([]map[string]string, error) { + images, err := imageDataURLs(payload) + if err != nil { + return nil, err + } + for _, image := range images { + value := strings.TrimSpace(image["dataUrl"]) + if strings.HasPrefix(value, "data:image/") { + continue + } + if !isPublicMediaURL(value) { + return nil, errors.New("火山方舟图片接口没有返回可下载的图片") + } + // 方舟默认返回临时 CDN 地址。必须由后端下载成内联结果,后续资源持久化才能 + // 原子地写入服务器或用户配置的对象存储,且不依赖浏览器跨域访问方舟 CDN。 + data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value) + if err != nil { + return nil, fmt.Errorf("火山方舟图片结果下载失败:%w", err) + } + detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) + mimeType = strings.ToLower(normalizedMediaMimeType(mimeType, data)) + if len(data) == 0 || strings.Contains(detected, "json") || strings.HasPrefix(detected, "text/") || !strings.HasPrefix(mimeType, "image/") { + return nil, fmt.Errorf("火山方舟图片结果无效:%s", defaultString(detected, mimeType)) + } + image["dataUrl"] = dataURL(mimeType, data) + image["mimeType"] = mimeType + } + return images, nil +} + func volcengineArkImageBody(input canvasGenerationInput) (map[string]interface{}, error) { body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": withSystemPrompt(input.Config, input.Prompt), - "n": 1, + "model": input.Config.Model, + "prompt": withSystemPrompt(input.Config, input.Prompt), + "n": 1, + "watermark": false, } if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { if key == "size" { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0ff88ef91..48ae5dee6 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -151,6 +151,9 @@ func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) if body["prompt"] != "keep the subject\n\ncombine the references" { t.Fatalf("prompt = %q", body["prompt"]) } + if watermark, ok := body["watermark"].(bool); !ok || watermark { + t.Fatalf("watermark = %#v, want false", body["watermark"]) + } size, _ := body["size"].(string) parts := strings.Split(size, "x") if len(parts) != 2 { @@ -184,6 +187,40 @@ func TestVolcengineArkImageBodyUpscalesPresetBelowMinimumPixels(t *testing.T) { } } +func TestVolcengineArkImageDataURLsDownloadsRemoteResult(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + imageBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + })) + defer server.Close() + + images, err := volcengineArkImageDataURLs(context.Background(), providerConfig{}, imageResponse{ + Data: []map[string]interface{}{{"url": server.URL + "/generated.png"}}, + }) + if err != nil { + t.Fatalf("volcengineArkImageDataURLs() error = %v", err) + } + if len(images) != 1 || !strings.HasPrefix(images[0]["dataUrl"], "data:image/png;base64,") { + t.Fatalf("images = %#v", images) + } + + svc := newResourceTestService(t) + stored, err := svc.persistGeneratedMediaResult("user-1", map[string]interface{}{"mode": "image", "images": images}) + if err != nil { + t.Fatalf("persistGeneratedMediaResult() error = %v", err) + } + storedImages, ok := stored["images"].([]interface{}) + if !ok || len(storedImages) != 1 { + t.Fatalf("stored images = %#v", stored["images"]) + } + storedImage, ok := storedImages[0].(map[string]interface{}) + if !ok || !strings.HasPrefix(stringField(storedImage, "storageKey"), "resource:") || !strings.HasPrefix(stringField(storedImage, "dataUrl"), "/api/resources/") { + t.Fatalf("stored image = %#v", storedImages[0]) + } +} + func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) { _, err := runImageTask(context.Background(), canvasGenerationInput{ Prompt: "edit only the masked area", diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index df4eaed3c..be199f220 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -886,6 +886,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option model: requestConfig.model, prompt: withSystemPrompt(requestConfig, prompt), n, + watermark: false, ...(normalizedRequestSize ? { [normalizedRequestSize.parameter]: normalizedRequestSize.value } : {}), } : { @@ -980,6 +981,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: model: requestConfig.model, prompt: withSystemPrompt(requestConfig, requestPrompt), n, + watermark: false, ...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}), ...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}), }, From 829488a32fb84d69f574ac534c8354214f573d8b Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Mon, 17 Aug 2026 09:10:16 +0800 Subject: [PATCH 19/25] =?UTF-8?q?docs(platform):=20=E5=9B=A2=E9=98=9F?= =?UTF-8?q?=E6=88=90=E5=91=98=20-=20=E5=A2=9E=E5=8A=A0=20Bowen=20=E4=B8=8E?= =?UTF-8?q?=20ken=20=E8=B4=A1=E7=8C=AE=E8=80=85=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ assets/user-bowen.jpg | Bin 0 -> 19394 bytes assets/user-ken.jpg | Bin 0 -> 29455 bytes 3 files changed, 2 insertions(+) create mode 100644 assets/user-bowen.jpg create mode 100644 assets/user-ken.jpg diff --git a/README.md b/README.md index 330ed7d70..7bbeb744d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ | 奶大佬 | 奶大佬 | [1304634970@qq.com](mailto:1304634970@qq.com) | 人生就是要不断的探索 | | dyh | dyh | [1613203335@qq.com](mailto:1613203335@qq.com) | 无 | | kyori | kyori | [1771634408@qq.com](mailto:1771634408@qq.com) | 励志成为未来最好用的画布仓库的贡献者 | +| Bowen | Bowen | [admin@bowen.games](mailto:admin@bowen.games) | 剑走偏峰,雷厉风行。 | +| ken | ken | [2506802@qq.com](mailto:2506802@qq.com) | 走自己的路 | ## 主要功能 diff --git a/assets/user-bowen.jpg b/assets/user-bowen.jpg new file mode 100644 index 0000000000000000000000000000000000000000..89037e3ae8794a7a9602317d34dd1ce9cd3b2748 GIT binary patch literal 19394 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 W0000)TwN2B^oq-c7lmsQ#S4%is?hEL literal 0 HcmV?d00001 diff --git a/assets/user-ken.jpg b/assets/user-ken.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ed147ea25030953ca26bd4b7c11e9c92e5b1aa76 GIT binary patch literal 29455 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 CEF`o5 literal 0 HcmV?d00001 From 58f41e83cf8c6d188f933c5f0e6f6d602fe45957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E7=AC=91=E7=9A=84=E6=AF=9B=E6=AF=9B=E8=99=AB?= <315515767@qq.com> Date: Mon, 17 Aug 2026 09:27:39 +0800 Subject: [PATCH 20/25] =?UTF-8?q?refactor(canvas):=20=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=A0=8F=20-=20=E6=94=B6=E6=95=9B=20Dock=20?= =?UTF-8?q?=E6=94=BE=E5=A4=A7=E4=B8=8E=E4=BA=A4=E4=BA=92=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留当前画布视觉体系,统一 Dock 按钮形态、状态反馈与子菜单展开关系。\n\nCloses #244 --- web/src/components/canvas/canvas-toolbar.tsx | 8 ++-- .../ui/aceternity/floating-dock.tsx | 8 ++-- web/src/styles/globals.css | 47 +++---------------- 3 files changed, 16 insertions(+), 47 deletions(-) diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx index a538a261c..63f336e8f 100644 --- a/web/src/components/canvas/canvas-toolbar.tsx +++ b/web/src/components/canvas/canvas-toolbar.tsx @@ -192,8 +192,8 @@ export function CanvasToolbar({ {appearanceOpen ? ( - - event.stopPropagation()}> + + event.stopPropagation()}> } title="画布外观" subtitle="调整整个创作空间" theme={theme} />
    主题模式
    @@ -231,8 +231,8 @@ function AddNodeMenu({ x, theme, commands }: { commands: CanvasCreateCommand[]; }) { return ( - - event.stopPropagation()}> + + event.stopPropagation()}> diff --git a/web/src/components/ui/aceternity/floating-dock.tsx b/web/src/components/ui/aceternity/floating-dock.tsx index ad7f62878..820c0f429 100644 --- a/web/src/components/ui/aceternity/floating-dock.tsx +++ b/web/src/components/ui/aceternity/floating-dock.tsx @@ -40,9 +40,11 @@ type DockMetrics = { distance: number; }; +// magnification 收敛:base 略增以适配圆角方块,magnified 幅度从 +14 降到 +8, +// 避免 dock 整体跳动,同时保留 Aceternity 接近放大身份。 const DOCK_METRICS: Record, DockMetrics> = { - default: { base: 29, magnified: 43, icon: 14, iconMagnified: 19, distance: 108 }, - compact: { base: 25, magnified: 35, icon: 13, iconMagnified: 17, distance: 88 }, + default: { base: 30, magnified: 38, icon: 15, iconMagnified: 18, distance: 100 }, + compact: { base: 26, magnified: 32, icon: 13, iconMagnified: 16, distance: 84 }, }; const TOUCH_DOCK_METRICS: Record, DockMetrics> = { @@ -228,7 +230,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s aria-expanded={command.expands ? command.active || undefined : undefined} aria-pressed={command.expands ? undefined : command.active || undefined} disabled={command.disabled} - className={cn("aceternity-dock-command group relative grid size-full place-items-center rounded-full border outline-none", command.quiet && "is-quiet", command.active && "is-active", command.danger && "is-danger")} + className={cn("aceternity-dock-command group relative grid size-full place-items-center rounded-[var(--dock-item-radius)] border outline-none", command.quiet && "is-quiet", command.active && "is-active", command.danger && "is-danger")} whileTap={motionEnabled && !command.disabled ? { scale: 0.92 } : undefined} transition={aceternityMotion.spring.dock} onMouseEnter={() => setHovered(true)} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 4bdf3a752..930deef68 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -5030,16 +5030,17 @@ box-shadow: 0 24px 72px rgba(15, 23, 42, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.16); } + /* Dock 命令按钮:BottomMenu 式干净状态——透明默认 + hover/active 仅换底色, + 去掉描边/内阴影/发光阴影的多层反馈,hover 与 active 用同一套视觉语言, + 保持 dock 整体性(参考 uselayouts BottomMenu 的 bg-muted/bg-accent 切换)。*/ .aceternity-dock-command { - border-color: color-mix(in srgb, currentColor 11%, transparent); - background: var(--dock-command-bg); + border-color: transparent; + background: transparent; color: inherit; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 5px 15px rgba(15, 23, 42, 0.08); + box-shadow: none; transition: - border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, - color var(--motion-instant) ease-out, - box-shadow var(--motion-instant) ease-out; + color var(--motion-instant) ease-out; } .aceternity-dock-command > span:first-child svg { @@ -5049,33 +5050,12 @@ } .aceternity-dock-command:hover:not(:disabled) { - border-color: color-mix(in srgb, currentColor 20%, transparent); background: var(--dock-command-hover); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 12px 30px rgba(15, 23, 42, 0.14); - } - - .aceternity-dock-command.is-labeled { - border-color: transparent; - background: transparent; - box-shadow: none; - } - - .aceternity-dock-command.is-labeled:hover:not(:disabled) { - border-color: transparent; - background: var(--dock-command-hover); - box-shadow: none; - } - - .aceternity-dock-command.is-labeled.is-active { - border-color: transparent; - box-shadow: none; } .aceternity-dock-command.is-active { - border-color: color-mix(in srgb, var(--dock-command-active) 28%, transparent); background: var(--dock-command-active); color: var(--dock-command-active-text); - box-shadow: 0 12px 30px color-mix(in srgb, var(--dock-command-active) 22%, transparent); } .aceternity-dock-command.is-active > span:first-child, @@ -5085,19 +5065,6 @@ opacity: 1; } - .aceternity-dock-command.is-quiet { - border-color: transparent; - background: transparent; - box-shadow: none; - } - - .aceternity-dock-command.is-quiet:hover:not(:disabled), - .aceternity-dock-command.is-quiet.is-active { - border-color: transparent; - background: var(--dock-command-hover); - box-shadow: none; - } - .aceternity-dock-command.is-danger { color: var(--dock-command-danger); } From c2352fd9b6ccb879d58fe89c47ad9dd347f02e13 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Mon, 17 Aug 2026 12:56:51 +0800 Subject: [PATCH 21/25] =?UTF-8?q?fix(models):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=20-=20=E4=BF=9D=E7=95=99=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=B9=B6=E6=8C=89=E5=A3=B0=E6=98=8E=E5=8F=91?= =?UTF-8?q?=E9=80=81=E8=A7=86=E9=A2=91=E5=88=86=E8=BE=A8=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/model_capability.go | 19 ++++- .../internal/service/model_capability_test.go | 29 +++++++ backend/internal/service/provider.go | 5 +- .../service/provider_request_types.go | 2 +- backend/internal/service/provider_test.go | 50 +++++++++++- .../components/model-capability-editor.tsx | 9 ++- web/src/lib/channel-model-catalog.ts | 81 ++++++++++++------- web/src/lib/model-capabilities.ts | 12 ++- web/src/lib/model-protocols.ts | 6 +- web/src/services/api/video.ts | 9 ++- web/test/channel-model-catalog.test.ts | 44 ++++++++++ 11 files changed, 218 insertions(+), 48 deletions(-) diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index c90eaed94..a45ade68a 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -67,6 +67,7 @@ type VideoCapabilityConfig struct { type VideoReferenceConfig struct { PromptMaxChars int `json:"promptMaxChars"` + MinImages int `json:"minImages"` MaxImages int `json:"maxImages"` MaxImageBytes int64 `json:"maxImageBytes"` MaxVideos int `json:"maxVideos"` @@ -154,7 +155,7 @@ func defaultImageSizeValues() []string { func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig { video := &VideoCapabilityConfig{ - References: VideoReferenceConfig{PromptMaxChars: 1000, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, + References: VideoReferenceConfig{PromptMaxChars: 1000, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, DefaultRatio: "16:9", @@ -277,11 +278,14 @@ func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") } - for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} { + for name, number := range map[string]int{"最少图片引用数": value.References.MinImages, "最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} { if number < 0 || number > 100 { return BadAuthRequest(name + "必须在 0-100 之间") } } + if value.References.MinImages > value.References.MaxImages { + return BadAuthRequest("最少图片引用数不能超过最大图片引用数") + } if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 { return BadAuthRequest("引用素材限制不能小于 0") } @@ -291,8 +295,12 @@ func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { if len(value.Ratios) == 0 || strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) { return BadAuthRequest("请至少配置一个画面比例,并选择默认比例") } - if len(value.Resolutions) == 0 || strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) { - return BadAuthRequest("请至少配置一个输出分辨率,并选择默认分辨率") + if len(value.Resolutions) == 0 { + if strings.TrimSpace(value.DefaultResolution) != "" { + return BadAuthRequest("未配置输出分辨率时不能设置默认分辨率") + } + } else if strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) { + return BadAuthRequest("默认输出分辨率必须属于支持值") } if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) { return BadAuthRequest("请至少配置一个生成模式,并选择默认模式") @@ -377,6 +385,9 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { return BadAuthRequest("参考素材数量超过当前模型限制") } + if len(input.ReferenceImages) < profile.References.MinImages { + return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages)) + } for _, media := range input.ReferenceImages { if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { return BadAuthRequest("参考图片文件超过当前模型大小限制") diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 7ac821b8f..852e047e0 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -116,3 +116,32 @@ func TestValidateVideoTaskIgnoresGlobalResolutionWhenCatalogDeclaresNone(t *test t.Fatalf("validateVideoTask() error = %v", err) } } + +func TestNormalizeVideoCapabilityAllowsOmittedResolution(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "endpoint-video").Video + profile.Resolutions = nil + profile.DefaultResolution = "" + + result, err := NormalizeModelCapabilityConfig("video", "newapi-channel-2", &ModelCapabilityConfig{Version: 1, Video: profile}) + if err != nil { + t.Fatalf("NormalizeModelCapabilityConfig() error = %v", err) + } + if result.Video == nil || len(result.Video.Resolutions) != 0 || result.Video.DefaultResolution != "" { + t.Fatalf("normalized video resolution = %#v", result.Video) + } +} + +func TestValidateVideoTaskRequiresDeclaredMinimumImages(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "image-required-video").Video + profile.References.MinImages = 1 + profile.References.MaxImages = 2 + profile.Operations = []string{"image_to_video"} + profile.DefaultOperation = "image_to_video" + + err := validateVideoTask(profile, canvasGenerationInput{ + Config: providerConfig{Model: "image-required-video", VideoSeconds: "6", Size: "16:9", VQuality: "720"}, + }) + if err == nil || !strings.Contains(err.Error(), "至少需要 1 张参考图") { + t.Fatalf("validateVideoTask() error = %v", err) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 2449a93e8..46b9cd6cb 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -1973,7 +1973,10 @@ func newAPIChannel2VideoRequestBody(input canvasGenerationInput) (newAPIVideoReq seconds = 6 } ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName) - resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName) + resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality) + if modelName == "grok-video-1.5-1080p" { + resolution = "1080p" + } body := newAPIVideoRequest{ Model: input.Config.Model, Prompt: strings.TrimSpace(input.Prompt), diff --git a/backend/internal/service/provider_request_types.go b/backend/internal/service/provider_request_types.go index 91e4e996d..a646018a0 100644 --- a/backend/internal/service/provider_request_types.go +++ b/backend/internal/service/provider_request_types.go @@ -19,7 +19,7 @@ type newAPIVideoRequest struct { Prompt string `json:"prompt"` Seconds string `json:"seconds"` AspectRatio string `json:"aspect_ratio"` - Resolution string `json:"resolution"` + Resolution string `json:"resolution,omitempty"` GenerateAudio *bool `json:"generate_audio,omitempty"` ImageURLs []string `json:"image_urls,omitempty"` VideoURLs []string `json:"video_urls,omitempty"` diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0e6b5bb4e..981843ea8 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -1278,9 +1278,12 @@ func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) { })) defer server.Close() + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "grok-image-video").Video + profile.Resolutions = []string{"720p"} + profile.DefaultResolution = "720p" result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", - Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "720x1280", VQuality: "high"}, + Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "720x1280", VQuality: "720"}, ReferenceImages: []providerMedia{ {ID: "image-1", DataURL: testReferenceImageDataURL}, {ID: "image-2", DataURL: testReferenceImageDataURL}, @@ -1288,6 +1291,7 @@ func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) { ReferenceVideos: []providerMedia{{ID: "video-1", URL: server.URL + "/reference.mp4"}}, ReferenceAudios: []providerMedia{{ID: "audio-1", URL: server.URL + "/reference.mp3"}}, Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"}, + VideoCapability: profile, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) @@ -1354,6 +1358,50 @@ func TestNewAPIChannel2SingleImageModelsRequireOneReference(t *testing.T) { } } +func TestNewAPIChannel2SendsOnlyDeclaredResolution(t *testing.T) { + tests := []struct { + name string + model string + quality string + resolutions []string + want string + }{ + {name: "catalog omits resolution", model: "endpoint-video", quality: "720"}, + {name: "declared 2K alias", model: "declared-video", quality: "2K", resolutions: []string{"1440p"}, want: "1440p"}, + {name: "fixed grok 1080p model", model: "grok-video-1.5-1080p", quality: "720", want: "1080p"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", test.model).Video + profile.Resolutions = test.resolutions + profile.DefaultResolution = "" + input := canvasGenerationInput{ + Config: providerConfig{Model: test.model, VideoSeconds: "6", Size: "16:9", VQuality: test.quality}, + VideoCapability: profile, + } + if strings.HasPrefix(test.model, "grok-video-1.5") { + input.ReferenceImages = []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}} + } + + body, err := newAPIChannel2VideoRequestBody(input) + if err != nil { + t.Fatalf("newAPIChannel2VideoRequestBody() error = %v", err) + } + if body.Resolution != test.want { + t.Fatalf("resolution = %q, want %q", body.Resolution, test.want) + } + mapped, err := requestAsMap(body) + if err != nil { + t.Fatalf("requestAsMap() error = %v", err) + } + _, hasResolution := mapped["resolution"] + if hasResolution != (test.want != "") { + t.Fatalf("resolution presence = %v, want %v; body = %#v", hasResolution, test.want != "", mapped) + } + }) + } +} + func TestNewAPIChannel2RejectsAudioWithoutReferenceVideo(t *testing.T) { _, err := newAPIChannel2VideoRequestBody(canvasGenerationInput{ Config: providerConfig{Model: "grok-image-video", VideoSeconds: "6"}, diff --git a/web/src/components/model-capability-editor.tsx b/web/src/components/model-capability-editor.tsx index 8186a2963..5f44eef3d 100644 --- a/web/src/components/model-capability-editor.tsx +++ b/web/src/components/model-capability-editor.tsx @@ -47,7 +47,8 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
    updateReferences({ promptMaxChars: value || 1 })} /> - updateReferences({ maxImages: value || 0 })} /> + updateReferences({ minImages: value || 0 })} /> + { const maxImages = value || 0; updateReferences({ maxImages, minImages: Math.min(profile.references.minImages, maxImages) }); }} /> updateReferences({ maxImageBytes: mbToBytes(value) })} /> updateReferences({ maxVideos: value || 0 })} /> updateReferences({ maxVideoBytes: mbToBytes(value) })} /> @@ -76,7 +77,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
    ({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /> - ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "" })} /> changeValue(event.target.value)} + placeholder="粘贴 https://app.tapnow.media/tapflow/view/…" + disabled={loading} + suffix={shareID && value !== shareID ? : null} + /> +
    + {loading && !result ? ( +
    + + 正在读取 TapNow 画布… +
    + ) : null} + {result ? ( +
    +
    +
    +
    +
    {result.projectName || "TapNow 画布"}
    +
    + 可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线 +
    +
    + 批次:{formatTapNowBatchTime(result.batchCreatedAt)} +
    +
    支持图片、视频、音频和文本节点;节点会保留相对位置,并整体放到当前可视区域中心。
    +
    + {hasWarnings ? ( +
    + +
    + {result.skippedNodes.length ?
    {result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。
    : null} + {!result.skippedNodes.length && result.skippedConnections.length ?
    {result.skippedConnections.length} 条无效连线已自动忽略。
    : null} + {result.reusedFailedNodeCount ?
    {result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。
    : null} + {result.placeholderNodeCount ?
    {result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。
    : null} + {result.multiResultNodeCount ?
    {result.multiResultNodeCount} 个多结果节点已使用首个结果。
    : null} + {result.warnings.map((warning, index) => ( +
    {warning.message}
    + ))} +
    +
    + ) : null} +
    + 等待确认导入 +
    +
    + ) : null} +
    + + ); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 68cce559c..b7766439c 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -72,6 +72,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation"; import { CanvasTopBar } from "./canvas-project-top-bar"; +import { TapNowImportDialog } from "./components/tapnow-import-dialog"; import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; import { CanvasProjectContextMenu } from "./canvas-project-context-menu"; import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; @@ -190,6 +191,7 @@ function InfiniteCanvasPage() { const [workspaceMode, setWorkspaceMode] = useState(readCanvasWorkspaceMode); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false); + const [tapNowImportOpen, setTapNowImportOpen] = useState(false); const [nodeSearchOpen, setNodeSearchOpen] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); @@ -331,6 +333,28 @@ function InfiniteCanvasPage() { cleanupAssetImages, cleanupCanvasFiles, }); + + const applyTapNowImport = useCallback( + async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => { + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + const nextNodes = [...nodesRef.current, ...importedNodes]; + const nextConnections = [...connectionsRef.current, ...importedConnections]; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + const saved = await saveCanvasProject(); + if (!saved) { + nodesRef.current = previousNodes; + connectionsRef.current = previousConnections; + setNodes(previousNodes); + setConnections(previousConnections); + throw new Error("画布保存失败,已撤销本次 TapNow 导入"); + } + }, + [saveCanvasProject, setConnections, setNodes], + ); const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : ""; const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) }); const refetchLinkedProject = linkedProjectQuery.refetch; @@ -1639,6 +1663,7 @@ function InfiniteCanvasPage() { onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} onImportImage={() => handleUploadRequest()} + onImportTapNow={() => setTapNowImportOpen(true)} onUndo={undoCanvas} onRedo={redoCanvas} onShare={() => setShareModalOpen(true)} @@ -1686,6 +1711,7 @@ function InfiniteCanvasPage() { ) : null} setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> + setTapNowImportOpen(false)} onApply={applyTapNowImport} /> setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> diff --git a/web/src/services/api/tapnow.ts b/web/src/services/api/tapnow.ts new file mode 100644 index 000000000..0799ae57d --- /dev/null +++ b/web/src/services/api/tapnow.ts @@ -0,0 +1,57 @@ +import type { CanvasConnection } from "@/types/canvas"; +import { apiClient, request } from "@/services/api/request"; + +export type TapNowImportIssue = { id?: string; name?: string; reason: string }; +export type TapNowImportWarning = { id?: string; message: string }; + +export type TapNowImportNode = { + id: string; + type: "image" | "video" | "audio" | "text"; + title: string; + x: number; + y: number; + width: number; + height: number; + content: string; + prompt?: string; + model?: string; + size?: string; + quality?: string; + seconds?: string; + vquality?: string; + generateAudio?: string; + naturalWidth?: number; + naturalHeight?: number; + durationMs?: number; + mimeType?: string; + status?: "idle" | "success" | "error"; + errorDetails?: string; + metadata: { + provider: "tapnow"; + shareId: string; + nodeId: string; + batchId: string; + sourceType?: string; + }; +}; + +export type TapNowImportResult = { + batchId: string; + batchCreatedAt: string; + shareId: string; + projectName: string; + nodes: TapNowImportNode[]; + connections: CanvasConnection[]; + importedNodeCount: number; + importedConnectionCount: number; + skippedNodes: TapNowImportIssue[]; + skippedConnections: TapNowImportIssue[]; + warnings: TapNowImportWarning[]; + multiResultNodeCount: number; + reusedFailedNodeCount: number; + placeholderNodeCount: number; +}; + +export function importTapNowCanvas(projectId: string, shareId: string) { + return request(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/tapnow`, { shareId })); +} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 43e9d5c0c..06fa09e74 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -142,6 +142,13 @@ export type CanvasSkillSnapshot = { }; export type CanvasNodeMetadata = { + importSource?: { + provider: "tapnow"; + shareId: string; + nodeId: string; + batchId: string; + sourceType?: string; + }; content?: string; richText?: Record; composerContent?: string; diff --git a/web/test/tapnow-import.test.ts b/web/test/tapnow-import.test.ts new file mode 100644 index 000000000..6c2c510c6 --- /dev/null +++ b/web/test/tapnow-import.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; + +import { formatTapNowBatchTime, parseTapNowShareID } from "../src/lib/canvas/tapnow-import"; + +describe("TapNow canvas import helpers", () => { + test("extracts share IDs from TapNow links", () => { + expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/8872a294")).toBe("8872a294"); + expect(parseTapNowShareID("8872a294")).toBe("8872a294"); + expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/abc_DEF-12?source=share")).toBe("abc_DEF-12"); + }); + + test("rejects unrelated links and malformed IDs", () => { + expect(parseTapNowShareID("https://example.com/tapflow/view/8872a294")).toBe(""); + expect(parseTapNowShareID("https://app.tapnow.ai/tapflow/view/8872a294")).toBe(""); + expect(parseTapNowShareID("https://app.tapnow.media/canvas/8872a294")).toBe(""); + expect(parseTapNowShareID("bad/id")).toBe(""); + }); + + test("formats the readable batch label", () => { + const localTime = new Date(2026, 7, 19, 5, 2); + expect(formatTapNowBatchTime(localTime.toISOString())).toBe("202608190502"); + }); +}); From 50b4cfc2041e390da54664bab8d098bcf225bba5 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Wed, 19 Aug 2026 11:54:58 +0800 Subject: [PATCH 25/25] =?UTF-8?q?revert(canvas):=20TapNow=20=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E7=94=BB=E5=B8=83=E5=AF=BC=E5=85=A5=20-=20=E6=92=A4?= =?UTF-8?q?=E9=94=80=20main=20=E8=AF=AF=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 1 - backend/internal/handler/tapnow.go | 36 -- backend/internal/service/tapnow.go | 222 ---------- backend/internal/service/tapnow_adapter.go | 407 ------------------ backend/internal/service/tapnow_test.go | 91 ---- web/package.json | 2 +- web/src/lib/canvas/tapnow-import.ts | 25 -- .../pages/canvas/canvas-project-top-bar.tsx | 7 +- .../components/tapnow-import-dialog.tsx | 185 -------- web/src/pages/canvas/project.tsx | 26 -- web/src/services/api/tapnow.ts | 57 --- web/src/types/canvas.ts | 7 - web/test/tapnow-import.test.ts | 23 - 13 files changed, 2 insertions(+), 1087 deletions(-) delete mode 100644 backend/internal/handler/tapnow.go delete mode 100644 backend/internal/service/tapnow.go delete mode 100644 backend/internal/service/tapnow_adapter.go delete mode 100644 backend/internal/service/tapnow_test.go delete mode 100644 web/src/lib/canvas/tapnow-import.ts delete mode 100644 web/src/pages/canvas/components/tapnow-import-dialog.tsx delete mode 100644 web/src/services/api/tapnow.ts delete mode 100644 web/test/tapnow-import.test.ts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ecee85a18..b10d3b60c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -80,7 +80,6 @@ func main() { handler.RegisterAdminAnalyticsRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) - handler.RegisterTapNowRoutes(api, svc) // 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。 handler.RegisterChannelModelRoutes(api, svc) handler.RegisterSystemProxyRoutes(api, svc) diff --git a/backend/internal/handler/tapnow.go b/backend/internal/handler/tapnow.go deleted file mode 100644 index 99a7cc263..000000000 --- a/backend/internal/handler/tapnow.go +++ /dev/null @@ -1,36 +0,0 @@ -package handler - -import ( - "net/http" - "strings" - - "infinite-canvas/backend/internal/service" - - "github.com/gin-gonic/gin" -) - -func RegisterTapNowRoutes(r *gin.RouterGroup, svc *service.Service) { - r.POST("/canvas-projects/:id/import/tapnow", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if user == nil { - failService(c, service.Unauthorized("请先登录")) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) - var req service.TapNowImportRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.ImportTapNow(user.ID, c.Param("id"), strings.TrimSpace(req.ShareID)) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) -} diff --git a/backend/internal/service/tapnow.go b/backend/internal/service/tapnow.go deleted file mode 100644 index 5665ebc2d..000000000 --- a/backend/internal/service/tapnow.go +++ /dev/null @@ -1,222 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -const ( - tapNowShareURLPrefix = "https://app.tapnow.media/api/conversation/v1/canvas-share/" - tapNowMaxResponseBytes = 16 << 20 - tapNowMaxNodes = 1000 - tapNowMaxConnections = 5000 -) - -type TapNowImportRequest struct { - ShareID string `json:"shareId"` -} - -type TapNowImportResult struct { - BatchID string `json:"batchId"` - BatchCreatedAt time.Time `json:"batchCreatedAt"` - ShareID string `json:"shareId"` - ProjectName string `json:"projectName"` - Nodes []TapNowCanvasNode `json:"nodes"` - Connections []TapNowCanvasConnection `json:"connections"` - ImportedNodeCount int `json:"importedNodeCount"` - ImportedConnectionCount int `json:"importedConnectionCount"` - SkippedNodes []TapNowImportIssue `json:"skippedNodes"` - SkippedConnections []TapNowImportIssue `json:"skippedConnections"` - Warnings []TapNowImportWarning `json:"warnings"` - MultiResultNodeCount int `json:"multiResultNodeCount"` - ReusedFailedNodeCount int `json:"reusedFailedNodeCount"` - PlaceholderNodeCount int `json:"placeholderNodeCount"` -} - -type TapNowCanvasNode struct { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - X float64 `json:"x"` - Y float64 `json:"y"` - Width float64 `json:"width"` - Height float64 `json:"height"` - Content string `json:"content"` - Prompt string `json:"prompt,omitempty"` - Model string `json:"model,omitempty"` - Size string `json:"size,omitempty"` - Quality string `json:"quality,omitempty"` - Seconds string `json:"seconds,omitempty"` - VQuality string `json:"vquality,omitempty"` - GenerateAudio string `json:"generateAudio,omitempty"` - NaturalWidth int `json:"naturalWidth,omitempty"` - NaturalHeight int `json:"naturalHeight,omitempty"` - DurationMs int64 `json:"durationMs,omitempty"` - MimeType string `json:"mimeType,omitempty"` - Status string `json:"status,omitempty"` - ErrorDetails string `json:"errorDetails,omitempty"` - Metadata TapNowImportMetadata `json:"metadata"` -} - -type TapNowImportMetadata struct { - Provider string `json:"provider"` - ShareID string `json:"shareId"` - NodeID string `json:"nodeId"` - BatchID string `json:"batchId"` - SourceType string `json:"sourceType,omitempty"` -} - -type TapNowCanvasConnection struct { - ID string `json:"id"` - FromNodeID string `json:"fromNodeId"` - ToNodeID string `json:"toNodeId"` - FromHandleID string `json:"fromHandleId,omitempty"` - ToHandleID string `json:"toHandleId,omitempty"` -} - -type TapNowImportIssue struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Reason string `json:"reason"` -} - -type TapNowImportWarning struct { - ID string `json:"id,omitempty"` - Message string `json:"message"` -} - -type tapNowEnvelope struct { - Code int `json:"code"` - Data json.RawMessage `json:"data"` - Msg string `json:"msg"` - Message string `json:"message"` -} - -type tapNowDetail struct { - ID string `json:"id"` - Name string `json:"name"` - Nodes []tapNowRawNode `json:"nodes"` - Connections []tapNowRawConnection `json:"connections"` -} - -type tapNowRawNode struct { - ID string `json:"id"` - ShortID int64 `json:"short_id"` - Type string `json:"type"` - Data json.RawMessage `json:"data"` - DeletedAt string `json:"deleted_at"` - Position struct { - X float64 `json:"x"` - Y float64 `json:"y"` - } `json:"position"` - Measured struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - } `json:"measured"` -} - -type tapNowRawConnection struct { - ID string `json:"id"` - Source string `json:"source"` - Target string `json:"target"` - SourceHandle string `json:"sourceHandle"` - TargetHandle string `json:"targetHandle"` - Data map[string]any `json:"data"` - DeletedAt string `json:"deleted_at"` -} - -type tapNowNodeData struct { - Title string `json:"title"` - Prompt string `json:"prompt"` - Text string `json:"text"` - Src string `json:"src"` - Options []string `json:"options"` - Type string `json:"type"` - Params map[string]any `json:"params"` - TaskInfo map[string]any `json:"taskInfo"` - Metadata struct { - URL string `json:"url"` - Width int `json:"width"` - Height int `json:"height"` - Duration float64 `json:"duration"` - } `json:"__metadata"` - Error string `json:"error"` - FailedReason string `json:"failedReason"` -} - -// 读取公开分享前先校验目标画布归属,避免把导入结果写入他人画布。 -func (s *Service) ImportTapNow(userID, canvasProjectID, shareID string) (*TapNowImportResult, error) { - userID = strings.TrimSpace(userID) - canvasProjectID = strings.TrimSpace(canvasProjectID) - if userID == "" || canvasProjectID == "" { - return nil, Unauthorized("请先打开已同步的故事创作画布") - } - if _, err := s.repo.CanvasProjectForUser(userID, canvasProjectID); err != nil { - return nil, err - } - detail, normalizedShareID, err := fetchTapNowDetail(strings.TrimSpace(shareID)) - if err != nil { - return nil, err - } - return adaptTapNowDetail(detail, normalizedShareID) -} - -func fetchTapNowDetail(shareID string) (*tapNowDetail, string, error) { - if !isTapNowShareID(shareID) { - return nil, "", BadAuthRequest("TapNow 分享 ID 格式无效") - } - endpoint, err := url.Parse(tapNowShareURLPrefix + url.PathEscape(shareID)) - if err != nil { - return nil, "", err - } - req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) - if err != nil { - return nil, "", err - } - req.Header.Set("Accept", "application/json") - ApplyDefaultOutboundHeaders(req) - client := OutboundHTTPClient(20 * time.Second) - client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { - return errors.New("TapNow API 不允许重定向") - } - resp, err := client.Do(req) - if err != nil { - return nil, "", errors.New("TapNow 请求失败,请检查网络或分享链接") - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, tapNowMaxResponseBytes+1)) - if err != nil { - return nil, "", errors.New("读取 TapNow 响应失败") - } - if len(body) > tapNowMaxResponseBytes { - return nil, "", errors.New("TapNow 画布响应过大") - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, "", fmt.Errorf("TapNow 请求失败(HTTP %d)", resp.StatusCode) - } - var envelope tapNowEnvelope - if err := json.Unmarshal(body, &envelope); err != nil { - return nil, "", errors.New("TapNow 响应格式无效") - } - if envelope.Code != 0 { - msg := strings.TrimSpace(envelope.Msg) - if msg == "" { - msg = strings.TrimSpace(envelope.Message) - } - if msg == "" { - msg = "TapNow 返回业务错误" - } - return nil, "", errors.New(msg) - } - var detail tapNowDetail - if err := json.Unmarshal(envelope.Data, &detail); err != nil { - return nil, "", errors.New("TapNow 画布数据格式无效") - } - return &detail, shareID, nil -} diff --git a/backend/internal/service/tapnow_adapter.go b/backend/internal/service/tapnow_adapter.go deleted file mode 100644 index 7bf6ee8b1..000000000 --- a/backend/internal/service/tapnow_adapter.go +++ /dev/null @@ -1,407 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "math" - "net/url" - "strconv" - "strings" - "time" - - "github.com/google/uuid" -) - -func adaptTapNowDetail(detail *tapNowDetail, shareID string) (*TapNowImportResult, error) { - if detail == nil { - return nil, errors.New("TapNow 画布数据为空") - } - shareID = strings.TrimSpace(shareID) - if !isTapNowShareID(shareID) { - return nil, BadAuthRequest("TapNow 分享 ID 格式无效") - } - if len(detail.Nodes) > tapNowMaxNodes { - return nil, fmt.Errorf("TapNow 画布节点过多(最多支持 %d 个)", tapNowMaxNodes) - } - - now := time.Now() - batchID := "tapnow-" + strconv.FormatInt(now.UnixMilli(), 36) + "-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8] - result := &TapNowImportResult{ - BatchID: batchID, - BatchCreatedAt: now, - ShareID: shareID, - ProjectName: strings.TrimSpace(detail.Name), - Nodes: make([]TapNowCanvasNode, 0, len(detail.Nodes)), - Connections: make([]TapNowCanvasConnection, 0, minInt(len(detail.Connections), tapNowMaxConnections)), - SkippedNodes: make([]TapNowImportIssue, 0), - SkippedConnections: make([]TapNowImportIssue, 0), - Warnings: make([]TapNowImportWarning, 0), - } - if result.ProjectName == "" { - result.ProjectName = "TapNow 画布" - } - - mapping := make(map[string]string, len(detail.Nodes)) - seenNodeIDs := make(map[string]struct{}, len(detail.Nodes)) - for _, raw := range detail.Nodes { - if strings.TrimSpace(raw.DeletedAt) != "" { - continue - } - nodeID := strings.TrimSpace(raw.ID) - if nodeID == "" && raw.ShortID != 0 { - nodeID = strconv.FormatInt(raw.ShortID, 10) - } - if nodeID == "" { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{Reason: "节点缺少 ID"}) - continue - } - if len(nodeID) > 128 { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID[:128], Reason: "节点 ID 过长"}) - continue - } - if _, exists := seenNodeIDs[nodeID]; exists { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点 ID 重复"}) - continue - } - seenNodeIDs[nodeID] = struct{}{} - - data, err := decodeTapNowNodeData(raw.Data) - if err != nil { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点数据格式无效"}) - continue - } - kind := strings.ToLower(strings.TrimSpace(raw.Type)) - if kind == "" { - kind = strings.ToLower(strings.TrimSpace(data.Type)) - } - if kind != "image" && kind != "video" && kind != "audio" && kind != "text" { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "暂不支持的节点类型"}) - continue - } - if !finiteNumber(raw.Position.X) || !finiteNumber(raw.Position.Y) { - result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "节点坐标无效"}) - continue - } - - content, mediaIndex := firstTapNowMediaURL(data) - if kind == "text" { - content = strings.TrimSpace(data.Text) - if content == "" { - content = strings.TrimSpace(data.Prompt) - } - mediaIndex = -1 - } - width, height := raw.Measured.Width, raw.Measured.Height - if !positiveFiniteNumber(width) { - width = 480 - } - if !positiveFiniteNumber(height) { - height = 300 - } - item := TapNowCanvasNode{ - ID: "tapnow-" + batchID + "-" + nodeID, - Type: kind, - Title: strings.TrimSpace(data.Title), - X: raw.Position.X, - Y: raw.Position.Y, - Width: width, - Height: height, - Content: content, - Prompt: firstNonEmptyString(strings.TrimSpace(data.Prompt), firstTapNowParam(data.Params, "prompt")), - Model: tapNowMapString(data.Params, "model"), - Size: firstTapNowParam(data.Params, "aspectRatio", "size"), - Quality: tapNowImageQuality(data.Params), - Seconds: firstTapNowParam(data.Params, "duration", "seconds"), - VQuality: firstTapNowParam(data.Params, "resolution", "vquality"), - GenerateAudio: tapNowBoolParam(data.Params, "generateAudio"), - Metadata: TapNowImportMetadata{ - Provider: "tapnow", - ShareID: shareID, - NodeID: nodeID, - BatchID: batchID, - SourceType: strings.TrimSpace(data.Type), - }, - } - if item.Title == "" { - item.Title = kind + " 节点" - } - item.NaturalWidth = data.Metadata.Width - item.NaturalHeight = data.Metadata.Height - if data.Metadata.Duration > 0 && !math.IsNaN(data.Metadata.Duration) && !math.IsInf(data.Metadata.Duration, 0) { - item.DurationMs = int64(math.Round(data.Metadata.Duration * 1000)) - } - item.MimeType = inferTapNowMime(kind, content, firstNonEmptyString(data.Prompt, data.Title)) - taskStatus := tapNowTaskStatus(data.TaskInfo) - hasContent := strings.TrimSpace(content) != "" - if hasContent { - item.Status = "success" - if tapNowTaskFailed(taskStatus) { - result.ReusedFailedNodeCount++ - } - } else if tapNowTaskFailed(taskStatus) { - item.Status = "error" - item.ErrorDetails = tapNowErrorDetails(data) - if item.ErrorDetails == "" { - item.ErrorDetails = "TapNow 生成任务失败" - } - result.PlaceholderNodeCount++ - } else { - item.Status = "idle" - result.PlaceholderNodeCount++ - } - mapping[nodeID] = item.ID - if raw.ShortID != 0 { - mapping[strconv.FormatInt(raw.ShortID, 10)] = item.ID - } - result.Nodes = append(result.Nodes, item) - if !positiveFiniteNumber(raw.Measured.Width) || !positiveFiniteNumber(raw.Measured.Height) { - result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”尺寸无效,已使用默认尺寸。", item.Title)}) - } - if mediaIndex >= 0 && len(data.Options) > 1 { - result.MultiResultNodeCount++ - result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”包含多个结果,已导入首个结果。", item.Title)}) - } - } - - if len(detail.Connections) > tapNowMaxConnections { - result.Warnings = append(result.Warnings, TapNowImportWarning{Message: fmt.Sprintf("连接超过 %d 条,仅处理前 %d 条。", tapNowMaxConnections, tapNowMaxConnections)}) - } - seenConnectionIDs := make(map[string]struct{}, len(detail.Connections)) - for index, raw := range detail.Connections { - if index >= tapNowMaxConnections { - break - } - if strings.TrimSpace(raw.DeletedAt) != "" { - continue - } - connectionID := strings.TrimSpace(raw.ID) - if connectionID == "" { - result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{Reason: "连接缺少 ID"}) - continue - } - if _, exists := seenConnectionIDs[connectionID]; exists { - result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接 ID 重复"}) - continue - } - seenConnectionIDs[connectionID] = struct{}{} - from, okFrom := mapping[strings.TrimSpace(raw.Source)] - to, okTo := mapping[strings.TrimSpace(raw.Target)] - if !okFrom || !okTo { - result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接端点节点未导入"}) - continue - } - fromHandleID := strings.TrimSpace(raw.SourceHandle) - toHandleID := strings.TrimSpace(raw.TargetHandle) - if toHandleID == "" { - // TapNow 将图片输入句柄放在边 data.valueKey,而不是 targetHandle。 - toHandleID = tapNowMapString(raw.Data, "valueKey") - } - result.Connections = append(result.Connections, TapNowCanvasConnection{ - ID: "tapnow-" + batchID + "-" + connectionID, - FromNodeID: from, - ToNodeID: to, - FromHandleID: fromHandleID, - ToHandleID: toHandleID, - }) - } - result.ImportedNodeCount = len(result.Nodes) - result.ImportedConnectionCount = len(result.Connections) - if result.ImportedNodeCount == 0 { - return nil, errors.New("TapNow 画布没有可导入的有效节点") - } - return result, nil -} - -func decodeTapNowNodeData(raw json.RawMessage) (tapNowNodeData, error) { - var data tapNowNodeData - trimmed := strings.TrimSpace(string(raw)) - if strings.HasPrefix(trimmed, `"`) { - var encoded string - if err := json.Unmarshal(raw, &encoded); err != nil { - return data, err - } - raw = json.RawMessage(encoded) - } - if err := json.Unmarshal(raw, &data); err != nil { - return data, err - } - return data, nil -} - -func firstTapNowMediaURL(data tapNowNodeData) (string, int) { - values := make([]string, 0, len(data.Options)+2) - values = append(values, data.Src) - values = append(values, data.Options...) - values = append(values, data.Metadata.URL) - for index, value := range values { - parsed, err := url.Parse(strings.TrimSpace(value)) - if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || !isTapNowMediaHost(parsed.Hostname()) { - continue - } - return parsed.String(), index - } - return "", -1 -} - -func isTapNowMediaHost(host string) bool { - host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") - // 导入后的媒体还可能作为后续生成的参考素材,只接受已核验的官方文件域名, - // 不能根据品牌前缀放行未经确认的同名域,避免把外部 URL 带入后端出网链路。 - return host == "files.tapnow.media" -} - -func inferTapNowMime(kind, rawURL, hint string) string { - lower := strings.ToLower(rawURL + " " + hint) - switch kind { - case "image": - switch { - case strings.Contains(lower, ".png"): - return "image/png" - case strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg"): - return "image/jpeg" - case strings.Contains(lower, ".webp"): - return "image/webp" - case strings.Contains(lower, ".gif"): - return "image/gif" - default: - return "image/*" - } - case "video": - switch { - case strings.Contains(lower, ".webm"): - return "video/webm" - case strings.Contains(lower, ".mov"): - return "video/quicktime" - default: - return "video/mp4" - } - case "audio": - switch { - case strings.Contains(lower, ".wav"): - return "audio/wav" - case strings.Contains(lower, ".flac"): - return "audio/flac" - case strings.Contains(lower, ".ogg") || strings.Contains(lower, ".oga"): - return "audio/ogg" - case strings.Contains(lower, ".m4a"): - return "audio/mp4" - default: - return "audio/mpeg" - } - case "text": - return "text/plain" - default: - return "application/octet-stream" - } -} - -func tapNowMapString(values map[string]any, key string) string { - if values == nil { - return "" - } - value, ok := values[key] - if !ok { - return "" - } - if value == nil { - return "" - } - if stringValue, ok := value.(string); ok { - return strings.TrimSpace(stringValue) - } - return strings.TrimSpace(fmt.Sprint(value)) -} - -func firstTapNowParam(values map[string]any, keys ...string) string { - for _, key := range keys { - if value := tapNowMapString(values, key); value != "" { - return value - } - } - return "" -} - -// TapNow 部分渠道用 imageSize(例如 "2K"),画布合同则使用小写质量档位。 -// 只迁移已知档位,避免把渠道私有值静默写成无效的本地质量设置。 -func tapNowImageQuality(values map[string]any) string { - value := firstTapNowParam(values, "quality") - if value == "" { - value = firstTapNowParam(values, "imageSize") - } - normalized := strings.ToLower(strings.TrimSpace(value)) - switch normalized { - case "1k", "2k", "4k", "auto", "low", "medium", "high": - return normalized - default: - return "" - } -} - -func tapNowBoolParam(values map[string]any, key string) string { - value, ok := values[key] - if !ok { - return "" - } - if boolean, ok := value.(bool); ok { - return strconv.FormatBool(boolean) - } - return tapNowMapString(values, key) -} - -func tapNowTaskStatus(values map[string]any) string { - status := strings.ToLower(strings.TrimSpace(tapNowMapString(values, "status"))) - return status -} - -func tapNowTaskFailed(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "failed", "error", "failure": - return true - default: - return false - } -} - -func tapNowErrorDetails(data tapNowNodeData) string { - if value := strings.TrimSpace(data.Error); value != "" { - return value - } - if value := strings.TrimSpace(data.FailedReason); value != "" { - return value - } - for _, key := range []string{"error", "failedReason", "message"} { - if value := tapNowMapString(data.TaskInfo, key); value != "" { - return value - } - } - return "" -} - -func isTapNowShareID(value string) bool { - value = strings.TrimSpace(value) - if value == "" || len(value) > 64 { - return false - } - for _, char := range value { - if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-') { - return false - } - } - return true -} - -func finiteNumber(value float64) bool { - return !math.IsNaN(value) && !math.IsInf(value, 0) -} - -func positiveFiniteNumber(value float64) bool { - return finiteNumber(value) && value > 0 -} - -func minInt(left, right int) int { - if left < right { - return left - } - return right -} diff --git a/backend/internal/service/tapnow_test.go b/backend/internal/service/tapnow_test.go deleted file mode 100644 index 9c80f707a..000000000 --- a/backend/internal/service/tapnow_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package service - -import "testing" - -func TestAdaptTapNowDetailSupportsMediaAndTextNodes(t *testing.T) { - detail := &tapNowDetail{ - Name: "TapNow 测试画布", - Nodes: []tapNowRawNode{ - {ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://files.tapnow.media/api/conversation/storage/uploads/image.png","prompt":"画面提示词","params":{"model":"gpt-image-2","imageSize":"2K"},"__metadata":{"width":1024,"height":768}}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 10, Y: 20}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 448, Height: 250}}, - {ID: "video-1", Type: "video", Data: []byte(`{"title":"视频","type":"generate","src":"","taskInfo":{"status":"failed","failedReason":"上游失败"},"params":{"model":"seedance-2.0","aspectRatio":"16:9","duration":8,"resolution":"720p","generateAudio":true},"__metadata":{"width":1280,"height":720,"duration":4.5}}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 500, Y: 20}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 444, Height: 250}}, - {ID: "audio-1", Type: "audio", Data: []byte(`{"title":"音频","type":"upload","src":"https://files.tapnow.media/api/conversation/storage/uploads/audio-file","prompt":"就这样.wav"}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 10, Y: 400}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 444, Height: 250}}, - {ID: "text-1", Type: "text", Data: []byte(`{"title":"文本","type":"generate","text":"故事文本"}`), Position: struct { - X float64 `json:"x"` - Y float64 `json:"y"` - }{X: 500, Y: 400}, Measured: struct { - Width float64 `json:"width"` - Height float64 `json:"height"` - }{Width: 300, Height: 200}}, - }, - Connections: []tapNowRawConnection{{ID: "edge-1", Source: "image-1", Target: "video-1", SourceHandle: "images", TargetHandle: "prompt"}}, - } - - result, err := adaptTapNowDetail(detail, "8872a294") - if err != nil { - t.Fatalf("adaptTapNowDetail() error = %v", err) - } - if result.ImportedNodeCount != 4 || result.ImportedConnectionCount != 1 { - t.Fatalf("counts = (%d, %d), want (4, 1)", result.ImportedNodeCount, result.ImportedConnectionCount) - } - if result.Nodes[0].Metadata.Provider != "tapnow" || result.Nodes[0].Model != "gpt-image-2" || result.Nodes[0].Quality != "2k" || result.Nodes[0].NaturalWidth != 1024 { - t.Fatalf("image mapping = %#v", result.Nodes[0]) - } - if result.Nodes[1].Status != "error" || result.Nodes[1].ErrorDetails != "上游失败" || result.PlaceholderNodeCount != 1 { - t.Fatalf("failed video mapping = %#v", result.Nodes[1]) - } - if result.Nodes[1].Size != "16:9" || result.Nodes[1].Seconds != "8" || result.Nodes[1].VQuality != "720p" || result.Nodes[1].GenerateAudio != "true" { - t.Fatalf("video generation params = %#v", result.Nodes[1]) - } - if result.Nodes[2].MimeType != "audio/wav" || result.Nodes[3].Type != "text" || result.Nodes[3].Content != "故事文本" { - t.Fatalf("audio/text mapping = %#v %#v", result.Nodes[2], result.Nodes[3]) - } - if result.Connections[0].FromHandleID != "images" || result.Connections[0].ToHandleID != "prompt" { - t.Fatalf("connection handles = %#v", result.Connections[0]) - } -} - -func TestAdaptTapNowDetailUsesValueKeyAsTargetHandle(t *testing.T) { - detail := &tapNowDetail{ - Nodes: []tapNowRawNode{ - {ID: "source", Type: "image", Data: []byte(`{"title":"源图","src":"https://files.tapnow.media/source"}`)}, - {ID: "target", Type: "image", Data: []byte(`{"title":"目标图","src":"https://files.tapnow.media/target"}`)}, - }, - Connections: []tapNowRawConnection{{ID: "edge", Source: "source", Target: "target", Data: map[string]any{"valueKey": "images"}}}, - } - result, err := adaptTapNowDetail(detail, "8872a294") - if err != nil { - t.Fatalf("adaptTapNowDetail() error = %v", err) - } - if got := result.Connections[0].ToHandleID; got != "images" { - t.Fatalf("target handle = %q, want images", got) - } -} - -func TestAdaptTapNowDetailRejectsUntrustedMediaAndKeepsPlaceholder(t *testing.T) { - detail := &tapNowDetail{Nodes: []tapNowRawNode{{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://evil.example/image.png"}`)}}} - result, err := adaptTapNowDetail(detail, "share_1") - if err != nil { - t.Fatalf("adaptTapNowDetail() error = %v", err) - } - if result.Nodes[0].Content != "" || result.Nodes[0].Status != "idle" || result.PlaceholderNodeCount != 1 { - t.Fatalf("untrusted media handling = %#v", result.Nodes[0]) - } -} diff --git a/web/package.json b/web/package.json index 9d0dcde1d..5485d69c3 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.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-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.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/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/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/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.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-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.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/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/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/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/lib/canvas/tapnow-import.ts b/web/src/lib/canvas/tapnow-import.ts deleted file mode 100644 index 26904fa48..000000000 --- a/web/src/lib/canvas/tapnow-import.ts +++ /dev/null @@ -1,25 +0,0 @@ -const TAPNOW_SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const TAPNOW_SHARE_HOST = "app.tapnow.media"; - -export function parseTapNowShareID(value: string) { - const trimmed = value.trim(); - if (!trimmed) return ""; - let candidate = trimmed; - try { - const parsed = new URL(trimmed); - const hostname = parsed.hostname.toLowerCase().replace(/\.$/, ""); - if (hostname !== TAPNOW_SHARE_HOST || !/^\/tapflow\/view\/?[^/]*\/?$/.test(parsed.pathname)) return ""; - const parts = parsed.pathname.split("/").filter(Boolean); - candidate = parts.at(-1) || ""; - } catch { - candidate = trimmed; - } - return TAPNOW_SHARE_ID_PATTERN.test(candidate) ? candidate : ""; -} - -export function formatTapNowBatchTime(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "未知"; - const pad = (part: number) => String(part).padStart(2, "0"); - return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}`; -} diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx index 798065413..7f1ec22cc 100644 --- a/web/src/pages/canvas/canvas-project-top-bar.tsx +++ b/web/src/pages/canvas/canvas-project-top-bar.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Link } from "react-router"; -import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react"; +import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react"; import { Button, Dropdown, Modal, Tooltip } from "antd"; import { useWalletBalance } from "@/hooks/use-wallet-balance"; @@ -28,7 +28,6 @@ type CanvasTopBarProps = { onCreateProject: () => void; onDeleteProject: () => void; onImportImage: () => void; - onImportTapNow: () => void; onUndo: () => void; onRedo: () => void; onShare: () => void; @@ -59,7 +58,6 @@ export function CanvasTopBar({ onCreateProject, onDeleteProject, onImportImage, - onImportTapNow, onUndo, onRedo, onShare, @@ -193,9 +191,6 @@ export function CanvasTopBar({
    void; - onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise; -}; - -function buildCanvasNodes(result: TapNowImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) { - const minX = Math.min(...result.nodes.map((node) => node.x)); - const minY = Math.min(...result.nodes.map((node) => node.y)); - const maxX = Math.max(...result.nodes.map((node) => node.x + node.width)); - const maxY = Math.max(...result.nodes.map((node) => node.y + node.height)); - const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2; - const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2; - return result.nodes.map((node) => ({ - id: node.id, - type: node.type === "video" ? CanvasNodeType.Video : node.type === "audio" ? CanvasNodeType.Audio : node.type === "text" ? CanvasNodeType.Text : CanvasNodeType.Image, - title: node.title, - position: { x: node.x + offsetX, y: node.y + offsetY }, - width: node.width, - height: node.height, - metadata: { - content: node.content, - prompt: node.prompt, - model: node.model, - size: node.size, - quality: node.quality, - seconds: node.seconds, - vquality: node.vquality, - generateAudio: node.generateAudio, - status: node.status || "idle", - errorDetails: node.errorDetails, - naturalWidth: node.naturalWidth, - naturalHeight: node.naturalHeight, - durationMs: node.durationMs, - mimeType: node.mimeType, - importSource: node.metadata, - }, - })); -} - -export function TapNowImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) { - const { message } = App.useApp(); - const [value, setValue] = useState(""); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const shareID = useMemo(() => parseTapNowShareID(value), [value]); - - const reset = () => { - setValue(""); - setResult(null); - }; - - const close = () => { - if (loading) return; - reset(); - onClose(); - }; - - const changeValue = (nextValue: string) => { - setValue(nextValue); - setResult(null); - }; - - const load = async () => { - if (!shareID) { - message.error("请填写有效的 TapNow 画布分享链接或分享 ID"); - return; - } - setLoading(true); - try { - setResult(await importTapNowCanvas(projectId, shareID)); - } catch (error) { - message.error(error instanceof Error ? error.message : "读取 TapNow 画布失败"); - } finally { - setLoading(false); - } - }; - - const apply = async () => { - if (!result) return; - setLoading(true); - try { - await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections); - reset(); - onClose(); - message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`); - } catch (error) { - message.error(error instanceof Error ? error.message : "保存导入结果失败"); - } finally { - setLoading(false); - } - }; - - const hasWarnings = result ? Boolean(result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.warnings.length) : false; - - return ( - - 关闭 - , - , - ] - ) : ( - - ) - } - > -
    -
    - - changeValue(event.target.value)} - placeholder="粘贴 https://app.tapnow.media/tapflow/view/…" - disabled={loading} - suffix={shareID && value !== shareID ? : null} - /> -
    - {loading && !result ? ( -
    - - 正在读取 TapNow 画布… -
    - ) : null} - {result ? ( -
    -
    -
    -
    -
    {result.projectName || "TapNow 画布"}
    -
    - 可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线 -
    -
    - 批次:{formatTapNowBatchTime(result.batchCreatedAt)} -
    -
    支持图片、视频、音频和文本节点;节点会保留相对位置,并整体放到当前可视区域中心。
    -
    - {hasWarnings ? ( -
    - -
    - {result.skippedNodes.length ?
    {result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。
    : null} - {!result.skippedNodes.length && result.skippedConnections.length ?
    {result.skippedConnections.length} 条无效连线已自动忽略。
    : null} - {result.reusedFailedNodeCount ?
    {result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。
    : null} - {result.placeholderNodeCount ?
    {result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。
    : null} - {result.multiResultNodeCount ?
    {result.multiResultNodeCount} 个多结果节点已使用首个结果。
    : null} - {result.warnings.map((warning, index) => ( -
    {warning.message}
    - ))} -
    -
    - ) : null} -
    - 等待确认导入 -
    -
    - ) : null} -
    -
    - ); -} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index b7766439c..68cce559c 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -72,7 +72,6 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation"; import { CanvasTopBar } from "./canvas-project-top-bar"; -import { TapNowImportDialog } from "./components/tapnow-import-dialog"; import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; import { CanvasProjectContextMenu } from "./canvas-project-context-menu"; import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; @@ -191,7 +190,6 @@ function InfiniteCanvasPage() { const [workspaceMode, setWorkspaceMode] = useState(readCanvasWorkspaceMode); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false); - const [tapNowImportOpen, setTapNowImportOpen] = useState(false); const [nodeSearchOpen, setNodeSearchOpen] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); @@ -333,28 +331,6 @@ function InfiniteCanvasPage() { cleanupAssetImages, cleanupCanvasFiles, }); - - const applyTapNowImport = useCallback( - async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => { - const previousNodes = nodesRef.current; - const previousConnections = connectionsRef.current; - const nextNodes = [...nodesRef.current, ...importedNodes]; - const nextConnections = [...connectionsRef.current, ...importedConnections]; - nodesRef.current = nextNodes; - connectionsRef.current = nextConnections; - setNodes(nextNodes); - setConnections(nextConnections); - const saved = await saveCanvasProject(); - if (!saved) { - nodesRef.current = previousNodes; - connectionsRef.current = previousConnections; - setNodes(previousNodes); - setConnections(previousConnections); - throw new Error("画布保存失败,已撤销本次 TapNow 导入"); - } - }, - [saveCanvasProject, setConnections, setNodes], - ); const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : ""; const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) }); const refetchLinkedProject = linkedProjectQuery.refetch; @@ -1663,7 +1639,6 @@ function InfiniteCanvasPage() { onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} onImportImage={() => handleUploadRequest()} - onImportTapNow={() => setTapNowImportOpen(true)} onUndo={undoCanvas} onRedo={redoCanvas} onShare={() => setShareModalOpen(true)} @@ -1711,7 +1686,6 @@ function InfiniteCanvasPage() { ) : null} setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> - setTapNowImportOpen(false)} onApply={applyTapNowImport} /> setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> diff --git a/web/src/services/api/tapnow.ts b/web/src/services/api/tapnow.ts deleted file mode 100644 index 0799ae57d..000000000 --- a/web/src/services/api/tapnow.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { CanvasConnection } from "@/types/canvas"; -import { apiClient, request } from "@/services/api/request"; - -export type TapNowImportIssue = { id?: string; name?: string; reason: string }; -export type TapNowImportWarning = { id?: string; message: string }; - -export type TapNowImportNode = { - id: string; - type: "image" | "video" | "audio" | "text"; - title: string; - x: number; - y: number; - width: number; - height: number; - content: string; - prompt?: string; - model?: string; - size?: string; - quality?: string; - seconds?: string; - vquality?: string; - generateAudio?: string; - naturalWidth?: number; - naturalHeight?: number; - durationMs?: number; - mimeType?: string; - status?: "idle" | "success" | "error"; - errorDetails?: string; - metadata: { - provider: "tapnow"; - shareId: string; - nodeId: string; - batchId: string; - sourceType?: string; - }; -}; - -export type TapNowImportResult = { - batchId: string; - batchCreatedAt: string; - shareId: string; - projectName: string; - nodes: TapNowImportNode[]; - connections: CanvasConnection[]; - importedNodeCount: number; - importedConnectionCount: number; - skippedNodes: TapNowImportIssue[]; - skippedConnections: TapNowImportIssue[]; - warnings: TapNowImportWarning[]; - multiResultNodeCount: number; - reusedFailedNodeCount: number; - placeholderNodeCount: number; -}; - -export function importTapNowCanvas(projectId: string, shareId: string) { - return request(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/tapnow`, { shareId })); -} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 06fa09e74..43e9d5c0c 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -142,13 +142,6 @@ export type CanvasSkillSnapshot = { }; export type CanvasNodeMetadata = { - importSource?: { - provider: "tapnow"; - shareId: string; - nodeId: string; - batchId: string; - sourceType?: string; - }; content?: string; richText?: Record; composerContent?: string; diff --git a/web/test/tapnow-import.test.ts b/web/test/tapnow-import.test.ts deleted file mode 100644 index 6c2c510c6..000000000 --- a/web/test/tapnow-import.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { formatTapNowBatchTime, parseTapNowShareID } from "../src/lib/canvas/tapnow-import"; - -describe("TapNow canvas import helpers", () => { - test("extracts share IDs from TapNow links", () => { - expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/8872a294")).toBe("8872a294"); - expect(parseTapNowShareID("8872a294")).toBe("8872a294"); - expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/abc_DEF-12?source=share")).toBe("abc_DEF-12"); - }); - - test("rejects unrelated links and malformed IDs", () => { - expect(parseTapNowShareID("https://example.com/tapflow/view/8872a294")).toBe(""); - expect(parseTapNowShareID("https://app.tapnow.ai/tapflow/view/8872a294")).toBe(""); - expect(parseTapNowShareID("https://app.tapnow.media/canvas/8872a294")).toBe(""); - expect(parseTapNowShareID("bad/id")).toBe(""); - }); - - test("formats the readable batch label", () => { - const localTime = new Date(2026, 7, 19, 5, 2); - expect(formatTapNowBatchTime(localTime.toISOString())).toBe("202608190502"); - }); -});