From 58f01d1eb662bfe25d38d6d0bf50ff30888049f8 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 5 Sep 2026 13:24:10 +0800 Subject: [PATCH] =?UTF-8?q?perf(*):=20=E5=88=9B=E4=BD=9C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=20-=20=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5=E6=8C=89?= =?UTF-8?q?=E9=9C=80=E5=8A=A0=E8=BD=BD=E4=B8=8E=E7=94=BB=E5=B8=83=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=8E=86=E5=8F=B2=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEPLOYMENT_GUIDE.md | 212 ------------- README.md | 6 +- backend/internal/database/migrations.go | 57 +++- .../database/migrations_lineage_test.go | 119 ++++++++ backend/internal/database/migrations_test.go | 25 ++ backend/internal/handler/user_data.go | 32 ++ .../internal/handler/user_data_page_test.go | 31 ++ .../internal/model/models_logical_model.go | 2 +- .../repository/logical_models_test.go | 6 + .../repository/project_workbench_read.go | 28 ++ backend/internal/service/provider.go | 125 +++++--- backend/internal/service/provider_test.go | 19 ++ backend/internal/service/task_output.go | 47 ++- backend/internal/service/task_output_test.go | 10 + backend/internal/service/user_data_page.go | 110 +++++++ .../internal/service/user_data_page_test.go | 68 +++++ .../content/docs/backend/backend-database.mdx | 8 +- docs/content/docs/progress/pending-test.mdx | 67 ++++ web/index.html | 33 +- web/src/application.tsx | 2 - web/src/components/ai/ai-message-markdown.tsx | 2 - .../components/ai/generation-tool-card.tsx | 2 +- .../assets/asset-library-picker-modal.tsx | 55 +++- .../components/auth/auth-session-hydrator.tsx | 29 +- .../components/canvas/asset-picker-modal.tsx | 1 + .../components/canvas/canvas-folder-card.tsx | 31 +- .../canvas/canvas-image-toolbar-tools.tsx | 87 ++++-- .../canvas/canvas-leafer-graphics-layer.tsx | 21 +- .../components/canvas/canvas-node-content.tsx | 78 +++-- .../canvas/canvas-node-mask-edit-dialog.tsx | 2 +- .../canvas/canvas-node-prompt-panel.tsx | 38 +-- .../canvas/canvas-node-split-dialog.tsx | 2 +- .../components/canvas/canvas-node-toolbar.tsx | 226 ++++++++------ .../canvas/canvas-node-upscale-dialog.tsx | 5 +- web/src/components/canvas/canvas-node.tsx | 3 + .../canvas-portrait-texture-popover.tsx | 6 +- .../canvas/canvas-preset-picker.tsx | 16 +- .../canvas/canvas-project-asset-modal.tsx | 1 + .../components/canvas/canvas-project-card.tsx | 2 +- .../canvas/canvas-workspace-overlays.tsx | 61 ++-- web/src/components/canvas/infinite-canvas.tsx | 41 ++- .../layout/app-changelog-dialog.tsx | 66 ++++ .../components/layout/app-changelog-modal.tsx | 69 +---- web/src/components/layout/app-providers.tsx | 66 +--- web/src/components/layout/app-top-nav.tsx | 7 +- .../components/layout/client-root-init.tsx | 48 +++ .../layout/system-announcement-center.tsx | 11 +- .../ui/aceternity/full-screen-loader.tsx | 67 +--- web/src/components/video-player.tsx | 5 +- web/src/hooks/use-external-asset-sources.ts | 3 +- web/src/hooks/use-wallet-balance.ts | 9 +- web/src/layouts/user-layout.tsx | 2 - .../lib/canvas/canvas-generation-task-sync.ts | 4 +- web/src/lib/canvas/canvas-live-viewport.ts | 52 ++++ web/src/lib/canvas/canvas-selection.ts | 133 ++++++++ web/src/lib/canvas/canvas-shortcuts.ts | 16 +- .../definitions/main-toolbar-tools.tsx | 15 +- .../definitions/node-hover-tools.tsx | 40 ++- web/src/lib/canvas/tool-registry/index.ts | 4 +- .../canvas/tool-registry/tool-definition.ts | 8 + .../lib/canvas/tool-registry/tool-registry.ts | 11 +- web/src/lib/user-session.ts | 6 +- web/src/lib/workspace-route-modules.ts | 9 +- web/src/pages/assets/index.tsx | 4 +- web/src/pages/auth/auth-scene.tsx | 11 +- web/src/pages/auth/login.tsx | 2 +- web/src/pages/auth/register.tsx | 2 +- .../canvas/canvas-project-world-layers.tsx | 9 +- web/src/pages/canvas/index.tsx | 83 +++-- web/src/pages/canvas/project.tsx | 17 +- web/src/pages/canvas/use-canvas-generation.ts | 4 +- web/src/pages/canvas/use-canvas-keyboard.ts | 1 + .../pages/canvas/use-canvas-media-tools.ts | 12 +- .../canvas/use-canvas-project-lifecycle.ts | 23 +- .../canvas/use-canvas-selection-controller.ts | 164 +++++----- web/src/pages/create/creation-runtime.ts | 11 + web/src/pages/create/index.tsx | 175 ++++++----- web/src/pages/projects/detail.tsx | 78 ++--- web/src/pages/projects/detail/assets.tsx | 5 + .../detail/workflow-production-workbench.tsx | 24 +- web/src/pages/projects/detail/workflow.css | 12 + web/src/pages/tasks/index.tsx | 2 +- web/src/pages/tasks/task-grid-card.tsx | 7 +- web/src/pages/tasks/task-list-row.tsx | 5 +- web/src/router.tsx | 18 +- web/src/services/api/generation-task.ts | 10 +- web/src/services/api/request.ts | 23 +- web/src/services/api/task-center.ts | 1 + web/src/services/api/user-data.ts | 16 + web/src/services/canvas-asset-repair.ts | 17 +- .../services/canvas-generation-consumer.ts | 6 + web/src/services/external-asset-sources.ts | 2 - web/src/services/project-asset-sync.ts | 42 +++ web/src/services/resource-blob-cache.ts | 20 ++ web/src/services/user-data-sync.ts | 147 ++++++++- web/src/styles/globals.css | 288 +++++++++++++++++- web/src/types/canvas.ts | 7 +- web/test/api-request.test.ts | 3 +- web/test/canvas-media-performance.test.ts | 22 +- web/test/canvas-node-toolbar.test.ts | 95 ++++++ web/test/canvas-selection.test.ts | 110 +++++++ web/test/canvas-shortcuts.test.ts | 9 + web/test/generation-task-local.test.ts | 46 +++ web/test/generation-task-materializer.test.ts | 56 +++- ...user-scoped-generation-persistence.test.ts | 81 ++++- web/test/workspace-route-loading.test.ts | 37 +++ web/vite.config.ts | 40 +++ 107 files changed, 3025 insertions(+), 1089 deletions(-) delete mode 100644 DEPLOYMENT_GUIDE.md create mode 100644 backend/internal/database/migrations_lineage_test.go create mode 100644 backend/internal/handler/user_data_page_test.go create mode 100644 backend/internal/service/user_data_page.go create mode 100644 backend/internal/service/user_data_page_test.go create mode 100644 web/src/components/layout/app-changelog-dialog.tsx create mode 100644 web/src/lib/canvas/canvas-selection.ts create mode 100644 web/src/pages/create/creation-runtime.ts create mode 100644 web/test/canvas-node-toolbar.test.ts create mode 100644 web/test/canvas-selection.test.ts diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md deleted file mode 100644 index e4222e89e..000000000 --- a/DEPLOYMENT_GUIDE.md +++ /dev/null @@ -1,212 +0,0 @@ -# 模型目录开关功能部署指南 - -## 问题修复 - -已解决以下问题: -1. ✅ 后端路由未注册 - 已在 `cmd/server/main.go` 中注册 `RegisterModelCatalogRoutes` -2. ✅ 前端初始化仍调用旧接口 - 已修改 `user-session.ts` 在登录和刷新时调用统一目录接口 -3. ✅ 前端菜单未根据开关隐藏 - 已修改 `admin-shell.tsx` 根据 `frontendModelsEnabled` 过滤菜单 -4. ✅ 路由未拦截直接访问 - 已在 `router.tsx` 中添加 `RequireFeature` 检查 - -## 部署步骤 - -### 1. 重新编译后端 - -```bash -cd /Users/catdd/Desktop/work/infinite-canvas/backend -go build -o bin/server ./cmd/server -``` - -### 2. 重启后端服务 - -```bash -# 停止当前服务 -pkill -f "infinite-canvas.*server" - -# 启动新服务 -./bin/server -``` - -### 3. 验证后端接口 - -```bash -# 测试统一目录接口 -curl http://localhost:3000/api/model-catalog - -# 应该返回类似: -# { -# "source": "frontend", -# "models": [...] -# } -``` - -### 4. 编译前端 - -```bash -cd /Users/catdd/Desktop/work/infinite-canvas/web -npm run build -``` - -### 5. 刷新浏览器 - -清除浏览器缓存并刷新页面,确保加载最新的前端代码。 - -## 功能测试 - -### 测试场景 1:前台模型开启(默认) - -1. 登录管理后台 -2. 进入"功能开放"页面 -3. 确认"前台模型"开关为**开启**状态 -4. 观察左侧菜单:应显示"前台模型"菜单项 -5. 进入创作页面,查看模型选择器:应显示前台模型 -6. 创建任务:应使用 `logicalModelId` - -### 测试场景 2:关闭前台模型 - -1. 在"功能开放"页面,点击"前台模型"开关关闭 -2. 确认二次确认对话框,点击"确认关闭" -3. 观察左侧菜单:**"前台模型"菜单项应消失** -4. 尝试直接访问 `/admin/models`:应显示"前台模型暂未开放"提示 -5. 刷新页面,进入创作页面 -6. 查看模型选择器:**应显示系统渠道中的模型**(不再是前台模型) -7. 创建任务:应使用 `channelId + model`(不再有 logicalModelId) - -### 测试场景 3:重新开启前台模型 - -1. 在"功能开放"页面,点击"前台模型"开关开启 -2. 观察左侧菜单:应重新显示"前台模型"菜单项 -3. 刷新页面,进入创作页面 -4. 查看模型选择器:应重新显示前台模型 - -## 关键验证点 - -### ✓ 管理后台 -- [ ] "管理后台功能"卡片正常显示 -- [ ] "前台模型"开关可以正常切换 -- [ ] 关闭时显示二次确认对话框 -- [ ] 关闭后左侧菜单隐藏"前台模型"项 -- [ ] 开启后左侧菜单显示"前台模型"项 - -### ✓ 用户端(前台模型开启) -- [ ] 模型选择器显示前台模型列表 -- [ ] 创建任务时带 `logicalModelId` -- [ ] 任务正常执行 - -### ✓ 用户端(前台模型关闭) -- [ ] 模型选择器显示系统渠道模型列表 -- [ ] 创建任务时带 `channelId + model`,不带 `logicalModelId` -- [ ] 任务正常执行 -- [ ] 刷新页面后仍然显示系统渠道模型 - -### ✓ 接口调用 -- [ ] `/api/model-catalog` 返回正确的数据 -- [ ] 开启时 `source: "frontend"` -- [ ] 关闭时 `source: "system"` - -## 常见问题排查 - -### 问题1:接口返回 404 -**原因**:后端未重启或路由未注册 -**解决**: -```bash -# 确认路由注册 -grep "RegisterModelCatalogRoutes" /Users/catdd/Desktop/work/infinite-canvas/backend/cmd/server/main.go - -# 重新编译并重启 -cd backend -go build -o bin/server ./cmd/server -./bin/server -``` - -### 问题2:关闭开关后仍显示前台模型 -**原因**:前端未刷新或浏览器缓存 -**解决**: -1. 硬刷新浏览器(Cmd+Shift+R / Ctrl+Shift+F5) -2. 清除浏览器缓存 -3. 检查浏览器控制台是否有错误 - -### 问题3:菜单未隐藏 -**原因**:前端代码未更新 -**解决**: -```bash -cd web -npm run build -# 清除浏览器缓存并刷新 -``` - -### 问题4:模型选择器为空 -**原因**:系统渠道无可用模型或接口异常 -**解决**: -1. 检查浏览器控制台网络请求 -2. 查看 `/api/model-catalog` 返回的数据 -3. 确认系统渠道中有启用且配置价格的模型 - -## API 对照表 - -| 场景 | 旧接口 | 新接口 | 返回数据 | -|------|--------|--------|----------| -| 前台模型开启 | `/api/models` | `/api/model-catalog` | `{source:"frontend", models:[...]}` | -| 前台模型关闭 | - | `/api/model-catalog` | `{source:"system", channels:[...]}` | - -## 数据流图 - -### 前台模型开启 -``` -用户登录 - ↓ -前端调用 /api/model-catalog - ↓ -后端检查 frontendModelsEnabled = true - ↓ -返回 {source:"frontend", models:[前台模型]} - ↓ -前端加载前台模型到选择器 - ↓ -用户创建任务(带 logicalModelId) -``` - -### 前台模型关闭 -``` -用户登录 - ↓ -前端调用 /api/model-catalog - ↓ -后端检查 frontendModelsEnabled = false - ↓ -返回 {source:"system", channels:[系统渠道模型]} - ↓ -前端加载系统渠道模型到选择器 - ↓ -用户创建任务(带 channelId + model) -``` - -## 文件修改清单 - -### 后端 -- ✅ `cmd/server/main.go` - 注册路由 -- ✅ `internal/handler/model_catalog.go` - 新增统一目录接口 -- ✅ `internal/service/model_catalog.go` - 实现目录逻辑 -- ✅ `internal/service/task_creation.go` - 任务创建分流 -- ✅ `internal/service/feature_availability.go` - 添加开关 - -### 前端 -- ✅ `services/api/logical-models.ts` - 新增API函数 -- ✅ `lib/user-session.ts` - 调用统一目录接口 -- ✅ `stores/use-user-store.ts` - 添加类型定义 -- ✅ `pages/admin/components/feature-availability-panel.tsx` - 添加开关UI -- ✅ `pages/admin/components/admin-shell.tsx` - 菜单过滤 -- ✅ `components/auth/require-feature.tsx` - 添加权限检查 -- ✅ `router.tsx` - 路由拦截 - -## 下一步工作 - -1. [ ] 数据库迁移(创建 logical_model_price_skus 表) -2. [ ] 编写单元测试 -3. [ ] 压力测试 -4. [ ] 文档更新 - ---- - -**创建时间**: 2026-08-22 -**状态**: 待部署测试 diff --git a/README.md b/README.md index 1653f6efd..b1efff9c3 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,13 @@ ## 交流与反馈 -Issue 反馈、技术讨论和产品升级建议都可以在 QQ 群中沟通。群内还会不定期组织 AI 学习与培训交流会。 +Issue 反馈、技术讨论和产品升级建议都可以在 微信 群中沟通。群内还会不定期组织 AI 学习与培训交流会。

- 影策 QQ 交流群 - 影策 微信交流群 + 影策 微信交流群

+ ## 当前能力 - **自由画布**:项目、节点、连线、框选、缩放、小地图、撤销重做、导入导出和公开只读分享。 diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 4ca74de76..09dcace10 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -10,7 +10,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 7 +const CurrentSchemaVersion int64 = 8 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -19,6 +19,7 @@ const resourceUploadKeyChecksum = "sha256:resource-upload-key-v4-20260901" const paymentTopupChecksum = "sha256:payment-topup-v5-20260902" const resourcePlaybackChecksum = "sha256:resource-playback-v6-20260902" const assetLibraryFoldersChecksum = "sha256:asset-library-folders-v6-20260902" +const logicalModelActiveCodeChecksum = "sha256:logical-model-active-code-v8-20260905" const postgresSchemaMigrationLockID int64 = 73123910420260830 @@ -52,6 +53,35 @@ var schemaMigrations = []migration{ {version: 5, name: "payment_topup", checksum: paymentTopupChecksum, apply: migrateSchemaV5}, {version: 6, name: "resource_playback_variant", checksum: resourcePlaybackChecksum, apply: migrateSchemaV6}, {version: 7, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7}, + {version: 8, name: "logical_model_active_code", checksum: logicalModelActiveCodeChecksum, apply: migrateSchemaV8}, +} + +func migrationsForDatabase(db *gorm.DB) ([]migration, error) { + var applied schemaMigration + err := db.First(&applied, "version = ?", 6).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return schemaMigrations, nil + } + if err != nil { + return nil, fmt.Errorf("读取数据库迁移 6:%w", err) + } + if applied.Name != "asset_library_folders" { + return schemaMigrations, nil + } + legacy := migration{version: 6, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7} + if err := validateMigrationRecord(applied, legacy); err != nil { + return nil, err + } + plan := append([]migration(nil), schemaMigrations...) + for index, item := range plan { + switch item.version { + case 6: + plan[index] = legacy + case 7: + plan[index] = migration{version: 7, name: "resource_playback_variant", checksum: resourcePlaybackChecksum, apply: migrateSchemaV6} + } + } + return plan, nil } func migrateSchemaV2(tx *gorm.DB) error { @@ -155,6 +185,19 @@ func migrateSchemaV7(tx *gorm.DB) error { return nil } +func migrateSchemaV8(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&model.LogicalModel{}) { + return nil + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_logical_models_code").Error; err != nil { + return fmt.Errorf("移除前台模型旧 code 唯一索引:%w", err) + } + if err := tx.Exec("CREATE UNIQUE INDEX idx_logical_models_code ON logical_models(code) WHERE archived_at IS NULL").Error; err != nil { + return fmt.Errorf("创建前台模型活动 code 唯一索引:%w", err) + } + return nil +} + func MigrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { if tx.Dialector.Name() == "postgres" { @@ -165,7 +208,11 @@ func MigrateSchema(db *gorm.DB) error { if err := tx.AutoMigrate(&schemaMigration{}); err != nil { return fmt.Errorf("初始化数据库迁移记录:%w", err) } - for _, item := range schemaMigrations { + plan, err := migrationsForDatabase(tx) + if err != nil { + return err + } + for _, item := range plan { var applied schemaMigration err := tx.First(&applied, "version = ?", item.version).Error if err == nil { @@ -208,7 +255,11 @@ func ReadSchemaStatus(db *gorm.DB) (SchemaStatus, error) { } func validateMigrationRecords(db *gorm.DB) error { - for _, item := range schemaMigrations { + plan, err := migrationsForDatabase(db) + if err != nil { + return err + } + for _, item := range plan { var applied schemaMigration if err := db.First(&applied, "version = ?", item.version).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/backend/internal/database/migrations_lineage_test.go b/backend/internal/database/migrations_lineage_test.go new file mode 100644 index 000000000..88c0fbd59 --- /dev/null +++ b/backend/internal/database/migrations_lineage_test.go @@ -0,0 +1,119 @@ +package database + +import ( + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func legacyFolderMigrationDatabase(t *testing.T) *gorm.DB { + t.Helper() + db, err := Open(Config{Driver: "sqlite", DSN: "file:" + t.Name() + "?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&schemaMigration{}); err != nil { + t.Fatal(err) + } + for _, item := range schemaMigrations[:5] { + if err := item.apply(db); err != nil { + t.Fatal(err) + } + if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + } + if err := migrateSchemaV7(db); err != nil { + t.Fatal(err) + } + for _, column := range []string{"playback_status", "playback_object_key", "playback_error"} { + if db.Migrator().HasColumn(&model.Resource{}, column) { + if err := db.Migrator().DropColumn(&model.Resource{}, column); err != nil { + t.Fatal(err) + } + } + } + if err := db.Create(&schemaMigration{Version: 6, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC)}).Error; err != nil { + t.Fatal(err) + } + return db +} + +func TestMigrateSchemaPreservesLegacyFolderMigration(t *testing.T) { + db := legacyFolderMigrationDatabase(t) + var before schemaMigration + if err := db.First(&before, "version = 6").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO asset_folders (id, user_id, name) VALUES ('kept-folder', 'owner', 'Keep me')").Error; err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if err := RequireSchemaVersion(db); err != nil { + t.Fatal(err) + } + } + var after schemaMigration + if err := db.First(&after, "version = 6").Error; err != nil { + t.Fatal(err) + } + if before.Name != after.Name || before.Checksum != after.Checksum || !before.AppliedAt.Equal(after.AppliedAt) { + t.Fatalf("historical record changed: before=%+v after=%+v", before, after) + } + var playback schemaMigration + if err := db.First(&playback, "version = 7").Error; err != nil { + t.Fatal(err) + } + if playback.Name != "resource_playback_variant" || playback.Checksum != resourcePlaybackChecksum { + t.Fatalf("unexpected playback migration: %+v", playback) + } + for _, column := range []string{"playback_status", "playback_object_key", "playback_error"} { + if !db.Migrator().HasColumn(&model.Resource{}, column) { + t.Fatalf("missing playback column %s", column) + } + } + var name string + if err := db.Raw("SELECT name FROM asset_folders WHERE id = 'kept-folder'").Scan(&name).Error; err != nil || name != "Keep me" { + t.Fatalf("folder data changed: %q %v", name, err) + } +} + +func TestMigrateSchemaRejectsUnknownLegacyLineage(t *testing.T) { + for _, scenario := range []string{"checksum", "name", "version-seven"} { + t.Run(scenario, func(t *testing.T) { + db := legacyFolderMigrationDatabase(t) + switch scenario { + case "checksum": + if err := db.Model(&schemaMigration{}).Where("version = 6").Update("checksum", "unknown").Error; err != nil { + t.Fatal(err) + } + case "name": + if err := db.Model(&schemaMigration{}).Where("version = 6").Update("name", "unknown").Error; err != nil { + t.Fatal(err) + } + case "version-seven": + if err := db.Create(&schemaMigration{Version: 7, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Now().UTC()}).Error; err != nil { + t.Fatal(err) + } + } + if err := MigrateSchema(db); err == nil || !strings.Contains(err.Error(), "不一致") { + t.Fatalf("expected lineage rejection, got %v", err) + } + if db.Migrator().HasColumn(&model.Resource{}, "playback_status") { + t.Fatal("rejected migration changed resource schema") + } + }) + } +} diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index e6dc7ec2d..28fc9e5e6 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -37,6 +37,31 @@ func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) { } } +func TestMigrateSchemaV8AllowsReusingArchivedLogicalModelCode(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-logical-model-active-code?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := db.Exec(`CREATE TABLE logical_models (id text PRIMARY KEY, code text NOT NULL, archived_at datetime)`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`CREATE UNIQUE INDEX idx_logical_models_code ON logical_models(code)`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('archived', 'gpt-image-2', CURRENT_TIMESTAMP)`).Error; err != nil { + t.Fatal(err) + } + if err := migrateSchemaV8(db); err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('active', 'gpt-image-2', NULL)`).Error; err != nil { + t.Fatalf("reusing archived code after migration: %v", err) + } + if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('duplicate', 'gpt-image-2', NULL)`).Error; err == nil { + t.Fatal("active logical model code must remain unique") + } +} + func TestMigrateSchemaRejectsChecksumMismatch(t *testing.T) { db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-checksum?mode=memory&cache=shared"}) if err != nil { diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index bae518c75..43d150ecf 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -17,6 +17,27 @@ import ( ) func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/assets/batch", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req struct { + IDs []string `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + assets, err := svc.UserAssetsByIDs(user.ID, req.IDs) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"assets": assets}) + }) r.GET("/settings/prompt-templates", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -571,6 +592,17 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if c.Query("page") != "" { + page, _ := strconv.Atoi(c.Query("page")) + pageSize, _ := strconv.Atoi(c.Query("page_size")) + result, pageErr := svc.UserCanvasProjectsPage(user.ID, page, pageSize, c.Query("project_id"), c.Query("q"), c.Query("sort")) + if pageErr != nil { + failService(c, pageErr) + return + } + ok(c, result) + return + } projects, err := svc.UserCanvasProjectSummaries(user.ID) if err != nil { failService(c, err) diff --git a/backend/internal/handler/user_data_page_test.go b/backend/internal/handler/user_data_page_test.go new file mode 100644 index 000000000..88cdf4837 --- /dev/null +++ b/backend/internal/handler/user_data_page_test.go @@ -0,0 +1,31 @@ +package handler + +import ( + "testing" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestUserDataIncrementalRoutesAreRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterUserDataRoutes(router.Group("/api"), &service.Service{}) + wanted := map[string]bool{ + "GET /api/canvas-projects": false, + "GET /api/canvas-projects/:id": false, + "POST /api/assets/batch": false, + } + for _, route := range router.Routes() { + key := route.Method + " " + route.Path + if _, exists := wanted[key]; exists { + wanted[key] = true + } + } + for route, found := range wanted { + if !found { + t.Errorf("missing route: %s", route) + } + } +} diff --git a/backend/internal/model/models_logical_model.go b/backend/internal/model/models_logical_model.go index 40181d3dc..b4855c91b 100644 --- a/backend/internal/model/models_logical_model.go +++ b/backend/internal/model/models_logical_model.go @@ -12,7 +12,7 @@ type IDSequence struct { type LogicalModel struct { ID string `json:"id" gorm:"primaryKey;size:36"` - Code string `json:"code" gorm:"size:80;uniqueIndex"` + Code string `json:"code" gorm:"size:80;uniqueIndex:idx_logical_models_code,where:archived_at IS NULL"` Name string `json:"name" gorm:"size:120"` Icon string `json:"icon" gorm:"size:80"` Description string `json:"description" gorm:"size:500"` diff --git a/backend/internal/repository/logical_models_test.go b/backend/internal/repository/logical_models_test.go index cd1faee1e..26b61f011 100644 --- a/backend/internal/repository/logical_models_test.go +++ b/backend/internal/repository/logical_models_test.go @@ -89,6 +89,12 @@ func TestArchiveLogicalModelPreservesPublishedHistory(t *testing.T) { t.Fatalf("%s history was not preserved: %v", label, err) } } + + replacement := &model.LogicalModel{ID: "LMODEL_REPLACEMENT", Code: item.Code, Name: "Replacement", Capability: "text", Enabled: true, PricePolicy: "unified", BillingMode: "fixed_request", CreatedAt: time.Now(), UpdatedAt: time.Now()} + replacementRevision := &model.LogicalModelRevision{ID: "REVISION_REPLACEMENT", LogicalModelID: replacement.ID, CapabilitySpecJSON: `{"version":1,"capability":"text"}`, DefaultOptionsJSON: `{}`, CreatedAt: time.Now()} + if err := repo.SaveLogicalModelBundle(replacement, replacementRevision, nil, true); err != nil { + t.Fatalf("reusing archived logical model code: %v", err) + } } func TestArchiveLogicalModelRejectsActiveTask(t *testing.T) { diff --git a/backend/internal/repository/project_workbench_read.go b/backend/internal/repository/project_workbench_read.go index f050984a9..4032ff15c 100644 --- a/backend/internal/repository/project_workbench_read.go +++ b/backend/internal/repository/project_workbench_read.go @@ -168,6 +168,34 @@ func (r *Repository) ProjectCanvasSummariesPage(userID string, projectID string, return canvases, total, err } +func (r *Repository) UserCanvasProjectsPage(userID string, page int, pageSize int, projectID string, search string, sort string) ([]model.CanvasProject, int64, error) { + var projects []model.CanvasProject + var total int64 + query := r.db.Model(&model.CanvasProject{}).Where("user_id = ?", userID) + if projectID == "independent" { + query = query.Where("project_id = '' OR project_id IS NULL") + } else if projectID != "" && projectID != "all" { + query = query.Where("project_id = ?", projectID) + } + if search = strings.TrimSpace(search); search != "" { + query = query.Where("LOWER(title) LIKE ?", "%"+strings.ToLower(search)+"%") + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + order := "updated_at desc, id asc" + if sort == "name" { + order = "title asc, id asc" + } else if sort == "nodes" { + order = "COALESCE(json_array_length(payload_json, '$.nodes'), 0) desc, id asc" + if r.db.Dialector.Name() == "postgres" { + order = "COALESCE(jsonb_array_length(payload_json::jsonb->'nodes'), 0) desc, id asc" + } + } + err := query.Order(order).Offset((page - 1) * pageSize).Limit(pageSize).Find(&projects).Error + return projects, total, err +} + func (r *Repository) ProjectCanvasUnitLinksForCanvases(projectID string, canvasIDs []string) ([]model.CanvasUnitLink, error) { if len(canvasIDs) == 0 { return []model.CanvasUnitLink{}, nil diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 8491e4589..2517ea811 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -46,6 +46,7 @@ type canvasGenerationInput struct { Mask *providerMedia `json:"mask"` Metadata map[string]interface{} `json:"metadata"` AgentRequests *agentToolRequests `json:"agentRequests"` + TextOptions canvasTextOptions `json:"textOptions"` ImageCapability *ImageCapabilityConfig `json:"-"` StreamText bool `json:"-"` // 分镜请求使用上游 SSE 保活;最终结构仍在流结束后统一校验。 MaxOutputTokens int `json:"-"` @@ -53,6 +54,11 @@ type canvasGenerationInput struct { VideoCapability *VideoCapabilityConfig `json:"-"` } +type canvasTextOptions struct { + Stream *bool `json:"stream"` + Thinking bool `json:"thinking"` +} + type agentToolRequests struct { Responses map[string]interface{} `json:"responses"` ChatCompletion map[string]interface{} `json:"chatCompletion"` @@ -335,8 +341,10 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string ctx = withProviderOutboundPolicy(ctx, input.Config) var textPublisher *taskTextStreamPublisher if input.Mode == "text" && strings.HasPrefix(taskType, "canvas_text") { + input.StreamText = input.TextOptions.Stream == nil || *input.TextOptions.Stream + } + if input.Mode == "text" && strings.HasPrefix(taskType, "canvas_text") && input.StreamText { textPublisher = newTaskTextStreamPublisher(s, userID, taskExecutionID(ctx)) - input.StreamText = true input.OnTextDelta = textPublisher.Publish defer textPublisher.Close() } @@ -673,6 +681,8 @@ func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map switch stringField(item, "type") { case "text": result["text"] = result["text"].(string) + stringField(item, "text") + case "thinking": + result["reasoning"] = resultString(result, "reasoning") + firstNonEmptyString(stringField(item, "thinking"), stringField(item, "text")) case "tool_use": arguments, err := json.Marshal(item["input"]) if err != nil { @@ -885,6 +895,8 @@ func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{} switch stringField(block, "type") { case "text": p.appendText(stringField(block, "text")) + case "thinking": + p.reasoning.WriteString(firstNonEmptyString(stringField(block, "thinking"), stringField(block, "text"))) case "tool_use": arguments := "" if input := block["input"]; input != nil { @@ -900,6 +912,9 @@ func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{} if stringField(delta, "type") == "text_delta" { p.appendText(stringField(delta, "text")) } + if stringField(delta, "type") == "thinking_delta" { + p.reasoning.WriteString(firstNonEmptyString(stringField(delta, "thinking"), stringField(delta, "text"))) + } if stringField(delta, "type") == "input_json_delta" { p.toolCall(index).arguments += stringField(delta, "partial_json") } @@ -909,6 +924,11 @@ func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{} } } +func resultString(value map[string]interface{}, key string) string { + text, _ := value[key].(string) + return text +} + func (p *streamingAgentParser) appendText(delta string) { if delta == "" { return @@ -2006,8 +2026,9 @@ func runLegacyTextTask(ctx context.Context, input canvasGenerationInput) (map[st return nil, err } body := map[string]interface{}{"model": input.Config.Model, "input": responseInput} + applyTextThinking(body, input, "responses") applyTextOutputLimit(body, input.MaxOutputTokens, "max_output_tokens") - text, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta) + result, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta) if err != nil { if !shouldFallbackTextToChat(err) { return nil, err @@ -2018,7 +2039,7 @@ func runLegacyTextTask(ctx context.Context, input canvasGenerationInput) (map[st } return nil, fmt.Errorf("文本接口请求失败:Responses API %v;Chat Completions %v", err, chatErr) } - return map[string]interface{}{"mode": "text", "text": text}, nil + return providerTextTaskResult(result), nil } func runResponsesTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { @@ -2027,12 +2048,13 @@ func runResponsesTextTask(ctx context.Context, input canvasGenerationInput) (map return nil, err } body := map[string]interface{}{"model": input.Config.Model, "input": responseInput} + applyTextThinking(body, input, "responses") applyTextOutputLimit(body, input.MaxOutputTokens, "max_output_tokens") - text, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta) + result, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta) if err != nil { return nil, err } - return map[string]interface{}{"mode": "text", "text": text}, nil + return providerTextTaskResult(result), nil } func runChatCompletionsTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { @@ -2047,12 +2069,13 @@ func runChatCompletionsTextTask(ctx context.Context, input canvasGenerationInput } messages = append(messages, map[string]interface{}{"role": "user", "content": userContent}) body := map[string]interface{}{"model": input.Config.Model, "messages": messages} + applyTextThinking(body, input, "chat-completion") applyTextOutputLimit(body, input.MaxOutputTokens, "max_tokens") - text, err := requestTextProvider(ctx, input.Config, "/chat/completions", body, "chat-completion", input.StreamText, input.OnTextDelta) + result, err := requestTextProvider(ctx, input.Config, "/chat/completions", body, "chat-completion", input.StreamText, input.OnTextDelta) if err != nil { return nil, err } - return map[string]interface{}{"mode": "text", "text": text}, nil + return providerTextTaskResult(result), nil } func runClaudeTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { @@ -2073,14 +2096,42 @@ func runClaudeTextTask(ctx context.Context, input canvasGenerationInput) (map[st maxTokens = input.MaxOutputTokens } body := map[string]interface{}{"model": input.Config.Model, "max_tokens": maxTokens, "messages": messages} + applyTextThinking(body, input, "claude-api") if systemPrompt := strings.TrimSpace(input.Config.SystemPrompt); systemPrompt != "" { body["system"] = systemPrompt } - text, err := requestTextProvider(ctx, input.Config, "/messages", body, "claude-api", input.StreamText, input.OnTextDelta) + result, err := requestTextProvider(ctx, input.Config, "/messages", body, "claude-api", input.StreamText, input.OnTextDelta) if err != nil { return nil, err } - return map[string]interface{}{"mode": "text", "text": text}, nil + return providerTextTaskResult(result), nil +} + +func applyTextThinking(body map[string]interface{}, input canvasGenerationInput, protocol string) { + if !input.TextOptions.Thinking { + return + } + switch protocol { + case "responses": + body["reasoning"] = map[string]interface{}{"effort": "medium", "summary": "auto"} + case "chat-completion": + body["reasoning_effort"] = "medium" + case "claude-api": + body["thinking"] = map[string]interface{}{"type": "enabled", "budget_tokens": 1024} + } +} + +type providerTextResult struct { + Text string + Reasoning string +} + +func providerTextTaskResult(result providerTextResult) map[string]interface{} { + payload := map[string]interface{}{"mode": "text", "text": result.Text} + if strings.TrimSpace(result.Reasoning) != "" { + payload["reasoning"] = result.Reasoning + } + return payload } func applyTextOutputLimit(body map[string]interface{}, limit int, field string) { @@ -4564,51 +4615,41 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn return nil, fmt.Errorf("%s视频生成超时", providerName) } -func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool, onDelta func(string)) (string, error) { +func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool, onDelta func(string)) (providerTextResult, 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, onDelta) + return postStreamingTextResult(ctx, config, path, body, protocol, onDelta) } var payload map[string]interface{} if err := postJSON(ctx, config, path, body, &payload); err != nil { - return "", err + return providerTextResult{}, err } - text := extractTextPayload(payload, protocol) - if text == "" { - return "", errors.New("文本接口没有返回内容") + parsed, err := parseAgentToolPayload(payload, protocol) + if err != nil { + return providerTextResult{}, err } - return text, nil + result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")} + if result.Text == "" { + return providerTextResult{}, errors.New("文本接口没有返回内容") + } + return result, nil } func postStreamingText(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (string, error) { - // 只把分镜规划/修复切到上游 SSE,完整 JSON 仍在流结束后校验,避免半截结构污染画布。 - body["stream"] = true - parser := newStreamingTextDeltaParser(protocol, onDelta) - data, mimeType, err := postStreamingBinary(ctx, config, path, body, parser.consume) + result, err := postStreamingTextResult(ctx, config, path, body, protocol, onDelta) + return result.Text, err +} + +func postStreamingTextResult(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (providerTextResult, error) { + // 文本创作与 Agent 共用同一套 SSE 解析,确保正文、推理摘要和供应商错误语义一致。 + parsed, err := postStreamingAgent(ctx, config, path, body, protocol, onDelta) if err != nil { - return "", err + return providerTextResult{}, err } - parser.flush() - if !strings.Contains(strings.ToLower(mimeType), "event-stream") { - var payload map[string]interface{} - if err := json.Unmarshal(data, &payload); err != nil { - return "", fmt.Errorf("流式文本接口返回格式无效:%w", err) - } - if err := validateTextPayload(payload); err != nil { - return "", err - } - text := extractTextPayload(payload, protocol) - if text == "" { - return "", errors.New("文本接口没有返回内容") - } - return text, nil + result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")} + if result.Text == "" { + return providerTextResult{}, errors.New("流式文本接口没有返回内容") } - return parseTextEventStream(data, protocol) + return result, nil } func extractTextPayload(payload map[string]interface{}, protocol string) string { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index b7c15412f..05d80d236 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -489,6 +489,25 @@ data: [DONE] } } +func TestTextThinkingOptionsAndReasoningParsing(t *testing.T) { + input := canvasGenerationInput{TextOptions: canvasTextOptions{Thinking: true}} + responsesBody := map[string]interface{}{} + applyTextThinking(responsesBody, input, "responses") + reasoning, _ := responsesBody["reasoning"].(map[string]interface{}) + if reasoning["effort"] != "medium" || reasoning["summary"] != "auto" { + t.Fatalf("responses reasoning options = %#v", reasoning) + } + chatBody := map[string]interface{}{} + applyTextThinking(chatBody, input, "chat-completion") + if chatBody["reasoning_effort"] != "medium" { + t.Fatalf("chat reasoning options = %#v", chatBody) + } + result, err := parseAgentToolPayload(map[string]interface{}{"choices": []interface{}{map[string]interface{}{"message": map[string]interface{}{"content": "正文", "reasoning_content": "推理摘要"}}}}, "chat-completion") + if err != nil || result["text"] != "正文" || result["reasoning"] != "推理摘要" { + t.Fatalf("parsed reasoning result = %#v, err = %v", result, err) + } +} + func TestStreamingAgentParserReassemblesChatToolCallsAcrossChunks(t *testing.T) { var deltas strings.Builder parser := newStreamingAgentParser("chat-completion", func(delta string) { diff --git a/backend/internal/service/task_output.go b/backend/internal/service/task_output.go index a2c4845d8..7ffc21cad 100644 --- a/backend/internal/service/task_output.go +++ b/backend/internal/service/task_output.go @@ -31,6 +31,7 @@ type TaskSummary struct { ErrorCode string `json:"errorCode,omitempty"` PreviewURL string `json:"previewUrl,omitempty"` PreviewKind string `json:"previewKind,omitempty"` + PreviewPosterURL string `json:"previewPosterUrl,omitempty"` Attempts int `json:"attempts"` StartedAt *time.Time `json:"startedAt"` CompletedAt *time.Time `json:"completedAt"` @@ -98,7 +99,7 @@ func taskSummaryForOutput(task model.Task) TaskSummary { if isContentModerationFailure(task.Error) { errorCode = contentModerationErrorCode } - previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) + previewURL, previewKind, previewPosterURL := taskMediaPreviewWithPoster(task.ResultJSON, task.Type) return TaskSummary{ ID: task.ID, SessionID: task.SessionID, @@ -120,6 +121,7 @@ func taskSummaryForOutput(task model.Task) TaskSummary { ErrorCode: errorCode, PreviewURL: previewURL, PreviewKind: previewKind, + PreviewPosterURL: previewPosterURL, Attempts: task.Attempts, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, @@ -179,18 +181,55 @@ func taskClientContext(raw string) *TaskClientContext { // 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。 func taskMediaPreview(raw string, taskType string) (string, string) { + previewURL, previewKind, _ := taskMediaPreviewWithPoster(raw, taskType) + return previewURL, previewKind +} + +func taskMediaPreviewWithPoster(raw string, taskType string) (string, string, string) { if strings.TrimSpace(raw) == "" { - return "", "" + return "", "", "" } var payload any if json.Unmarshal([]byte(raw), &payload) != nil { - return "", "" + return "", "", "" } defaultKind := "image" if strings.Contains(strings.ToLower(taskType), "video") { defaultKind = "video" } - return findTaskMediaPreview(payload, defaultKind) + previewURL, previewKind := findTaskMediaPreview(payload, defaultKind) + posterURL := findTaskMediaPoster(payload) + if previewKind == "image" && posterURL == "" { + posterURL = previewURL + } + return previewURL, previewKind, posterURL +} + +func findTaskMediaPoster(value any) string { + switch item := value.(type) { + case []any: + for _, child := range item { + if posterURL := findTaskMediaPoster(child); posterURL != "" { + return posterURL + } + } + case map[string]any: + for _, key := range []string{"posterUrl", "posterURL", "thumbnailUrl", "thumbnailURL", "coverUrl", "coverURL", "poster", "thumbnail", "cover"} { + child, exists := item[key] + if !exists { + continue + } + if previewURL, previewKind := findTaskMediaPreview(child, "image"); previewURL != "" && previewKind == "image" { + return previewURL + } + } + for _, child := range item { + if posterURL := findTaskMediaPoster(child); posterURL != "" { + return posterURL + } + } + } + return "" } func findTaskMediaPreview(value any, hint string) (string, string) { diff --git a/backend/internal/service/task_output_test.go b/backend/internal/service/task_output_test.go index 3d34056d9..92ff6c44c 100644 --- a/backend/internal/service/task_output_test.go +++ b/backend/internal/service/task_output_test.go @@ -41,6 +41,16 @@ func TestTaskMediaPreviewUsesSafeMediaURLs(t *testing.T) { } } +func TestTaskMediaPreviewSeparatesVideoPoster(t *testing.T) { + previewURL, previewKind, posterURL := taskMediaPreviewWithPoster(`{"video":{"url":"https://cdn.example.com/output.mp4","posterUrl":"https://cdn.example.com/poster.webp"}}`, "canvas_video") + if previewURL != "https://cdn.example.com/output.mp4" || previewKind != "video" || posterURL != "https://cdn.example.com/poster.webp" { + t.Fatalf("unexpected video preview: url=%q kind=%q poster=%q", previewURL, previewKind, posterURL) + } + if _, _, posterURL := taskMediaPreviewWithPoster(`{"video":{"url":"https://cdn.example.com/output.mp4","posterUrl":"file:///tmp/poster.png"}}`, "canvas_video"); posterURL != "" { + t.Fatalf("unsafe local poster was exposed: %q", posterURL) + } +} + func TestTaskClientContextRequiresCreatePageMetadata(t *testing.T) { valid := taskClientContext(`{"metadata":{"source":"create-page","conversationId":"conversation-1","messageId":"message-1","batchIndex":2,"batchCount":4}}`) if valid == nil || valid.ConversationID != "conversation-1" || valid.BatchIndex != 2 { diff --git a/backend/internal/service/user_data_page.go b/backend/internal/service/user_data_page.go new file mode 100644 index 000000000..252fe2160 --- /dev/null +++ b/backend/internal/service/user_data_page.go @@ -0,0 +1,110 @@ +package service + +import ( + "encoding/json" + "strings" + "time" +) + +func (s *Service) UserAssetsByIDs(userID string, ids []string) ([]json.RawMessage, error) { + if len(ids) > 100 { + return nil, BadAuthRequest("每次最多读取 100 个素材") + } + unique := make([]string, 0, len(ids)) + seen := make(map[string]bool, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" || len(id) > 80 { + return nil, BadAuthRequest("素材 ID 无效") + } + if !seen[id] { + seen[id] = true + unique = append(unique, id) + } + } + assets, err := s.repo.AssetsForUserIDs(userID, unique) + if err != nil { + return nil, err + } + result := make([]json.RawMessage, 0, len(assets)) + for _, asset := range assets { + result = append(result, json.RawMessage(asset.PayloadJSON)) + } + return result, nil +} + +type CanvasLibrarySummary struct { + ID string `json:"id"` + ProjectID string `json:"projectId,omitempty"` + Title string `json:"title"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + NodeCount int `json:"nodeCount"` + PreviewNodes []map[string]any `json:"previewNodes"` +} + +type CanvasLibraryPage struct { + Projects []CanvasLibrarySummary `json:"projects"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + HasMore bool `json:"hasMore"` +} + +func (s *Service) UserCanvasProjectsPage(userID string, page int, pageSize int, projectID string, search string, sort string) (CanvasLibraryPage, error) { + if page < 1 { + page = 1 + } + if page > 1000000 { + return CanvasLibraryPage{}, BadAuthRequest("页码超出范围") + } + if pageSize < 1 { + pageSize = 40 + } + if pageSize > 50 { + pageSize = 50 + } + projects, total, err := s.repo.UserCanvasProjectsPage(userID, page, pageSize, projectID, search, sort) + if err != nil { + return CanvasLibraryPage{}, err + } + result := CanvasLibraryPage{Projects: make([]CanvasLibrarySummary, 0, len(projects)), Page: page, PageSize: pageSize, Total: total, HasMore: int64(page)*int64(pageSize) < total} + for _, project := range projects { + var document struct { + Nodes []map[string]any `json:"nodes"` + } + if err := json.Unmarshal([]byte(project.PayloadJSON), &document); err != nil { + return CanvasLibraryPage{}, err + } + preview := make([]map[string]any, 0, 4) + for _, node := range document.Nodes { + if len(preview) == 4 { + break + } + if node["type"] != "image" && node["type"] != "video" { + continue + } + item := map[string]any{"position": map[string]int{"x": 0, "y": 0}} + for _, key := range []string{"id", "type", "title"} { + if value, ok := node[key].(string); ok { + item[key] = string([]rune(value)[:min(len([]rune(value)), 256)]) + } + } + for _, key := range []string{"width", "height"} { + if value, ok := node[key].(float64); ok { + item[key] = value + } + } + metadata := map[string]any{} + if original, ok := node["metadata"].(map[string]any); ok { + if key, ok := original["storageKey"].(string); ok && len(key) <= 512 { + metadata["storageKey"] = key + } + } + item["metadata"] = metadata + preview = append(preview, item) + } + result.Projects = append(result.Projects, CanvasLibrarySummary{ID: project.ID, ProjectID: project.ProjectID, Title: project.Title, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, NodeCount: len(document.Nodes), PreviewNodes: preview}) + } + return result, nil +} diff --git a/backend/internal/service/user_data_page_test.go b/backend/internal/service/user_data_page_test.go new file mode 100644 index 000000000..ed01b6f07 --- /dev/null +++ b/backend/internal/service/user_data_page_test.go @@ -0,0 +1,68 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestUserCanvasLibraryPageIsBoundedAndScoped(t *testing.T) { + service, db := newProjectWorkbenchReadTestService(t) + for index := 0; index < 55; index++ { + project := model.CanvasProject{ID: fmt.Sprintf("canvas-%02d", index), UserID: "owner", Title: fmt.Sprintf("Title %02d", index), PayloadJSON: `{"nodes":[{"id":"image","type":"image","metadata":{"storageKey":"resource:preview","content":"secret-inline-media","prompt":"secret-prompt"}}],"chatSessions":["secret-chat"]}`} + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Create(&model.CanvasProject{ID: "foreign", UserID: "other", Title: "Foreign", PayloadJSON: `{}`}).Error; err != nil { + t.Fatal(err) + } + page, err := service.UserCanvasProjectsPage("owner", 1, 999, "", "", "name") + if err != nil { + t.Fatal(err) + } + if len(page.Projects) != 50 || page.Total != 55 || !page.HasMore || page.PageSize != 50 { + t.Fatalf("unexpected pagination: %+v", page) + } + encoded, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "secret") || strings.Contains(string(encoded), "foreign") { + t.Fatal("page exposed full payload or another account") + } + last, err := service.UserCanvasProjectsPage("owner", 2, 50, "", "", "name") + if err != nil || len(last.Projects) != 5 || last.HasMore { + t.Fatalf("last page: %+v %v", last, err) + } + filtered, err := service.UserCanvasProjectsPage("owner", 1, 40, "", "Title 04", "name") + if err != nil || filtered.Total != 1 || filtered.Projects[0].NodeCount != 1 { + t.Fatalf("filter: %+v %v", filtered, err) + } +} + +func TestUserAssetsBatchIsBoundedAndScoped(t *testing.T) { + service, db := newProjectWorkbenchReadTestService(t) + for _, owner := range []string{"owner", "other"} { + if err := db.Create(&model.Asset{ID: owner, UserID: owner, PayloadJSON: fmt.Sprintf(`{"id":%q}`, owner)}).Error; err != nil { + t.Fatal(err) + } + } + assets, err := service.UserAssetsByIDs("owner", []string{"owner", "owner", "other", "missing"}) + if err != nil || len(assets) != 1 || string(assets[0]) != `{"id":"owner"}` { + t.Fatalf("unexpected assets: %s %v", assets, err) + } + empty, err := service.UserAssetsByIDs("owner", nil) + if err != nil || len(empty) != 0 { + t.Fatalf("empty IDs must not list account: %s %v", empty, err) + } + if _, err := service.UserAssetsByIDs("owner", make([]string, 101)); err == nil { + t.Fatal("expected batch limit") + } + if _, err := service.UserAssetsByIDs("owner", []string{" "}); err == nil { + t.Fatal("expected invalid ID error") + } +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 435a3e4df..0112a5479 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -15,7 +15,11 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 6。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`。 +当前结构版本为 8。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录。 + +历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 + +升级前备份数据库,迁移命令必须沿用部署实际配置的数据库连接和数据目录。开发环境启用自动迁移时重新启动新代码即可;关闭自动迁移的部署先使用新构建的 `migrate-schema up`,然后 `migrate-schema verify`,成功后启动 Backend。迁移与记录写入在事务中执行;此修复不提供降级迁移,回退旧二进制可能无法识别历史顺序,应恢复匹配的备份和版本,不手工改迁移表。 版本 3 会将 `wardrobe`、`weapon`、`accessory` 迁移为 `prop`,将 `style` 迁移为 `material`,并把原先归入 `other` 的图片、视频、音频和 3D 模型迁移为 `material`。迁移只合并重复的待确认候选:保留最早记录,其余标记为 `ignored`;已经确认的资产不会被自动删除或归并。 @@ -110,7 +114,7 @@ description: 影策后端核心持久化表与状态边界 | `model_channels` | 保存系统渠道与用户渠道的连接配置 | 系统渠道密钥不进入公开目录;普通用户只能读取脱敏后的可用模型集合 | | `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、模型 Logo、能力、协议和版本状态 | `icon` 只用于模型选择器展示;能力、协议、启用状态和价格仍由 service 强校验 | | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录 | -| `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联 | +| `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 | ## 插件管理 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index e276dd564..d33500a20 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,46 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 历史迁移 6 编号冲突 + +- 已修复历史 `asset_library_folders` 占用版本 6 导致启动失败的问题:保留既有记录,精确验证名称与校验和后,在版本 7 补齐播放副本字段;现行数据库迁移顺序不变。 +- 已验证 SQLite 数据库测试:历史版本升级、目录数据与迁移时间保留、播放字段实际补齐、重复执行、未知名称/校验和及冲突记录拒绝;未直接修改用户数据库。 +- 待验证:实际部署备份后的迁移与后端重启,以及 PostgreSQL 历史数据库升级。回退必须使用匹配备份与代码,不能直接修改 `schema_migrations`。 + +## 登录与个人库按需加载 + +- 登录不调用全量 snapshot;画布库每页 40 条摘要、服务端搜索排序;详情、导出和重命名按 ID 加载。素材选择按页读取,画布引用素材每批最多 100 个 ID。 +- 已验证:前端构建、账号持久化专项 50 项测试、后端分页与批量读取隔离测试、handler 包测试。待新后端加载代码后验证登录首页网络、分页滚动、搜索、跨页选择、导出、重命名及失败重试;本轮浏览器连接中断,未取得改造后请求数。 +- 后端 service 包更大范围测试有 4 项失败:`TestChannelFromRequestStoresAndClearsHeaders`、`TestRuntimeConcurrencyUsesEnvironmentFallback`(私网地址策略),`TestOnlyResumableNewAPIChannel2VideoDeadlinesStayRunning`、`TestResumableVideoDeadlineUsesResolvedSystemChannelProtocol`(视频恢复策略);未借本次分页优化调整这些逻辑。 +- 待验证:新账号无缓存时的生成回填、历史创作转画布、项目图片与声音选择、跨设备编辑冲突和 PostgreSQL 查询。 +- 边界:Vite 开发模块请求不能当作业务 API 数量;本地 IndexedDB 仍整体恢复,后端仍解析当前页完整画布。增量会话不基于未加载数据推断删除或自动归档素材。 + +## 提示词编辑器顶栏精简 + +- 标题改为“图片生成 / 视频生成”等无底色文本;“提示词模板”和“润色”右对齐,辅助操作默认透明,仅悬停、展开或键盘聚焦时强调。 +- 参考数量移到缩略图行,纯图片引用显示“参考图”,混合引用显示“参考素材”;保留预览、插入引用和移除连接行为。 +- [x] 前端生产构建通过;浏览器确认图片普通面板与放大面板的标题、参考数量位置,以及模板菜单展开。 +- [ ] 确认明暗主题、窄窗口、视频及音频面板的显示;启用润色插件后检查入口与原有优化流程。当前验证环境未启用该插件,未触发生成或收费操作。 + +## 画布浮层平移同步 + +- [ ] 连续平移、缩放时,节点工具条与提示词卡片逐帧跟随,结束后不回跳;调整窗口宽度后仍正确定位。 +- [ ] 顶部空间不足时工具条仍避让提示词卡片;明暗主题下移动无模糊残影,菜单、输入和键盘操作正常。 +- 实现:卡片改用逐帧视口事件和 transform 定位,尺寸变化使用最新视口;工具条合并重复通知并缓存尺寸,交互期间关闭浮层滤镜。 +- [x] 工具条及媒体性能专项测试 31 项通过,前端生产构建通过;浏览器连续平移与缩放采样中,卡片与节点间距约 10px、工具条与节点间距 30px,未出现定位回跳。大画布性能、不同设备上的残影观感仍待确认。 + +## 图片与视频节点工具条重组 + +- [ ] 图片:局部重绘、人像调整(表情/质感)、多角度、图片工具(构图与尺寸/拆分与标记),预览与下载为图标操作。 +- [ ] 视频:截取片段、字幕、提取素材(画面/音频)、进入剪辑;替换素材收进更多,移除节点单独分组。 +- [ ] 调整尺寸明确为插值放大而非 AI 超分;未实现的超分入口不再展示。裁剪、标注、放大、宫格和音频结果名称保留源素材名。 +- [ ] 窄画布折叠工具与剪辑入口,顶部空间不足时工具条保持在可视范围;检查明暗主题、滚轮不穿透和点击后关闭。 +- [x] 工具条专项测试覆盖分组、文案、空态、简洁模式、忙碌禁用和动作派发;前端类型检查与生产构建通过(存在分包体积及混合导入警告)。 +- [x] 已在现有开发服务确认图片菜单分组、方向键进入菜单、Escape 关闭并返回触发按钮;390px 视口下工具条为单行且不越界。 +- [x] 已确认视频四个一级入口及提取素材菜单;边缘节点工具条避让生成面板后可正常点击。 +- [ ] 素材预览端到端验证受当前资源接口 500、本机 Agent 连接失败影响;不将界面验证视为媒体链路全部通过。 +- [ ] 真实素材端到端复核:局部重绘与表情生成、质感设置、音频提取保留原声、项目时间线定位;本次不自动触发收费模型任务。 + ## 编辑器(M2 命令状态机) - [x] 命令协议黄金文件:`editor-commands` 8 个黄金 op(addClip/moveClip/trimClip/splitClip/removeClip/setClipProperty/addSubtitle/removeSubtitle)全量快照比对通过;未知 op、非法 payload 一律抛错(fail-closed)且输入不可变。 @@ -83,6 +123,24 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 用户浏览器 re-test(第 35 条):媒体面板中导入的素材与项目资产分组显示、筛选 Tab 切换、分组折叠与资产行展开;浅色/深色主题切换后媒体面板配色与编辑面板一致;时间线横跨整个编辑面板宽度,拖动分隔条调整高度正常。 - [x] 轨道可见/静音开关(Concat 行头对齐):轨道行头补齐 eye(显示/隐藏轨道)与 volume(静音/取消静音)按钮,与移除 X 三按钮同组(行头加宽 w-40→w-48);关闭态图标变 accent 色(无背景块),轨道名与片段区随隐藏降透明度;新增 `setTrackFlag` 黄金命令(flag 白名单 visible/muted,golden ops 11→12),隐藏轨道(visible=false)上的片段不再参与监视器预览合成(getClipAtTime 过滤);新增 3 条单测(目标轨设置且其余不受影响、muted 独立于 visible、未知轨道/flag/非布尔拒绝),输入不可变校验。 - [ ] 用户浏览器 re-test(轨道可见/静音):行头 hover 出现三按钮组;点 eye 隐藏轨道后监视器该轨画面消失、轨道行与轨道名变淡、再点恢复;点 volume 静音后图标变 accent(音频在 M4 播放/导出阶段生效);仅剩一条轨道时无移除 X;以上操作均可一步 undo。 +## 任务预览、文本创作与首屏加载优化 + +- [x] 任务列表视频改为后端海报字段与前端懒加载图片占位,列表本身不再挂载视频源;后端安全 URL、视频海报专项测试通过。 +- [x] 图片、视频、文字节点的编辑面板统一定位在节点下方;文本创作新增默认开启的流式输出与默认关闭的思考开关,并完成前后端协议、推理内容解析和专项测试。 +- [x] 创作输入支持一键清空,生成结果刷新后保持展开,“生成同款”入口移至结果标题区,对话文字默认左对齐。 +- [x] 匿名首屏移除画布、素材、插件、用户数据同步和品牌视频的提前加载;生产预览的请求数由 203 降至 71,前端生产构建与相关专项测试通过。 +- [x] 首屏骨架屏替换为平台 Logo、分镜、文字稿和时间线组成的创作动画,并支持减少动态效果偏好。 +- [ ] 使用真实登录态分别生成 Responses、Chat Completions 与 Claude 文本,验证流式开关、思考内容、刷新后的结果展开状态和历史消息左对齐。 +- [ ] 在任务中心打开含海报、无海报两类视频任务,确认列表滚动时不下载视频正文,进入详情后才加载并播放视频。 +- [ ] 在画布底部边缘分别选择图片、视频和文字节点,确认编辑面板仍固定在节点下方且控件可通过画布滚动或平移访问。 +- [ ] 在弱网、浅色/深色主题及 `prefers-reduced-motion` 下刷新首页,确认 Logo 创作动画清晰、无闪屏且登录后工作区模块正常按需加载。 + +## 生成结果项目资产同步限流恢复 + +- [x] 生成成功后的自动资产同步会识别后端 HTTP 429,读取 `Retry-After` 并在可取消等待后重试;普通校验失败仍立即上抛,专项测试与前端生产构建通过。 +- [ ] 在启用常规素材/画布写入频控的部署环境连续完成一批图片、视频或音频生成,确认结果先保留在画布,临时 429 不再立即显示为项目资产同步失败,等待频控窗口后资产自动出现在项目资产库。 + +## 平台外观管理 ## 支付插件跨平台构建 @@ -816,6 +874,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [x] 修复项目更新仓储遗漏 `cover_resource_id`,避免接口返回“主图已更新”但数据库字段仍为空;回归测试同时校验设置与移除主图后的数据库重读结果,后端全量测试通过。 - [x] 剧情章节列表去除装饰性侧边表达并显示状态、字数、画布数;制作概览新增六阶段标准流程、当前指标、制作缺口和可点击快速入口;底部镜头卡增加画面、加粗时长、镜头参数、产物版本与绿色成功状态。 - [x] 画布空白区域双击改为直接打开已有新增节点面板,并保留双击位置作为节点落点;原“适配内容”能力继续由缩放控制区提供。 +- [x] 项目分镜视频页完成首屏请求治理:项目详情与七个子视图按路由加载,命令面板、更新日志、公告正文和画布创建运行时改为交互后加载,视频预览点击后才取流;真实登录项目生产构建实测首屏 75 个请求(59 个静态资源、13 个 API、3 个其他),首屏不请求 MP4,Console 无错误。 - [ ] 在项目设置分别从个人素材、项目素材和上传图片设置主图,刷新项目列表后确认图片一致;替换、移除主图后确认回退逻辑正确,尝试删除当前主图资源时确认显示项目引用来源。 - [ ] 使用 2500 章项目搜索章节序号和标题,确认定位弹窗桌面端为 5 列、默认每页 100 章,搜索区与分页区保持同一表面;连续切换上一章、下一章并刷新页面,确认当前位置、总章数、字数和路由一致。 - [ ] 在制作概览点击剧情、资产、分镜、预演、视频、交付和三个快速入口,确认跳转到正确章节及阶段,真实镜头产物数量与完成状态一致。 @@ -876,3 +935,11 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 打开剪辑器进入已含画布产物(selected+ready 的 storyboard/action_board/分镜/导出成片)的项目:素材面板自动出现"画布素材"分组与对应素材;再次进入/刷新不产生重复素材。 - [ ] 素材面板"画布素材/本地上传/项目素材"三个页签与分组切换、来源徽章与详情来源文本正确。 - [ ] 项目素材库页与剪辑器素材面板对本轮新同步的画布素材均可见;下载/删除画布素材不影响对应画布产物资源。 + +## 画布默认框选与移动期性能优化 + +- [x] 默认工具改为“区域选择”;空白处左键拖动框选,抓手工具拖动平移。Space + 左键、中键和触控板双指手势保留为导航路径;专项测试、类型检查和生产构建通过。 +- [x] 框选移动期只通过 Leafer 选区与 DOM 差量标记预览命中节点,不再逐帧提交 React `selectedNodeIds`;抬手使用最终指针坐标计算并只提交一次。 +- [x] 可选节点空间索引按不可变节点数组引用缓存;折叠画框子节点和未展开批次子节点继续排除。50k 节点空间索引回归测试通过。 +- [x] 登录态真实项目浏览器确认:默认区域选择为激活状态,左键框选不移动视口;抓手工具可平移;区域选择激活时滚动手势继续平移画布。 +- [ ] 使用真实 MacBook 触控板复测双指横向/纵向滑动、捏合缩放及 Space + 左键临时抓手,并在 500、2000、5000 个可见节点画布记录连续框选帧率和输入延迟。 diff --git a/web/index.html b/web/index.html index 7b82110f9..b065229fb 100644 --- a/web/index.html +++ b/web/index.html @@ -14,25 +14,36 @@ #root:empty { min-height: 100vh; } + #root:empty::before { + content: ""; + position: fixed; + left: 50%; + top: 50%; + width: 42px; + height: 42px; + margin: -21px 0 0 -21px; + background: rgba(255, 255, 255, 0.84); + mask: url('/logo.svg') center / contain no-repeat; + -webkit-mask: url('/logo.svg') center / contain no-repeat; + animation: appearance-bootstrap-logo 1.8s ease-in-out infinite; + } #root:empty::after { content: ""; position: fixed; left: 50%; top: 50%; - width: 20px; - height: 20px; - margin: -10px 0 0 -10px; - border: 2px solid rgba(255, 255, 255, 0.16); - border-top-color: rgba(255, 255, 255, 0.72); + width: 82px; + height: 82px; + margin: -42px 0 0 -42px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-right-color: transparent; border-radius: 999px; - animation: appearance-bootstrap-spin 0.8s linear infinite; - } - @keyframes appearance-bootstrap-spin { - to { - transform: rotate(360deg); - } + animation: appearance-bootstrap-orbit 2.8s linear infinite; } + @keyframes appearance-bootstrap-logo { 0%, 100% { opacity: .62; transform: scale(.94); } 50% { opacity: 1; transform: scale(1.04); } } + @keyframes appearance-bootstrap-orbit { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { + #root:empty::before, #root:empty::after { animation: none; } diff --git a/web/src/application.tsx b/web/src/application.tsx index d193b8f52..62d2c9c8e 100644 --- a/web/src/application.tsx +++ b/web/src/application.tsx @@ -4,8 +4,6 @@ import "antd/dist/reset.css"; import "./styles/globals.css"; import { RouterProvider } from "react-router"; -import "@/lib/plugins/builtin"; - import { AppProviders } from "@/components/layout/app-providers"; import { router } from "@/router"; diff --git a/web/src/components/ai/ai-message-markdown.tsx b/web/src/components/ai/ai-message-markdown.tsx index 39b9a4f77..877e4d138 100644 --- a/web/src/components/ai/ai-message-markdown.tsx +++ b/web/src/components/ai/ai-message-markdown.tsx @@ -1,5 +1,4 @@ import { Streamdown, type Components } from "streamdown"; -import { code as streamdownCode } from "@streamdown/code"; import "streamdown/styles.css"; @@ -41,7 +40,6 @@ export function AIMessageMarkdown({ children, isStreaming = false, className = " parseIncompleteMarkdown skipHtml lineNumbers={false} - plugins={{ code: streamdownCode }} components={components} > {children} diff --git a/web/src/components/ai/generation-tool-card.tsx b/web/src/components/ai/generation-tool-card.tsx index a165ff9da..4e4dbd4ec 100644 --- a/web/src/components/ai/generation-tool-card.tsx +++ b/web/src/components/ai/generation-tool-card.tsx @@ -4,7 +4,7 @@ import { ChevronDown } from "lucide-react"; export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled"; export function GenerationToolCard({ status, isBulk = false, heading, children }: { status: GenerationToolStatus; isBulk?: boolean; heading: ReactNode; children: ReactNode }) { - const [open, setOpen] = useState(status !== "completed" || !isBulk); + const [open, setOpen] = useState(true); useEffect(() => { if (status !== "completed") setOpen(true); diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 1fc3efba9..b3cc3b0d8 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -2,6 +2,8 @@ import { App, Button, Dropdown, Modal, Popconfirm } from "antd"; import type { MenuProps } from "antd"; import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useUserStore } from "@/stores/use-user-store"; import { AssetMediaPreview } from "@/components/asset-media-preview"; import { AssetLibraryCard } from "@/components/assets/asset-library-card"; @@ -10,7 +12,7 @@ import { PaginationBar } from "@/components/layout/workspace-page"; import { cn } from "@/lib/utils"; import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types"; import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store"; -import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync"; export type AssetLibraryPickerItem = { id: string; @@ -36,6 +38,8 @@ export type AssetLibraryPickerFolder = { }; type Props = { + remoteLibrary?: boolean; + remoteKind?: string; open: boolean; items: AssetLibraryPickerItem[]; categoryLabels: Record; @@ -70,6 +74,8 @@ type Props = { }; export function AssetLibraryPickerModal({ + remoteLibrary = false, + remoteKind, open, items, categoryLabels, @@ -104,6 +110,28 @@ export function AssetLibraryPickerModal({ const [working, setWorking] = useState(false); const [uploadingCount, setUploadingCount] = useState(0); const [error, setError] = useState(""); + const userId = useUserStore((state) => state.user?.id); + const sessionHydrated = useUserStore((state) => state.hydrated); + const [remotePage, setRemotePage] = useState(1); + const [remotePageSize, setRemotePageSize] = useState(40); + const [remoteKeyword, setRemoteKeyword] = useState(""); + const remoteEnabled = remoteLibrary && Boolean(userId) && source === "local"; + useEffect(() => { + const timer = window.setTimeout(() => setRemoteKeyword(keyword.trim()), 250); + return () => window.clearTimeout(timer); + }, [keyword]); + useEffect(() => setRemotePage(1), [category, remoteKeyword, open]); + const remoteQuery = useQuery({ + queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteKind], + queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteKind, category: category === "all" || category === "archived" || category === remoteKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }), + enabled: remoteEnabled && open && sessionHydrated, + }); + const remoteItems = useMemo(() => (remoteQuery.data?.assets || []).filter((asset) => asset.kind !== "entity" && asset.kind !== "model").map((asset) => ({ + id: asset.id, title: asset.title, category: asset.category || "other", archived: asset.status === "archived", asset, + kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本", searchText: asset.tags.join(" "), + ...(items.find((item) => item.id === asset.id) || { disabledReason: "此素材不适用于当前操作" }), + })), [remoteQuery.data, items]); + const effectivePagination = remoteEnabled ? { current: remotePage, pageSize: remotePageSize, total: remoteQuery.data?.total || 0, onChange: (page: number, pageSize: number) => { setRemotePage(page); setRemotePageSize(pageSize); } } : pagination; const uploadInputRef = useRef(null); const initialSelectedIdsRef = useRef(initialSelectedIds); const itemsRef = useRef(items); @@ -116,12 +144,12 @@ export function AssetLibraryPickerModal({ const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]); const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]); const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]); - const sourceItems = source === "plugin" ? pluginItems : localItems; + const sourceItems = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems; const activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]); const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]); const sourceFolders = source === "plugin" ? folders : []; const showCategories = source === "local" || !sourceFolders.length; - const normalCategories = useMemo(() => ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems]); + const normalCategories = useMemo(() => remoteEnabled ? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:")) : ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems, categoryLabels, remoteEnabled]); const archivedCount = archivedItems.length; const isRecycleBin = category === "archived"; @@ -134,9 +162,9 @@ export function AssetLibraryPickerModal({ return false; } if (folderId !== "all" && (item.folderId || "") !== folderId) return false; - return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); + return remoteEnabled || !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); }); - }, [category, folderId, keyword, sourceItems]); + }, [category, folderId, keyword, sourceItems, remoteEnabled]); const selectedIds = useMemo( () => Array.from(selected).filter((id) => { @@ -221,6 +249,7 @@ export function AssetLibraryPickerModal({ message.warning("已在本地还原,稍后自动同步至云端"); } finally { setWorking(false); + if (remoteEnabled) void remoteQuery.refetch(); } }; @@ -235,6 +264,7 @@ export function AssetLibraryPickerModal({ message.error(err instanceof Error ? err.message : "删除失败"); } finally { setWorking(false); + if (remoteEnabled) void remoteQuery.refetch(); } }; @@ -245,12 +275,13 @@ export function AssetLibraryPickerModal({ try { for (const item of toDelete) await deleteAssetWithRemoteSync(item.id); setSelected(new Set()); - message.success(`已清空回收站 ${toDelete.length} 个素材`); + message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`); setCategory("all"); } catch (err) { message.error(err instanceof Error ? err.message : "清空回收站失败"); } finally { setWorking(false); + if (remoteEnabled) void remoteQuery.refetch(); } }; @@ -367,7 +398,7 @@ export function AssetLibraryPickerModal({ setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /> - 已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材 + 已选 {selectedIds.length} · {effectivePagination ? effectivePagination.total : visibleItems.length} 个素材
@@ -391,7 +422,7 @@ export function AssetLibraryPickerModal({ {countFor(value)} ))} - {archivedCount > 0 ? ( + {archivedCount > 0 || remoteEnabled ? (
: loading || (remoteEnabled && remoteQuery.isFetching) ? (
正在读取素材 @@ -428,7 +459,7 @@ export function AssetLibraryPickerModal({
)}
- {pagination ? : null} + {effectivePagination ? : null}